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_user = None;
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_user = Some(message);
1751                    }
1752                }
1753            }
1754        }
1755
1756        for message in &mut messages {
1757            message.metadata.remove("__codex_open_turn");
1758            message.metadata.remove("codex_event_message");
1759            if message
1760                .metadata
1761                .remove("__grok_remove_synthetic_turn_id")
1762                .is_some()
1763            {
1764                message.metadata.remove("turn_id");
1765            }
1766        }
1767        truncate_messages_with_anchor(&mut messages, message_limit, preceding_user);
1768        let imported_message_count = Some(total_message_count);
1769        Ok(Session {
1770            meta,
1771            messages,
1772            subagents: Vec::new(),
1773            // Preserve the cheap count without retaining hundreds of
1774            // megabytes of source lines in a display-only value.
1775            raw: vec![String::new(); record_count],
1776            raw_trailing_newline: jsonl.ends_with('\n'),
1777            imported_message_count,
1778            raw_is_verbatim: false,
1779            parse_error_lines,
1780            load_residue: vec![
1781                "display history is a bounded native-record projection, not resumable model context"
1782                    .to_string(),
1783            ],
1784        })
1785    }
1786
1787    /// Load a Pi session from a file.
1788    pub fn from_pi(path: impl AsRef<Path>) -> Result<Session> {
1789        Self::from_pi_str(&std::fs::read_to_string(path.as_ref())?)
1790    }
1791
1792    /// Parse a Pi session (`docs/interop/opencode-pi-spec.md` §1.1,
1793    /// `docs/interop/research/pi-fields.md`) from an in-memory JSONL string.
1794    ///
1795    /// Line 1 is the `session` header; every other line is one `SessionEntry`
1796    /// in a tree keyed by `id`/`parentId` — file order is append order, not
1797    /// tree order. `raw` captures every line verbatim (byte-lossless T1,
1798    /// exactly like Claude Code/Codex). `messages` is the **active path
1799    /// only**: pi's own leaf rule is "the last entry in file order"
1800    /// (`pi-fields.md` `sm:897`), so this walks `parentId` from there back to
1801    /// the root and linearizes root→leaf. Non-active branches, `label`s, and
1802    /// state records (`thinking_level_change`/`model_change`/`custom`/
1803    /// `session_info`) are never visited by that walk — they survive in
1804    /// `raw` only, pi's defining residue (§1.1).
1805    ///
1806    /// `message.role` is an OPEN union upstream (§1.1 S6): a role outside the
1807    /// five modeled here (`user`/`assistant`/`toolResult`/`bashExecution`/
1808    /// `custom`) produces no canonical message — raw-only survival, never a
1809    /// panic — and the Pi corpus audit turns that into a
1810    /// visible coverage failure rather than a silent drop.
1811    ///
1812    /// Same fail-loud discipline applies to `ImageContent` blocks
1813    /// (`user`/`toolResult`/`custom*` content, see `pi_image_shape`): the
1814    /// assumed `{mimeType, data}` shape is UNVERIFIED against real pi output
1815    /// (`pi-fields.md` doesn't enumerate `ImageContent`'s own fields, only
1816    /// cites the containing union) — a follow-up TR tracks confirming it
1817    /// against a real corpus. Until then, an image block that doesn't match
1818    /// that shape never gets silently synthesized as an empty/corrupt
1819    /// `image_url` part; the containing message survives in `raw` only and
1820    /// trips `crate::audit::Corpus::Pi`'s `message/UnknownImageShape` bucket.
1821    pub fn from_pi_str(jsonl: &str) -> Result<Session> {
1822        let mut meta = SessionMeta::new(SessionSource::Pi);
1823        // IX-1: `raw` is captured STRICT-VERBATIM — separate from the
1824        // blank-skipping PARSE walk (`lines_v`) below, which must keep
1825        // skipping blank/whitespace-only lines when it looks for `SessionEntry`
1826        // records (a blank line is never a record, on either view).
1827        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
1828        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
1829        let non_empty_line_count = non_empty_lines(jsonl).count();
1830        let lines_v: Vec<Value> = non_empty_lines(jsonl)
1831            .filter_map(|l| serde_json::from_str(l).ok())
1832            .collect();
1833        // PARITY-15: every line that failed to even deserialize as JSON at
1834        // all (never mind whether it then parsed as a recognized
1835        // `SessionEntry` shape) — see `from_claude_code_str`'s identical
1836        // counter.
1837        let parse_error_lines = non_empty_line_count.saturating_sub(lines_v.len());
1838
1839        if let Some(header) = lines_v.first() {
1840            capture_pi_header(header, &mut meta)?;
1841        }
1842
1843        // Every non-header entry that parses as an object carrying an `id`.
1844        // (A line that fails to parse, or a header re-parsed as an entry,
1845        // simply never enters `by_id` — it survives in `raw` only, exactly
1846        // like a malformed/non-conversational line in the other loaders.)
1847        struct PiEntry {
1848            id: String,
1849            parent_id: Option<String>,
1850            value: Value,
1851        }
1852        let mut entries: Vec<PiEntry> = Vec::new();
1853        let mut by_id: HashMap<String, usize> = HashMap::new();
1854        for v in lines_v.iter().skip(1) {
1855            let Some(id) = v.get("id").and_then(Value::as_str) else {
1856                continue;
1857            };
1858            let parent_id = v
1859                .get("parentId")
1860                .and_then(Value::as_str)
1861                .map(str::to_string);
1862            by_id.insert(id.to_string(), entries.len());
1863            entries.push(PiEntry {
1864                id: id.to_string(),
1865                parent_id,
1866                value: v.clone(),
1867            });
1868        }
1869
1870        if entries.is_empty() {
1871            return Ok(Session {
1872                meta,
1873                messages: Vec::new(),
1874                subagents: Vec::new(),
1875                raw,
1876                raw_trailing_newline,
1877                imported_message_count: Some(0),
1878                // Pi is line-oriented: `raw` is split directly out of the
1879                // source text (strict-verbatim, IX-1), even for this
1880                // no-entries early return.
1881                raw_is_verbatim: true,
1882                parse_error_lines,
1883                load_residue: Vec::new(),
1884            });
1885        }
1886
1887        // Leaf = the last entry in file order (pi's own rule, `sm:897`), NOT
1888        // necessarily a `message` entry — a trailing `label`/`session_info`
1889        // still anchors the walk correctly since the walk just follows
1890        // `parentId` regardless of the leaf's own type.
1891        let leaf_idx = entries.len() - 1;
1892        let mut chain_rev: Vec<usize> = Vec::new();
1893        let mut cur: Option<String> = Some(entries[leaf_idx].id.clone());
1894        let mut guard = 0usize;
1895        while let Some(id) = cur {
1896            let Some(&idx) = by_id.get(&id) else { break };
1897            chain_rev.push(idx);
1898            cur = entries[idx].parent_id.clone();
1899            guard += 1;
1900            if guard > entries.len() + 1 {
1901                break; // cycle guard — malformed parentId chain
1902            }
1903        }
1904        chain_rev.reverse();
1905        let active = chain_rev; // indices into `entries`, root..leaf order
1906
1907        let pos_in_active: HashMap<&str, usize> = active
1908            .iter()
1909            .enumerate()
1910            .map(|(pos, &idx)| (entries[idx].id.as_str(), pos))
1911            .collect();
1912
1913        // First pass: compaction discipline (§2.1 S3) — every message from an
1914        // entry before the LATEST `firstKeptEntryId` on the active path is
1915        // excluded from replay (`compacted_out`), mirroring pi's own
1916        // `buildContextEntries` slice (`sm:414-450`).
1917        let mut kept_from_pos = 0usize;
1918        for &idx in &active {
1919            let e = &entries[idx];
1920            if e.value.get("type").and_then(Value::as_str) == Some("compaction") {
1921                if let Some(fk) = e.value.get("firstKeptEntryId").and_then(Value::as_str) {
1922                    if let Some(&p) = pos_in_active.get(fk) {
1923                        kept_from_pos = kept_from_pos.max(p);
1924                    }
1925                }
1926            }
1927        }
1928
1929        let mut messages = Vec::new();
1930        let mut current_model: Option<String> = None;
1931        for (pos, &idx) in active.iter().enumerate() {
1932            let e = &entries[idx];
1933            let v = &e.value;
1934            let entry_ts = v
1935                .get("timestamp")
1936                .and_then(Value::as_str)
1937                .map(str::to_string);
1938            let before = messages.len();
1939            match v.get("type").and_then(Value::as_str) {
1940                Some("message") => {
1941                    let msg_v = v.get("message").cloned().unwrap_or(Value::Null);
1942                    match msg_v.get("role").and_then(Value::as_str) {
1943                        Some("user") => push_pi_user(&msg_v, &mut messages),
1944                        Some("assistant") => {
1945                            push_pi_assistant(&msg_v, &mut messages);
1946                            if let Some(m) = msg_v.get("model").and_then(Value::as_str) {
1947                                current_model = Some(m.to_string());
1948                            }
1949                        }
1950                        Some("toolResult") => push_pi_tool_result(&msg_v, &mut messages),
1951                        Some("bashExecution") => push_pi_bash(&msg_v, &mut messages),
1952                        Some("custom") => push_pi_custom_common(&msg_v, &mut messages),
1953                        // OPEN UNION (S6): any other role — raw-only survival.
1954                        _ => {}
1955                    }
1956                }
1957                Some("custom_message") => push_pi_custom_common(v, &mut messages),
1958                Some("compaction") => push_pi_compaction(v, &mut messages),
1959                Some("branch_summary") => push_pi_branch_summary(v, &mut messages),
1960                Some("model_change") => {
1961                    if let Some(m) = v.get("modelId").and_then(Value::as_str) {
1962                        current_model = Some(m.to_string());
1963                    }
1964                }
1965                Some("session_info") => {
1966                    if let Some(name) = v.get("name").and_then(Value::as_str) {
1967                        if !name.is_empty() {
1968                            meta.lineage
1969                                .insert("session_name".to_string(), name.to_string());
1970                        }
1971                    }
1972                }
1973                // thinking_level_change, custom (entry-level state), label —
1974                // no clean home, raw-only (§2.3).
1975                _ => {}
1976            }
1977            let is_summary = matches!(
1978                v.get("type").and_then(Value::as_str),
1979                Some("compaction") | Some("branch_summary")
1980            );
1981            for m in &mut messages[before..] {
1982                m.metadata.insert("pi_entry_id".to_string(), e.id.clone());
1983                if let Some(p) = &e.parent_id {
1984                    m.metadata.insert("pi_parent_id".to_string(), p.clone());
1985                }
1986                if let Some(ts) = &entry_ts {
1987                    m.metadata
1988                        .entry("timestamp".to_string())
1989                        .or_insert_with(|| ts.clone());
1990                }
1991                // WAVE-2 item 1 fallback: the entry-level `timestamp` above
1992                // is pi's authoritative, always-monotonic-in-file-order
1993                // wall-clock (mandatory on every entry) and wins whenever
1994                // present. The nested `message.timestamp` (unix-ms) is only
1995                // reached here — via `entry(...).or_insert_with`, so it
1996                // never overwrites the entry-level value — in the rare case
1997                // an entry lacks its own `timestamp`. This intentionally
1998                // does NOT prefer the msg-level field even though it LOOKS
1999                // more precise: unlike the entry-level timestamp, it is not
2000                // guaranteed monotonic with this loader's root->leaf
2001                // linearization (e.g. a rewound-branch entry can carry an
2002                // earlier msg-level clock reading than its file-order
2003                // neighbors), and OpenCode's own loader re-sorts messages by
2004                // this canonical timestamp — a non-monotonic source would
2005                // silently scramble replay order on a pi->opencode hop.
2006                if let Some(ms) = v
2007                    .get("message")
2008                    .and_then(|mm| mm.get("timestamp"))
2009                    .and_then(Value::as_u64)
2010                {
2011                    m.metadata
2012                        .entry("timestamp".to_string())
2013                        .or_insert_with(|| crate::sidecar::ms_to_rfc3339(ms as i64));
2014                }
2015                // A compaction/branch-summary message IS the retained marker
2016                // — never mark it excluded, regardless of its own position.
2017                if !is_summary && pos < kept_from_pos {
2018                    m.metadata
2019                        .insert("compacted_out".to_string(), "true".to_string());
2020                }
2021            }
2022            restore_single_grok_message(v, &mut messages[before..]);
2023            for message in &mut messages[before..] {
2024                restore_tool_outcome_extension(v, message);
2025            }
2026        }
2027
2028        meta.model = current_model;
2029        ensure_tool_results_paired(&mut messages);
2030        let imported_message_count = Some(messages.len());
2031        Ok(Session {
2032            meta,
2033            messages,
2034            subagents: Vec::new(),
2035            raw,
2036            raw_trailing_newline,
2037            imported_message_count,
2038            // Pi is line-oriented: `raw` is split directly out of the
2039            // source text (strict-verbatim, IX-1).
2040            raw_is_verbatim: true,
2041            parse_error_lines,
2042            load_residue: Vec::new(),
2043        })
2044    }
2045
2046    /// Load Grok's resumable `chat_history.jsonl` transcript.
2047    ///
2048    /// The surrounding session directory carries the session id, workspace,
2049    /// and `summary.json`; [`Self::from_grok_str`] handles the transcript
2050    /// itself while this path-aware entry point overlays that directory
2051    /// metadata.
2052    pub fn from_grok(path: impl AsRef<Path>) -> Result<Session> {
2053        let path = path.as_ref();
2054        let mut session = Self::from_grok_str(&std::fs::read_to_string(path)?)?;
2055        session.capture_grok_path_metadata(path);
2056        Ok(session)
2057    }
2058
2059    /// Parse Grok's line-oriented `chat_history.jsonl` format.
2060    ///
2061    /// Conversational records are `user`, `assistant`, and `tool_result`.
2062    /// `system` is the regenerated base prompt and is retained in
2063    /// [`SessionMeta::system_prompt`]; encrypted reasoning and backend-only
2064    /// state remain byte-exact in [`Session::raw`] but are intentionally not
2065    /// replayed as chat turns.
2066    pub fn from_grok_str(jsonl: &str) -> Result<Session> {
2067        let mut meta = SessionMeta::new(SessionSource::Grok);
2068        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
2069        let raw: Vec<String> = raw_lines.iter().map(|line| line.to_string()).collect();
2070        let mut messages = Vec::new();
2071        let mut parse_error_lines = 0usize;
2072        let mut tool_names: HashMap<String, String> = HashMap::new();
2073
2074        for line in non_empty_lines(jsonl) {
2075            let value: Value = match serde_json::from_str(line) {
2076                Ok(value) => value,
2077                Err(_) => {
2078                    parse_error_lines += 1;
2079                    continue;
2080                }
2081            };
2082            restore_codex_provenance_from_top_level(&value, &mut meta)?;
2083            match value.get("type").and_then(Value::as_str) {
2084                Some("system") => {
2085                    if meta.system_prompt.is_none() {
2086                        meta.system_prompt = value
2087                            .get("content")
2088                            .and_then(Value::as_str)
2089                            .map(str::to_string);
2090                    }
2091                }
2092                Some("user") => {
2093                    let content = extract_text_content(value.get("content"));
2094                    let role = if value.get("synthetic_reason").and_then(Value::as_str)
2095                        == Some("supercode_system_event")
2096                    {
2097                        Role::System
2098                    } else {
2099                        Role::User
2100                    };
2101                    let content = if role == Role::User {
2102                        match grok_human_user_text(&content) {
2103                            Some(content) => content,
2104                            None if value.get(SUPERCODE_GROK_MESSAGE_KEY).is_some() => {
2105                                String::new()
2106                            }
2107                            None => continue,
2108                        }
2109                    } else {
2110                        content
2111                    };
2112                    let mut message = ChatMessage {
2113                        role,
2114                        content: Some(content),
2115                        content_parts: None,
2116                        tool_calls: None,
2117                        tool_call_id: None,
2118                        name: None,
2119                        metadata: Default::default(),
2120                    };
2121                    capture_grok_scalar_metadata(
2122                        &value,
2123                        &mut message,
2124                        &["prompt_index", "prior_turn_interrupt", "synthetic_reason"],
2125                    );
2126                    restore_grok_message_extension(&value, &mut message);
2127                    messages.push(message);
2128                }
2129                Some("assistant") => {
2130                    let calls: Vec<ToolCall> = value
2131                        .get("tool_calls")
2132                        .and_then(Value::as_array)
2133                        .into_iter()
2134                        .flatten()
2135                        .filter_map(|call| {
2136                            let id = call.get("id")?.as_str()?.to_string();
2137                            let name = call.get("name")?.as_str()?.to_string();
2138                            let arguments = call
2139                                .get("arguments")
2140                                .map(value_to_arg_string)
2141                                .unwrap_or_else(|| "{}".to_string());
2142                            tool_names.insert(id.clone(), name.clone());
2143                            Some(function_call(&id, &name, arguments))
2144                        })
2145                        .collect();
2146                    let content = value
2147                        .get("content")
2148                        .and_then(Value::as_str)
2149                        .filter(|content| !content.is_empty())
2150                        .map(str::to_string);
2151                    let mut message = ChatMessage {
2152                        role: Role::Assistant,
2153                        content,
2154                        content_parts: None,
2155                        tool_calls: (!calls.is_empty()).then_some(calls),
2156                        tool_call_id: None,
2157                        name: None,
2158                        metadata: Default::default(),
2159                    };
2160                    capture_grok_scalar_metadata(
2161                        &value,
2162                        &mut message,
2163                        &["model_id", "model_fingerprint", "reasoning_effort"],
2164                    );
2165                    if let Some(model) = value.get("model_id").and_then(Value::as_str) {
2166                        meta.model = Some(model.to_string());
2167                    }
2168                    restore_grok_message_extension(&value, &mut message);
2169                    messages.push(message);
2170                }
2171                Some("tool_result") => {
2172                    let id = value
2173                        .get("tool_call_id")
2174                        .and_then(Value::as_str)
2175                        .unwrap_or_default();
2176                    let content = value
2177                        .get("content")
2178                        .map(|value| match value {
2179                            Value::String(text) => text.clone(),
2180                            other => extract_text_content(Some(other)),
2181                        })
2182                        .unwrap_or_default();
2183                    let mut message = tool_message(id, content);
2184                    message.name = tool_names.get(id).cloned();
2185                    restore_grok_message_extension(&value, &mut message);
2186                    messages.push(message);
2187                }
2188                // `reasoning` contains encrypted chain-of-thought and
2189                // `backend_tool_call` is execution bookkeeping. Both survive
2190                // verbatim in raw without being replayed to another model.
2191                _ => {}
2192            }
2193        }
2194
2195        ensure_tool_results_paired(&mut messages);
2196        let imported_message_count = Some(messages.len());
2197        Ok(Session {
2198            meta,
2199            messages,
2200            subagents: Vec::new(),
2201            raw,
2202            raw_trailing_newline,
2203            imported_message_count,
2204            raw_is_verbatim: true,
2205            parse_error_lines,
2206            load_residue: Vec::new(),
2207        })
2208    }
2209
2210    fn capture_grok_path_metadata(&mut self, transcript: &Path) {
2211        let Some(session_dir) = transcript.parent() else {
2212            return;
2213        };
2214        self.meta.session_id = session_dir
2215            .file_name()
2216            .and_then(|name| name.to_str())
2217            .map(str::to_string);
2218        self.meta.cwd = session_dir
2219            .parent()
2220            .and_then(Path::file_name)
2221            .and_then(|name| name.to_str())
2222            .and_then(percent_decode_path)
2223            .map(PathBuf::from);
2224
2225        let Ok(summary_text) = std::fs::read_to_string(session_dir.join("summary.json")) else {
2226            return;
2227        };
2228        let Ok(summary) = serde_json::from_str::<Value>(&summary_text) else {
2229            return;
2230        };
2231        if let Some(model) = summary.get("current_model_id").and_then(Value::as_str) {
2232            self.meta.model = Some(model.to_string());
2233        }
2234        for (source, target) in [
2235            ("generated_title", "session_name"),
2236            ("created_at", "created_at"),
2237            ("updated_at", "updated_at"),
2238            ("chat_format_version", "grok_chat_format_version"),
2239        ] {
2240            if let Some(value) = summary.get(source) {
2241                self.meta.lineage.insert(
2242                    target.to_string(),
2243                    value
2244                        .as_str()
2245                        .map(str::to_string)
2246                        .unwrap_or_else(|| value.to_string()),
2247                );
2248            }
2249        }
2250    }
2251
2252    /// Load a Gemini CLI transcript from disk.
2253    pub fn from_gemini(path: impl AsRef<Path>) -> Result<Session> {
2254        Self::from_gemini_str(&std::fs::read_to_string(path.as_ref())?)
2255    }
2256
2257    /// Parse Gemini CLI's line-oriented session format.
2258    ///
2259    /// Gemini stores a header without a `type`, followed by `user` and
2260    /// `gemini` records. Function calls are embedded in assistant content
2261    /// parts and function responses in user content parts. Unknown records
2262    /// remain byte-exact in [`Session::raw`] instead of silently entering the
2263    /// replay conversation.
2264    pub fn from_gemini_str(jsonl: &str) -> Result<Session> {
2265        let mut meta = SessionMeta::new(SessionSource::Gemini);
2266        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
2267        let raw = raw_lines.iter().map(|line| line.to_string()).collect();
2268        let mut messages = Vec::new();
2269        let mut parse_error_lines = 0usize;
2270        let mut pending_by_name: HashMap<String, Vec<String>> = HashMap::new();
2271
2272        for (line_index, line) in non_empty_lines(jsonl).enumerate() {
2273            let value: Value = match serde_json::from_str(line) {
2274                Ok(value) => value,
2275                Err(_) => {
2276                    parse_error_lines += 1;
2277                    continue;
2278                }
2279            };
2280            let kind = value.get("type").and_then(Value::as_str);
2281            if kind.is_none() {
2282                if meta.session_id.is_none() {
2283                    meta.session_id = value
2284                        .get("sessionId")
2285                        .and_then(Value::as_str)
2286                        .map(str::to_string);
2287                }
2288                for (source, target) in [
2289                    ("projectHash", "gemini_project_hash"),
2290                    ("startTime", "created_at"),
2291                    ("lastUpdated", "updated_at"),
2292                    ("kind", "gemini_session_kind"),
2293                ] {
2294                    if let Some(raw) = value.get(source) {
2295                        meta.lineage.insert(
2296                            target.to_string(),
2297                            raw.as_str()
2298                                .map(str::to_string)
2299                                .unwrap_or_else(|| raw.to_string()),
2300                        );
2301                    }
2302                }
2303                continue;
2304            }
2305            if kind != Some("user") && kind != Some("gemini") {
2306                continue;
2307            }
2308
2309            let timestamp = value.get("timestamp").and_then(Value::as_str);
2310            let model = value.get("model").and_then(Value::as_str);
2311            if let Some(model) = model {
2312                meta.model = Some(model.to_string());
2313            }
2314            let content = value.get("content").unwrap_or(&Value::Null);
2315            let parts = content.as_array();
2316            let text = match content {
2317                Value::String(text) => text.clone(),
2318                Value::Array(parts) => parts
2319                    .iter()
2320                    .filter_map(|part| part.get("text").and_then(Value::as_str))
2321                    .collect::<Vec<_>>()
2322                    .join(" ")
2323                    .trim()
2324                    .to_string(),
2325                _ => String::new(),
2326            };
2327
2328            if kind == Some("gemini") {
2329                let legacy_calls = parts
2330                    .into_iter()
2331                    .flatten()
2332                    .filter_map(|part| part.get("functionCall"));
2333                let native_calls = value
2334                    .get("toolCalls")
2335                    .and_then(Value::as_array)
2336                    .into_iter()
2337                    .flatten();
2338                let calls = native_calls
2339                    .chain(legacy_calls)
2340                    .enumerate()
2341                    .filter_map(|(call_index, call)| {
2342                        let name = call.get("name")?.as_str()?.to_string();
2343                        let id = call
2344                            .get("id")
2345                            .and_then(Value::as_str)
2346                            .map(str::to_string)
2347                            .unwrap_or_else(|| format!("gemini-{line_index}-{call_index}"));
2348                        pending_by_name
2349                            .entry(name.clone())
2350                            .or_default()
2351                            .push(id.clone());
2352                        let arguments = call
2353                            .get("args")
2354                            .map(value_to_arg_string)
2355                            .unwrap_or_else(|| "{}".to_string());
2356                        Some(function_call(&id, &name, arguments))
2357                    })
2358                    .collect::<Vec<_>>();
2359                let mut message = ChatMessage {
2360                    role: Role::Assistant,
2361                    content: (!text.is_empty()).then_some(text),
2362                    content_parts: None,
2363                    tool_calls: (!calls.is_empty()).then_some(calls),
2364                    tool_call_id: None,
2365                    name: None,
2366                    metadata: Default::default(),
2367                };
2368                if let Some(timestamp) = timestamp {
2369                    message
2370                        .metadata
2371                        .insert("timestamp".into(), timestamp.into());
2372                }
2373                if let Some(model) = model {
2374                    message.metadata.insert("gemini_model".into(), model.into());
2375                }
2376                if let Some(thoughts) = value.get("thoughts").filter(|value| !value.is_null()) {
2377                    message
2378                        .metadata
2379                        .insert("gemini_thoughts".into(), thoughts.to_string());
2380                }
2381                restore_gemini_message_extension(&value, &mut message);
2382                if message.content.is_some() || message.tool_calls.is_some() {
2383                    messages.push(message);
2384                }
2385                continue;
2386            }
2387
2388            let mut user_parts = Vec::new();
2389            if let Some(parts) = parts {
2390                for part in parts {
2391                    if let Some(response) = part.get("functionResponse") {
2392                        push_gemini_user_parts(
2393                            &mut messages,
2394                            std::mem::take(&mut user_parts),
2395                            timestamp,
2396                            &value,
2397                        );
2398                        let name = response
2399                            .get("name")
2400                            .and_then(Value::as_str)
2401                            .unwrap_or("tool")
2402                            .to_string();
2403                        let explicit_id = response
2404                            .get("id")
2405                            .and_then(Value::as_str)
2406                            .map(str::to_string);
2407                        if let Some(id) = explicit_id.as_deref() {
2408                            if let Some(ids) = pending_by_name.get_mut(&name) {
2409                                if let Some(position) = ids.iter().position(|pending| pending == id)
2410                                {
2411                                    ids.remove(position);
2412                                }
2413                            }
2414                        }
2415                        let id = explicit_id
2416                            .or_else(|| {
2417                                pending_by_name
2418                                    .get_mut(&name)
2419                                    .and_then(|ids| (!ids.is_empty()).then(|| ids.remove(0)))
2420                            })
2421                            .unwrap_or_else(|| format!("gemini-{line_index}-response"));
2422                        let output = response
2423                            .get("response")
2424                            .and_then(|response| response.get("output"))
2425                            .map(|output| {
2426                                output
2427                                    .as_str()
2428                                    .map(str::to_string)
2429                                    .unwrap_or_else(|| output.to_string())
2430                            })
2431                            .or_else(|| response.get("response").map(Value::to_string))
2432                            .unwrap_or_default();
2433                        let mut message = tool_message(&id, output);
2434                        message.name = Some(name);
2435                        if let Some(timestamp) = timestamp {
2436                            message
2437                                .metadata
2438                                .insert("timestamp".into(), timestamp.into());
2439                        }
2440                        restore_gemini_message_extension(&value, &mut message);
2441                        messages.push(message);
2442                        continue;
2443                    }
2444                    if let Some(text) = part.get("text").and_then(Value::as_str) {
2445                        user_parts.push(serde_json::json!({"type": "text", "text": text}));
2446                        continue;
2447                    }
2448                    if let Some(inline) = part.get("inlineData") {
2449                        let Some(data) = inline.get("data").and_then(Value::as_str) else {
2450                            continue;
2451                        };
2452                        let media_type = inline
2453                            .get("mimeType")
2454                            .and_then(Value::as_str)
2455                            .unwrap_or("application/octet-stream");
2456                        user_parts.push(serde_json::json!({
2457                            "type": "image_url",
2458                            "image_url": {"url": format!("data:{media_type};base64,{data}")},
2459                        }));
2460                    }
2461                }
2462            } else if !text.is_empty() {
2463                user_parts.push(serde_json::json!({"type": "text", "text": text}));
2464            }
2465            push_gemini_user_parts(&mut messages, user_parts, timestamp, &value);
2466        }
2467
2468        ensure_tool_results_paired(&mut messages);
2469        let imported_message_count = Some(messages.len());
2470        Ok(Session {
2471            meta,
2472            messages,
2473            subagents: Vec::new(),
2474            raw,
2475            raw_trailing_newline,
2476            imported_message_count,
2477            raw_is_verbatim: true,
2478            parse_error_lines,
2479            load_residue: Vec::new(),
2480        })
2481    }
2482
2483    /// Load a Goose session-export JSON document from disk.
2484    pub fn from_goose(path: impl AsRef<Path>) -> Result<Session> {
2485        Self::from_goose_str(&std::fs::read_to_string(path.as_ref())?)
2486    }
2487
2488    /// Parse Goose's official native import/export document.
2489    ///
2490    /// Goose's durable store is SQLite, but its own
2491    /// `_goose/unstable/session/export` and `/session/import` boundary is one
2492    /// JSON object containing a `conversation` array. Unknown native content
2493    /// blocks are retained on the first canonical message in a namespaced
2494    /// portability envelope; unchanged same-format exports replay the exact
2495    /// source bytes.
2496    pub fn from_goose_str(json: &str) -> Result<Session> {
2497        let document: Value = serde_json::from_str(json).map_err(crate::Error::Decode)?;
2498        let object = document.as_object().ok_or_else(|| {
2499            Error::InvalidSession("Goose session export must be a JSON object".to_string())
2500        })?;
2501        let conversation = object
2502            .get("conversation")
2503            .and_then(Value::as_array)
2504            .ok_or_else(|| {
2505                Error::InvalidSession(
2506                    "Goose session export must contain a conversation array".to_string(),
2507                )
2508            })?;
2509
2510        let mut meta = SessionMeta::new(SessionSource::Goose);
2511        meta.session_id = object.get("id").and_then(Value::as_str).map(str::to_string);
2512        meta.cwd = object
2513            .get("working_dir")
2514            .or_else(|| object.get("workingDir"))
2515            .and_then(Value::as_str)
2516            .map(PathBuf::from);
2517        meta.model = object
2518            .get("model_config")
2519            .or_else(|| object.get("modelConfig"))
2520            .and_then(|model| model.get("model_name").or_else(|| model.get("modelName")))
2521            .and_then(Value::as_str)
2522            .map(str::to_string);
2523        for (source, target) in [
2524            ("name", "session_name"),
2525            ("created_at", "created_at"),
2526            ("updated_at", "updated_at"),
2527            ("session_type", "goose_session_type"),
2528            ("goose_mode", "goose_mode"),
2529            ("provider_name", "goose_provider_name"),
2530            ("parent_session_id", "parent_session_id"),
2531        ] {
2532            if let Some(value) = object.get(source) {
2533                meta.lineage.insert(
2534                    target.to_string(),
2535                    value
2536                        .as_str()
2537                        .map(str::to_string)
2538                        .unwrap_or_else(|| value.to_string()),
2539                );
2540            }
2541        }
2542        let mut header = document.clone();
2543        if let Some(header) = header.as_object_mut() {
2544            header.remove("conversation");
2545        }
2546        meta.goose_header = Some(header.clone());
2547
2548        let mut messages = Vec::new();
2549        for (native_index, native) in conversation.iter().enumerate() {
2550            let before = messages.len();
2551            normalize_goose_message(native, native_index, &mut messages);
2552            if let Some(first) = messages.get_mut(before) {
2553                first
2554                    .metadata
2555                    .insert("goose_native_message".to_string(), native.to_string());
2556                first
2557                    .metadata
2558                    .insert("goose_native_index".to_string(), native_index.to_string());
2559                if native_index == 0 {
2560                    first
2561                        .metadata
2562                        .insert("goose_session_header".to_string(), header.to_string());
2563                }
2564                restore_grok_message_extension(native, first);
2565            }
2566            for message in messages.iter_mut().skip(before + 1) {
2567                message
2568                    .metadata
2569                    .insert("goose_native_index".to_string(), native_index.to_string());
2570            }
2571        }
2572        ensure_tool_results_paired(&mut messages);
2573
2574        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(json);
2575        let raw = raw_lines.iter().map(|line| line.to_string()).collect();
2576        let imported_message_count = Some(messages.len());
2577        Ok(Session {
2578            meta,
2579            messages,
2580            subagents: Vec::new(),
2581            raw,
2582            raw_trailing_newline,
2583            imported_message_count,
2584            raw_is_verbatim: true,
2585            parse_error_lines: 0,
2586            load_residue: Vec::new(),
2587        })
2588    }
2589
2590    /// Load one Goose session directly from its native SQLite store.
2591    ///
2592    /// The selector is Goose's stable `sessions.id`. The reconstructed JSON
2593    /// uses Goose's own public export shape, so the ordinary Goose codec is
2594    /// the single normalization boundary for both files and the live store.
2595    pub fn from_goose_sqlite(db_path: &Path, session_id: &str) -> Result<Session> {
2596        Self::from_goose_sqlite_with_limit(db_path, session_id, None)
2597    }
2598
2599    /// Bounded Goose store read for transcript UI surfaces. The inner query
2600    /// selects only the newest native rows; the outer query restores their
2601    /// chronological order. Export/continue callers deliberately use the
2602    /// unbounded public loader above.
2603    #[doc(hidden)]
2604    pub fn from_goose_sqlite_display(
2605        db_path: &Path,
2606        session_id: &str,
2607        message_limit: usize,
2608    ) -> Result<Session> {
2609        Self::from_goose_sqlite_with_limit(db_path, session_id, Some(message_limit.max(1)))
2610    }
2611
2612    fn from_goose_sqlite_with_limit(
2613        db_path: &Path,
2614        session_id: &str,
2615        message_limit: Option<usize>,
2616    ) -> Result<Session> {
2617        let connection = Connection::open_with_flags(
2618            db_path,
2619            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2620        )
2621        .map_err(|error| Error::Other(format!("failed to open Goose SQLite store: {error}")))?;
2622        let mut statement = connection
2623            .prepare(
2624                "SELECT id, name, working_dir, created_at, updated_at, session_type, \
2625                    extension_data, goose_mode, provider_name, model_config_json \
2626             FROM sessions WHERE id = ?1",
2627            )
2628            .map_err(|error| Error::Other(format!("failed to query Goose sessions: {error}")))?;
2629        let mut document = statement
2630            .query_row([session_id], |row| {
2631                let extension_data: Option<String> = row.get(6)?;
2632                let model_config: Option<String> = row.get(9)?;
2633                Ok(serde_json::json!({
2634                    "id": row.get::<_, String>(0)?,
2635                    "working_dir": row.get::<_, String>(2)?,
2636                    "name": row.get::<_, String>(1)?,
2637                    "user_set_name": false,
2638                    "session_type": row.get::<_, String>(5)?,
2639                    "created_at": row.get::<_, String>(3)?,
2640                    "updated_at": row.get::<_, String>(4)?,
2641                    "extension_data": extension_data
2642                        .as_deref()
2643                        .and_then(|value| serde_json::from_str::<Value>(value).ok())
2644                        .unwrap_or_else(|| serde_json::json!({})),
2645                    "usage": {},
2646                    "accumulated_usage": {},
2647                    "accumulated_cost": Value::Null,
2648                    "schedule_id": Value::Null,
2649                    "recipe": Value::Null,
2650                    "user_recipe_values": Value::Null,
2651                    "conversation": [],
2652                    "message_count": 0,
2653                    "last_message_at": Value::Null,
2654                    "provider_name": row.get::<_, Option<String>>(8)?,
2655                    "model_config": model_config
2656                        .as_deref()
2657                        .and_then(|value| serde_json::from_str::<Value>(value).ok()),
2658                    "goose_mode": row.get::<_, String>(7)?,
2659                    "archived_at": Value::Null,
2660                    "project_id": Value::Null,
2661                    "parent_session_id": Value::Null,
2662                    "last_message_snippet": Value::Null,
2663                }))
2664            })
2665            .map_err(|error| Error::Other(format!("failed to load Goose session: {error}")))?;
2666
2667        let message_query = message_limit.map_or_else(
2668            || {
2669                "SELECT message_id, role, content_json, created_timestamp, metadata_json \
2670                 FROM messages WHERE session_id = ?1 ORDER BY created_timestamp, id"
2671                    .to_string()
2672            },
2673            |limit| {
2674                format!(
2675                    "SELECT message_id, role, content_json, created_timestamp, metadata_json \
2676                     FROM (SELECT id AS native_row_id, message_id, role, content_json, \
2677                                  created_timestamp, metadata_json \
2678                           FROM messages WHERE session_id = ?1 \
2679                           ORDER BY created_timestamp DESC, id DESC LIMIT {limit}) \
2680                     ORDER BY created_timestamp, native_row_id"
2681                )
2682            },
2683        );
2684        let mut message_statement = connection
2685            .prepare(&message_query)
2686            .map_err(|error| Error::Other(format!("failed to query Goose messages: {error}")))?;
2687        let rows = message_statement
2688            .query_map([session_id], |row| {
2689                let content: String = row.get(2)?;
2690                let metadata: Option<String> = row.get(4)?;
2691                Ok(serde_json::json!({
2692                    "id": row.get::<_, Option<String>>(0)?,
2693                    "role": row.get::<_, String>(1)?,
2694                    "created": row.get::<_, i64>(3)?,
2695                    "content": serde_json::from_str::<Value>(&content)
2696                        .unwrap_or_else(|_| Value::Array(Vec::new())),
2697                    "metadata": metadata
2698                        .as_deref()
2699                        .and_then(|value| serde_json::from_str::<Value>(value).ok())
2700                        .unwrap_or_else(|| serde_json::json!({
2701                            "userVisible": true,
2702                            "agentVisible": true
2703                        })),
2704                }))
2705            })
2706            .map_err(|error| Error::Other(format!("failed to load Goose messages: {error}")))?;
2707        let conversation = rows
2708            .collect::<std::result::Result<Vec<_>, _>>()
2709            .map_err(|error| Error::Other(format!("failed to decode Goose messages: {error}")))?;
2710        document["message_count"] = Value::from(conversation.len());
2711        document["conversation"] = Value::Array(conversation);
2712        let json = serde_json::to_string_pretty(&document).map_err(crate::Error::Decode)?;
2713        let mut session = Self::from_goose_str(&json)?;
2714        // SQLite was reconstructed through values, not captured byte-for-byte.
2715        session.raw_is_verbatim = false;
2716        Ok(session)
2717    }
2718
2719    /// Load an OpenCode session from a file — either read surface, see
2720    /// [`Self::from_opencode_str`].
2721    pub fn from_opencode(path: impl AsRef<Path>) -> Result<Session> {
2722        Self::from_opencode_str(&std::fs::read_to_string(path.as_ref())?)
2723    }
2724
2725    /// Load an OpenCode session from a real SQLite store (PARITY-3): opens
2726    /// `db_path`, resolves `session_id` (explicit, or — when `None` — the
2727    /// most-recently-updated top-level session, see
2728    /// `opencode_sqlite_primary_session_id`) and reconstructs the SAME
2729    /// envelope form [`Self::from_opencode_str`] already parses for the
2730    /// JSON-tree surfaces — so canonicalization (§2.1 mapping, compaction
2731    /// discipline, S1 tool-output masking, …) is shared code, not
2732    /// reimplemented here. See `docs/interop/opencode-pi-spec.md` §1.2/S9c
2733    /// for the envelope-construction rules this follows (all-columns rule,
2734    /// raw `revert` column carried verbatim).
2735    pub fn from_opencode_sqlite(db_path: &Path, session_id: Option<&str>) -> Result<Session> {
2736        let conn = opencode_sqlite_open(db_path)?;
2737        let id = match session_id {
2738            Some(id) => id.to_string(),
2739            None => opencode_sqlite_primary_session_id(&conn)?,
2740        };
2741        let lines = opencode_sqlite_session_envelope_lines(&conn, db_path, &id)?;
2742        let mut text = lines.join("\n");
2743        text.push('\n');
2744        let mut session = Self::from_opencode_str(&text)?;
2745        // D4: `text` above is a SYNTHESIZED reconstruction from SQL rows —
2746        // not the original source bytes (a binary `.db` file has no
2747        // "verbatim" line-oriented form to begin with). `from_opencode_str`
2748        // defaults `raw_is_verbatim` to `true` because for its OTHER two
2749        // callers (an actual envelope-form file's own text, an actual
2750        // export-document's text) that really is the source. It is NEVER
2751        // true for this diagonal — mirrors the export-document fix just
2752        // above for the same reason (`from_opencode_export_doc`, `false`).
2753        // `convert opencode.db --to opencode` must not claim byte-identical.
2754        session.raw_is_verbatim = false;
2755        Ok(session)
2756    }
2757
2758    /// Parse an OpenCode session from either of its two frozen **read
2759    /// surfaces** (`docs/interop/opencode-pi-spec.md` §1.2, `S9a`,
2760    /// `opencode-fields.md`):
2761    ///
2762    /// - the **envelope form**: each line is
2763    ///   `{"key":[<storage key path>],"value":<record>}`, minified — the
2764    ///   synthesized raw-capture unit for the JSON-tree/SQLite storage
2765    ///   generations;
2766    /// - the **export-document form**: a single pretty-printed JSON document
2767    ///   `{info: SessionInfo, messages:[{info: Message, parts:[Part]}, …]}`
2768    ///   — the `opencode export`/`import` interchange shape, and EXACTLY
2769    ///   what the OpenCode writer emits.
2770    ///
2771    /// Both forms are parsed into the same `(session_info, side_records,
2772    /// Vec<OcMsg>)` shape and funnel through the SAME shared canonicalizer,
2773    /// `opencode_session_from_records` — so the same underlying records
2774    /// produce identical `messages` regardless of which surface carried
2775    /// them in. This is what makes `load(to_opencode_jsonl(S))` round-trip
2776    /// (§4.1's OpenCode diagonal, the exact circuit the fidelity matrix
2777    /// exercises): previously this function parsed the envelope form only
2778    /// and silently returned an empty-but-`Ok` `Session` for an export
2779    /// document — the confirmed footgun this now closes.
2780    ///
2781    /// Record classification (envelope form) is driven by the envelope
2782    /// `key`'s first component (`"session"` / `"message"` / `"part"` /
2783    /// `"session_diff"` / `"todo"`) — the frozen key scheme for all three
2784    /// storage generations plus SQLite rows (§1.2 S9c: a SQLite row's
2785    /// envelope synthesizes `key:["<table>","<ses>",...ids]` and MUST carry
2786    /// every column, `data` and non-`data` alike — e.g. the `session` row's
2787    /// `revert` column under the V2 `Revert.State` schema, whose extra
2788    /// `files` field the CLI's own row→V1 reconstruction drops; the
2789    /// envelope's `raw` capture keeps that raw column value regardless of
2790    /// what this loader's canonicalization understands).
2791    ///
2792    /// Mapping to canonical `messages` (§2.1, shared by both forms via
2793    /// `push_opencode_user`/`push_opencode_assistant`): `User`/`Assistant`
2794    /// text parts → `content`; a `User` `file` part whose `mime` is an image
2795    /// and whose `url` is a `data:` URI → an `image_url` `content_parts`
2796    /// entry; an `Assistant` `tool` part's `callID` + `state.*.input` → a
2797    /// `ToolCall`, and the SAME part's `state.completed.output` /
2798    /// `state.error.error` → a paired `Tool` message split by `callID`
2799    /// (opencode keeps call+result on one record; this loader splits it
2800    /// into the two OpenAI-shape messages the other loaders already
2801    /// produce).
2802    ///
2803    /// **S1 (`time.compacted`):** when a `tool` part's
2804    /// `state.completed.time.compacted` is set, the emitted `Tool`
2805    /// message's `content` is the placeholder
2806    /// [`OPENCODE_COMPACTED_TOOL_PLACEHOLDER`] — mirroring what opencode's
2807    /// own `toModelMessage` replays — while the REAL output survives in
2808    /// `raw` (always) and in `metadata["oc_tool_output_compacted"]` (full
2809    /// text) + `metadata["oc_tool_time_compacted"]` (the mask timestamp), so
2810    /// it is reversible, never actually lost.
2811    ///
2812    /// **Compaction boundary:** a `compaction` part's `tail_start_id` marks
2813    /// every message strictly before that message id
2814    /// `metadata["compacted_out"]="true"` (honored uniformly by
2815    /// `is_replay_excluded`) — except a `summary:true` `Assistant`
2816    /// message, which opencode itself hoists in FRONT of the retained tail
2817    /// on replay (`message-v2.ts:521-572`) and so must never be excluded
2818    /// regardless of its position, mirroring pi's identical exemption for
2819    /// its own compaction/branch-summary entries.
2820    ///
2821    /// **Unknown part `type` or unknown `tool.state.status`:** never
2822    /// canonicalized — raw-only survival, exactly like an unmodeled Pi
2823    /// `message.role` (S6-style fail-loud). The OpenCode corpus audit
2824    /// is what turns that into a visible coverage failure rather than a
2825    /// silent drop.
2826    ///
2827    /// **Export-document `raw`:** an export document is a single
2828    /// pretty-printed JSON value with no per-line envelope structure of its
2829    /// own to capture verbatim, so `raw` here is RE-SYNTHESIZED — one
2830    /// envelope line per `session`/`message`/`part` record found in the
2831    /// document, in the exact `{"key":[...],"value":...}` shape the native
2832    /// envelope form uses — so every native/T1-value-tier path
2833    /// (`to_native_jsonl`, `opencode_records_from_raw`, the
2834    /// splice/direct-write writers) stays consistent regardless of which
2835    /// read surface produced this `Session`.
2836    ///
2837    /// **Malformed input:** input that reaches this function non-empty but
2838    /// yields zero session/message/part records under EITHER form returns a
2839    /// clear `Err` rather than a silently-empty `Ok(Session)` — the
2840    /// confirmed footgun (`supercode resume`/`convert`/`inspect` on such
2841    /// input must not silently succeed with an empty session). A
2842    /// legitimately-empty session — a real `session` record with zero
2843    /// messages, or a valid export document with an empty `messages` array
2844    /// — is not an error.
2845    pub fn from_opencode_str(text: &str) -> Result<Session> {
2846        let trimmed = text.trim();
2847
2848        // Export-document form first (§1.2/S9a) — mirrors `detect_source`'s
2849        // own precedence: try the whole-text parse before the per-line
2850        // envelope loop below, since a pretty-printed multi-line document
2851        // has no individually-valid-JSON lines for that loop to match.
2852        if let Ok(doc) = serde_json::from_str::<Value>(trimmed) {
2853            if doc.get("info").is_some() && doc.get("messages").and_then(Value::as_array).is_some()
2854            {
2855                return Self::from_opencode_export_doc(&doc);
2856            }
2857        }
2858
2859        // Envelope form: each line `{"key":[<storage key path>],"value":<record>}`.
2860        // IX-1: `raw` is captured STRICT-VERBATIM — separate from the
2861        // blank-skipping PARSE walk just below, which keeps skipping
2862        // blank/whitespace-only lines when it looks for envelope records.
2863        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(text);
2864        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
2865        let mut session_info: Option<Value> = None;
2866        let mut side_records: Vec<Value> = Vec::new();
2867        let mut msgs: Vec<OcMsg> = Vec::new();
2868        let mut msg_index: HashMap<String, usize> = HashMap::new();
2869        // PARITY-15: see `from_claude_code_str`'s identical counter — only
2870        // a genuinely malformed line (fails to deserialize as JSON at all),
2871        // not a well-formed envelope this loader simply doesn't recognize.
2872        let mut parse_error_lines = 0usize;
2873
2874        for line in non_empty_lines(text) {
2875            let Ok(env) = serde_json::from_str::<Value>(line) else {
2876                parse_error_lines += 1;
2877                continue; // malformed line — raw-only, exactly like the other loaders
2878            };
2879            let Some(key) = env.get("key").and_then(Value::as_array) else {
2880                continue; // not an envelope record — raw-only
2881            };
2882            let value = env.get("value").cloned().unwrap_or(Value::Null);
2883            match key.first().and_then(Value::as_str) {
2884                Some("session") => session_info = Some(value),
2885                Some("message") => {
2886                    let Some(id) = value.get("id").and_then(Value::as_str) else {
2887                        continue;
2888                    };
2889                    let time_created = value
2890                        .get("time")
2891                        .and_then(|t| t.get("created"))
2892                        .and_then(Value::as_i64)
2893                        .unwrap_or(0);
2894                    msg_index.insert(id.to_string(), msgs.len());
2895                    msgs.push(OcMsg {
2896                        id: id.to_string(),
2897                        time_created,
2898                        value,
2899                        parts: Vec::new(),
2900                    });
2901                }
2902                Some("part") => {
2903                    if let Some(msg_id) = value.get("messageID").and_then(Value::as_str) {
2904                        if let Some(&idx) = msg_index.get(msg_id) {
2905                            msgs[idx].parts.push(value);
2906                        }
2907                        // A part whose message wasn't captured (out-of-order
2908                        // envelope) — still fully present in `raw`, just not
2909                        // attached to a canonical message.
2910                    }
2911                }
2912                Some("session_diff") | Some("todo") => {
2913                    side_records.push(serde_json::json!({"key": key, "value": value}));
2914                }
2915                _ => {} // unrecognized top-level key — raw-only
2916            }
2917        }
2918
2919        opencode_guard_against_silent_empty(
2920            !trimmed.is_empty(),
2921            &session_info,
2922            &msgs,
2923            &side_records,
2924        )?;
2925        opencode_session_from_records(
2926            session_info,
2927            side_records,
2928            msgs,
2929            raw,
2930            raw_trailing_newline,
2931            // Envelope form: `raw` is split directly out of the source text
2932            // (strict-verbatim, IX-1) — genuinely reproduces the original
2933            // bytes on replay.
2934            true,
2935            parse_error_lines,
2936        )
2937    }
2938
2939    /// The **export-document** read surface of [`Self::from_opencode_str`]
2940    /// — see that function's doc comment for the shared canonicalization
2941    /// and the `raw` re-synthesis this performs. `doc` is already known to
2942    /// have the `{info, messages:[...]}` shape (the caller checks this,
2943    /// matching `detect_source`'s own S9a check) before calling this.
2944    fn from_opencode_export_doc(doc: &Value) -> Result<Session> {
2945        let session_info = doc.get("info").cloned().filter(|v| !v.is_null());
2946        let messages_arr = doc
2947            .get("messages")
2948            .and_then(Value::as_array)
2949            .cloned()
2950            .unwrap_or_default();
2951
2952        let session_id = session_info
2953            .as_ref()
2954            .and_then(|si| si.get("id"))
2955            .and_then(Value::as_str)
2956            .unwrap_or("ses_unknown")
2957            .to_string();
2958        let project_id = session_info
2959            .as_ref()
2960            .and_then(|si| si.get("projectID"))
2961            .and_then(Value::as_str)
2962            .unwrap_or("global")
2963            .to_string();
2964
2965        // Re-synthesize one envelope line per record — see the doc comment
2966        // on `from_opencode_str` ("Export-document `raw`").
2967        let mut raw: Vec<String> = Vec::new();
2968        if let Some(si) = &session_info {
2969            raw.push(
2970                serde_json::json!({"key": ["session", project_id, session_id], "value": si})
2971                    .to_string(),
2972            );
2973        }
2974
2975        let mut msgs: Vec<OcMsg> = Vec::new();
2976        for entry in &messages_arr {
2977            let Some(info) = entry.get("info") else {
2978                continue; // malformed message entry — no clean home, raw-only
2979            };
2980            let Some(id) = info.get("id").and_then(Value::as_str) else {
2981                continue;
2982            };
2983            let time_created = info
2984                .get("time")
2985                .and_then(|t| t.get("created"))
2986                .and_then(Value::as_i64)
2987                .unwrap_or(0);
2988            let parts: Vec<Value> = entry
2989                .get("parts")
2990                .and_then(Value::as_array)
2991                .cloned()
2992                .unwrap_or_default();
2993
2994            raw.push(
2995                serde_json::json!({"key": ["message", session_id, id], "value": info}).to_string(),
2996            );
2997            for p in &parts {
2998                let part_id = p.get("id").and_then(Value::as_str).unwrap_or("");
2999                raw.push(serde_json::json!({"key": ["part", id, part_id], "value": p}).to_string());
3000            }
3001
3002            msgs.push(OcMsg {
3003                id: id.to_string(),
3004                time_created,
3005                value: info.clone(),
3006                parts,
3007            });
3008        }
3009
3010        opencode_guard_against_silent_empty(true, &session_info, &msgs, &[])?;
3011        opencode_session_from_records(
3012            session_info,
3013            Vec::new(),
3014            msgs,
3015            raw,
3016            // Synthesized `raw` (§1.2's "Export-document raw" — one envelope
3017            // line re-derived per record, no real per-line source bytes to
3018            // measure) — matches the historical always-newline-terminated
3019            // behavior; see `Session::raw_trailing_newline`'s doc comment.
3020            true,
3021            // Export-document form: `raw` above is RE-SYNTHESIZED, one
3022            // envelope line derived per record — not the original document's
3023            // bytes (see this function's doc comment). `convert`'s
3024            // byte-identical claim must not fire on this diagonal.
3025            false,
3026            // PARITY-15: a pretty-printed export document is parsed WHOLE
3027            // (the caller's `if let Ok(doc) = serde_json::from_str(...)`) —
3028            // there's no per-line parse-loss concept here; a malformed
3029            // document fails that top-level parse and never reaches this
3030            // function at all.
3031            0,
3032        )
3033    }
3034
3035    /// P5-5 (design §2 module 21 `session.tree`, §2.1 D-6 "session.tree →
3036    /// core.session(tree-addressable transcript)"): materialize this
3037    /// session's linear [`Self::messages`] into a native in-place
3038    /// [`crate::session_tree::SessionTree`] — the bridge a caller uses the
3039    /// FIRST time it wants to run a tree operation (rewind/branch/label)
3040    /// against an otherwise-linear [`Session`]. `created_at_ms` stamps every
3041    /// synthesized node (see
3042    /// [`crate::session_tree::SessionTree::from_linear`]'s doc comment for
3043    /// why a single timestamp is used: the source linear messages carry no
3044    /// per-turn timestamp of their own here).
3045    ///
3046    /// This does not mutate `self` or persist anything — see
3047    /// the composition layer's session-store tree writer for persistence, and
3048    /// [`Self::apply_session_tree`] for the inverse bridge.
3049    pub fn to_session_tree(&self, created_at_ms: i64) -> crate::session_tree::SessionTree {
3050        crate::session_tree::SessionTree::from_linear(&self.messages, created_at_ms)
3051    }
3052
3053    /// P5-5: the inverse of [`Self::to_session_tree`] — overwrite
3054    /// [`Self::messages`] with `tree`'s ACTIVE branch's linear projection
3055    /// (C7's "tree-with-linear-projection": this is exactly what keeps every
3056    /// existing linear consumer — the agent loop, exporters — working
3057    /// unchanged after a tree operation runs). Nothing else on `self`
3058    /// (`meta`, `raw`, ...) is touched.
3059    ///
3060    /// **Fail-closed.** Propagates [`crate::session_tree::SessionTree::linear_projection`]'s
3061    /// `Err` rather than applying anything — a structurally-corrupt tree
3062    /// (a cycle, a dangling leaf, an active branch pointing at nothing) must
3063    /// error, not silently overwrite [`Self::messages`] with an empty `Vec`.
3064    /// `self` is left untouched on `Err` (the assignment only happens after
3065    /// the projection has already succeeded).
3066    pub fn apply_session_tree(&mut self, tree: &crate::session_tree::SessionTree) -> Result<()> {
3067        self.messages = tree.linear_projection()?;
3068        Ok(())
3069    }
3070}
3071
3072/// One opencode `message` record plus its `part` children, gathered from
3073/// EITHER read surface (envelope-form records or export-document
3074/// `{info, parts}` entries) before the shared per-record canonicalization
3075/// in [`opencode_session_from_records`].
3076struct OcMsg {
3077    id: String,
3078    time_created: i64,
3079    value: Value,
3080    parts: Vec<Value>,
3081}
3082
3083const OPENCODE_SUPERCODE_MESSAGE_POSITION: &str = "_supercode_message_position";
3084const OPENCODE_SUPERCODE_RESULT_POSITION: &str = "_supercode_result_position";
3085const OPENCODE_INTERNAL_ORIGINAL_POSITION: &str = "__supercode_original_position";
3086
3087/// Guard against the confirmed footgun: input that reached
3088/// [`Session::from_opencode_str`] non-empty but produced no
3089/// session/message/part record under either read surface returns `Err`
3090/// instead of a silently-empty `Ok(Session)`. A legitimately-empty session
3091/// (a real session record with zero messages, or a valid empty `messages`
3092/// array) is not an error — only genuinely unparseable content is.
3093fn opencode_guard_against_silent_empty(
3094    non_empty_input: bool,
3095    session_info: &Option<Value>,
3096    msgs: &[OcMsg],
3097    side_records: &[Value],
3098) -> Result<()> {
3099    let has_any_record = session_info.as_ref().is_some_and(|v| !v.is_null())
3100        || !msgs.is_empty()
3101        || !side_records.is_empty();
3102    if non_empty_input && !has_any_record {
3103        return Err(crate::Error::Other(
3104            "opencode input was recognized as an OpenCode source (envelope or \
3105             export-document form) but no session/message/part record could be parsed from \
3106             it — refusing to silently return an empty session"
3107                .to_string(),
3108        ));
3109    }
3110    Ok(())
3111}
3112
3113/// The shared per-record canonicalization for BOTH of
3114/// [`Session::from_opencode_str`]'s read surfaces (envelope form and
3115/// export-document form): frozen ordering, `SessionMeta` capture, the
3116/// compaction boundary pass, and the `User`/`Assistant` → `messages`
3117/// mapping via [`push_opencode_user`]/[`push_opencode_assistant`]. Fed the
3118/// same underlying `(session_info, side_records, msgs)` regardless of which
3119/// surface produced them, this produces byte-for-byte identical `messages`
3120/// — the invariant that makes `load(to_opencode_jsonl(S))` round-trip.
3121fn opencode_session_from_records(
3122    session_info: Option<Value>,
3123    side_records: Vec<Value>,
3124    mut msgs: Vec<OcMsg>,
3125    raw: Vec<String>,
3126    raw_trailing_newline: bool,
3127    raw_is_verbatim: bool,
3128    parse_error_lines: usize,
3129) -> Result<Session> {
3130    let mut meta = SessionMeta::new(SessionSource::OpenCode);
3131
3132    // `msg_index` is captured BEFORE the frozen-order sort below, mapping
3133    // each message id to its PRE-sort position — used only to resolve a
3134    // `tail_start_id` reference in the compaction-boundary pass further
3135    // down. In every real opencode session (either surface) records
3136    // already arrive/are listed in creation order, so pre- and post-sort
3137    // positions coincide; this mirrors the original envelope-only
3138    // implementation's behavior exactly (not a new invariant introduced by
3139    // sharing this code across both surfaces).
3140    let msg_index: HashMap<String, usize> = msgs
3141        .iter()
3142        .enumerate()
3143        .map(|(i, m)| (m.id.clone(), i))
3144        .collect();
3145
3146    // Frozen order (§1.2): messages by (time.created, id); each
3147    // message's parts by id.
3148    msgs.sort_by(|a, b| {
3149        a.time_created
3150            .cmp(&b.time_created)
3151            .then_with(|| a.id.cmp(&b.id))
3152    });
3153    for m in &mut msgs {
3154        m.parts.sort_by(|a, b| {
3155            let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
3156            let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
3157            ai.cmp(bi)
3158        });
3159    }
3160
3161    meta.opencode_headers
3162        .push(session_info.clone().unwrap_or(Value::Null));
3163    meta.opencode_headers.extend(side_records);
3164    if let Some(si) = &session_info {
3165        capture_opencode_session_info(si, &mut meta)?;
3166    }
3167
3168    // Compaction boundary (§2.1/§2.2 S3): the LATEST `tail_start_id`
3169    // seen — mirrors pi's `kept_from_pos` discipline (there is only one
3170    // active path in opencode's own linear message list, so no branch
3171    // walk is needed the way pi's tree requires).
3172    let mut tail_start_pos: Option<usize> = None;
3173    for m in &msgs {
3174        for p in &m.parts {
3175            if p.get("type").and_then(Value::as_str) == Some("compaction") {
3176                if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
3177                    if let Some(&tp) = msg_index.get(t) {
3178                        tail_start_pos = Some(tail_start_pos.map_or(tp, |cur| cur.max(tp)));
3179                    }
3180                }
3181            }
3182        }
3183    }
3184
3185    let mut messages = Vec::new();
3186    let mut first_system_seen = false;
3187    for (pos, m) in msgs.iter().enumerate() {
3188        let before = messages.len();
3189        match m.value.get("role").and_then(Value::as_str) {
3190            // B4: a `User` message that's actually
3191            // `append_synthesized_opencode_messages`'s own re-materialized
3192            // Claude `system` record (one `synthetic: true` text part
3193            // carrying the supercode marker key — see
3194            // `opencode_claude_system_subtype`'s doc comment) restores
3195            // `Role::System`, not a genuine user turn.
3196            Some("user") => match opencode_claude_system_subtype(&m.parts) {
3197                Some(subtype) => {
3198                    push_opencode_claude_system(&m.value, &m.parts, subtype, &mut messages)
3199                }
3200                None => push_opencode_user(
3201                    &m.value,
3202                    &m.parts,
3203                    &mut messages,
3204                    &mut meta,
3205                    &mut first_system_seen,
3206                ),
3207            },
3208            Some("assistant") => {
3209                push_opencode_assistant(&m.value, &m.parts, &mut messages, &mut meta)
3210            }
3211            // Unrecognized/missing role — raw-only survival;
3212            // `audit::Corpus::OpenCode` scores this as Unmodeled.
3213            _ => {}
3214        }
3215        if let Some(original_position) = m
3216            .value
3217            .get(OPENCODE_SUPERCODE_MESSAGE_POSITION)
3218            .and_then(Value::as_u64)
3219        {
3220            if let Some(message) = messages[before..]
3221                .iter_mut()
3222                .find(|message| message.role != Role::Tool)
3223            {
3224                message.metadata.insert(
3225                    OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
3226                    original_position.to_string(),
3227                );
3228            }
3229        }
3230        for msg in &mut messages[before..] {
3231            let is_summary = msg.metadata.get("is_summary").map(String::as_str) == Some("true");
3232            if !is_summary {
3233                if let Some(tsp) = tail_start_pos {
3234                    if pos < tsp {
3235                        msg.metadata
3236                            .insert("compacted_out".to_string(), "true".to_string());
3237                    }
3238                }
3239            }
3240        }
3241    }
3242
3243    let marked_slots = messages
3244        .iter()
3245        .enumerate()
3246        .filter_map(|(index, message)| {
3247            message
3248                .metadata
3249                .contains_key(OPENCODE_INTERNAL_ORIGINAL_POSITION)
3250                .then_some(index)
3251        })
3252        .collect::<Vec<_>>();
3253    if !marked_slots.is_empty() {
3254        // A spliced OpenCode export can contain an unmarked native prefix
3255        // followed by a marked synthesized tail. Reorder only among the
3256        // marked slots so the tail never jumps in front of its raw prefix.
3257        let mut marked_messages = marked_slots
3258            .iter()
3259            .map(|index| messages[*index].clone())
3260            .collect::<Vec<_>>();
3261        marked_messages.sort_by_key(|message| {
3262            message
3263                .metadata
3264                .get(OPENCODE_INTERNAL_ORIGINAL_POSITION)
3265                .and_then(|position| position.parse::<usize>().ok())
3266                .unwrap_or(usize::MAX)
3267        });
3268        for (slot, message) in marked_slots.into_iter().zip(marked_messages) {
3269            messages[slot] = message;
3270        }
3271        for message in &mut messages {
3272            message.metadata.remove(OPENCODE_INTERNAL_ORIGINAL_POSITION);
3273        }
3274    }
3275    ensure_tool_results_paired(&mut messages);
3276    let imported_message_count = Some(messages.len());
3277    Ok(Session {
3278        meta,
3279        messages,
3280        subagents: Vec::new(),
3281        raw,
3282        raw_trailing_newline,
3283        imported_message_count,
3284        raw_is_verbatim,
3285        parse_error_lines,
3286        load_residue: Vec::new(),
3287    })
3288}
3289
3290/// Resolve each opencode subagent (`task`) child session's
3291/// `meta.parent_tool_use_id` from its parent's own `task` tool part
3292/// `callID` (`docs/interop/opencode-pi-spec.md` §2.1 — the analogue of
3293/// Claude Code's `agentId`/`parent_tool_use_id` linkage,
3294/// `opencode-fields.md` `task.ts:145,171-176`).
3295///
3296/// Nesting itself needs no opencode-specific pass:
3297/// `capture_opencode_session_info` already mirrors `SessionInfo.parentID`
3298/// into `lineage["parent_thread_id"]` (the same key Codex subagents use),
3299/// so the existing generic [`Session::reconstruct_tree`] nests these
3300/// sessions correctly on its own. Call this FIRST — it only reads
3301/// `session_id`/`messages`/metadata, never reorders `sessions` — then feed
3302/// the same `Vec` to `reconstruct_tree`.
3303pub fn resolve_opencode_parent_tool_use_ids(sessions: &mut [Session]) {
3304    let ids: Vec<Option<String>> = sessions.iter().map(|s| s.meta.session_id.clone()).collect();
3305    for i in 0..sessions.len() {
3306        let child_id = sessions[i].meta.session_id.clone();
3307        let parent_id = sessions[i].meta.lineage.get("parent_session_id").cloned();
3308        let (Some(child_id), Some(parent_id)) = (child_id, parent_id) else {
3309            continue;
3310        };
3311        let Some(parent_idx) = ids
3312            .iter()
3313            .position(|id| id.as_deref() == Some(parent_id.as_str()))
3314        else {
3315            continue;
3316        };
3317        for m in &sessions[parent_idx].messages {
3318            for (k, v) in &m.metadata {
3319                if let Some(call_id) = k.strip_prefix("oc_task_child_session_id__") {
3320                    if v == &child_id {
3321                        sessions[i].meta.parent_tool_use_id = Some(call_id.to_string());
3322                    }
3323                }
3324            }
3325        }
3326    }
3327}
3328
3329/// D7: repair tool-result/tool-call ORDERING for the Claude Code loader.
3330///
3331/// Real Claude Code transcripts are well-formed by `uuid`/`parentUuid` tree
3332/// structure but are NOT guaranteed to be well-formed in raw file order: async
3333/// tool-result delivery (or an edited turn) can write a `tool_result` `user`
3334/// line BEFORE the assistant `tool_use` line that owns it, even though the
3335/// parent/child tree itself is fine. The active-branch projection restores
3336/// parent-before-child order, but a result can still trail a later assistant
3337/// turn instead of sitting next to the assistant that owns it. Both OpenAI-
3338/// and Anthropic-shaped wire APIs reject either shape on `resume`. Measured
3339/// on a real corpus: 21/450 sessions (4.7%), 89 inverted pairs.
3340///
3341/// This reorders `messages` so every OWNED `Role::Tool` result (its
3342/// `tool_call_id` matches a `tool_calls` entry on some `Role::Assistant`
3343/// message anywhere in the list) sits immediately after the `Role::Assistant`
3344/// message that owns it, while leaving every other message's relative order
3345/// untouched. Orphan tool results — no matching call anywhere in the list —
3346/// are left in their ORIGINAL position, untouched; they are never moved. It
3347/// is a pure reorder: same message count, same multiset of messages, in/out.
3348///
3349/// Tool-call ids (`toolu_*`) are globally unique within a Claude Code session
3350/// — each appears exactly once as a call and once as its result — so a
3351/// simple id -> owning-assistant map is sufficient; no special-casing is
3352/// needed for sidechain (`isSidechain`) messages, since `push_claude_*`
3353/// already pushes those inline with their own distinct ids.
3354///
3355/// Results whose matching call is missing entirely (no owner found) are left
3356/// in place untouched — `ensure_tool_results_paired` (which runs right after
3357/// this) is responsible for synthesizing a placeholder result for any call
3358/// that ends up unanswered; this pass never drops or fabricates anything.
3359fn reorder_tool_results_after_calls(messages: &mut Vec<ChatMessage>) {
3360    // 0. First pass: which tool_call_ids are actually "owned" — emitted by
3361    //    some assistant message anywhere in the list — and the position of
3362    //    that owning assistant. Owned as `String` (not borrowed) so this map
3363    //    can outlive the later `messages.drain(..)`.
3364    let mut owner_positions: HashMap<String, usize> = HashMap::new();
3365    for (index, m) in messages.iter().enumerate() {
3366        if m.role == Role::Assistant {
3367            for c in m.tool_calls() {
3368                if !c.id.is_empty() {
3369                    owner_positions.entry(c.id.clone()).or_insert(index);
3370                }
3371            }
3372        }
3373    }
3374
3375    // Fast, cheap detection of "nothing to do": every owned result must be
3376    // in the contiguous tool-result block immediately following its owning
3377    // assistant. Checking only result-before-owner inversions is insufficient
3378    // after Claude's active-branch projection: that projection can put the
3379    // owner first while leaving its result behind a later assistant turn.
3380    // Mere orphans never set this flag. A canonical session returns with
3381    // `messages` byte-for-byte unchanged, mirroring
3382    // `ensure_tool_results_paired`'s own no-op guard.
3383    let mut contiguous_owner = None;
3384    let needs_reorder =
3385        messages
3386            .iter()
3387            .enumerate()
3388            .any(|(message_index, message)| match message.role {
3389                Role::Assistant => {
3390                    contiguous_owner = Some(message_index);
3391                    false
3392                }
3393                Role::Tool => match message
3394                    .tool_call_id
3395                    .as_deref()
3396                    .and_then(|id| owner_positions.get(id))
3397                    .copied()
3398                {
3399                    Some(owner) => Some(owner) != contiguous_owner,
3400                    None => {
3401                        // An orphan or unlinked tool message interrupts the
3402                        // owner's contiguous result block but never moves by
3403                        // itself.
3404                        contiguous_owner = None;
3405                        false
3406                    }
3407                },
3408                _ => {
3409                    contiguous_owner = None;
3410                    false
3411                }
3412            });
3413    if !needs_reorder {
3414        return;
3415    }
3416
3417    // 1. Second pass: route messages into the "spine" (everything that stays
3418    //    at its own position — non-tool messages AND orphan tool results)
3419    //    versus owned tool results (pulled out, to be reattached right after
3420    //    their owner). Record, for each spine index that's an assistant, the
3421    //    set of tool_call_ids it owns.
3422    let mut spine: Vec<ChatMessage> = Vec::with_capacity(messages.len());
3423    let mut call_owner: HashMap<String, usize> = HashMap::new();
3424    // Buffer of (original_position, message) for every OWNED tool result,
3425    // built alongside the spine; a result can reference a call emitted later
3426    // in file order, so owner spine-index is resolved in a later step once
3427    // `call_owner` is complete.
3428    let mut owned_results: Vec<(usize, ChatMessage)> = Vec::new();
3429
3430    let drained: Vec<ChatMessage> = std::mem::take(messages);
3431    for (orig_pos, msg) in drained.into_iter().enumerate() {
3432        if msg.role == Role::Tool {
3433            let is_owned = msg
3434                .tool_call_id
3435                .as_deref()
3436                .map(|id| !id.is_empty() && owner_positions.contains_key(id))
3437                .unwrap_or(false);
3438            if is_owned {
3439                owned_results.push((orig_pos, msg));
3440                continue;
3441            }
3442            // Orphan: no matching call anywhere. Treat exactly like a
3443            // non-tool message for placement — it joins the spine at its
3444            // current position and is never moved.
3445            spine.push(msg);
3446            continue;
3447        }
3448        if msg.role == Role::Assistant {
3449            let spine_idx = spine.len();
3450            for c in msg.tool_calls() {
3451                if !c.id.is_empty() {
3452                    call_owner.entry(c.id.clone()).or_insert(spine_idx);
3453                }
3454            }
3455        }
3456        spine.push(msg);
3457    }
3458
3459    // 2. Resolve each owned result's owner spine-index now that `call_owner`
3460    //    is complete, then bucket results by owner spine-index. Every result
3461    //    here was routed as "owned" because its id was found in `owned_ids`,
3462    //    which was built from the exact same `tool_calls()` scan that
3463    //    populates `call_owner` below, so the lookup is guaranteed to hit.
3464    let mut buckets: HashMap<usize, Vec<(usize, ChatMessage)>> = HashMap::new();
3465    for (orig_pos, msg) in owned_results.into_iter() {
3466        let id = msg
3467            .tool_call_id
3468            .as_deref()
3469            .filter(|id| !id.is_empty())
3470            .expect("routed as owned, so tool_call_id must be a non-empty owned id");
3471        let idx = *call_owner
3472            .get(id)
3473            .expect("owned id must have an owning assistant in call_owner");
3474        buckets.entry(idx).or_default().push((orig_pos, msg));
3475    }
3476    // Keep each bucket's results in their original relative file order.
3477    for v in buckets.values_mut() {
3478        v.sort_by_key(|(pos, _)| *pos);
3479    }
3480
3481    // 3. Rebuild: emit each spine message (which now includes orphans at
3482    //    their original position, untouched) in order; immediately after
3483    //    emitting an assistant message that owns one or more tool results,
3484    //    emit its owned results, in original relative order.
3485    let mut out: Vec<ChatMessage> = Vec::with_capacity(spine.len() + buckets.len());
3486    for (idx, msg) in spine.into_iter().enumerate() {
3487        out.push(msg);
3488        if let Some(results) = buckets.remove(&idx) {
3489            for (_, r) in results {
3490                out.push(r);
3491            }
3492        }
3493    }
3494    *messages = out;
3495}
3496
3497/// Guarantee every assistant `tool_calls` entry is answered by a following tool
3498/// result. Interrupted/aborted turns leave a tool call with no result, which
3499/// many chat-completions endpoints reject when the conversation is replayed.
3500/// We insert a synthetic placeholder result immediately after the assistant
3501/// turn so the transcript stays valid for continuation. (Orphan results — a
3502/// tool message with no preceding call — do not occur in practice and are left
3503/// untouched.)
3504fn ensure_tool_results_paired(messages: &mut Vec<ChatMessage>) {
3505    let answered: HashSet<String> = messages
3506        .iter()
3507        .filter(|m| m.role == Role::Tool)
3508        .filter_map(|m| m.tool_call_id.clone())
3509        .collect();
3510
3511    // Nothing missing? Leave the vector byte-for-byte unchanged.
3512    let any_missing = messages.iter().any(|m| {
3513        m.tool_calls()
3514            .iter()
3515            .any(|c| !c.id.is_empty() && !answered.contains(&c.id))
3516    });
3517    if !any_missing {
3518        return;
3519    }
3520
3521    let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len() + 4);
3522    for msg in messages.drain(..) {
3523        let synth: Vec<ChatMessage> = msg
3524            .tool_calls()
3525            .iter()
3526            .filter(|c| !c.id.is_empty() && !answered.contains(&c.id))
3527            .map(|c| {
3528                let mut m = ChatMessage::tool_result(
3529                    c.id.clone(),
3530                    c.function.name.clone(),
3531                    "[no tool result recorded — turn interrupted]".to_string(),
3532                );
3533                // TR-10: an interrupted call never executed to completion —
3534                // never a candidate for `ReductionKind::ToolInputElided`
3535                // (the "still-pending calls are never input-elided"
3536                // boundary).
3537                crate::mark_tool_error(&mut m);
3538                m
3539            })
3540            .collect();
3541        out.push(msg);
3542        out.extend(synth);
3543    }
3544    *messages = out;
3545}
3546
3547/// Whether `msg` is excluded from every replay/export path — the frozen
3548/// cross-format discipline (`docs/interop/opencode-pi-spec.md` §2.1/§2.2/§6):
3549/// a message marked `compacted_out` (pre-compaction history a source harness
3550/// itself no longer replays) or `pi_exclude_from_context` (a pi `!!` bash
3551/// escape) must never reach `to_jsonl`/`to_jsonl_spliced` for ANY target
3552/// format, not just the one that produced the marker — so a translated
3553/// compacted session replays the same sliced context the source harness
3554/// would, instead of double-including history plus its own summary.
3555fn is_replay_excluded(msg: &ChatMessage) -> bool {
3556    msg.metadata.get("compacted_out").map(String::as_str) == Some("true")
3557        || msg
3558            .metadata
3559            .get("pi_exclude_from_context")
3560            .map(String::as_str)
3561            == Some("true")
3562}
3563
3564// ---- detection ------------------------------------------------------------
3565
3566fn detect_source(text: &str) -> Option<SessionSource> {
3567    // OpenCode export-document form (`docs/interop/opencode-pi-spec.md`
3568    // §1.2/S9a): `{info: SessionInfo, messages: [...]}` — a single
3569    // pretty-printed, MULTI-LINE JSON document, unlike every other format
3570    // this crate reads. It cannot be recognized by the per-line loop below
3571    // (no individual line of a pretty-printed document is itself valid
3572    // JSON), so it gets its own whole-text parse attempt up front. Cheap to
3573    // attempt: a real JSONL file (many newline-separated objects) fails this
3574    // parse immediately (trailing-data error) and falls through unaffected.
3575    if let Ok(v) = serde_json::from_str::<Value>(text.trim()) {
3576        if v.get("conversation").and_then(Value::as_array).is_some()
3577            && (v.get("working_dir").is_some() || v.get("workingDir").is_some())
3578        {
3579            return Some(SessionSource::Goose);
3580        }
3581        if v.get("info").is_some() && v.get("messages").and_then(Value::as_array).is_some() {
3582            return Some(SessionSource::OpenCode);
3583        }
3584    }
3585    for line in non_empty_lines(text) {
3586        // Tolerate a corrupt/truncated line (e.g. a partial first line) rather
3587        // than abandoning detection — the loaders themselves skip bad lines, so
3588        // bailing here would silently misroute an otherwise-valid Codex file.
3589        let Ok(v) = serde_json::from_str::<Value>(line) else {
3590            continue;
3591        };
3592        // OpenCode's frozen envelope form (§1.2): `{"key":[...],"value":...}`,
3593        // one record per line — the synthesized raw-capture unit for the
3594        // JSON-tree/SQLite generations alike. No other format's lines carry
3595        // both a top-level `key` ARRAY and a `value` field, so this is
3596        // unambiguous against Codex/Pi/Claude Code.
3597        if v.get("key").and_then(Value::as_array).is_some() && v.get("value").is_some() {
3598            return Some(SessionSource::OpenCode);
3599        }
3600        // Codex envelopes always carry a `payload`; Claude Code lines never do.
3601        if v.get("payload").is_some() {
3602            return Some(SessionSource::Codex);
3603        }
3604        // Gemini CLI starts with an untyped session header. Its project hash
3605        // and timestamps distinguish it from Claude Code records that also
3606        // carry `sessionId`.
3607        if v.get("sessionId").and_then(Value::as_str).is_some()
3608            && (v.get("projectHash").is_some()
3609                || v.get("startTime").is_some()
3610                || v.get("lastUpdated").is_some())
3611            && v.get("type").is_none()
3612        {
3613            return Some(SessionSource::Gemini);
3614        }
3615        // Grok's resumable `chat_history.jsonl` stores the role/type and
3616        // content directly on each record. Claude Code uses a nested
3617        // `message` envelope for the overlapping `user`/`assistant` tags.
3618        let tag = v.get("type").and_then(Value::as_str);
3619        if tag == Some("gemini") && v.get("content").is_some() {
3620            return Some(SessionSource::Gemini);
3621        }
3622        if v.get("message").is_none()
3623            && v.get("uuid").is_none()
3624            && v.get("sessionId").is_none()
3625            && matches!(
3626                tag,
3627                Some(
3628                    "system"
3629                        | "user"
3630                        | "assistant"
3631                        | "tool_result"
3632                        | "reasoning"
3633                        | "backend_tool_call"
3634                )
3635            )
3636            && (v.get("content").is_some()
3637                || v.get("tool_calls").is_some()
3638                || v.get("tool_call_id").is_some()
3639                || v.get("encrypted_content").is_some()
3640                || v.get("kind").is_some())
3641        {
3642            return Some(SessionSource::Grok);
3643        }
3644        // Pi's `SessionHeader` (line 1, always `type:"session"`): a bare `id`
3645        // (the session id) with no `message`/`uuid` — Claude Code's own
3646        // `type`-bearing lines always carry one or the other, never a
3647        // `type:"session"` header shape (`docs/interop/research/pi-fields.md`
3648        // §1).
3649        if v.get("type").and_then(Value::as_str) == Some("session")
3650            && v.get("id").and_then(Value::as_str).is_some()
3651            && v.get("message").is_none()
3652            && v.get("uuid").is_none()
3653        {
3654            return Some(SessionSource::Pi);
3655        }
3656        if v.get("type").is_some() || v.get("message").is_some() {
3657            return Some(SessionSource::ClaudeCode);
3658        }
3659    }
3660    None
3661}
3662
3663/// Which on-disk OpenCode storage surface is present under a data root
3664/// (`docs/interop/opencode-pi-spec.md` §1.2/S9a): SQLite `opencode*.db` (or
3665/// `$OPENCODE_DB`), legacy JSON tree generation B, or legacy JSON tree
3666/// generation A. This is a **filesystem classifier only** — it answers
3667/// "which generation is this?" for a corpus-discovery tool; it does not
3668/// itself read/parse the surface. See [`Session::from_opencode_str`]'s docs
3669/// for the envelope form any of these three surfaces synthesizes into, and
3670/// [`Session::from_opencode_sqlite`] (PARITY-3/PARITY-16) for the `rusqlite`
3671/// reader that reconstructs that same envelope form from `Sqlite`'s rows —
3672/// `JsonTreeA`/`JsonTreeB` remain classifier-only (their `session_diff`
3673/// round-trips via the JSON store per upstream's own behavior even on a
3674/// SQLite install, so nothing is silently lost by not reading the legacy
3675/// trees directly).
3676#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3677pub enum OpenCodeStorageSurface {
3678    /// SQLite `<data>/opencode*.db` (or `$OPENCODE_DB` override). Read by
3679    /// [`Session::from_opencode_sqlite`] / [`opencode_sqlite_store_stats`] /
3680    /// [`opencode_sqlite_corpus_envelope_text`].
3681    Sqlite,
3682    /// Legacy JSON tree, generation B: `<data>/storage/{session,message,part,session_diff}/…`,
3683    /// marker file `storage/migration`.
3684    JsonTreeB,
3685    /// Legacy JSON tree, generation A: `<data>/project/<slug>/storage/session/{info,message,part}/…`.
3686    JsonTreeA,
3687}
3688
3689/// Probe `data_root` (e.g. `~/.local/share/opencode`) for the OpenCode
3690/// storage surface present, per the discovery rules frozen in
3691/// `docs/interop/opencode-pi-spec.md` §1.2/S9a: `$OPENCODE_DB` wins outright;
3692/// otherwise glob `opencode*.db` (not just `opencode.db` — dev/beta channels
3693/// suffix the filename, `database.ts:43-55`); otherwise look for the JSON
3694/// tree generation-B marker (`storage/migration`); otherwise generation-A's
3695/// `project/` subtree. Returns `None` if nothing is found.
3696pub fn detect_opencode_storage_surface(
3697    data_root: &Path,
3698) -> Option<(OpenCodeStorageSurface, PathBuf)> {
3699    if let Ok(p) = std::env::var("OPENCODE_DB") {
3700        let pb = PathBuf::from(p);
3701        if pb.is_file() {
3702            return Some((OpenCodeStorageSurface::Sqlite, pb));
3703        }
3704    }
3705    if let Ok(entries) = std::fs::read_dir(data_root) {
3706        // D8: `std::fs::read_dir`'s iteration order is filesystem-dependent,
3707        // NOT deterministic — a store with both a default-channel
3708        // `opencode.db` and a channel-suffixed `opencode-dev.db` (S9a: both
3709        // are legal, e.g. after switching install channels) previously
3710        // returned "whichever the OS happened to list first", which could
3711        // differ between two `inspect`/`audit`/`convert` runs against the
3712        // exact same directory. Collect every `opencode*.db` candidate and
3713        // pick deterministically: the exact `opencode.db` name wins if
3714        // present (the default/most-common channel); otherwise the
3715        // lexicographically-smallest match, so repeated runs always agree.
3716        let mut candidates: Vec<PathBuf> = entries
3717            .flatten()
3718            .map(|entry| entry.path())
3719            .filter(|p| {
3720                p.file_name()
3721                    .and_then(|n| n.to_str())
3722                    .is_some_and(|name| name.starts_with("opencode") && name.ends_with(".db"))
3723            })
3724            .collect();
3725        candidates.sort();
3726        if let Some(exact) = candidates
3727            .iter()
3728            .find(|p| p.file_name().and_then(|n| n.to_str()) == Some("opencode.db"))
3729        {
3730            return Some((OpenCodeStorageSurface::Sqlite, exact.clone()));
3731        }
3732        if let Some(first) = candidates.into_iter().next() {
3733            return Some((OpenCodeStorageSurface::Sqlite, first));
3734        }
3735    }
3736    let storage = data_root.join("storage");
3737    if storage.join("migration").is_file() {
3738        return Some((OpenCodeStorageSurface::JsonTreeB, storage));
3739    }
3740    let project_dir = data_root.join("project");
3741    if project_dir.is_dir() {
3742        return Some((OpenCodeStorageSurface::JsonTreeA, project_dir));
3743    }
3744    None
3745}
3746
3747/// STRICT-VERBATIM line split for `raw` capture (IX-1) — the counterpart to
3748/// [`non_empty_lines`]'s blank-skipping/trimming PARSE view.
3749///
3750/// Splits only on `\n` (never treats `\r\n` specially), so a CRLF source's
3751/// `\r` survives as part of the returned line's own content; blank lines and
3752/// trailing-whitespace-only lines are kept verbatim rather than dropped or
3753/// trimmed. This is what makes `Session.raw` — populated from this at every
3754/// raw-capture site — byte-for-byte faithful to the source for ANY input, not
3755/// just well-formed LF JSONL with no blank lines.
3756///
3757/// Returns `(lines, ends_with_newline)`. `lines.join("\n")` alone cannot
3758/// distinguish a source that ended with a trailing newline from one that
3759/// didn't (both split into the same line list), so `ends_with_newline`
3760/// records that fact out-of-band — [`join_lines_verbatim`] is the exact
3761/// inverse, given both. `text == ""` yields `(vec![], false)`: an empty
3762/// source has zero lines, not one blank line.
3763fn split_lines_verbatim(text: &str) -> (Vec<&str>, bool) {
3764    if text.is_empty() {
3765        return (Vec::new(), false);
3766    }
3767    let ends_with_newline = text.ends_with('\n');
3768    let body = if ends_with_newline {
3769        &text[..text.len() - 1]
3770    } else {
3771        text
3772    };
3773    (body.split('\n').collect(), ends_with_newline)
3774}
3775
3776/// The exact inverse of [`split_lines_verbatim`]: reconstruct the original
3777/// source bytes from its verbatim lines plus the trailing-newline flag.
3778fn join_lines_verbatim(lines: &[String], ends_with_newline: bool) -> String {
3779    let mut out = lines.join("\n");
3780    if ends_with_newline {
3781        out.push('\n');
3782    }
3783    out
3784}
3785
3786// ---- OpenCode SQLite (PARITY-3/PARITY-4/PARITY-16) -------------------------
3787//
3788// Reads a real `opencode*.db` store directly with `rusqlite` (bundled
3789// SQLite — no system library dependency) and reconstructs the SAME envelope
3790// form `{"key":[...],"value":...}` that `Session::from_opencode_str` already
3791// parses for the JSON-tree surfaces (`docs/interop/opencode-pi-spec.md`
3792// §1.2/S9c). Row -> envelope reconstruction mirrors the real app's own
3793// `session.ts` `fromRow` (session table: columnar fields recombined into the
3794// camelCase `SessionInfo` shape) with ONE deliberate divergence: `revert` is
3795// carried as the RAW column value, not upstream's own `fromRow`
3796// reconstruction — which silently drops the V2 `Revert.State` schema's extra
3797// `files` field (`session.ts:70-76`, `revert.ts:18-24`, spec finding S9c).
3798// `message`/`part` rows are simpler: their `data` column is already the V1
3799// `Info`/`Part` JSON minus the id columns hoisted out by the schema
3800// (`session/sql.ts:68-98`), so reconstruction is just re-injecting
3801// `id`/`sessionID`(/`messageID`).
3802
3803/// First 16 bytes of every SQLite database file — the format's own magic,
3804/// independent of file extension.
3805const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0";
3806
3807/// Whether `path` should be routed to the OpenCode SQLite loader instead of
3808/// the UTF-8 text path (PARITY-16): true when the file's first 16 bytes are
3809/// the SQLite magic, OR its extension is `.db` — the latter so a
3810/// corrupted/truncated `opencode*.db` still gets `rusqlite`'s own "not a
3811/// database" diagnostic (PARITY-3 AC03) instead of a confusing UTF-8 error.
3812/// A non-existent path is NOT considered SQLite here — the missing-file
3813/// diagnostic in that case comes from the normal load path (`with_context`
3814/// at the CLI call sites), which already names the path clearly.
3815pub fn looks_like_sqlite(path: &Path) -> bool {
3816    if !path.is_file() {
3817        return false;
3818    }
3819    if path.extension().and_then(|e| e.to_str()) == Some("db") {
3820        return true;
3821    }
3822    use std::io::Read;
3823    let Ok(mut f) = std::fs::File::open(path) else {
3824        return false;
3825    };
3826    let mut buf = [0u8; 16];
3827    f.read_exact(&mut buf).is_ok() && &buf == SQLITE_MAGIC
3828}
3829
3830/// Read `path` as UTF-8 text, translating a non-UTF-8 failure into a clear,
3831/// format-aware diagnostic (PARITY-16) instead of the raw "stream did not
3832/// contain valid UTF-8" `io::Error` — named path, and what supercode DOES
3833/// accept there. Binary SQLite input never reaches this function: callers
3834/// check [`looks_like_sqlite`] first and route to
3835/// [`Session::from_opencode_sqlite`] instead.
3836fn read_utf8_or_diagnose(path: &Path) -> Result<String> {
3837    let bytes = std::fs::read(path)?;
3838    String::from_utf8(bytes).map_err(|_| {
3839        crate::Error::Other(format!(
3840            "{} is not valid UTF-8 text, and is not a recognized OpenCode SQLite store \
3841             (no `SQLite format 3` header) — supercode reads Claude Code / Codex / Pi / \
3842             OpenCode session logs as UTF-8 JSONL, or an OpenCode `opencode*.db` SQLite file",
3843            path.display()
3844        ))
3845    })
3846}
3847
3848/// Read only the portion of a JSONL transcript a bounded scrollback can use.
3849///
3850/// The first record carries durable session metadata (especially for Codex),
3851/// while the trailing window carries the messages the viewport will render.
3852/// Full lossless loaders intentionally continue to read every byte.
3853fn read_display_jsonl(
3854    path: &Path,
3855    message_limit: usize,
3856) -> Result<(Option<SessionSource>, String, bool)> {
3857    const MIN_TAIL_BYTES: u64 = 4 * 1024 * 1024;
3858    const MAX_TAIL_BYTES: u64 = 64 * 1024 * 1024;
3859    const BYTES_PER_MESSAGE: u64 = 16 * 1024;
3860
3861    let mut first = String::new();
3862    BufReader::new(std::fs::File::open(path)?).read_line(&mut first)?;
3863    let source = detect_source(&first);
3864    if !matches!(
3865        source,
3866        Some(SessionSource::ClaudeCode | SessionSource::Codex | SessionSource::Gemini)
3867    ) {
3868        let text = read_utf8_or_diagnose(path)?;
3869        return Ok((detect_source(&text), text, false));
3870    }
3871
3872    let mut file = std::fs::File::open(path)?;
3873    let file_len = file.metadata()?.len();
3874    let requested = (message_limit.max(1) as u64)
3875        .saturating_mul(BYTES_PER_MESSAGE)
3876        .clamp(MIN_TAIL_BYTES, MAX_TAIL_BYTES);
3877    if file_len <= requested {
3878        let text = read_utf8_or_diagnose(path)?;
3879        return Ok((source, text, false));
3880    }
3881
3882    let start = file_len - requested;
3883    file.seek(SeekFrom::Start(start))?;
3884    let mut bytes = Vec::with_capacity(requested as usize);
3885    file.read_to_end(&mut bytes)?;
3886    // The window normally starts in the middle of a JSON record. Discard that
3887    // partial prefix so every line passed to the existing parsers is valid.
3888    if let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') {
3889        bytes.drain(..=newline);
3890    }
3891    let mut tail = String::from_utf8(bytes).map_err(|_| {
3892        crate::Error::Other(format!(
3893            "{} contains non-UTF-8 data in its display window",
3894            path.display()
3895        ))
3896    })?;
3897    if !tail
3898        .lines()
3899        .any(|line| native_display_human_line(line, source))
3900    {
3901        // A single tool-heavy turn can exceed the ordinary byte window. Search backward through a
3902        // separately bounded native slice for only its nearest human record, then prepend that one
3903        // line to the cheap tail. The skipped megabytes are never normalized or sent over RPC.
3904        let search_bytes = file_len.min(requested.saturating_mul(2).min(MAX_TAIL_BYTES));
3905        let search_start = file_len - search_bytes;
3906        file.seek(SeekFrom::Start(search_start))?;
3907        let mut search = Vec::with_capacity(search_bytes as usize);
3908        file.read_to_end(&mut search)?;
3909        if search_start > 0 {
3910            if let Some(newline) = search.iter().position(|byte| *byte == b'\n') {
3911                search.drain(..=newline);
3912            }
3913        }
3914        if let Ok(search) = std::str::from_utf8(&search) {
3915            if let Some(anchor) = search
3916                .lines()
3917                .rev()
3918                .find(|line| native_display_human_line(line, source))
3919            {
3920                tail = format!("{anchor}\n{tail}");
3921            }
3922        }
3923    }
3924    let text = if matches!(source, Some(SessionSource::Codex | SessionSource::Gemini)) {
3925        format!("{first}{tail}")
3926    } else {
3927        tail
3928    };
3929    Ok((source, text, true))
3930}
3931
3932fn native_display_human_line(line: &str, source: Option<SessionSource>) -> bool {
3933    if !line
3934        .as_bytes()
3935        .windows(6)
3936        .any(|window| window == b"\"user\"")
3937    {
3938        return false;
3939    }
3940    let Ok(value) = serde_json::from_str::<Value>(line) else {
3941        return false;
3942    };
3943    match source {
3944        Some(SessionSource::Codex) => {
3945            value.get("type").and_then(Value::as_str) == Some("response_item")
3946                && value
3947                    .get("payload")
3948                    .and_then(|payload| payload.get("type"))
3949                    .and_then(Value::as_str)
3950                    == Some("message")
3951                && value
3952                    .get("payload")
3953                    .and_then(|payload| payload.get("role"))
3954                    .and_then(Value::as_str)
3955                    == Some("user")
3956        }
3957        Some(SessionSource::ClaudeCode) => {
3958            value.get("type").and_then(Value::as_str) == Some("user")
3959                && value
3960                    .get("message")
3961                    .and_then(|message| message.get("content"))
3962                    .is_some_and(|content| match content {
3963                        Value::String(text) => !text.trim().is_empty(),
3964                        Value::Array(parts) => parts.iter().any(|part| {
3965                            part.get("type").and_then(Value::as_str) == Some("text")
3966                                && part
3967                                    .get("text")
3968                                    .and_then(Value::as_str)
3969                                    .is_some_and(|text| !text.trim().is_empty())
3970                        }),
3971                        _ => false,
3972                    })
3973        }
3974        Some(SessionSource::Gemini) => {
3975            value.get("type").and_then(Value::as_str) == Some("user")
3976                && value.get("content").is_some_and(|content| match content {
3977                    Value::String(text) => !text.trim().is_empty(),
3978                    Value::Array(parts) => parts.iter().any(|part| {
3979                        part.get("text")
3980                            .and_then(Value::as_str)
3981                            .is_some_and(|text| !text.trim().is_empty())
3982                    }),
3983                    _ => false,
3984                })
3985        }
3986        _ => false,
3987    }
3988}
3989
3990fn opencode_sql_err(e: rusqlite::Error, context: &str) -> crate::Error {
3991    crate::Error::Other(format!("OpenCode SQLite error while {context}: {e}"))
3992}
3993
3994/// Open `db_path` read-only and confirm it carries the expected V1 schema
3995/// (a `session` table) — the shared entry point for every SQLite read below,
3996/// so every caller gets the SAME clear diagnostics (PARITY-3 AC03): missing
3997/// path, not-a-database, and wrong/unsupported schema are each named
3998/// distinctly rather than surfacing later as "zero sessions" or a generic
3999/// parse failure.
4000fn opencode_sqlite_open(db_path: &Path) -> Result<Connection> {
4001    if !db_path.is_file() {
4002        return Err(crate::Error::Other(format!(
4003            "OpenCode SQLite store not found at {} — expected an `opencode*.db` file \
4004             (see `docs/interop/opencode-pi-spec.md` §1.2)",
4005            db_path.display()
4006        )));
4007    }
4008    let conn = Connection::open_with_flags(
4009        db_path,
4010        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
4011    )
4012    .map_err(|e| {
4013        crate::Error::Other(format!(
4014            "{} does not look like a valid OpenCode SQLite database: {e}",
4015            db_path.display()
4016        ))
4017    })?;
4018    let has_session_table: i64 = conn
4019        .query_row(
4020            "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='session'",
4021            [],
4022            |r| r.get(0),
4023        )
4024        .map_err(|e| {
4025            crate::Error::Other(format!(
4026                "failed to read the OpenCode SQLite schema at {}: {e}",
4027                db_path.display()
4028            ))
4029        })?;
4030    if has_session_table == 0 {
4031        return Err(crate::Error::Other(format!(
4032            "{} is a SQLite database but has no `session` table — not a recognized \
4033             OpenCode store (wrong file, or an unsupported/pre-SQLite OpenCode version)",
4034            db_path.display()
4035        )));
4036    }
4037    Ok(conn)
4038}
4039
4040/// Parse a nullable JSON-typed SQLite column (`metadata`, `revert`, `model`,
4041/// …). D7: an unparseable non-empty column previously degraded to
4042/// `Value::Null` with NO diagnostic — indistinguishable from a genuinely
4043/// absent/NULL column, so a corrupt `data`/`metadata` value silently
4044/// vanished (e.g. a message whose `data` fails to parse loses its entire
4045/// canonical content with no trace). A `tracing::warn!` now surfaces the
4046/// column name and context (session/record id) whenever this happens, so
4047/// the failure is diagnosable — `Value::Null` is kept as the parsed VALUE
4048/// (still the least-wrong placeholder for a broken column; changing it to a
4049/// sentinel would risk misleading every legitimate `.is_null()` check
4050/// elsewhere) but the frontend/log now knows it happened.
4051fn opencode_json_col(s: Option<String>, col: &str, context: &str) -> Value {
4052    match s.as_deref() {
4053        None => Value::Null,
4054        Some(t) => match serde_json::from_str::<Value>(t) {
4055            Ok(v) => v,
4056            Err(e) => {
4057                tracing::warn!(
4058                    column = col,
4059                    context,
4060                    error = %e,
4061                    "opencode SQLite column failed to parse as JSON — treating as absent (D7)"
4062                );
4063                Value::Null
4064            }
4065        },
4066    }
4067}
4068
4069/// Columns the `session` table has in a GIVEN store, read once per session
4070/// load via `PRAGMA table_info` (D3: real-world stores vary — a lean/older
4071/// `opencode` generation may lack columns the newest schema added, e.g.
4072/// `workspace_id`; rusqlite's `Row::get(name)` hard-errors
4073/// "Invalid column name" on an absent column, so callers must check
4074/// membership before reading a not-guaranteed column instead of reading it
4075/// unconditionally).
4076fn opencode_session_columns(
4077    conn: &Connection,
4078) -> rusqlite::Result<std::collections::HashSet<String>> {
4079    let mut stmt = conn.prepare("PRAGMA table_info(session)")?;
4080    let names = stmt.query_map([], |r| r.get::<_, String>(1))?; // column 1 = name
4081    names.collect()
4082}
4083
4084/// Reconstruct one `session` row into the camelCase `SessionInfo` envelope
4085/// value [`Session::from_opencode_str`]'s `capture_opencode_session_info`
4086/// already parses — mirrors upstream's own `session.ts` `fromRow`, EXCEPT
4087/// `revert` carries the raw column value verbatim rather than upstream's
4088/// field-selecting reconstruction (spec S9c: that reconstruction silently
4089/// drops the V2 `Revert.State` schema's extra `files` field).
4090///
4091/// D3: not every column this loader would like to read is guaranteed to
4092/// exist — a real, older/leaner `opencode` install's `session` table (e.g.
4093/// v1.2.15) lacks `workspace_id`/`path`/`metadata`/`cost`/`tokens_*`/
4094/// `agent`/`model` entirely. Those are read defensively (guarded by
4095/// [`opencode_session_columns`]); columns present in EVERY `opencode`
4096/// generation this loader has ever targeted are still read unconditionally.
4097fn opencode_row_session_info(conn: &Connection, session_id: &str) -> Result<Value> {
4098    let cols = opencode_session_columns(conn)
4099        .map_err(|e| opencode_sql_err(e, &format!("reading session `{session_id}` schema")))?;
4100    let has = |name: &str| cols.contains(name);
4101
4102    conn.query_row("SELECT * FROM session WHERE id = ?1", [session_id], |r| {
4103        let id: String = r.get("id")?;
4104        let project_id: String = r.get("project_id")?;
4105        let workspace_id: Option<String> = if has("workspace_id") {
4106            r.get("workspace_id")?
4107        } else {
4108            None
4109        };
4110        let parent_id: Option<String> = r.get("parent_id")?;
4111        let slug: String = r.get("slug")?;
4112        let directory: String = r.get("directory")?;
4113        let path: Option<String> = if has("path") { r.get("path")? } else { None };
4114        let title: String = r.get("title")?;
4115        let version: String = r.get("version")?;
4116        let share_url: Option<String> = r.get("share_url")?;
4117        let summary_additions: Option<i64> = r.get("summary_additions")?;
4118        let summary_deletions: Option<i64> = r.get("summary_deletions")?;
4119        let summary_files: Option<i64> = r.get("summary_files")?;
4120        let summary_diffs: Option<String> = r.get("summary_diffs")?;
4121        let metadata: Option<String> = if has("metadata") {
4122            r.get("metadata")?
4123        } else {
4124            None
4125        };
4126        let cost: f64 = if has("cost") { r.get("cost")? } else { 0.0 };
4127        let tokens_input: i64 = if has("tokens_input") {
4128            r.get("tokens_input")?
4129        } else {
4130            0
4131        };
4132        let tokens_output: i64 = if has("tokens_output") {
4133            r.get("tokens_output")?
4134        } else {
4135            0
4136        };
4137        let tokens_reasoning: i64 = if has("tokens_reasoning") {
4138            r.get("tokens_reasoning")?
4139        } else {
4140            0
4141        };
4142        let tokens_cache_read: i64 = if has("tokens_cache_read") {
4143            r.get("tokens_cache_read")?
4144        } else {
4145            0
4146        };
4147        let tokens_cache_write: i64 = if has("tokens_cache_write") {
4148            r.get("tokens_cache_write")?
4149        } else {
4150            0
4151        };
4152        let revert: Option<String> = r.get("revert")?;
4153        let permission: Option<String> = if has("permission") {
4154            r.get("permission")?
4155        } else {
4156            None
4157        };
4158        let agent: Option<String> = if has("agent") { r.get("agent")? } else { None };
4159        let model: Option<String> = if has("model") { r.get("model")? } else { None };
4160        let time_created: i64 = r.get("time_created")?;
4161        let time_updated: i64 = r.get("time_updated")?;
4162        let time_compacting: Option<i64> = if has("time_compacting") {
4163            r.get("time_compacting")?
4164        } else {
4165            None
4166        };
4167        let time_archived: Option<i64> = if has("time_archived") {
4168            r.get("time_archived")?
4169        } else {
4170            None
4171        };
4172
4173        let summary =
4174            (summary_additions.is_some() || summary_deletions.is_some() || summary_files.is_some())
4175                .then(|| {
4176                    serde_json::json!({
4177                        "additions": summary_additions.unwrap_or(0),
4178                        "deletions": summary_deletions.unwrap_or(0),
4179                        "files": summary_files.unwrap_or(0),
4180                        "diffs": opencode_json_col(summary_diffs, "summary_diffs", session_id),
4181                    })
4182                });
4183        let share = share_url.map(|u| serde_json::json!({"url": u}));
4184
4185        Ok(serde_json::json!({
4186            "id": id,
4187            "slug": slug,
4188            "projectID": project_id,
4189            "workspaceID": workspace_id,
4190            "directory": directory,
4191            "path": path,
4192            "parentID": parent_id,
4193            "summary": summary,
4194            "cost": cost,
4195            "tokens": {
4196                "input": tokens_input,
4197                "output": tokens_output,
4198                "reasoning": tokens_reasoning,
4199                "cache": {"read": tokens_cache_read, "write": tokens_cache_write},
4200            },
4201            "share": share,
4202            "title": title,
4203            "agent": agent,
4204            "model": opencode_json_col(model, "model", session_id),
4205            "version": version,
4206            "metadata": opencode_json_col(metadata, "metadata", session_id),
4207            "time": {
4208                "created": time_created,
4209                "updated": time_updated,
4210                "compacting": time_compacting,
4211                "archived": time_archived,
4212            },
4213            "permission": opencode_json_col(permission, "permission", session_id),
4214            // S9c: raw column value, not a field-selecting reconstruction —
4215            // see this function's doc comment.
4216            "revert": opencode_json_col(revert, "revert", session_id),
4217        }))
4218    })
4219    .map_err(|e| match e {
4220        rusqlite::Error::QueryReturnedNoRows => crate::Error::Other(format!(
4221            "OpenCode session `{session_id}` not found in this SQLite store"
4222        )),
4223        e => opencode_sql_err(e, &format!("reading session `{session_id}`")),
4224    })
4225}
4226
4227/// `message.data` / `part.data` already ARE the V1 `Info`/`Part` JSON minus
4228/// the id columns the schema hoists out (`session/sql.ts:68-98`) — just
4229/// re-inject them, matching what a JSON-tree file (or the export document)
4230/// carries at this same key. Also re-injects the row's own `time_created`/
4231/// `time_updated` columns (D2/S9c: these live OUTSIDE `data` in the real
4232/// schema — `Timestamps` in `database/schema.sql.ts` — and MUST be carried
4233/// in the envelope so `raw` is value-complete and re-writable without
4234/// re-minting timestamps; distinct keys from `data`'s own `"time"` object,
4235/// which is a different, in-schema field with different semantics).
4236fn opencode_row_message_value(
4237    id: &str,
4238    session_id: &str,
4239    data_json: &str,
4240    time_created: i64,
4241    time_updated: i64,
4242) -> Value {
4243    let mut v = opencode_json_col(Some(data_json.to_string()), "message.data", id);
4244    if let Value::Object(map) = &mut v {
4245        map.insert("id".to_string(), Value::String(id.to_string()));
4246        map.insert(
4247            "sessionID".to_string(),
4248            Value::String(session_id.to_string()),
4249        );
4250        map.insert("time_created".to_string(), Value::from(time_created));
4251        map.insert("time_updated".to_string(), Value::from(time_updated));
4252    }
4253    v
4254}
4255
4256fn opencode_row_part_value(
4257    id: &str,
4258    session_id: &str,
4259    message_id: &str,
4260    data_json: &str,
4261    time_created: i64,
4262    time_updated: i64,
4263) -> Value {
4264    let mut v = opencode_json_col(Some(data_json.to_string()), "part.data", id);
4265    if let Value::Object(map) = &mut v {
4266        map.insert("id".to_string(), Value::String(id.to_string()));
4267        map.insert(
4268            "sessionID".to_string(),
4269            Value::String(session_id.to_string()),
4270        );
4271        map.insert(
4272            "messageID".to_string(),
4273            Value::String(message_id.to_string()),
4274        );
4275        map.insert("time_created".to_string(), Value::from(time_created));
4276        map.insert("time_updated".to_string(), Value::from(time_updated));
4277    }
4278    v
4279}
4280
4281/// Ordered envelope lines for one session's `session`/`message`/`part`/`todo`
4282/// records (`docs/interop/opencode-pi-spec.md` §1.2's frozen order): session
4283/// info first, then each message (by `time_created, id`) immediately
4284/// followed by its own parts (by `id`) — parts MUST directly follow their
4285/// owning message line, since `Session::from_opencode_str`'s envelope parser
4286/// attaches a `part` line to whichever message id is already in its index
4287/// and silently leaves an out-of-order part `raw`-only otherwise — then
4288/// `todo` side-records, then a `session_diff` side-record if the JSON
4289/// sidecar file for this session exists (order-independent).
4290///
4291/// `db_path` is needed only for the `session_diff` sidecar (D3): §1.3 says
4292/// it "is still JSON-written even on SQLite installs" — verified against
4293/// `packages/opencode/src/session/revert.ts:76` /
4294/// `packages/opencode/src/storage/storage.ts:192` at the pinned schema
4295/// commit, which write it to `<data>/storage/session_diff/<session>.json`
4296/// (`<data>` being `db_path`'s parent directory) on every revert, entirely
4297/// separate from the `session.revert` DB column this loader already
4298/// captures. Without this, revert diffs vanish from `raw` and audit
4299/// under-counts `session_diff` records for real reverted sessions.
4300fn opencode_sqlite_session_envelope_lines(
4301    conn: &Connection,
4302    db_path: &Path,
4303    session_id: &str,
4304) -> Result<Vec<String>> {
4305    let mut lines = Vec::new();
4306
4307    let session_info = opencode_row_session_info(conn, session_id)?;
4308    let project_id = session_info
4309        .get("projectID")
4310        .and_then(Value::as_str)
4311        .unwrap_or("global")
4312        .to_string();
4313    lines.push(
4314        serde_json::json!({"key": ["session", project_id, session_id], "value": session_info})
4315            .to_string(),
4316    );
4317
4318    let mut msg_stmt = conn
4319        .prepare(
4320            "SELECT id, data, time_created, time_updated FROM message \
4321             WHERE session_id = ?1 ORDER BY time_created, id",
4322        )
4323        .map_err(|e| opencode_sql_err(e, "preparing the message query"))?;
4324    let msg_rows = msg_stmt
4325        .query_map([session_id], |r| {
4326            let id: String = r.get("id")?;
4327            let data: String = r.get("data")?;
4328            let time_created: i64 = r.get("time_created")?;
4329            let time_updated: i64 = r.get("time_updated")?;
4330            Ok((id, data, time_created, time_updated))
4331        })
4332        .map_err(|e| opencode_sql_err(e, "querying messages"))?;
4333
4334    let mut part_stmt = conn
4335        .prepare(
4336            "SELECT id, data, time_created, time_updated FROM part \
4337             WHERE message_id = ?1 ORDER BY id",
4338        )
4339        .map_err(|e| opencode_sql_err(e, "preparing the part query"))?;
4340
4341    for row in msg_rows {
4342        let (msg_id, data, msg_time_created, msg_time_updated) =
4343            row.map_err(|e| opencode_sql_err(e, "reading a message row"))?;
4344        let msg_value = opencode_row_message_value(
4345            &msg_id,
4346            session_id,
4347            &data,
4348            msg_time_created,
4349            msg_time_updated,
4350        );
4351        lines.push(
4352            serde_json::json!({"key": ["message", session_id, msg_id], "value": msg_value})
4353                .to_string(),
4354        );
4355
4356        let part_rows = part_stmt
4357            .query_map([&msg_id], |r| {
4358                let id: String = r.get("id")?;
4359                let data: String = r.get("data")?;
4360                let time_created: i64 = r.get("time_created")?;
4361                let time_updated: i64 = r.get("time_updated")?;
4362                Ok((id, data, time_created, time_updated))
4363            })
4364            .map_err(|e| opencode_sql_err(e, "querying parts"))?;
4365        for prow in part_rows {
4366            let (part_id, pdata, part_time_created, part_time_updated) =
4367                prow.map_err(|e| opencode_sql_err(e, "reading a part row"))?;
4368            let part_value = opencode_row_part_value(
4369                &part_id,
4370                session_id,
4371                &msg_id,
4372                &pdata,
4373                part_time_created,
4374                part_time_updated,
4375            );
4376            lines.push(
4377                serde_json::json!({"key": ["part", msg_id, part_id], "value": part_value})
4378                    .to_string(),
4379            );
4380        }
4381    }
4382
4383    let mut todo_stmt = conn
4384        .prepare(
4385            "SELECT content, status, priority, position, time_created, time_updated \
4386             FROM todo WHERE session_id = ?1 ORDER BY position",
4387        )
4388        .map_err(|e| opencode_sql_err(e, "preparing the todo query"))?;
4389    let todo_rows = todo_stmt
4390        .query_map([session_id], |r| {
4391            let content: String = r.get("content")?;
4392            let status: String = r.get("status")?;
4393            let priority: String = r.get("priority")?;
4394            let position: i64 = r.get("position")?;
4395            let time_created: i64 = r.get("time_created")?;
4396            let time_updated: i64 = r.get("time_updated")?;
4397            Ok(serde_json::json!({
4398                "sessionID": session_id,
4399                "content": content,
4400                "status": status,
4401                "priority": priority,
4402                "position": position,
4403                "time": {"created": time_created, "updated": time_updated},
4404            }))
4405        })
4406        .map_err(|e| opencode_sql_err(e, "querying todos"))?;
4407    for trow in todo_rows {
4408        let tv = trow.map_err(|e| opencode_sql_err(e, "reading a todo row"))?;
4409        let position = tv.get("position").cloned().unwrap_or(Value::Null);
4410        lines.push(
4411            serde_json::json!({"key": ["todo", session_id, position], "value": tv}).to_string(),
4412        );
4413    }
4414
4415    if let Some(diff_value) = opencode_read_session_diff_sidecar(db_path, session_id) {
4416        lines.push(
4417            serde_json::json!({"key": ["session_diff", session_id], "value": diff_value})
4418                .to_string(),
4419        );
4420    }
4421
4422    Ok(lines)
4423}
4424
4425/// D3: read the `session_diff` JSON sidecar file for `session_id`, if
4426/// present, from `<db_path's parent>/storage/session_diff/<session_id>.json`
4427/// — the real on-disk location (`storage.ts`'s `Global.Path.data` +
4428/// `"storage"`, with `db_path` itself living at `Global.Path.data/opencode.db`
4429/// or `opencode-<channel>.db`). Best-effort: a missing file is the common
4430/// case (most sessions never revert) and is not an error; an existing-but-
4431/// unparseable file surfaces a diagnostic (D7-style) rather than silently
4432/// vanishing.
4433fn opencode_read_session_diff_sidecar(db_path: &Path, session_id: &str) -> Option<Value> {
4434    let dir = db_path.parent()?;
4435    let sidecar = dir
4436        .join("storage")
4437        .join("session_diff")
4438        .join(format!("{session_id}.json"));
4439    let text = std::fs::read_to_string(&sidecar).ok()?;
4440    match serde_json::from_str::<Value>(&text) {
4441        Ok(v) => Some(v),
4442        Err(e) => {
4443            tracing::warn!(
4444                path = %sidecar.display(),
4445                error = %e,
4446                "opencode session_diff sidecar failed to parse as JSON — skipping (D7)"
4447            );
4448            None
4449        }
4450    }
4451}
4452
4453/// Pick the "primary" session for a bare `.db` path with no explicit session
4454/// id (`Session::load`'s auto-detect entry point): the most-recently-updated
4455/// TOP-LEVEL session (`parent_id IS NULL` sorts first, then `time_updated`
4456/// descending) — a subagent/task child session is never picked over an
4457/// available root session, mirroring `most_recent_session`'s "latest wins"
4458/// convention used elsewhere in this crate for supercode's own store.
4459fn opencode_sqlite_primary_session_id(conn: &Connection) -> Result<String> {
4460    conn.query_row(
4461        "SELECT id FROM session ORDER BY (parent_id IS NULL) DESC, time_updated DESC LIMIT 1",
4462        [],
4463        |r| r.get::<_, String>(0),
4464    )
4465    .map_err(|e| match e {
4466        rusqlite::Error::QueryReturnedNoRows => {
4467            crate::Error::Other("OpenCode SQLite store contains no sessions".to_string())
4468        }
4469        e => opencode_sql_err(e, "selecting the primary session"),
4470    })
4471}
4472
4473fn opencode_sqlite_all_session_ids(conn: &Connection, limit: Option<usize>) -> Result<Vec<String>> {
4474    let mut stmt = conn
4475        .prepare("SELECT id FROM session ORDER BY time_created, id")
4476        .map_err(|e| opencode_sql_err(e, "listing sessions"))?;
4477    let rows = stmt
4478        .query_map([], |r| r.get::<_, String>(0))
4479        .map_err(|e| opencode_sql_err(e, "listing sessions"))?;
4480    let mut ids = Vec::new();
4481    for row in rows {
4482        ids.push(row.map_err(|e| opencode_sql_err(e, "reading a session id"))?);
4483        if limit.is_some_and(|n| ids.len() >= n) {
4484            break;
4485        }
4486    }
4487    Ok(ids)
4488}
4489
4490/// D6: list every session id in an OpenCode SQLite store (oldest first) —
4491/// exposed so CLI callers (`convert`, `inspect`) can detect a multi-session
4492/// store and warn before [`Session::load`]/[`Session::from_opencode_sqlite`]
4493/// silently picks just the primary one. Previously nothing surfaced this:
4494/// `convert opencode.db --to X` converted 1-of-N sessions with no warning
4495/// and no way to name a different one.
4496pub fn opencode_sqlite_session_ids(db_path: &Path) -> Result<Vec<String>> {
4497    let conn = opencode_sqlite_open(db_path)?;
4498    opencode_sqlite_all_session_ids(&conn, None)
4499}
4500
4501/// D6: the same "most-recently-updated top-level session" selection
4502/// [`Session::load`]/[`Session::from_opencode_sqlite`] make by default when
4503/// no explicit session id is given — exposed so a CLI-level warning can name
4504/// which one was chosen.
4505pub fn opencode_sqlite_primary_id(db_path: &Path) -> Result<String> {
4506    let conn = opencode_sqlite_open(db_path)?;
4507    opencode_sqlite_primary_session_id(&conn)
4508}
4509
4510/// Cheap store-level counts — `COUNT(*)` only, no row hydration — for
4511/// `inspect`'s "reports the audited real store's sessions, messages, and
4512/// parts" summary (PARITY-3 AC01).
4513#[derive(Debug, Clone, Copy, Default)]
4514#[non_exhaustive]
4515pub struct OpenCodeSqliteStoreStats {
4516    /// Row count of the `session` table.
4517    pub sessions: u64,
4518    /// Row count of the `message` table.
4519    pub messages: u64,
4520    /// Row count of the `part` table.
4521    pub parts: u64,
4522    /// Row count of the `todo` table.
4523    pub todos: u64,
4524}
4525
4526/// Count sessions/messages/parts/todos in a real OpenCode SQLite store
4527/// without loading any of them (PARITY-3 AC01).
4528pub fn opencode_sqlite_store_stats(db_path: &Path) -> Result<OpenCodeSqliteStoreStats> {
4529    let conn = opencode_sqlite_open(db_path)?;
4530    let count = |table: &str| -> Result<u64> {
4531        let sql = format!("SELECT count(*) FROM {table}");
4532        conn.query_row(&sql, [], |r| r.get::<_, i64>(0))
4533            .map(|n| n.max(0) as u64)
4534            .map_err(|e| opencode_sql_err(e, &format!("counting `{table}` rows")))
4535    };
4536    Ok(OpenCodeSqliteStoreStats {
4537        sessions: count("session")?,
4538        messages: count("message")?,
4539        parts: count("part")?,
4540        todos: count("todo")?,
4541    })
4542}
4543
4544/// Combined envelope text spanning every session in `db_path` (or up to
4545/// `limit_sessions`) — for corpus-style scanning
4546/// (the OpenCode SQLite corpus-audit path, PARITY-4).
4547/// Safe to concatenate multiple sessions' records into one text even though
4548/// [`Session::from_opencode_str`] itself only keeps the LAST `session` line
4549/// (single-session semantics) — the audit line-classifier
4550/// (`audit_opencode_line`) scores each line independently and doesn't care
4551/// about session boundaries. Use [`Session::from_opencode_sqlite`] to load
4552/// one session as a real [`Session`].
4553pub fn opencode_sqlite_corpus_envelope_text(
4554    db_path: &Path,
4555    limit_sessions: Option<usize>,
4556) -> Result<String> {
4557    let conn = opencode_sqlite_open(db_path)?;
4558    let ids = opencode_sqlite_all_session_ids(&conn, limit_sessions)?;
4559    let mut out = String::new();
4560    for id in ids {
4561        for line in opencode_sqlite_session_envelope_lines(&conn, db_path, &id)? {
4562            out.push_str(&line);
4563            out.push('\n');
4564        }
4565    }
4566    Ok(out)
4567}
4568
4569/// PARSE-iteration view (blank/whitespace-only lines skipped, each line
4570/// trimmed, `\r` line endings normalized away by [`str::lines`]) — used
4571/// everywhere a loader walks lines looking for JSON *records*, where a blank
4572/// line is simply not a record and must not become a spurious parse
4573/// failure/empty entry. Deliberately NOT used for `raw` capture any more
4574/// (IX-1) — see [`split_lines_verbatim`] for that.
4575fn non_empty_lines(text: &str) -> impl Iterator<Item = &str> {
4576    text.lines().map(str::trim).filter(|l| !l.is_empty())
4577}
4578
4579// ---- Claude Code ----------------------------------------------------------
4580
4581/// The `subagents/` directory for a Claude Code transcript at `<dir>/<stem>.jsonl`
4582/// is `<dir>/<stem>/subagents/`. Returns it only if it exists.
4583fn subagents_dir_for(main_path: &Path) -> Option<PathBuf> {
4584    let dir = main_path.parent()?;
4585    let stem = main_path.file_stem()?.to_str()?;
4586    let candidate = dir.join(stem).join("subagents");
4587    candidate.is_dir().then_some(candidate)
4588}
4589
4590/// The first `agentId` recorded in a subagent transcript.
4591fn first_agent_id(jsonl: &str) -> Option<String> {
4592    for line in non_empty_lines(jsonl) {
4593        if let Ok(v) = serde_json::from_str::<Value>(line) {
4594            if let Some(id) = v.get("agentId").and_then(Value::as_str) {
4595                return Some(id.to_string());
4596            }
4597        }
4598    }
4599    None
4600}
4601
4602/// Find the `tool_use_id` of each parent `Task` call that spawned one of
4603/// `agent_ids`, by locating the parent transcript's `tool_result` whose
4604/// serialized content mentions the agent id. Best effort: an id with no
4605/// qualifying match is simply absent from the returned map.
4606///
4607/// Single pass over `main_text` — each line is parsed at most once,
4608/// regardless of how many agent ids are being sought — with each id's result
4609/// reproducing exactly what a per-id scan-and-parse-per-hit-line search would
4610/// return: the first line (in file order) whose raw text contains the id and
4611/// which — the first qualifying `tool_result` block in that line, in block
4612/// order — has a string `tool_use_id` and a serialized form that also
4613/// contains the id. A `tool_result` block matching on raw-line/serialized
4614/// containment but lacking a `tool_use_id` yields nothing for that id and
4615/// does not shadow a later match.
4616fn parent_tool_use_index(main_text: &str, agent_ids: &[String]) -> HashMap<String, String> {
4617    let mut index: HashMap<String, String> = HashMap::new();
4618    if agent_ids.is_empty() {
4619        return index;
4620    }
4621
4622    for line in non_empty_lines(main_text) {
4623        if index.len() == agent_ids.len() {
4624            break;
4625        }
4626        // Cheap prefilter: every match this function can ever return comes
4627        // from a block whose raw line carries the literal JSON string value
4628        // `tool_result` (no JSON-escape variants of that ASCII literal).
4629        if !line.contains("tool_result") {
4630            continue;
4631        }
4632        let still_unmapped: Vec<&String> = agent_ids
4633            .iter()
4634            .filter(|id| !index.contains_key(id.as_str()))
4635            .collect();
4636        if still_unmapped.is_empty() {
4637            break;
4638        }
4639        let Ok(v) = serde_json::from_str::<Value>(line) else {
4640            continue;
4641        };
4642        let content = v.get("message").and_then(|m| m.get("content"));
4643        let Some(Value::Array(blocks)) = content else {
4644            continue;
4645        };
4646        for b in blocks {
4647            if b.get("type").and_then(Value::as_str) != Some("tool_result") {
4648                continue;
4649            }
4650            let Some(tool_use_id) = b.get("tool_use_id").and_then(Value::as_str) else {
4651                continue;
4652            };
4653            let block_str = b.to_string();
4654            for id in &still_unmapped {
4655                if index.contains_key(id.as_str()) {
4656                    continue;
4657                }
4658                if line.contains(id.as_str()) && block_str.contains(id.as_str()) {
4659                    index.insert((*id).clone(), tool_use_id.to_string());
4660                }
4661            }
4662        }
4663    }
4664
4665    index
4666}
4667
4668#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4669enum ClaudeReplayKind {
4670    User,
4671    Assistant,
4672    Attachment,
4673    System,
4674}
4675
4676impl ClaudeReplayKind {
4677    fn is_conversation(self) -> bool {
4678        matches!(self, Self::User | Self::Assistant)
4679    }
4680}
4681
4682#[derive(Debug, Clone)]
4683struct ClaudeReplayNode {
4684    line_index: usize,
4685    uuid: String,
4686    parent_uuid: Option<String>,
4687    kind: ClaudeReplayKind,
4688    is_sidechain: bool,
4689    assistant_message_id: Option<String>,
4690    is_tool_result: bool,
4691    compact: Option<ClaudeCompactBoundary>,
4692}
4693
4694#[derive(Debug, Clone)]
4695struct ClaudeCompactBoundary {
4696    anchor_uuid: Option<String>,
4697    preserved_uuids: Vec<String>,
4698    preserved_segment: Option<(String, String)>,
4699}
4700
4701/// One projection of a Claude transcript graph: the source lines to replay,
4702/// plus whatever the projection had to give up to produce them (always empty
4703/// below [`Fidelity::Semantic`], which is the only level that degrades
4704/// instead of failing).
4705#[derive(Debug, Default)]
4706struct ClaudeReplaySelection {
4707    lines: Vec<usize>,
4708    residue: Vec<String>,
4709}
4710
4711#[derive(Debug, Default)]
4712struct ClaudeReplayIndex {
4713    nodes: Vec<ClaudeReplayNode>,
4714    by_uuid: HashMap<String, usize>,
4715    segment_anchors: HashSet<String>,
4716    last_prompt: Option<(String, bool)>,
4717    linear_lines: Vec<usize>,
4718}
4719
4720impl ClaudeReplayIndex {
4721    fn observe(&mut self, line_index: usize, v: &Value) -> Result<()> {
4722        if v.get("type").and_then(Value::as_str) == Some("last-prompt") {
4723            if let Some(leaf) = v.get("leafUuid").and_then(Value::as_str) {
4724                self.last_prompt = Some((
4725                    leaf.to_string(),
4726                    v.get("explicit").and_then(Value::as_bool) == Some(true),
4727                ));
4728            }
4729            return Ok(());
4730        }
4731
4732        // A fork-context-ref is a real Claude graph anchor, but not a replay
4733        // message. Its child is the first conversational record in the
4734        // exported fork, so reaching this UUID terminates the locally
4735        // replayable segment rather than indicating a broken parent edge.
4736        if v.get("type").and_then(Value::as_str) == Some("fork-context-ref") {
4737            if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
4738                self.segment_anchors.insert(uuid.to_string());
4739            }
4740            return Ok(());
4741        }
4742
4743        let kind = match v.get("type").and_then(Value::as_str) {
4744            Some("user") => ClaudeReplayKind::User,
4745            Some("assistant") => ClaudeReplayKind::Assistant,
4746            Some("attachment") => ClaudeReplayKind::Attachment,
4747            Some("system") => ClaudeReplayKind::System,
4748            _ => return Ok(()),
4749        };
4750        self.linear_lines.push(line_index);
4751        let Some(uuid) = v.get("uuid").and_then(Value::as_str) else {
4752            return Ok(());
4753        };
4754        if self.by_uuid.contains_key(uuid) {
4755            return Err(claude_replay_error(format!(
4756                "duplicate uuid `{uuid}` in Claude transcript"
4757            )));
4758        }
4759
4760        let compact = (kind == ClaudeReplayKind::System
4761            && v.get("subtype").and_then(Value::as_str) == Some("compact_boundary"))
4762        .then(|| ClaudeCompactBoundary::from_value(v));
4763        let assistant_message_id = (kind == ClaudeReplayKind::Assistant)
4764            .then(|| claude_assistant_message_id(v).map(str::to_string))
4765            .flatten();
4766        let is_tool_result = kind == ClaudeReplayKind::User
4767            && v.get("message")
4768                .and_then(|m| m.get("content"))
4769                .and_then(Value::as_array)
4770                .is_some_and(|blocks| {
4771                    blocks
4772                        .iter()
4773                        .any(|b| b.get("type").and_then(Value::as_str) == Some("tool_result"))
4774                });
4775        let node = ClaudeReplayNode {
4776            line_index,
4777            uuid: uuid.to_string(),
4778            parent_uuid: v
4779                .get("parentUuid")
4780                .and_then(Value::as_str)
4781                .map(str::to_string),
4782            kind,
4783            is_sidechain: v.get("isSidechain").and_then(Value::as_bool) == Some(true),
4784            assistant_message_id,
4785            is_tool_result,
4786            compact,
4787        };
4788        self.by_uuid.insert(uuid.to_string(), self.nodes.len());
4789        self.nodes.push(node);
4790        Ok(())
4791    }
4792
4793    /// Project the transcript at `fidelity`.
4794    ///
4795    /// Below [`Fidelity::Semantic`] this is the STRICT projection every
4796    /// continuation, transfer and export path depends on: reconstruct
4797    /// Claude's own single active post-compaction branch, or fail naming what
4798    /// could not be reconstructed.
4799    ///
4800    /// [`Fidelity::Semantic`] is the read-only VIEW mode. A Claude transcript
4801    /// that has been compacted, summarized, or resumed across files routinely
4802    /// contains a live record whose `parentUuid` names a record that is no
4803    /// longer on disk. Strict projection rightly refuses — a continuation
4804    /// built on a guessed graph is silent loss — but a VIEW does not need a
4805    /// continuation, so this mode anchors each dangling edge as a segment
4806    /// root, projects every severed segment exactly as the active branch is
4807    /// projected, splices them back together in transcript order, and names
4808    /// every degradation in the returned residue instead of erroring.
4809    fn select_lines(mut self, fidelity: Fidelity) -> Result<ClaudeReplaySelection> {
4810        let lenient = fidelity.tolerates_residue();
4811        let mut residue = Vec::new();
4812        if self.nodes.is_empty() {
4813            // Older exports and many hand-authored compatibility fixtures do
4814            // not carry Claude's `uuid`/`parentUuid` graph fields. There is no
4815            // branch information to project in that shape, so preserve the
4816            // historical linear normalization behavior. Native graph-bearing
4817            // transcripts always take the projection below.
4818            return Ok(ClaudeReplaySelection {
4819                lines: self.linear_lines,
4820                residue,
4821            });
4822        }
4823        if lenient {
4824            self.anchor_dangling_parents(&mut residue);
4825        }
4826        // Last resort for a VIEW: a transcript whose graph is unprojectable
4827        // for some OTHER reason (a cycle, an unresolvable compact boundary)
4828        // still renders as the file's own record order. A read-only mirror
4829        // that cannot open a session at all is the defect this mode exists
4830        // to remove, so `Semantic` never returns an error.
4831        let fallback = lenient.then(|| self.linear_lines.clone());
4832        match self.project(lenient, &mut residue) {
4833            Ok(lines) => Ok(ClaudeReplaySelection { lines, residue }),
4834            Err(error) => match fallback {
4835                Some(lines) => {
4836                    residue.push(format!(
4837                        "the Claude record graph could not be projected ({error}); \
4838                         every record was stitched in transcript order instead"
4839                    ));
4840                    Ok(ClaudeReplaySelection { lines, residue })
4841                }
4842                None => Err(error),
4843            },
4844        }
4845    }
4846
4847    /// Turn every edge that points outside the transcript into a segment
4848    /// root, naming the dangling uuids as residue.
4849    ///
4850    /// A `fork-context-ref` anchor is already a declared segment boundary,
4851    /// not a break, so it is left alone.
4852    fn anchor_dangling_parents(&mut self, residue: &mut Vec<String>) {
4853        let mut dangling = Vec::new();
4854        for idx in 0..self.nodes.len() {
4855            let Some(parent) = self.nodes[idx].parent_uuid.as_deref() else {
4856                continue;
4857            };
4858            if self.by_uuid.contains_key(parent) || self.segment_anchors.contains(parent) {
4859                continue;
4860            }
4861            dangling.push(format!("`{}` → `{parent}`", self.nodes[idx].uuid));
4862            self.nodes[idx].parent_uuid = None;
4863        }
4864        if dangling.is_empty() {
4865            return;
4866        }
4867        const NAMED: usize = 8;
4868        let total = dangling.len();
4869        let overflow = total.saturating_sub(NAMED);
4870        dangling.truncate(NAMED);
4871        let mut listed = dangling.join(", ");
4872        if overflow > 0 {
4873            listed.push_str(&format!(", and {overflow} more"));
4874        }
4875        residue.push(format!(
4876            "{total} Claude record(s) reference a parentUuid absent from the transcript and were \
4877             anchored as segment roots: {listed}"
4878        ));
4879    }
4880
4881    fn project(&mut self, lenient: bool, residue: &mut Vec<String>) -> Result<Vec<usize>> {
4882        let mut retained = vec![true; self.nodes.len()];
4883        if let Some(boundary_index) = self.nodes.iter().rposition(|n| n.compact.is_some()) {
4884            let parents: Option<Vec<Option<String>>> = lenient.then(|| {
4885                self.nodes
4886                    .iter()
4887                    .map(|node| node.parent_uuid.clone())
4888                    .collect()
4889            });
4890            if let Err(error) = self.apply_latest_compaction(boundary_index, &mut retained) {
4891                let Some(parents) = parents else {
4892                    return Err(error);
4893                };
4894                // The boundary rewrites parents as it goes, so restore the
4895                // graph it half-edited before continuing without it.
4896                for (node, parent) in self.nodes.iter_mut().zip(parents) {
4897                    node.parent_uuid = parent;
4898                }
4899                retained.iter_mut().for_each(|keep| *keep = true);
4900                residue.push(format!(
4901                    "the latest Claude compact boundary could not be projected ({error}); \
4902                     no pre-compaction record was pruned from this view"
4903                ));
4904            }
4905        }
4906        let sidechain_only = self
4907            .nodes
4908            .iter()
4909            .enumerate()
4910            .filter(|(idx, node)| retained[*idx] && node.kind.is_conversation())
4911            .all(|(_, node)| node.is_sidechain);
4912
4913        let explicit_leaf = self
4914            .last_prompt
4915            .as_ref()
4916            .filter(|(_, explicit)| *explicit)
4917            .and_then(|(uuid, _)| self.by_uuid.get(uuid).copied())
4918            .filter(|idx| retained[*idx]);
4919        let newest_non_sidechain = self
4920            .nodes
4921            .iter()
4922            .enumerate()
4923            .rev()
4924            .find(|(idx, node)| retained[*idx] && !node.is_sidechain)
4925            .map(|(idx, _)| idx);
4926        // Dedicated Claude subagent transcripts are sidechains by design:
4927        // every record, including their root user prompt, has
4928        // `isSidechain:true`. When there is no main-chain candidate, resume
4929        // the newest retained sidechain leaf instead of rejecting the child.
4930        let newest_sidechain = self
4931            .nodes
4932            .iter()
4933            .enumerate()
4934            .rev()
4935            .find(|(idx, node)| retained[*idx] && node.is_sidechain && node.kind.is_conversation())
4936            .map(|(idx, _)| idx);
4937        let mut active = explicit_leaf
4938            .or(newest_non_sidechain)
4939            .or(newest_sidechain)
4940            .ok_or_else(|| claude_replay_error("no Claude record remains after compaction"))?;
4941
4942        // Metadata descendants such as turn_duration are leaves in the raw
4943        // graph. Claude resumes from their nearest user/assistant ancestor,
4944        // then appends those descendants to the reconstructed chain.
4945        let mut seeking = HashSet::new();
4946        while !self.nodes[active].kind.is_conversation() {
4947            if !seeking.insert(active) {
4948                return Err(claude_replay_error(
4949                    "cycle while resolving active Claude leaf",
4950                ));
4951            }
4952            active = self.parent_index(active, &retained)?;
4953        }
4954
4955        let mut segments =
4956            vec![self.project_segment(active, &retained, sidechain_only, lenient)?];
4957        if lenient {
4958            for leaf in self.severed_segment_leaves(active, &retained) {
4959                segments.push(self.project_segment(leaf, &retained, sidechain_only, lenient)?);
4960            }
4961            if segments.len() > 1 {
4962                residue.push(format!(
4963                    "{} conversation segments were stitched in transcript order because the \
4964                     Claude record graph is severed",
4965                    segments.len()
4966                ));
4967            }
4968        }
4969        // Each segment keeps its own reconstructed order; the segments
4970        // themselves are spliced by where they start in the file.
4971        segments.retain(|segment| !segment.is_empty());
4972        segments.sort_by_key(|segment| {
4973            segment
4974                .iter()
4975                .map(|idx| self.nodes[*idx].line_index)
4976                .min()
4977                .unwrap_or(usize::MAX)
4978        });
4979        let mut ordered = Vec::new();
4980        let mut placed = HashSet::new();
4981        for idx in segments.into_iter().flatten() {
4982            if placed.insert(idx) {
4983                ordered.push(idx);
4984            }
4985        }
4986
4987        self.recover_parallel_assistant_chunks(ordered, &retained)
4988            .map(|indices| {
4989                indices
4990                    .into_iter()
4991                    .map(|idx| self.nodes[idx].line_index)
4992                    .collect()
4993            })
4994    }
4995
4996    /// Reconstruct one segment: `leaf`'s parent chain, oldest-first, plus the
4997    /// non-conversation descendants rooted at it.
4998    fn project_segment(
4999        &self,
5000        leaf: usize,
5001        retained: &[bool],
5002        sidechain_only: bool,
5003        lenient: bool,
5004    ) -> Result<Vec<usize>> {
5005        let mut reversed = Vec::new();
5006        let mut seen = HashSet::new();
5007        let mut cursor = Some(leaf);
5008        while let Some(idx) = cursor {
5009            if !seen.insert(idx) {
5010                return Err(claude_replay_error(format!(
5011                    "cycle in active Claude parentUuid chain at `{}`",
5012                    self.nodes[idx].uuid
5013                )));
5014            }
5015            reversed.push(idx);
5016            cursor = match self.nodes[idx].parent_uuid.as_deref() {
5017                Some(parent) => match self.by_uuid.get(parent).copied() {
5018                    Some(parent) => Some(parent),
5019                    None if self.segment_anchors.contains(parent) => None,
5020                    // Claude can resume a background child in-place while
5021                    // retaining only the new segment in that child's JSONL.
5022                    // Its first record then points to a UUID not present in
5023                    // the sidechain file. That external edge is a segment
5024                    // boundary, not corruption; the complete source remains
5025                    // available byte-for-byte in `raw`.
5026                    None if sidechain_only => None,
5027                    None => {
5028                        return Err(claude_replay_error(format!(
5029                            "active Claude record `{}` has missing parentUuid `{parent}`",
5030                            self.nodes[idx].uuid
5031                        )));
5032                    }
5033                },
5034                None => None,
5035            };
5036            if cursor.is_some_and(|parent| !retained[parent]) {
5037                if lenient {
5038                    // A compaction boundary is where this segment ends; the
5039                    // records it pruned stay pruned.
5040                    break;
5041                }
5042                return Err(claude_replay_error(format!(
5043                    "active Claude chain crosses an excluded compaction record from `{}`",
5044                    self.nodes[idx].uuid
5045                )));
5046            }
5047        }
5048        reversed.reverse();
5049
5050        // Include non-conversation descendants rooted at the segment's leaf
5051        // (turn_duration, attachments, etc.), matching Claude's QYH/n1T.
5052        let mut descendants = Vec::new();
5053        let mut frontier = vec![leaf];
5054        let mut head = 0;
5055        while head < frontier.len() {
5056            let parent = frontier[head];
5057            head += 1;
5058            for (idx, node) in self.nodes.iter().enumerate() {
5059                if !retained[idx]
5060                    || node.kind.is_conversation()
5061                    || seen.contains(&idx)
5062                    || node.parent_uuid.as_deref() != Some(self.nodes[parent].uuid.as_str())
5063                {
5064                    continue;
5065                }
5066                seen.insert(idx);
5067                descendants.push(idx);
5068                frontier.push(idx);
5069            }
5070        }
5071        descendants.sort_by_key(|idx| self.nodes[*idx].line_index);
5072        reversed.extend(descendants);
5073        Ok(reversed)
5074    }
5075
5076    /// The newest retained conversation record of every component the active
5077    /// leaf's own component cannot reach.
5078    ///
5079    /// Only a severed graph produces any: a healthy transcript is one
5080    /// component, so the abandoned branches a rewind left behind stay
5081    /// abandoned here exactly as they do under strict projection.
5082    fn severed_segment_leaves(&self, active: usize, retained: &[bool]) -> Vec<usize> {
5083        let active_root = self.component_root(active, retained);
5084        let mut newest_by_root: BTreeMap<usize, usize> = BTreeMap::new();
5085        for idx in 0..self.nodes.len() {
5086            if !retained[idx] || !self.nodes[idx].kind.is_conversation() {
5087                continue;
5088            }
5089            let Some(root) = self.component_root(idx, retained) else {
5090                continue;
5091            };
5092            if Some(root) == active_root {
5093                continue;
5094            }
5095            let newest = newest_by_root.entry(root).or_insert(idx);
5096            if self.nodes[idx].line_index > self.nodes[*newest].line_index {
5097                *newest = idx;
5098            }
5099        }
5100        newest_by_root.into_values().collect()
5101    }
5102
5103    /// Walk `idx` up to the record that anchors its component, stopping at a
5104    /// root, an edge that leaves the transcript, or a pruned parent. `None`
5105    /// when the walk cycles.
5106    fn component_root(&self, idx: usize, retained: &[bool]) -> Option<usize> {
5107        let mut cursor = idx;
5108        let mut seen = HashSet::new();
5109        loop {
5110            if !seen.insert(cursor) {
5111                return None;
5112            }
5113            let next = self.nodes[cursor]
5114                .parent_uuid
5115                .as_deref()
5116                .and_then(|parent| self.by_uuid.get(parent).copied())
5117                .filter(|parent| retained[*parent]);
5118            match next {
5119                Some(parent) => cursor = parent,
5120                None => return Some(cursor),
5121            }
5122        }
5123    }
5124
5125    fn apply_latest_compaction(
5126        &mut self,
5127        boundary_index: usize,
5128        retained: &mut [bool],
5129    ) -> Result<()> {
5130        let compact = self.nodes[boundary_index]
5131            .compact
5132            .clone()
5133            .expect("called with compact boundary");
5134        let mut preserved = compact.preserved_uuids;
5135        if preserved.is_empty() {
5136            if let Some((head, tail)) = compact.preserved_segment {
5137                preserved = self.walk_preserved_segment(&head, &tail)?;
5138            }
5139        }
5140
5141        let preserved_set: HashSet<String> = preserved.iter().cloned().collect();
5142        for uuid in &preserved {
5143            if !self.by_uuid.contains_key(uuid) {
5144                return Err(claude_replay_error(format!(
5145                    "latest compact boundary references missing preserved uuid `{uuid}`"
5146                )));
5147            }
5148        }
5149
5150        let removed_uuids: HashSet<String> = self
5151            .nodes
5152            .iter()
5153            .enumerate()
5154            .filter(|(idx, node)| *idx < boundary_index && !preserved_set.contains(&node.uuid))
5155            .map(|(_, node)| node.uuid.clone())
5156            .collect();
5157        for (idx, node) in self.nodes.iter().enumerate() {
5158            if idx < boundary_index && !preserved_set.contains(&node.uuid) {
5159                retained[idx] = false;
5160            }
5161        }
5162
5163        if preserved.is_empty() {
5164            return Ok(());
5165        }
5166        let anchor = compact.anchor_uuid.ok_or_else(|| {
5167            claude_replay_error("preserved compact boundary is missing anchorUuid")
5168        })?;
5169        if !self.by_uuid.contains_key(&anchor) {
5170            return Err(claude_replay_error(format!(
5171                "latest compact boundary references missing anchor uuid `{anchor}`"
5172            )));
5173        }
5174        let tail = preserved.last().cloned().expect("non-empty preserved list");
5175        let mut parent = anchor.clone();
5176        for uuid in &preserved {
5177            let idx = self.by_uuid[uuid];
5178            self.nodes[idx].parent_uuid = Some(parent);
5179            parent = uuid.clone();
5180        }
5181        let first = &preserved[0];
5182        for node in &mut self.nodes {
5183            if node.parent_uuid.as_deref() == Some(anchor.as_str()) && node.uuid != *first {
5184                node.parent_uuid = Some(tail.clone());
5185            }
5186        }
5187        for node in &mut self.nodes {
5188            if node.kind.is_conversation()
5189                && node
5190                    .parent_uuid
5191                    .as_ref()
5192                    .is_some_and(|parent| removed_uuids.contains(parent))
5193            {
5194                node.parent_uuid = Some(tail.clone());
5195            }
5196        }
5197        Ok(())
5198    }
5199
5200    fn walk_preserved_segment(&self, head: &str, tail: &str) -> Result<Vec<String>> {
5201        let mut reversed = Vec::new();
5202        let mut seen = HashSet::new();
5203        let mut cursor = tail;
5204        loop {
5205            if !seen.insert(cursor.to_string()) {
5206                return Err(claude_replay_error("cycle in compact preservedSegment"));
5207            }
5208            let idx = *self.by_uuid.get(cursor).ok_or_else(|| {
5209                claude_replay_error(format!(
5210                    "compact preservedSegment references missing uuid `{cursor}`"
5211                ))
5212            })?;
5213            reversed.push(cursor.to_string());
5214            if cursor == head {
5215                reversed.reverse();
5216                return Ok(reversed);
5217            }
5218            cursor = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
5219                claude_replay_error(format!(
5220                    "compact preservedSegment tail `{tail}` does not reach head `{head}`"
5221                ))
5222            })?;
5223        }
5224    }
5225
5226    fn parent_index(&self, idx: usize, retained: &[bool]) -> Result<usize> {
5227        let parent = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
5228            claude_replay_error(format!(
5229                "Claude record `{}` has no conversational ancestor",
5230                self.nodes[idx].uuid
5231            ))
5232        })?;
5233        let parent_idx = *self.by_uuid.get(parent).ok_or_else(|| {
5234            claude_replay_error(format!(
5235                "Claude record `{}` has missing parentUuid `{parent}`",
5236                self.nodes[idx].uuid
5237            ))
5238        })?;
5239        if !retained[parent_idx] {
5240            return Err(claude_replay_error(format!(
5241                "Claude record `{}` points into compacted-out history",
5242                self.nodes[idx].uuid
5243            )));
5244        }
5245        Ok(parent_idx)
5246    }
5247
5248    fn recover_parallel_assistant_chunks(
5249        &self,
5250        base: Vec<usize>,
5251        retained: &[bool],
5252    ) -> Result<Vec<usize>> {
5253        let selected: HashSet<usize> = base.iter().copied().collect();
5254        let mut replacements: HashMap<usize, Vec<usize>> = HashMap::new();
5255        let mut skipped_positions = HashSet::new();
5256        let mut handled_ids = HashSet::new();
5257
5258        for (base_pos, idx) in base.iter().copied().enumerate() {
5259            let Some(message_id) = self.nodes[idx].assistant_message_id.as_deref() else {
5260                continue;
5261            };
5262            if !handled_ids.insert(message_id.to_string()) {
5263                continue;
5264            }
5265            let base_positions: Vec<usize> = base
5266                .iter()
5267                .enumerate()
5268                .filter(|(_, candidate)| {
5269                    self.nodes[**candidate].assistant_message_id.as_deref() == Some(message_id)
5270                })
5271                .map(|(pos, _)| pos)
5272                .collect();
5273            let anchor_pos = base_positions.first().copied().unwrap_or(base_pos);
5274            skipped_positions.extend(base_positions.iter().copied().skip(1));
5275
5276            // A streamed Anthropic response can be stored as sibling records
5277            // rather than a literal parent chain. Reassemble every chunk at
5278            // the first active occurrence and restore raw chunk order before
5279            // the normalizer coalesces their content blocks.
5280            let mut chunks: Vec<usize> = self
5281                .nodes
5282                .iter()
5283                .enumerate()
5284                .filter(|(candidate, node)| {
5285                    retained[*candidate] && node.assistant_message_id.as_deref() == Some(message_id)
5286                })
5287                .map(|(candidate, _)| candidate)
5288                .collect();
5289            chunks.sort_by_key(|candidate| self.nodes[*candidate].line_index);
5290
5291            let assistant_uuids: HashSet<&str> = self
5292                .nodes
5293                .iter()
5294                .filter(|node| node.assistant_message_id.as_deref() == Some(message_id))
5295                .map(|node| node.uuid.as_str())
5296                .collect();
5297            let mut results: Vec<usize> = self
5298                .nodes
5299                .iter()
5300                .enumerate()
5301                .filter(|(candidate, node)| {
5302                    retained[*candidate]
5303                        && !selected.contains(candidate)
5304                        && node.is_tool_result
5305                        && node
5306                            .parent_uuid
5307                            .as_deref()
5308                            .is_some_and(|parent| assistant_uuids.contains(parent))
5309                })
5310                .map(|(candidate, _)| candidate)
5311                .collect();
5312            results.sort_by_key(|candidate| self.nodes[*candidate].line_index);
5313            chunks.extend(results);
5314            replacements.insert(anchor_pos, chunks);
5315        }
5316
5317        let mut out = Vec::with_capacity(selected.len());
5318        for (pos, idx) in base.into_iter().enumerate() {
5319            if let Some(replacement) = replacements.remove(&pos) {
5320                out.extend(replacement);
5321            } else if !skipped_positions.contains(&pos) {
5322                out.push(idx);
5323            }
5324        }
5325        Ok(out)
5326    }
5327}
5328
5329impl ClaudeCompactBoundary {
5330    fn from_value(v: &Value) -> Self {
5331        let metadata = v.get("compactMetadata");
5332        let preserved_messages = metadata.and_then(|m| m.get("preservedMessages"));
5333        let anchor_uuid = preserved_messages
5334            .and_then(|p| p.get("anchorUuid"))
5335            .and_then(Value::as_str)
5336            .or_else(|| {
5337                metadata
5338                    .and_then(|m| m.get("preservedSegment"))
5339                    .and_then(|p| p.get("anchorUuid"))
5340                    .and_then(Value::as_str)
5341            })
5342            .map(str::to_string);
5343        let preserved_uuids = preserved_messages
5344            .and_then(|p| p.get("uuids"))
5345            .and_then(Value::as_array)
5346            .map(|uuids| {
5347                uuids
5348                    .iter()
5349                    .filter_map(Value::as_str)
5350                    .map(str::to_string)
5351                    .collect()
5352            })
5353            .unwrap_or_default();
5354        let preserved_segment =
5355            metadata
5356                .and_then(|m| m.get("preservedSegment"))
5357                .and_then(|segment| {
5358                    Some((
5359                        segment.get("headUuid")?.as_str()?.to_string(),
5360                        segment.get("tailUuid")?.as_str()?.to_string(),
5361                    ))
5362                });
5363        Self {
5364            anchor_uuid,
5365            preserved_uuids,
5366            preserved_segment,
5367        }
5368    }
5369}
5370
5371fn claude_replay_error(message: impl Into<String>) -> crate::Error {
5372    crate::Error::Other(format!(
5373        "cannot reconstruct lossless Claude continuation: {}",
5374        message.into()
5375    ))
5376}
5377
5378fn claude_assistant_message_id(v: &Value) -> Option<&str> {
5379    v.get("message")
5380        .and_then(|message| message.get("id"))
5381        .and_then(Value::as_str)
5382}
5383
5384fn merge_claude_assistant_chunk(target: &mut Value, chunk: &Value) {
5385    let Some(target_message) = target.get_mut("message") else {
5386        return;
5387    };
5388    let Some(chunk_message) = chunk.get("message") else {
5389        return;
5390    };
5391    let mut content = target_message
5392        .get("content")
5393        .and_then(Value::as_array)
5394        .cloned()
5395        .unwrap_or_default();
5396    if let Some(blocks) = chunk_message.get("content").and_then(Value::as_array) {
5397        content.extend(blocks.iter().cloned());
5398    }
5399    let mut merged_message = chunk_message.clone();
5400    merged_message["content"] = Value::Array(content);
5401    *target_message = merged_message;
5402}
5403
5404fn flush_claude_assistant(pending: &mut Option<Value>, out: &mut Vec<ChatMessage>) {
5405    let Some(v) = pending.take() else {
5406        return;
5407    };
5408    let reasoning_only = claude_assistant_message_id(&v).is_some()
5409        && v.get("message")
5410            .and_then(|message| message.get("content"))
5411            .and_then(Value::as_array)
5412            .is_some_and(|blocks| {
5413                !blocks.is_empty()
5414                    && blocks.iter().all(|block| {
5415                        matches!(
5416                            block.get("type").and_then(Value::as_str),
5417                            Some("thinking" | "redacted_thinking")
5418                        )
5419                    })
5420            });
5421    if reasoning_only {
5422        return;
5423    }
5424    let before = out.len();
5425    push_claude_assistant(&v, out);
5426    capture_claude_record_provenance(&v, &mut out[before..]);
5427    restore_single_grok_message(&v, &mut out[before..]);
5428}
5429
5430/// Attach the record identity, clock, and actual assistant model to every
5431/// canonical message produced from one Claude JSONL record. These fields are
5432/// deliberately per-message: a continued transcript can cross a provider
5433/// boundary, so the session-level source model is not authoritative for its
5434/// appended tail.
5435fn capture_claude_record_provenance(v: &Value, messages: &mut [ChatMessage]) {
5436    let timestamp = v.get("timestamp").and_then(Value::as_str);
5437    let uuid = v.get("uuid").and_then(Value::as_str);
5438    let model = v
5439        .get("message")
5440        .and_then(|message| message.get("model"))
5441        .and_then(Value::as_str);
5442    for message in messages {
5443        if let Some(timestamp) = timestamp {
5444            message
5445                .metadata
5446                .entry("timestamp".to_string())
5447                .or_insert_with(|| timestamp.to_string());
5448        }
5449        if let Some(uuid) = uuid {
5450            message
5451                .metadata
5452                .entry("claude_uuid".to_string())
5453                .or_insert_with(|| uuid.to_string());
5454        }
5455        if let Some(model) = model {
5456            message
5457                .metadata
5458                .entry("model".to_string())
5459                .or_insert_with(|| model.to_string());
5460        }
5461    }
5462}
5463
5464fn capture_claude_meta(v: &Value, meta: &mut SessionMeta, raw_line: &str) -> Result<()> {
5465    restore_codex_provenance_from_top_level(v, meta)?;
5466    if meta.session_id.is_none() {
5467        if let Some(id) = v.get("sessionId").and_then(Value::as_str) {
5468            meta.session_id = Some(id.to_string());
5469        }
5470    }
5471    if meta.cwd.is_none() {
5472        if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
5473            meta.cwd = Some(PathBuf::from(cwd));
5474        }
5475    }
5476    if meta.model.is_none() {
5477        if let Some(model) = v
5478            .get("message")
5479            .and_then(|m| m.get("model"))
5480            .and_then(Value::as_str)
5481        {
5482            meta.model = Some(model.to_string());
5483        }
5484    }
5485    // PARITY-10 (provenance P010): `fork-context-ref` is an extremely rare
5486    // real Claude Code record with no confirmed field shape (see
5487    // `ClaudeRecord::ForkContextRef`'s doc comment) — rather than guess at
5488    // named fields and risk silently mis-modeling it, stash the WHOLE raw
5489    // line verbatim under a lineage key. `write_claude_code_records` (below)
5490    // re-emits it byte-for-byte, so the record survives the Claude Code
5491    // semantic writer (not just the CLI's raw-passthrough diagonal path) —
5492    // and `to_codex_jsonl`'s synthesized header carries a namespaced copy
5493    // so a Claude -> Codex -> Claude round trip can still reconstruct it
5494    // (dev/03). A session can only fork from one context, so the first one
5495    // seen wins, matching every other "first wins" field above.
5496    //
5497    // D4 (Fable-5 review, confirmed): this used to store `v.to_string()` —
5498    // a RE-SERIALIZATION of the parsed `Value`, not the original source
5499    // text. `serde_json::Value` here has no `preserve_order` feature (see
5500    // `Cargo.toml`), so its object keys are a `BTreeMap` and get silently
5501    // REORDERED (alphabetized) on re-emit — the "byte-for-byte" claim in
5502    // this very comment was false. Fixed the cheap+honest way: store the
5503    // caller's own already-verbatim source `raw_line` text instead of
5504    // re-serializing `v` at all, so this is now ACTUALLY byte-for-byte
5505    // (key order, spacing, everything) rather than merely
5506    // structurally-equivalent JSON.
5507    if v.get("type").and_then(Value::as_str) == Some("fork-context-ref")
5508        && !meta.lineage.contains_key("claude_fork_context_ref_raw")
5509    {
5510        meta.lineage.insert(
5511            "claude_fork_context_ref_raw".to_string(),
5512            raw_line.to_string(),
5513        );
5514    }
5515    Ok(())
5516}
5517
5518fn push_claude_user(v: &Value, out: &mut Vec<ChatMessage>) {
5519    let content = v.get("message").and_then(|m| m.get("content"));
5520    let provenance = claude_user_provenance(v);
5521    match content {
5522        Some(Value::String(s)) => {
5523            if !s.trim().is_empty() {
5524                out.push(ChatMessage::user(s.clone()).with_metas(&provenance));
5525            }
5526        }
5527        Some(Value::Array(blocks)) => {
5528            let mut text = String::new();
5529            // IX-5: image blocks alongside/instead of text — collected
5530            // separately (never synthesized on a malformed shape, see
5531            // `claude_image_block_to_part`) so a multimodal user turn
5532            // survives as `content_parts` instead of the image silently
5533            // vanishing.
5534            let mut images: Vec<Value> = Vec::new();
5535            // D5: an `image` block whose `source` isn't base64/url (e.g. a
5536            // Files-API `{"source":{"type":"file","file_id":..}}`
5537            // reference) makes `claude_image_block_to_part` return `None` —
5538            // track that it was SEEN even though it couldn't be converted,
5539            // so an image-ONLY record (no text, no convertible image) isn't
5540            // silently dropped below (the same vanishing-record bug-class
5541            // PARITY-11 fixed for reasoning-only turns).
5542            let mut saw_unconvertible_image = false;
5543            for b in blocks {
5544                match b.get("type").and_then(Value::as_str) {
5545                    Some("text") => push_text(&mut text, b.get("text")),
5546                    Some("tool_result") => {
5547                        let id = b
5548                            .get("tool_use_id")
5549                            .and_then(Value::as_str)
5550                            .unwrap_or_default();
5551                        // PARITY-11 (nested images): `extract_tool_result_content`
5552                        // captures any `image` blocks nested inside this
5553                        // `tool_result` into `content_parts` (via
5554                        // `claude_image_block_to_part`, the same conversion the
5555                        // top-level `image` block path already uses) instead of
5556                        // flattening them to the bare `[image]` marker text the
5557                        // old `extract_tool_result` emitted — the everyday
5558                        // "Read a PNG / screenshot tool output" shape.
5559                        let (result, images) =
5560                            extract_tool_result_content(b.get("content"), v.get("toolUseResult"));
5561                        let mut msg = tool_message(id, result);
5562                        if !images.is_empty() {
5563                            // D-mix (Fable review, must-fix): `content_parts`
5564                            // is a self-contained contract — the pi writer
5565                            // (`pi_content_value`) reads ONLY `content_parts`
5566                            // for a `Role::Tool` message and never falls back
5567                            // to `msg.content`, so on a MIXED text+image
5568                            // tool_result a bare `content_parts: [image]`
5569                            // silently drops the sibling text on `convert
5570                            // --to pi` (a regression vs. the pre-PARITY-11
5571                            // baseline, which at least preserved the text).
5572                            // Prepend the text as part 0, exactly mirroring
5573                            // `pi_content_to_text_and_parts` and
5574                            // `push_opencode_user`'s identical
5575                            // self-contained-parts construction. `msg.content`
5576                            // keeps the text too (unchanged) for the writers
5577                            // that read text from `msg.content` and only scan
5578                            // `content_parts` for `image_url` entries
5579                            // (`claude_tool_result_content_value`,
5580                            // `codex_tool_output_text`, the opencode
5581                            // assistant writer) — those already filter
5582                            // strictly on `image_url`/text-typed lookups, so
5583                            // this text part is never double-counted.
5584                            let mut parts = Vec::new();
5585                            if let Some(t) = &msg.content {
5586                                if !t.is_empty() {
5587                                    parts.push(serde_json::json!({"type": "text", "text": t}));
5588                                }
5589                            }
5590                            parts.extend(images);
5591                            msg.content_parts = Some(parts);
5592                        }
5593                        // The assistant turn that issued this tool call — the
5594                        // tool-pairing graph edge (parallel to parentUuid).
5595                        if let Some(src) = v.get("sourceToolAssistantUUID").and_then(Value::as_str)
5596                        {
5597                            msg.metadata
5598                                .insert("sourceToolAssistantUUID".to_string(), src.to_string());
5599                        }
5600                        // TR-10: preserve the Claude wire `is_error` flag so
5601                        // the reduction layer's success/failure boundary
5602                        // (`ReductionKind::ToolInputElided` must never target
5603                        // an errored call) survives import — `ChatMessage`
5604                        // otherwise has no structural slot for it.
5605                        if b.get("is_error").and_then(Value::as_bool) == Some(true) {
5606                            crate::mark_tool_error(&mut msg);
5607                        } else {
5608                            restore_tool_outcome_extension(v, &mut msg);
5609                        }
5610                        out.push(msg);
5611                    }
5612                    Some("image") => match claude_image_block_to_part(b) {
5613                        Some(part) => images.push(part),
5614                        None => saw_unconvertible_image = true,
5615                    },
5616                    _ => {} // document / unknown — skip
5617                }
5618            }
5619            // D5: nothing convertible landed in `text`/`images` but an
5620            // image block WAS present — fold in the same short bracketed
5621            // marker convention already used for `[web_search]`/`[model
5622            // fallback: ...]` rather than letting the record vanish.
5623            if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
5624                push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
5625            }
5626            let before = out.len();
5627            if !images.is_empty() {
5628                let mut parts = Vec::new();
5629                if !text.trim().is_empty() {
5630                    parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
5631                }
5632                parts.extend(images);
5633                out.push(
5634                    ChatMessage {
5635                        role: Role::User,
5636                        content: None,
5637                        content_parts: Some(parts),
5638                        tool_calls: None,
5639                        tool_call_id: None,
5640                        name: None,
5641                        metadata: Default::default(),
5642                    }
5643                    .with_metas(&provenance),
5644                );
5645            } else if !text.trim().is_empty() {
5646                out.push(ChatMessage::user(text).with_metas(&provenance));
5647            }
5648            if saw_unconvertible_image && out.len() > before {
5649                if let Some(msg) = out.last_mut() {
5650                    msg.metadata
5651                        .insert("image_source_unconvertible".to_string(), "true".to_string());
5652                }
5653            }
5654        }
5655        _ => {}
5656    }
5657}
5658
5659/// D5 (Fable-5 review): the bracketed marker folded into `text` when an
5660/// `image` block is SEEN but [`claude_image_block_to_part`] can't convert it
5661/// (e.g. a Files-API `{"source":{"type":"file",...}}` reference) and nothing
5662/// else in the record survives either — matches the existing
5663/// `[web_search]`/`[model fallback: ...]` convention rather than letting the
5664/// whole record vanish (`push_claude_user`/`push_claude_assistant`).
5665const UNCONVERTIBLE_IMAGE_MARKER: &str =
5666    "[image: source not captured — unsupported/unconvertible image reference]";
5667
5668/// Parse a Claude Code user-turn `image` content block
5669/// (`{"type":"image","source":{"type":"base64","media_type":..,"data":..}}`
5670/// or `{"type":"image","source":{"type":"url","url":..}}`) into a
5671/// `content_parts` `image_url` entry (a `data:` URI for the base64 form, the
5672/// bare URL for the url form) — the inverse of
5673/// [`claude_user_content_value`]'s emission. Only a well-formed source
5674/// (non-empty `media_type`/`data`, or non-empty `url`) is recognized;
5675/// anything else — including a well-formed but unconvertible source like a
5676/// Files-API `{"type":"file",...}` reference (D5) — is left as raw-only
5677/// residue rather than synthesizing a corrupt/empty part (mirrors the
5678/// pi/opencode loaders' `pi_image_shape`/`opencode_file_image_part`
5679/// discipline). Callers must not let that turn the record invisible though:
5680/// see [`UNCONVERTIBLE_IMAGE_MARKER`].
5681fn claude_image_block_to_part(b: &Value) -> Option<Value> {
5682    let source = b.get("source")?;
5683    match source.get("type").and_then(Value::as_str) {
5684        Some("base64") => {
5685            let mime = source.get("media_type").and_then(Value::as_str)?;
5686            let data = source.get("data").and_then(Value::as_str)?;
5687            if mime.is_empty() || data.is_empty() {
5688                return None;
5689            }
5690            Some(serde_json::json!({
5691                "type": "image_url",
5692                "image_url": {"url": format!("data:{mime};base64,{data}")},
5693            }))
5694        }
5695        Some("url") => {
5696            let url = source.get("url").and_then(Value::as_str)?;
5697            if url.is_empty() {
5698                return None;
5699            }
5700            Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
5701        }
5702        _ => None,
5703    }
5704}
5705
5706/// Rebuild a Claude Code user-turn `message.content` value from a
5707/// `ChatMessage` — the inverse of `push_claude_user`'s parse (text blocks +
5708/// [`claude_image_block_to_part`]). When `content_parts` is absent this
5709/// MUST reproduce the historical plain-string `content` exactly (IX-5's
5710/// overriding constraint: a text-only message's export stays byte-identical)
5711/// — only a multimodal message (`content_parts` present, e.g. imported from
5712/// Pi/OpenCode or a real Claude Code image turn) gets the Anthropic
5713/// content-array shape, one `text` block (if any non-empty text part) plus
5714/// one `image` block per `image_url` part (`data:` URI → `source.base64`;
5715/// any other URL → `source.url`).
5716fn claude_user_content_value(msg: &ChatMessage) -> Value {
5717    match &msg.content_parts {
5718        Some(parts) => {
5719            let mut blocks = Vec::new();
5720            for p in parts {
5721                match p.get("type").and_then(Value::as_str) {
5722                    Some("text") => {
5723                        if let Some(t) = p.get("text").and_then(Value::as_str) {
5724                            if !t.is_empty() {
5725                                blocks.push(serde_json::json!({"type": "text", "text": t}));
5726                            }
5727                        }
5728                    }
5729                    Some("image_url") => {
5730                        if let Some(url) = p
5731                            .get("image_url")
5732                            .and_then(|u| u.get("url"))
5733                            .and_then(Value::as_str)
5734                        {
5735                            blocks.push(match parse_data_uri(url) {
5736                                Some((mime, data)) => serde_json::json!({
5737                                    "type": "image",
5738                                    "source": {"type": "base64", "media_type": mime, "data": data},
5739                                }),
5740                                None => serde_json::json!({
5741                                    "type": "image",
5742                                    "source": {"type": "url", "url": url},
5743                                }),
5744                            });
5745                        }
5746                    }
5747                    _ => {}
5748                }
5749            }
5750            Value::Array(blocks)
5751        }
5752        None => Value::String(msg.content.clone().unwrap_or_default()),
5753    }
5754}
5755
5756/// Rebuild a Claude Code `tool_result` block's `content` value from a `Tool`
5757/// `ChatMessage` — the inverse of [`extract_tool_result_content`]'s parse
5758/// (PARITY-11). When `content_parts` is absent (or empty) this MUST reproduce
5759/// the historical plain-string `content` exactly (same IX-5-style constraint
5760/// `claude_user_content_value` follows) — only a `tool_result` that actually
5761/// carries a captured nested image gets the Anthropic content-array shape,
5762/// one `text` block (the existing `msg.content`, if any) plus one `image`
5763/// block per `image_url` part (mirrors `claude_user_content_value`'s
5764/// `data:` URI -> `source.base64` / other URL -> `source.url` mapping).
5765fn claude_tool_result_content_value(msg: &ChatMessage) -> Value {
5766    match &msg.content_parts {
5767        Some(parts) if !parts.is_empty() => {
5768            let mut blocks = Vec::new();
5769            if let Some(t) = &msg.content {
5770                if !t.is_empty() {
5771                    blocks.push(serde_json::json!({"type": "text", "text": t}));
5772                }
5773            }
5774            for p in parts {
5775                if p.get("type").and_then(Value::as_str) == Some("image_url") {
5776                    if let Some(url) = p
5777                        .get("image_url")
5778                        .and_then(|u| u.get("url"))
5779                        .and_then(Value::as_str)
5780                    {
5781                        blocks.push(match parse_data_uri(url) {
5782                            Some((mime, data)) => serde_json::json!({
5783                                "type": "image",
5784                                "source": {"type": "base64", "media_type": mime, "data": data},
5785                            }),
5786                            None => serde_json::json!({
5787                                "type": "image",
5788                                "source": {"type": "url", "url": url},
5789                            }),
5790                        });
5791                    }
5792                }
5793            }
5794            Value::Array(blocks)
5795        }
5796        _ => Value::String(msg.content.clone().unwrap_or_default()),
5797    }
5798}
5799
5800/// Collect the Claude Code user-turn provenance fields that distinguish real
5801/// human input from system-injected turns and record replay-relevant state.
5802pub(crate) fn claude_user_provenance(v: &Value) -> Vec<(String, String)> {
5803    let mut out = Vec::new();
5804    let mut take_str = |key: &str| {
5805        if let Some(s) = v.get(key).and_then(Value::as_str) {
5806            out.push((key.to_string(), s.to_string()));
5807        }
5808    };
5809    take_str("promptSource"); // typed | queued | system | sdk
5810    take_str("interruptedMessageId");
5811    take_str("sourceToolUseID");
5812    for flag in ["isMeta", "isCompactSummary", "isVisibleInTranscriptOnly"] {
5813        if v.get(flag).and_then(Value::as_bool) == Some(true) {
5814            out.push((flag.to_string(), "true".to_string()));
5815        }
5816    }
5817    if let Some(n) = v.get("queuePriority").and_then(Value::as_i64) {
5818        out.push(("queuePriority".to_string(), n.to_string()));
5819    }
5820    // `origin` is an object like {"kind":"task-notification"} — keep its kind.
5821    if let Some(kind) = v
5822        .get("origin")
5823        .and_then(|o| o.get("kind"))
5824        .and_then(Value::as_str)
5825    {
5826        out.push(("origin".to_string(), kind.to_string()));
5827    }
5828    out
5829}
5830
5831/// Content-bearing Claude `system` events (`scheduled_task_fire`,
5832/// `local_command`, `away_summary`) carry real text that's part of the
5833/// interaction; fold them in as system context. Marker/metric subtypes
5834/// (`turn_duration`, `compact_boundary`, `api_error`, `stop_hook_summary`) carry
5835/// no conversational content and are skipped.
5836fn push_claude_system(v: &Value, out: &mut Vec<ChatMessage>) {
5837    let keep = matches!(
5838        v.get("subtype").and_then(Value::as_str),
5839        Some("scheduled_task_fire") | Some("local_command") | Some("away_summary")
5840    );
5841    if !keep {
5842        return;
5843    }
5844    if let Some(content) = v.get("content").and_then(Value::as_str) {
5845        if !content.trim().is_empty() {
5846            let subtype = v.get("subtype").and_then(Value::as_str).unwrap_or("system");
5847            out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
5848        }
5849    }
5850}
5851
5852/// Fold content-bearing Claude Code `attachment` records into the conversation
5853/// as user-role messages. Most attachment subtypes (`task_reminder`,
5854/// `deferred_tools_delta`, `skill_listing`, `hook_*`, `command_permissions`, …)
5855/// are regenerable system injections and are skipped; only the four that carry
5856/// non-regenerable user/external content are kept.
5857fn push_claude_attachment(v: &Value, out: &mut Vec<ChatMessage>) {
5858    let att = match v.get("attachment") {
5859        Some(a) => a,
5860        None => return,
5861    };
5862    let kind = match att.get("type").and_then(Value::as_str) {
5863        Some(kind) => kind,
5864        None => return,
5865    };
5866    let text = match kind {
5867        // A queued prompt. `commandMode` says whose: `prompt` is the person's
5868        // own text, `task-notification` is the runtime reporting a finished
5869        // background task. Kept verbatim below.
5870        "queued_command" => att
5871            .get("prompt")
5872            .and_then(Value::as_str)
5873            .map(str::to_string),
5874        // A file the user attached: header + contents.
5875        "file" => attachment_with_path(att, "attached file", "filename", "content"),
5876        // A user-edited file snippet.
5877        "edited_text_file" => attachment_with_path(att, "edited file", "filename", "snippet"),
5878        // Injected project memory (CLAUDE.md), point-in-time.
5879        "nested_memory" => attachment_with_path(att, "project memory", "path", "content"),
5880        _ => None, // regenerable system injection — skip
5881    };
5882    let Some(text) = text else { return };
5883    if text.trim().is_empty() {
5884        return;
5885    }
5886    // An attachment record wears the user's ROLE, but the record itself says
5887    // who actually spoke — and that fact is lost the moment the attachment is
5888    // flattened to `[label: path]` text, so carry it as metadata the way
5889    // `push_claude_system` carries `systemSubtype`. Two fields, both verbatim:
5890    //
5891    //   `attachmentType`  the subtype. `file` / `edited_text_file` /
5892    //                     `nested_memory` are envelopes the runtime built
5893    //                     around a file body; a frontend that trusts the role
5894    //                     shows the reader a numbered source listing in a
5895    //                     chat bubble apparently sent by themselves.
5896    //   `commandMode`     present on `queued_command` only, and the whole
5897    //                     story for it. Measured over the local Claude Code
5898    //                     corpus (2,512 `queued_command` attachments): 926
5899    //                     `prompt`, every one of them plain human text, and
5900    //                     1,586 `task-notification`, every one of them a
5901    //                     `<task-notification>` frame — the same text Claude
5902    //                     Code also writes as a `type:"user"` record stamped
5903    //                     `origin.kind = "task-notification"`.
5904    //
5905    // Presentation policy (which of these a frontend hides) belongs to the
5906    // frontend — `isContextMessage` in `sdk/client/client.mjs`. The loader's
5907    // job is to stop discarding the producer's own answer.
5908    let mut message = ChatMessage::user(text).with_meta("attachmentType", kind);
5909    if let Some(mode) = att.get("commandMode").and_then(Value::as_str) {
5910        message = message.with_meta("commandMode", mode);
5911    }
5912    out.push(message);
5913}
5914
5915/// Format an attachment as `[<label>: <path>]\n<body>`.
5916fn attachment_with_path(
5917    att: &Value,
5918    label: &str,
5919    path_key: &str,
5920    body_key: &str,
5921) -> Option<String> {
5922    let body = att.get(body_key).and_then(Value::as_str)?;
5923    let path = att
5924        .get(path_key)
5925        .or_else(|| att.get("displayPath"))
5926        .and_then(Value::as_str)
5927        .unwrap_or("");
5928    Some(format!("[{label}: {path}]\n{body}"))
5929}
5930
5931fn push_str_field(buf: &mut String, s: &str) {
5932    if !buf.is_empty() {
5933        buf.push('\n');
5934    }
5935    buf.push_str(s);
5936}
5937
5938/// N3: build a synthesized message for reasoning that could not attach to a
5939/// following assistant turn — either interrupted mid-stream by a
5940/// non-assistant item, or dangling at EOF (an aborted-turn shape). Drains
5941/// the three pending buffers (all empty/`false` afterward) so callers don't
5942/// separately have to remember to clear them.
5943fn orphaned_reasoning_message(
5944    reasoning: &mut String,
5945    reasoning_content: &mut String,
5946    encrypted: &mut bool,
5947) -> ChatMessage {
5948    let mut msg = ChatMessage::system("[reasoning] (turn ended without a reply)".to_string());
5949    if !reasoning.is_empty() {
5950        msg = msg.with_meta("reasoning", std::mem::take(reasoning));
5951    }
5952    if !reasoning_content.is_empty() {
5953        msg = msg.with_meta("reasoning_content", std::mem::take(reasoning_content));
5954    }
5955    if *encrypted {
5956        msg = msg.with_meta("reasoning_encrypted", "true");
5957        *encrypted = false;
5958    }
5959    msg
5960}
5961
5962fn push_claude_assistant(v: &Value, out: &mut Vec<ChatMessage>) {
5963    let content = v.get("message").and_then(|m| m.get("content"));
5964    let mut text = String::new();
5965    let mut calls: Vec<ToolCall> = Vec::new();
5966    // Legacy singular fields — kept for backward compatibility with every
5967    // existing consumer of `metadata["thinking"]`/`["thinking_signature"]`/
5968    // `["redacted_thinking"]` (a concatenation of all thinking text, and the
5969    // LAST block's signature/data). D8 (Fable-5 review, confirmed): when a
5970    // message carries MULTIPLE `thinking` blocks, collapsing them down to
5971    // these singular fields silently drops every signature but the last
5972    // one's — a real Anthropic `thinking` block's `signature` cryptographically
5973    // covers ONLY that block's own text, so re-emitting block 1's text under
5974    // block 2's signature (or vice versa) produces a signature that will
5975    // never verify. `thinking_blocks` below is the fix: every block
5976    // preserved SEPARATELY, in order, each with its own (optional)
5977    // signature/data — the writer prefers it over the legacy fields
5978    // whenever present.
5979    let mut thinking = String::new();
5980    let mut signature: Option<String> = None;
5981    // PARITY-11: real Claude corpora also carry `redacted_thinking` and
5982    // `image` assistant blocks, and (rarely) a `fallback` model-routing
5983    // marker — none handled before, all silently vanishing (audit's own
5984    // census: 132,927 `thinking` / 67 `redacted_thinking` / 9 `image` / 4
5985    // `fallback` blocks in the reference corpus).
5986    //
5987    // D8: `redacted_thinking` is real data ONLY — never a fabricated
5988    // placeholder. The pre-fix code defaulted a missing `data` field to the
5989    // literal string `"<redacted>"`, which is indistinguishable from an
5990    // actual (if oddly-named) opaque payload on re-emit — a caller reading
5991    // it back has no way to tell "no data was ever captured" from "the
5992    // provider's own opaque blob happens to be the string `<redacted>`".
5993    // `redacted_thinking_seen` tracks block PRESENCE independently of
5994    // whether it had real data, so the reasoning-only-turn rescue below
5995    // still fires even when no block had a `data` field at all.
5996    let mut redacted_thinking: Option<String> = None;
5997    let mut redacted_thinking_seen = false;
5998    let mut images: Vec<Value> = Vec::new();
5999    // Real Anthropic `thinking` blocks very commonly carry an EMPTY
6000    // `thinking` string alongside a real `signature` (the summarized/
6001    // redacted-in-the-clear-but-replayable case) — `thinking.trim()` alone
6002    // would miss those, so track "a thinking block existed at all"
6003    // separately from whether it had visible text.
6004    let mut thinking_block_seen = false;
6005    // D8: every `thinking`/`redacted_thinking` block, preserved SEPARATELY
6006    // and IN ORDER — see the comment on `thinking`/`redacted_thinking`
6007    // above. Serialized as a single JSON-array metadata string
6008    // (`ChatMessage::metadata` is a flat string map) under
6009    // `"thinking_blocks"`.
6010    let mut thinking_blocks: Vec<Value> = Vec::new();
6011    // D5: mirrors `push_claude_user`'s tracking — an `image` block whose
6012    // source isn't base64/url (e.g. Files-API `{"type":"file",...}`) must
6013    // not silently vanish the whole record when nothing else survives.
6014    let mut saw_unconvertible_image = false;
6015
6016    match content {
6017        Some(Value::String(s)) => push_text(&mut text, Some(&Value::String(s.clone()))),
6018        Some(Value::Array(blocks)) => {
6019            for b in blocks {
6020                match b.get("type").and_then(Value::as_str) {
6021                    Some("text") => push_text(&mut text, b.get("text")),
6022                    Some("tool_use") => {
6023                        let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
6024                        let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
6025                        let args = b
6026                            .get("input")
6027                            .map(|i| i.to_string())
6028                            .unwrap_or_else(|| "{}".to_string());
6029                        calls.push(function_call(id, name, args));
6030                    }
6031                    // Thinking is not replayed across providers, but retain it in
6032                    // (skip-serialized) metadata so a same-model continuation can
6033                    // re-inject it. See P3.
6034                    Some("thinking") => {
6035                        thinking_block_seen = true;
6036                        let t = b.get("thinking").and_then(Value::as_str).unwrap_or("");
6037                        if !t.is_empty() {
6038                            push_str_field(&mut thinking, t); // legacy concatenated field
6039                        }
6040                        let sig = b.get("signature").and_then(Value::as_str);
6041                        if let Some(s) = sig {
6042                            signature = Some(s.to_string()); // legacy last-wins field
6043                        }
6044                        // D8: this block's OWN text + signature, not folded
6045                        // into the running concatenation above.
6046                        let mut block = serde_json::json!({"type": "thinking", "thinking": t});
6047                        if let Some(s) = sig {
6048                            block["signature"] = Value::String(s.to_string());
6049                        }
6050                        thinking_blocks.push(block);
6051                    }
6052                    // Anthropic's redacted reasoning: an opaque, provider-private
6053                    // payload (flagged content the API declines to show in the
6054                    // clear). Like `thinking`, it's not replayable, but the raw
6055                    // `data` is retained in metadata rather than silently
6056                    // vanishing — a same-model continuation can still replay it
6057                    // verbatim even though supercode never renders it.
6058                    Some("redacted_thinking") => {
6059                        redacted_thinking_seen = true;
6060                        let data = b.get("data").and_then(Value::as_str);
6061                        // D8: no fabricated fallback — `data` is only ever
6062                        // the real captured payload, or genuinely absent.
6063                        if let Some(d) = data {
6064                            redacted_thinking = Some(d.to_string()); // legacy last-wins field
6065                        }
6066                        let mut block = serde_json::json!({"type": "redacted_thinking"});
6067                        if let Some(d) = data {
6068                            block["data"] = Value::String(d.to_string());
6069                        }
6070                        thinking_blocks.push(block);
6071                    }
6072                    // An assistant-emitted image block (e.g. a generated
6073                    // image) — collected exactly like `push_claude_user`'s
6074                    // user-turn image handling (`claude_image_block_to_part`
6075                    // is role-general), so it survives as `content_parts`
6076                    // instead of vanishing.
6077                    Some("image") => match claude_image_block_to_part(b) {
6078                        Some(part) => images.push(part),
6079                        None => saw_unconvertible_image = true,
6080                    },
6081                    // A provider-routing note (real shape:
6082                    // `{"type":"fallback","from":{"model":..},"to":{"model":..}}`
6083                    // — a mid-generation model swap, e.g. an overloaded model
6084                    // falling back to another). Carries no replayable
6085                    // conversational content, but folding it into `text` as a
6086                    // short bracketed marker — the same convention the Codex
6087                    // loader already uses for `[web_search]`/
6088                    // `[image_generation] ...` — keeps it visible instead of
6089                    // silently vanishing, including the case where it's the
6090                    // ONLY block in the turn (see the reasoning-only-turn fix
6091                    // below: before this, that shape dropped the entire
6092                    // message).
6093                    Some("fallback") => {
6094                        let from = b
6095                            .get("from")
6096                            .and_then(|f| f.get("model"))
6097                            .and_then(Value::as_str)
6098                            .unwrap_or("?");
6099                        let to = b
6100                            .get("to")
6101                            .and_then(|t| t.get("model"))
6102                            .and_then(Value::as_str)
6103                            .unwrap_or("?");
6104                        push_str_field(&mut text, &format!("[model fallback: {from} -> {to}]"));
6105                    }
6106                    _ => {}
6107                }
6108            }
6109        }
6110        _ => {}
6111    }
6112
6113    // D5: nothing convertible landed in `text`/`images` but an image block
6114    // WAS present — fold in the same bracketed-marker convention `fallback`
6115    // uses above, so a genuinely image-only (unconvertible source) turn
6116    // doesn't vanish (mirrors `push_claude_user`'s identical fix).
6117    if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
6118        push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
6119    }
6120
6121    let before = out.len();
6122    if !images.is_empty() {
6123        let mut parts = Vec::new();
6124        if !text.trim().is_empty() {
6125            parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
6126        }
6127        parts.extend(images);
6128        out.push(ChatMessage {
6129            role: Role::Assistant,
6130            content: None,
6131            content_parts: Some(parts),
6132            tool_calls: (!calls.is_empty()).then_some(calls),
6133            tool_call_id: None,
6134            name: None,
6135            metadata: Default::default(),
6136        });
6137    } else {
6138        push_assistant(out, text, calls);
6139        // A recognized native assistant record remains transcript state even
6140        // when its content array is empty (for example, an interrupted model
6141        // turn). Force a bare message whenever `push_assistant` had nothing
6142        // to emit. This includes the reasoning-only case and also preserves
6143        // genuinely part-less records instead of silently changing turn
6144        // count/order during translation.
6145        if out.len() == before {
6146            let mut empty = ChatMessage {
6147                role: Role::Assistant,
6148                content: None,
6149                content_parts: None,
6150                tool_calls: None,
6151                tool_call_id: None,
6152                name: None,
6153                metadata: Default::default(),
6154            };
6155            if !thinking_block_seen && !redacted_thinking_seen {
6156                empty
6157                    .metadata
6158                    .insert("empty_assistant_record".to_string(), "true".to_string());
6159            }
6160            out.push(empty);
6161        }
6162    }
6163    // Attach retained reasoning + attribution to the message we just produced.
6164    if out.len() > before {
6165        if let Some(msg) = out.last_mut() {
6166            // Insert "thinking" (even as an empty string) whenever a
6167            // `thinking` block was actually seen, not just when it had
6168            // visible text — a real `thinking` block commonly carries an
6169            // empty `thinking` string alongside a real `signature` (the
6170            // summarized-away-but-still-replayable case), and the writer
6171            // below keys its re-emission decision off this metadata key's
6172            // PRESENCE, not its content.
6173            if thinking_block_seen {
6174                msg.metadata.insert("thinking".to_string(), thinking);
6175            }
6176            if let Some(sig) = signature {
6177                msg.metadata.insert("thinking_signature".to_string(), sig);
6178            }
6179            if let Some(rt) = redacted_thinking {
6180                msg.metadata.insert("redacted_thinking".to_string(), rt);
6181            }
6182            // D8: exact per-block re-emission list — every `thinking`/
6183            // `redacted_thinking` block preserved separately, in order, each
6184            // with its own (optional) signature/data. The writer prefers
6185            // this over the legacy singular fields above whenever present,
6186            // so a multi-block message round-trips losslessly instead of
6187            // collapsing to one block under one (now-unverifiable)
6188            // signature.
6189            if !thinking_blocks.is_empty() {
6190                msg.metadata.insert(
6191                    "thinking_blocks".to_string(),
6192                    Value::Array(thinking_blocks).to_string(),
6193                );
6194            }
6195            // D5: honest signal that this message contained an image block
6196            // whose source this loader couldn't convert — the actual image
6197            // content is NOT captured, only a marker/partial record.
6198            if saw_unconvertible_image {
6199                msg.metadata
6200                    .insert("image_source_unconvertible".to_string(), "true".to_string());
6201            }
6202            // Attribution: which skill / subagent / MCP server+tool produced
6203            // this turn, plus the model `slug`.
6204            for key in [
6205                "attributionSkill",
6206                "attributionAgent",
6207                "attributionMcpServer",
6208                "attributionMcpTool",
6209                "slug",
6210            ] {
6211                if let Some(s) = v.get(key).and_then(Value::as_str) {
6212                    msg.metadata.insert(key.to_string(), s.to_string());
6213                }
6214            }
6215        }
6216    }
6217}
6218
6219// ---- Codex ----------------------------------------------------------------
6220
6221const SUPERCODE_CODEX_PROVENANCE_KEY: &str = "_supercode_codex_provenance";
6222
6223fn codex_provenance_kind(record: &Value) -> Option<&str> {
6224    match record.get("type").and_then(Value::as_str) {
6225        Some("session_meta") => Some("session_meta"),
6226        Some("turn_context") => Some("turn_context"),
6227        Some("compacted") => Some("compacted"),
6228        Some("event_msg") => match record
6229            .get("payload")
6230            .and_then(|payload| payload.get("type"))
6231            .and_then(Value::as_str)
6232        {
6233            Some("thread_rolled_back") => Some("event_msg/thread_rolled_back"),
6234            Some("thread_goal_updated") => Some("event_msg/thread_goal_updated"),
6235            Some("entered_review_mode") => Some("event_msg/entered_review_mode"),
6236            Some("exited_review_mode") => Some("event_msg/exited_review_mode"),
6237            _ => None,
6238        },
6239        _ => None,
6240    }
6241}
6242
6243fn capture_codex_provenance_record(
6244    meta: &mut SessionMeta,
6245    record_index: usize,
6246    raw_line: &str,
6247    record: &Value,
6248) {
6249    let Some(kind) = codex_provenance_kind(record) else {
6250        return;
6251    };
6252    meta.codex_provenance.push(serde_json::json!({
6253        "record_index": record_index,
6254        "kind": kind,
6255        "raw": raw_line,
6256    }));
6257}
6258
6259fn codex_provenance_envelope(meta: &SessionMeta) -> Option<Value> {
6260    (!meta.codex_provenance.is_empty()).then(|| {
6261        serde_json::json!({
6262            "version": 1,
6263            "records": &meta.codex_provenance,
6264        })
6265    })
6266}
6267
6268fn restore_codex_provenance(extension: &Value, meta: &mut SessionMeta) -> Result<bool> {
6269    if extension.get("version").and_then(Value::as_u64) != Some(1) {
6270        return Err(Error::InvalidSession(
6271            "invalid portable Codex provenance: expected version 1".to_string(),
6272        ));
6273    }
6274    let Some(records) = extension.get("records").and_then(Value::as_array) else {
6275        return Err(Error::InvalidSession(
6276            "invalid portable Codex provenance: `records` must be an array".to_string(),
6277        ));
6278    };
6279    if records.is_empty() {
6280        return Err(Error::InvalidSession(
6281            "invalid portable Codex provenance: `records` must not be empty".to_string(),
6282        ));
6283    }
6284    let mut restored = Vec::with_capacity(records.len());
6285    for entry in records {
6286        let Some(_record_index) = entry.get("record_index").and_then(Value::as_u64) else {
6287            return Err(Error::InvalidSession(
6288                "invalid portable Codex provenance: record_index must be an integer".to_string(),
6289            ));
6290        };
6291        let Some(kind) = entry.get("kind").and_then(Value::as_str) else {
6292            return Err(Error::InvalidSession(
6293                "invalid portable Codex provenance: kind must be a string".to_string(),
6294            ));
6295        };
6296        let Some(raw) = entry.get("raw").and_then(Value::as_str) else {
6297            return Err(Error::InvalidSession(
6298                "invalid portable Codex provenance: raw must be a string".to_string(),
6299            ));
6300        };
6301        let Ok(record) = serde_json::from_str::<Value>(raw) else {
6302            return Err(Error::InvalidSession(
6303                "invalid portable Codex provenance: raw is not valid JSON".to_string(),
6304            ));
6305        };
6306        if codex_provenance_kind(&record) != Some(kind) {
6307            return Err(Error::InvalidSession(format!(
6308                "invalid portable Codex provenance: kind `{kind}` does not match raw record"
6309            )));
6310        }
6311        restored.push(entry.clone());
6312    }
6313    meta.codex_provenance = restored;
6314    meta.codex_headers.clear();
6315    for entry in &meta.codex_provenance {
6316        let Some(raw) = entry.get("raw").and_then(Value::as_str) else {
6317            continue;
6318        };
6319        let Ok(record) = serde_json::from_str::<Value>(raw) else {
6320            continue;
6321        };
6322        if matches!(
6323            record.get("type").and_then(Value::as_str),
6324            Some("session_meta") | Some("turn_context")
6325        ) {
6326            meta.codex_headers.push(record);
6327        }
6328    }
6329    Ok(true)
6330}
6331
6332fn restore_codex_provenance_from_top_level(record: &Value, meta: &mut SessionMeta) -> Result<bool> {
6333    match record.get(SUPERCODE_CODEX_PROVENANCE_KEY) {
6334        Some(extension) => restore_codex_provenance(extension, meta),
6335        None => Ok(false),
6336    }
6337}
6338
6339fn inject_first_jsonl_top_level(out: &mut String, key: &str, extension: Value) {
6340    let Some(line_end) = out.find('\n') else {
6341        return;
6342    };
6343    let Ok(mut record) = serde_json::from_str::<Value>(&out[..line_end]) else {
6344        return;
6345    };
6346    let Some(object) = record.as_object_mut() else {
6347        return;
6348    };
6349    object.insert(key.to_string(), extension);
6350    out.replace_range(..line_end, &record.to_string());
6351}
6352
6353fn inject_codex_provenance(out: &mut String, extension: Value) {
6354    let Some(line_end) = out.find('\n') else {
6355        return;
6356    };
6357    let Ok(mut record) = serde_json::from_str::<Value>(&out[..line_end]) else {
6358        return;
6359    };
6360    if record.get("type").and_then(Value::as_str) != Some("session_meta") {
6361        return;
6362    }
6363    let Some(payload) = record.get_mut("payload").and_then(Value::as_object_mut) else {
6364        return;
6365    };
6366    payload.insert(SUPERCODE_CODEX_PROVENANCE_KEY.to_string(), extension);
6367    out.replace_range(..line_end, &record.to_string());
6368}
6369
6370/// Remove the last conversational turn from `messages`: everything from the
6371/// last `user` message to the end (the user prompt plus the assistant's
6372/// response and any tool calls/results it triggered).
6373fn remove_last_turn(messages: &mut Vec<ChatMessage>) {
6374    if let Some(idx) = messages.iter().rposition(|m| m.role == Role::User) {
6375        messages.truncate(idx);
6376    } else {
6377        messages.clear();
6378    }
6379    // IX-6 fix: the new tail exposed by `truncate` may still carry
6380    // `__codex_open_turn` from when it was marked (it was NOT the last
6381    // message at that time — items after it, now removed by the rollback,
6382    // intervened). A bare `function_call` arriving after the rollback is a
6383    // genuinely NEW turn and must get its own message, not merge into this
6384    // stale marked tail — close it out here so `push_codex_item`'s
6385    // adjacency check (`out.last()` + marker) can't be fooled by the
6386    // truncation re-exposing it.
6387    if let Some(last) = messages.last_mut() {
6388        last.metadata.remove("__codex_open_turn");
6389    }
6390}
6391
6392fn truncate_messages(messages: &mut Vec<ChatMessage>, message_limit: usize) {
6393    truncate_messages_with_anchor(messages, message_limit, None);
6394}
6395
6396fn truncate_messages_with_anchor(
6397    messages: &mut Vec<ChatMessage>,
6398    message_limit: usize,
6399    preceding_user: Option<ChatMessage>,
6400) {
6401    let limit = message_limit.max(1);
6402    if messages.len() <= limit {
6403        return;
6404    }
6405    // Keep two recent human turn anchors. One anchor is insufficient once a
6406    // tool-heavy current turn grows beyond the numeric tail: the newest prompt
6407    // itself becomes that sole anchor, leaving a follower no overlap with the
6408    // transcript it already rendered. Two anchors preserve the previous turn
6409    // across a send and let overlap merging remain monotonic.
6410    let anchor_limit = limit.min(2);
6411    let mut anchor_indices = messages
6412        .iter()
6413        .enumerate()
6414        .rev()
6415        .filter_map(|(index, message)| (message.role == Role::User).then_some(index))
6416        .take(anchor_limit)
6417        .collect::<Vec<_>>();
6418    anchor_indices.reverse();
6419    let preceding_anchor = (anchor_indices.len() < anchor_limit)
6420        .then_some(preceding_user)
6421        .flatten();
6422    let has_preceding_anchor = preceding_anchor.is_some();
6423    let anchor_count = anchor_indices.len() + usize::from(has_preceding_anchor);
6424    let target_index_count = limit.saturating_sub(usize::from(has_preceding_anchor));
6425    let mut selected_indices = anchor_indices.clone();
6426    for index in (0..messages.len()).rev() {
6427        if selected_indices.len() >= target_index_count || anchor_indices.contains(&index) {
6428            continue;
6429        }
6430        selected_indices.push(index);
6431    }
6432    selected_indices.sort_unstable();
6433    let mut selected = Vec::with_capacity(limit);
6434    if let Some(anchor) = preceding_anchor {
6435        selected.push(anchor);
6436    }
6437    selected.extend(
6438        selected_indices
6439            .into_iter()
6440            .map(|index| messages[index].clone()),
6441    );
6442    debug_assert_eq!(selected.len(), limit.max(anchor_count));
6443    *messages = selected;
6444}
6445
6446fn truncate_session_messages(session: &mut Session, message_limit: usize) {
6447    truncate_messages(&mut session.messages, message_limit);
6448}
6449
6450/// The text of a Codex `agent_message` event. `message` is usually a string but
6451/// can be a structured object (e.g. review output) — fall back to its JSON.
6452fn agent_message_text(payload: &Value) -> String {
6453    match payload.get("message") {
6454        Some(Value::String(s)) => s.clone(),
6455        Some(other) => extract_text_content(Some(other)),
6456        None => String::new(),
6457    }
6458}
6459
6460/// Trimmed texts of all assistant messages present as `response_item` — the
6461/// dedup set for recovering collab-only `agent_message` narration.
6462fn collect_codex_assistant_texts(jsonl: &str) -> std::collections::HashSet<String> {
6463    let mut set = std::collections::HashSet::new();
6464    for line in non_empty_lines(jsonl) {
6465        let Ok(v) = serde_json::from_str::<Value>(line) else {
6466            continue;
6467        };
6468        if v.get("type").and_then(Value::as_str) != Some("response_item") {
6469            continue;
6470        }
6471        let payload = v.get("payload").unwrap_or(&Value::Null);
6472        if payload.get("type").and_then(Value::as_str) == Some("message")
6473            && payload.get("role").and_then(Value::as_str) == Some("assistant")
6474        {
6475            let text = extract_text_content(payload.get("content"));
6476            if !text.trim().is_empty() {
6477                set.insert(text.trim().to_string());
6478            }
6479        }
6480    }
6481    set
6482}
6483
6484fn capture_codex_session_meta(payload: &Value, meta: &mut SessionMeta) {
6485    if meta.session_id.is_none() {
6486        if let Some(id) = payload.get("id").and_then(Value::as_str) {
6487            meta.session_id = Some(id.to_string());
6488        }
6489    }
6490    if meta.cwd.is_none() {
6491        if let Some(cwd) = payload.get("cwd").and_then(Value::as_str) {
6492            meta.cwd = Some(PathBuf::from(cwd));
6493        }
6494    }
6495    if meta.system_prompt.is_none() {
6496        // `base_instructions` may be a string or `{ "text": "..." }`.
6497        let bi = payload.get("base_instructions");
6498        let text = match bi {
6499            Some(Value::String(s)) => Some(s.clone()),
6500            Some(Value::Object(_)) => bi
6501                .and_then(|b| b.get("text"))
6502                .and_then(Value::as_str)
6503                .map(str::to_string),
6504            _ => None,
6505        };
6506        meta.system_prompt = text;
6507    }
6508    if meta.model.is_none() {
6509        if let Some(m) = payload.get("model").and_then(Value::as_str) {
6510            meta.model = Some(m.to_string());
6511        }
6512    }
6513    // Cross-file lineage keys for multi-agent / forked sessions.
6514    let mut put = |key: &str, v: Option<&Value>| {
6515        if let Some(s) = v.and_then(Value::as_str) {
6516            meta.lineage.insert(key.to_string(), s.to_string());
6517        }
6518    };
6519    put("parent_thread_id", payload.get("parent_thread_id"));
6520    put("forked_from_id", payload.get("forked_from_id"));
6521    put("thread_source", payload.get("thread_source"));
6522    // PARITY-10 dev/03: the other half of `write_synthesized_codex_header`'s
6523    // passthrough — restores a captured Claude `fork-context-ref` so a
6524    // Claude -> Codex -> Claude round trip reconstructs the original record
6525    // (`to_claude_code_jsonl` re-emits whatever lands in this lineage key).
6526    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
6527        if let Some(v) = payload.get("claude_fork_context_ref") {
6528            meta.lineage
6529                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
6530        }
6531    }
6532    if let Some(spawn) = payload
6533        .get("source")
6534        .and_then(|s| s.get("subagent"))
6535        .and_then(|s| s.get("thread_spawn"))
6536    {
6537        // parent_thread_id can also live here (preferred when both present).
6538        if let Some(p) = spawn.get("parent_thread_id").and_then(Value::as_str) {
6539            meta.lineage
6540                .insert("parent_thread_id".to_string(), p.to_string());
6541        }
6542        for k in ["agent_role", "agent_nickname"] {
6543            if let Some(s) = spawn.get(k).and_then(Value::as_str) {
6544                meta.lineage.insert(k.to_string(), s.to_string());
6545            }
6546        }
6547        if let Some(d) = spawn.get("depth").and_then(Value::as_i64) {
6548            meta.lineage.insert("depth".to_string(), d.to_string());
6549        }
6550    }
6551}
6552
6553/// Depth of a node in the parent forest (root = 0), bounded against cycles.
6554fn depth_of(mut i: usize, parent_of: &[Option<usize>]) -> usize {
6555    let mut d = 0;
6556    let mut guard = 0;
6557    while let Some(p) = parent_of[i] {
6558        if p == i || guard > parent_of.len() {
6559            break;
6560        }
6561        i = p;
6562        d += 1;
6563        guard += 1;
6564    }
6565    d
6566}
6567
6568/// Codex per-turn grouping id, stored under `payload.metadata.turn_id`.
6569fn codex_turn_id(payload: &Value) -> Option<&str> {
6570    payload
6571        .get("metadata")
6572        .and_then(|m| m.get("turn_id"))
6573        .and_then(Value::as_str)
6574}
6575
6576/// N2 (spliced-export hardening): every Codex group id already present in
6577/// `raw_prefix` — the verbatim RAW lines [`Session::to_codex_jsonl_spliced`]
6578/// replays ahead of the appended tail it synthesizes via
6579/// `Session::write_codex_records`. This is the GROUND TRUTH of what
6580/// physically lands in the exported `out` string for the prefix: each line
6581/// is parsed as a Codex envelope and its own `payload.metadata.turn_id` (the
6582/// exact field `codex_turn_id` reads, whether it's a real native `turn_id`
6583/// or one of OUR OWN fabricated `sc-grp-N`/`<real>~dupN` ids from a prior
6584/// export) is extracted directly — no re-derivation from `self.messages`
6585/// needed (that would have to reconstruct which ids the ORIGINAL export
6586/// happened to assign, which this sidesteps entirely by reading them back
6587/// out of the bytes themselves). A line that fails to parse, isn't a
6588/// `response_item`, or carries no `turn_id` contributes nothing — headers
6589/// and non-message/call records (e.g. `session_meta`, `function_call_output`)
6590/// never carry this field to begin with.
6591fn collect_codex_group_ids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
6592    let mut ids = HashSet::new();
6593    for line in raw_prefix {
6594        if let Ok(v) = serde_json::from_str::<Value>(line) {
6595            if let Some(payload) = v.get("payload") {
6596                if let Some(tid) = codex_turn_id(payload) {
6597                    ids.insert(tid.to_string());
6598                }
6599            }
6600        }
6601    }
6602    ids
6603}
6604
6605/// Stamp every `ChatMessage` appended to `messages` since index `from` with
6606/// `ts` (a Codex record's own top-level `timestamp`, ISO-8601) as the
6607/// canonical `metadata["timestamp"]` (WAVE-2 item 1) — the same
6608/// `entry(...).or_insert_with` discipline the pi/Claude loaders use, so a
6609/// message that already carries a more specific timestamp of its own is
6610/// never overwritten (none currently do on the Codex side, but this keeps
6611/// every loader consistent). A no-op when `ts` is `None` (a line with no
6612/// `timestamp` field) or `from >= messages.len()` (nothing new was pushed).
6613fn stamp_new_codex_messages(messages: &mut [ChatMessage], from: usize, ts: Option<&str>) {
6614    let Some(ts) = ts else { return };
6615    let Some(slice) = messages.get_mut(from..) else {
6616        return;
6617    };
6618    for m in slice {
6619        m.metadata
6620            .entry("timestamp".to_string())
6621            .or_insert_with(|| ts.to_string());
6622    }
6623}
6624
6625fn push_codex_item(payload: &Value, out: &mut Vec<ChatMessage>) {
6626    match payload.get("type").and_then(Value::as_str) {
6627        Some("message") => {
6628            let role = match payload.get("role").and_then(Value::as_str) {
6629                Some("user") => Role::User,
6630                Some("assistant") => Role::Assistant,
6631                // "developer" and "system" both carry operator instructions.
6632                _ => Role::System,
6633            };
6634            let content = payload.get("content");
6635            let text = extract_text_content(content);
6636            // IX-5: `input_image` blocks alongside/instead of text — see
6637            // `codex_extract_images`. A text-only message (no image blocks)
6638            // takes the historical `content: Some(text)` shape unchanged.
6639            let images = codex_extract_images(content);
6640            let is_empty_assistant =
6641                role == Role::Assistant && text.trim().is_empty() && images.is_empty();
6642            if !text.trim().is_empty() || !images.is_empty() || is_empty_assistant {
6643                let content_parts = if images.is_empty() {
6644                    None
6645                } else {
6646                    let mut parts = Vec::new();
6647                    if !text.trim().is_empty() {
6648                        parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
6649                    }
6650                    parts.extend(images);
6651                    Some(parts)
6652                };
6653                let mut msg = ChatMessage {
6654                    role,
6655                    content: if content_parts.is_some() || text.is_empty() {
6656                        None
6657                    } else {
6658                        Some(text)
6659                    },
6660                    content_parts,
6661                    tool_calls: None,
6662                    tool_call_id: None,
6663                    name: None,
6664                    metadata: Default::default(),
6665                };
6666                // Preserve the assistant `phase` (commentary vs final_answer) so
6667                // a reloaded transcript can distinguish narration from the answer.
6668                if role == Role::Assistant {
6669                    if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
6670                        msg.metadata.insert("phase".to_string(), phase.to_string());
6671                    }
6672                    // IX-6: mark this as an open, mergeable combined-turn
6673                    // candidate — a `function_call` response_item found
6674                    // immediately after (still `out.last()` when reached,
6675                    // i.e. no other item intervened) merges into this SAME
6676                    // `ChatMessage` instead of splitting into a second one,
6677                    // matching how Claude's parser keeps a text+tool_use
6678                    // turn together. Stripped again before the loaded
6679                    // `Session` is returned (`from_codex_str`), so it never
6680                    // leaks as visible metadata.
6681                    msg.metadata
6682                        .insert("__codex_open_turn".to_string(), "true".to_string());
6683                }
6684                // The per-turn grouping key (Codex batches items by turn_id).
6685                if let Some(tid) = codex_turn_id(payload) {
6686                    msg.metadata.insert("turn_id".to_string(), tid.to_string());
6687                }
6688                // PARITY-6 dev/02: restore the original Claude
6689                // `systemSubtype` for a `developer`/`system` message that
6690                // was itself synthesized FROM a real Claude system record
6691                // (`write_codex_records`'s `Role::System` arm stamps
6692                // `claude_system_subtype`) — the exact inverse, so
6693                // `write_claude_code_records`'s `Role::System` arm can
6694                // re-materialize the real Claude `type: "system"` record
6695                // faithfully on a Codex -> Claude Code hop instead of
6696                // guessing a fallback subtype.
6697                if role == Role::System {
6698                    if let Some(subtype) = payload
6699                        .get("metadata")
6700                        .and_then(|m| m.get("claude_system_subtype"))
6701                        .and_then(Value::as_str)
6702                    {
6703                        msg.metadata
6704                            .insert("systemSubtype".to_string(), subtype.to_string());
6705                    }
6706                }
6707                if is_empty_assistant {
6708                    msg.metadata
6709                        .insert("empty_assistant_record".to_string(), "true".to_string());
6710                }
6711                out.push(msg);
6712            }
6713        }
6714        Some("function_call") => {
6715            let id = payload
6716                .get("call_id")
6717                .and_then(Value::as_str)
6718                .unwrap_or_default();
6719            let raw_name = payload
6720                .get("name")
6721                .and_then(Value::as_str)
6722                .unwrap_or_default();
6723            // Preserve the MCP `namespace` by qualifying the tool name
6724            // (`<namespace>__<name>`, matching the mcp__server__tool convention),
6725            // so the tool identity isn't ambiguous on round-trip.
6726            let qualified;
6727            let name = match payload.get("namespace").and_then(Value::as_str) {
6728                Some(ns) if !ns.is_empty() && !raw_name.starts_with(ns) => {
6729                    qualified = format!("{ns}__{raw_name}");
6730                    qualified.as_str()
6731                }
6732                _ => raw_name,
6733            };
6734            let args = payload
6735                .get("arguments")
6736                .map(value_to_arg_string)
6737                .unwrap_or_else(|| "{}".to_string());
6738            let call = function_call(id, name, args);
6739            // IX-6: a `function_call` immediately after an assistant `message`
6740            // in the SAME turn (still `out.last()`, marked `__codex_open_turn`
6741            // by the "message" arm above, and not yet closed by anything else)
6742            // merges into that ONE `ChatMessage` — text→`content`,
6743            // call→`tool_calls` — instead of splitting into a second message.
6744            // A bare `function_call` with no such preceding turn (the marker
6745            // absent, or `out.last()` not an assistant message) is unaffected:
6746            // it still gets its own synthesized message, exactly as before.
6747            //
6748            // Belt-and-suspenders (PARITY-6/7 tightened): if this
6749            // `function_call` response_item itself carries a `turn_id` (rare
6750            // in observed real-native-Codex corpora — Codex usually only
6751            // stamps it on `message` payloads — but ALWAYS present on OUR
6752            // OWN synthesized export whenever a `ChatMessage`'s own tool
6753            // calls need merge disambiguation, see `write_codex_records`),
6754            // it must match the marked assistant message's recorded
6755            // `turn_id` EXACTLY — including "the marked message has none at
6756            // all" counting as a mismatch. That's exactly the shape of two
6757            // genuinely separate, adjacent `ChatMessage`s (an unrelated
6758            // text-only turn immediately followed by a different,
6759            // tool-call-only turn): the tool-only turn's own `function_call`s
6760            // carry a synthetic id while the unrelated preceding text
6761            // message carries none, so this correctly refuses the merge
6762            // instead of falling through to a permissive default. Only when
6763            // this `function_call` carries NO `turn_id` at all (the ordinary
6764            // real-native-Codex shape) does this fall back to the original
6765            // permissive "adjacency + open marker is enough" rule —
6766            // unchanged from before for the vast majority of real Codex
6767            // data. The truncation/clear strip above is what actually closes
6768            // the marker across rollback/compaction boundaries; this is only
6769            // an extra guard for the case where a stale-but-unstripped
6770            // marker and a turn_id mismatch coincide.
6771            let can_merge = out.last().is_some_and(|last| {
6772                last.role == Role::Assistant
6773                    && last.metadata.contains_key("__codex_open_turn")
6774                    && match codex_turn_id(payload) {
6775                        Some(fc_tid) => {
6776                            last.metadata.get("turn_id").map(String::as_str) == Some(fc_tid)
6777                        }
6778                        None => true,
6779                    }
6780            });
6781            if can_merge {
6782                out.last_mut()
6783                    .expect("can_merge implies out.last() is Some")
6784                    .tool_calls
6785                    .get_or_insert_with(Vec::new)
6786                    .push(call);
6787            } else {
6788                push_assistant(out, String::new(), vec![call]);
6789                // PARITY-6/7: a BARE tool-call turn (no preceding `message`
6790                // in this turn, so nothing set `__codex_open_turn` above) can
6791                // still be the FIRST of several tool calls that all belong to
6792                // the SAME original `ChatMessage` (`write_codex_records`
6793                // stamps every one of a message's own tool calls with the
6794                // identical synthetic `turn_id`). Re-open THIS freshly
6795                // created message — but ONLY when a real `turn_id` is
6796                // present — so the NEXT `function_call` in the same group
6797                // merges into it instead of becoming its own message too.
6798                // Gated on `codex_turn_id(payload).is_some()` (not the bare
6799                // default `true` the belt-and-suspenders check above uses)
6800                // so real native Codex data — which almost never carries
6801                // this field on `function_call` payloads (see the comment
6802                // above) — keeps its existing "every bare tool call is its
6803                // own turn" behavior exactly as before.
6804                if let Some(tid) = codex_turn_id(payload) {
6805                    if let Some(last) = out.last_mut() {
6806                        last.metadata
6807                            .insert("__codex_open_turn".to_string(), "true".to_string());
6808                        last.metadata.insert("turn_id".to_string(), tid.to_string());
6809                    }
6810                }
6811            }
6812        }
6813        Some("function_call_output") => {
6814            let id = payload
6815                .get("call_id")
6816                .and_then(Value::as_str)
6817                .unwrap_or_default();
6818            let result = match payload.get("output") {
6819                Some(Value::String(s)) => s.clone(),
6820                Some(v) => extract_text_content(Some(v)),
6821                None => String::new(),
6822            };
6823            let mut message = tool_message(id, result);
6824            // TR-13: Codex v1 exposes no structured success/error field on
6825            // this record. Free-text output is not a safe classifier, so the
6826            // reduction engine must treat the outcome as explicitly unknown
6827            // and fail closed on both success-only and error-only pruning.
6828            crate::mark_tool_outcome_unknown(&mut message);
6829            out.push(message);
6830        }
6831        // Custom / MCP tool calls are shaped like function calls but carry their
6832        // arguments under `input` (a JSON-encoded string). Normalize them the
6833        // same way so MCP-using sessions don't lose those turns.
6834        Some("custom_tool_call") => {
6835            let id = payload
6836                .get("call_id")
6837                .and_then(Value::as_str)
6838                .unwrap_or_default();
6839            let name = payload
6840                .get("name")
6841                .and_then(Value::as_str)
6842                .unwrap_or_default();
6843            // Unlike `function_call.arguments`, Codex custom tools accept a
6844            // free-form `input` string (apply_patch is the common case).
6845            // Canonical `FunctionCall::arguments` must remain valid JSON, so
6846            // retain the input's JSON type instead of treating a free-form
6847            // string as if it were already a JSON document. This lets every
6848            // target harness carry the value rather than silently replacing
6849            // it with `{}` when `parsed_arguments()` fails.
6850            let args = payload
6851                .get("input")
6852                .map(Value::to_string)
6853                .unwrap_or_else(|| "{}".to_string());
6854            push_assistant(out, String::new(), vec![function_call(id, name, args)]);
6855            if let Some(message) = out.last_mut() {
6856                message.metadata.insert(
6857                    "codex_custom_tool_call_ids".to_string(),
6858                    serde_json::json!([id]).to_string(),
6859                );
6860            }
6861        }
6862        Some("custom_tool_call_output") => {
6863            let id = payload
6864                .get("call_id")
6865                .and_then(Value::as_str)
6866                .unwrap_or_default();
6867            let result = match payload.get("output") {
6868                Some(Value::String(s)) => s.clone(),
6869                Some(v) => extract_text_content(Some(v)),
6870                None => String::new(),
6871            };
6872            let mut message = tool_message(id, result);
6873            crate::mark_tool_outcome_unknown(&mut message);
6874            out.push(message);
6875        }
6876        // Tool-search is a clean call/output pair keyed by call_id.
6877        //
6878        // D1: this arm used to ALWAYS start a brand-new `ChatMessage`,
6879        // ignoring the `turn_id` merge stamps `write_codex_records` puts on
6880        // its own synthesized `tool_search_call` records (see the PARITY-6/7
6881        // comment there and on `codex_turn_id`/the `function_call` arm
6882        // above). That left the same bug-class the turn_id work fixed for
6883        // `function_call` half-done here: a single Claude assistant message
6884        // containing text + a `tool_search` block reloaded as 2 messages
6885        // (1 -> 2 inflation), and a message with 2 `tool_search` blocks
6886        // reloaded as 3. Mirror the `function_call` arm's merge check
6887        // exactly so a `tool_search_call` immediately following an open
6888        // assistant turn (or another tool call sharing the same `turn_id`)
6889        // merges into that SAME `ChatMessage` instead of splitting.
6890        Some("tool_search_call") => {
6891            let id = payload
6892                .get("call_id")
6893                .and_then(Value::as_str)
6894                .unwrap_or_default();
6895            let args = payload
6896                .get("arguments")
6897                .map(value_to_arg_string)
6898                .unwrap_or_else(|| "{}".to_string());
6899            let call = function_call(id, "tool_search", args);
6900            let can_merge = out.last().is_some_and(|last| {
6901                last.role == Role::Assistant
6902                    && last.metadata.contains_key("__codex_open_turn")
6903                    && match codex_turn_id(payload) {
6904                        Some(fc_tid) => {
6905                            last.metadata.get("turn_id").map(String::as_str) == Some(fc_tid)
6906                        }
6907                        None => true,
6908                    }
6909            });
6910            if can_merge {
6911                out.last_mut()
6912                    .expect("can_merge implies out.last() is Some")
6913                    .tool_calls
6914                    .get_or_insert_with(Vec::new)
6915                    .push(call);
6916            } else {
6917                push_assistant(out, String::new(), vec![call]);
6918                // Re-open the freshly created message so a FOLLOWING
6919                // `function_call`/`tool_search_call` sharing this same
6920                // `turn_id` merges into it too — matching the bare
6921                // `function_call` case's own re-open logic above.
6922                if let Some(tid) = codex_turn_id(payload) {
6923                    if let Some(last) = out.last_mut() {
6924                        last.metadata
6925                            .insert("__codex_open_turn".to_string(), "true".to_string());
6926                        last.metadata.insert("turn_id".to_string(), tid.to_string());
6927                    }
6928                }
6929            }
6930        }
6931        Some("tool_search_output") => {
6932            let id = payload
6933                .get("call_id")
6934                .and_then(Value::as_str)
6935                .unwrap_or_default();
6936            let result = payload
6937                .get("tools")
6938                .map(value_to_arg_string)
6939                .unwrap_or_default();
6940            out.push(tool_message(id, result));
6941        }
6942        // Web-search / image-generation response_items carry no paired output
6943        // here (results live in event_msg), so emit an assistant marker rather
6944        // than a dangling unanswered tool call.
6945        Some("web_search_call") => {
6946            push_assistant(out, "[web_search]".to_string(), Vec::new());
6947        }
6948        Some("image_generation_call") => {
6949            let prompt = payload
6950                .get("revised_prompt")
6951                .and_then(Value::as_str)
6952                .unwrap_or("");
6953            push_assistant(
6954                out,
6955                format!("[image_generation] {prompt}").trim().to_string(),
6956                Vec::new(),
6957            );
6958        }
6959        // "reasoning" and anything else — dropped.
6960        _ => {}
6961    }
6962}
6963
6964// ---- Grok -------------------------------------------------------------
6965
6966const SUPERCODE_GEMINI_MESSAGE_KEY: &str = "_supercode_gemini_message";
6967
6968fn set_gemini_message_extension(value: &mut Value, message: &ChatMessage) {
6969    value[SUPERCODE_GEMINI_MESSAGE_KEY] = serde_json::json!({
6970        "schema": 1,
6971        "role": message.role,
6972        "content": message.content,
6973        "content_parts": message.content_parts,
6974        "tool_calls": message.tool_calls,
6975        "tool_call_id": message.tool_call_id,
6976        "name": message.name,
6977        "metadata": message.metadata,
6978    });
6979}
6980
6981fn restore_gemini_message_extension(value: &Value, message: &mut ChatMessage) {
6982    let Some(extension) = value.get(SUPERCODE_GEMINI_MESSAGE_KEY) else {
6983        return;
6984    };
6985    if extension.get("schema").and_then(Value::as_u64) != Some(1) {
6986        return;
6987    }
6988    if let Some(role) = extension
6989        .get("role")
6990        .and_then(|value| serde_json::from_value(value.clone()).ok())
6991    {
6992        message.role = role;
6993    }
6994    message.content = extension
6995        .get("content")
6996        .and_then(Value::as_str)
6997        .map(str::to_string);
6998    message.content_parts = extension
6999        .get("content_parts")
7000        .and_then(|value| serde_json::from_value(value.clone()).ok());
7001    message.tool_calls = extension
7002        .get("tool_calls")
7003        .and_then(|value| serde_json::from_value(value.clone()).ok());
7004    message.tool_call_id = extension
7005        .get("tool_call_id")
7006        .and_then(Value::as_str)
7007        .map(str::to_string);
7008    message.name = extension
7009        .get("name")
7010        .and_then(Value::as_str)
7011        .map(str::to_string);
7012    message.metadata.clear();
7013    if let Some(metadata) = extension.get("metadata").and_then(Value::as_object) {
7014        for (key, value) in metadata {
7015            if let Some(value) = value.as_str() {
7016                message.metadata.insert(key.clone(), value.to_string());
7017            }
7018        }
7019    }
7020}
7021
7022fn capture_grok_scalar_metadata(value: &Value, message: &mut ChatMessage, keys: &[&str]) {
7023    for key in keys {
7024        if let Some(value) = value.get(*key) {
7025            message.metadata.insert(
7026                format!("grok_{key}"),
7027                value
7028                    .as_str()
7029                    .map(str::to_string)
7030                    .unwrap_or_else(|| value.to_string()),
7031            );
7032        }
7033    }
7034}
7035
7036fn grok_human_user_text(raw: &str) -> Option<String> {
7037    let text = raw.trim();
7038    if text.is_empty() || text.starts_with("<user_info>") || text.starts_with("<system-reminder>") {
7039        return None;
7040    }
7041    let unwrapped = text
7042        .strip_prefix("<user_query>")
7043        .and_then(|value| value.strip_suffix("</user_query>"))
7044        .map(str::trim)
7045        .unwrap_or(text);
7046    (!unwrapped.is_empty()).then(|| unwrapped.to_string())
7047}
7048
7049/// Portable extension for messages whose canonical fields cannot be expressed
7050/// by the target's stock schema. It was introduced for Grok and retains that
7051/// on-disk key for compatibility. Gemini has the same need: Claude Code and
7052/// Codex have no native slot for a tool-result name or Gemini-only metadata.
7053/// Their readers tolerate unknown namespaced fields, so forwarding this
7054/// adapter-owned envelope keeps those cross-format hops reversible without
7055/// pretending the stock schemas represent the fields directly.
7056const SUPERCODE_GROK_MESSAGE_KEY: &str = "_supercode_grok_message";
7057
7058/// Namespaced line-level extension carrying the one tool-result outcome state
7059/// Claude cannot represent natively. Keeping this narrower than the full Grok
7060/// portability envelope avoids changing unrelated target-message projection.
7061const SUPERCODE_TOOL_OUTCOME_KEY: &str = "_supercode_tool_outcome";
7062
7063fn restore_tool_outcome_extension(value: &Value, message: &mut ChatMessage) {
7064    if !crate::is_tool_error(message)
7065        && value
7066            .get(SUPERCODE_TOOL_OUTCOME_KEY)
7067            .and_then(Value::as_str)
7068            == Some("unknown")
7069    {
7070        crate::mark_tool_outcome_unknown(message);
7071    }
7072}
7073
7074fn grok_message_extension(source: SessionSource, message: &ChatMessage) -> Option<Value> {
7075    let metadata = message
7076        .metadata
7077        .iter()
7078        .filter(|(key, _)| {
7079            key.starts_with("gemini_") || key.starts_with("grok_") || key.starts_with("goose_")
7080        })
7081        .map(|(key, value)| (key.clone(), Value::String(value.clone())))
7082        .collect::<serde_json::Map<_, _>>();
7083
7084    // `meta.source` changes after every reload. Keying portability only on
7085    // the immediate source therefore made Grok metadata survive one hop but
7086    // disappear on A -> B -> C translations. Once Grok-owned fields are
7087    // present, keep forwarding them regardless of the current container.
7088    let has_portable_fields =
7089        !metadata.is_empty() || message.metadata.contains_key("codex_custom_tool_call_ids");
7090    (matches!(
7091        source,
7092        SessionSource::Gemini | SessionSource::Grok | SessionSource::Goose
7093    ) || has_portable_fields
7094        || message.content_parts.is_some())
7095    .then(|| {
7096        serde_json::json!({
7097            "schema": 2,
7098            "role": message.role,
7099            "content": message.content,
7100            "content_parts": message.content_parts,
7101            "tool_calls": message.tool_calls,
7102            "tool_call_id": message.tool_call_id,
7103            "name": message.name,
7104            "metadata": message.metadata,
7105        })
7106    })
7107}
7108
7109fn set_grok_target_message_extension(value: &mut Value, message: &ChatMessage) {
7110    value[SUPERCODE_GROK_MESSAGE_KEY] = serde_json::json!({
7111        "schema": 2,
7112        "role": message.role,
7113        "content": message.content,
7114        "content_parts": message.content_parts,
7115        "tool_calls": message.tool_calls,
7116        "tool_call_id": message.tool_call_id,
7117        "name": message.name,
7118        "metadata": message.metadata,
7119    });
7120}
7121
7122fn set_grok_message_extension(value: &mut Value, source: SessionSource, message: &ChatMessage) {
7123    if let Some(extension) = grok_message_extension(source, message) {
7124        value[SUPERCODE_GROK_MESSAGE_KEY] = extension;
7125    }
7126}
7127
7128fn restore_grok_message_extension(value: &Value, message: &mut ChatMessage) {
7129    let Some(extension) = value.get(SUPERCODE_GROK_MESSAGE_KEY) else {
7130        return;
7131    };
7132    // Codex temporarily marks a text assistant item so immediately-following
7133    // function-call items can merge back into the same canonical turn. The
7134    // portable envelope must not erase that loader-private marker before the
7135    // merge happens; `from_codex_str` removes it before returning.
7136    let codex_open_turn = message.metadata.get("__codex_open_turn").cloned();
7137    let codex_turn_id = message.metadata.get("turn_id").cloned();
7138    let extension_has_turn_id = extension
7139        .get("metadata")
7140        .and_then(Value::as_object)
7141        .is_some_and(|metadata| metadata.contains_key("turn_id"));
7142    if extension.get("schema").and_then(Value::as_u64) == Some(2) {
7143        if let Some(role) = extension
7144            .get("role")
7145            .and_then(|value| serde_json::from_value(value.clone()).ok())
7146        {
7147            message.role = role;
7148        }
7149        message.content = extension
7150            .get("content")
7151            .and_then(Value::as_str)
7152            .map(str::to_string);
7153        message.content_parts = extension
7154            .get("content_parts")
7155            .and_then(|value| serde_json::from_value(value.clone()).ok());
7156        // Tool calls are shared native structure in every supported format.
7157        // Keep the loader's reconstruction instead of restoring this copy:
7158        // Codex stores a combined text+tool turn across multiple records, so
7159        // eagerly restoring calls on its text record would duplicate them
7160        // when the following function-call records merge.
7161        message.tool_call_id = extension
7162            .get("tool_call_id")
7163            .and_then(Value::as_str)
7164            .map(str::to_string);
7165        message.name = extension
7166            .get("name")
7167            .and_then(Value::as_str)
7168            .map(str::to_string);
7169        message.metadata.clear();
7170    }
7171    if let Some(metadata) = extension.get("metadata").and_then(Value::as_object) {
7172        for (key, value) in metadata {
7173            if let Some(value) = value.as_str() {
7174                message.metadata.insert(key.clone(), value.to_string());
7175            }
7176        }
7177    }
7178    if let Some(name) = extension.get("name").and_then(Value::as_str) {
7179        message.name = Some(name.to_string());
7180    }
7181    if let Some(marker) = codex_open_turn {
7182        message
7183            .metadata
7184            .insert("__codex_open_turn".to_string(), marker);
7185    }
7186    if let Some(turn_id) = codex_turn_id {
7187        message.metadata.insert("turn_id".to_string(), turn_id);
7188        if !extension_has_turn_id {
7189            message.metadata.insert(
7190                "__grok_remove_synthetic_turn_id".to_string(),
7191                "true".to_string(),
7192            );
7193        }
7194    }
7195}
7196
7197fn restore_single_grok_message(value: &Value, messages: &mut [ChatMessage]) {
7198    if let [message] = messages {
7199        restore_grok_message_extension(value, message);
7200    }
7201}
7202
7203fn normalize_goose_message(native: &Value, native_index: usize, out: &mut Vec<ChatMessage>) {
7204    let role = match native.get("role").and_then(Value::as_str) {
7205        Some("assistant") => Role::Assistant,
7206        _ => Role::User,
7207    };
7208    let created = native.get("created").and_then(Value::as_i64);
7209    let native_id = native.get("id").and_then(Value::as_str);
7210    let mut text = Vec::new();
7211    let mut content_parts = Vec::new();
7212    let mut tool_calls = Vec::new();
7213    let mut tool_results = Vec::new();
7214
7215    for (block_index, block) in native
7216        .get("content")
7217        .and_then(Value::as_array)
7218        .into_iter()
7219        .flatten()
7220        .enumerate()
7221    {
7222        match block.get("type").and_then(Value::as_str) {
7223            Some("text") => {
7224                if let Some(value) = block.get("text").and_then(Value::as_str) {
7225                    text.push(value.to_string());
7226                    content_parts.push(serde_json::json!({"type": "text", "text": value}));
7227                }
7228            }
7229            Some("image") => {
7230                let data = block
7231                    .get("data")
7232                    .and_then(Value::as_str)
7233                    .unwrap_or_default();
7234                let media_type = block
7235                    .get("mimeType")
7236                    .or_else(|| block.get("mime_type"))
7237                    .and_then(Value::as_str)
7238                    .unwrap_or("application/octet-stream");
7239                content_parts.push(serde_json::json!({
7240                    "type": "image_url",
7241                    "image_url": {"url": format!("data:{media_type};base64,{data}")},
7242                }));
7243            }
7244            Some("toolRequest" | "frontendToolRequest") => {
7245                let id = block
7246                    .get("id")
7247                    .and_then(Value::as_str)
7248                    .map(str::to_string)
7249                    .unwrap_or_else(|| format!("goose-{native_index}-{block_index}"));
7250                let call = block
7251                    .get("toolCall")
7252                    .and_then(|call| {
7253                        (call.get("status").and_then(Value::as_str) == Some("success"))
7254                            .then(|| call.get("value"))
7255                            .flatten()
7256                    })
7257                    .or_else(|| block.get("toolCall"));
7258                let Some(call) = call else { continue };
7259                let name = call.get("name").and_then(Value::as_str).unwrap_or("tool");
7260                let arguments = call
7261                    .get("arguments")
7262                    .map(value_to_arg_string)
7263                    .unwrap_or_else(|| "{}".to_string());
7264                tool_calls.push(function_call(&id, name, arguments));
7265            }
7266            Some("toolResponse") => tool_results.push(block.clone()),
7267            _ => {}
7268        }
7269    }
7270
7271    if !text.is_empty() || !content_parts.is_empty() || !tool_calls.is_empty() {
7272        let has_non_text = content_parts
7273            .iter()
7274            .any(|part| part.get("type").and_then(Value::as_str) != Some("text"));
7275        let mut message = ChatMessage {
7276            role,
7277            content: (!text.is_empty()).then(|| text.join("\n")),
7278            content_parts: has_non_text.then_some(content_parts),
7279            tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
7280            tool_call_id: None,
7281            name: None,
7282            metadata: Default::default(),
7283        };
7284        capture_goose_message_metadata(native, created, native_id, &mut message);
7285        out.push(message);
7286    }
7287
7288    for (result_index, block) in tool_results.into_iter().enumerate() {
7289        let id = block
7290            .get("id")
7291            .and_then(Value::as_str)
7292            .map(str::to_string)
7293            .unwrap_or_else(|| format!("goose-{native_index}-result-{result_index}"));
7294        let result = block.get("toolResult").unwrap_or(&Value::Null);
7295        let status_error = result.get("status").and_then(Value::as_str) == Some("error");
7296        let value = result.get("value").unwrap_or(result);
7297        let is_error = status_error || value.get("isError").and_then(Value::as_bool) == Some(true);
7298        let output = if status_error {
7299            result
7300                .get("error")
7301                .and_then(Value::as_str)
7302                .unwrap_or("Goose tool call failed")
7303                .to_string()
7304        } else {
7305            value
7306                .get("content")
7307                .and_then(Value::as_array)
7308                .map(|content| {
7309                    content
7310                        .iter()
7311                        .filter_map(|part| {
7312                            part.get("text")
7313                                .and_then(Value::as_str)
7314                                .map(str::to_string)
7315                                .or_else(|| Some(part.to_string()))
7316                        })
7317                        .collect::<Vec<_>>()
7318                        .join("\n")
7319                })
7320                .unwrap_or_else(|| value.to_string())
7321        };
7322        let mut message = tool_message(&id, output);
7323        if is_error {
7324            crate::mark_tool_error(&mut message);
7325        }
7326        capture_goose_message_metadata(native, created, native_id, &mut message);
7327        out.push(message);
7328    }
7329}
7330
7331fn capture_goose_message_metadata(
7332    native: &Value,
7333    created: Option<i64>,
7334    native_id: Option<&str>,
7335    message: &mut ChatMessage,
7336) {
7337    if let Some(created) = created {
7338        message
7339            .metadata
7340            .insert("goose_created".to_string(), created.to_string());
7341    }
7342    if let Some(native_id) = native_id {
7343        message
7344            .metadata
7345            .insert("goose_message_id".to_string(), native_id.to_string());
7346    }
7347    if let Some(metadata) = native.get("metadata") {
7348        message
7349            .metadata
7350            .insert("goose_metadata".to_string(), metadata.to_string());
7351    }
7352}
7353
7354#[doc(hidden)]
7355pub fn percent_decode_path(encoded: &str) -> Option<String> {
7356    fn hex(byte: u8) -> Option<u8> {
7357        match byte {
7358            b'0'..=b'9' => Some(byte - b'0'),
7359            b'a'..=b'f' => Some(byte - b'a' + 10),
7360            b'A'..=b'F' => Some(byte - b'A' + 10),
7361            _ => None,
7362        }
7363    }
7364
7365    let bytes = encoded.as_bytes();
7366    let mut decoded = Vec::with_capacity(bytes.len());
7367    let mut index = 0usize;
7368    while index < bytes.len() {
7369        if bytes[index] == b'%' {
7370            let high = *bytes.get(index + 1)?;
7371            let low = *bytes.get(index + 2)?;
7372            decoded.push(hex(high)? * 16 + hex(low)?);
7373            index += 3;
7374        } else {
7375            decoded.push(bytes[index]);
7376            index += 1;
7377        }
7378    }
7379    String::from_utf8(decoded).ok()
7380}
7381
7382// ---- Pi ---------------------------------------------------------------
7383
7384fn capture_pi_header(v: &Value, meta: &mut SessionMeta) -> Result<()> {
7385    restore_codex_provenance_from_top_level(v, meta)?;
7386    if let Some(id) = v.get("id").and_then(Value::as_str) {
7387        meta.session_id = Some(id.to_string());
7388    }
7389    if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
7390        meta.cwd = Some(PathBuf::from(cwd));
7391    }
7392    // Absent `version` means a pre-v3 file (`pi-fields.md` §1: "absent = v1").
7393    let version = v
7394        .get("version")
7395        .and_then(Value::as_u64)
7396        .map(|n| n.to_string())
7397        .unwrap_or_else(|| "1".to_string());
7398    meta.lineage.insert("pi_version".to_string(), version);
7399    if let Some(ts) = v.get("timestamp").and_then(Value::as_str) {
7400        meta.lineage
7401            .insert("created_at".to_string(), ts.to_string());
7402    }
7403    if let Some(ps) = v.get("parentSession").and_then(Value::as_str) {
7404        meta.lineage
7405            .insert("parent_session_path".to_string(), ps.to_string());
7406    }
7407    // D7: the other half of `push_pi_header`'s passthrough — restores a
7408    // captured Claude `fork-context-ref` so a Claude -> Pi -> Claude round
7409    // trip reconstructs the original record (mirrors
7410    // `capture_codex_session_meta`'s identical `claude_fork_context_ref`
7411    // restore for the Codex hop).
7412    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
7413        if let Some(v) = v.get("claude_fork_context_ref") {
7414            meta.lineage
7415                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
7416        }
7417    }
7418    Ok(())
7419}
7420
7421/// Validate one `ImageContent` block's `{mimeType, data}` shape and return
7422/// `(mime, data)` when it looks like a real image payload.
7423///
7424/// **This shape is UNVERIFIED against real pi output**: `pi-fields.md` cites
7425/// `ai:316-350` for the `ImageContent` content-block union but does not
7426/// enumerate `ImageContent`'s own fields (only `TextContent`/`ThinkingContent`/
7427/// `ToolCall` are itemized there) — `{mimeType, data}` (mirroring the OpenAI/
7428/// Anthropic multimodal wire shape) is this loader's best guess, not a
7429/// frozen-spec fact. A follow-up TR tracks confirming/correcting this shape
7430/// against a real pi corpus. Until then this function VALIDATES rather than
7431/// assumes: both fields must be present, non-empty strings, and `data` must
7432/// look like base64 (only the base64 alphabet, incl. `=` padding) — anything
7433/// else is an unknown/unexpected image shape, and the caller must route the
7434/// whole message to raw-only survival (S6-style fail loud) instead of
7435/// silently synthesizing a corrupt/empty `image_url` part.
7436fn pi_image_shape(item: &Value) -> Option<(String, String)> {
7437    let mime = item.get("mimeType").and_then(Value::as_str)?;
7438    let data = item.get("data").and_then(Value::as_str)?;
7439    if mime.is_empty() || data.is_empty() {
7440        return None;
7441    }
7442    if !data
7443        .bytes()
7444        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
7445    {
7446        return None;
7447    }
7448    Some((mime.to_string(), data.to_string()))
7449}
7450
7451/// True if `content` (a pi content value: bare string or
7452/// `(TextContent|ImageContent)[]`) contains at least one `type:"image"` block
7453/// that does not match [`pi_image_shape`] — shared by the loader (which
7454/// routes such a message to raw-only survival, never a synthesized-empty
7455/// part) and the coverage audit (`audit::Corpus::Pi`), which scores it as
7456/// `message/UnknownImageShape` instead of silently `Normalized`, so the shape
7457/// mismatch surfaces as a coverage FAILURE rather than vanishing.
7458#[doc(hidden)]
7459pub fn pi_content_has_unknown_image_shape(content: Option<&Value>) -> bool {
7460    let Some(Value::Array(items)) = content else {
7461        return false;
7462    };
7463    items.iter().any(|item| {
7464        item.get("type").and_then(Value::as_str) == Some("image") && pi_image_shape(item).is_none()
7465    })
7466}
7467
7468/// Split a pi `(TextContent|ImageContent)[]` (or bare string) content value
7469/// into concatenated text plus, when a WELL-FORMED image block is present,
7470/// the full `content_parts` array (leading text block + one `image_url` part
7471/// per image, its `data:` URI carrying the exact `mimeType`/`data` bytes pi
7472/// stored) — shared by `user`/`toolResult`/`custom*` content, which all use
7473/// the identical union (`pi-fields.md` §3a/§3c/§3e).
7474///
7475/// Returns `(text, parts, unknown_image_shape)`. When an image block does NOT
7476/// match [`pi_image_shape`] (missing/empty `data`/`mimeType`, or a `data`
7477/// value that isn't recognizable base64), this NEVER synthesizes an empty/
7478/// corrupt `image_url` part — it reports `unknown_image_shape = true` and
7479/// every caller must treat that as raw-only survival for the whole message
7480/// (mirroring the unknown-`message.role` rule, S6), so a shape this loader
7481/// guessed wrong fails loud instead of silently dropping/corrupting the
7482/// image.
7483fn pi_content_to_text_and_parts(content: Option<&Value>) -> (String, Option<Vec<Value>>, bool) {
7484    match content {
7485        Some(Value::String(s)) => (s.clone(), None, false),
7486        Some(Value::Array(items)) => {
7487            let mut text = String::new();
7488            let mut parts: Vec<Value> = Vec::new();
7489            let mut has_image = false;
7490            let mut unknown_image_shape = false;
7491            for item in items {
7492                match item.get("type").and_then(Value::as_str) {
7493                    Some("text") => {
7494                        if let Some(t) = item.get("text").and_then(Value::as_str) {
7495                            push_str_field(&mut text, t);
7496                        }
7497                    }
7498                    Some("image") => {
7499                        has_image = true;
7500                        match pi_image_shape(item) {
7501                            Some((mime, data)) => {
7502                                parts.push(serde_json::json!({
7503                                    "type": "image_url",
7504                                    "image_url": {"url": format!("data:{mime};base64,{data}")},
7505                                }));
7506                            }
7507                            None => unknown_image_shape = true,
7508                        }
7509                    }
7510                    _ => {}
7511                }
7512            }
7513            if unknown_image_shape {
7514                // Never synthesize an empty/corrupt part for a shape we
7515                // don't recognize — raw-only survival for the whole message;
7516                // the coverage guard is what turns this into a visible
7517                // failure (S6-style).
7518                return (String::new(), None, true);
7519            }
7520            if has_image {
7521                if !text.trim().is_empty() {
7522                    parts.insert(0, serde_json::json!({"type": "text", "text": text.clone()}));
7523                }
7524                (text, Some(parts), false)
7525            } else {
7526                (text, None, false)
7527            }
7528        }
7529        _ => (String::new(), None, false),
7530    }
7531}
7532
7533fn push_pi_user(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7534    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
7535    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7536    // survival, never a synthesized-empty part. `audit::Corpus::Pi`'s
7537    // `message/UnknownImageShape` bucket is what turns this into a visible
7538    // coverage failure.
7539    if unknown_image_shape {
7540        return;
7541    }
7542    if text.trim().is_empty() && parts.is_none() {
7543        return;
7544    }
7545    let mut msg = match parts {
7546        Some(parts) => ChatMessage {
7547            role: Role::User,
7548            content: None,
7549            content_parts: Some(parts),
7550            tool_calls: None,
7551            tool_call_id: None,
7552            name: None,
7553            metadata: Default::default(),
7554        },
7555        None => ChatMessage::user(text),
7556    };
7557    // WAVE-2 fidelity fix: pi's message-level unix-ms clock
7558    // (`message.timestamp`) is a DISTINCT field from the canonical
7559    // entry-level ISO `metadata["timestamp"]` WAVE-2 item 1 wires — the two
7560    // carry genuinely different values in real corpora (the fixture's are
7561    // ~6 months apart). Preserve it separately so it isn't silently lost for
7562    // every pi session; see `msg_pi_native_timestamp_ms` (the pi writer's
7563    // native round-trip consumer) and the INHERENT residue note on
7564    // `pi_dropped_keys_cross` in `interop_fidelity_matrix.rs`.
7565    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7566        msg.metadata
7567            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7568    }
7569    out.push(msg);
7570}
7571
7572fn push_pi_assistant(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7573    let mut text = String::new();
7574    let mut calls: Vec<ToolCall> = Vec::new();
7575    let mut thinking = String::new();
7576    let mut thinking_seen = false;
7577    let mut thinking_sig: Option<String> = None;
7578    let mut thinking_redacted = false;
7579    let mut text_sig: Option<String> = None;
7580    let mut thought_sig: Option<String> = None;
7581
7582    if let Some(Value::Array(blocks)) = msg_v.get("content") {
7583        for b in blocks {
7584            match b.get("type").and_then(Value::as_str) {
7585                Some("text") => {
7586                    if let Some(t) = b.get("text").and_then(Value::as_str) {
7587                        push_str_field(&mut text, t);
7588                    }
7589                    if let Some(sig) = b.get("textSignature") {
7590                        text_sig = Some(match sig {
7591                            Value::String(s) => s.clone(),
7592                            other => other.to_string(),
7593                        });
7594                    }
7595                }
7596                Some("thinking") => {
7597                    thinking_seen = true;
7598                    if let Some(t) = b.get("thinking").and_then(Value::as_str) {
7599                        push_str_field(&mut thinking, t);
7600                    }
7601                    if let Some(sig) = b.get("thinkingSignature").and_then(Value::as_str) {
7602                        thinking_sig = Some(sig.to_string());
7603                    }
7604                    if b.get("redacted").and_then(Value::as_bool) == Some(true) {
7605                        thinking_redacted = true;
7606                    }
7607                }
7608                Some("toolCall") => {
7609                    let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
7610                    let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
7611                    // `arguments` is a JSON OBJECT on pi's wire, not a string
7612                    // (`pi-fields.md` §3b open question 4) — serialize to the
7613                    // string `FunctionCall::arguments` expects.
7614                    let args = b
7615                        .get("arguments")
7616                        .cloned()
7617                        .unwrap_or_else(|| Value::Object(Default::default()));
7618                    calls.push(function_call(id, name, args.to_string()));
7619                    if let Some(sig) = b.get("thoughtSignature").and_then(Value::as_str) {
7620                        thought_sig = Some(sig.to_string());
7621                    }
7622                }
7623                _ => {}
7624            }
7625        }
7626    }
7627
7628    let before = out.len();
7629    push_assistant(out, text, calls);
7630    // A recognized native assistant entry remains transcript state even
7631    // when its content array is empty, except Pi's explicit empty error
7632    // response: that record has no replayable content and is established
7633    // raw-only residue (`pi_real_corpus_error_retry`). Preserve empty
7634    // non-error turns and Pi's standalone thinking-block shape.
7635    let is_empty_error =
7636        !thinking_seen && msg_v.get("stopReason").and_then(Value::as_str) == Some("error");
7637    if out.len() == before && !is_empty_error {
7638        let mut empty = ChatMessage {
7639            role: Role::Assistant,
7640            content: None,
7641            content_parts: None,
7642            tool_calls: None,
7643            tool_call_id: None,
7644            name: None,
7645            metadata: Default::default(),
7646        };
7647        if !thinking_seen {
7648            empty
7649                .metadata
7650                .insert("empty_assistant_record".to_string(), "true".to_string());
7651        }
7652        out.push(empty);
7653    }
7654    if out.len() > before {
7655        let msg = out.last_mut().expect("just pushed");
7656        if thinking_seen {
7657            msg.metadata.insert("thinking".to_string(), thinking);
7658        }
7659        if let Some(s) = thinking_sig {
7660            msg.metadata.insert("thinking_signature".to_string(), s);
7661        }
7662        if thinking_redacted {
7663            msg.metadata
7664                .insert("pi_thinking_redacted".to_string(), "true".to_string());
7665        }
7666        if let Some(s) = text_sig {
7667            msg.metadata.insert("pi_text_signature".to_string(), s);
7668        }
7669        if let Some(s) = thought_sig {
7670            msg.metadata.insert("pi_thought_signature".to_string(), s);
7671        }
7672        for (key, field) in [
7673            ("pi_api", "api"),
7674            ("pi_provider", "provider"),
7675            ("pi_response_model", "responseModel"),
7676            ("pi_response_id", "responseId"),
7677            ("pi_stop_reason", "stopReason"),
7678            ("pi_error_message", "errorMessage"),
7679        ] {
7680            if let Some(s) = msg_v.get(field).and_then(Value::as_str) {
7681                msg.metadata.insert(key.to_string(), s.to_string());
7682            }
7683        }
7684        if let Some(diag) = msg_v.get("diagnostics") {
7685            if !diag.is_null() {
7686                msg.metadata
7687                    .insert("pi_diagnostics".to_string(), diag.to_string());
7688            }
7689        }
7690        if let Some(usage) = msg_v.get("usage") {
7691            if !usage.is_null() {
7692                msg.metadata
7693                    .insert("pi_usage".to_string(), usage.to_string());
7694            }
7695        }
7696        // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7697        // separately from the canonical entry-level ISO `timestamp` — see
7698        // `push_pi_user`.
7699        if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7700            msg.metadata
7701                .insert("pi_msg_timestamp".to_string(), ts.to_string());
7702        }
7703    }
7704}
7705
7706fn push_pi_tool_result(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7707    let id = msg_v
7708        .get("toolCallId")
7709        .and_then(Value::as_str)
7710        .unwrap_or_default();
7711    let name = msg_v
7712        .get("toolName")
7713        .and_then(Value::as_str)
7714        .unwrap_or_default();
7715    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
7716    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7717    // survival, never a synthesized-empty part. Dropping the toolResult
7718    // message here leaves its `toolCallId` unanswered, which
7719    // `ensure_tool_results_paired` already turns into a visible
7720    // "[no tool result recorded — turn interrupted]" placeholder — a loud
7721    // failure mode, not a silent one.
7722    if unknown_image_shape {
7723        return;
7724    }
7725    let mut msg = ChatMessage {
7726        role: Role::Tool,
7727        content: Some(text),
7728        content_parts: parts,
7729        tool_calls: None,
7730        tool_call_id: Some(id.to_string()),
7731        name: Some(name.to_string()),
7732        metadata: Default::default(),
7733    };
7734    if let Some(details) = msg_v.get("details") {
7735        if !details.is_null() {
7736            msg.metadata
7737                .insert("pi_tool_details".to_string(), details.to_string());
7738        }
7739    }
7740    let is_error = msg_v
7741        .get("isError")
7742        .and_then(Value::as_bool)
7743        .unwrap_or(false);
7744    msg.metadata
7745        .insert("pi_is_error".to_string(), is_error.to_string());
7746    if is_error {
7747        crate::mark_tool_error(&mut msg);
7748    }
7749    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7750    // separately from the canonical entry-level ISO `timestamp` — see
7751    // `push_pi_user`.
7752    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7753        msg.metadata
7754            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7755    }
7756    out.push(msg);
7757}
7758
7759/// Render a pi `bashExecution` message (`!`/`!!` shell escape) to the text
7760/// pi itself sends the model, mirroring `bashExecutionToText`
7761/// (`pi-fields.md` §3d, `msg:82-98`). The exact upstream string constants
7762/// aren't reproduced in the frozen research doc (only cited by file:line),
7763/// so this is a faithful, clearly-labeled reconstruction — every structured
7764/// field is additionally preserved verbatim in `metadata`/`raw` regardless.
7765fn push_pi_bash(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7766    let command = msg_v.get("command").and_then(Value::as_str).unwrap_or("");
7767    let output = msg_v.get("output").and_then(Value::as_str).unwrap_or("");
7768    let exit_code = msg_v.get("exitCode").and_then(Value::as_i64);
7769    let cancelled = msg_v
7770        .get("cancelled")
7771        .and_then(Value::as_bool)
7772        .unwrap_or(false);
7773    let truncated = msg_v
7774        .get("truncated")
7775        .and_then(Value::as_bool)
7776        .unwrap_or(false);
7777
7778    let mut text = format!("$ {command}\n{output}");
7779    if let Some(code) = exit_code {
7780        if code != 0 {
7781            text.push_str(&format!("\n[exit code: {code}]"));
7782        }
7783    }
7784    if cancelled {
7785        text.push_str("\n[cancelled]");
7786    }
7787    if truncated {
7788        text.push_str("\n[truncated]");
7789    }
7790
7791    let mut msg = ChatMessage::user(text);
7792    msg.metadata
7793        .insert("pi_bash_command".to_string(), command.to_string());
7794    msg.metadata
7795        .insert("pi_bash_output".to_string(), output.to_string());
7796    if let Some(code) = exit_code {
7797        msg.metadata
7798            .insert("pi_bash_exit_code".to_string(), code.to_string());
7799    }
7800    msg.metadata
7801        .insert("pi_bash_cancelled".to_string(), cancelled.to_string());
7802    msg.metadata
7803        .insert("pi_bash_truncated".to_string(), truncated.to_string());
7804    if let Some(p) = msg_v.get("fullOutputPath").and_then(Value::as_str) {
7805        msg.metadata
7806            .insert("pi_bash_full_output_path".to_string(), p.to_string());
7807    }
7808    // `!!` — hidden from the model context; honored by `is_replay_excluded`
7809    // on every writer, not just pi's own (§2.2).
7810    if msg_v.get("excludeFromContext").and_then(Value::as_bool) == Some(true) {
7811        msg.metadata
7812            .insert("pi_exclude_from_context".to_string(), "true".to_string());
7813    }
7814    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7815    // separately from the canonical entry-level ISO `timestamp` — see
7816    // `push_pi_user`.
7817    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7818        msg.metadata
7819            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7820    }
7821    out.push(msg);
7822}
7823
7824/// B4: the `customType` marker `write_pi_entries`'s `Role::System` arm
7825/// stamps on a re-materialized content-bearing Claude `system` record (see
7826/// that arm's doc comment). Namespaced (`supercode_`-prefixed) so it can
7827/// never collide with a real pi `CustomMessage.customType` — pi's own
7828/// hook-injected custom types are hook/extension names (e.g. `hookMessage`
7829/// migration targets), never this literal string.
7830const PI_CLAUDE_SYSTEM_CUSTOM_TYPE: &str = "supercode_claude_system";
7831
7832/// Shared mapping for pi's `role:"custom"` message (§3e) and top-level
7833/// `custom_message` entries (§9) — both enter context as a `User` message
7834/// with the same `customType`/`display`/`details` residue.
7835///
7836/// B4 exception: when `customType` is [`PI_CLAUDE_SYSTEM_CUSTOM_TYPE`] (our
7837/// own marker — see `write_pi_entries`'s `Role::System` arm), this is
7838/// actually a re-materialized content-bearing Claude `system` record round-
7839/// tripping through pi, not a genuine pi extension message — restore
7840/// `Role::System` + `metadata["systemSubtype"]` (from `details.
7841/// claude_system_subtype`, falling back to `local_command` — still one of
7842/// `push_claude_system`'s own keep subtypes — exactly like
7843/// `write_codex_records`'s Codex-leg fallback) instead of the generic
7844/// `Role::User` path below, so a Claude -> Pi -> Claude round trip restores
7845/// the exact original role, not just the text.
7846fn push_pi_custom_common(v: &Value, out: &mut Vec<ChatMessage>) {
7847    if v.get("customType").and_then(Value::as_str) == Some(PI_CLAUDE_SYSTEM_CUSTOM_TYPE) {
7848        let content = v.get("content").and_then(Value::as_str).unwrap_or("");
7849        if content.trim().is_empty() {
7850            return;
7851        }
7852        let subtype = v
7853            .get("details")
7854            .and_then(|d| d.get("claude_system_subtype"))
7855            .and_then(Value::as_str)
7856            .unwrap_or("local_command");
7857        out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
7858        return;
7859    }
7860    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(v.get("content"));
7861    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7862    // survival, never a synthesized-empty part.
7863    if unknown_image_shape {
7864        return;
7865    }
7866    if text.trim().is_empty() && parts.is_none() {
7867        return;
7868    }
7869    let mut msg = match parts {
7870        Some(parts) => ChatMessage {
7871            role: Role::User,
7872            content: None,
7873            content_parts: Some(parts),
7874            tool_calls: None,
7875            tool_call_id: None,
7876            name: None,
7877            metadata: Default::default(),
7878        },
7879        None => ChatMessage::user(text),
7880    };
7881    if let Some(ct) = v.get("customType").and_then(Value::as_str) {
7882        msg.metadata
7883            .insert("pi_custom_type".to_string(), ct.to_string());
7884    }
7885    if let Some(d) = v.get("display").and_then(Value::as_bool) {
7886        msg.metadata.insert("pi_display".to_string(), d.to_string());
7887    }
7888    if let Some(details) = v.get("details") {
7889        if !details.is_null() {
7890            msg.metadata
7891                .insert("pi_details".to_string(), details.to_string());
7892        }
7893    }
7894    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7895    // separately from the canonical entry-level ISO `timestamp` — see
7896    // `push_pi_user`. `v` here is the `message` object for the `role:
7897    // "custom"` case; for the top-level `custom_message` case `v` is the
7898    // entry itself, whose `timestamp` is the entry-level ISO string (not a
7899    // u64), so this is a no-op there — matching pre-WAVE-2 behavior.
7900    if let Some(ts) = v.get("timestamp").and_then(Value::as_u64) {
7901        msg.metadata
7902            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7903    }
7904    out.push(msg);
7905}
7906
7907/// pi's own prefix-wrapped user text for a `compaction` entry summary
7908/// (§2.1: "compaction/branch summaries (pi's own prefix-wrapped user text)").
7909/// The exact upstream wrapper string is cited (`msg:11-17`) but not
7910/// reproduced in the frozen research doc; this is a clearly-labeled
7911/// reconstruction, not pi's literal bytes — see build-report ambiguity note.
7912fn push_pi_compaction(entry_v: &Value, out: &mut Vec<ChatMessage>) {
7913    let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
7914    if summary.trim().is_empty() {
7915        return;
7916    }
7917    let mut msg = ChatMessage::user(format!("[compaction summary]\n{summary}"));
7918    msg.metadata
7919        .insert("pi_type".to_string(), "compaction".to_string());
7920    if let Some(fk) = entry_v.get("firstKeptEntryId").and_then(Value::as_str) {
7921        msg.metadata
7922            .insert("pi_first_kept_entry_id".to_string(), fk.to_string());
7923    }
7924    if let Some(tb) = entry_v.get("tokensBefore").and_then(Value::as_u64) {
7925        msg.metadata
7926            .insert("pi_tokens_before".to_string(), tb.to_string());
7927    }
7928    if let Some(d) = entry_v.get("details") {
7929        if !d.is_null() {
7930            msg.metadata.insert("pi_details".to_string(), d.to_string());
7931        }
7932    }
7933    if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
7934        msg.metadata
7935            .insert("pi_from_hook".to_string(), "true".to_string());
7936    }
7937    out.push(msg);
7938}
7939
7940/// pi's own prefix-wrapped user text for a `branch_summary` entry (a
7941/// rewind-with-summary) — same reconstruction caveat as
7942/// [`push_pi_compaction`].
7943fn push_pi_branch_summary(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!("[branch summary]\n{summary}"));
7949    msg.metadata
7950        .insert("pi_type".to_string(), "branch_summary".to_string());
7951    if let Some(f) = entry_v.get("fromId").and_then(Value::as_str) {
7952        msg.metadata.insert("pi_from_id".to_string(), f.to_string());
7953    }
7954    if let Some(d) = entry_v.get("details") {
7955        if !d.is_null() {
7956            msg.metadata.insert("pi_details".to_string(), d.to_string());
7957        }
7958    }
7959    if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
7960        msg.metadata
7961            .insert("pi_from_hook".to_string(), "true".to_string());
7962    }
7963    out.push(msg);
7964}
7965
7966// ---- OpenCode ---------------------------------------------------------
7967
7968/// The placeholder opencode's own replay substitutes for a `tool` part's
7969/// output once `state.completed.time.compacted` is set
7970/// (`message-v2.ts:293-296 @fd9ee43`) — the REAL output is never actually
7971/// erased from the record (S1); it survives in `raw` and in this loader's
7972/// `metadata["oc_tool_output_compacted"]`.
7973pub const OPENCODE_COMPACTED_TOOL_PLACEHOLDER: &str = "[Old tool result content cleared]";
7974
7975fn capture_opencode_session_info(si: &Value, meta: &mut SessionMeta) -> Result<()> {
7976    restore_codex_provenance_from_top_level(si, meta)?;
7977    if let Some(id) = si.get("id").and_then(Value::as_str) {
7978        meta.session_id = Some(id.to_string());
7979    }
7980    if let Some(dir) = si.get("directory").and_then(Value::as_str) {
7981        meta.cwd = Some(PathBuf::from(dir));
7982    }
7983    if let Some(agent) = si.get("agent").and_then(Value::as_str) {
7984        meta.agent_id = Some(agent.to_string());
7985    }
7986    if let Some(model) = si.get("model") {
7987        let provider = model.get("providerID").and_then(Value::as_str);
7988        let id = model.get("id").and_then(Value::as_str);
7989        if let (Some(p), Some(i)) = (provider, id) {
7990            meta.model = Some(format!("{p}/{i}"));
7991        }
7992    }
7993    if let Some(project_id) = si.get("projectID").and_then(Value::as_str) {
7994        meta.lineage
7995            .insert("projectID".to_string(), project_id.to_string());
7996    }
7997    if let Some(slug) = si.get("slug").and_then(Value::as_str) {
7998        meta.lineage.insert("slug".to_string(), slug.to_string());
7999    }
8000    if let Some(ws) = si.get("workspaceID").and_then(Value::as_str) {
8001        meta.lineage
8002            .insert("workspaceID".to_string(), ws.to_string());
8003    }
8004    if let Some(parent) = si.get("parentID").and_then(Value::as_str) {
8005        meta.lineage
8006            .insert("parent_session_id".to_string(), parent.to_string());
8007        // Mirrored under the Codex-originated lineage key so the existing
8008        // generic `Session::reconstruct_tree` nests opencode subagent
8009        // sessions too, with no format-specific nesting pass (§2.1: "child
8010        // session's parentID ... → drives reconstruct_tree").
8011        meta.lineage
8012            .insert("parent_thread_id".to_string(), parent.to_string());
8013    }
8014    // D7: the other half of `synthesized_opencode_info`'s passthrough —
8015    // restores a captured Claude `fork-context-ref` so a Claude -> OpenCode
8016    // -> Claude round trip reconstructs the original record (mirrors
8017    // `capture_codex_session_meta`/`capture_pi_header`'s identical
8018    // `claude_fork_context_ref` restore for the Codex/Pi hops).
8019    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
8020        if let Some(v) = si.get("claude_fork_context_ref") {
8021            meta.lineage
8022                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
8023        }
8024    }
8025    Ok(())
8026}
8027
8028/// An opencode `User`/`Assistant` `file` part's image data-URI →
8029/// `content_parts` `image_url` entry (§2.1). Only `data:` URIs with an
8030/// `image/*` mime are mapped ("T3 clean for images; non-media residue") —
8031/// a bare filesystem path, an `https:` link, or a non-image mime is left as
8032/// raw-only residue (§2.3), never a corrupt/guessed `image_url`. `pub(crate)`
8033/// so [`crate::audit::audit_opencode_line`] can classify a `file` part's
8034/// coverage with the SAME test this loader uses to canonicalize it (D5) —
8035/// one definition of "is this file part actually replayed", not two.
8036#[doc(hidden)]
8037pub fn opencode_file_image_part(part: &Value) -> Option<Value> {
8038    let mime = part.get("mime").and_then(Value::as_str)?;
8039    let url = part.get("url").and_then(Value::as_str)?;
8040    if !mime.starts_with("image/") || !url.starts_with("data:") {
8041        return None;
8042    }
8043    Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
8044}
8045
8046/// B4: the part-`metadata` key `append_synthesized_opencode_messages`'s
8047/// `Role::System` arm stamps on the one `synthetic: true` text part of a
8048/// re-materialized content-bearing Claude `system` record (see that arm's
8049/// doc comment). Namespaced (`supercode_`-prefixed) so it can never collide
8050/// with real opencode/provider part metadata (e.g. `anthropic.signature`).
8051const OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: &str = "supercode_claude_system_subtype";
8052
8053/// Detect `append_synthesized_opencode_messages`'s own marker shape: a
8054/// `User` message with EXACTLY one `synthetic: true` text part carrying
8055/// [`OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY`] in its part-level `metadata`. Real
8056/// opencode data is never misclassified — a genuine opencode `synthetic`
8057/// text part never carries this supercode-namespaced key, and a real
8058/// multi-part user message (text + an attached file, say) never matches
8059/// (`parts.len() != 1` bails). Returns the original Claude `systemSubtype`
8060/// (e.g. `local_command`) on a match.
8061fn opencode_claude_system_subtype(parts: &[Value]) -> Option<String> {
8062    let [part] = parts else { return None };
8063    if part.get("type").and_then(Value::as_str) != Some("text") {
8064        return None;
8065    }
8066    if part.get("synthetic").and_then(Value::as_bool) != Some(true) {
8067        return None;
8068    }
8069    part.get("metadata")
8070        .and_then(|m| m.get(OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY))
8071        .and_then(Value::as_str)
8072        .map(str::to_string)
8073}
8074
8075/// The [`opencode_claude_system_subtype`] match arm: restores `Role::System`
8076/// and `metadata["systemSubtype"]` from the marked text part instead of
8077/// `push_opencode_user`'s generic `Role::User` path, so a Claude ->
8078/// OpenCode -> Claude round trip restores the exact original role, not just
8079/// the text. Content is never fabricated — only emitted when non-empty.
8080fn push_opencode_claude_system(
8081    msg_value: &Value,
8082    parts: &[Value],
8083    subtype: String,
8084    out: &mut Vec<ChatMessage>,
8085) {
8086    let Some(text) = parts
8087        .first()
8088        .and_then(|p| p.get("text"))
8089        .and_then(Value::as_str)
8090    else {
8091        return;
8092    };
8093    if text.trim().is_empty() {
8094        return;
8095    }
8096    let mut msg = ChatMessage::system(text.to_string()).with_meta("systemSubtype", subtype);
8097    set_opencode_msg_timestamp(&mut msg, msg_value);
8098    out.push(msg);
8099}
8100
8101/// Map an opencode `User` message (`msg_value`) + its parts to zero or one
8102/// canonical `Role::User` `ChatMessage` (§2.1). `text` parts concatenate
8103/// (an `ignored` one is never included — §2.2, "must not be re-emitted to
8104/// the model"); `file` parts with a recognized image shape become
8105/// `content_parts`. `System` (opencode's per-turn `User.system`) fills
8106/// `SessionMeta.system_prompt` on the first turn that carries it, and
8107/// `metadata["system"]` on every turn that does (§2.1: "System prompt is
8108/// per-user-message, not per-session").
8109/// Fold an opencode message envelope's `time.created` (unix-ms) into the
8110/// canonical `metadata["timestamp"]` (ISO-8601, WAVE-2 item 1) — the same
8111/// field claude/codex/pi loaders populate. Lossless to millisecond precision
8112/// (opencode's own wire granularity); a `None`/malformed `time.created`
8113/// leaves `metadata["timestamp"]` unset, so the writer falls back to
8114/// `SYNTH_TS`/`SYNTH_TS_MS`.
8115fn set_opencode_msg_timestamp(msg: &mut ChatMessage, msg_value: &Value) {
8116    if let Some(ms) = msg_value
8117        .get("time")
8118        .and_then(|t| t.get("created"))
8119        .and_then(Value::as_i64)
8120    {
8121        msg.metadata
8122            .insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
8123    }
8124}
8125
8126fn push_opencode_user(
8127    msg_value: &Value,
8128    parts: &[Value],
8129    out: &mut Vec<ChatMessage>,
8130    meta: &mut SessionMeta,
8131    first_system_seen: &mut bool,
8132) {
8133    let mut text = String::new();
8134    let mut image_parts: Vec<Value> = Vec::new();
8135    let mut has_ignored = false;
8136    for p in parts {
8137        match p.get("type").and_then(Value::as_str) {
8138            Some("text") => {
8139                if p.get("ignored").and_then(Value::as_bool) == Some(true) {
8140                    has_ignored = true;
8141                    continue; // must never be replayed (§2.2)
8142                }
8143                if let Some(t) = p.get("text").and_then(Value::as_str) {
8144                    push_str_field(&mut text, t);
8145                }
8146            }
8147            Some("file") => {
8148                if let Some(img) = opencode_file_image_part(p) {
8149                    image_parts.push(img);
8150                }
8151            }
8152            // reasoning/tool never appear on a User message; step-start,
8153            // step-finish, snapshot, patch, agent, subtask, retry have no
8154            // clean home (§2.3); compaction is read separately by the
8155            // caller (tail_start_id) and tagged onto the message below.
8156            _ => {}
8157        }
8158    }
8159
8160    let has_images = !image_parts.is_empty();
8161    if text.trim().is_empty() && !has_images {
8162        return;
8163    }
8164    let mut msg = if has_images {
8165        let mut all = Vec::new();
8166        if !text.trim().is_empty() {
8167            all.push(serde_json::json!({"type": "text", "text": text.clone()}));
8168        }
8169        all.extend(image_parts);
8170        ChatMessage {
8171            role: Role::User,
8172            content: None,
8173            content_parts: Some(all),
8174            tool_calls: None,
8175            tool_call_id: None,
8176            name: None,
8177            metadata: Default::default(),
8178        }
8179    } else {
8180        ChatMessage::user(text)
8181    };
8182
8183    if has_ignored {
8184        msg.metadata
8185            .insert("oc_has_ignored_part".to_string(), "true".to_string());
8186    }
8187    if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
8188        msg.metadata
8189            .insert("oc_message_id".to_string(), id.to_string());
8190    }
8191    if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
8192        msg.metadata.insert("agent".to_string(), agent.to_string());
8193    }
8194    if let Some(model) = msg_value.get("model") {
8195        if !model.is_null() {
8196            msg.metadata.insert("model".to_string(), model.to_string());
8197        }
8198    }
8199    if let Some(system) = msg_value.get("system").and_then(Value::as_str) {
8200        if !*first_system_seen {
8201            meta.system_prompt = Some(system.to_string());
8202            *first_system_seen = true;
8203        }
8204        msg.metadata
8205            .insert("system".to_string(), system.to_string());
8206    }
8207    for p in parts {
8208        if p.get("type").and_then(Value::as_str) == Some("compaction") {
8209            msg.metadata
8210                .insert("phase".to_string(), "compaction".to_string());
8211            if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
8212                msg.metadata
8213                    .insert("tail_start_id".to_string(), t.to_string());
8214            }
8215        }
8216    }
8217    set_opencode_msg_timestamp(&mut msg, msg_value);
8218    restore_grok_message_extension(msg_value, &mut msg);
8219    out.push(msg);
8220}
8221
8222/// Map an opencode `Assistant` message + its parts to a canonical
8223/// `Role::Assistant` `ChatMessage` (text + `tool_calls`), followed
8224/// immediately by one `Role::Tool` `ChatMessage` per `tool` part that
8225/// reached `completed`/`error` — the split-by-`callID` opencode's single
8226/// part→two-messages mapping (§2.1). `pending`/`running` calls (an
8227/// interrupted turn) synthesize no tool call/result of their own here; the
8228/// shared [`ensure_tool_results_paired`] fills that gap uniformly, exactly
8229/// like the other three loaders. A `tool` part whose `state.status` is none
8230/// of the four known values is skipped entirely — raw-only survival, never
8231/// guessed — so [`crate::audit::Corpus::OpenCode`] can flag it.
8232fn push_opencode_assistant(
8233    msg_value: &Value,
8234    parts: &[Value],
8235    out: &mut Vec<ChatMessage>,
8236    meta: &mut SessionMeta,
8237) {
8238    let mut text = String::new();
8239    let mut calls: Vec<ToolCall> = Vec::new();
8240    let mut thinking = String::new();
8241    let mut reasoning_seen = false;
8242    let mut thinking_sig: Option<String> = None;
8243    // (call_id, tool_name, the tool part itself) — deferred so the
8244    // assistant message (carrying `tool_calls`) is pushed FIRST, matching
8245    // every other loader's message ordering (call, then result).
8246    let mut tool_results: Vec<(String, String, Value)> = Vec::new();
8247
8248    for p in parts {
8249        match p.get("type").and_then(Value::as_str) {
8250            Some("text") => {
8251                if p.get("ignored").and_then(Value::as_bool) == Some(true) {
8252                    continue;
8253                }
8254                if let Some(t) = p.get("text").and_then(Value::as_str) {
8255                    push_str_field(&mut text, t);
8256                }
8257            }
8258            Some("reasoning") => {
8259                reasoning_seen = true;
8260                if let Some(t) = p.get("text").and_then(Value::as_str) {
8261                    push_str_field(&mut thinking, t);
8262                }
8263                if let Some(sig) = p
8264                    .get("metadata")
8265                    .and_then(|m| m.get("anthropic"))
8266                    .and_then(|a| a.get("signature"))
8267                    .and_then(Value::as_str)
8268                {
8269                    thinking_sig = Some(sig.to_string());
8270                }
8271            }
8272            Some("tool") => {
8273                let call_id = p.get("callID").and_then(Value::as_str).unwrap_or_default();
8274                let tool_name = p.get("tool").and_then(Value::as_str).unwrap_or_default();
8275                let status = p
8276                    .get("state")
8277                    .and_then(|s| s.get("status"))
8278                    .and_then(Value::as_str);
8279                let known_status = matches!(
8280                    status,
8281                    Some("pending") | Some("running") | Some("completed") | Some("error")
8282                );
8283                if call_id.is_empty() || !known_status {
8284                    // Unknown/unrecognized status, or a malformed part with
8285                    // no callID — raw-only survival, never synthesized.
8286                    continue;
8287                }
8288                let input = p
8289                    .get("state")
8290                    .and_then(|s| s.get("input"))
8291                    .cloned()
8292                    .unwrap_or_else(|| Value::Object(Default::default()));
8293                calls.push(function_call(call_id, tool_name, input.to_string()));
8294                if matches!(status, Some("completed") | Some("error")) {
8295                    tool_results.push((call_id.to_string(), tool_name.to_string(), p.clone()));
8296                }
8297            }
8298            // file/step-start/step-finish/snapshot/patch/agent/subtask/retry
8299            // — no clean home on an Assistant turn (§2.3).
8300            _ => {}
8301        }
8302    }
8303
8304    let before = out.len();
8305    push_assistant(out, text, calls);
8306    // A native OpenCode assistant record is transcript state even when it
8307    // has no parts. Real stores contain these after an interrupted/empty
8308    // model turn; dropping the record here loses its id, timestamp, model,
8309    // token/cost metadata, and shifts the conversation on every export.
8310    // Keep one empty canonical assistant message so all target writers can
8311    // preserve the turn. This also covers reasoning-only records (whose
8312    // reasoning payload is attached as metadata just below).
8313    if out.len() == before {
8314        let mut empty = ChatMessage {
8315            role: Role::Assistant,
8316            content: None,
8317            content_parts: None,
8318            tool_calls: None,
8319            tool_call_id: None,
8320            name: None,
8321            metadata: Default::default(),
8322        };
8323        if !reasoning_seen {
8324            empty
8325                .metadata
8326                .insert("empty_assistant_record".to_string(), "true".to_string());
8327        }
8328        out.push(empty);
8329    }
8330    if out.len() > before {
8331        let msg = out.last_mut().expect("just pushed");
8332        if reasoning_seen {
8333            msg.metadata.insert("thinking".to_string(), thinking);
8334        }
8335        if let Some(sig) = thinking_sig {
8336            msg.metadata.insert("thinking_signature".to_string(), sig);
8337        }
8338        if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
8339            msg.metadata
8340                .insert("oc_message_id".to_string(), id.to_string());
8341        }
8342        if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
8343            msg.metadata.insert("agent".to_string(), agent.to_string());
8344            if meta.agent_id.is_none() {
8345                meta.agent_id = Some(agent.to_string());
8346            }
8347        }
8348        let provider = msg_value.get("providerID").and_then(Value::as_str);
8349        let model_id = msg_value.get("modelID").and_then(Value::as_str);
8350        if let (Some(p), Some(i)) = (provider, model_id) {
8351            let full = format!("{p}/{i}");
8352            msg.metadata.insert("model".to_string(), full.clone());
8353            if meta.model.is_none() {
8354                meta.model = Some(full);
8355            }
8356        }
8357        if let Some(cwd) = msg_value
8358            .get("path")
8359            .and_then(|p| p.get("cwd"))
8360            .and_then(Value::as_str)
8361        {
8362            if meta.cwd.is_none() {
8363                meta.cwd = Some(PathBuf::from(cwd));
8364            }
8365        }
8366        if msg_value.get("summary").and_then(Value::as_bool) == Some(true) {
8367            msg.metadata
8368                .insert("is_summary".to_string(), "true".to_string());
8369        }
8370        for (key, field) in [
8371            ("finish", "finish"),
8372            ("variant", "variant"),
8373            ("mode", "mode"),
8374        ] {
8375            if let Some(s) = msg_value.get(field).and_then(Value::as_str) {
8376                msg.metadata.insert(key.to_string(), s.to_string());
8377            }
8378        }
8379        for (key, field) in [
8380            ("cost", "cost"),
8381            ("tokens", "tokens"),
8382            ("error", "error"),
8383            ("structured", "structured"),
8384        ] {
8385            if let Some(v) = msg_value.get(field) {
8386                if !v.is_null() {
8387                    msg.metadata.insert(key.to_string(), v.to_string());
8388                }
8389            }
8390        }
8391        // Subagent linkage (§2.1): a `task` tool's own `metadata` carries
8392        // the spawned child session id — keyed by callID so multiple `task`
8393        // calls in one message never collide.
8394        // `resolve_opencode_parent_tool_use_ids` reads these back once a
8395        // whole session set is loaded.
8396        for p in parts {
8397            if p.get("type").and_then(Value::as_str) == Some("tool")
8398                && p.get("tool").and_then(Value::as_str) == Some("task")
8399            {
8400                if let (Some(call_id), Some(child)) = (
8401                    p.get("callID").and_then(Value::as_str),
8402                    p.get("metadata")
8403                        .and_then(|m| m.get("sessionId"))
8404                        .and_then(Value::as_str),
8405                ) {
8406                    msg.metadata.insert(
8407                        format!("oc_task_child_session_id__{call_id}"),
8408                        child.to_string(),
8409                    );
8410                }
8411            }
8412        }
8413        set_opencode_msg_timestamp(msg, msg_value);
8414        restore_grok_message_extension(msg_value, msg);
8415    }
8416
8417    // Second pass: the paired Tool-role message for each completed/error
8418    // tool part, split by callID (§2.1 — "the SAME part carries call and
8419    // result").
8420    for (call_id, tool_name, part) in tool_results {
8421        let status = part
8422            .get("state")
8423            .and_then(|s| s.get("status"))
8424            .and_then(Value::as_str);
8425        let compacted_at = part
8426            .get("state")
8427            .and_then(|s| s.get("time"))
8428            .and_then(|t| t.get("compacted"))
8429            .and_then(Value::as_i64);
8430        let real_output = part
8431            .get("state")
8432            .and_then(|s| s.get("output"))
8433            .and_then(Value::as_str)
8434            .unwrap_or("")
8435            .to_string();
8436        let (content, is_error) = match status {
8437            Some("completed") => {
8438                if compacted_at.is_some() {
8439                    (OPENCODE_COMPACTED_TOOL_PLACEHOLDER.to_string(), false)
8440                } else {
8441                    (real_output.clone(), false)
8442                }
8443            }
8444            Some("error") => {
8445                let err = part
8446                    .get("state")
8447                    .and_then(|s| s.get("error"))
8448                    .and_then(Value::as_str)
8449                    .unwrap_or("")
8450                    .to_string();
8451                (err, true)
8452            }
8453            _ => (String::new(), false),
8454        };
8455        let mut tmsg = ChatMessage {
8456            role: Role::Tool,
8457            content: Some(content),
8458            content_parts: None,
8459            tool_calls: None,
8460            tool_call_id: Some(call_id),
8461            name: Some(tool_name),
8462            metadata: Default::default(),
8463        };
8464        if let Some(original_position) = part
8465            .get(OPENCODE_SUPERCODE_RESULT_POSITION)
8466            .and_then(Value::as_u64)
8467        {
8468            tmsg.metadata.insert(
8469                OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
8470                original_position.to_string(),
8471            );
8472        }
8473        if is_error {
8474            crate::mark_tool_error(&mut tmsg);
8475        }
8476        restore_tool_outcome_extension(&part, &mut tmsg);
8477        if let Some(ts) = compacted_at {
8478            // S1: the real output is preserved — reversible, never erased.
8479            tmsg.metadata
8480                .insert("oc_tool_output_compacted".to_string(), real_output);
8481            tmsg.metadata
8482                .insert("oc_tool_time_compacted".to_string(), ts.to_string());
8483        }
8484        if status == Some("completed") {
8485            if let Some(atts) = part
8486                .get("state")
8487                .and_then(|s| s.get("attachments"))
8488                .and_then(Value::as_array)
8489            {
8490                let images: Vec<Value> = atts.iter().filter_map(opencode_file_image_part).collect();
8491                if !images.is_empty() {
8492                    // D-mix consistency fix (Fable-recommended, same
8493                    // pattern as `push_claude_user`'s tool_result arm above):
8494                    // a completed opencode tool part with BOTH `state.output`
8495                    // text and `state.attachments` images is the same
8496                    // non-self-contained hybrid shape — `content_parts` here
8497                    // used to hold images only, so opencode -> pi silently
8498                    // dropped the output text (`pi_content_value` reads
8499                    // `content_parts` exclusively for `Role::Tool`). Prepend
8500                    // the text as part 0 so `content_parts` is
8501                    // self-contained; `tmsg.content` keeps the text too,
8502                    // unchanged, for writers that read it from there and
8503                    // only scan `content_parts` for `image_url` entries.
8504                    let mut parts = Vec::new();
8505                    if let Some(t) = &tmsg.content {
8506                        if !t.is_empty() {
8507                            parts.push(serde_json::json!({"type": "text", "text": t}));
8508                        }
8509                    }
8510                    parts.extend(images);
8511                    tmsg.content_parts = Some(parts);
8512                }
8513            }
8514        }
8515        if let Some(id) = part.get("id").and_then(Value::as_str) {
8516            tmsg.metadata
8517                .insert("oc_part_id".to_string(), id.to_string());
8518        }
8519        // WAVE-2 item 1: a tool part's own `state.time.{end,start}` (unix-ms,
8520        // real opencode wire shape — `pi-fields.md`/`opencode-pi-spec.md`
8521        // cite `state.time.compacted`, but the SAME object also carries
8522        // `start`/`end` on every completed/error call) is this Tool
8523        // message's real source timestamp; prefer `end` (completion, closer
8524        // to when the RESULT — this message's content — was produced) and
8525        // fall back to `start` when only that is present.
8526        let tool_ts = part
8527            .get("state")
8528            .and_then(|s| s.get("time"))
8529            .and_then(|t| t.get("end").or_else(|| t.get("start")))
8530            .and_then(Value::as_i64);
8531        if let Some(ms) = tool_ts {
8532            tmsg.metadata
8533                .insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
8534        }
8535        // OpenCode folds a canonical tool result into the assistant's tool
8536        // part. Restore the portable envelope from that part after native
8537        // fields have been captured so A -> OpenCode -> A retains fields
8538        // OpenCode does not model independently (for example Goose's
8539        // message-level metadata and an intentionally absent tool name).
8540        restore_grok_message_extension(&part, &mut tmsg);
8541        out.push(tmsg);
8542    }
8543}
8544
8545// ---- shared helpers -------------------------------------------------------
8546
8547fn push_text(buf: &mut String, v: Option<&Value>) {
8548    if let Some(Value::String(s)) = v {
8549        if !buf.is_empty() {
8550            buf.push('\n');
8551        }
8552        buf.push_str(s);
8553    }
8554}
8555
8556/// Extract a Claude `tool_result` block's content, preserving non-text items
8557/// instead of silently dropping them:
8558///
8559/// - text blocks are concatenated;
8560/// - PARITY-11 (nested images, real-session-confirmed — the everyday "Read a
8561///   PNG / screenshot tool output" shape): `image` blocks are captured into
8562///   the returned `content_parts`-shaped `Vec<Value>` via
8563///   [`claude_image_block_to_part`] — the SAME base64/url conversion the
8564///   top-level `image` content-block path (`push_claude_user`) already uses
8565///   — instead of being flattened to the bare `[image]` marker text that used
8566///   to make the data unrecoverable from every writer. An unconvertible
8567///   source (D5 discipline — a Files-API `{"type":"file",...}` reference,
8568///   etc.) folds [`UNCONVERTIBLE_IMAGE_MARKER`] into the text instead of
8569///   vanishing, exactly like the top-level path;
8570/// - `tool_reference` blocks become `[tool_reference: <name>]`;
8571///
8572/// and if the block yields no text/images at all, fall back to the record's
8573/// `toolUseResult` field (string used directly, structured value serialized),
8574/// which is where Claude Code stores the actual result in many cases.
8575///
8576/// Returns `(text, images)`; callers that only need the old text-only
8577/// behavior can ignore the second element — every caller MUST fold non-empty
8578/// `images` into the resulting `ChatMessage.content_parts` themselves (this
8579/// function has no `ChatMessage` to attach to).
8580fn extract_tool_result_content(
8581    content: Option<&Value>,
8582    tool_use_result: Option<&Value>,
8583) -> (String, Vec<Value>) {
8584    let mut parts: Vec<String> = Vec::new();
8585    let mut images: Vec<Value> = Vec::new();
8586    match content {
8587        Some(Value::String(s)) => {
8588            if !s.is_empty() {
8589                parts.push(s.clone());
8590            }
8591        }
8592        Some(Value::Array(items)) => {
8593            for item in items {
8594                match item.get("type").and_then(Value::as_str) {
8595                    Some("text") => {
8596                        if let Some(t) = item.get("text").and_then(Value::as_str) {
8597                            parts.push(t.to_string());
8598                        }
8599                    }
8600                    Some("image") => match claude_image_block_to_part(item) {
8601                        Some(part) => images.push(part),
8602                        None => parts.push(UNCONVERTIBLE_IMAGE_MARKER.to_string()),
8603                    },
8604                    Some("tool_reference") => {
8605                        let name = item.get("tool_name").and_then(Value::as_str).unwrap_or("?");
8606                        parts.push(format!("[tool_reference: {name}]"));
8607                    }
8608                    _ => {
8609                        if let Some(s) = item.as_str() {
8610                            parts.push(s.to_string());
8611                        }
8612                    }
8613                }
8614            }
8615        }
8616        Some(other) => parts.push(other.to_string()),
8617        None => {}
8618    }
8619
8620    let joined = parts.join("\n");
8621    if !joined.trim().is_empty() || !images.is_empty() {
8622        return (joined, images);
8623    }
8624    // Empty tool_result content — recover from toolUseResult.
8625    match tool_use_result {
8626        Some(Value::String(s)) => (s.clone(), images),
8627        Some(v) => (v.to_string(), images),
8628        None => (joined, images),
8629    }
8630}
8631
8632/// Pull readable text out of a content value that may be a plain string or an
8633/// array of `{ "text": "..." }`-bearing blocks (any block type).
8634fn extract_text_content(v: Option<&Value>) -> String {
8635    match v {
8636        Some(Value::String(s)) => s.clone(),
8637        Some(Value::Array(items)) => {
8638            let mut parts = Vec::new();
8639            for item in items {
8640                if let Some(t) = item.get("text").and_then(Value::as_str) {
8641                    parts.push(t.to_string());
8642                } else if let Some(s) = item.as_str() {
8643                    parts.push(s.to_string());
8644                }
8645            }
8646            parts.join("\n")
8647        }
8648        Some(other) => other.to_string(),
8649        None => String::new(),
8650    }
8651}
8652
8653/// Extract Codex `input_image` content blocks from a `message` response_item's
8654/// `content` value into `content_parts` `image_url` entries — the inverse of
8655/// [`codex_message_content_blocks`]'s `input_image` emission (IX-5). Only a
8656/// block whose `image_url` is a non-empty string is recognized; anything else
8657/// (a missing/malformed `image_url`, a `file_id`-only reference) is left as
8658/// raw-only residue rather than synthesizing a corrupt/empty part — mirroring
8659/// the pi/opencode/Claude loaders' image-shape discipline.
8660fn codex_extract_images(content: Option<&Value>) -> Vec<Value> {
8661    let Some(Value::Array(items)) = content else {
8662        return Vec::new();
8663    };
8664    items
8665        .iter()
8666        .filter(|item| item.get("type").and_then(Value::as_str) == Some("input_image"))
8667        .filter_map(|item| {
8668            let url = item.get("image_url").and_then(Value::as_str)?;
8669            if url.is_empty() {
8670                return None;
8671            }
8672            Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
8673        })
8674        .collect()
8675}
8676
8677fn value_to_arg_string(v: &Value) -> String {
8678    match v {
8679        Value::String(s) => s.clone(),
8680        other => other.to_string(),
8681    }
8682}
8683
8684fn push_gemini_user_parts(
8685    messages: &mut Vec<ChatMessage>,
8686    content_parts: Vec<Value>,
8687    timestamp: Option<&str>,
8688    source: &Value,
8689) {
8690    if content_parts.is_empty() {
8691        return;
8692    }
8693    let mut message = ChatMessage {
8694        role: Role::User,
8695        content: None,
8696        content_parts: Some(content_parts),
8697        tool_calls: None,
8698        tool_call_id: None,
8699        name: None,
8700        metadata: Default::default(),
8701    };
8702    if let Some(timestamp) = timestamp {
8703        message
8704            .metadata
8705            .insert("timestamp".into(), timestamp.into());
8706    }
8707    restore_gemini_message_extension(source, &mut message);
8708    messages.push(message);
8709}
8710
8711fn function_call(id: &str, name: &str, arguments: String) -> ToolCall {
8712    ToolCall {
8713        id: id.to_string(),
8714        kind: "function".to_string(),
8715        function: FunctionCall {
8716            name: name.to_string(),
8717            arguments,
8718        },
8719    }
8720}
8721
8722fn tool_message(tool_call_id: &str, content: String) -> ChatMessage {
8723    ChatMessage {
8724        role: Role::Tool,
8725        content: Some(content),
8726        content_parts: None,
8727        tool_calls: None,
8728        tool_call_id: Some(tool_call_id.to_string()),
8729        name: None,
8730        metadata: Default::default(),
8731    }
8732}
8733
8734/// Emit a single assistant message combining accumulated text and tool calls.
8735/// A turn with neither (e.g. thinking-only) produces nothing.
8736fn push_assistant(out: &mut Vec<ChatMessage>, text: String, calls: Vec<ToolCall>) {
8737    let has_text = !text.trim().is_empty();
8738    if !has_text && calls.is_empty() {
8739        return;
8740    }
8741    out.push(ChatMessage {
8742        role: Role::Assistant,
8743        content: has_text.then_some(text),
8744        content_parts: None,
8745        tool_calls: (!calls.is_empty()).then_some(calls),
8746        tool_call_id: None,
8747        name: None,
8748        metadata: Default::default(),
8749    });
8750}
8751
8752// ---- writers --------------------------------------------------------------
8753
8754/// A placeholder timestamp for SYNTHESIZED records only — WAVE-2 item 1's
8755/// fallback (`docs/interop` build brief): every writer now emits a message's
8756/// REAL source timestamp (`metadata["timestamp"]`, the canonical ISO-8601
8757/// field every loader populates) when one is present. `SYNTH_TS` fires only
8758/// for a message with no source timestamp at all — a turn synthesized/
8759/// appended after import (the live agent loop, a splice's appended tail,
8760/// ...), which was never loaded from a real per-message timestamp to begin
8761/// with. Both tools tolerate identical timestamps; callers that need real
8762/// ones for a synthesized turn can post-process.
8763const SYNTH_TS: &str = "2026-01-01T00:00:00.000Z";
8764
8765/// The unix-millisecond twin of [`SYNTH_TS`], for OpenCode's numeric
8766/// `time.created`/`time.updated` fields.
8767const SYNTH_TS_MS: i64 = 1_767_225_600_000;
8768
8769/// `msg`'s real source timestamp (`metadata["timestamp"]`, canonical
8770/// ISO-8601 — WAVE-2 item 1) when present AND well-formed, else the
8771/// [`SYNTH_TS`] fallback. Validated via [`crate::sidecar::rfc3339_to_ms`] (a
8772/// parse, not just a presence check) so an absent, empty, or malformed
8773/// source value all degrade to the same documented fallback rather than
8774/// propagating garbage verbatim. Used by every writer that emits an
8775/// ISO-8601 timestamp field
8776/// (Claude Code, Codex, pi's entry-level `timestamp`).
8777fn msg_timestamp_or_synth(msg: &ChatMessage) -> &str {
8778    match msg.metadata.get("timestamp") {
8779        Some(ts) if crate::sidecar::rfc3339_to_ms(ts).is_some() => ts.as_str(),
8780        _ => SYNTH_TS,
8781    }
8782}
8783
8784/// OpenCode reloads an export document by sorting messages on
8785/// `time.created`, so a timestamp-less appended continuation cannot reuse
8786/// the fixed historical [`SYNTH_TS_MS`] fallback when its imported prefix is
8787/// newer. Advance a deterministic cursor for synthesized clocks while still
8788/// preserving every real source timestamp verbatim.
8789fn opencode_message_timestamp(msg: &ChatMessage, cursor: &mut i64) -> Result<i64> {
8790    if let Some(real) = msg
8791        .metadata
8792        .get("timestamp")
8793        .and_then(|s| crate::sidecar::rfc3339_to_ms(s))
8794    {
8795        // A NativeTurn timestamp is durable provenance minted by supercode,
8796        // not an OpenCode source clock that must be replayed verbatim.
8797        // Multiple turns may be recorded in the same millisecond, while
8798        // OpenCode sorts solely by `time.created`; allocate such turns after
8799        // the existing cursor so their persisted order cannot collapse. This
8800        // also preserves the fail-closed i64::MAX exhaustion behavior.
8801        if msg.metadata.contains_key("supercode_native_uuid") && real <= *cursor {
8802            *cursor = cursor.checked_add(1).ok_or_else(|| {
8803                crate::Error::Other(
8804                    "cannot synthesize an OpenCode continuation timestamp after i64::MAX"
8805                        .to_string(),
8806                )
8807            })?;
8808            return Ok(*cursor);
8809        }
8810        *cursor = (*cursor).max(real);
8811        return Ok(real);
8812    }
8813    let next = cursor.checked_add(1).ok_or_else(|| {
8814        crate::Error::Other(
8815            "cannot synthesize an OpenCode continuation timestamp after i64::MAX".to_string(),
8816        )
8817    })?;
8818    *cursor = next.max(SYNTH_TS_MS);
8819    Ok(*cursor)
8820}
8821
8822/// Largest integer nested under any OpenCode `time` object. Imported
8823/// prefixes carry more clocks than `message.time.created` (assistant
8824/// completion, tool start/end, session updated); a synthesized continuation
8825/// must follow all of them, not merely sort after message creation times.
8826fn opencode_max_timestamp(value: &Value) -> Option<i64> {
8827    fn max_number(value: &Value) -> Option<i64> {
8828        match value {
8829            Value::Number(n) => n.as_i64(),
8830            Value::Array(values) => values.iter().filter_map(max_number).max(),
8831            Value::Object(fields) => fields.values().filter_map(max_number).max(),
8832            _ => None,
8833        }
8834    }
8835
8836    match value {
8837        Value::Array(values) => values.iter().filter_map(opencode_max_timestamp).max(),
8838        Value::Object(fields) => fields
8839            .iter()
8840            .filter_map(|(key, value)| {
8841                if key == "time" {
8842                    max_number(value)
8843                } else {
8844                    opencode_max_timestamp(value)
8845                }
8846            })
8847            .max(),
8848        _ => None,
8849    }
8850}
8851
8852/// pi's own message-level unix-ms clock (`metadata["pi_msg_timestamp"]`,
8853/// restored by the pi loader's `push_pi_*` helpers) — DISTINCT from the
8854/// canonical entry-level ISO `metadata["timestamp"]` [`msg_timestamp_or_synth`]
8855/// reads. The two carry genuinely different values in real pi corpora (a
8856/// message-level clock reading vs. the entry's own wall-clock stamp), so this
8857/// is intentionally its own accessor. Used ONLY by [`Session::write_pi_entries`]'s
8858/// nested `message.timestamp` field, so a pi -> pi native round-trip
8859/// preserves the source message-level clock value-exact instead of deriving
8860/// it from the (distinct) entry-level timestamp. Falls back to
8861/// [`SYNTH_TS_MS`] for a message that never carried a pi message-level clock
8862/// reading (non-pi-sourced, or a synthesized/appended turn).
8863fn msg_pi_native_timestamp_ms(msg: &ChatMessage) -> i64 {
8864    msg.metadata
8865        .get("pi_msg_timestamp")
8866        .and_then(|s| s.parse::<i64>().ok())
8867        .unwrap_or(SYNTH_TS_MS)
8868}
8869
8870/// A format-valid (v4-shaped) but deterministic uuid, derived from a counter.
8871fn synth_uuid(n: usize) -> String {
8872    format!("00000000-0000-4000-8000-{n:012x}")
8873}
8874
8875/// R1 (Skeptic B, spliced-export uuid-collision hardening — the same bug
8876/// class N2 closed for the Codex spliced path's group ids, see
8877/// `collect_codex_group_ids_from_raw`/`write_codex_records`): mint the next
8878/// `synth_uuid`, skipping any value already present in `seed_used_ids` — the
8879/// uuids [`collect_claude_uuids_from_raw`] found already sitting in the
8880/// verbatim raw prefix [`Session::to_claude_code_jsonl_spliced`] replays
8881/// ahead of the tail this counter mints. Without this, re-splicing a
8882/// previously-exported-then-reimported session (export -> reimport -> append
8883/// -> export again) restarts `counter` at 1 with no memory of the prior
8884/// export's tail uuids now sitting in the prefix, so the second tail
8885/// fabricates the SAME `00000000-0000-4000-8000-…` values the first one did
8886/// — a uuid collision across prefix and tail that can mis-link any
8887/// uuid-keyed consumer (fork/tree lineage, `parentUuid`). `counter` keeps
8888/// climbing monotonically even across skips. `used_ids` is also updated for
8889/// each minted or metadata-backed identity, so collisions are prevented both
8890/// against the replayed prefix and within the appended tail.
8891fn next_claude_uuid(counter: &mut usize, used_ids: &mut HashSet<String>) -> String {
8892    loop {
8893        let candidate = synth_uuid(*counter);
8894        *counter += 1;
8895        if used_ids.insert(candidate.clone()) {
8896            return candidate;
8897        }
8898    }
8899}
8900
8901/// Reuse a message's durable native/source UUID when available, falling back
8902/// to the deterministic synthesized sequence only for hand-built or legacy
8903/// messages that never carried identity metadata.
8904fn claude_message_uuid(
8905    msg: &ChatMessage,
8906    counter: &mut usize,
8907    used_ids: &mut HashSet<String>,
8908) -> String {
8909    for key in ["claude_uuid", "supercode_native_uuid"] {
8910        if let Some(candidate) = msg.metadata.get(key) {
8911            if !candidate.is_empty() && used_ids.insert(candidate.clone()) {
8912                return candidate.clone();
8913            }
8914        }
8915    }
8916    next_claude_uuid(counter, used_ids)
8917}
8918
8919/// Companion to [`next_claude_uuid`]: every `uuid` already present in
8920/// `raw_prefix` — the verbatim RAW lines
8921/// [`Session::to_claude_code_jsonl_spliced`] replays ahead of the appended
8922/// tail it synthesizes via [`Session::write_claude_code_records`]. This is
8923/// the GROUND TRUTH of what physically lands in the exported `out` string
8924/// for the prefix (mirrors `collect_codex_group_ids_from_raw`'s approach on
8925/// the Codex side): each line is parsed as a Claude Code JSONL record and
8926/// its own top-level `uuid` field is read back out of the bytes directly, no
8927/// re-derivation from `self.messages` needed. A line that fails to parse, or
8928/// parses but carries no `uuid` (e.g. a trailing `file-history-snapshot`
8929/// record), contributes nothing.
8930fn collect_claude_uuids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
8931    let mut ids = HashSet::new();
8932    for line in raw_prefix {
8933        if let Ok(v) = serde_json::from_str::<Value>(line) {
8934            if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
8935                ids.insert(uuid.to_string());
8936            }
8937        }
8938    }
8939    ids
8940}
8941
8942fn push_jsonl(out: &mut String, value: &Value) {
8943    out.push_str(&value.to_string());
8944    out.push('\n');
8945}
8946
8947/// Replay a raw (verbatim) JSONL `line` into `out`, rewriting `key` to
8948/// `new_id` when the line parses as a JSON object carrying that key — used
8949/// by A12's Claude Code splice, where the session id lives at the top level
8950/// of (almost) every record under `key = "sessionId"`. A line that fails to
8951/// parse, or parses but lacks `key`, is copied through byte-for-byte
8952/// (nothing to patch, so nothing is reserialized).
8953fn push_spliced_line(out: &mut String, line: &str, new_id: Option<&str>, key: &str) {
8954    if let Some(new_id) = new_id {
8955        if let Ok(mut v) = serde_json::from_str::<Value>(line) {
8956            if v.get(key).is_some() {
8957                v[key] = Value::String(new_id.to_string());
8958                out.push_str(&v.to_string());
8959                out.push('\n');
8960                return;
8961            }
8962        }
8963    }
8964    out.push_str(line);
8965    out.push('\n');
8966}
8967
8968impl Session {
8969    fn cwd_string(&self) -> String {
8970        self.meta
8971            .cwd
8972            .as_ref()
8973            .map(|p| p.to_string_lossy().into_owned())
8974            .unwrap_or_else(|| ".".to_string())
8975    }
8976
8977    /// `(raw_prefix_len, message_prefix_len)` for A12 splicing: how many
8978    /// leading `raw` lines / `messages` came from the imported log, as
8979    /// opposed to being appended after import.
8980    ///
8981    /// `imported_message_count` (see its doc comment) pins the message-side
8982    /// boundary directly. The raw-side boundary isn't separately tracked —
8983    /// [`Self::from_native_str`]'s turn-appending loop pushes exactly one
8984    /// `raw` line per appended message, so the two lists grow by the same
8985    /// `appended_count` from the same starting point, and
8986    /// `raw.len() - appended_count` recovers it without a second counter.
8987    fn spliced_prefix_lens(&self) -> (usize, usize) {
8988        let message_prefix_len = self
8989            .imported_message_count
8990            .unwrap_or(self.messages.len())
8991            .min(self.messages.len());
8992        let appended_count = self.messages.len() - message_prefix_len;
8993        let raw_prefix_len = self.raw.len().saturating_sub(appended_count);
8994        (raw_prefix_len, message_prefix_len)
8995    }
8996
8997    /// Synthesize a Claude Code transcript.
8998    ///
8999    /// Claude Code transcripts have no slot for the *session-level system
9000    /// prompt* (that lives in the CLI, not the log) — but PARITY-6 dev/02
9001    /// (Fable-5 corpus audit) surfaced that content-bearing `System`
9002    /// `ChatMessage`s (Claude's own `type: "system"` records with a
9003    /// content-bearing `subtype` like `local_command`/`scheduled_task_fire`/
9004    /// `away_summary` — see `push_claude_system`, the exact inverse of what
9005    /// this writer now does) DO have a first-class slot: the real `type:
9006    /// "system"` record itself. This function used to unconditionally drop
9007    /// every `System` message, silently losing e.g. a real
9008    /// `<local-command-stdout>` record on any format -> Claude Code hop
9009    /// (confirmed on a real 2,982-message corpus session: Codex -> Claude
9010    /// Code dropped its one surviving `system` message, 2215 -> 2214, with
9011    /// no loss manifest). `write_claude_code_records`'s `Role::System` arm
9012    /// now re-materializes it instead.
9013    fn to_claude_code_jsonl(&self) -> String {
9014        let session_id = self
9015            .meta
9016            .session_id
9017            .clone()
9018            .unwrap_or_else(|| synth_uuid(0));
9019        let cwd = self.cwd_string();
9020        let mut out = String::new();
9021        // PARITY-10: a captured `fork-context-ref` (see `capture_claude_meta`)
9022        // re-emitted byte-for-byte, ahead of the conversation it applies to —
9023        // this is what makes the record survive the SEMANTIC Claude Code
9024        // writer (the raw-passthrough diagonal in `crates/cli` already
9025        // preserves it by construction; this covers the library `to_jsonl`
9026        // path too, e.g. a `--session-id` override that forces the semantic
9027        // writer).
9028        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
9029            out.push_str(raw);
9030            out.push('\n');
9031        }
9032        // Full synthesis: `out` at this point has no raw prefix ahead of it
9033        // (unlike the A12 splice below), so there are no uuids yet in play
9034        // to seed against — see `next_claude_uuid`'s doc comment.
9035        self.write_claude_code_records(
9036            &mut out,
9037            &self.messages,
9038            &session_id,
9039            &cwd,
9040            None,
9041            1,
9042            &HashSet::new(),
9043        );
9044        if let Some(extension) = codex_provenance_envelope(&self.meta) {
9045            if out.is_empty() {
9046                push_jsonl(
9047                    &mut out,
9048                    &serde_json::json!({
9049                        "type": "file-history-snapshot",
9050                        "messageId": synth_uuid(1),
9051                        "snapshot": {},
9052                        "sessionId": session_id,
9053                        "cwd": cwd,
9054                        "timestamp": SYNTH_TS,
9055                    }),
9056                );
9057            }
9058            inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
9059        }
9060        out
9061    }
9062
9063    /// Synthesize Claude Code records for `messages` (a full session or an
9064    /// appended tail — A12's [`Self::to_jsonl_spliced`] reuses this for just
9065    /// the latter), starting the `parentUuid` chain at `parent` and the
9066    /// `synth_uuid` counter at `counter`. Factored out of
9067    /// [`Self::to_claude_code_jsonl`] so the record shape is defined once.
9068    ///
9069    /// `seed_used_ids` primes [`next_claude_uuid`]'s collision guard with
9070    /// every uuid that will ALREADY be present in `out` before this call
9071    /// ever runs — see that function's doc comment for why the A12 splice
9072    /// path needs this and full synthesis doesn't.
9073    // R1: this was already at clippy's `too_many_arguments` threshold (7,
9074    // including `&self`) before the fix; the added `seed_used_ids` param
9075    // pushes it to 8. Every argument here is independently meaningful (two
9076    // record-shape inputs, two id/parent-chain threading values, and now
9077    // the collision seed) — bundling them into a params struct is a larger
9078    // refactor of this already-widely-called private helper than the R1 fix
9079    // warrants, so this is allowed rather than restructured.
9080    #[allow(clippy::too_many_arguments)]
9081    fn write_claude_code_records(
9082        &self,
9083        out: &mut String,
9084        messages: &[ChatMessage],
9085        session_id: &str,
9086        cwd: &str,
9087        mut parent: Option<String>,
9088        mut counter: usize,
9089        seed_used_ids: &HashSet<String>,
9090    ) {
9091        let mut used_ids = seed_used_ids.clone();
9092        for msg in messages {
9093            if is_replay_excluded(msg) {
9094                continue;
9095            }
9096            let blocks: Vec<Value> = match msg.role {
9097                // PARITY-6 dev/02: re-materialize a content-bearing System
9098                // `ChatMessage` as a real Claude Code `type: "system"`
9099                // record — the exact inverse of `push_claude_system`, which
9100                // is what produced it in the first place for a message
9101                // loaded FROM a real Claude Code transcript. `subtype`
9102                // prefers the original `systemSubtype` metadata
9103                // (`push_claude_system`'s `.with_meta`, round-tripped
9104                // through the Codex hop via `write_codex_records`'s
9105                // `claude_system_subtype` metadata channel and restored by
9106                // `push_codex_item`); when that channel didn't carry it
9107                // (e.g. a genuinely native, non-Claude-origin developer
9108                // message), fall back to `local_command` — the observed
9109                // common case, and still one of `push_claude_system`'s own
9110                // `keep` subtypes, so the record survives a *subsequent*
9111                // reload rather than being silently re-dropped. This never
9112                // fabricates content: the real text is always carried
9113                // verbatim, only the subtype label is a best-effort guess
9114                // when the true one wasn't recoverable.
9115                Role::System => {
9116                    let content = msg.content.clone().unwrap_or_default();
9117                    if content.trim().is_empty() {
9118                        continue;
9119                    }
9120                    let subtype = msg
9121                        .metadata
9122                        .get("systemSubtype")
9123                        .cloned()
9124                        .unwrap_or_else(|| "local_command".to_string());
9125                    // R1/B3 union: this mint must ALSO route through
9126                    // `next_claude_uuid` + `seed_used_ids` like the other
9127                    // three arms below — otherwise this System arm (added by
9128                    // B3 after R1 landed) mints a raw `synth_uuid` that can
9129                    // collide with a uuid already sitting in the A12 splice's
9130                    // raw prefix (see `next_claude_uuid`'s doc comment).
9131                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9132                    let mut line = serde_json::json!({
9133                        "parentUuid": parent,
9134                        "type": "system",
9135                        "subtype": subtype,
9136                        "content": content,
9137                        "uuid": uuid,
9138                        "sessionId": session_id,
9139                        "cwd": cwd,
9140                        "timestamp": msg_timestamp_or_synth(msg),
9141                    });
9142                    set_grok_message_extension(&mut line, self.meta.source, msg);
9143                    push_jsonl(out, &line);
9144                    parent = Some(uuid);
9145                    continue;
9146                }
9147                Role::User => {
9148                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9149                    let mut line = serde_json::json!({
9150                        "parentUuid": parent,
9151                        "type": "user",
9152                        "message": {
9153                            "role": "user",
9154                            "content": claude_user_content_value(msg),
9155                        },
9156                        "uuid": uuid,
9157                        "sessionId": session_id,
9158                        "cwd": cwd,
9159                        "timestamp": msg_timestamp_or_synth(msg),
9160                    });
9161                    set_grok_message_extension(&mut line, self.meta.source, msg);
9162                    push_jsonl(out, &line);
9163                    parent = Some(uuid);
9164                    continue;
9165                }
9166                Role::Tool => {
9167                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9168                    let mut line = serde_json::json!({
9169                        "parentUuid": parent,
9170                        "type": "user",
9171                        "message": {
9172                            "role": "user",
9173                            "content": [{
9174                                "type": "tool_result",
9175                                "tool_use_id": msg.tool_call_id.clone().unwrap_or_default(),
9176                                "content": claude_tool_result_content_value(msg),
9177                            }],
9178                        },
9179                        "uuid": uuid,
9180                        "sessionId": session_id,
9181                        "cwd": cwd,
9182                        "timestamp": msg_timestamp_or_synth(msg),
9183                    });
9184                    if crate::tool_outcome(msg) == crate::ToolOutcome::Unknown {
9185                        line[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
9186                    }
9187                    set_grok_message_extension(&mut line, self.meta.source, msg);
9188                    push_jsonl(out, &line);
9189                    parent = Some(uuid);
9190                    continue;
9191                }
9192                Role::Assistant => {
9193                    let mut blocks = Vec::new();
9194                    // PARITY-16 (found via the REAL pi corpus, PARITY-5
9195                    // dev/01): thinking/redacted_thinking must be re-emitted
9196                    // BEFORE text/tool_use, unconditionally whenever
9197                    // retained metadata is present — not only when `blocks`
9198                    // is otherwise empty. The previous `if blocks.is_empty()`
9199                    // gate (now below, applied unconditionally instead)
9200                    // meant a turn that thinks AND THEN answers/calls a tool
9201                    // in the SAME turn — pi's own default emission shape,
9202                    // and the overwhelmingly common real-world case for any
9203                    // reasoning model, not the rare reasoning-only edge case
9204                    // this gate's comment described — silently dropped its
9205                    // entire `thinking` block on Pi -> Claude Code export. A
9206                    // genuine multi-turn pi session driven through pi's own
9207                    // real Agent loop (faux provider, see
9208                    // `pi_interop.rs`'s live-corpus tests) exposed this: its
9209                    // thinking+text turns lost the thinking block entirely.
9210                    // D8: prefer the exact per-block list when present —
9211                    // every `thinking`/`redacted_thinking` block re-emitted
9212                    // SEPARATELY with its own signature/data, exactly as
9213                    // captured (`push_claude_assistant`), instead of the
9214                    // legacy singular fields' lossy collapse (which drops
9215                    // every signature but the last one's on a multi-block
9216                    // message). Falls back to the legacy fields only for a
9217                    // `Session` that never populated `thinking_blocks` (e.g.
9218                    // hand-constructed in another loader/test, or loaded
9219                    // from a non-Claude-Code source like Pi).
9220                    match msg
9221                        .metadata
9222                        .get("thinking_blocks")
9223                        .and_then(|s| serde_json::from_str::<Value>(s).ok())
9224                        .and_then(|v| v.as_array().cloned())
9225                    {
9226                        Some(saved_blocks) => blocks.extend(saved_blocks),
9227                        None => {
9228                            if let Some(t) = msg.metadata.get("thinking") {
9229                                let mut block =
9230                                    serde_json::json!({"type": "thinking", "thinking": t});
9231                                if let Some(sig) = msg.metadata.get("thinking_signature") {
9232                                    block["signature"] = Value::String(sig.clone());
9233                                }
9234                                blocks.push(block);
9235                            }
9236                            if let Some(rt) = msg.metadata.get("redacted_thinking") {
9237                                blocks.push(
9238                                    serde_json::json!({"type": "redacted_thinking", "data": rt}),
9239                                );
9240                            }
9241                        }
9242                    }
9243                    if let Some(t) = &msg.content {
9244                        if !t.is_empty() {
9245                            blocks.push(serde_json::json!({"type": "text", "text": t}));
9246                        }
9247                    }
9248                    // PARITY-11: an assistant-emitted image (`content_parts`,
9249                    // e.g. a generated image — `push_claude_assistant`'s
9250                    // load-side counterpart) has no slot in `msg.content`;
9251                    // without this, `blocks` stayed empty for an image-only
9252                    // turn and the whole message vanished on Claude Code
9253                    // semantic export, same failure mode the IX-6 Codex
9254                    // writer fix already closed on that side.
9255                    if let Some(parts) = &msg.content_parts {
9256                        for p in parts {
9257                            if p.get("type").and_then(Value::as_str) == Some("image_url") {
9258                                if let Some(url) = p
9259                                    .get("image_url")
9260                                    .and_then(|u| u.get("url"))
9261                                    .and_then(Value::as_str)
9262                                {
9263                                    blocks.push(match parse_data_uri(url) {
9264                                        Some((mime, data)) => serde_json::json!({
9265                                            "type": "image",
9266                                            "source": {"type": "base64", "media_type": mime, "data": data},
9267                                        }),
9268                                        None => serde_json::json!({
9269                                            "type": "image",
9270                                            "source": {"type": "url", "url": url},
9271                                        }),
9272                                    });
9273                                }
9274                            }
9275                        }
9276                    }
9277                    for tc in msg.tool_calls() {
9278                        let input = tc
9279                            .function
9280                            .parsed_arguments()
9281                            .unwrap_or_else(|_| Value::Object(Default::default()));
9282                        blocks.push(serde_json::json!({
9283                            "type": "tool_use",
9284                            "id": tc.id,
9285                            "name": tc.function.name,
9286                            "input": input,
9287                        }));
9288                    }
9289                    // PARITY-11 / PARITY-16: a genuinely reasoning-only turn
9290                    // (no text, no tool_use, no image) still doesn't vanish
9291                    // — the thinking/redacted_thinking prepend above already
9292                    // ran unconditionally, so `blocks` is non-empty here
9293                    // whenever any of those were present.
9294                    blocks
9295                }
9296            };
9297
9298            // An empty assistant content array is a valid native interrupted
9299            // turn and must remain a record. Every non-assistant arm above
9300            // already `continue`s after writing its own shape, so an empty
9301            // `blocks` value here belongs specifically to that assistant.
9302            let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9303            let mut message = serde_json::json!({"role": "assistant", "content": blocks});
9304            if let Some(model) = msg.metadata.get("model").or(self.meta.model.as_ref()) {
9305                message["model"] = Value::String(model.clone());
9306            }
9307            let mut line = serde_json::json!({
9308                "parentUuid": parent,
9309                "type": "assistant",
9310                "message": message,
9311                "uuid": uuid,
9312                "sessionId": session_id,
9313                "cwd": cwd,
9314                "timestamp": msg_timestamp_or_synth(msg),
9315            });
9316            set_grok_message_extension(&mut line, self.meta.source, msg);
9317            push_jsonl(out, &line);
9318            parent = Some(uuid);
9319        }
9320    }
9321
9322    /// A12 splice: replay the imported Claude Code `raw` prefix verbatim
9323    /// (patching `sessionId` on each line when `session_id` is `Some`), then
9324    /// synthesize records only for the appended tail, via
9325    /// [`Self::write_claude_code_records`] — chaining `parentUuid` from the
9326    /// last original `uuid` found anywhere in the raw prefix (not just its
9327    /// final line: a trailing loader-skipped record, e.g.
9328    /// `file-history-snapshot`, may carry no `uuid` of its own).
9329    fn to_claude_code_jsonl_spliced(&self, session_id: Option<&str>) -> String {
9330        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
9331        let sid = session_id
9332            .map(str::to_string)
9333            .or_else(|| self.meta.session_id.clone())
9334            .unwrap_or_else(|| synth_uuid(0));
9335        let cwd = self.cwd_string();
9336
9337        let mut out = String::new();
9338        let mut parent: Option<String> = None;
9339        for line in &self.raw[..raw_prefix_len] {
9340            push_spliced_line(&mut out, line, session_id, "sessionId");
9341            if let Ok(v) = serde_json::from_str::<Value>(line) {
9342                if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
9343                    parent = Some(uuid.to_string());
9344                }
9345            }
9346        }
9347
9348        // R1 (spliced-path hardening, mirrors the Codex N2 fix above): seed
9349        // the tail's collision guard with every uuid the just-replayed RAW
9350        // prefix already carries, so `write_claude_code_records` never
9351        // fabricates a `synth_uuid` for the appended tail that collides with
9352        // one already sitting in the prefix (see `next_claude_uuid`'s and
9353        // `collect_claude_uuids_from_raw`'s doc comments).
9354        let seed_used_ids = collect_claude_uuids_from_raw(&self.raw[..raw_prefix_len]);
9355        self.write_claude_code_records(
9356            &mut out,
9357            &self.messages[message_prefix_len..],
9358            &sid,
9359            &cwd,
9360            parent,
9361            1,
9362            &seed_used_ids,
9363        );
9364        out
9365    }
9366
9367    /// Synthesize a Codex rollout.
9368    fn to_codex_jsonl(&self) -> String {
9369        let mut out = String::new();
9370
9371        if self.meta.codex_headers.is_empty() {
9372            self.write_synthesized_codex_header(&mut out);
9373        } else {
9374            // Replay the exact header records the original tool wrote — Codex's
9375            // reader validates the header shape strictly — overriding only the
9376            // session id when the caller changed it.
9377            for header in &self.meta.codex_headers {
9378                let mut header = header.clone();
9379                if header.get("type").and_then(Value::as_str) == Some("session_meta") {
9380                    if let Some(id) = &self.meta.session_id {
9381                        if let Some(payload) = header.get_mut("payload") {
9382                            payload["id"] = Value::String(id.clone());
9383                        }
9384                    }
9385                }
9386                push_jsonl(&mut out, &header);
9387            }
9388        }
9389
9390        // Full synthesis: `out` at this point is only the header, so there
9391        // are no group ids yet in play to seed against (see
9392        // `write_codex_records`'s doc comment).
9393        self.write_codex_records(&mut out, &self.messages, &std::collections::HashSet::new());
9394        if let Some(extension) = codex_provenance_envelope(&self.meta) {
9395            inject_codex_provenance(&mut out, extension);
9396        }
9397        out
9398    }
9399
9400    /// Synthesize Codex `response_item` records for `messages` (a full
9401    /// session or an appended tail — A12's [`Self::to_jsonl_spliced`] reuses
9402    /// this for just the latter). Factored out of [`Self::to_codex_jsonl`] so
9403    /// the record shape is defined once; `tool_search_call_ids` pairing is
9404    /// scoped to this call's `messages`, matching the header-replay
9405    /// contract that only appended records need synthesizing.
9406    ///
9407    /// `seed_used_ids` primes the N2 collision guard below with every group
9408    /// id that will ALREADY be present in `out` before this call ever runs —
9409    /// [`Self::to_codex_jsonl`] (full synthesis, `out` starts as just the
9410    /// header) passes an empty set, since every group id in that case is
9411    /// assigned by this very loop. [`Self::to_codex_jsonl_spliced`] (the A12
9412    /// splice) passes the ids already used by the verbatim RAW prefix it
9413    /// replayed into `out` just before calling this for the appended tail —
9414    /// without that seed, the tail's own `used_group_ids`/`next_group_id`
9415    /// start blind to the prefix and can fabricate/reuse a group id that
9416    /// COLLIDES with one still "open" at the end of the prefix, letting
9417    /// reimport's merge check (`can_merge`, `push_codex_item`) wrongly splice
9418    /// an unrelated appended message into a historical one — the same
9419    /// bug-class N2 closed for full synthesis, reopened here because the
9420    /// spliced tail's tracking set used to always start empty regardless of
9421    /// what the replayed prefix already contained.
9422    fn write_codex_records(
9423        &self,
9424        out: &mut String,
9425        messages: &[ChatMessage],
9426        seed_used_ids: &std::collections::HashSet<String>,
9427    ) {
9428        // Call ids of assistant `tool_search` calls (B6 agent intrinsic), so
9429        // the matching tool result below can be emitted as the paired
9430        // `tool_search_output` record rather than a generic
9431        // `function_call_output` — the exact inverse of the importer's
9432        // `tool_search_call`/`tool_search_output` normalization
9433        // (`push_codex_item`, above).
9434        let mut tool_search_call_ids: std::collections::HashSet<String> = Default::default();
9435        // PARITY-6/7: two ADJACENT but genuinely SEPARATE Claude assistant
9436        // records (e.g. a text-only narration turn immediately followed by a
9437        // bare tool-call turn, no user turn between — a real, common Claude
9438        // Code shape) each become their own Codex `message`/`function_call`
9439        // response_item(s) here. Codex's own reader (`push_codex_item`, IX-6)
9440        // opportunistically RE-MERGES an assistant `message` immediately
9441        // followed by a `function_call` back into ONE `ChatMessage`, to match
9442        // how a genuinely single Claude turn (text+tool_use in the SAME
9443        // record) round-trips — but with no distinguishing signal, it can't
9444        // tell that case apart from two originally-separate records that
9445        // just happen to be adjacent, so it wrongly recombines them too,
9446        // silently shrinking the message count on every Claude -> Codex ->
9447        // (inspect) hop. Fix: stamp a synthetic `metadata.turn_id` — unique
9448        // per ORIGINAL `ChatMessage` — onto the `message` record AND every
9449        // `function_call`/`tool_search_call` record THAT SAME `ChatMessage`
9450        // itself emits. `push_codex_item`'s merge already treats a turn_id
9451        // mismatch as "different turn, do not merge" (the pre-existing
9452        // belt-and-suspenders check); real native Codex data almost never
9453        // carries this field (per that check's own comment), so this is a
9454        // no-op there and only sharpens fidelity for OUR OWN synthesized
9455        // export.
9456        let mut next_group_id: u64 = 0;
9457        // N2 (Fable-5 review, turn_id-collision hardening): every group id
9458        // this export has already assigned — whether REUSED from a real
9459        // `turn_id` or FABRICATED as `sc-grp-N` — so a later assistant
9460        // `ChatMessage` never emits one that's already in use. Two concrete
9461        // mis-merge scenarios motivate this:
9462        //
9463        // (a) Claude->Codex export fabricates `sc-grp-0` for msg A (msg A's
9464        //     own text+tool_use); reload makes A carry REAL turn_id
9465        //     `sc-grp-0`. A bare tool-call msg B (metadata has no turn_id of
9466        //     its own) is then appended. Re-export: A reuses its real
9467        //     `sc-grp-0`, but B independently fabricates a FRESH id starting
9468        //     from `next_group_id == 0` again (nothing bumped it when A's id
9469        //     was reused rather than fabricated) — also `sc-grp-0`.
9470        //     Collision. If A's call has no output (interrupted session),
9471        //     reimport sees message(sc-grp-0)+call(sc-grp-0)+call(sc-grp-0)
9472        //     adjacent with nothing to break the run and merges all three
9473        //     into ONE message (2 -> 1).
9474        // (b) Native Codex: `message(turn-7)` opens the merge marker, a
9475        //     truncation/clear event strips `__codex_open_turn` (closing the
9476        //     turn without changing the id), then `function_call(turn-7)`
9477        //     loads as a SECOND, separate `ChatMessage` that still carries
9478        //     the SAME real `turn_id` (the reopen step in `push_codex_item`
9479        //     restamps it). Full-synthesis export naively reuses `turn-7`
9480        //     verbatim for BOTH messages (they're two different loop
9481        //     iterations, each independently reusing its own `real_turn_id`)
9482        //     and emits them adjacent — reimport's merge check can't tell
9483        //     this apart from a single message's own multi-call turn and
9484        //     recombines them (2 -> 1).
9485        //
9486        // Fix: the fabricated-id counter is advanced (skipped) past any id
9487        // already in `used_group_ids`, AND a real id that's already been
9488        // used gets disambiguated (`<real>~dupN`) instead of reused verbatim
9489        // — never letting two DIFFERENT `ChatMessage`s in this export share
9490        // one group id, since `push_codex_item`'s merge check treats a
9491        // shared id as "same turn, merge". A single `ChatMessage`'s own
9492        // message record + its own tool call records still share ONE group
9493        // id (computed once per loop iteration below, before insertion), so
9494        // the D1 tool_search merge and ordinary same-turn multi-call
9495        // grouping are unaffected — this only stops REUSE across iterations.
9496        //
9497        // Seeded from `seed_used_ids` (see this fn's doc comment) so the
9498        // spliced-export tail is likewise blind-proof against the prefix it
9499        // doesn't itself write.
9500        let mut used_group_ids: std::collections::HashSet<String> = seed_used_ids.clone();
9501
9502        for msg in messages {
9503            if is_replay_excluded(msg) {
9504                continue;
9505            }
9506            // D3 (Fable-5 review): a message loaded FROM real native Codex
9507            // carries its OWN real `turn_id` in `msg.metadata["turn_id"]`
9508            // (`push_codex_item`'s "message" arm stamps it whenever the
9509            // source record itself has one). The group-id logic below used
9510            // to ALWAYS fabricate a fresh `"sc-grp-N"` value regardless,
9511            // silently overwriting/discarding that real id on any
9512            // native-Codex -> load -> export-Codex hop. Reuse it verbatim
9513            // when present; only fabricate a synthetic id as a fallback for
9514            // our own merge-disambiguation need (PARITY-6/7) when the
9515            // message has no real one of its own.
9516            let real_turn_id = msg.metadata.get("turn_id").map(String::as_str);
9517            match msg.role {
9518                Role::System => {
9519                    // PARITY-6 dev/02: carry the original Claude
9520                    // `systemSubtype` (`push_claude_system`'s `.with_meta`)
9521                    // through as `metadata.claude_system_subtype`, so
9522                    // `push_codex_item`'s reverse load can restore it and
9523                    // `write_claude_code_records`'s `Role::System` arm can
9524                    // re-materialize the EXACT original subtype rather than
9525                    // guessing on a Codex -> Claude hop.
9526                    let subtype_meta = msg
9527                        .metadata
9528                        .get("systemSubtype")
9529                        .map(|s| ("claude_system_subtype", s.as_str()));
9530                    self.push_codex_message(
9531                        out,
9532                        "developer",
9533                        "input_text",
9534                        msg,
9535                        real_turn_id,
9536                        subtype_meta,
9537                    )
9538                }
9539                Role::User => {
9540                    self.push_codex_message(out, "user", "input_text", msg, real_turn_id, None)
9541                }
9542                Role::Assistant => {
9543                    // Emit the message record whenever there is text OR
9544                    // content_parts (IX-6 follow-up): an image-only assistant
9545                    // message has `content: None, content_parts:
9546                    // Some([image])` (the loader's `codex_extract_images` is
9547                    // role-general, so this shape can occur on the assistant
9548                    // side too) — gating on `msg.content` alone silently
9549                    // dropped the whole message, image included. A
9550                    // text-only message (content_parts: None) keeps taking
9551                    // the historical byte-identical path via
9552                    // `codex_message_content_blocks`'s `None` arm. A real
9553                    // empty native assistant record carries the
9554                    // loader's explicit marker and must also be emitted.
9555                    // Reasoning-only cross-provider turns deliberately lack
9556                    // that marker and keep the documented Codex residue.
9557                    let has_text = msg.content.as_deref().is_some_and(|t| !t.is_empty());
9558                    let has_message_record = has_text
9559                        || msg.content_parts.is_some()
9560                        || msg.metadata.contains_key("empty_assistant_record");
9561                    // Only assign a synthetic group id when there's actual
9562                    // merge ambiguity to resolve (a message AND its own tool
9563                    // calls, or 2+ of this message's own tool calls) — a
9564                    // pure-text message with no tool calls, or a lone tool
9565                    // call with nothing else from the same `ChatMessage`,
9566                    // has nothing to disambiguate, so it keeps the exact
9567                    // historical byte shape (no `metadata` key at all).
9568                    let group_id: Option<String> = if let Some(real) = real_turn_id {
9569                        if used_group_ids.contains(real) {
9570                            // N2: this real turn_id was already used by an
9571                            // earlier (now-closed) `ChatMessage` in this same
9572                            // export — reusing it verbatim would let the
9573                            // reimport merge check recombine two originally
9574                            // separate messages (see the doc comment above).
9575                            let mut n = 1u64;
9576                            let mut candidate = format!("{real}~dup{n}");
9577                            while used_group_ids.contains(&candidate) {
9578                                n += 1;
9579                                candidate = format!("{real}~dup{n}");
9580                            }
9581                            Some(candidate)
9582                        } else {
9583                            Some(real.to_string())
9584                        }
9585                    } else if !msg.tool_calls().is_empty() {
9586                        // N2: skip past any id already used (e.g. a REAL
9587                        // turn_id that happens to look like `sc-grp-N`, or an
9588                        // id an earlier reused-real case landed on).
9589                        let mut candidate = format!("sc-grp-{next_group_id}");
9590                        next_group_id += 1;
9591                        while used_group_ids.contains(&candidate) {
9592                            candidate = format!("sc-grp-{next_group_id}");
9593                            next_group_id += 1;
9594                        }
9595                        Some(candidate)
9596                    } else {
9597                        None
9598                    };
9599                    if let Some(g) = &group_id {
9600                        used_group_ids.insert(g.clone());
9601                    }
9602                    if has_message_record {
9603                        self.push_codex_message(
9604                            out,
9605                            "assistant",
9606                            "output_text",
9607                            msg,
9608                            group_id.as_deref(),
9609                            None,
9610                        );
9611                    }
9612                    for tc in msg.tool_calls() {
9613                        let custom_tool_call = msg
9614                            .metadata
9615                            .get("codex_custom_tool_call_ids")
9616                            .and_then(|raw| serde_json::from_str::<Vec<String>>(raw).ok())
9617                            .is_some_and(|ids| ids.iter().any(|id| id == &tc.id));
9618                        if custom_tool_call {
9619                            let input = tc
9620                                .function
9621                                .parsed_arguments()
9622                                .unwrap_or_else(|_| Value::String(tc.function.arguments.clone()));
9623                            let mut payload = with_turn_id(
9624                                serde_json::json!({
9625                                    "type": "custom_tool_call",
9626                                    "name": tc.function.name,
9627                                    "input": input,
9628                                    "call_id": tc.id,
9629                                }),
9630                                group_id.as_deref(),
9631                            );
9632                            set_grok_message_extension(&mut payload, self.meta.source, msg);
9633                            push_jsonl(
9634                                out,
9635                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9636                            );
9637                        } else if tc.function.name == "tool_search" {
9638                            tool_search_call_ids.insert(tc.id.clone());
9639                            let mut payload = with_turn_id(
9640                                serde_json::json!({
9641                                    "type": "tool_search_call",
9642                                    "arguments": tc.function.arguments,
9643                                    "call_id": tc.id,
9644                                }),
9645                                group_id.as_deref(),
9646                            );
9647                            set_grok_message_extension(&mut payload, self.meta.source, msg);
9648                            push_jsonl(
9649                                out,
9650                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9651                            );
9652                        } else {
9653                            let mut payload = with_turn_id(
9654                                serde_json::json!({
9655                                    "type": "function_call",
9656                                    "name": tc.function.name,
9657                                    "arguments": tc.function.arguments,
9658                                    "call_id": tc.id,
9659                                }),
9660                                group_id.as_deref(),
9661                            );
9662                            set_grok_message_extension(&mut payload, self.meta.source, msg);
9663                            push_jsonl(
9664                                out,
9665                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9666                            );
9667                        }
9668                    }
9669                    // PARITY-11: a genuinely reasoning-only turn (Claude
9670                    // `thinking`/`redacted_thinking` with no text, tool_use,
9671                    // or image — `push_claude_assistant`'s load-side fix for
9672                    // the ~21% of real assistant records that are exactly
9673                    // this shape) has no message record and no tool calls,
9674                    // so nothing above writes anything for it. This is
9675                    // DELIBERATE, not a residual gap: Codex's `reasoning`
9676                    // response_item is understood on import (see the
9677                    // `response_item`/`"reasoning"` arm above), but its
9678                    // real-native semantics is "the reasoning immediately
9679                    // BEFORE the next turn" — the reader attaches it to
9680                    // whatever response_item comes next, unconditionally.
9681                    // For a genuinely standalone Claude reasoning-only turn
9682                    // (no related turn follows in Codex's export at all),
9683                    // emitting one here would get silently misattributed as
9684                    // belonging to some later, unrelated turn instead —
9685                    // strictly worse than the current honest, accounted-for
9686                    // absence (thinking/redacted_thinking is provider-
9687                    // private and "not replayed across providers" by
9688                    // original design; the audit correctly classifies it
9689                    // `Coverage::Dropped`, not `Unmodeled`). See the
9690                    // PARITY-6/7 corpus test's `is_replayable` filter for
9691                    // why this doesn't count as a message-count regression.
9692                }
9693                Role::Tool
9694                    if msg
9695                        .tool_call_id
9696                        .as_deref()
9697                        .is_some_and(|id| tool_search_call_ids.contains(id)) =>
9698                {
9699                    let content = msg.content.clone().unwrap_or_default();
9700                    let tools =
9701                        serde_json::from_str::<Value>(&content).unwrap_or(Value::String(content));
9702                    let mut payload = serde_json::json!({
9703                        "type": "tool_search_output",
9704                        "call_id": msg.tool_call_id.clone().unwrap_or_default(),
9705                        "tools": tools,
9706                    });
9707                    set_grok_message_extension(&mut payload, self.meta.source, msg);
9708                    push_jsonl(
9709                        out,
9710                        &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9711                    );
9712                }
9713                Role::Tool => {
9714                    let mut payload = serde_json::json!({
9715                        "type": "function_call_output",
9716                        "call_id": msg.tool_call_id.clone().unwrap_or_default(),
9717                        "output": codex_tool_output_text(msg),
9718                    });
9719                    set_grok_message_extension(&mut payload, self.meta.source, msg);
9720                    push_jsonl(
9721                        out,
9722                        &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9723                    );
9724                }
9725            }
9726        }
9727    }
9728
9729    /// A12 splice: replay the imported Codex `raw` prefix verbatim — every
9730    /// line, not just the `session_meta`/`turn_context` headers
9731    /// [`Self::to_codex_jsonl`] replays — overriding only
9732    /// `session_meta.payload.id` when `session_id` is `Some` (every other
9733    /// line, including `response_item`s the stock synthesis would otherwise
9734    /// rebuild from scratch, is untouched byte-for-byte). Then synthesizes
9735    /// `response_item` records only for the appended tail, via
9736    /// [`Self::write_codex_records`].
9737    fn to_codex_jsonl_spliced(&self, session_id: Option<&str>) -> String {
9738        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
9739
9740        let mut out = String::new();
9741        for line in &self.raw[..raw_prefix_len] {
9742            match session_id {
9743                Some(id) => {
9744                    let patched = serde_json::from_str::<Value>(line)
9745                        .ok()
9746                        .filter(|v| v.get("type").and_then(Value::as_str) == Some("session_meta"))
9747                        .map(|mut v| {
9748                            if let Some(payload) = v.get_mut("payload") {
9749                                payload["id"] = Value::String(id.to_string());
9750                            }
9751                            v.to_string()
9752                        });
9753                    out.push_str(patched.as_deref().unwrap_or(line));
9754                }
9755                None => out.push_str(line),
9756            }
9757            out.push('\n');
9758        }
9759
9760        // N2 (spliced-path hardening): seed the tail's collision guard with
9761        // every group id the just-replayed RAW prefix already carries, so
9762        // `write_codex_records` never fabricates/reuses an id for the
9763        // appended tail that collides with one still open at the end of the
9764        // prefix (see that fn's doc comment, and
9765        // `collect_codex_group_ids_from_raw`'s).
9766        let mut seed_used_ids = collect_codex_group_ids_from_raw(&self.raw[..raw_prefix_len]);
9767        // Belt-and-suspenders: also union in the prefix `messages`' own
9768        // recorded `turn_id` metadata. In the ordinary case this is already
9769        // a subset of what the raw-line scan above found (the loader stamps
9770        // `metadata["turn_id"]` from the very same `payload.metadata.turn_id`
9771        // field the scan reads) — but scanning `messages` too costs nothing
9772        // and means this stays correct even if some future loader path ever
9773        // derives a message's `turn_id` by some means other than a literal
9774        // `payload.metadata.turn_id` copy.
9775        for msg in &self.messages[..message_prefix_len] {
9776            if let Some(tid) = msg.metadata.get("turn_id") {
9777                seed_used_ids.insert(tid.clone());
9778            }
9779        }
9780        self.write_codex_records(
9781            &mut out,
9782            &self.messages[message_prefix_len..],
9783            &seed_used_ids,
9784        );
9785        out
9786    }
9787
9788    /// Build a Codex header from scratch (used when converting from another
9789    /// format, where no original Codex header exists to replay). Emits the
9790    /// fields Codex requires on `session_meta`.
9791    fn write_synthesized_codex_header(&self, out: &mut String) {
9792        let mut meta_payload = serde_json::json!({
9793            "id": self.meta.session_id.clone().unwrap_or_else(|| synth_uuid(0)),
9794            "timestamp": SYNTH_TS,
9795            "cwd": self.cwd_string(),
9796            "originator": "supercode",
9797            "cli_version": env!("CARGO_PKG_VERSION"),
9798            "source": "exec",
9799            "thread_source": "user",
9800            "model_provider": "openai",
9801        });
9802        if let Some(sp) = &self.meta.system_prompt {
9803            meta_payload["base_instructions"] = serde_json::json!({"text": sp});
9804        }
9805        // PARITY-10 dev/03: carry a captured Claude `fork-context-ref` (see
9806        // `capture_claude_meta`) through the Codex hop under a clearly
9807        // namespaced custom field — real Codex tooling ignores unknown
9808        // `session_meta.payload` keys, and `capture_codex_session_meta`
9809        // reads this same key back on import, so a Claude -> Codex -> Claude
9810        // round trip still reconstructs the original record instead of
9811        // silently losing the lineage note on the cross-format hop.
9812        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
9813            meta_payload["claude_fork_context_ref"] =
9814                serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
9815        }
9816        push_jsonl(
9817            out,
9818            &serde_json::json!({"timestamp": SYNTH_TS, "type": "session_meta", "payload": meta_payload}),
9819        );
9820        if let Some(model) = &self.meta.model {
9821            push_jsonl(
9822                out,
9823                &serde_json::json!({
9824                    "timestamp": SYNTH_TS,
9825                    "type": "turn_context",
9826                    "payload": {"model": model, "cwd": self.cwd_string()},
9827                }),
9828            );
9829        }
9830    }
9831
9832    /// `turn_id`: see the PARITY-6/7 (and D3) comment on
9833    /// [`Self::write_codex_records`] — `Some` when the source message
9834    /// carries its own REAL `turn_id` (a native-Codex round-trip), or
9835    /// (assistant only) a synthetic disambiguation id when it owns tool
9836    /// calls needing merge disambiguation and has no real id of its own;
9837    /// `None` reproduces the exact historical shape (no `metadata` key at
9838    /// all).
9839    /// `extra_metadata`: PARITY-6 dev/02 — an additional `(key, value)`
9840    /// pair folded into `payload.metadata` alongside `turn_id` (used by the
9841    /// `Role::System` case in [`Self::write_codex_records`] to carry
9842    /// `claude_system_subtype`, so a Claude `<local-command-stdout>`-style
9843    /// system record's subtype survives the Claude -> Codex -> Claude round
9844    /// trip instead of only its text; `None` for every other caller,
9845    /// preserving the exact historical shape).
9846    fn push_codex_message(
9847        &self,
9848        out: &mut String,
9849        role: &str,
9850        text_type: &str,
9851        msg: &ChatMessage,
9852        turn_id: Option<&str>,
9853        extra_metadata: Option<(&str, &str)>,
9854    ) {
9855        let mut payload = with_turn_id(
9856            serde_json::json!({
9857                "type": "message",
9858                "role": role,
9859                "content": codex_message_content_blocks(text_type, msg),
9860            }),
9861            turn_id,
9862        );
9863        if let Some((k, v)) = extra_metadata {
9864            if payload.get("metadata").is_none() {
9865                payload["metadata"] = serde_json::json!({});
9866            }
9867            payload["metadata"][k] = serde_json::json!(v);
9868        }
9869        set_grok_message_extension(&mut payload, self.meta.source, msg);
9870        push_jsonl(
9871            out,
9872            &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9873        );
9874    }
9875
9876    /// Synthesize a fresh pi v3 session from the canonical `messages`
9877    /// (T3 cross-format/full synthesis — `to_jsonl(other)`'s "view round-trip"
9878    /// tier, §3/§4.1: this is NOT the byte-lossless native path, which goes
9879    /// through `raw` + `to_native_jsonl(_v2)` instead).
9880    fn to_pi_jsonl(&self) -> String {
9881        let session_id = self
9882            .meta
9883            .session_id
9884            .clone()
9885            .unwrap_or_else(|| synth_uuid(0));
9886        let cwd = self.cwd_string();
9887        let mut out = String::new();
9888        push_pi_header(
9889            &mut out,
9890            &session_id,
9891            &cwd,
9892            self.meta
9893                .lineage
9894                .get("parent_session_path")
9895                .map(String::as_str),
9896            self.meta.lineage.get("created_at").map(String::as_str),
9897            // D7: carry a captured Claude `fork-context-ref` (see
9898            // `capture_claude_meta`) through the Pi hop too — mirrors the
9899            // Codex hop's `claude_fork_context_ref` passthrough
9900            // (`write_synthesized_codex_header`) so a Claude -> Pi -> Claude
9901            // round trip doesn't silently lose fork lineage just because Pi
9902            // has no native slot for it.
9903            self.meta
9904                .lineage
9905                .get("claude_fork_context_ref_raw")
9906                .map(String::as_str),
9907        );
9908        let mut used_ids: HashSet<String> = HashSet::new();
9909        let mut counter: u64 = 0;
9910        self.write_pi_entries(&mut out, &self.messages, None, &mut used_ids, &mut counter);
9911        if let Some(extension) = codex_provenance_envelope(&self.meta) {
9912            inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
9913        }
9914        out
9915    }
9916
9917    /// Synthesize pi `message` entries for `messages` (a full session, or —
9918    /// for [`Self::to_pi_jsonl_spliced`] — just the appended tail), chaining
9919    /// `parentId` from `parent` and drawing fresh ids from `used_ids`/
9920    /// `counter`. Skips [`is_replay_excluded`] messages (`compacted_out`,
9921    /// `pi_exclude_from_context`) exactly like the Claude/Codex writers.
9922    fn write_pi_entries(
9923        &self,
9924        out: &mut String,
9925        messages: &[ChatMessage],
9926        mut parent: Option<String>,
9927        used_ids: &mut HashSet<String>,
9928        counter: &mut u64,
9929    ) {
9930        // Claude Code and Codex do not repeat the tool name on their native
9931        // tool-result records. Recover that redundant Pi field from the
9932        // paired assistant call when a cross-format round trip therefore
9933        // returns a canonical Tool message with `name == None`.
9934        let mut paired_tool_names = HashMap::<String, String>::new();
9935        for msg in messages {
9936            if is_replay_excluded(msg) {
9937                continue;
9938            }
9939            for call in msg.tool_calls() {
9940                paired_tool_names.insert(call.id.clone(), call.function.name.clone());
9941            }
9942            let id = pi_fresh_id(used_ids, counter);
9943            let mut entry = match msg.role {
9944                // B4: pi has no session-level system/developer PROMPT slot
9945                // (§1.1: "no system-prompt... rebuilt at runtime"), but a
9946                // content-bearing `Role::System` message loaded from a real
9947                // Claude Code `type: "system"` record (`push_claude_system`'s
9948                // keep-listed subtypes: `local_command`, `scheduled_task_fire`,
9949                // `away_summary`) is NOT a system prompt — it's a real,
9950                // non-regenerable transcript event. Pi's own `role:"custom"`
9951                // `CustomMessage` (§3e: "extension-injected... sent to the LLM
9952                // as a user message") is the closest existing, non-fabricated
9953                // slot pi's own parser already understands, so this
9954                // re-materializes the record there instead of silently
9955                // dropping it — the exact allowance push_claude_system's own
9956                // doc comment describes in reverse. `customType` is a
9957                // supercode-namespaced marker (`push_pi_custom_common`
9958                // recognizes it on reload and restores `Role::System` +
9959                // `metadata["systemSubtype"]`, exactly like `push_claude_system`
9960                // produced in the first place); a real pi customType never
9961                // collides with this name. `details.claude_system_subtype`
9962                // carries the original subtype losslessly through the pi leg
9963                // (mirrors `write_codex_records`'s `claude_system_subtype`
9964                // metadata channel on the Codex leg, PARITY-6 dev/02). Content
9965                // is never fabricated — only emitted when non-empty.
9966                Role::System => {
9967                    let content = msg.content.clone().unwrap_or_default();
9968                    if content.trim().is_empty() {
9969                        continue;
9970                    }
9971                    let subtype = msg
9972                        .metadata
9973                        .get("systemSubtype")
9974                        .cloned()
9975                        .unwrap_or_else(|| "local_command".to_string());
9976                    serde_json::json!({
9977                        "type": "message",
9978                        "id": id,
9979                        "parentId": parent,
9980                        "timestamp": msg_timestamp_or_synth(msg),
9981                        "message": {
9982                            "role": "custom",
9983                            "customType": PI_CLAUDE_SYSTEM_CUSTOM_TYPE,
9984                            "content": content,
9985                            "display": true,
9986                            "details": {"claude_system_subtype": subtype},
9987                            "timestamp": msg_pi_native_timestamp_ms(msg),
9988                        },
9989                    })
9990                }
9991                Role::User => serde_json::json!({
9992                    "type": "message",
9993                    "id": id,
9994                    "parentId": parent,
9995                    "timestamp": msg_timestamp_or_synth(msg),
9996                    "message": {
9997                        "role": "user",
9998                        "content": pi_content_value(msg),
9999                        "timestamp": msg_pi_native_timestamp_ms(msg),
10000                    },
10001                }),
10002                Role::Assistant => {
10003                    let api = msg
10004                        .metadata
10005                        .get("pi_api")
10006                        .cloned()
10007                        .unwrap_or_else(|| "anthropic-messages".to_string());
10008                    let provider = msg
10009                        .metadata
10010                        .get("pi_provider")
10011                        .cloned()
10012                        .unwrap_or_else(|| "anthropic".to_string());
10013                    let model = self
10014                        .meta
10015                        .model
10016                        .clone()
10017                        .unwrap_or_else(|| "unknown".to_string());
10018                    let usage = msg
10019                        .metadata
10020                        .get("pi_usage")
10021                        .and_then(|s| serde_json::from_str::<Value>(s).ok())
10022                        .unwrap_or_else(default_pi_usage);
10023                    let stop_reason = msg
10024                        .metadata
10025                        .get("pi_stop_reason")
10026                        .cloned()
10027                        .unwrap_or_else(|| "stop".to_string());
10028                    serde_json::json!({
10029                        "type": "message",
10030                        "id": id,
10031                        "parentId": parent,
10032                        "timestamp": msg_timestamp_or_synth(msg),
10033                        "message": {
10034                            "role": "assistant",
10035                            "content": pi_assistant_content_value(msg),
10036                            "api": api,
10037                            "provider": provider,
10038                            "model": model,
10039                            "usage": usage,
10040                            "stopReason": stop_reason,
10041                            "timestamp": msg_pi_native_timestamp_ms(msg),
10042                        },
10043                    })
10044                }
10045                Role::Tool => serde_json::json!({
10046                    "type": "message",
10047                    "id": id,
10048                    "parentId": parent,
10049                    "timestamp": msg_timestamp_or_synth(msg),
10050                    "message": {
10051                        "role": "toolResult",
10052                        "toolCallId": msg.tool_call_id.clone().unwrap_or_default(),
10053                        "toolName": msg.name.as_deref().or_else(|| {
10054                            msg.tool_call_id
10055                                .as_deref()
10056                                .and_then(|id| paired_tool_names.get(id).map(String::as_str))
10057                        }).unwrap_or_default(),
10058                        "content": pi_content_value(msg),
10059                        "isError": is_tool_error_flag(msg),
10060                        "timestamp": msg_pi_native_timestamp_ms(msg),
10061                    },
10062                }),
10063            };
10064            if msg.role == Role::Tool && crate::tool_outcome(msg) == crate::ToolOutcome::Unknown {
10065                entry[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
10066            }
10067            set_grok_message_extension(&mut entry, self.meta.source, msg);
10068            push_jsonl(out, &entry);
10069            parent = Some(id);
10070            if msg.role == Role::Tool {
10071                if let Some(call_id) = msg.tool_call_id.as_deref() {
10072                    paired_tool_names.remove(call_id);
10073                }
10074            }
10075        }
10076    }
10077
10078    /// A12-style splice for pi (§1.1/§4.2): replay the imported `raw` prefix
10079    /// **verbatim** — the header line always has its `version` normalized to
10080    /// 3 (§1.3: pi rewrites any pre-v3 file in place on load, destroying
10081    /// byte-identity, so the writer never re-emits one; this intentionally
10082    /// breaks byte-identity for pre-v3 originals only, the accepted
10083    /// trade-off) and its `id` overridden when `session_id` is `Some`. Every
10084    /// other raw line — every entry — is untouched (pi repeats the session
10085    /// id on no other line, `pi-fields.md` §1). Then synthesizes `message`
10086    /// entries only for the appended tail via [`Self::write_pi_entries`],
10087    /// chaining from the last entry `id` found in the raw prefix.
10088    fn to_pi_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
10089        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10090        if raw_prefix_len == 0 {
10091            return Ok(self.to_pi_jsonl());
10092        }
10093
10094        let mut out = String::new();
10095        let mut used_ids: HashSet<String> = HashSet::new();
10096        let mut leaf: Option<String> = None;
10097        for (i, line) in self.raw[..raw_prefix_len].iter().enumerate() {
10098            if i == 0 {
10099                if let Ok(v) = serde_json::from_str::<Value>(line) {
10100                    if v.get("type").and_then(Value::as_str) == Some("session") {
10101                        let needs_v3 = v.get("version").and_then(Value::as_u64) != Some(3);
10102                        // Only reparse+reserialize the header when something
10103                        // actually needs to change — this crate doesn't
10104                        // enable serde_json's `preserve_order`, so a no-op
10105                        // round-trip through `Value` would reorder keys
10106                        // alphabetically and silently break the "prefix
10107                        // bytes unchanged" splice guarantee for the (common)
10108                        // already-v3, no-override case.
10109                        if needs_v3 || session_id.is_some() {
10110                            let mut v = v;
10111                            v["version"] = serde_json::json!(3);
10112                            if let Some(new_id) = session_id {
10113                                v["id"] = Value::String(new_id.to_string());
10114                            }
10115                            out.push_str(&v.to_string());
10116                            out.push('\n');
10117                            continue;
10118                        }
10119                    }
10120                }
10121            }
10122            out.push_str(line);
10123            out.push('\n');
10124            if let Ok(v) = serde_json::from_str::<Value>(line) {
10125                if let Some(id) = v.get("id").and_then(Value::as_str) {
10126                    used_ids.insert(id.to_string());
10127                    leaf = Some(id.to_string());
10128                }
10129            }
10130        }
10131
10132        let mut counter: u64 = 0;
10133        self.write_pi_entries(
10134            &mut out,
10135            &self.messages[message_prefix_len..],
10136            leaf,
10137            &mut used_ids,
10138            &mut counter,
10139        );
10140        Ok(out)
10141    }
10142
10143    // ---- Grok writers -----------------------------------------------
10144
10145    /// Synthesize Grok's resumable `chat_history.jsonl` transcript.
10146    fn to_grok_jsonl(&self) -> String {
10147        let mut out = String::new();
10148        if let Some(prompt) = self
10149            .meta
10150            .system_prompt
10151            .as_deref()
10152            .filter(|prompt| !prompt.is_empty())
10153        {
10154            push_jsonl(
10155                &mut out,
10156                &serde_json::json!({
10157                    "type": "system",
10158                    "content": prompt,
10159                }),
10160            );
10161        }
10162        self.write_grok_records(&mut out, &self.messages);
10163        if let Some(extension) = codex_provenance_envelope(&self.meta) {
10164            if out.is_empty() {
10165                push_jsonl(
10166                    &mut out,
10167                    &serde_json::json!({"type": "system", "content": ""}),
10168                );
10169            }
10170            inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
10171        }
10172        out
10173    }
10174
10175    fn write_grok_records(&self, out: &mut String, messages: &[ChatMessage]) {
10176        for message in messages {
10177            if is_replay_excluded(message) {
10178                continue;
10179            }
10180            let mut value = match message.role {
10181                Role::System => serde_json::json!({
10182                    "type": "user",
10183                    "content": [{"type": "text", "text": message.content.clone().unwrap_or_default()}],
10184                    "synthetic_reason": "supercode_system_event",
10185                }),
10186                Role::User => {
10187                    let mut value = serde_json::json!({
10188                        "type": "user",
10189                        "content": [{"type": "text", "text": message.content.clone().unwrap_or_default()}],
10190                    });
10191                    if let Some(object) = value.as_object_mut() {
10192                        for (metadata, field) in [
10193                            ("grok_prompt_index", "prompt_index"),
10194                            ("grok_prior_turn_interrupt", "prior_turn_interrupt"),
10195                            ("grok_synthetic_reason", "synthetic_reason"),
10196                        ] {
10197                            if let Some(raw) = message.metadata.get(metadata) {
10198                                object.insert(
10199                                    field.to_string(),
10200                                    serde_json::from_str(raw)
10201                                        .unwrap_or_else(|_| Value::String(raw.clone())),
10202                                );
10203                            }
10204                        }
10205                    }
10206                    value
10207                }
10208                Role::Assistant => {
10209                    let calls = message
10210                        .tool_calls()
10211                        .iter()
10212                        .map(|call| {
10213                            serde_json::json!({
10214                                "id": call.id,
10215                                "name": call.function.name,
10216                                "arguments": call.function.arguments,
10217                            })
10218                        })
10219                        .collect::<Vec<_>>();
10220                    let mut value = serde_json::json!({
10221                        "type": "assistant",
10222                        "content": message.content.clone().unwrap_or_default(),
10223                        "tool_calls": calls,
10224                        "model_id": message.metadata.get("grok_model_id")
10225                            .or(self.meta.model.as_ref())
10226                            .cloned()
10227                            .unwrap_or_else(|| "unknown".to_string()),
10228                    });
10229                    if let Some(object) = value.as_object_mut() {
10230                        for (metadata, field) in [
10231                            ("grok_model_fingerprint", "model_fingerprint"),
10232                            ("grok_reasoning_effort", "reasoning_effort"),
10233                        ] {
10234                            if let Some(raw) = message.metadata.get(metadata) {
10235                                object.insert(field.to_string(), Value::String(raw.clone()));
10236                            }
10237                        }
10238                    }
10239                    value
10240                }
10241                Role::Tool => serde_json::json!({
10242                    "type": "tool_result",
10243                    "tool_call_id": message.tool_call_id.clone().unwrap_or_default(),
10244                    "content": message.content.clone().unwrap_or_default(),
10245                }),
10246            };
10247            set_grok_target_message_extension(&mut value, message);
10248            push_jsonl(out, &value);
10249        }
10250    }
10251
10252    /// Replay a Grok imported prefix verbatim, then append newly-created
10253    /// canonical turns. Grok stores the session id in the directory name,
10254    /// not in transcript records, so there is no in-file id to rewrite.
10255    fn to_grok_jsonl_spliced(&self) -> String {
10256        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10257        if raw_prefix_len == 0 {
10258            return self.to_grok_jsonl();
10259        }
10260        let mut out = String::new();
10261        for line in &self.raw[..raw_prefix_len] {
10262            out.push_str(line);
10263            out.push('\n');
10264        }
10265        self.write_grok_records(&mut out, &self.messages[message_prefix_len..]);
10266        out
10267    }
10268
10269    // ---- Gemini writers ---------------------------------------------
10270
10271    fn to_gemini_jsonl(&self) -> String {
10272        let mut out = String::new();
10273        self.write_gemini_header(&mut out, self.meta.session_id.as_deref());
10274        self.write_gemini_records(&mut out, &self.messages);
10275        push_jsonl(
10276            &mut out,
10277            &serde_json::json!({
10278                "$set": {"lastUpdated": SYNTH_TS}
10279            }),
10280        );
10281        out
10282    }
10283
10284    fn write_gemini_header(&self, out: &mut String, session_id: Option<&str>) {
10285        push_jsonl(
10286            out,
10287            &serde_json::json!({
10288                "sessionId": session_id.unwrap_or("supercode-gemini-session"),
10289                "projectHash": self.meta.lineage.get("gemini_project_hash")
10290                    .cloned().unwrap_or_else(|| "supercode".to_string()),
10291                "startTime": self.meta.lineage.get("created_at")
10292                    .cloned().unwrap_or_else(|| SYNTH_TS.to_string()),
10293                "lastUpdated": self.meta.lineage.get("updated_at")
10294                    .cloned().unwrap_or_else(|| SYNTH_TS.to_string()),
10295                "kind": self.meta.lineage.get("gemini_session_kind")
10296                    .cloned().unwrap_or_else(|| "main".to_string()),
10297            }),
10298        );
10299    }
10300
10301    fn write_gemini_records(&self, out: &mut String, messages: &[ChatMessage]) {
10302        let mut call_names = HashMap::new();
10303        for (index, message) in messages.iter().enumerate() {
10304            if is_replay_excluded(message) {
10305                continue;
10306            }
10307            let timestamp = message
10308                .metadata
10309                .get("timestamp")
10310                .cloned()
10311                .unwrap_or_else(|| SYNTH_TS.to_string());
10312            match message.role {
10313                Role::System | Role::User => {
10314                    let mut parts = Vec::new();
10315                    let text = message.content.clone().or_else(|| {
10316                        message.content_parts.as_ref().and_then(|parts| {
10317                            let text = parts
10318                                .iter()
10319                                .filter_map(|part| part.get("text").and_then(Value::as_str))
10320                                .collect::<Vec<_>>()
10321                                .join(" ");
10322                            (!text.is_empty()).then_some(text)
10323                        })
10324                    });
10325                    if let Some(text) = text {
10326                        let text = if message.role == Role::System {
10327                            format!("[System] {text}")
10328                        } else {
10329                            text
10330                        };
10331                        parts.push(serde_json::json!({"text": text}));
10332                    }
10333                    if let Some(content_parts) = &message.content_parts {
10334                        for part in content_parts {
10335                            let Some(url) = part
10336                                .get("image_url")
10337                                .and_then(|value| value.get("url"))
10338                                .and_then(Value::as_str)
10339                            else {
10340                                continue;
10341                            };
10342                            let Some(rest) = url.strip_prefix("data:") else {
10343                                continue;
10344                            };
10345                            let Some((media_type, data)) = rest.split_once(";base64,") else {
10346                                continue;
10347                            };
10348                            parts.push(serde_json::json!({
10349                                "inlineData": {"mimeType": media_type, "data": data}
10350                            }));
10351                        }
10352                    }
10353                    if !parts.is_empty() {
10354                        let mut value = serde_json::json!({
10355                            "id": format!("supercode-user-{index}"),
10356                            "timestamp": timestamp,
10357                            "type": "user",
10358                            "content": parts,
10359                        });
10360                        set_gemini_message_extension(&mut value, message);
10361                        push_jsonl(out, &value);
10362                    }
10363                }
10364                Role::Assistant => {
10365                    let mut tool_calls = Vec::new();
10366                    for call in message.tool_calls() {
10367                        call_names.insert(call.id.clone(), call.function.name.clone());
10368                        let args = serde_json::from_str::<Value>(&call.function.arguments)
10369                            .unwrap_or_else(|_| Value::String(call.function.arguments.clone()));
10370                        tool_calls.push(serde_json::json!({
10371                            "id": call.id,
10372                            "name": call.function.name,
10373                            "args": args,
10374                        }));
10375                    }
10376                    let mut value = serde_json::json!({
10377                        "id": format!("supercode-gemini-{index}"),
10378                        "timestamp": timestamp,
10379                        "type": "gemini",
10380                        "content": message.content.clone().unwrap_or_default(),
10381                        "model": message.metadata.get("gemini_model")
10382                            .or(self.meta.model.as_ref())
10383                            .cloned().unwrap_or_else(|| "unknown".to_string()),
10384                    });
10385                    if !tool_calls.is_empty() {
10386                        value["toolCalls"] = Value::Array(tool_calls);
10387                    }
10388                    if let Some(thoughts) = message.metadata.get("gemini_thoughts") {
10389                        value["thoughts"] = serde_json::from_str(thoughts)
10390                            .unwrap_or_else(|_| Value::String(thoughts.clone()));
10391                    }
10392                    set_gemini_message_extension(&mut value, message);
10393                    push_jsonl(out, &value);
10394                }
10395                Role::Tool => {
10396                    let id = message.tool_call_id.clone().unwrap_or_default();
10397                    let name = message
10398                        .name
10399                        .clone()
10400                        .or_else(|| call_names.get(&id).cloned())
10401                        .unwrap_or_else(|| "tool".to_string());
10402                    let output = message.content.clone().unwrap_or_else(|| {
10403                        message
10404                            .content_parts
10405                            .as_ref()
10406                            .map(|parts| Value::Array(parts.clone()))
10407                            .map(|value| value.to_string())
10408                            .unwrap_or_default()
10409                    });
10410                    let mut value = serde_json::json!({
10411                        "id": format!("supercode-tool-{index}"),
10412                        "timestamp": timestamp,
10413                        "type": "user",
10414                        "content": [{
10415                            "functionResponse": {
10416                                "id": id,
10417                                "name": name,
10418                                "response": {"output": output}
10419                            }
10420                        }],
10421                    });
10422                    set_gemini_message_extension(&mut value, message);
10423                    push_jsonl(out, &value);
10424                }
10425            }
10426        }
10427    }
10428
10429    fn to_gemini_jsonl_spliced(&self, session_id: Option<&str>) -> String {
10430        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10431        if raw_prefix_len == 0 {
10432            let mut out = String::new();
10433            self.write_gemini_header(&mut out, session_id.or(self.meta.session_id.as_deref()));
10434            self.write_gemini_records(&mut out, &self.messages);
10435            return out;
10436        }
10437        let mut out = String::new();
10438        for (index, line) in self.raw[..raw_prefix_len].iter().enumerate() {
10439            if index == 0 && session_id.is_some() {
10440                if let Ok(mut value) = serde_json::from_str::<Value>(line) {
10441                    if value.get("type").is_none() && value.get("sessionId").is_some() {
10442                        value["sessionId"] =
10443                            Value::String(session_id.unwrap_or_default().to_string());
10444                        push_jsonl(&mut out, &value);
10445                        continue;
10446                    }
10447                }
10448            }
10449            out.push_str(line);
10450            out.push('\n');
10451        }
10452        self.write_gemini_records(&mut out, &self.messages[message_prefix_len..]);
10453        out
10454    }
10455
10456    // ---- Goose writers ----------------------------------------------
10457
10458    fn to_goose_json(&self) -> String {
10459        if self.meta.source == SessionSource::Goose
10460            && !self.raw.is_empty()
10461            && self.imported_message_count == Some(self.messages.len())
10462        {
10463            return join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10464        }
10465        self.synthesized_goose_document(None, &self.messages)
10466    }
10467
10468    fn to_goose_json_spliced(&self, session_id: Option<&str>) -> String {
10469        let message_prefix_len = self
10470            .imported_message_count
10471            .unwrap_or(self.messages.len())
10472            .min(self.messages.len());
10473        if self.meta.source == SessionSource::Goose && !self.raw.is_empty() {
10474            if session_id.is_none() && message_prefix_len == self.messages.len() {
10475                return join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10476            }
10477            let source = join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10478            if let Ok(mut document) = serde_json::from_str::<Value>(&source) {
10479                if let Some(session_id) = session_id {
10480                    document["id"] = Value::String(session_id.to_string());
10481                }
10482                let appended = self.goose_conversation(&self.messages[message_prefix_len..]);
10483                if let Some(conversation) = document
10484                    .get_mut("conversation")
10485                    .and_then(Value::as_array_mut)
10486                {
10487                    conversation.extend(appended);
10488                    document["message_count"] = Value::from(conversation.len());
10489                }
10490                return serde_json::to_string_pretty(&document).unwrap_or_else(|_| {
10491                    self.synthesized_goose_document(session_id, &self.messages)
10492                });
10493            }
10494        }
10495        self.synthesized_goose_document(session_id, &self.messages)
10496    }
10497
10498    fn synthesized_goose_document(
10499        &self,
10500        session_id: Option<&str>,
10501        messages: &[ChatMessage],
10502    ) -> String {
10503        let mut document = self
10504            .meta
10505            .goose_header
10506            .clone()
10507            .or_else(|| {
10508                self.messages.iter().find_map(|message| {
10509                    message
10510                        .metadata
10511                        .get("goose_session_header")
10512                        .and_then(|value| serde_json::from_str(value).ok())
10513                })
10514            })
10515            .unwrap_or_else(|| {
10516                serde_json::json!({
10517                    "id": "supercode-goose-session",
10518                    "working_dir": self.cwd_string(),
10519                    "name": "supercode export",
10520                    "user_set_name": false,
10521                    "session_type": "user",
10522                    "created_at": SYNTH_TS,
10523                    "updated_at": SYNTH_TS,
10524                    "extension_data": {},
10525                    "usage": {},
10526                    "accumulated_usage": {},
10527                    "accumulated_cost": Value::Null,
10528                    "schedule_id": Value::Null,
10529                    "recipe": Value::Null,
10530                    "user_recipe_values": Value::Null,
10531                    "message_count": 0,
10532                    "last_message_at": Value::Null,
10533                    "provider_name": Value::Null,
10534                    "model_config": Value::Null,
10535                    "goose_mode": "auto",
10536                    "archived_at": Value::Null,
10537                    "project_id": Value::Null,
10538                    "parent_session_id": Value::Null,
10539                    "last_message_snippet": Value::Null,
10540                })
10541            });
10542        document["id"] = Value::String(
10543            session_id
10544                .map(str::to_string)
10545                .or_else(|| self.meta.session_id.clone())
10546                .unwrap_or_else(|| "supercode-goose-session".to_string()),
10547        );
10548        document["working_dir"] = Value::String(self.cwd_string());
10549        let conversation = self.goose_conversation(messages);
10550        document["message_count"] = Value::from(conversation.len());
10551        document["conversation"] = Value::Array(conversation);
10552        serde_json::to_string_pretty(&document).unwrap_or_else(|_| "{}".to_string())
10553    }
10554
10555    fn goose_conversation(&self, messages: &[ChatMessage]) -> Vec<Value> {
10556        let mut out = Vec::new();
10557        let mut last_native_index: Option<String> = None;
10558        let mut tool_names = HashMap::<String, String>::new();
10559        for (index, message) in messages.iter().enumerate() {
10560            if is_replay_excluded(message) {
10561                continue;
10562            }
10563            if let Some(native_index) = message.metadata.get("goose_native_index") {
10564                if last_native_index.as_ref() == Some(native_index) {
10565                    continue;
10566                }
10567                last_native_index = Some(native_index.clone());
10568                if let Some(native) = message
10569                    .metadata
10570                    .get("goose_native_message")
10571                    .and_then(|value| serde_json::from_str::<Value>(value).ok())
10572                {
10573                    out.push(native);
10574                    continue;
10575                }
10576            } else {
10577                last_native_index = None;
10578            }
10579
10580            for call in message.tool_calls() {
10581                tool_names.insert(call.id.clone(), call.function.name.clone());
10582            }
10583            let created = message
10584                .metadata
10585                .get("goose_created")
10586                .and_then(|value| value.parse::<i64>().ok())
10587                .unwrap_or(SYNTH_TS_MS / 1000 + index as i64);
10588            let role = match message.role {
10589                Role::Assistant => "assistant",
10590                _ => "user",
10591            };
10592            let mut content = Vec::new();
10593            // A Goose tool response carries its output inside
10594            // `toolResult.value.content`; duplicating it as a sibling text
10595            // block makes the loader normalize one Tool message twice.
10596            if message.role != Role::Tool {
10597                if let Some(text) = &message.content {
10598                    let text = if message.role == Role::System {
10599                        format!("[System] {text}")
10600                    } else {
10601                        text.clone()
10602                    };
10603                    content.push(serde_json::json!({"type": "text", "text": text}));
10604                }
10605                if let Some(parts) = &message.content_parts {
10606                    for part in parts {
10607                        if let Some(text) = part.get("text").and_then(Value::as_str) {
10608                            if message.content.is_none() {
10609                                content.push(serde_json::json!({"type": "text", "text": text}));
10610                            }
10611                        }
10612                        let Some(url) = part
10613                            .get("image_url")
10614                            .and_then(|image| image.get("url"))
10615                            .and_then(Value::as_str)
10616                        else {
10617                            continue;
10618                        };
10619                        let Some(data) = url.strip_prefix("data:") else {
10620                            continue;
10621                        };
10622                        let Some((media_type, data)) = data.split_once(";base64,") else {
10623                            continue;
10624                        };
10625                        content.push(serde_json::json!({
10626                            "type": "image",
10627                            "data": data,
10628                            "mimeType": media_type,
10629                        }));
10630                    }
10631                }
10632            }
10633            for call in message.tool_calls() {
10634                let arguments = serde_json::from_str::<Value>(&call.function.arguments)
10635                    .unwrap_or_else(|_| Value::String(call.function.arguments.clone()));
10636                content.push(serde_json::json!({
10637                    "type": "toolRequest",
10638                    "id": call.id,
10639                    "toolCall": {
10640                        "status": "success",
10641                        "value": {"name": call.function.name, "arguments": arguments}
10642                    }
10643                }));
10644            }
10645            if message.role == Role::Tool {
10646                let id = message.tool_call_id.clone().unwrap_or_default();
10647                let output = message.content.clone().unwrap_or_else(|| {
10648                    message
10649                        .content_parts
10650                        .as_ref()
10651                        .map(|parts| Value::Array(parts.clone()).to_string())
10652                        .unwrap_or_default()
10653                });
10654                let tool_result = if crate::is_tool_error(message) {
10655                    serde_json::json!({"status": "error", "error": output})
10656                } else {
10657                    serde_json::json!({
10658                        "status": "success",
10659                        "value": {
10660                            "content": [{"type": "text", "text": output}],
10661                            "isError": false
10662                        }
10663                    })
10664                };
10665                content.push(serde_json::json!({
10666                    "type": "toolResponse",
10667                    "id": id,
10668                    "toolResult": tool_result,
10669                    "metadata": {
10670                        "toolName": message.name.as_ref()
10671                            .or_else(|| tool_names.get(&id))
10672                    }
10673                }));
10674            }
10675            if content.is_empty() {
10676                continue;
10677            }
10678            let metadata = message
10679                .metadata
10680                .get("goose_metadata")
10681                .and_then(|value| serde_json::from_str::<Value>(value).ok())
10682                .unwrap_or_else(|| {
10683                    serde_json::json!({
10684                        "userVisible": true,
10685                        "agentVisible": true
10686                    })
10687                });
10688            let mut native = serde_json::json!({
10689                "id": message.metadata.get("goose_message_id")
10690                    .cloned().unwrap_or_else(|| format!("supercode-goose-{index}")),
10691                "role": role,
10692                "created": created,
10693                "content": content,
10694                "metadata": metadata,
10695            });
10696            // Goose tolerates unknown top-level fields on a conversation
10697            // message. Always carry the canonical envelope when Goose is
10698            // the TARGET so metadata absent from Goose's stock schema can
10699            // make a later Goose -> source round trip without residue.
10700            set_grok_target_message_extension(&mut native, message);
10701            out.push(native);
10702        }
10703        out
10704    }
10705
10706    // ---- OpenCode writers ---------------------------------------------
10707
10708    /// Re-derive the structured OpenCode records (`SessionInfo`, an ordered
10709    /// `(message value, part values)` list) directly from `self.raw`'s
10710    /// envelope lines — the same classification
10711    /// [`Self::from_opencode_str`] performs, but returning the raw VALUES
10712    /// rather than canonical `ChatMessage`s. Used by
10713    /// [`Self::to_opencode_jsonl_spliced`] (§4.2 S5: the imported prefix
10714    /// must be VALUE-EQUAL at its position, not re-synthesized from lossy
10715    /// `messages`) and [`Self::to_opencode_direct_write`] (S5: the fidelity
10716    /// path for excess keys/timestamps/side-records `opencode import`
10717    /// cannot restore).
10718    fn opencode_records_from_raw(&self) -> (Option<Value>, Vec<(Value, Vec<Value>)>) {
10719        let mut session_info: Option<Value> = None;
10720        let mut msg_order: Vec<String> = Vec::new();
10721        let mut msg_values: HashMap<String, Value> = HashMap::new();
10722        let mut msg_parts: HashMap<String, Vec<Value>> = HashMap::new();
10723        for line in &self.raw {
10724            let Ok(env) = serde_json::from_str::<Value>(line) else {
10725                continue;
10726            };
10727            let Some(key) = env.get("key").and_then(Value::as_array) else {
10728                continue;
10729            };
10730            let value = env.get("value").cloned().unwrap_or(Value::Null);
10731            match key.first().and_then(Value::as_str) {
10732                Some("session") => session_info = Some(value),
10733                Some("message") => {
10734                    if let Some(id) = value.get("id").and_then(Value::as_str) {
10735                        if !msg_values.contains_key(id) {
10736                            msg_order.push(id.to_string());
10737                        }
10738                        msg_values.insert(id.to_string(), value);
10739                    }
10740                }
10741                Some("part") => {
10742                    if let Some(mid) = value.get("messageID").and_then(Value::as_str) {
10743                        msg_parts.entry(mid.to_string()).or_default().push(value);
10744                    }
10745                }
10746                _ => {}
10747            }
10748        }
10749        let mut ordered: Vec<(String, i64)> = msg_order
10750            .iter()
10751            .map(|id| {
10752                let tc = msg_values
10753                    .get(id)
10754                    .and_then(|v| v.get("time"))
10755                    .and_then(|t| t.get("created"))
10756                    .and_then(Value::as_i64)
10757                    .unwrap_or(0);
10758                (id.clone(), tc)
10759            })
10760            .collect();
10761        ordered.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
10762        let mut out = Vec::new();
10763        for (id, _) in ordered {
10764            let mut parts = msg_parts.remove(&id).unwrap_or_default();
10765            parts.sort_by(|a, b| {
10766                let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
10767                let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
10768                ai.cmp(bi)
10769            });
10770            if let Some(v) = msg_values.remove(&id) {
10771                out.push((v, parts));
10772            }
10773        }
10774        (session_info, out)
10775    }
10776
10777    /// Best-effort `SessionInfo` synthesized from `self.meta` — used when no
10778    /// `raw` prefix exists to replay (a fresh/cross-format-converted
10779    /// session). T3 tier: only what `SessionMeta` carries survives.
10780    fn synthesized_opencode_info(&self) -> Value {
10781        let id = self
10782            .meta
10783            .session_id
10784            .clone()
10785            .unwrap_or_else(|| "ses_supercode00000000000001".to_string());
10786        let mut info = serde_json::json!({
10787            "id": id,
10788            "projectID": self.meta.lineage.get("projectID").cloned().unwrap_or_else(|| "global".to_string()),
10789            // OpenCode 1.2.15's import path writes this into a NOT NULL
10790            // SQLite column. Preserve a real source slug when available and
10791            // mint a stable, human-readable fallback for foreign sessions.
10792            "slug": self.meta.lineage.get("slug").cloned().unwrap_or_else(|| "supercode-export".to_string()),
10793            "directory": self.cwd_string(),
10794            "title": "supercode export",
10795            "version": env!("CARGO_PKG_VERSION"),
10796            "time": {"created": SYNTH_TS_MS, "updated": SYNTH_TS_MS},
10797        });
10798        if let Some(agent) = &self.meta.agent_id {
10799            info["agent"] = Value::String(agent.clone());
10800        }
10801        if let Some(model) = &self.meta.model {
10802            if let Some((provider, mid)) = model.split_once('/') {
10803                info["model"] = serde_json::json!({"providerID": provider, "id": mid});
10804            }
10805        }
10806        if let Some(parent) = self.meta.lineage.get("parent_session_id") {
10807            info["parentID"] = Value::String(parent.clone());
10808        }
10809        // D7: carry a captured Claude `fork-context-ref` through the
10810        // OpenCode hop too, exactly like the Codex (`claude_fork_context_ref`
10811        // on `session_meta.payload`) and Pi (`claude_fork_context_ref` on
10812        // the `session` header) hops already do — namespaced so real
10813        // OpenCode tooling ignores it, and `capture_opencode_session_info`
10814        // reads this same key back on import so a Claude -> OpenCode ->
10815        // Claude round trip doesn't silently lose fork lineage either.
10816        //
10817        // DOCUMENTED LIMITATION (D7 caveat, per the frozen interop spec —
10818        // `interop-research-spec@f168465`, `opencode-pi-spec.md` ~line 123):
10819        // this `claude_fork_context_ref` key on `SessionInfo` survives
10820        // supercode's OWN round-trip (write here, read back by
10821        // `capture_opencode_session_info` above) but NOT a real upstream
10822        // `opencode import` ingestion — that path decodes with
10823        // `Schema.decodeUnknownSync`, which strips any key its schema
10824        // doesn't declare. The direct-file/DB fallback (bypassing
10825        // `opencode import` entirely) is the per-spec fidelity path for
10826        // this lineage to actually reach real OpenCode.
10827        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
10828            info["claude_fork_context_ref"] =
10829                serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
10830        }
10831        if let Some(extension) = codex_provenance_envelope(&self.meta) {
10832            info[SUPERCODE_CODEX_PROVENANCE_KEY] = extension;
10833        }
10834        info
10835    }
10836
10837    /// OpenCode uses `SessionInfo.time.updated` for recency selection. Any
10838    /// synthesized continuation message therefore has to advance the
10839    /// session clock along with its own `time.created` value.
10840    fn touch_opencode_session_updated(info: &mut Value, timestamp: i64) {
10841        if !info.get("time").is_some_and(Value::is_object) {
10842            info["time"] = serde_json::json!({});
10843        }
10844        info["time"]["updated"] = serde_json::json!(timestamp);
10845    }
10846
10847    /// Synthesize opencode `{info, parts}` message objects for `messages`
10848    /// (T3 tier — full synthesis from canonical `ChatMessage`s, the inverse
10849    /// of [`push_opencode_user`]/[`push_opencode_assistant`]), appending
10850    /// them to `out`. Every `Tool` message anywhere in `messages` is folded
10851    /// back into its call's assistant `tool` part (match by
10852    /// `tool_call_id`/`callID`, pairing repeated ids by occurrence over the
10853    /// whole slice)
10854    /// — the exact inverse of the loader's call/result split. This is a
10855    /// GLOBAL match, not a scan of the contiguous run of `Tool` messages
10856    /// immediately following each assistant: two-or-more consecutive
10857    /// assistant-with-tool-call messages before their results (streamed /
10858    /// parallel tool calls) otherwise strand the earlier call's real result
10859    /// behind a later assistant message, silently downgrading it to
10860    /// "pending" and losing the recorded output. Skips [`is_replay_excluded`]
10861    /// messages exactly like every other writer.
10862    fn append_synthesized_opencode_messages(
10863        &self,
10864        out: &mut Vec<Value>,
10865        messages: &[ChatMessage],
10866        session_id: &str,
10867        counter: &mut u64,
10868        timestamp_cursor: &mut i64,
10869    ) -> Result<()> {
10870        // Pair each Tool message to its call GLOBALLY by `tool_call_id`,
10871        // over the ENTIRE slice being processed, rather than by scanning
10872        // only the contiguous run of `Role::Tool` messages immediately
10873        // following a given assistant message. Two-or-more consecutive
10874        // assistant-with-tool-call messages before their results (streamed
10875        // / parallel tool calls — extremely common in real Claude Code and
10876        // Codex sessions) break the contiguous-run assumption: the first
10877        // assistant's own result(s) land AFTER a second assistant message,
10878        // not immediately after the first, so a contiguous scan starting
10879        // right after the first assistant finds nothing and silently drops
10880        // its real tool output into the `None => "pending"` branch below.
10881        // A single `id -> result` map is still insufficient: long real
10882        // sessions can reuse provider call ids. Last-write-wins then attaches
10883        // the final output to every earlier occurrence. Collect calls and
10884        // results independently and zip their occurrences in transcript
10885        // order, giving every concrete call position its own result.
10886        let mut calls_by_id: HashMap<&str, Vec<(usize, usize)>> = HashMap::new();
10887        let mut results_by_id: HashMap<&str, Vec<(usize, &ChatMessage)>> = HashMap::new();
10888        for (message_index, message) in messages.iter().enumerate() {
10889            if message.role == Role::Assistant {
10890                for (tool_index, call) in message.tool_calls().iter().enumerate() {
10891                    calls_by_id
10892                        .entry(call.id.as_str())
10893                        .or_default()
10894                        .push((message_index, tool_index));
10895                }
10896            } else if message.role == Role::Tool {
10897                if let Some(id) = &message.tool_call_id {
10898                    results_by_id
10899                        .entry(id.as_str())
10900                        .or_default()
10901                        .push((message_index, message));
10902                }
10903            }
10904        }
10905        let mut paired_results: HashMap<(usize, usize), (usize, &ChatMessage)> = HashMap::new();
10906        for (id, calls) in calls_by_id {
10907            let Some(results) = results_by_id.get(id) else {
10908                continue;
10909            };
10910            for (call_position, result) in calls.into_iter().zip(results.iter().copied()) {
10911                paired_results.insert(call_position, result);
10912            }
10913        }
10914        let mut i = 0;
10915        while i < messages.len() {
10916            let msg = &messages[i];
10917            if is_replay_excluded(msg) {
10918                i += 1;
10919                continue;
10920            }
10921            match msg.role {
10922                // B4: opencode V1 has no session-level system-PROMPT slot
10923                // either — `User.system` is a per-turn system-PROMPT
10924                // OVERRIDE (§2.1), a different thing from a content-bearing
10925                // `Role::System` message loaded from a real Claude `type:
10926                // "system"` record (`push_claude_system`'s keep-listed
10927                // subtypes). Stuffing real transcript content into
10928                // `User.system` would be a genuine misuse — it overrides the
10929                // replayed system prompt, not just annotates a turn — so
10930                // this instead reuses opencode's own `text` part `synthetic`
10931                // flag (§3.1: "injected by opencode, not typed by user"),
10932                // which is EXACTLY the right existing, non-fabricated
10933                // semantic for "system-originated content presented as a
10934                // user turn": a dedicated `User` message with one
10935                // `synthetic: true` text part, tagged with a
10936                // supercode-namespaced part-`metadata` key so
10937                // `opencode_claude_system_subtype`/`push_opencode_claude_system`
10938                // recognize it on reload and restore `Role::System` +
10939                // `metadata["systemSubtype"]` rather than treating it as a
10940                // real user turn. Content is never fabricated — only
10941                // emitted when non-empty.
10942                Role::System => {
10943                    let content = msg.content.clone().unwrap_or_default();
10944                    if content.trim().is_empty() {
10945                        i += 1;
10946                        continue;
10947                    }
10948                    let subtype = msg
10949                        .metadata
10950                        .get("systemSubtype")
10951                        .cloned()
10952                        .unwrap_or_else(|| "local_command".to_string());
10953                    let msg_id = opencode_fresh_id("msg", counter);
10954                    let part_id = opencode_fresh_id("prt", counter);
10955                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
10956                    let mut info = serde_json::json!({
10957                        "id": msg_id,
10958                        "sessionID": session_id,
10959                        "role": "user",
10960                        "time": {"created": timestamp},
10961                    });
10962                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
10963                    let parts = vec![serde_json::json!({
10964                        "id": part_id,
10965                        "sessionID": session_id,
10966                        "messageID": msg_id,
10967                        "type": "text",
10968                        "text": content,
10969                        "synthetic": true,
10970                        "metadata": {OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: subtype},
10971                    })];
10972                    out.push(serde_json::json!({"info": info, "parts": parts}));
10973                    i += 1;
10974                }
10975                Role::User => {
10976                    let msg_id = opencode_fresh_id("msg", counter);
10977                    let parts = opencode_user_parts_from_message(msg, &msg_id, session_id, counter);
10978                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
10979                    let mut info = serde_json::json!({
10980                        "id": msg_id,
10981                        "sessionID": session_id,
10982                        "role": "user",
10983                        "time": {"created": timestamp},
10984                    });
10985                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
10986                    opencode_restore_agent_model_fields(
10987                        &mut info, msg, /* is_assistant */ false,
10988                    );
10989                    set_grok_message_extension(&mut info, self.meta.source, msg);
10990                    out.push(serde_json::json!({
10991                        "info": info,
10992                        "parts": parts,
10993                    }));
10994                    i += 1;
10995                }
10996                Role::Assistant => {
10997                    let msg_id = opencode_fresh_id("msg", counter);
10998                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
10999                    let mut parts = Vec::new();
11000                    if let Some(thinking) = msg.metadata.get("thinking") {
11001                        let mut part = serde_json::json!({
11002                            "id": opencode_fresh_id("prt", counter),
11003                            "sessionID": session_id,
11004                            "messageID": msg_id,
11005                            "type": "reasoning",
11006                            "text": thinking,
11007                            // Required by OpenCode V1's native reasoning
11008                            // schema. A synthesized part has no distinct
11009                            // stream start/end, so the source message clock
11010                            // is the honest zero-duration span.
11011                            "time": {"start": timestamp, "end": timestamp},
11012                        });
11013                        if let Some(signature) = msg.metadata.get("thinking_signature") {
11014                            part["metadata"] = serde_json::json!({
11015                                "anthropic": {"signature": signature},
11016                            });
11017                        }
11018                        parts.push(part);
11019                    }
11020                    if let Some(t) = &msg.content {
11021                        if !t.is_empty() {
11022                            parts.push(serde_json::json!({
11023                                "id": opencode_fresh_id("prt", counter),
11024                                "sessionID": session_id,
11025                                "messageID": msg_id,
11026                                "type": "text",
11027                                "text": t,
11028                            }));
11029                        }
11030                    }
11031                    // Fold each tool call's result back into ONE `tool`
11032                    // part, matched by tool_call_id via the GLOBAL
11033                    // `all_results` map built above (not a contiguous scan)
11034                    // — a result may be many messages away when other
11035                    // assistant turns with their own pending calls
11036                    // intervene before it appears.
11037                    for (tool_index, tc) in msg.tool_calls().iter().enumerate() {
11038                        let input = tc
11039                            .function
11040                            .parsed_arguments()
11041                            .unwrap_or_else(|_| Value::Object(Default::default()));
11042                        let paired_result = paired_results.get(&(i, tool_index)).copied();
11043                        let state = match paired_result {
11044                            Some((_, result)) if crate::is_tool_error(result) => {
11045                                let result_timestamp =
11046                                    opencode_message_timestamp(result, timestamp_cursor)?;
11047                                serde_json::json!({
11048                                    "status": "error",
11049                                    "input": input,
11050                                    "error": result.content.clone().unwrap_or_default(),
11051                                    "time": {"end": result_timestamp},
11052                                })
11053                            }
11054                            Some((_, result)) => {
11055                                let result_timestamp =
11056                                    opencode_message_timestamp(result, timestamp_cursor)?;
11057                                let mut s = serde_json::json!({
11058                                    "status": "completed",
11059                                    "input": input,
11060                                    "output": result.content.clone().unwrap_or_default(),
11061                                    "title": tc.function.name,
11062                                    "time": {"end": result_timestamp},
11063                                });
11064                                // PARITY-11 (nested images): the LOADER already
11065                                // reads a completed tool part's
11066                                // `state.attachments` back into `content_parts`
11067                                // (`opencode_file_image_part`, above) — this is
11068                                // the missing WRITE-side inverse. Without it, a
11069                                // Claude `tool_result`'s nested image (now
11070                                // captured into `content_parts` by
11071                                // `extract_tool_result_content`) reached
11072                                // `content_parts` on the canonical `ChatMessage`
11073                                // but was silently dropped again on re-export to
11074                                // OpenCode, because nothing ever read it back
11075                                // out. `mime`/`url` shape matches exactly what
11076                                // `opencode_file_image_part` expects on reload.
11077                                if let Some(cps) = &result.content_parts {
11078                                    let atts: Vec<Value> = cps
11079                                        .iter()
11080                                        .filter(|p| {
11081                                            p.get("type").and_then(Value::as_str)
11082                                                == Some("image_url")
11083                                        })
11084                                        .filter_map(|p| {
11085                                            let url = p
11086                                                .get("image_url")
11087                                                .and_then(|u| u.get("url"))
11088                                                .and_then(Value::as_str)?;
11089                                            let mime = url
11090                                                .strip_prefix("data:")
11091                                                .and_then(|r| r.split_once(','))
11092                                                .map(|(m, _)| m.trim_end_matches(";base64"))
11093                                                .unwrap_or("application/octet-stream");
11094                                            Some(serde_json::json!({
11095                                                "mime": mime,
11096                                                "url": url,
11097                                            }))
11098                                        })
11099                                        .collect();
11100                                    if !atts.is_empty() {
11101                                        s["attachments"] = Value::Array(atts);
11102                                    }
11103                                }
11104                                s
11105                            }
11106                            None => serde_json::json!({"status": "pending", "input": input}),
11107                        };
11108                        let mut part = serde_json::json!({
11109                            "id": opencode_fresh_id("prt", counter),
11110                            "sessionID": session_id,
11111                            "messageID": msg_id,
11112                            "type": "tool",
11113                            "callID": tc.id,
11114                            "tool": tc.function.name,
11115                            "state": state,
11116                        });
11117                        if let Some((result_position, _)) = paired_result {
11118                            part[OPENCODE_SUPERCODE_RESULT_POSITION] =
11119                                serde_json::json!(result_position);
11120                        }
11121                        if paired_result.is_some_and(|(_, result)| {
11122                            crate::tool_outcome(result) == crate::ToolOutcome::Unknown
11123                        }) {
11124                            part[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
11125                        }
11126                        if let Some((_, result)) = paired_result {
11127                            set_grok_message_extension(&mut part, self.meta.source, result);
11128                        }
11129                        parts.push(part);
11130                    }
11131                    let mut info = serde_json::json!({
11132                        "id": msg_id,
11133                        "sessionID": session_id,
11134                        "role": "assistant",
11135                        "time": {"created": timestamp},
11136                    });
11137                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
11138                    opencode_restore_agent_model_fields(
11139                        &mut info, msg, /* is_assistant */ true,
11140                    );
11141                    set_grok_message_extension(&mut info, self.meta.source, msg);
11142                    out.push(serde_json::json!({
11143                        "info": info,
11144                        "parts": parts,
11145                    }));
11146                    i += 1;
11147                }
11148                // A Tool message is always folded into its call's assistant
11149                // `tool` part above (via occurrence-aware global pairing, not
11150                // positional adjacency), so it never needs its own entry
11151                // here — just advance past it.
11152                Role::Tool => i += 1,
11153            }
11154        }
11155        Ok(())
11156    }
11157
11158    /// Synthesize a fresh OpenCode export DOCUMENT from the canonical
11159    /// `messages` (T3 cross-format/full synthesis tier — mirrors
11160    /// [`Self::to_pi_jsonl`]'s doc comment: this is NOT the value-lossless
11161    /// native path, which goes through `raw` + `to_native_jsonl(_v2)`
11162    /// instead). Shape: `{info: SessionInfo, messages: [{info, parts}, …]}`
11163    /// (§1.2 — the `opencode export`/`import` interchange shape).
11164    fn to_opencode_jsonl(&self) -> Result<String> {
11165        let mut info = self.synthesized_opencode_info();
11166        let ses_id = info
11167            .get("id")
11168            .and_then(Value::as_str)
11169            .unwrap_or("ses_new")
11170            .to_string();
11171        let mut messages_json: Vec<Value> = Vec::new();
11172        let mut counter: u64 = 0;
11173        let mut timestamp_cursor =
11174            opencode_max_timestamp(&info).unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11175        self.append_synthesized_opencode_messages(
11176            &mut messages_json,
11177            &self.messages,
11178            &ses_id,
11179            &mut counter,
11180            &mut timestamp_cursor,
11181        )?;
11182        if !messages_json.is_empty() {
11183            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11184        }
11185        let doc = serde_json::json!({"info": info, "messages": messages_json});
11186        Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
11187    }
11188
11189    /// A12-style splice for OpenCode (§1.2/§4.2 point 1, S5): replay the
11190    /// imported records **value-equal at their position** in the export
11191    /// doc's `messages[]`/`parts[]` — reconstructed directly from `self.raw`
11192    /// via [`Self::opencode_records_from_raw`], never re-derived from the
11193    /// lossy canonical `messages` — then append freshly synthesized
11194    /// `{info, parts}` objects for the tail via
11195    /// [`Self::append_synthesized_opencode_messages`]. Unlike the
11196    /// line-oriented formats' splice, `out` here is a single export
11197    /// DOCUMENT, not a line stream (§4.2 restates the "prefix verbatim"
11198    /// assertion accordingly: value-equality at position, not byte
11199    /// equality of a line range).
11200    fn to_opencode_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
11201        if self.raw.is_empty() {
11202            return self.to_opencode_jsonl();
11203        }
11204        let (session_info, records) = self.opencode_records_from_raw();
11205        let (_, message_prefix_len) = self.spliced_prefix_lens();
11206
11207        let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
11208        if let Some(id) = session_id {
11209            info["id"] = Value::String(id.to_string());
11210        }
11211        let ses_id_for_new = info
11212            .get("id")
11213            .and_then(Value::as_str)
11214            .unwrap_or("ses_new")
11215            .to_string();
11216
11217        let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
11218            .chain(records.iter().flat_map(|(msg, parts)| {
11219                std::iter::once(opencode_max_timestamp(msg))
11220                    .chain(parts.iter().map(opencode_max_timestamp))
11221            }))
11222            .flatten()
11223            .max()
11224            .unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11225
11226        let mut messages_json: Vec<Value> = records
11227            .into_iter()
11228            .map(|(msg, parts)| serde_json::json!({"info": msg, "parts": parts}))
11229            .collect();
11230        let imported_len = messages_json.len();
11231
11232        let mut counter: u64 = 0;
11233        self.append_synthesized_opencode_messages(
11234            &mut messages_json,
11235            &self.messages[message_prefix_len..],
11236            &ses_id_for_new,
11237            &mut counter,
11238            &mut timestamp_cursor,
11239        )?;
11240        if messages_json.len() > imported_len {
11241            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11242        }
11243
11244        let doc = serde_json::json!({"info": info, "messages": messages_json});
11245        Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
11246    }
11247
11248    /// The **required** direct-write fallback (S5): write the imported
11249    /// OpenCode records **verbatim** — excess/unknown keys, part-row
11250    /// timestamps, and `session_diff`/`todo` side-records intact — to a
11251    /// generation-B JSON-file storage tree
11252    /// (`docs/interop/opencode-pi-spec.md` §1.2), the fidelity path
11253    /// `opencode import` cannot provide (S5: import re-decodes through a
11254    /// strict schema and STRIPS excess keys; inserts part rows without
11255    /// `time_created`/`time_updated`, so those reset to `Date.now()`; and
11256    /// has no ingestion path for `session_diff`/`todo` at all).
11257    ///
11258    /// Writes the JSON-FILE layout rather than a live SQLite write
11259    /// specifically to avoid a new `rusqlite`-class dependency on this
11260    /// build's memory-constrained box (see the build report); `session_diff`
11261    /// itself is still JSON-written by upstream even on SQLite installs
11262    /// (§1.3), so this is a real fidelity path, not a fictional one.
11263    ///
11264    /// Returns the `storage/session/<projectID>/` directory written to.
11265    pub fn to_opencode_direct_write(&self, data_root: &Path) -> Result<PathBuf> {
11266        let (session_info, mut records) = self.opencode_records_from_raw();
11267        let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
11268        let ses_id = info
11269            .get("id")
11270            .and_then(Value::as_str)
11271            .unwrap_or("ses_new")
11272            .to_string();
11273        if info.get("id").is_none() {
11274            info["id"] = Value::String(ses_id.clone());
11275        }
11276        let project_id = info
11277            .get("projectID")
11278            .and_then(Value::as_str)
11279            .unwrap_or("global")
11280            .to_string();
11281
11282        // Appended tail (messages produced after import): synthesize fresh
11283        // message/part VALUES via the same T3 synthesis the splice writer
11284        // uses, so continuation turns get files too. Do this BEFORE creating
11285        // any directories: timestamp exhaustion must fail atomically rather
11286        // than leave a partial direct-write tree behind.
11287        let (_, message_prefix_len) = self.spliced_prefix_lens();
11288        let mut counter: u64 = 0;
11289        let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
11290            .chain(records.iter().flat_map(|(msg, parts)| {
11291                std::iter::once(opencode_max_timestamp(msg))
11292                    .chain(parts.iter().map(opencode_max_timestamp))
11293            }))
11294            .flatten()
11295            .max()
11296            .unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11297        let mut appended_json: Vec<Value> = Vec::new();
11298        self.append_synthesized_opencode_messages(
11299            &mut appended_json,
11300            &self.messages[message_prefix_len..],
11301            &ses_id,
11302            &mut counter,
11303            &mut timestamp_cursor,
11304        )?;
11305        if !appended_json.is_empty() {
11306            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11307        }
11308        for entry in appended_json {
11309            let msg = entry.get("info").cloned().unwrap_or(Value::Null);
11310            let parts = entry
11311                .get("parts")
11312                .and_then(Value::as_array)
11313                .cloned()
11314                .unwrap_or_default();
11315            records.push((msg, parts));
11316        }
11317
11318        let storage = data_root.join("storage");
11319        let session_dir = storage.join("session").join(&project_id);
11320        std::fs::create_dir_all(&session_dir)?;
11321        std::fs::write(
11322            session_dir.join(format!("{ses_id}.json")),
11323            serde_json::to_string_pretty(&info).unwrap_or_default(),
11324        )?;
11325
11326        let message_dir = storage.join("message").join(&ses_id);
11327        let part_dir = storage.join("part");
11328        std::fs::create_dir_all(&message_dir)?;
11329
11330        for (msg, parts) in &records {
11331            let Some(msg_id) = msg.get("id").and_then(Value::as_str) else {
11332                continue;
11333            };
11334            std::fs::write(
11335                message_dir.join(format!("{msg_id}.json")),
11336                serde_json::to_string_pretty(msg).unwrap_or_default(),
11337            )?;
11338            let this_part_dir = part_dir.join(msg_id);
11339            std::fs::create_dir_all(&this_part_dir)?;
11340            for part in parts {
11341                let Some(part_id) = part.get("id").and_then(Value::as_str) else {
11342                    continue;
11343                };
11344                std::fs::write(
11345                    this_part_dir.join(format!("{part_id}.json")),
11346                    serde_json::to_string_pretty(part).unwrap_or_default(),
11347                )?;
11348            }
11349        }
11350
11351        // Side-records (S5c): session_diff / todo have NO ingestion path via
11352        // `opencode import` at all — the direct write is their only
11353        // fidelity path.
11354        for header in &self.meta.opencode_headers {
11355            let Some(key) = header.get("key").and_then(Value::as_array) else {
11356                continue;
11357            };
11358            let Some(kind) = key.first().and_then(Value::as_str) else {
11359                continue;
11360            };
11361            let value = header.get("value").cloned().unwrap_or(Value::Null);
11362            if !matches!(kind, "session_diff" | "todo") {
11363                continue;
11364            }
11365            let dir = storage.join(kind);
11366            std::fs::create_dir_all(&dir)?;
11367            std::fs::write(
11368                dir.join(format!("{ses_id}.json")),
11369                serde_json::to_string_pretty(&value).unwrap_or_default(),
11370            )?;
11371        }
11372
11373        Ok(session_dir)
11374    }
11375}
11376
11377fn opencode_fresh_id(prefix: &str, counter: &mut u64) -> String {
11378    *counter += 1;
11379    format!("{prefix}_synth{counter:06}")
11380}
11381
11382/// WAVE-2 fidelity item 2: restore the `agent`/`cost`/`finish`/`is_summary`/
11383/// `model`/`tokens` fields onto a synthesized OpenCode `info` record, in the
11384/// EXACT native shape opencode's own loaders (`push_opencode_user` /
11385/// `push_opencode_assistant`, above) parse back out — so an opencode->opencode
11386/// export-doc round-trip (write here, reload via [`Session::from_opencode_str`])
11387/// reconstructs value-identical `ChatMessage.metadata`. Each field is emitted
11388/// ONLY when its metadata key is present (a synthesized continuation turn, or
11389/// a User message that never carried `agent`, stays clean — no spurious
11390/// null/empty fields).
11391///
11392/// - `agent`: plain string on BOTH User (`v1/session.ts:346`) and Assistant
11393///   (`:465`) — `metadata["agent"]` is inserted verbatim by both loaders from
11394///   `msg_value.get("agent")`, so it's re-emitted verbatim here too.
11395/// - `model`: the loaders capture it in TWO DIFFERENT shapes depending on
11396///   role (`docs/interop/research/opencode-fields.md` §3 2a/2b), so the
11397///   inverse must match per-role:
11398///   - User: `push_opencode_user` stores `metadata["model"]` as the
11399///     STRINGIFIED `{providerID, modelID, variant?}` object
11400///     (`msg_value.get("model")...to_string()`) — re-parsed and re-emitted
11401///     as that same object under `"model"`.
11402///   - Assistant: `push_opencode_assistant` stores `metadata["model"]` as
11403///     `"{providerID}/{modelID}"` (from the top-level `providerID`/`modelID`
11404///     fields, joined) — split back on the FIRST `/` (matching `format!`'s
11405///     join; a `modelID` containing further `/`s round-trips correctly since
11406///     `split_once` only consumes the first) and re-emitted as the two
11407///     top-level `providerID`/`modelID` fields the loader actually reads.
11408/// - `is_summary`/`cost`/`finish`/`tokens`: Assistant-only concepts (no such
11409///   fields exist on opencode's `User` schema) — `is_summary` re-expands
11410///   `"true"` back to the native `summary: true` bool (the loader only ever
11411///   sets the metadata key on `Some(true)`, never on absent/false, so the
11412///   inverse never needs to emit `false`); `finish` is a plain string;
11413///   `cost`/`tokens` were captured via `v.to_string()` on the raw JSON
11414///   `Value` (a number and an object respectively), so they're re-parsed
11415///   from that stringified form and re-emitted as the native JSON value —
11416///   NOT as strings — matching `msg_value.get(field)` shape exactly.
11417fn opencode_restore_agent_model_fields(info: &mut Value, msg: &ChatMessage, is_assistant: bool) {
11418    if let Some(agent) = msg.metadata.get("agent") {
11419        info["agent"] = Value::String(agent.clone());
11420    }
11421    if let Some(model) = msg.metadata.get("model") {
11422        if is_assistant {
11423            if let Some((provider, model_id)) = model.split_once('/') {
11424                info["providerID"] = Value::String(provider.to_string());
11425                info["modelID"] = Value::String(model_id.to_string());
11426            }
11427        } else if let Ok(v) = serde_json::from_str::<Value>(model) {
11428            info["model"] = v;
11429        }
11430    }
11431    if !is_assistant {
11432        return;
11433    }
11434    if msg.metadata.get("is_summary").map(String::as_str) == Some("true") {
11435        info["summary"] = Value::Bool(true);
11436    }
11437    if let Some(finish) = msg.metadata.get("finish") {
11438        info["finish"] = Value::String(finish.clone());
11439    }
11440    if let Some(cost) = msg.metadata.get("cost") {
11441        if let Ok(v) = serde_json::from_str::<Value>(cost) {
11442            info["cost"] = v;
11443        }
11444    }
11445    if let Some(tokens) = msg.metadata.get("tokens") {
11446        if let Ok(v) = serde_json::from_str::<Value>(tokens) {
11447            info["tokens"] = v;
11448        }
11449    }
11450}
11451
11452fn opencode_user_parts_from_message(
11453    msg: &ChatMessage,
11454    msg_id: &str,
11455    session_id: &str,
11456    counter: &mut u64,
11457) -> Vec<Value> {
11458    let mut parts = Vec::new();
11459    if let Some(cps) = &msg.content_parts {
11460        for p in cps {
11461            match p.get("type").and_then(Value::as_str) {
11462                Some("text") => {
11463                    if let Some(t) = p.get("text").and_then(Value::as_str) {
11464                        parts.push(serde_json::json!({
11465                            "id": opencode_fresh_id("prt", counter),
11466                            "sessionID": session_id,
11467                            "messageID": msg_id,
11468                            "type": "text",
11469                            "text": t,
11470                        }));
11471                    }
11472                }
11473                Some("image_url") => {
11474                    if let Some(url) = p
11475                        .get("image_url")
11476                        .and_then(|u| u.get("url"))
11477                        .and_then(Value::as_str)
11478                    {
11479                        let mime = url
11480                            .strip_prefix("data:")
11481                            .and_then(|r| r.split_once(','))
11482                            .map(|(m, _)| m.trim_end_matches(";base64"))
11483                            .unwrap_or("application/octet-stream");
11484                        parts.push(serde_json::json!({
11485                            "id": opencode_fresh_id("prt", counter),
11486                            "sessionID": session_id,
11487                            "messageID": msg_id,
11488                            "type": "file",
11489                            "mime": mime,
11490                            "url": url,
11491                        }));
11492                    }
11493                }
11494                _ => {}
11495            }
11496        }
11497    } else if let Some(t) = &msg.content {
11498        if !t.is_empty() {
11499            parts.push(serde_json::json!({
11500                "id": opencode_fresh_id("prt", counter),
11501                "sessionID": session_id,
11502                "messageID": msg_id,
11503                "type": "text",
11504                "text": t,
11505            }));
11506        }
11507    }
11508    parts
11509}
11510
11511fn codex_response_item(payload: Value, ts: &str) -> Value {
11512    serde_json::json!({"timestamp": ts, "type": "response_item", "payload": payload})
11513}
11514
11515/// Stamp `payload.metadata.turn_id` when `turn_id` is `Some` (PARITY-6/7,
11516/// see [`Session::write_codex_records`]); a no-op returning `payload`
11517/// untouched when `None`, so the historical byte shape is preserved for
11518/// every record that has no merge ambiguity to disambiguate.
11519fn with_turn_id(mut payload: Value, turn_id: Option<&str>) -> Value {
11520    if let Some(tid) = turn_id {
11521        payload["metadata"] = serde_json::json!({"turn_id": tid});
11522    }
11523    payload
11524}
11525
11526/// Build a Codex `message` response_item's `content` block array from a
11527/// `ChatMessage` — the inverse of [`codex_extract_images`]/`extract_text_content`'s
11528/// parse. When `content_parts` is `None` this MUST reproduce the historical
11529/// single-block shape exactly (IX-5's overriding constraint: a text-only
11530/// message's export stays byte-identical) — only a multimodal message gets
11531/// one `{text_type}` block per non-empty text part plus one native Codex
11532/// `input_image` block (`{"type":"input_image","image_url":<data:URI or
11533/// URL>}` — the Responses-API-shaped image content Codex's own `input_text`/
11534/// `output_text` blocks already follow the family of) per `image_url` part.
11535fn codex_message_content_blocks(text_type: &str, msg: &ChatMessage) -> Value {
11536    match &msg.content_parts {
11537        Some(parts) => {
11538            let mut blocks = Vec::new();
11539            for p in parts {
11540                match p.get("type").and_then(Value::as_str) {
11541                    Some("text") => {
11542                        if let Some(t) = p.get("text").and_then(Value::as_str) {
11543                            if !t.is_empty() {
11544                                blocks.push(serde_json::json!({"type": text_type, "text": t}));
11545                            }
11546                        }
11547                    }
11548                    Some("image_url") => {
11549                        if let Some(url) = p
11550                            .get("image_url")
11551                            .and_then(|u| u.get("url"))
11552                            .and_then(Value::as_str)
11553                        {
11554                            blocks.push(serde_json::json!({
11555                                "type": "input_image",
11556                                "image_url": url,
11557                            }));
11558                        }
11559                    }
11560                    _ => {}
11561                }
11562            }
11563            Value::Array(blocks)
11564        }
11565        None => {
11566            let text = msg.content.clone().unwrap_or_default();
11567            Value::Array(vec![serde_json::json!({"type": text_type, "text": text})])
11568        }
11569    }
11570}
11571
11572/// PARITY-11 (nested images, honest-residue side): a Codex
11573/// `function_call_output` response_item's `output` field is a BARE STRING
11574/// (real `codex-rs` protocol shape — unlike a `message` response_item, it has
11575/// no structured content array, so [`codex_message_content_blocks`]'s
11576/// `input_image` slot genuinely does not apply here). A nested image captured
11577/// off a Claude `tool_result` (`extract_tool_result_content`,
11578/// `content_parts`) therefore CANNOT be carried through this hop — but rather
11579/// than silently re-emitting the old bare `[image]` marker (indistinguishable
11580/// from a real, intentional annotation and impossible to tell apart from
11581/// "the data survived") or dropping it with zero trace, fold in an honest,
11582/// countable disclosure of exactly how many images were dropped and why —
11583/// same bracketed-note convention as [`UNCONVERTIBLE_IMAGE_MARKER`], applied
11584/// on the WRITE side instead of the read side. `content_parts` being `None`
11585/// (every pre-existing call site, and any tool result with no nested image)
11586/// reproduces the historical `msg.content` text byte-for-byte.
11587fn codex_tool_output_text(msg: &ChatMessage) -> String {
11588    let mut text = msg.content.clone().unwrap_or_default();
11589    if let Some(parts) = &msg.content_parts {
11590        let n = parts
11591            .iter()
11592            .filter(|p| p.get("type").and_then(Value::as_str) == Some("image_url"))
11593            .count();
11594        if n > 0 {
11595            if !text.is_empty() {
11596                text.push('\n');
11597            }
11598            text.push_str(&format!(
11599                "[image: {n} nested image(s) dropped — codex tool output has no \
11600                 structured content slot to carry them]"
11601            ));
11602        }
11603    }
11604    text
11605}
11606
11607// ---- Pi writer helpers -----------------------------------------------------
11608
11609/// Emit the pi v3 `session` header line. Frozen (§1.1/§1.3): `version` is
11610/// ALWAYS 3, never a lower value, so pi never rewrites a supercode-emitted
11611/// file in place on first resume (`pi-fields.md` sm:848-850).
11612fn push_pi_header(
11613    out: &mut String,
11614    id: &str,
11615    cwd: &str,
11616    parent_session: Option<&str>,
11617    created_at: Option<&str>,
11618    claude_fork_context_ref: Option<&str>,
11619) {
11620    let mut header = serde_json::json!({
11621        "type": "session",
11622        "version": 3,
11623        "id": id,
11624        "timestamp": created_at.unwrap_or(SYNTH_TS),
11625        "cwd": cwd,
11626    });
11627    if let Some(ps) = parent_session {
11628        header["parentSession"] = Value::String(ps.to_string());
11629    }
11630    // D7: namespaced passthrough field, exactly like the Codex writer's
11631    // `session_meta.payload.claude_fork_context_ref` — pi tolerates unknown
11632    // header keys, and `capture_pi_header` reads this same key back on
11633    // import, so a Claude -> Pi -> Claude round trip still reconstructs the
11634    // fork-context-ref record instead of silently losing it on this hop.
11635    if let Some(raw) = claude_fork_context_ref {
11636        header["claude_fork_context_ref"] =
11637            serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()));
11638    }
11639    push_jsonl(out, &header);
11640}
11641
11642/// A fresh 8-hex entry id, collision-checked against `used` (mirrors pi's own
11643/// `randomUUID().slice(0,8)` + collision check, `pi-fields.md` sm:216-224 —
11644/// deterministic here rather than random, which still satisfies "fresh,
11645/// collision-free" without an extra RNG dependency).
11646fn pi_fresh_id(used: &mut HashSet<String>, counter: &mut u64) -> String {
11647    loop {
11648        *counter += 1;
11649        let h = counter.wrapping_mul(0x9E3779B97F4A7C15);
11650        let id = format!("{:08x}", (h >> 32) as u32);
11651        if used.insert(id.clone()) {
11652            return id;
11653        }
11654    }
11655}
11656
11657/// Parse a `data:<mime>[;base64],<data>` URI back into `(mime, data)` — the
11658/// inverse of the loader's `data:{mime};base64,{data}` construction.
11659fn parse_data_uri(url: &str) -> Option<(String, String)> {
11660    let rest = url.strip_prefix("data:")?;
11661    let (meta, data) = rest.split_once(',')?;
11662    let mime = meta.strip_suffix(";base64").unwrap_or(meta);
11663    Some((mime.to_string(), data.to_string()))
11664}
11665
11666/// Rebuild a pi `(TextContent|ImageContent)[]` (or bare string) content value
11667/// from a `ChatMessage`'s `content`/`content_parts` — shared by `user` and
11668/// `toolResult` entries (both use the identical union on the wire).
11669fn pi_content_value(msg: &ChatMessage) -> Value {
11670    if let Some(parts) = &msg.content_parts {
11671        let mut arr = Vec::new();
11672        for p in parts {
11673            match p.get("type").and_then(Value::as_str) {
11674                Some("text") => {
11675                    if let Some(t) = p.get("text").and_then(Value::as_str) {
11676                        arr.push(serde_json::json!({"type": "text", "text": t}));
11677                    }
11678                }
11679                Some("image_url") => {
11680                    if let Some(url) = p
11681                        .get("image_url")
11682                        .and_then(|u| u.get("url"))
11683                        .and_then(Value::as_str)
11684                    {
11685                        if let Some((mime, data)) = parse_data_uri(url) {
11686                            arr.push(
11687                                serde_json::json!({"type": "image", "mimeType": mime, "data": data}),
11688                            );
11689                        }
11690                    }
11691                }
11692                _ => {}
11693            }
11694        }
11695        Value::Array(arr)
11696    } else {
11697        Value::String(msg.content.clone().unwrap_or_default())
11698    }
11699}
11700
11701fn pi_assistant_content_value(msg: &ChatMessage) -> Value {
11702    let mut arr = Vec::new();
11703    if let Some(thinking) = msg.metadata.get("thinking") {
11704        let mut block = serde_json::json!({"type": "thinking", "thinking": thinking});
11705        if let Some(sig) = msg.metadata.get("thinking_signature") {
11706            block["thinkingSignature"] = Value::String(sig.clone());
11707        }
11708        if msg.metadata.get("pi_thinking_redacted").map(String::as_str) == Some("true") {
11709            block["redacted"] = Value::Bool(true);
11710        }
11711        arr.push(block);
11712    }
11713    if let Some(text) = &msg.content {
11714        if !text.is_empty() {
11715            let mut block = serde_json::json!({"type": "text", "text": text});
11716            if let Some(sig) = msg.metadata.get("pi_text_signature") {
11717                block["textSignature"] = Value::String(sig.clone());
11718            }
11719            arr.push(block);
11720        }
11721    }
11722    for tc in msg.tool_calls() {
11723        let args = tc
11724            .function
11725            .parsed_arguments()
11726            .unwrap_or_else(|_| Value::Object(Default::default()));
11727        let mut block = serde_json::json!({
11728            "type": "toolCall",
11729            "id": tc.id,
11730            "name": tc.function.name,
11731            "arguments": args,
11732        });
11733        if let Some(sig) = msg.metadata.get("pi_thought_signature") {
11734            block["thoughtSignature"] = Value::String(sig.clone());
11735        }
11736        arr.push(block);
11737    }
11738    Value::Array(arr)
11739}
11740
11741fn default_pi_usage() -> Value {
11742    serde_json::json!({
11743        "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0,
11744        "cost": {"input": 0.0, "output": 0.0, "cacheRead": 0.0, "cacheWrite": 0.0, "total": 0.0},
11745    })
11746}
11747
11748fn is_tool_error_flag(msg: &ChatMessage) -> bool {
11749    msg.metadata.get("pi_is_error").map(String::as_str) == Some("true") || crate::is_tool_error(msg)
11750}
11751
11752#[cfg(test)]
11753mod tests {
11754    use super::{
11755        opencode_message_timestamp, parent_tool_use_index, truncate_messages_with_anchor, Session,
11756        SessionFormat,
11757    };
11758    use crate::message::ChatMessage;
11759    use crate::{Fidelity, Role};
11760
11761    #[test]
11762    fn a_new_user_message_slides_the_display_tail_without_becoming_its_only_anchor() {
11763        let mut messages = vec![
11764            ChatMessage::user("original prompt"),
11765            ChatMessage::assistant("one"),
11766            ChatMessage::assistant("two"),
11767            ChatMessage::assistant("three"),
11768            ChatMessage::assistant("four"),
11769            ChatMessage::assistant("five"),
11770            ChatMessage::user("new prompt"),
11771        ];
11772
11773        truncate_messages_with_anchor(&mut messages, 4, None);
11774
11775        assert_eq!(messages.len(), 4);
11776        assert_eq!(messages[0].content.as_deref(), Some("original prompt"));
11777        assert_eq!(messages[3].content.as_deref(), Some("new prompt"));
11778    }
11779
11780    #[test]
11781    fn a_tool_heavy_current_turn_keeps_the_previous_and_current_user_anchors() {
11782        let mut messages = vec![
11783            ChatMessage::user("previous prompt"),
11784            ChatMessage::assistant("previous answer"),
11785            ChatMessage::user("current prompt"),
11786            ChatMessage::assistant("tool one"),
11787            ChatMessage::assistant("tool two"),
11788            ChatMessage::assistant("tool three"),
11789            ChatMessage::assistant("tool four"),
11790            ChatMessage::assistant("tool five"),
11791        ];
11792
11793        truncate_messages_with_anchor(&mut messages, 4, None);
11794
11795        assert_eq!(messages.len(), 4);
11796        assert_eq!(messages[0].content.as_deref(), Some("previous prompt"));
11797        assert_eq!(messages[1].content.as_deref(), Some("current prompt"));
11798        assert_eq!(messages[3].content.as_deref(), Some("tool five"));
11799    }
11800
11801    #[test]
11802    fn bounded_codex_file_expands_past_tool_noise_and_loads_real_earlier_history() {
11803        let nonce = std::time::SystemTime::now()
11804            .duration_since(std::time::UNIX_EPOCH)
11805            .unwrap()
11806            .as_nanos();
11807        let path = std::env::temp_dir().join(format!(
11808            "supercode-display-history-{}-{nonce}.jsonl",
11809            std::process::id()
11810        ));
11811        let user = |text: &str| {
11812            format!(
11813                r#"{{"type":"response_item","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"{text}"}}]}}}}"#,
11814            )
11815        };
11816        let assistant = |index: usize| {
11817            format!(
11818                r#"{{"type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"answer-{index}"}}]}}}}"#,
11819            )
11820        };
11821        let mut lines = vec![
11822            r#"{"type":"session_meta","payload":{"id":"session-1"}}"#.to_string(),
11823            user("earlier prompt"),
11824            format!(
11825                r#"{{"type":"event_msg","payload":{{"type":"token_count","noise":"{}"}}}}"#,
11826                "x".repeat(5 * 1024 * 1024)
11827            ),
11828            user("latest prompt"),
11829        ];
11830        lines.extend((0..130).map(assistant));
11831        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
11832
11833        let initial = Session::load_display_view(&path, Fidelity::Semantic, 120).unwrap();
11834        let expanded = Session::load_display_view(&path, Fidelity::Semantic, 240).unwrap();
11835        std::fs::remove_file(&path).unwrap();
11836
11837        let initial_users = initial
11838            .messages
11839            .iter()
11840            .filter(|message| message.role == Role::User)
11841            .filter_map(|message| message.content.as_deref())
11842            .collect::<Vec<_>>();
11843        assert_eq!(initial.messages.len(), 120);
11844        assert_eq!(initial_users, ["earlier prompt", "latest prompt"]);
11845        assert!(
11846            initial.imported_message_count.unwrap() > initial.messages.len(),
11847            "a bounded initial page must truthfully report earlier history"
11848        );
11849        assert_eq!(expanded.messages.len(), 132);
11850        assert_eq!(expanded.imported_message_count, Some(132));
11851    }
11852
11853    #[test]
11854    fn bounded_codex_display_history_reports_the_unbounded_message_total() {
11855        let jsonl = (0..6)
11856            .map(|index| {
11857                let role = if index % 2 == 0 { "user" } else { "assistant" };
11858                format!(
11859                    r#"{{"type":"response_item","payload":{{"type":"message","role":"{role}","content":[{{"type":"input_text","text":"message-{index}"}}]}}}}"#,
11860                )
11861            })
11862            .collect::<Vec<_>>()
11863            .join("\n");
11864
11865        let session = Session::from_codex_display_str(&jsonl, 2).unwrap();
11866
11867        assert_eq!(session.messages.len(), 2);
11868        assert_eq!(session.imported_message_count, Some(6));
11869    }
11870
11871    #[test]
11872    fn malformed_discriminated_native_turn_is_counted_as_parse_loss() {
11873        let base = Session::from_native_messages(Vec::new());
11874        let mut native = base.to_native_jsonl_v2(&[]);
11875        native.push_str("{\"supercode_turn\":1}\n");
11876
11877        let parsed = Session::from_native_str(&native).unwrap();
11878        assert_eq!(parsed.parse_error_lines, 1);
11879        assert!(parsed.messages.is_empty());
11880        assert_eq!(
11881            parsed.raw.last().map(String::as_str),
11882            Some("{\"supercode_turn\":1}")
11883        );
11884    }
11885
11886    #[test]
11887    fn spliced_export_refuses_a_native_wrapper_with_parse_loss() {
11888        let imported = Session::from_claude_code_str(
11889            r#"{"type":"user","sessionId":"s","cwd":"/tmp","message":{"role":"user","content":"hi"}}"#,
11890        )
11891        .unwrap();
11892        let mut native = imported.to_native_jsonl_v2(&[ChatMessage::assistant("continued")]);
11893        native.push_str("{\"supercode_turn\":1}\n");
11894
11895        let parsed = Session::from_native_str(&native).unwrap();
11896        let error = parsed
11897            .to_jsonl_spliced(SessionFormat::ClaudeCode, None)
11898            .unwrap_err();
11899        assert!(error.to_string().contains("parse loss"), "{error}");
11900    }
11901
11902    #[test]
11903    fn sidecar_loader_requires_a_supported_native_header() {
11904        for malformed in [
11905            "",
11906            "not-json\n",
11907            "{}\n",
11908            "{\"supercode_native\":2}\n",
11909            "{\"supercode_native\":99,\"source\":\"native\"}\n",
11910        ] {
11911            let error = Session::from_sidecar_str(malformed).unwrap_err();
11912            assert!(error.to_string().contains("sidecar header"), "{error}");
11913        }
11914    }
11915
11916    #[test]
11917    fn gemini_user_parts_preserve_text_media_and_response_order() {
11918        let session = Session::from_gemini_str(
11919            r#"{"sessionId":"11111111-1111-4111-8111-111111111111","projectHash":"p"}
11920{"type":"gemini","content":"calling","toolCalls":[{"id":"a","name":"read","args":{}},{"id":"b","name":"read","args":{}}]}
11921{"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"}]}
11922"#,
11923        )
11924        .unwrap();
11925
11926        assert_eq!(session.messages.len(), 6);
11927        assert_eq!(
11928            session.messages[1].content_parts.as_ref().unwrap()[0]["text"],
11929            "before"
11930        );
11931        assert_eq!(session.messages[2].tool_call_id.as_deref(), Some("b"));
11932        assert!(
11933            session.messages[3].content_parts.as_ref().unwrap()[0]["image_url"]["url"]
11934                .as_str()
11935                .unwrap()
11936                .starts_with("data:image/png;base64,")
11937        );
11938        assert_eq!(session.messages[4].tool_call_id.as_deref(), Some("a"));
11939        assert_eq!(
11940            session.messages[5].content_parts.as_ref().unwrap()[0]["text"],
11941            "after"
11942        );
11943    }
11944
11945    #[test]
11946    fn opencode_synthetic_timestamp_fails_closed_at_i64_max() {
11947        let msg = ChatMessage::user("continuation");
11948        let mut cursor = i64::MAX - 1;
11949        assert_eq!(
11950            opencode_message_timestamp(&msg, &mut cursor).unwrap(),
11951            i64::MAX
11952        );
11953        let err = opencode_message_timestamp(&msg, &mut cursor).unwrap_err();
11954        assert!(err.to_string().contains("after i64::MAX"));
11955        assert_eq!(cursor, i64::MAX, "overflow must not wrap or mutate state");
11956    }
11957
11958    /// Pin of the single-pass indexer against the relevant Claude tool-result
11959    /// shape (SUP-21). An id absent from the transcript must map to nothing.
11960    #[test]
11961    fn parent_tool_use_index_matches_known_fixture_linkage() {
11962        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"}}"#;
11963
11964        let ids = vec![
11965            "ad8dc6cf98b49eea6".to_string(),
11966            "no-such-agent-id".to_string(),
11967        ];
11968        let index = parent_tool_use_index(main_text, &ids);
11969
11970        assert_eq!(
11971            index.get("ad8dc6cf98b49eea6").map(String::as_str),
11972            Some("toolu_01SYjhg9qRCzUWY2GTa3iazQ"),
11973            "known agent id must resolve to the pinned parent tool_use_id"
11974        );
11975        assert_eq!(
11976            index.get("no-such-agent-id"),
11977            None,
11978            "unknown agent id must yield no entry (best-effort None)"
11979        );
11980    }
11981
11982    #[test]
11983    fn parent_tool_use_index_empty_ids_returns_empty_map() {
11984        let index = parent_tool_use_index("irrelevant text", &[]);
11985        assert!(index.is_empty());
11986    }
11987}