Skip to main content

supercode_interchange/
session.rs

1//! Natively load — and continue — real Claude Code and Codex sessions.
2//!
3//! Both tools persist their conversations as JSONL on disk:
4//!
5//! - **Claude Code**: `~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`,
6//!   one line per event in the Anthropic message format, linked by
7//!   `uuid`/`parentUuid`.
8//! - **Codex**: `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`, where each line
9//!   is a `{timestamp, type, payload}` envelope and the `response_item` lines
10//!   form the canonical conversation.
11//!
12//! [`Session::load`] auto-detects the format and normalizes either one into a
13//! provider-neutral [`Vec<ChatMessage>`] that can be handed straight back to a
14//! model (via OpenRouter or any OpenAI-compatible endpoint) to continue.
15//!
16//! Provider-internal artifacts that don't replay across vendors — Anthropic
17//! `thinking` blocks, Codex `reasoning` items — are dropped during
18//! normalization.
19//!
20//! # Where this sits in supercode's priorities
21//!
22//! This module is the home of **feature 1 (translate between session formats)**
23//! and half of **feature 2 (emulate-to-continue)** — the load/emit surface for
24//! each harness ([`SessionFormat`], `from_*_str` loaders, `to_*_jsonl`
25//! emitters). See [`AGENTS.md`](../../../AGENTS.md) for the three ranked
26//! feature-priorities and the glue-tool positioning; the top priority is
27//! **feature 3 (continue losslessly *with massive token reduction*)**, which
28//! this fidelity work exists to make trustworthy. `opencode` + `pi` loaders
29//! are built against the frozen `docs/interop/opencode-pi-spec.md` contract
30//! — OpenCode additionally reads its native SQLite store (`opencode*.db`,
31//! PARITY-3/PARITY-16) via `rusqlite`, reconstructing the same envelope form
32//! [`Session::from_opencode_str`] already parses for the JSON-tree surfaces.
33
34use std::collections::{BTreeMap, HashMap, HashSet};
35use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
36use std::path::{Path, PathBuf};
37
38use rusqlite::Connection;
39use serde_json::Value;
40
41use crate::{
42    ChatMessage, Fidelity, FunctionCall, InterchangeError as Error, Result, Role, ToolCall,
43};
44
45/// Which tool produced a session log.
46///
47/// This is **read-provenance**: a fact recovered when a log is loaded (stored
48/// in [`SessionMeta::source`], filled in by auto-detection in
49/// `detect_source`), describing which tool originally wrote the file on
50/// disk. It answers "where did this session come from?" — e.g. for
51/// `inspect`/`convert` display in the CLI.
52///
53/// It is deliberately distinct from [`SessionFormat`], even though the two
54/// enums' variant lists currently coincide: [`SessionFormat`] selects a
55/// serialization codec (what to parse/export *as*), while `SessionSource`
56/// records history (what wrote the file). The pair is intentionally kept
57/// separate rather than merged — a session loaded from one tool's log can
58/// still be exported in the other tool's format, and the two concepts could
59/// diverge further (e.g. a format that is readable but not attributable, or
60/// multiple versioned formats sharing one source).
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum SessionSource {
63    /// A `~/.claude/projects/.../<id>.jsonl` transcript.
64    ClaudeCode,
65    /// A `~/.codex/sessions/.../rollout-*.jsonl` file.
66    Codex,
67    /// An OpenCode session — multi-file JSON tree(s) or SQLite `opencode*.db`
68    /// (`docs/interop/opencode-pi-spec.md` §1.2). Detection and loading are
69    /// wave B; this variant exists now so `SessionSource`/`SessionFormat` stay
70    /// 1:1 per the frozen interop spec (§0).
71    OpenCode,
72    /// A `~/.pi/agent/sessions/--<enc-cwd>--/<iso>_<sessionId>.jsonl`
73    /// transcript (`docs/interop/opencode-pi-spec.md` §1.1) — line-oriented
74    /// JSONL like Claude Code/Codex, so it shares their byte-lossless native
75    /// round-trip property.
76    Pi,
77    /// A Grok session transcript stored as
78    /// `~/.grok/sessions/<percent-encoded-cwd>/<session-id>/chat_history.jsonl`.
79    Grok,
80    /// A Gemini CLI transcript stored under
81    /// `~/.gemini/tmp/<project>/chats/session-*.jsonl`.
82    Gemini,
83    /// A Goose session exported through `_goose/unstable/session/export`, or
84    /// reconstructed from Goose's `sessions/sessions.db` native store.
85    Goose,
86    /// P5-3 safety-hardening fix (Fable-5 review, LOW "translation-fidelity
87    /// cosmetic"): a session that was never imported from ANY foreign
88    /// tool's log at all — authored directly by supercode's own agent loop,
89    /// with no foreign-tool prefix (`Session.raw` starts empty). Currently
90    /// only `crate::agent::Agent`'s `persist_subagent_transcript` (P5-3,
91    /// natively-spawned `spawn_subagent` children) uses this — before this
92    /// variant existed, that call site built its blank `Session` via
93    /// `Session::from_claude_code_str("")` purely as an "empty parser to
94    /// get a blank skeleton" trick, which left `meta.source ==
95    /// SessionSource::ClaudeCode` even though nothing Claude-Code-shaped
96    /// was ever involved, mislabeling a native supercode spawn as an
97    /// imported CC session on disk (and in any `inspect`/`convert` reading
98    /// it back). Never produced by auto-detection (`detect_source`) or any
99    /// `from_<tool>_str` loader — only by code that explicitly constructs
100    /// a `SessionMeta` with this source, so no existing imported-session
101    /// path can ever observe this variant appearing where it didn't before.
102    Native,
103}
104
105/// An on-disk session format supercode can both read and write.
106///
107/// Like an image editor that opens and exports several file formats, supercode
108/// keeps one canonical in-memory model ([`Session`]) and converts to/from each
109/// supported format on the edges.
110///
111/// This is a **write-target** / codec selector: a caller's request, passed to
112/// [`Session::load_str`], [`Session::to_jsonl`], and [`Session::save`],
113/// choosing which on-disk dialect to parse or emit. It answers "what format
114/// should I read/write?" — as opposed to [`SessionSource`], which records the
115/// provenance fact of what actually produced a loaded file. The two enums are
116/// intentionally kept separate (provenance fact vs. serialization choice) and
117/// should not be unified, even though their variants currently match
118/// one-to-one.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum SessionFormat {
121    /// Claude Code transcript JSONL.
122    ClaudeCode,
123    /// Codex rollout JSONL.
124    Codex,
125    /// OpenCode export-document / envelope JSONL (wave B; see
126    /// [`SessionSource::OpenCode`]).
127    OpenCode,
128    /// Pi session JSONL (see [`SessionSource::Pi`]).
129    Pi,
130    /// Grok `chat_history.jsonl` transcript.
131    Grok,
132    /// Gemini CLI session JSONL.
133    Gemini,
134    /// Goose native session-export JSON.
135    Goose,
136}
137
138impl SessionFormat {
139    /// The [`SessionSource`] a file of this format reports.
140    ///
141    /// This is the deliberate one-way bridge between the two concepts: a file
142    /// saved in this format will, when reloaded, report this provenance (see
143    /// `crates/harness/tests/session_saving.rs`), making the relationship
144    /// discoverable from the method itself.
145    pub fn source(self) -> SessionSource {
146        match self {
147            SessionFormat::ClaudeCode => SessionSource::ClaudeCode,
148            SessionFormat::Codex => SessionSource::Codex,
149            SessionFormat::OpenCode => SessionSource::OpenCode,
150            SessionFormat::Pi => SessionSource::Pi,
151            SessionFormat::Grok => SessionSource::Grok,
152            SessionFormat::Gemini => SessionSource::Gemini,
153            SessionFormat::Goose => SessionSource::Goose,
154        }
155    }
156}
157
158/// Metadata recovered from a session log.
159#[derive(Debug, Clone)]
160#[non_exhaustive]
161pub struct SessionMeta {
162    /// The tool that wrote the log.
163    pub source: SessionSource,
164    /// The session/rollout id.
165    pub session_id: Option<String>,
166    /// The model the session was running.
167    pub model: Option<String>,
168    /// The working directory the session ran in.
169    pub cwd: Option<PathBuf>,
170    /// The system / base-instructions prompt, when the log records it.
171    pub system_prompt: Option<String>,
172    /// Verbatim source-format header records (the Codex `session_meta` /
173    /// `turn_context` lines), preserved so re-export can replay the exact header
174    /// the original tool expects rather than guessing its required fields.
175    pub codex_headers: Vec<Value>,
176    /// Exact source lines for Codex execution/provenance records that affect
177    /// continuation semantics but must not be replayed as active events after
178    /// a foreign-format hop. Each entry records its original physical-line
179    /// index, discriminant, and verbatim JSONL text. Foreign writers carry the
180    /// list in a namespaced extension; a later Codex export restores headers
181    /// from it while keeping compaction/rollback/review records non-operative,
182    /// avoiding a second rollback or compaction of the already-normalized view.
183    pub codex_provenance: Vec<Value>,
184    /// The OpenCode analogue of [`Self::codex_headers`]
185    /// (`docs/interop/opencode-pi-spec.md` §1.2/§2.1): the verbatim
186    /// `SessionInfo` record (always element 0, or `Value::Null` if somehow
187    /// absent), plus any captured `session_diff`/`todo` side-records — each
188    /// wrapped as `{"key": [...], "value": ...}`, mirroring the envelope
189    /// shape `raw` uses, so a consumer can tell which storage key a header
190    /// record belongs to. These replay only via the direct-write fallback
191    /// (`Session::to_opencode_direct_write`); `opencode import` has no
192    /// ingestion path for `session_diff`/`todo` (S5).
193    pub opencode_headers: Vec<Value>,
194    /// Goose's native session-export object with `conversation` removed.
195    /// Goose stores sessions in SQLite but defines this JSON object as its
196    /// official import/export boundary. Keeping the shell lets an unchanged
197    /// direct round-trip remain byte exact while appended turns are spliced
198    /// into a stock-importable artifact without guessing native metadata.
199    pub goose_header: Option<Value>,
200    /// For a Claude Code subagent session: its `agentId` (the `agent-<id>` file
201    /// stem). `None` for top-level sessions.
202    pub agent_id: Option<String>,
203    /// For a subagent session: the `tool_use_id` of the parent `Task` call that
204    /// spawned it, recovered from the parent transcript's tool result. Best
205    /// effort — `None` if the link could not be established.
206    pub parent_tool_use_id: Option<String>,
207    /// Cross-file lineage keys for multi-file/multi-agent sessions (Codex
208    /// `parent_thread_id`, `forked_from_id`, `thread_source`, and the
209    /// `source.subagent.thread_spawn` fields `agent_role` / `agent_nickname` /
210    /// `depth`). Empty for a plain top-level session. Used by
211    /// [`Session::reconstruct_tree`] to nest children under their parents.
212    pub lineage: std::collections::BTreeMap<String, String>,
213}
214
215impl SessionMeta {
216    fn new(source: SessionSource) -> Self {
217        SessionMeta {
218            source,
219            session_id: None,
220            model: None,
221            cwd: None,
222            system_prompt: None,
223            codex_headers: Vec::new(),
224            codex_provenance: Vec::new(),
225            opencode_headers: Vec::new(),
226            goose_header: None,
227            agent_id: None,
228            parent_tool_use_id: None,
229            lineage: std::collections::BTreeMap::new(),
230        }
231    }
232}
233
234/// A normalized, replayable conversation loaded from a tool's session log.
235#[derive(Debug, Clone)]
236pub struct Session {
237    /// Recovered metadata.
238    pub meta: SessionMeta,
239    /// The conversation, normalized to the OpenAI chat-completions shape.
240    pub messages: Vec<ChatMessage>,
241    /// Subagent (Task) sub-conversations. Claude Code stores these as separate
242    /// `<session>/subagents/agent-*.jsonl` files; loading a session by path now
243    /// discovers and attaches them here (each is a full [`Session`] whose
244    /// `meta.agent_id` / `meta.parent_tool_use_id` link it back to its spawn).
245    pub subagents: Vec<Session>,
246    /// Every original JSONL line of the source log, STRICT-VERBATIM (IX-1):
247    /// captured via `split_lines_verbatim`, not the blank-skipping/trimming
248    /// `non_empty_lines` parse view, so a blank line, a CRLF (`\r\n`)
249    /// terminator, or trailing whitespace on a line all survive bit-for-bit
250    /// rather than being dropped/normalized away. Normalization into
251    /// `messages` is still lossy by design (it targets the OpenAI replay
252    /// shape), but these raw lines retain *everything* — including records
253    /// with no canonical representation (e.g. Claude `file-history-snapshot`)
254    /// — so a round-trip through the supercode-native format
255    /// ([`Session::to_native_jsonl`]) is byte-lossless for the line-oriented
256    /// formats (Claude Code/Codex/Pi), for ANY input (see
257    /// [`Self::raw_trailing_newline`] for the one piece of information a line
258    /// list alone can't carry).
259    pub raw: Vec<String>,
260    /// Whether the source text `raw` was captured from ended with a trailing
261    /// `\n`. `raw`'s line list alone can't distinguish a source ending with a
262    /// trailing newline from one that doesn't (both split into the same
263    /// lines) — this flag carries that fact out-of-band so
264    /// [`Self::to_native_jsonl`]/[`Self::from_native_str`] can reproduce the
265    /// original source bytes exactly, including the presence/absence of a
266    /// final newline. `true` for a `Session` whose `raw` isn't captured
267    /// verbatim from real source text (e.g. OpenCode's re-synthesized
268    /// export-document `raw`, or a `Session` assembled programmatically) —
269    /// matching the historical always-terminated-by-newline behavior for
270    /// those cases.
271    pub raw_trailing_newline: bool,
272    /// How many of `messages` (and, symmetrically, of `raw` — see below) came
273    /// from parsing the imported log, as opposed to being appended after
274    /// import. Set once, at the end of [`Self::from_claude_code_str`] /
275    /// [`Self::from_codex_str`], to `messages.len()` at that moment — i.e.
276    /// before [`Self::from_native_str`]'s subsequent loop reattaches any
277    /// appended [`crate::sidecar::NativeTurn`] records onto `messages`/`raw`.
278    /// That loop pushes exactly one `raw` line and one message per appended
279    /// turn, so the two lists grow in lockstep from here on: the raw-prefix
280    /// boundary A12's [`Self::to_jsonl_spliced`] needs is always recoverable
281    /// as `raw.len() - (messages.len() - imported_message_count)`, without a
282    /// second counter. `None` only when a `Session` is constructed some other
283    /// way than through those two loaders — splicing then has no boundary to
284    /// honor and treats every message as imported (equivalent to
285    /// `Some(messages.len())`).
286    /// A bounded display-history projection uses this field for the total
287    /// number of normalized messages observed before its in-memory window was
288    /// applied. Such a semantic view is never a continuation source, and all
289    /// splice callers clamp the value to `messages.len()`.
290    pub imported_message_count: Option<usize>,
291    /// Whether `raw` was captured strict-verbatim from real source text
292    /// (`true`) or re-synthesized by this crate (`false`) — the fact
293    /// [`Self::raw_verbatim`]'s callers need to know before claiming a
294    /// same-format `convert` is byte-identical (PARITY-AUDIT.md P006/P007).
295    /// `true` for every line-oriented loader (`from_claude_code_str`,
296    /// `from_codex_str`, `from_pi_str`) and OpenCode's own ENVELOPE read
297    /// surface (`from_opencode_str`'s per-line loop) — each of those splits
298    /// `raw` directly out of the source text via `split_lines_verbatim`, so
299    /// replaying it reproduces the original bytes exactly. `false` for
300    /// OpenCode's EXPORT-DOCUMENT read surface
301    /// (`Session::from_opencode_export_doc`): a pretty-printed
302    /// `{info, messages:[...]}` document has no per-line envelope structure
303    /// of its own, so `raw` there is one envelope line RE-SYNTHESIZED per
304    /// record — faithful in value, but not the original document's bytes.
305    /// A `Session` assembled programmatically (not through a `from_*_str`
306    /// loader) also defaults to `false` — no real source text was captured
307    /// at all.
308    pub raw_is_verbatim: bool,
309    /// PARITY-15: how many non-empty lines of the source text FAILED to
310    /// deserialize at all (a genuinely malformed/truncated JSON line — not
311    /// a well-formed-but-unmodeled record type, which is a normal,
312    /// intentional "skip", tracked separately by `crate::audit`). Every
313    /// line-oriented loader tolerates a stray corrupt line rather than
314    /// hard-failing the whole load (a single bad line must not make an
315    /// otherwise-healthy multi-thousand-line session unloadable) — but that
316    /// tolerance used to be completely invisible: `Session::load` returned
317    /// `Ok` either way, with no signal that anything was skipped. This
318    /// count is what lets a caller (the CLI, `inspect`/`convert`) surface
319    /// that loss loudly instead of silently. `0` for a cleanly-parsed file,
320    /// and for a `Session` assembled programmatically.
321    pub parse_error_lines: usize,
322    /// Named degradations a [`Fidelity::Semantic`] load accepted instead of
323    /// failing — the same "say exactly what was given up" residue list
324    /// `harness.v1.sessions.export` already reports for artifacts.
325    ///
326    /// ALWAYS empty for a lossless load: every stricter fidelity refuses a
327    /// transcript it cannot reconstruct exactly, which is what keeps
328    /// continuation/transfer/export guarantees intact. A non-empty list means
329    /// this session is a read-only VIEW ([`Session::load_with_fidelity`] with
330    /// [`Fidelity::Semantic`]) and must not be used as a continuation source.
331    pub load_residue: Vec<String>,
332}
333
334impl Session {
335    /// The fidelity this reconstruction actually achieved.
336    ///
337    /// Same rule the export path applies to an artifact: named residue means
338    /// [`Fidelity::Semantic`]; otherwise a verbatim source capture is
339    /// [`Fidelity::ByteLossless`] and a re-synthesized one is
340    /// [`Fidelity::ValueLossless`]. A subagent's residue counts as this
341    /// session's: the whole reconstruction is only as faithful as its least
342    /// faithful part, and each child still reports its own residue where it
343    /// was measured.
344    pub fn load_fidelity(&self) -> Fidelity {
345        let own = if !self.load_residue.is_empty() {
346            Fidelity::Semantic
347        } else if self.raw_is_verbatim {
348            Fidelity::ByteLossless
349        } else {
350            Fidelity::ValueLossless
351        };
352        if own != Fidelity::Semantic
353            && self
354                .subagents
355                .iter()
356                .any(|subagent| subagent.load_fidelity() == Fidelity::Semantic)
357        {
358            return Fidelity::Semantic;
359        }
360        own
361    }
362
363    /// Assemble a session from supercode's own flat store transcript (one
364    /// [`ChatMessage`] per JSONL line). These files are the native working
365    /// format written by Supercode's native session store, not a foreign
366    /// harness log, so routing them through format auto-detection would
367    /// misclassify them as an empty Claude Code session.
368    pub fn from_native_messages(messages: Vec<ChatMessage>) -> Session {
369        Session {
370            meta: SessionMeta::new(SessionSource::Native),
371            messages,
372            subagents: Vec::new(),
373            raw: Vec::new(),
374            raw_trailing_newline: true,
375            imported_message_count: None,
376            raw_is_verbatim: false,
377            parse_error_lines: 0,
378            load_residue: Vec::new(),
379        }
380    }
381
382    /// Load a session, auto-detecting whether it's a Claude Code or Codex log
383    /// — or, when `path` looks like a SQLite database, a real OpenCode
384    /// `opencode*.db` store (PARITY-3/PARITY-16): that check runs BEFORE any
385    /// UTF-8 text read, so a binary `.db` file is routed to
386    /// [`Self::from_opencode_sqlite`] instead of failing on a raw "stream
387    /// did not contain valid UTF-8" error (the confirmed footgun these items
388    /// close — see [`looks_like_sqlite`] and the UTF-8 diagnostic reader).
389    ///
390    /// A DIRECTORY is also accepted directly: `path` is probed with
391    /// [`detect_opencode_storage_surface`] BEFORE the SQLite/UTF-8 file
392    /// checks below (both of which assume a file and would otherwise surface
393    /// a cryptic "Is a directory" `io::Error` — the confirmed footgun this
394    /// closes). This lets `inspect`/`convert`/`resume` accept an OpenCode
395    /// DATA-ROOT directly (e.g. `~/.local/share/opencode`), matching what
396    /// `audit --format opencode` already does. A resolved `Sqlite` surface
397    /// loads exactly like pointing `load` at that `opencode*.db` file
398    /// directly (most-recently-updated top-level session). The legacy
399    /// `JsonTreeA`/`JsonTreeB` surfaces are classifier-only (see
400    /// [`OpenCodeStorageSurface`]) — there's no direct-JSON-tree loader, so
401    /// that case returns a clear error naming the `.db` file / `audit` as the
402    /// way in, rather than silently doing nothing or crashing.
403    pub fn load(path: impl AsRef<Path>) -> Result<Session> {
404        Self::load_with_fidelity(path, Fidelity::ByteLossless)
405    }
406
407    /// Load a session at a declared [`Fidelity`].
408    ///
409    /// [`Fidelity::Semantic`] is the READ-ONLY VIEW mode: a transcript whose
410    /// record graph cannot be reconstructed exactly (the everyday case for a
411    /// Claude Code session that has been compacted or resumed across files,
412    /// where a live record's `parentUuid` names a record that was pruned)
413    /// still loads, stitched best-effort in transcript order, and names what
414    /// it gave up in [`Session::load_residue`]. Every stricter level keeps
415    /// the historical behavior — refuse loudly — because a continuation,
416    /// transfer or export built on a guessed graph is exactly the loss
417    /// supercode exists to prevent. Callers that go on to RESUME a session
418    /// must therefore use [`Session::load`].
419    pub fn load_with_fidelity(path: impl AsRef<Path>, fidelity: Fidelity) -> Result<Session> {
420        Self::load_with_fidelity_and_subagents(path, fidelity, true)
421    }
422
423    /// Load only the selected session's own transcript at a declared fidelity.
424    ///
425    /// This is the read-only frontend path: Claude Code can place hundreds of
426    /// child transcripts beside a parent, but a chat viewport displaying the
427    /// parent must not eagerly parse and transport that entire child tree.
428    /// Translation, continuation, export, and the ordinary [`Self::load`]
429    /// path keep attaching every subagent unchanged.
430    #[doc(hidden)]
431    pub fn load_parent_with_fidelity(
432        path: impl AsRef<Path>,
433        fidelity: Fidelity,
434    ) -> Result<Session> {
435        Self::load_with_fidelity_and_subagents(path, fidelity, false)
436    }
437
438    /// Load a bounded, parent-only transcript for human display.
439    ///
440    /// Unlike the continuation loader, Codex compaction records do not erase
441    /// earlier visible assistant turns here: the native rollout still holds
442    /// those records, and a scrollback view should show what the human saw,
443    /// not only the compacted context the next model call will receive.
444    #[doc(hidden)]
445    pub fn load_display_view(
446        path: impl AsRef<Path>,
447        fidelity: Fidelity,
448        message_limit: usize,
449    ) -> Result<Session> {
450        let path = path.as_ref();
451        if path.is_dir() || looks_like_sqlite(path) {
452            let mut session = Self::load_parent_with_fidelity(path, fidelity)?;
453            truncate_session_messages(&mut session, message_limit);
454            return Ok(session);
455        }
456        let mut read_limit = message_limit.max(1);
457        let mut previous_window_len = 0usize;
458        let (mut session, omitted_prefix) = loop {
459            let (source, text, omitted_prefix) = read_display_jsonl(path, read_limit)?;
460            let mut candidate = match source {
461                Some(SessionSource::Codex) => Self::from_codex_display_str(&text, message_limit)?,
462                Some(SessionSource::Gemini) => {
463                    let mut session = Self::from_gemini_str(&text)?;
464                    session.raw_is_verbatim = false;
465                    session.load_residue.push(
466                        "display history is a bounded native-record projection, not a complete Gemini artifact"
467                            .to_string(),
468                    );
469                    session
470                }
471                Some(SessionSource::Pi) => Self::from_pi_str(&text)?,
472                Some(SessionSource::Grok) => {
473                    let mut session = Self::from_grok_str(&text)?;
474                    session.capture_grok_path_metadata(path);
475                    session
476                }
477                Some(SessionSource::OpenCode) => Self::from_opencode_str(&text)?,
478                _ => Self::from_claude_code_str_with_fidelity(&text, fidelity)?,
479            };
480            let observed_messages = candidate
481                .imported_message_count
482                .unwrap_or(candidate.messages.len())
483                .max(candidate.messages.len());
484            let human_turns = candidate
485                .messages
486                .iter()
487                .filter(|message| message.role == Role::User)
488                .count();
489            let window_len = text.len();
490            let sufficient =
491                !omitted_prefix || (observed_messages > message_limit.max(1) && human_turns >= 2);
492            // 16 KiB/message with a 64 MiB ceiling means 4096 is the first
493            // read limit that cannot grow the native byte window further.
494            // Smaller repeated lengths can be the intentional 4 MiB floor;
495            // keep doubling through that plateau instead of declaring a
496            // false pagination end.
497            let byte_window_exhausted = window_len <= previous_window_len && read_limit >= 4096;
498            if sufficient || byte_window_exhausted {
499                if omitted_prefix {
500                    // The prefix is known to contain more native history even
501                    // when this bounded window cannot cheaply normalize its
502                    // exact size. Never turn that into a false end-of-history.
503                    candidate.imported_message_count =
504                        Some(observed_messages.max(message_limit.max(1).saturating_add(1)));
505                }
506                break (candidate, omitted_prefix);
507            }
508            previous_window_len = window_len;
509            read_limit = read_limit.saturating_mul(2);
510        };
511        if omitted_prefix {
512            session.load_residue.push(
513                "older native records remain outside this bounded display window".to_string(),
514            );
515        }
516        truncate_session_messages(&mut session, message_limit);
517        Ok(session)
518    }
519
520    fn load_with_fidelity_and_subagents(
521        path: impl AsRef<Path>,
522        fidelity: Fidelity,
523        include_subagents: bool,
524    ) -> Result<Session> {
525        let path = path.as_ref();
526        if path.is_dir() {
527            return match detect_opencode_storage_surface(path) {
528                Some((OpenCodeStorageSurface::Sqlite, db_path)) => {
529                    Self::from_opencode_sqlite(&db_path, None)
530                }
531                Some((
532                    OpenCodeStorageSurface::JsonTreeA | OpenCodeStorageSurface::JsonTreeB,
533                    _,
534                )) => Err(crate::Error::Other(format!(
535                    "{} is an OpenCode data root using a legacy JSON storage tree, which \
536                         supercode does not load directly — point `inspect`/`convert`/`resume` \
537                         at the store's `opencode*.db` SQLite file if this install has one, or \
538                         use `audit --format opencode {}` instead",
539                    path.display(),
540                    path.display()
541                ))),
542                None => Err(crate::Error::Other(format!(
543                    "{} is a directory, but no session file or OpenCode store was found in it \
544                     (expected an `opencode*.db` SQLite file, or an OpenCode legacy JSON storage \
545                     tree)",
546                    path.display()
547                ))),
548            };
549        }
550        if looks_like_sqlite(path) {
551            return Self::from_opencode_sqlite(path, None);
552        }
553        let text = read_utf8_or_diagnose(path)?;
554        match detect_source(&text) {
555            Some(SessionSource::Codex) => Self::from_codex_str(&text),
556            Some(SessionSource::Pi) => Self::from_pi_str(&text),
557            Some(SessionSource::Grok) => {
558                let mut session = Self::from_grok_str(&text)?;
559                session.capture_grok_path_metadata(path);
560                Ok(session)
561            }
562            Some(SessionSource::Gemini) => Self::from_gemini_str(&text),
563            Some(SessionSource::Goose) => Self::from_goose_str(&text),
564            // IX-3: a detected OpenCode session must route to its own
565            // loader, not the Claude Code fallback below
566            // (`docs/interop/build-followups.md`).
567            Some(SessionSource::OpenCode) => Self::from_opencode_str(&text),
568            _ => {
569                let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
570                if include_subagents {
571                    session.attach_claude_subagents(path, &text, fidelity)?;
572                }
573                Ok(session)
574            }
575        }
576    }
577
578    /// Load a Claude Code transcript from a file, attaching any subagent
579    /// (`Task`) sub-conversations stored alongside it.
580    pub fn from_claude_code(path: impl AsRef<Path>) -> Result<Session> {
581        Self::from_claude_code_with_fidelity(path, Fidelity::ByteLossless)
582    }
583
584    /// [`Session::from_claude_code`] at a declared [`Fidelity`] — see
585    /// [`Session::load_with_fidelity`] for what [`Fidelity::Semantic`] buys.
586    pub fn from_claude_code_with_fidelity(
587        path: impl AsRef<Path>,
588        fidelity: Fidelity,
589    ) -> Result<Session> {
590        let text = std::fs::read_to_string(path.as_ref())?;
591        let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
592        session.attach_claude_subagents(path.as_ref(), &text, fidelity)?;
593        Ok(session)
594    }
595
596    /// Discover and load `<session>/subagents/agent-*.jsonl` files for a
597    /// Claude Code transcript at `main_path`, linking each back to the parent
598    /// `Task` tool call via the agent id embedded in the parent's tool result.
599    fn attach_claude_subagents(
600        &mut self,
601        main_path: &Path,
602        main_text: &str,
603        fidelity: Fidelity,
604    ) -> Result<()> {
605        let Some(dir) = subagents_dir_for(main_path) else {
606            return Ok(());
607        };
608        let entries = std::fs::read_dir(&dir).map_err(|error| {
609            crate::Error::Other(format!(
610                "failed to enumerate Claude subagents at {}: {error}",
611                dir.display()
612            ))
613        })?;
614        let mut files = Vec::new();
615        for entry in entries {
616            let entry = entry.map_err(|error| {
617                crate::Error::Other(format!(
618                    "failed to enumerate Claude subagents at {}: {error}",
619                    dir.display()
620                ))
621            })?;
622            let path = entry.path();
623            if path.extension().and_then(|extension| extension.to_str()) == Some("jsonl") {
624                files.push(path);
625            }
626        }
627        files.sort();
628
629        // Phase 1 — collect each subagent + its recovered agent id, without
630        // touching the main transcript yet.
631        let mut collected: Vec<(Session, Option<String>)> = Vec::with_capacity(files.len());
632        for file in files {
633            let text = read_utf8_or_diagnose(&file).map_err(|error| {
634                crate::Error::Other(format!(
635                    "failed to read Claude subagent {}: {error}",
636                    file.display()
637                ))
638            })?;
639            let sub = match Self::from_claude_code_str_with_fidelity(&text, fidelity) {
640                Ok(sub) => sub,
641                // A read-only VIEW keeps the main conversation rather than
642                // losing the whole session to one unreconstructable child;
643                // the skip is named, not silent. Every stricter fidelity
644                // still propagates the child's failure.
645                Err(error) if fidelity.tolerates_residue() => {
646                    self.load_residue.push(format!(
647                        "Claude subagent {} could not be reconstructed ({error}); it was omitted from this view",
648                        file.display()
649                    ));
650                    continue;
651                }
652                Err(error) => {
653                    return Err(crate::Error::Other(format!(
654                        "failed to reconstruct Claude subagent {}: {error}",
655                        file.display()
656                    )))
657                }
658            };
659            // agentId: prefer the file's own record, fall back to the filename stem.
660            let agent_id = first_agent_id(&text).or_else(|| {
661                file.file_stem()
662                    .and_then(|s| s.to_str())
663                    .map(|s| s.trim_start_matches("agent-").to_string())
664            });
665            collected.push((sub, agent_id));
666        }
667
668        // Phase 2 — single pass over the main transcript to index every
669        // requested agent id at once, then assign each subagent's parent by
670        // an O(1) lookup.
671        let agent_ids: Vec<String> = collected.iter().filter_map(|(_, id)| id.clone()).collect();
672        let index = parent_tool_use_index(main_text, &agent_ids);
673
674        for (mut sub, agent_id) in collected {
675            sub.meta.parent_tool_use_id = agent_id.as_ref().and_then(|id| index.get(id).cloned());
676            sub.meta.agent_id = agent_id;
677            self.subagents.push(sub);
678        }
679        Ok(())
680    }
681
682    /// Load a Codex rollout from a file.
683    pub fn from_codex(path: impl AsRef<Path>) -> Result<Session> {
684        Self::from_codex_str(&std::fs::read_to_string(path.as_ref())?)
685    }
686
687    /// Parse a Claude Code transcript from an in-memory JSONL string.
688    pub fn from_claude_code_str(jsonl: &str) -> Result<Session> {
689        Self::from_claude_code_str_with_fidelity(jsonl, Fidelity::ByteLossless)
690    }
691
692    /// [`Session::from_claude_code_str`] at a declared [`Fidelity`] — see
693    /// [`Session::load_with_fidelity`] for what [`Fidelity::Semantic`] buys.
694    pub fn from_claude_code_str_with_fidelity(jsonl: &str, fidelity: Fidelity) -> Result<Session> {
695        let mut meta = SessionMeta::new(SessionSource::ClaudeCode);
696        let mut messages = Vec::new();
697        // IX-1: `raw` is captured STRICT-VERBATIM (blank lines, CRLF, trailing
698        // whitespace all preserved) — separate from the blank-skipping
699        // `non_empty_lines` walk just below, which still parses records only
700        // (a blank line is not a JSON record and must not become one).
701        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
702        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
703        // PARITY-15: a malformed/truncated line is still tolerated (a
704        // single bad line must not make an otherwise-healthy multi-
705        // thousand-line session unloadable) — but it's no longer INVISIBLE.
706        let mut parse_error_lines = 0usize;
707        let mut index = ClaudeReplayIndex::default();
708
709        // Claude transcripts are append-only trees, not linear chat logs.
710        // Build a lightweight graph index first so normalization sees the
711        // same single active, post-compaction branch Claude Code would
712        // resume. `raw` above deliberately remains the complete source.
713        for (line_index, line) in raw_lines.iter().enumerate() {
714            if line.trim().is_empty() {
715                continue;
716            }
717            let v: Value = match serde_json::from_str(line) {
718                Ok(v) => v,
719                Err(_) => {
720                    parse_error_lines += 1; // tolerate stray/corrupt lines
721                    continue;
722                }
723            };
724            capture_claude_meta(&v, &mut meta, line)?;
725            index.observe(line_index, &v)?;
726        }
727
728        let ClaudeReplaySelection {
729            lines: replay_lines,
730            residue: load_residue,
731        } = index.select_lines(fidelity)?;
732        let mut pending_assistant: Option<Value> = None;
733
734        for line_index in replay_lines {
735            let line = raw_lines[line_index];
736            let v: Value = serde_json::from_str(line).map_err(crate::Error::Decode)?;
737
738            if v.get("type").and_then(Value::as_str) == Some("assistant") {
739                if v.get("isApiErrorMessage").and_then(Value::as_bool) == Some(true) {
740                    flush_claude_assistant(&mut pending_assistant, &mut messages);
741                    continue;
742                }
743                if let Some(pending) = pending_assistant.as_mut() {
744                    if claude_assistant_message_id(pending).is_some_and(|message_id| {
745                        claude_assistant_message_id(&v) == Some(message_id)
746                    }) {
747                        merge_claude_assistant_chunk(pending, &v);
748                        continue;
749                    }
750                    flush_claude_assistant(&mut pending_assistant, &mut messages);
751                }
752                pending_assistant = Some(v);
753                continue;
754            }
755
756            flush_claude_assistant(&mut pending_assistant, &mut messages);
757
758            // WAVE-2 item 1: every Claude Code record carries a real
759            // top-level `timestamp` (ISO-8601) — provenance stamping below
760            // attaches it to every canonical `ChatMessage` this line
761            // produces, together with the record UUID and assistant model.
762            // `entry(...).or_insert_with` preserves any more-precise value a
763            // role-specific loader already supplied.
764            let before = messages.len();
765            match v.get("type").and_then(Value::as_str) {
766                Some("user") => push_claude_user(&v, &mut messages),
767                Some("assistant") => push_claude_assistant(&v, &mut messages),
768                Some("attachment") => push_claude_attachment(&v, &mut messages),
769                Some("system") => push_claude_system(&v, &mut messages),
770                _ => {} // mode, queue-operation, ... — skip
771            }
772            // UUID/model provenance remains meaningful even for legacy
773            // records that predate Claude Code's timestamp field.
774            capture_claude_record_provenance(&v, &mut messages[before..]);
775            restore_single_grok_message(&v, &mut messages[before..]);
776        }
777        flush_claude_assistant(&mut pending_assistant, &mut messages);
778
779        reorder_tool_results_after_calls(&mut messages);
780        ensure_tool_results_paired(&mut messages);
781        let imported_message_count = Some(messages.len());
782        Ok(Session {
783            meta,
784            messages,
785            subagents: Vec::new(),
786            raw,
787            raw_trailing_newline,
788            imported_message_count,
789            // Claude Code is line-oriented: `raw` is split directly out of
790            // the source text (strict-verbatim, IX-1).
791            raw_is_verbatim: true,
792            parse_error_lines,
793            load_residue,
794        })
795    }
796
797    /// Reconstruct multi-file subagent trees from a flat set of loaded sessions.
798    ///
799    /// Codex stores subagents as separate rollout files linked to their parent
800    /// by `lineage["parent_thread_id"]` (→ the parent's `session_id`). Given a
801    /// collection of sessions, this nests each child into its parent's
802    /// [`Session::subagents`] and returns only the roots. Children whose parent
803    /// isn't in the set are returned as roots themselves (best effort).
804    pub fn reconstruct_tree(sessions: Vec<Session>) -> Vec<Session> {
805        use std::collections::HashMap;
806        // Index each session's position by its session_id.
807        let mut idx: HashMap<String, usize> = HashMap::new();
808        for (i, s) in sessions.iter().enumerate() {
809            if let Some(id) = &s.meta.session_id {
810                idx.insert(id.clone(), i);
811            }
812        }
813        // Determine each session's parent (by index), if present in the set.
814        let parent_of: Vec<Option<usize>> = sessions
815            .iter()
816            .map(|s| {
817                s.meta
818                    .lineage
819                    .get("parent_thread_id")
820                    .and_then(|p| idx.get(p).copied())
821            })
822            .collect();
823
824        // Move children into parents, deepest-first so chains nest correctly.
825        let mut slots: Vec<Option<Session>> = sessions.into_iter().map(Some).collect();
826        let mut order: Vec<usize> = (0..slots.len()).collect();
827        order.sort_by_key(|&i| std::cmp::Reverse(depth_of(i, &parent_of)));
828        for i in order {
829            if let Some(p) = parent_of[i] {
830                if p != i {
831                    if let Some(child) = slots[i].take() {
832                        if let Some(parent) = slots[p].as_mut() {
833                            parent.subagents.push(child);
834                        } else {
835                            slots[i] = Some(child); // parent already moved; keep as root
836                        }
837                    }
838                }
839            }
840        }
841        slots.into_iter().flatten().collect()
842    }
843
844    /// Parse a session of a known format from an in-memory JSONL string.
845    pub fn load_str(jsonl: &str, format: SessionFormat) -> Result<Session> {
846        match format {
847            SessionFormat::ClaudeCode => Self::from_claude_code_str(jsonl),
848            SessionFormat::Codex => Self::from_codex_str(jsonl),
849            SessionFormat::Pi => Self::from_pi_str(jsonl),
850            SessionFormat::OpenCode => Self::from_opencode_str(jsonl),
851            SessionFormat::Grok => Self::from_grok_str(jsonl),
852            SessionFormat::Gemini => Self::from_gemini_str(jsonl),
853            SessionFormat::Goose => Self::from_goose_str(jsonl),
854        }
855    }
856
857    /// Serialize this session to JSONL in the given format.
858    ///
859    /// The conversation is synthesized from the canonical messages, so this
860    /// works for sessions loaded from *either* tool as well as ones supercode
861    /// built itself. Converting between formats (e.g. Codex → Claude Code) is an
862    /// "export": format-specific framing that has no slot in the target may be
863    /// dropped, but the user/assistant/tool conversation is preserved.
864    pub fn to_jsonl(&self, format: SessionFormat) -> Result<String> {
865        match format {
866            SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl()),
867            SessionFormat::Codex => Ok(self.to_codex_jsonl()),
868            SessionFormat::Pi => Ok(self.to_pi_jsonl()),
869            SessionFormat::OpenCode => self.to_opencode_jsonl(),
870            SessionFormat::Grok => Ok(self.to_grok_jsonl()),
871            SessionFormat::Gemini => Ok(self.to_gemini_jsonl()),
872            SessionFormat::Goose => Ok(self.to_goose_json()),
873        }
874    }
875
876    /// Export back to `format`, replaying the imported `raw` prefix
877    /// **verbatim** — original uuids/ids, real timestamps, and
878    /// loader-skipped records (e.g. Claude Code `file-history-snapshot`) that
879    /// [`Self::to_jsonl`]'s full synthesis discards or fakes — when `format`
880    /// is the session's own origin (`format.source() == self.meta.source`,
881    /// see [`SessionFormat::source`]) and there is a `raw` prefix to replay.
882    /// Only messages appended *after* import (tracked by
883    /// [`Self::imported_message_count`]) are synthesized, chained onto the
884    /// last original record found in the raw prefix.
885    ///
886    /// `session_id` of `Some(new)` rewrites the session id on every emitted
887    /// line, raw and synthesized alike (`sessionId` for Claude Code,
888    /// `session_meta.payload.id` for Codex); `None` leaves ids as recorded.
889    ///
890    /// Cross-format export (no verbatim prefix exists in the target dialect,
891    /// by definition) and a session with no `raw` lines both fall back
892    /// unchanged to [`Self::to_jsonl`] — full synthesis, same output as
893    /// today. A12 (SPEC.md §6): this turns "export back to origin" from
894    /// *semantic* to *near-byte* fidelity for the dominant hop-back case;
895    /// cross-format stays at the documented semantic tier.
896    pub fn to_jsonl_spliced(
897        &self,
898        format: SessionFormat,
899        session_id: Option<&str>,
900    ) -> Result<String> {
901        if self.parse_error_lines > 0
902            || self
903                .subagents
904                .iter()
905                .any(|subagent| subagent.parse_error_lines > 0)
906        {
907            return Err(Error::InvalidSession(
908                "refusing spliced export because the loaded session contains parse loss"
909                    .to_string(),
910            ));
911        }
912        if self.raw.is_empty() || format.source() != self.meta.source {
913            if let Some(session_id) = session_id {
914                let mut rewritten = self.clone();
915                rewritten.meta.session_id = Some(session_id.to_string());
916                return rewritten.to_jsonl(format);
917            }
918            return self.to_jsonl(format);
919        }
920        match format {
921            SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl_spliced(session_id)),
922            SessionFormat::Codex => Ok(self.to_codex_jsonl_spliced(session_id)),
923            SessionFormat::Pi => self.to_pi_jsonl_spliced(session_id),
924            SessionFormat::OpenCode => self.to_opencode_jsonl_spliced(session_id),
925            SessionFormat::Grok => Ok(self.to_grok_jsonl_spliced()),
926            SessionFormat::Gemini => Ok(self.to_gemini_jsonl_spliced(session_id)),
927            SessionFormat::Goose => Ok(self.to_goose_json_spliced(session_id)),
928        }
929    }
930
931    /// Write this session to `path` in the given format.
932    pub fn save(&self, path: impl AsRef<Path>, format: SessionFormat) -> Result<()> {
933        std::fs::write(path.as_ref(), self.to_jsonl(format)?)?;
934        Ok(())
935    }
936
937    /// Reconstruct the exact source bytes this `Session` was loaded from,
938    /// out of [`Self::raw`] + [`Self::raw_trailing_newline`] (the exact
939    /// inverse of the strict-verbatim capture those two fields record — see
940    /// `join_lines_verbatim`).
941    ///
942    /// For a genuinely line-oriented source (Claude Code, Codex, Pi, and an
943    /// OpenCode *envelope*-form JSONL), `raw` is captured verbatim from the
944    /// original text, so this reproduces the original file byte-for-byte —
945    /// the P008/P009 diagonal-convert fix (`convert <file> --to
946    /// <same-format>` is byte-identical to `<file>`) is built on exactly
947    /// this. The one documented exception is an OpenCode **export-document**
948    /// source (a single pretty-printed JSON value, not JSONL): `raw` there
949    /// is RE-SYNTHESIZED as one envelope line per record (see
950    /// `from_opencode_export_doc`'s contract), so this returns a
951    /// verbatim reproduction of THAT captured representation rather than the
952    /// original pretty-printed document — a known, narrow residue, not a
953    /// silent loss (the same records are all still present).
954    pub fn raw_verbatim(&self) -> String {
955        join_lines_verbatim(&self.raw, self.raw_trailing_newline)
956    }
957
958    /// Serialize to the **supercode-native** lossless format: a header line
959    /// recording the original source, followed by every original JSONL line
960    /// verbatim. Unlike [`Self::to_jsonl`] (which targets a foreign tool's
961    /// schema and is necessarily lossy), this preserves *everything* — including
962    /// records with no canonical representation — so [`Self::from_native_str`]
963    /// reconstructs the session with full fidelity.
964    pub fn to_native_jsonl(&self) -> String {
965        let source = match self.meta.source {
966            SessionSource::ClaudeCode => "claude_code",
967            SessionSource::Codex => "codex",
968            SessionSource::Pi => "pi",
969            SessionSource::OpenCode => "opencode",
970            SessionSource::Grok => "grok",
971            SessionSource::Gemini => "gemini",
972            SessionSource::Goose => "goose",
973            // P5-3 safety-hardening fix: a natively-spawned session must
974            // never be written to disk labeled as an imported CC session.
975            SessionSource::Native => "native",
976        };
977        let header = serde_json::json!({
978            "supercode_native": 1,
979            "source": source,
980            // IX-1: carries whether the ORIGINAL imported source text ended
981            // with a trailing newline — `from_native_str` needs this to
982            // reconstruct the exact source bytes (not just the `raw` line
983            // list) when re-parsing the body with the per-source loader.
984            "raw_trailing_newline": self.raw_trailing_newline,
985        })
986        .to_string();
987        let mut out =
988            String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
989        out.push_str(&header);
990        out.push('\n');
991        for line in &self.raw {
992            out.push_str(line);
993            out.push('\n');
994        }
995        out
996    }
997
998    /// Serialize to the **supercode-native v2** format: the same imported-body
999    /// mechanism as [`Self::to_native_jsonl`] (a versioned header line
1000    /// followed by every `Session.raw` line verbatim), plus one
1001    /// [`crate::sidecar::NativeTurn`] record per message in `appended` — turns
1002    /// produced after import, which have no backing `raw` line of their own.
1003    /// `NativeTurn` carries `metadata` in full (unlike `ChatMessage`'s wire
1004    /// serde), so nothing the live agent loop records is lost to disk.
1005    ///
1006    /// `appended` is caller-supplied rather than inferred from
1007    /// `self.messages`: A1 doesn't track which of `self.messages` came from
1008    /// import vs. the live loop — that bookkeeping belongs to the live writer
1009    /// built on top of this (A2/A3).
1010    pub fn to_native_jsonl_v2(&self, appended: &[ChatMessage]) -> String {
1011        self.to_native_jsonl_v2_with_timestamp(appended, None)
1012    }
1013
1014    pub(crate) fn to_native_jsonl_v2_with_timestamp(
1015        &self,
1016        appended: &[ChatMessage],
1017        fixed_timestamp: Option<&str>,
1018    ) -> String {
1019        let source = match self.meta.source {
1020            SessionSource::ClaudeCode => "claude_code",
1021            SessionSource::Codex => "codex",
1022            SessionSource::Pi => "pi",
1023            SessionSource::OpenCode => "opencode",
1024            SessionSource::Grok => "grok",
1025            SessionSource::Gemini => "gemini",
1026            SessionSource::Goose => "goose",
1027            // P5-3 safety-hardening fix: a natively-spawned session must
1028            // never be written to disk labeled as an imported CC session.
1029            SessionSource::Native => "native",
1030        };
1031        // P5-3 (§2 module 9, §5.2 P5 row 3 "native write side — store
1032        // already parses CC sidechains + CX lineage on import"): a
1033        // natively-spawned subagent's own `Session` carries its lineage on
1034        // `SessionMeta` (`agent_id`/`parent_tool_use_id`/`lineage`) — before
1035        // this, `to_native_jsonl_v2` never wrote any of the three to disk at
1036        // all, so a native-spawned child's lineage was lost the instant it
1037        // round-tripped through a sidecar. Emitted only when non-empty/`Some`
1038        // (`skip_serializing_if`-equivalent via manual omission below) so a
1039        // plain top-level session's header is byte-identical to before this
1040        // change.
1041        let mut header_obj = serde_json::json!({
1042            "supercode_native": 2,
1043            "source": source,
1044            "session_id": self.meta.session_id,
1045            "created": fixed_timestamp
1046                .map(ToOwned::to_owned)
1047                .unwrap_or_else(crate::sidecar::now_rfc3339),
1048            // IX-1: see `to_native_jsonl`'s header field of the same name.
1049            "raw_trailing_newline": self.raw_trailing_newline,
1050        });
1051        if let Some(obj) = header_obj.as_object_mut() {
1052            if let Some(agent_id) = &self.meta.agent_id {
1053                obj.insert("agent_id".to_string(), Value::String(agent_id.clone()));
1054            }
1055            if let Some(parent_tool_use_id) = &self.meta.parent_tool_use_id {
1056                obj.insert(
1057                    "parent_tool_use_id".to_string(),
1058                    Value::String(parent_tool_use_id.clone()),
1059                );
1060            }
1061            if !self.meta.lineage.is_empty() {
1062                obj.insert(
1063                    "lineage".to_string(),
1064                    serde_json::to_value(&self.meta.lineage).unwrap_or(Value::Null),
1065                );
1066            }
1067        }
1068        let header = header_obj.to_string();
1069        let mut out =
1070            String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
1071        out.push_str(&header);
1072        out.push('\n');
1073        for line in &self.raw {
1074            out.push_str(line);
1075            out.push('\n');
1076        }
1077        for (turn_index, msg) in appended.iter().enumerate() {
1078            let turn = match fixed_timestamp {
1079                Some(timestamp) => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
1080                    msg,
1081                    timestamp.to_string(),
1082                    turn_index as u64,
1083                ),
1084                None => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
1085                    msg,
1086                    crate::sidecar::now_rfc3339(),
1087                    turn_index as u64,
1088                ),
1089            };
1090            out.push_str(&serde_json::to_string(&turn).unwrap_or_default());
1091            out.push('\n');
1092        }
1093        out
1094    }
1095
1096    /// Parse the supercode-native format produced by [`Self::to_native_jsonl`]
1097    /// / [`Self::to_native_jsonl_v2`]. A v1 file (no appended turns) parses
1098    /// exactly as before. A v2 file's appended `NativeTurn` records —
1099    /// discriminated by the `supercode_turn` key, which never appears in a v1
1100    /// body — are split out before the imported body is handed to the
1101    /// per-source loader, then reattached in file order: to `messages` (via
1102    /// [`crate::sidecar::NativeTurn::into_message`]) and to `raw` (verbatim),
1103    /// so a v2 file round-trips byte-for-byte through
1104    /// [`Self::to_native_jsonl_v2`] again.
1105    pub fn from_native_str(jsonl: &str) -> Result<Session> {
1106        // IX-1: the native WRAPPER's own lines are split verbatim (not via
1107        // the blank-skipping `non_empty_lines`) so that any `raw` line it
1108        // carries — which can itself be blank, CRLF-terminated, or
1109        // whitespace-padded, now that raw-capture is strict-verbatim —
1110        // survives being embedded in (and re-extracted from) this wrapper
1111        // bit-for-bit. The wrapper we ourselves emit never has a blank line
1112        // of its own (`to_native_jsonl(_v2)` always writes one well-formed
1113        // record per line), so this is a behavior-preserving switch for any
1114        // native text this crate produced; it also makes a hand-fed/legacy
1115        // native string tolerated exactly as `non_empty_lines` used to.
1116        let (all_lines, _wrapper_trailing_newline) = split_lines_verbatim(jsonl);
1117        let mut lines = all_lines.into_iter();
1118        let header = lines.next().unwrap_or("");
1119        let hv: Value = serde_json::from_str(header).unwrap_or(Value::Null);
1120        let source = hv.get("source").and_then(Value::as_str);
1121        // IX-1: whether the ORIGINAL imported source (before it was wrapped
1122        // in this native format) ended with a trailing newline — a property
1123        // of the pre-wrap source, not of this wrapper (which always
1124        // LF-terminates every line it writes, regardless). Missing on a
1125        // native file written before IX-1 (or a hand-built header in an
1126        // older test/sidecar) — default `true`, the historical
1127        // always-newline-terminated assumption.
1128        let raw_trailing_newline = hv
1129            .get("raw_trailing_newline")
1130            .and_then(Value::as_bool)
1131            .unwrap_or(true);
1132
1133        // Split appended NativeTurn records (v2) out of the imported body. A
1134        // v1 body never carries a `supercode_turn` key, so this is a no-op
1135        // there — one code path serves both versions.
1136        let mut body_lines: Vec<String> = Vec::new();
1137        let mut turn_lines: Vec<&str> = Vec::new();
1138        for line in lines {
1139            let is_turn = serde_json::from_str::<Value>(line)
1140                .ok()
1141                .is_some_and(|v| v.get("supercode_turn").is_some());
1142            if is_turn {
1143                turn_lines.push(line);
1144            } else {
1145                body_lines.push(line.to_string());
1146            }
1147        }
1148        // IX-1: reconstruct the ORIGINAL imported source bytes exactly —
1149        // `body_lines.join("\n")` alone would silently gain a trailing
1150        // newline the original source never had (or lose one it did have).
1151        let body = join_lines_verbatim(&body_lines, raw_trailing_newline);
1152
1153        // The remaining lines are the original log; re-parse with the right loader.
1154        let mut session = match source {
1155            Some("codex") => Self::from_codex_str(&body)?,
1156            Some("claude_code") => Self::from_claude_code_str(&body)?,
1157            Some("pi") => Self::from_pi_str(&body)?,
1158            Some("opencode") => Self::from_opencode_str(&body)?,
1159            Some("grok") => Self::from_grok_str(&body)?,
1160            Some("gemini") => Self::from_gemini_str(&body)?,
1161            Some("goose") => Self::from_goose_str(&body)?,
1162            // P5-3 safety-hardening fix: a natively-spawned session's body
1163            // is always empty (it never had any foreign-tool prefix to
1164            // begin with — see `SessionSource::Native`'s doc comment), so
1165            // any loader would parse it identically; `from_claude_code_str`
1166            // is reused purely as a blank-skeleton builder (empty
1167            // `raw`/`messages`), then its `meta.source` is corrected to
1168            // `Native` — never left mislabeled as `ClaudeCode`.
1169            Some("native") => {
1170                let mut s = Self::from_claude_code_str(&body)?;
1171                s.meta.source = SessionSource::Native;
1172                s
1173            }
1174            // No/unknown header — auto-detect the body.
1175            _ => match detect_source(&body) {
1176                Some(SessionSource::Codex) => Self::from_codex_str(&body)?,
1177                Some(SessionSource::Pi) => Self::from_pi_str(&body)?,
1178                Some(SessionSource::OpenCode) => Self::from_opencode_str(&body)?,
1179                Some(SessionSource::Grok) => Self::from_grok_str(&body)?,
1180                Some(SessionSource::Gemini) => Self::from_gemini_str(&body)?,
1181                Some(SessionSource::Goose) => Self::from_goose_str(&body)?,
1182                _ => Self::from_claude_code_str(&body)?,
1183            },
1184        };
1185
1186        for line in turn_lines {
1187            match serde_json::from_str::<crate::sidecar::NativeTurn>(line) {
1188                Ok(turn) => {
1189                    session.raw.push(line.to_string());
1190                    session.messages.push(turn.into_message());
1191                }
1192                Err(_) => {
1193                    // A valid JSON object carrying the native-turn
1194                    // discriminator belongs to this wrapper, not to the
1195                    // imported body. If its required fields are malformed,
1196                    // count it as parse loss so every fail-loud caller can
1197                    // refuse continuation instead of silently dropping a
1198                    // native history record. Keep the rejected source line
1199                    // in `raw` as well: diagnostics must count it in their
1200                    // denominator, and even corrupt input must not disappear
1201                    // merely because it reached the parser.
1202                    session.raw.push(line.to_string());
1203                    session.parse_error_lines += 1;
1204                }
1205            }
1206        }
1207
1208        // P5-3 (native write side, see `Self::to_native_jsonl_v2`'s matching
1209        // header block): recover a natively-spawned subagent's own lineage
1210        // from the v2 header, when present. Overlays (rather than merges
1211        // into) whatever the per-source body loader may have already set on
1212        // `session.meta` — these three keys are ONLY ever written by
1213        // `to_native_jsonl_v2` itself (never by a CC/CX/OC/Pi body), so a
1214        // header that carries them is authoritative for a file this crate
1215        // produced.
1216        if let Some(agent_id) = hv.get("agent_id").and_then(Value::as_str) {
1217            session.meta.agent_id = Some(agent_id.to_string());
1218        }
1219        if let Some(parent_tool_use_id) = hv.get("parent_tool_use_id").and_then(Value::as_str) {
1220            session.meta.parent_tool_use_id = Some(parent_tool_use_id.to_string());
1221        }
1222        if let Some(lineage) = hv.get("lineage").and_then(Value::as_object) {
1223            for (k, v) in lineage {
1224                if let Some(s) = v.as_str() {
1225                    session.meta.lineage.insert(k.clone(), s.to_string());
1226                }
1227            }
1228        }
1229
1230        Ok(session)
1231    }
1232
1233    /// The full-fidelity [`Session`] a sidecar denotes.
1234    ///
1235    /// The sidecar (native-v2 format, D1) is the imported body plus every
1236    /// appended [`crate::sidecar::NativeTurn`]. Unlike the deliberately
1237    /// tolerant lower-level native parser, this persisted-store entry point
1238    /// validates its framing header before loading anything: a missing,
1239    /// malformed, or unsupported header must never become a zero-message
1240    /// session that callers could continue as if it were complete.
1241    pub fn from_sidecar_str(s: &str) -> Result<Session> {
1242        let header = s.lines().next().ok_or_else(|| {
1243            Error::InvalidSession("sidecar header is missing from an empty artifact".to_string())
1244        })?;
1245        let value: Value = serde_json::from_str(header).map_err(|error| {
1246            Error::InvalidSession(format!("sidecar header is not valid JSON: {error}"))
1247        })?;
1248        let version = value.get("supercode_native").and_then(Value::as_u64);
1249        if !matches!(version, Some(1 | 2)) {
1250            return Err(Error::InvalidSession(
1251                "sidecar header must declare supported `supercode_native` version 1 or 2"
1252                    .to_string(),
1253            ));
1254        }
1255        let source = value.get("source").and_then(Value::as_str);
1256        if !matches!(
1257            source,
1258            Some(
1259                "native"
1260                    | "claude_code"
1261                    | "codex"
1262                    | "gemini"
1263                    | "goose"
1264                    | "opencode"
1265                    | "pi"
1266                    | "grok"
1267            )
1268        ) {
1269            return Err(Error::InvalidSession(
1270                "sidecar header must declare a supported `source`".to_string(),
1271            ));
1272        }
1273        Self::from_native_str(s)
1274    }
1275
1276    /// Parse a Codex rollout from an in-memory JSONL string.
1277    pub fn from_codex_str(jsonl: &str) -> Result<Session> {
1278        let mut meta = SessionMeta::new(SessionSource::Codex);
1279        let mut messages = Vec::new();
1280
1281        // First pass: collect the text of every assistant message that exists as
1282        // a canonical `response_item`. In normal sessions the streamed
1283        // `event_msg/agent_message` events duplicate these and are safely
1284        // skipped; in collab/multi-agent sessions the assistant narration lives
1285        // ONLY as `agent_message` events, so we recover the ones with no
1286        // response_item counterpart (deduping by exact text).
1287        let assistant_texts = collect_codex_assistant_texts(jsonl);
1288        // IX-1: strict-verbatim raw capture — see `from_claude_code_str`.
1289        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
1290        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
1291        let mut pending_reasoning = String::new();
1292        let mut pending_reasoning_content = String::new();
1293        let mut pending_reasoning_encrypted = false;
1294        // PARITY-15: see `from_claude_code_str`'s identical counter.
1295        let mut parse_error_lines = 0usize;
1296        let mut restored_embedded_codex_provenance = false;
1297
1298        for (record_index, raw_line) in raw_lines.iter().enumerate() {
1299            let line = raw_line.trim();
1300            if line.is_empty() {
1301                continue;
1302            }
1303            let v: Value = match serde_json::from_str(line) {
1304                Ok(v) => v,
1305                Err(_) => {
1306                    parse_error_lines += 1;
1307                    continue;
1308                }
1309            };
1310            let payload = v.get("payload").unwrap_or(&Value::Null);
1311            if !restored_embedded_codex_provenance
1312                && v.get("type").and_then(Value::as_str) == Some("session_meta")
1313                && payload
1314                    .get(SUPERCODE_CODEX_PROVENANCE_KEY)
1315                    .map(|extension| restore_codex_provenance(extension, &mut meta))
1316                    .transpose()?
1317                    .unwrap_or(false)
1318            {
1319                restored_embedded_codex_provenance = true;
1320            }
1321            if !restored_embedded_codex_provenance {
1322                capture_codex_provenance_record(&mut meta, record_index, raw_line, &v);
1323            }
1324            // WAVE-2 item 1: every Codex record carries a real top-level
1325            // `timestamp` (ISO-8601) — threaded onto each `ChatMessage` a
1326            // line produces via `stamp_new_codex_messages` below, at each
1327            // arm that pushes messages.
1328            let line_ts = v.get("timestamp").and_then(Value::as_str);
1329
1330            match v.get("type").and_then(Value::as_str) {
1331                Some("session_meta") => {
1332                    capture_codex_session_meta(payload, &mut meta);
1333                    if !restored_embedded_codex_provenance {
1334                        meta.codex_headers.push(v.clone());
1335                    }
1336                }
1337                Some("turn_context") => {
1338                    if meta.model.is_none() {
1339                        meta.model = payload
1340                            .get("model")
1341                            .and_then(Value::as_str)
1342                            .map(str::to_string);
1343                    }
1344                    if !restored_embedded_codex_provenance {
1345                        meta.codex_headers.push(v.clone());
1346                    }
1347                }
1348                Some("response_item")
1349                    if payload.get("type").and_then(Value::as_str) == Some("reasoning") =>
1350                {
1351                    // Retain reasoning (P3): summary text if any, the raw
1352                    // `content` chain-of-thought text if any (N2 — this used
1353                    // to be dropped despite `Coverage::Retained` claiming the
1354                    // whole item survived; see `crate::audit`'s doc comment),
1355                    // plus a flag for the opaque encrypted_content a
1356                    // same-model continuation can replay. Stashed onto the
1357                    // next assistant message below.
1358                    let summary = extract_text_content(payload.get("summary"));
1359                    if !summary.trim().is_empty() {
1360                        push_str_field(&mut pending_reasoning, &summary);
1361                    }
1362                    // N2: `content` is `null` on the vast majority of real
1363                    // turns (raw reasoning text is only ever populated for
1364                    // certain reasoning-transcript configurations) — guard
1365                    // on non-null BEFORE calling `extract_text_content`,
1366                    // since `Some(&Value::Null)` would otherwise fall into
1367                    // its `Some(other) => other.to_string()` arm and
1368                    // stringify to the literal text `"null"`.
1369                    if let Some(raw_content) = payload.get("content").filter(|v| !v.is_null()) {
1370                        let text = extract_text_content(Some(raw_content));
1371                        if !text.trim().is_empty() {
1372                            push_str_field(&mut pending_reasoning_content, &text);
1373                        }
1374                    }
1375                    // N1: `serde_json` returns `Some(&Value::Null)` for a
1376                    // present-but-null `encrypted_content` key — which is
1377                    // what EVERY real rollout's reasoning item carries
1378                    // (upstream always serializes the field, never
1379                    // `skip_serializing_if`, `codex-rs/protocol/src/
1380                    // models.rs:970-983`). The old `.is_some()` check
1381                    // false-flagged every single reasoning item as
1382                    // "encrypted" on real data; only a genuinely non-null
1383                    // value means the model actually returned an opaque
1384                    // blob that a same-model continuation could replay.
1385                    if payload
1386                        .get("encrypted_content")
1387                        .is_some_and(|v| !v.is_null())
1388                    {
1389                        pending_reasoning_encrypted = true;
1390                    }
1391                }
1392                Some("response_item") => {
1393                    let before = messages.len();
1394                    push_codex_item(payload, &mut messages);
1395                    // Attach any pending reasoning to a newly produced assistant turn.
1396                    if messages.len() > before
1397                        && (!pending_reasoning.is_empty()
1398                            || !pending_reasoning_content.is_empty()
1399                            || pending_reasoning_encrypted)
1400                    {
1401                        let is_assistant = messages
1402                            .last()
1403                            .map(|m| m.role == Role::Assistant)
1404                            .unwrap_or(false);
1405                        if is_assistant {
1406                            let last = messages.last_mut().expect("checked above");
1407                            if !pending_reasoning.is_empty() {
1408                                last.metadata.insert(
1409                                    "reasoning".to_string(),
1410                                    std::mem::take(&mut pending_reasoning),
1411                                );
1412                            }
1413                            if !pending_reasoning_content.is_empty() {
1414                                last.metadata.insert(
1415                                    "reasoning_content".to_string(),
1416                                    std::mem::take(&mut pending_reasoning_content),
1417                                );
1418                            }
1419                            if pending_reasoning_encrypted {
1420                                last.metadata
1421                                    .insert("reasoning_encrypted".to_string(), "true".to_string());
1422                                pending_reasoning_encrypted = false;
1423                            }
1424                        } else {
1425                            // N3: the item that just landed is NOT the
1426                            // assistant turn the pending reasoning was for
1427                            // (e.g. an aborted turn's reasoning directly
1428                            // followed by a user message) — the old code
1429                            // unconditionally cleared the pending state
1430                            // here, silently discarding it. Flush it as its
1431                            // own message instead, inserted just before the
1432                            // interrupting item so replay order stays
1433                            // chronological, keeping `Coverage::Retained`
1434                            // honest for this shape too.
1435                            let orphan = orphaned_reasoning_message(
1436                                &mut pending_reasoning,
1437                                &mut pending_reasoning_content,
1438                                &mut pending_reasoning_encrypted,
1439                            );
1440                            messages.insert(before, orphan);
1441                        }
1442                    }
1443                    stamp_new_codex_messages(&mut messages, before, line_ts);
1444                    restore_single_grok_message(payload, &mut messages[before..]);
1445                }
1446                // A compaction record replaces all prior turns with its
1447                // summarized `replacement_history` — exactly how Codex itself
1448                // resumes a compacted session.
1449                Some("compacted") => {
1450                    messages.clear();
1451                    if let Some(Value::Array(history)) = payload.get("replacement_history") {
1452                        for item in history {
1453                            push_codex_item(item, &mut messages);
1454                        }
1455                    }
1456                    // `replacement_history` items carry no per-item
1457                    // timestamp of their own (observed corpora) — the
1458                    // `compacted` record's own timestamp (when it happened)
1459                    // is the best-effort real source for every message it
1460                    // synthesizes, so it stamps the whole rebuilt vec (index
1461                    // 0, since `clear()` reset it above).
1462                    stamp_new_codex_messages(&mut messages, 0, line_ts);
1463                    // IX-6 fix: replaying `replacement_history` through
1464                    // `push_codex_item` can leave the LAST replayed message
1465                    // marked `__codex_open_turn` (if it's an assistant
1466                    // `message`, per the combined-turn merge below). That
1467                    // marker must not survive past the compaction boundary —
1468                    // a live `function_call` arriving after this record is a
1469                    // NEW turn, not a continuation of the compaction
1470                    // summary's synthetic turn, so it must not merge into it.
1471                    if let Some(last) = messages.last_mut() {
1472                        last.metadata.remove("__codex_open_turn");
1473                    }
1474                }
1475                Some("event_msg")
1476                    if payload.get("type").and_then(Value::as_str) == Some("agent_message") =>
1477                {
1478                    let before = messages.len();
1479                    let text = agent_message_text(payload);
1480                    if !text.trim().is_empty() && !assistant_texts.contains(text.trim()) {
1481                        push_assistant(&mut messages, text, Vec::new());
1482                        if let Some(last) = messages.last_mut() {
1483                            if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
1484                                last.metadata.insert("phase".to_string(), phase.to_string());
1485                            }
1486                        }
1487                    }
1488                    stamp_new_codex_messages(&mut messages, before, line_ts);
1489                }
1490                // The user rolled back (undid) the last N turns — replay must
1491                // drop them so the reloaded conversation matches what the user
1492                // actually kept.
1493                Some("event_msg")
1494                    if payload.get("type").and_then(Value::as_str)
1495                        == Some("thread_rolled_back") =>
1496                {
1497                    let n = payload
1498                        .get("num_turns")
1499                        .and_then(Value::as_u64)
1500                        .unwrap_or(1);
1501                    for _ in 0..n {
1502                        remove_last_turn(&mut messages);
1503                    }
1504                }
1505                // The natural-language goal assigned to this thread (sometimes
1506                // the only place the objective text is recorded).
1507                Some("event_msg")
1508                    if payload.get("type").and_then(Value::as_str)
1509                        == Some("thread_goal_updated") =>
1510                {
1511                    let before = messages.len();
1512                    let goal = payload.get("goal");
1513                    if let Some(obj) = goal
1514                        .and_then(|g| g.get("objective"))
1515                        .and_then(Value::as_str)
1516                    {
1517                        if !obj.trim().is_empty() {
1518                            messages.push(ChatMessage::system(format!("[thread goal] {obj}")));
1519                            // D4: `goal.objective` alone used to be the ONLY
1520                            // captured field, but the audit labeled this
1521                            // `Retained` as if the whole record survived.
1522                            // `goal.status`/`goal.tokenBudget` (real
1523                            // `ThreadGoal` wire fields, camelCase) are
1524                            // captured too so that label is honest — see
1525                            // `crate::audit::event_msg_coverage`'s doc
1526                            // comment.
1527                            if let Some(last) = messages.last_mut() {
1528                                if let Some(status) =
1529                                    goal.and_then(|g| g.get("status")).and_then(Value::as_str)
1530                                {
1531                                    last.metadata
1532                                        .insert("goal_status".to_string(), status.to_string());
1533                                }
1534                                if let Some(budget) = goal
1535                                    .and_then(|g| g.get("tokenBudget"))
1536                                    .and_then(Value::as_i64)
1537                                {
1538                                    last.metadata.insert(
1539                                        "goal_token_budget".to_string(),
1540                                        budget.to_string(),
1541                                    );
1542                                }
1543                            }
1544                        }
1545                    }
1546                    stamp_new_codex_messages(&mut messages, before, line_ts);
1547                }
1548                // Code-review output — unique assistant-generated content with no
1549                // `message` counterpart.
1550                Some("event_msg")
1551                    if payload.get("type").and_then(Value::as_str)
1552                        == Some("exited_review_mode") =>
1553                {
1554                    let before = messages.len();
1555                    if let Some(review) = payload.get("review_output") {
1556                        let text = review
1557                            .get("overall_explanation")
1558                            .and_then(Value::as_str)
1559                            .map(str::to_string)
1560                            .unwrap_or_else(|| review.to_string());
1561                        push_assistant(&mut messages, format!("[code review] {text}"), Vec::new());
1562                        // D4: `overall_explanation` alone used to be the ONLY
1563                        // captured field, but the audit labeled this
1564                        // `Retained` as if `review_output.findings` survived
1565                        // too. Capture `findings` verbatim (as JSON, onto
1566                        // metadata) so that label is honest — this is the
1567                        // only place review-mode findings (title/body/
1568                        // confidence_score/priority/code_location) live.
1569                        if let Some(findings) = review.get("findings") {
1570                            if findings.as_array().is_some_and(|a| !a.is_empty()) {
1571                                if let Some(last) = messages.last_mut() {
1572                                    if let Ok(s) = serde_json::to_string(findings) {
1573                                        last.metadata.insert("review_findings".to_string(), s);
1574                                    }
1575                                }
1576                            }
1577                        }
1578                        // N4: `overall_correctness`/`overall_confidence_score`
1579                        // are the review's actual verdict — distinct from the
1580                        // findings list and the explanation prose already
1581                        // captured above — and were neither captured nor
1582                        // disclosed as residue while the audit doc stayed
1583                        // silent about them. Capture both onto the same
1584                        // message's metadata, same pattern as `findings`.
1585                        if let Some(last) = messages.last_mut() {
1586                            if let Some(correctness) =
1587                                review.get("overall_correctness").and_then(Value::as_str)
1588                            {
1589                                last.metadata.insert(
1590                                    "review_overall_correctness".to_string(),
1591                                    correctness.to_string(),
1592                                );
1593                            }
1594                            if let Some(score) = review
1595                                .get("overall_confidence_score")
1596                                .and_then(Value::as_f64)
1597                            {
1598                                last.metadata.insert(
1599                                    "review_overall_confidence_score".to_string(),
1600                                    score.to_string(),
1601                                );
1602                            }
1603                        }
1604                    }
1605                    stamp_new_codex_messages(&mut messages, before, line_ts);
1606                }
1607                _ => {} // other event_msg, token_count, ... — UI events, skip
1608            }
1609        }
1610
1611        // N3/PARITY-11: reasoning still pending at EOF is an aborted-turn
1612        // shape a real rollout can leave behind (the process was
1613        // interrupted mid-turn, after the model reasoned but before it
1614        // replied — end of file, or a rollback/compaction boundary that
1615        // clears the pending state some other way) — the old code silently
1616        // dropped it here (nothing ever consumed the pending buffers once
1617        // the loop ended). Flush it as its own trailing message instead, so
1618        // `Coverage::Retained` holds for this shape too. Superset of the
1619        // independently-discovered PARITY-11 fix: also folds in
1620        // `pending_reasoning_content` (the raw chain-of-thought, distinct
1621        // from `summary`/`reasoning`) via the shared `orphaned_reasoning_
1622        // message` helper, which the interrupted-by-a-user-message shape
1623        // (`orphaned_reasoning_is_flushed_not_discarded`, case A) also
1624        // relies on — a trailing-EOF-only flush here would miss that case.
1625        if !pending_reasoning.is_empty()
1626            || !pending_reasoning_content.is_empty()
1627            || pending_reasoning_encrypted
1628        {
1629            let orphan = orphaned_reasoning_message(
1630                &mut pending_reasoning,
1631                &mut pending_reasoning_content,
1632                &mut pending_reasoning_encrypted,
1633            );
1634            messages.push(orphan);
1635        }
1636
1637        ensure_tool_results_paired(&mut messages);
1638        // IX-6: `__codex_open_turn` is an internal bookkeeping marker for the
1639        // combined-turn merge above — strip it so it never leaks out as
1640        // visible `ChatMessage` metadata.
1641        for m in &mut messages {
1642            m.metadata.remove("__codex_open_turn");
1643            if m.metadata
1644                .remove("__grok_remove_synthetic_turn_id")
1645                .is_some()
1646            {
1647                m.metadata.remove("turn_id");
1648            }
1649        }
1650        let imported_message_count = Some(messages.len());
1651        Ok(Session {
1652            meta,
1653            messages,
1654            subagents: Vec::new(),
1655            raw,
1656            raw_trailing_newline,
1657            imported_message_count,
1658            // Codex is line-oriented: `raw` is split directly out of the
1659            // source text (strict-verbatim, IX-1).
1660            raw_is_verbatim: true,
1661            parse_error_lines,
1662            load_residue: Vec::new(),
1663        })
1664    }
1665
1666    /// Parse a Codex rollout as bounded human-visible history rather than as
1667    /// resumable model context. This deliberately ignores outer `compacted`
1668    /// replacement semantics: the original `response_item` records remain in
1669    /// the rollout and are the authoritative UI history.
1670    fn from_codex_display_str(jsonl: &str, message_limit: usize) -> Result<Session> {
1671        let mut meta = SessionMeta::new(SessionSource::Codex);
1672        let mut messages: Vec<ChatMessage> = Vec::new();
1673        let mut preceding_users = Vec::new();
1674        let mut parse_error_lines = 0usize;
1675        let mut record_count = 0usize;
1676        let mut total_message_count = 0usize;
1677        let retain = message_limit.max(1).saturating_add(64);
1678        let mut canonical_assistant_texts = HashSet::new();
1679
1680        for raw_line in non_empty_lines(jsonl) {
1681            record_count += 1;
1682            let value: Value = match serde_json::from_str(raw_line) {
1683                Ok(value) => value,
1684                Err(_) => {
1685                    parse_error_lines += 1;
1686                    continue;
1687                }
1688            };
1689            let payload = value.get("payload").unwrap_or(&Value::Null);
1690            let line_ts = value.get("timestamp").and_then(Value::as_str);
1691            match value.get("type").and_then(Value::as_str) {
1692                Some("session_meta") => capture_codex_session_meta(payload, &mut meta),
1693                Some("turn_context") if meta.model.is_none() => {
1694                    meta.model = payload
1695                        .get("model")
1696                        .and_then(Value::as_str)
1697                        .map(str::to_string);
1698                }
1699                Some("response_item")
1700                    if payload.get("type").and_then(Value::as_str) != Some("reasoning") =>
1701                {
1702                    let assistant_text = (payload.get("type").and_then(Value::as_str)
1703                        == Some("message")
1704                        && payload.get("role").and_then(Value::as_str) == Some("assistant"))
1705                    .then(|| extract_text_content(payload.get("content")))
1706                    .filter(|text| !text.trim().is_empty());
1707                    if let Some(text) = assistant_text.as_deref() {
1708                        if let Some(index) = messages.iter().rposition(|message| {
1709                            message.metadata.contains_key("codex_event_message")
1710                                && message.content.as_deref() == Some(text)
1711                        }) {
1712                            messages.remove(index);
1713                            total_message_count = total_message_count.saturating_sub(1);
1714                        }
1715                        canonical_assistant_texts.insert(text.trim().to_string());
1716                    }
1717                    let before = messages.len();
1718                    push_codex_item(payload, &mut messages);
1719                    total_message_count += messages.len().saturating_sub(before);
1720                    stamp_new_codex_messages(&mut messages, before, line_ts);
1721                    restore_single_grok_message(payload, &mut messages[before..]);
1722                }
1723                Some("event_msg")
1724                    if payload.get("type").and_then(Value::as_str) == Some("agent_message") =>
1725                {
1726                    let text = agent_message_text(payload);
1727                    if !text.trim().is_empty() && !canonical_assistant_texts.contains(text.trim()) {
1728                        let before = messages.len();
1729                        push_assistant(&mut messages, text, Vec::new());
1730                        total_message_count += 1;
1731                        if let Some(last) = messages.last_mut() {
1732                            last.metadata
1733                                .insert("codex_event_message".to_string(), "true".to_string());
1734                            if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
1735                                last.metadata.insert("phase".to_string(), phase.to_string());
1736                            }
1737                        }
1738                        stamp_new_codex_messages(&mut messages, before, line_ts);
1739                    }
1740                }
1741                // `compacted` changes continuation context, not what was
1742                // already visible in scrollback. Other event records are UI
1743                // lifecycle noise or duplicate canonical response items.
1744                _ => {}
1745            }
1746            if messages.len() > retain {
1747                let remove = messages.len() - retain;
1748                for message in messages.drain(..remove) {
1749                    if message.role == Role::User {
1750                        preceding_users.push(message);
1751                        if preceding_users.len() > 2 {
1752                            preceding_users.remove(0);
1753                        }
1754                    }
1755                }
1756            }
1757        }
1758
1759        for message in &mut messages {
1760            message.metadata.remove("__codex_open_turn");
1761            message.metadata.remove("codex_event_message");
1762            if message
1763                .metadata
1764                .remove("__grok_remove_synthetic_turn_id")
1765                .is_some()
1766            {
1767                message.metadata.remove("turn_id");
1768            }
1769        }
1770        truncate_messages_with_anchor(&mut messages, message_limit, preceding_users);
1771        let imported_message_count = Some(total_message_count);
1772        Ok(Session {
1773            meta,
1774            messages,
1775            subagents: Vec::new(),
1776            // Preserve the cheap count without retaining hundreds of
1777            // megabytes of source lines in a display-only value.
1778            raw: vec![String::new(); record_count],
1779            raw_trailing_newline: jsonl.ends_with('\n'),
1780            imported_message_count,
1781            raw_is_verbatim: false,
1782            parse_error_lines,
1783            load_residue: vec![
1784                "display history is a bounded native-record projection, not resumable model context"
1785                    .to_string(),
1786            ],
1787        })
1788    }
1789
1790    /// Load a Pi session from a file.
1791    pub fn from_pi(path: impl AsRef<Path>) -> Result<Session> {
1792        Self::from_pi_str(&std::fs::read_to_string(path.as_ref())?)
1793    }
1794
1795    /// Parse a Pi session (`docs/interop/opencode-pi-spec.md` §1.1,
1796    /// `docs/interop/research/pi-fields.md`) from an in-memory JSONL string.
1797    ///
1798    /// Line 1 is the `session` header; every other line is one `SessionEntry`
1799    /// in a tree keyed by `id`/`parentId` — file order is append order, not
1800    /// tree order. `raw` captures every line verbatim (byte-lossless T1,
1801    /// exactly like Claude Code/Codex). `messages` is the **active path
1802    /// only**: pi's own leaf rule is "the last entry in file order"
1803    /// (`pi-fields.md` `sm:897`), so this walks `parentId` from there back to
1804    /// the root and linearizes root→leaf. Non-active branches, `label`s, and
1805    /// state records (`thinking_level_change`/`model_change`/`custom`/
1806    /// `session_info`) are never visited by that walk — they survive in
1807    /// `raw` only, pi's defining residue (§1.1).
1808    ///
1809    /// `message.role` is an OPEN union upstream (§1.1 S6): a role outside the
1810    /// five modeled here (`user`/`assistant`/`toolResult`/`bashExecution`/
1811    /// `custom`) produces no canonical message — raw-only survival, never a
1812    /// panic — and the Pi corpus audit turns that into a
1813    /// visible coverage failure rather than a silent drop.
1814    ///
1815    /// Same fail-loud discipline applies to `ImageContent` blocks
1816    /// (`user`/`toolResult`/`custom*` content, see `pi_image_shape`): the
1817    /// assumed `{mimeType, data}` shape is UNVERIFIED against real pi output
1818    /// (`pi-fields.md` doesn't enumerate `ImageContent`'s own fields, only
1819    /// cites the containing union) — a follow-up TR tracks confirming it
1820    /// against a real corpus. Until then, an image block that doesn't match
1821    /// that shape never gets silently synthesized as an empty/corrupt
1822    /// `image_url` part; the containing message survives in `raw` only and
1823    /// trips `crate::audit::Corpus::Pi`'s `message/UnknownImageShape` bucket.
1824    pub fn from_pi_str(jsonl: &str) -> Result<Session> {
1825        let mut meta = SessionMeta::new(SessionSource::Pi);
1826        // IX-1: `raw` is captured STRICT-VERBATIM — separate from the
1827        // blank-skipping PARSE walk (`lines_v`) below, which must keep
1828        // skipping blank/whitespace-only lines when it looks for `SessionEntry`
1829        // records (a blank line is never a record, on either view).
1830        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
1831        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
1832        let non_empty_line_count = non_empty_lines(jsonl).count();
1833        let lines_v: Vec<Value> = non_empty_lines(jsonl)
1834            .filter_map(|l| serde_json::from_str(l).ok())
1835            .collect();
1836        // PARITY-15: every line that failed to even deserialize as JSON at
1837        // all (never mind whether it then parsed as a recognized
1838        // `SessionEntry` shape) — see `from_claude_code_str`'s identical
1839        // counter.
1840        let parse_error_lines = non_empty_line_count.saturating_sub(lines_v.len());
1841
1842        if let Some(header) = lines_v.first() {
1843            capture_pi_header(header, &mut meta)?;
1844        }
1845
1846        // Every non-header entry that parses as an object carrying an `id`.
1847        // (A line that fails to parse, or a header re-parsed as an entry,
1848        // simply never enters `by_id` — it survives in `raw` only, exactly
1849        // like a malformed/non-conversational line in the other loaders.)
1850        struct PiEntry {
1851            id: String,
1852            parent_id: Option<String>,
1853            value: Value,
1854        }
1855        let mut entries: Vec<PiEntry> = Vec::new();
1856        let mut by_id: HashMap<String, usize> = HashMap::new();
1857        for v in lines_v.iter().skip(1) {
1858            let Some(id) = v.get("id").and_then(Value::as_str) else {
1859                continue;
1860            };
1861            let parent_id = v
1862                .get("parentId")
1863                .and_then(Value::as_str)
1864                .map(str::to_string);
1865            by_id.insert(id.to_string(), entries.len());
1866            entries.push(PiEntry {
1867                id: id.to_string(),
1868                parent_id,
1869                value: v.clone(),
1870            });
1871        }
1872
1873        if entries.is_empty() {
1874            return Ok(Session {
1875                meta,
1876                messages: Vec::new(),
1877                subagents: Vec::new(),
1878                raw,
1879                raw_trailing_newline,
1880                imported_message_count: Some(0),
1881                // Pi is line-oriented: `raw` is split directly out of the
1882                // source text (strict-verbatim, IX-1), even for this
1883                // no-entries early return.
1884                raw_is_verbatim: true,
1885                parse_error_lines,
1886                load_residue: Vec::new(),
1887            });
1888        }
1889
1890        // Leaf = the last entry in file order (pi's own rule, `sm:897`), NOT
1891        // necessarily a `message` entry — a trailing `label`/`session_info`
1892        // still anchors the walk correctly since the walk just follows
1893        // `parentId` regardless of the leaf's own type.
1894        let leaf_idx = entries.len() - 1;
1895        let mut chain_rev: Vec<usize> = Vec::new();
1896        let mut cur: Option<String> = Some(entries[leaf_idx].id.clone());
1897        let mut guard = 0usize;
1898        while let Some(id) = cur {
1899            let Some(&idx) = by_id.get(&id) else { break };
1900            chain_rev.push(idx);
1901            cur = entries[idx].parent_id.clone();
1902            guard += 1;
1903            if guard > entries.len() + 1 {
1904                break; // cycle guard — malformed parentId chain
1905            }
1906        }
1907        chain_rev.reverse();
1908        let active = chain_rev; // indices into `entries`, root..leaf order
1909
1910        let pos_in_active: HashMap<&str, usize> = active
1911            .iter()
1912            .enumerate()
1913            .map(|(pos, &idx)| (entries[idx].id.as_str(), pos))
1914            .collect();
1915
1916        // First pass: compaction discipline (§2.1 S3) — every message from an
1917        // entry before the LATEST `firstKeptEntryId` on the active path is
1918        // excluded from replay (`compacted_out`), mirroring pi's own
1919        // `buildContextEntries` slice (`sm:414-450`).
1920        let mut kept_from_pos = 0usize;
1921        for &idx in &active {
1922            let e = &entries[idx];
1923            if e.value.get("type").and_then(Value::as_str) == Some("compaction") {
1924                if let Some(fk) = e.value.get("firstKeptEntryId").and_then(Value::as_str) {
1925                    if let Some(&p) = pos_in_active.get(fk) {
1926                        kept_from_pos = kept_from_pos.max(p);
1927                    }
1928                }
1929            }
1930        }
1931
1932        let mut messages = Vec::new();
1933        let mut current_model: Option<String> = None;
1934        for (pos, &idx) in active.iter().enumerate() {
1935            let e = &entries[idx];
1936            let v = &e.value;
1937            let entry_ts = v
1938                .get("timestamp")
1939                .and_then(Value::as_str)
1940                .map(str::to_string);
1941            let before = messages.len();
1942            match v.get("type").and_then(Value::as_str) {
1943                Some("message") => {
1944                    let msg_v = v.get("message").cloned().unwrap_or(Value::Null);
1945                    match msg_v.get("role").and_then(Value::as_str) {
1946                        Some("user") => push_pi_user(&msg_v, &mut messages),
1947                        Some("assistant") => {
1948                            push_pi_assistant(&msg_v, &mut messages);
1949                            if let Some(m) = msg_v.get("model").and_then(Value::as_str) {
1950                                current_model = Some(m.to_string());
1951                            }
1952                        }
1953                        Some("toolResult") => push_pi_tool_result(&msg_v, &mut messages),
1954                        Some("bashExecution") => push_pi_bash(&msg_v, &mut messages),
1955                        Some("custom") => push_pi_custom_common(&msg_v, &mut messages),
1956                        // OPEN UNION (S6): any other role — raw-only survival.
1957                        _ => {}
1958                    }
1959                }
1960                Some("custom_message") => push_pi_custom_common(v, &mut messages),
1961                Some("compaction") => push_pi_compaction(v, &mut messages),
1962                Some("branch_summary") => push_pi_branch_summary(v, &mut messages),
1963                Some("model_change") => {
1964                    if let Some(m) = v.get("modelId").and_then(Value::as_str) {
1965                        current_model = Some(m.to_string());
1966                    }
1967                }
1968                Some("session_info") => {
1969                    if let Some(name) = v.get("name").and_then(Value::as_str) {
1970                        if !name.is_empty() {
1971                            meta.lineage
1972                                .insert("session_name".to_string(), name.to_string());
1973                        }
1974                    }
1975                }
1976                // thinking_level_change, custom (entry-level state), label —
1977                // no clean home, raw-only (§2.3).
1978                _ => {}
1979            }
1980            let is_summary = matches!(
1981                v.get("type").and_then(Value::as_str),
1982                Some("compaction") | Some("branch_summary")
1983            );
1984            for m in &mut messages[before..] {
1985                m.metadata.insert("pi_entry_id".to_string(), e.id.clone());
1986                if let Some(p) = &e.parent_id {
1987                    m.metadata.insert("pi_parent_id".to_string(), p.clone());
1988                }
1989                if let Some(ts) = &entry_ts {
1990                    m.metadata
1991                        .entry("timestamp".to_string())
1992                        .or_insert_with(|| ts.clone());
1993                }
1994                // WAVE-2 item 1 fallback: the entry-level `timestamp` above
1995                // is pi's authoritative, always-monotonic-in-file-order
1996                // wall-clock (mandatory on every entry) and wins whenever
1997                // present. The nested `message.timestamp` (unix-ms) is only
1998                // reached here — via `entry(...).or_insert_with`, so it
1999                // never overwrites the entry-level value — in the rare case
2000                // an entry lacks its own `timestamp`. This intentionally
2001                // does NOT prefer the msg-level field even though it LOOKS
2002                // more precise: unlike the entry-level timestamp, it is not
2003                // guaranteed monotonic with this loader's root->leaf
2004                // linearization (e.g. a rewound-branch entry can carry an
2005                // earlier msg-level clock reading than its file-order
2006                // neighbors), and OpenCode's own loader re-sorts messages by
2007                // this canonical timestamp — a non-monotonic source would
2008                // silently scramble replay order on a pi->opencode hop.
2009                if let Some(ms) = v
2010                    .get("message")
2011                    .and_then(|mm| mm.get("timestamp"))
2012                    .and_then(Value::as_u64)
2013                {
2014                    m.metadata
2015                        .entry("timestamp".to_string())
2016                        .or_insert_with(|| crate::sidecar::ms_to_rfc3339(ms as i64));
2017                }
2018                // A compaction/branch-summary message IS the retained marker
2019                // — never mark it excluded, regardless of its own position.
2020                if !is_summary && pos < kept_from_pos {
2021                    m.metadata
2022                        .insert("compacted_out".to_string(), "true".to_string());
2023                }
2024            }
2025            restore_single_grok_message(v, &mut messages[before..]);
2026            for message in &mut messages[before..] {
2027                restore_tool_outcome_extension(v, message);
2028            }
2029        }
2030
2031        meta.model = current_model;
2032        ensure_tool_results_paired(&mut messages);
2033        let imported_message_count = Some(messages.len());
2034        Ok(Session {
2035            meta,
2036            messages,
2037            subagents: Vec::new(),
2038            raw,
2039            raw_trailing_newline,
2040            imported_message_count,
2041            // Pi is line-oriented: `raw` is split directly out of the
2042            // source text (strict-verbatim, IX-1).
2043            raw_is_verbatim: true,
2044            parse_error_lines,
2045            load_residue: Vec::new(),
2046        })
2047    }
2048
2049    /// Load Grok's resumable `chat_history.jsonl` transcript.
2050    ///
2051    /// The surrounding session directory carries the session id, workspace,
2052    /// and `summary.json`; [`Self::from_grok_str`] handles the transcript
2053    /// itself while this path-aware entry point overlays that directory
2054    /// metadata.
2055    pub fn from_grok(path: impl AsRef<Path>) -> Result<Session> {
2056        let path = path.as_ref();
2057        let mut session = Self::from_grok_str(&std::fs::read_to_string(path)?)?;
2058        session.capture_grok_path_metadata(path);
2059        Ok(session)
2060    }
2061
2062    /// Parse Grok's line-oriented `chat_history.jsonl` format.
2063    ///
2064    /// Conversational records are `user`, `assistant`, and `tool_result`.
2065    /// `system` is the regenerated base prompt and is retained in
2066    /// [`SessionMeta::system_prompt`]; encrypted reasoning and backend-only
2067    /// state remain byte-exact in [`Session::raw`] but are intentionally not
2068    /// replayed as chat turns.
2069    pub fn from_grok_str(jsonl: &str) -> Result<Session> {
2070        let mut meta = SessionMeta::new(SessionSource::Grok);
2071        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
2072        let raw: Vec<String> = raw_lines.iter().map(|line| line.to_string()).collect();
2073        let mut messages = Vec::new();
2074        let mut parse_error_lines = 0usize;
2075        let mut tool_names: HashMap<String, String> = HashMap::new();
2076
2077        for line in non_empty_lines(jsonl) {
2078            let value: Value = match serde_json::from_str(line) {
2079                Ok(value) => value,
2080                Err(_) => {
2081                    parse_error_lines += 1;
2082                    continue;
2083                }
2084            };
2085            restore_codex_provenance_from_top_level(&value, &mut meta)?;
2086            match value.get("type").and_then(Value::as_str) {
2087                Some("system") => {
2088                    if meta.system_prompt.is_none() {
2089                        meta.system_prompt = value
2090                            .get("content")
2091                            .and_then(Value::as_str)
2092                            .map(str::to_string);
2093                    }
2094                }
2095                Some("user") => {
2096                    let content = extract_text_content(value.get("content"));
2097                    let role = if value.get("synthetic_reason").and_then(Value::as_str)
2098                        == Some("supercode_system_event")
2099                    {
2100                        Role::System
2101                    } else {
2102                        Role::User
2103                    };
2104                    let content = if role == Role::User {
2105                        match grok_human_user_text(&content) {
2106                            Some(content) => content,
2107                            None if value.get(SUPERCODE_GROK_MESSAGE_KEY).is_some() => {
2108                                String::new()
2109                            }
2110                            None => continue,
2111                        }
2112                    } else {
2113                        content
2114                    };
2115                    let mut message = ChatMessage {
2116                        role,
2117                        content: Some(content),
2118                        content_parts: None,
2119                        tool_calls: None,
2120                        tool_call_id: None,
2121                        name: None,
2122                        metadata: Default::default(),
2123                    };
2124                    capture_grok_scalar_metadata(
2125                        &value,
2126                        &mut message,
2127                        &["prompt_index", "prior_turn_interrupt", "synthetic_reason"],
2128                    );
2129                    restore_grok_message_extension(&value, &mut message);
2130                    messages.push(message);
2131                }
2132                Some("assistant") => {
2133                    let calls: Vec<ToolCall> = value
2134                        .get("tool_calls")
2135                        .and_then(Value::as_array)
2136                        .into_iter()
2137                        .flatten()
2138                        .filter_map(|call| {
2139                            let id = call.get("id")?.as_str()?.to_string();
2140                            let name = call.get("name")?.as_str()?.to_string();
2141                            let arguments = call
2142                                .get("arguments")
2143                                .map(value_to_arg_string)
2144                                .unwrap_or_else(|| "{}".to_string());
2145                            tool_names.insert(id.clone(), name.clone());
2146                            Some(function_call(&id, &name, arguments))
2147                        })
2148                        .collect();
2149                    let content = value
2150                        .get("content")
2151                        .and_then(Value::as_str)
2152                        .filter(|content| !content.is_empty())
2153                        .map(str::to_string);
2154                    let mut message = ChatMessage {
2155                        role: Role::Assistant,
2156                        content,
2157                        content_parts: None,
2158                        tool_calls: (!calls.is_empty()).then_some(calls),
2159                        tool_call_id: None,
2160                        name: None,
2161                        metadata: Default::default(),
2162                    };
2163                    capture_grok_scalar_metadata(
2164                        &value,
2165                        &mut message,
2166                        &["model_id", "model_fingerprint", "reasoning_effort"],
2167                    );
2168                    if let Some(model) = value.get("model_id").and_then(Value::as_str) {
2169                        meta.model = Some(model.to_string());
2170                    }
2171                    restore_grok_message_extension(&value, &mut message);
2172                    messages.push(message);
2173                }
2174                Some("tool_result") => {
2175                    let id = value
2176                        .get("tool_call_id")
2177                        .and_then(Value::as_str)
2178                        .unwrap_or_default();
2179                    let content = value
2180                        .get("content")
2181                        .map(|value| match value {
2182                            Value::String(text) => text.clone(),
2183                            other => extract_text_content(Some(other)),
2184                        })
2185                        .unwrap_or_default();
2186                    let mut message = tool_message(id, content);
2187                    message.name = tool_names.get(id).cloned();
2188                    restore_grok_message_extension(&value, &mut message);
2189                    messages.push(message);
2190                }
2191                // `reasoning` contains encrypted chain-of-thought and
2192                // `backend_tool_call` is execution bookkeeping. Both survive
2193                // verbatim in raw without being replayed to another model.
2194                _ => {}
2195            }
2196        }
2197
2198        ensure_tool_results_paired(&mut messages);
2199        let imported_message_count = Some(messages.len());
2200        Ok(Session {
2201            meta,
2202            messages,
2203            subagents: Vec::new(),
2204            raw,
2205            raw_trailing_newline,
2206            imported_message_count,
2207            raw_is_verbatim: true,
2208            parse_error_lines,
2209            load_residue: Vec::new(),
2210        })
2211    }
2212
2213    fn capture_grok_path_metadata(&mut self, transcript: &Path) {
2214        let Some(session_dir) = transcript.parent() else {
2215            return;
2216        };
2217        self.meta.session_id = session_dir
2218            .file_name()
2219            .and_then(|name| name.to_str())
2220            .map(str::to_string);
2221        self.meta.cwd = session_dir
2222            .parent()
2223            .and_then(Path::file_name)
2224            .and_then(|name| name.to_str())
2225            .and_then(percent_decode_path)
2226            .map(PathBuf::from);
2227
2228        let Ok(summary_text) = std::fs::read_to_string(session_dir.join("summary.json")) else {
2229            return;
2230        };
2231        let Ok(summary) = serde_json::from_str::<Value>(&summary_text) else {
2232            return;
2233        };
2234        if let Some(model) = summary.get("current_model_id").and_then(Value::as_str) {
2235            self.meta.model = Some(model.to_string());
2236        }
2237        for (source, target) in [
2238            ("generated_title", "session_name"),
2239            ("created_at", "created_at"),
2240            ("updated_at", "updated_at"),
2241            ("chat_format_version", "grok_chat_format_version"),
2242        ] {
2243            if let Some(value) = summary.get(source) {
2244                self.meta.lineage.insert(
2245                    target.to_string(),
2246                    value
2247                        .as_str()
2248                        .map(str::to_string)
2249                        .unwrap_or_else(|| value.to_string()),
2250                );
2251            }
2252        }
2253    }
2254
2255    /// Load a Gemini CLI transcript from disk.
2256    pub fn from_gemini(path: impl AsRef<Path>) -> Result<Session> {
2257        Self::from_gemini_str(&std::fs::read_to_string(path.as_ref())?)
2258    }
2259
2260    /// Parse Gemini CLI's line-oriented session format.
2261    ///
2262    /// Gemini stores a header without a `type`, followed by `user` and
2263    /// `gemini` records. Function calls are embedded in assistant content
2264    /// parts and function responses in user content parts. Unknown records
2265    /// remain byte-exact in [`Session::raw`] instead of silently entering the
2266    /// replay conversation.
2267    pub fn from_gemini_str(jsonl: &str) -> Result<Session> {
2268        let mut meta = SessionMeta::new(SessionSource::Gemini);
2269        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
2270        let raw = raw_lines.iter().map(|line| line.to_string()).collect();
2271        let mut messages = Vec::new();
2272        let mut parse_error_lines = 0usize;
2273        let mut pending_by_name: HashMap<String, Vec<String>> = HashMap::new();
2274
2275        for (line_index, line) in non_empty_lines(jsonl).enumerate() {
2276            let value: Value = match serde_json::from_str(line) {
2277                Ok(value) => value,
2278                Err(_) => {
2279                    parse_error_lines += 1;
2280                    continue;
2281                }
2282            };
2283            let kind = value.get("type").and_then(Value::as_str);
2284            if kind.is_none() {
2285                if meta.session_id.is_none() {
2286                    meta.session_id = value
2287                        .get("sessionId")
2288                        .and_then(Value::as_str)
2289                        .map(str::to_string);
2290                }
2291                for (source, target) in [
2292                    ("projectHash", "gemini_project_hash"),
2293                    ("startTime", "created_at"),
2294                    ("lastUpdated", "updated_at"),
2295                    ("kind", "gemini_session_kind"),
2296                ] {
2297                    if let Some(raw) = value.get(source) {
2298                        meta.lineage.insert(
2299                            target.to_string(),
2300                            raw.as_str()
2301                                .map(str::to_string)
2302                                .unwrap_or_else(|| raw.to_string()),
2303                        );
2304                    }
2305                }
2306                continue;
2307            }
2308            if kind != Some("user") && kind != Some("gemini") {
2309                continue;
2310            }
2311
2312            let timestamp = value.get("timestamp").and_then(Value::as_str);
2313            let model = value.get("model").and_then(Value::as_str);
2314            if let Some(model) = model {
2315                meta.model = Some(model.to_string());
2316            }
2317            let content = value.get("content").unwrap_or(&Value::Null);
2318            let parts = content.as_array();
2319            let text = match content {
2320                Value::String(text) => text.clone(),
2321                Value::Array(parts) => parts
2322                    .iter()
2323                    .filter_map(|part| part.get("text").and_then(Value::as_str))
2324                    .collect::<Vec<_>>()
2325                    .join(" ")
2326                    .trim()
2327                    .to_string(),
2328                _ => String::new(),
2329            };
2330
2331            if kind == Some("gemini") {
2332                let legacy_calls = parts
2333                    .into_iter()
2334                    .flatten()
2335                    .filter_map(|part| part.get("functionCall"));
2336                let native_calls = value
2337                    .get("toolCalls")
2338                    .and_then(Value::as_array)
2339                    .into_iter()
2340                    .flatten();
2341                let calls = native_calls
2342                    .chain(legacy_calls)
2343                    .enumerate()
2344                    .filter_map(|(call_index, call)| {
2345                        let name = call.get("name")?.as_str()?.to_string();
2346                        let id = call
2347                            .get("id")
2348                            .and_then(Value::as_str)
2349                            .map(str::to_string)
2350                            .unwrap_or_else(|| format!("gemini-{line_index}-{call_index}"));
2351                        pending_by_name
2352                            .entry(name.clone())
2353                            .or_default()
2354                            .push(id.clone());
2355                        let arguments = call
2356                            .get("args")
2357                            .map(value_to_arg_string)
2358                            .unwrap_or_else(|| "{}".to_string());
2359                        Some(function_call(&id, &name, arguments))
2360                    })
2361                    .collect::<Vec<_>>();
2362                let mut message = ChatMessage {
2363                    role: Role::Assistant,
2364                    content: (!text.is_empty()).then_some(text),
2365                    content_parts: None,
2366                    tool_calls: (!calls.is_empty()).then_some(calls),
2367                    tool_call_id: None,
2368                    name: None,
2369                    metadata: Default::default(),
2370                };
2371                if let Some(timestamp) = timestamp {
2372                    message
2373                        .metadata
2374                        .insert("timestamp".into(), timestamp.into());
2375                }
2376                if let Some(model) = model {
2377                    message.metadata.insert("gemini_model".into(), model.into());
2378                }
2379                if let Some(thoughts) = value.get("thoughts").filter(|value| !value.is_null()) {
2380                    message
2381                        .metadata
2382                        .insert("gemini_thoughts".into(), thoughts.to_string());
2383                }
2384                restore_gemini_message_extension(&value, &mut message);
2385                if message.content.is_some() || message.tool_calls.is_some() {
2386                    messages.push(message);
2387                }
2388                continue;
2389            }
2390
2391            let mut user_parts = Vec::new();
2392            if let Some(parts) = parts {
2393                for part in parts {
2394                    if let Some(response) = part.get("functionResponse") {
2395                        push_gemini_user_parts(
2396                            &mut messages,
2397                            std::mem::take(&mut user_parts),
2398                            timestamp,
2399                            &value,
2400                        );
2401                        let name = response
2402                            .get("name")
2403                            .and_then(Value::as_str)
2404                            .unwrap_or("tool")
2405                            .to_string();
2406                        let explicit_id = response
2407                            .get("id")
2408                            .and_then(Value::as_str)
2409                            .map(str::to_string);
2410                        if let Some(id) = explicit_id.as_deref() {
2411                            if let Some(ids) = pending_by_name.get_mut(&name) {
2412                                if let Some(position) = ids.iter().position(|pending| pending == id)
2413                                {
2414                                    ids.remove(position);
2415                                }
2416                            }
2417                        }
2418                        let id = explicit_id
2419                            .or_else(|| {
2420                                pending_by_name
2421                                    .get_mut(&name)
2422                                    .and_then(|ids| (!ids.is_empty()).then(|| ids.remove(0)))
2423                            })
2424                            .unwrap_or_else(|| format!("gemini-{line_index}-response"));
2425                        let output = response
2426                            .get("response")
2427                            .and_then(|response| response.get("output"))
2428                            .map(|output| {
2429                                output
2430                                    .as_str()
2431                                    .map(str::to_string)
2432                                    .unwrap_or_else(|| output.to_string())
2433                            })
2434                            .or_else(|| response.get("response").map(Value::to_string))
2435                            .unwrap_or_default();
2436                        let mut message = tool_message(&id, output);
2437                        message.name = Some(name);
2438                        if let Some(timestamp) = timestamp {
2439                            message
2440                                .metadata
2441                                .insert("timestamp".into(), timestamp.into());
2442                        }
2443                        restore_gemini_message_extension(&value, &mut message);
2444                        messages.push(message);
2445                        continue;
2446                    }
2447                    if let Some(text) = part.get("text").and_then(Value::as_str) {
2448                        user_parts.push(serde_json::json!({"type": "text", "text": text}));
2449                        continue;
2450                    }
2451                    if let Some(inline) = part.get("inlineData") {
2452                        let Some(data) = inline.get("data").and_then(Value::as_str) else {
2453                            continue;
2454                        };
2455                        let media_type = inline
2456                            .get("mimeType")
2457                            .and_then(Value::as_str)
2458                            .unwrap_or("application/octet-stream");
2459                        user_parts.push(serde_json::json!({
2460                            "type": "image_url",
2461                            "image_url": {"url": format!("data:{media_type};base64,{data}")},
2462                        }));
2463                    }
2464                }
2465            } else if !text.is_empty() {
2466                user_parts.push(serde_json::json!({"type": "text", "text": text}));
2467            }
2468            push_gemini_user_parts(&mut messages, user_parts, timestamp, &value);
2469        }
2470
2471        ensure_tool_results_paired(&mut messages);
2472        let imported_message_count = Some(messages.len());
2473        Ok(Session {
2474            meta,
2475            messages,
2476            subagents: Vec::new(),
2477            raw,
2478            raw_trailing_newline,
2479            imported_message_count,
2480            raw_is_verbatim: true,
2481            parse_error_lines,
2482            load_residue: Vec::new(),
2483        })
2484    }
2485
2486    /// Load a Goose session-export JSON document from disk.
2487    pub fn from_goose(path: impl AsRef<Path>) -> Result<Session> {
2488        Self::from_goose_str(&std::fs::read_to_string(path.as_ref())?)
2489    }
2490
2491    /// Parse Goose's official native import/export document.
2492    ///
2493    /// Goose's durable store is SQLite, but its own
2494    /// `_goose/unstable/session/export` and `/session/import` boundary is one
2495    /// JSON object containing a `conversation` array. Unknown native content
2496    /// blocks are retained on the first canonical message in a namespaced
2497    /// portability envelope; unchanged same-format exports replay the exact
2498    /// source bytes.
2499    pub fn from_goose_str(json: &str) -> Result<Session> {
2500        let document: Value = serde_json::from_str(json).map_err(crate::Error::Decode)?;
2501        let object = document.as_object().ok_or_else(|| {
2502            Error::InvalidSession("Goose session export must be a JSON object".to_string())
2503        })?;
2504        let conversation = object
2505            .get("conversation")
2506            .and_then(Value::as_array)
2507            .ok_or_else(|| {
2508                Error::InvalidSession(
2509                    "Goose session export must contain a conversation array".to_string(),
2510                )
2511            })?;
2512
2513        let mut meta = SessionMeta::new(SessionSource::Goose);
2514        meta.session_id = object.get("id").and_then(Value::as_str).map(str::to_string);
2515        meta.cwd = object
2516            .get("working_dir")
2517            .or_else(|| object.get("workingDir"))
2518            .and_then(Value::as_str)
2519            .map(PathBuf::from);
2520        meta.model = object
2521            .get("model_config")
2522            .or_else(|| object.get("modelConfig"))
2523            .and_then(|model| model.get("model_name").or_else(|| model.get("modelName")))
2524            .and_then(Value::as_str)
2525            .map(str::to_string);
2526        for (source, target) in [
2527            ("name", "session_name"),
2528            ("created_at", "created_at"),
2529            ("updated_at", "updated_at"),
2530            ("session_type", "goose_session_type"),
2531            ("goose_mode", "goose_mode"),
2532            ("provider_name", "goose_provider_name"),
2533            ("parent_session_id", "parent_session_id"),
2534        ] {
2535            if let Some(value) = object.get(source) {
2536                meta.lineage.insert(
2537                    target.to_string(),
2538                    value
2539                        .as_str()
2540                        .map(str::to_string)
2541                        .unwrap_or_else(|| value.to_string()),
2542                );
2543            }
2544        }
2545        let mut header = document.clone();
2546        if let Some(header) = header.as_object_mut() {
2547            header.remove("conversation");
2548        }
2549        meta.goose_header = Some(header.clone());
2550
2551        let mut messages = Vec::new();
2552        for (native_index, native) in conversation.iter().enumerate() {
2553            let before = messages.len();
2554            normalize_goose_message(native, native_index, &mut messages);
2555            if let Some(first) = messages.get_mut(before) {
2556                first
2557                    .metadata
2558                    .insert("goose_native_message".to_string(), native.to_string());
2559                first
2560                    .metadata
2561                    .insert("goose_native_index".to_string(), native_index.to_string());
2562                if native_index == 0 {
2563                    first
2564                        .metadata
2565                        .insert("goose_session_header".to_string(), header.to_string());
2566                }
2567                restore_grok_message_extension(native, first);
2568            }
2569            for message in messages.iter_mut().skip(before + 1) {
2570                message
2571                    .metadata
2572                    .insert("goose_native_index".to_string(), native_index.to_string());
2573            }
2574        }
2575        ensure_tool_results_paired(&mut messages);
2576
2577        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(json);
2578        let raw = raw_lines.iter().map(|line| line.to_string()).collect();
2579        let imported_message_count = Some(messages.len());
2580        Ok(Session {
2581            meta,
2582            messages,
2583            subagents: Vec::new(),
2584            raw,
2585            raw_trailing_newline,
2586            imported_message_count,
2587            raw_is_verbatim: true,
2588            parse_error_lines: 0,
2589            load_residue: Vec::new(),
2590        })
2591    }
2592
2593    /// Load one Goose session directly from its native SQLite store.
2594    ///
2595    /// The selector is Goose's stable `sessions.id`. The reconstructed JSON
2596    /// uses Goose's own public export shape, so the ordinary Goose codec is
2597    /// the single normalization boundary for both files and the live store.
2598    pub fn from_goose_sqlite(db_path: &Path, session_id: &str) -> Result<Session> {
2599        Self::from_goose_sqlite_with_limit(db_path, session_id, None)
2600    }
2601
2602    /// Bounded Goose store read for transcript UI surfaces. The inner query
2603    /// selects only the newest native rows; the outer query restores their
2604    /// chronological order. Export/continue callers deliberately use the
2605    /// unbounded public loader above.
2606    #[doc(hidden)]
2607    pub fn from_goose_sqlite_display(
2608        db_path: &Path,
2609        session_id: &str,
2610        message_limit: usize,
2611    ) -> Result<Session> {
2612        Self::from_goose_sqlite_with_limit(db_path, session_id, Some(message_limit.max(1)))
2613    }
2614
2615    fn from_goose_sqlite_with_limit(
2616        db_path: &Path,
2617        session_id: &str,
2618        message_limit: Option<usize>,
2619    ) -> Result<Session> {
2620        let connection = Connection::open_with_flags(
2621            db_path,
2622            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2623        )
2624        .map_err(|error| Error::Other(format!("failed to open Goose SQLite store: {error}")))?;
2625        let mut statement = connection
2626            .prepare(
2627                "SELECT id, name, working_dir, created_at, updated_at, session_type, \
2628                    extension_data, goose_mode, provider_name, model_config_json \
2629             FROM sessions WHERE id = ?1",
2630            )
2631            .map_err(|error| Error::Other(format!("failed to query Goose sessions: {error}")))?;
2632        let mut document = statement
2633            .query_row([session_id], |row| {
2634                let extension_data: Option<String> = row.get(6)?;
2635                let model_config: Option<String> = row.get(9)?;
2636                Ok(serde_json::json!({
2637                    "id": row.get::<_, String>(0)?,
2638                    "working_dir": row.get::<_, String>(2)?,
2639                    "name": row.get::<_, String>(1)?,
2640                    "user_set_name": false,
2641                    "session_type": row.get::<_, String>(5)?,
2642                    "created_at": row.get::<_, String>(3)?,
2643                    "updated_at": row.get::<_, String>(4)?,
2644                    "extension_data": extension_data
2645                        .as_deref()
2646                        .and_then(|value| serde_json::from_str::<Value>(value).ok())
2647                        .unwrap_or_else(|| serde_json::json!({})),
2648                    "usage": {},
2649                    "accumulated_usage": {},
2650                    "accumulated_cost": Value::Null,
2651                    "schedule_id": Value::Null,
2652                    "recipe": Value::Null,
2653                    "user_recipe_values": Value::Null,
2654                    "conversation": [],
2655                    "message_count": 0,
2656                    "last_message_at": Value::Null,
2657                    "provider_name": row.get::<_, Option<String>>(8)?,
2658                    "model_config": model_config
2659                        .as_deref()
2660                        .and_then(|value| serde_json::from_str::<Value>(value).ok()),
2661                    "goose_mode": row.get::<_, String>(7)?,
2662                    "archived_at": Value::Null,
2663                    "project_id": Value::Null,
2664                    "parent_session_id": Value::Null,
2665                    "last_message_snippet": Value::Null,
2666                }))
2667            })
2668            .map_err(|error| Error::Other(format!("failed to load Goose session: {error}")))?;
2669
2670        let message_query = message_limit.map_or_else(
2671            || {
2672                "SELECT message_id, role, content_json, created_timestamp, metadata_json \
2673                 FROM messages WHERE session_id = ?1 ORDER BY created_timestamp, id"
2674                    .to_string()
2675            },
2676            |limit| {
2677                format!(
2678                    "SELECT message_id, role, content_json, created_timestamp, metadata_json \
2679                     FROM (SELECT id AS native_row_id, message_id, role, content_json, \
2680                                  created_timestamp, metadata_json \
2681                           FROM messages WHERE session_id = ?1 \
2682                           ORDER BY created_timestamp DESC, id DESC LIMIT {limit}) \
2683                     ORDER BY created_timestamp, native_row_id"
2684                )
2685            },
2686        );
2687        let mut message_statement = connection
2688            .prepare(&message_query)
2689            .map_err(|error| Error::Other(format!("failed to query Goose messages: {error}")))?;
2690        let rows = message_statement
2691            .query_map([session_id], |row| {
2692                let content: String = row.get(2)?;
2693                let metadata: Option<String> = row.get(4)?;
2694                Ok(serde_json::json!({
2695                    "id": row.get::<_, Option<String>>(0)?,
2696                    "role": row.get::<_, String>(1)?,
2697                    "created": row.get::<_, i64>(3)?,
2698                    "content": serde_json::from_str::<Value>(&content)
2699                        .unwrap_or_else(|_| Value::Array(Vec::new())),
2700                    "metadata": metadata
2701                        .as_deref()
2702                        .and_then(|value| serde_json::from_str::<Value>(value).ok())
2703                        .unwrap_or_else(|| serde_json::json!({
2704                            "userVisible": true,
2705                            "agentVisible": true
2706                        })),
2707                }))
2708            })
2709            .map_err(|error| Error::Other(format!("failed to load Goose messages: {error}")))?;
2710        let conversation = rows
2711            .collect::<std::result::Result<Vec<_>, _>>()
2712            .map_err(|error| Error::Other(format!("failed to decode Goose messages: {error}")))?;
2713        document["message_count"] = Value::from(conversation.len());
2714        document["conversation"] = Value::Array(conversation);
2715        let json = serde_json::to_string_pretty(&document).map_err(crate::Error::Decode)?;
2716        let mut session = Self::from_goose_str(&json)?;
2717        // SQLite was reconstructed through values, not captured byte-for-byte.
2718        session.raw_is_verbatim = false;
2719        Ok(session)
2720    }
2721
2722    /// Load an OpenCode session from a file — either read surface, see
2723    /// [`Self::from_opencode_str`].
2724    pub fn from_opencode(path: impl AsRef<Path>) -> Result<Session> {
2725        Self::from_opencode_str(&std::fs::read_to_string(path.as_ref())?)
2726    }
2727
2728    /// Load an OpenCode session from a real SQLite store (PARITY-3): opens
2729    /// `db_path`, resolves `session_id` (explicit, or — when `None` — the
2730    /// most-recently-updated top-level session, see
2731    /// `opencode_sqlite_primary_session_id`) and reconstructs the SAME
2732    /// envelope form [`Self::from_opencode_str`] already parses for the
2733    /// JSON-tree surfaces — so canonicalization (§2.1 mapping, compaction
2734    /// discipline, S1 tool-output masking, …) is shared code, not
2735    /// reimplemented here. See `docs/interop/opencode-pi-spec.md` §1.2/S9c
2736    /// for the envelope-construction rules this follows (all-columns rule,
2737    /// raw `revert` column carried verbatim).
2738    pub fn from_opencode_sqlite(db_path: &Path, session_id: Option<&str>) -> Result<Session> {
2739        let conn = opencode_sqlite_open(db_path)?;
2740        let id = match session_id {
2741            Some(id) => id.to_string(),
2742            None => opencode_sqlite_primary_session_id(&conn)?,
2743        };
2744        let lines = opencode_sqlite_session_envelope_lines(&conn, db_path, &id)?;
2745        let mut text = lines.join("\n");
2746        text.push('\n');
2747        let mut session = Self::from_opencode_str(&text)?;
2748        // D4: `text` above is a SYNTHESIZED reconstruction from SQL rows —
2749        // not the original source bytes (a binary `.db` file has no
2750        // "verbatim" line-oriented form to begin with). `from_opencode_str`
2751        // defaults `raw_is_verbatim` to `true` because for its OTHER two
2752        // callers (an actual envelope-form file's own text, an actual
2753        // export-document's text) that really is the source. It is NEVER
2754        // true for this diagonal — mirrors the export-document fix just
2755        // above for the same reason (`from_opencode_export_doc`, `false`).
2756        // `convert opencode.db --to opencode` must not claim byte-identical.
2757        session.raw_is_verbatim = false;
2758        Ok(session)
2759    }
2760
2761    /// Parse an OpenCode session from either of its two frozen **read
2762    /// surfaces** (`docs/interop/opencode-pi-spec.md` §1.2, `S9a`,
2763    /// `opencode-fields.md`):
2764    ///
2765    /// - the **envelope form**: each line is
2766    ///   `{"key":[<storage key path>],"value":<record>}`, minified — the
2767    ///   synthesized raw-capture unit for the JSON-tree/SQLite storage
2768    ///   generations;
2769    /// - the **export-document form**: a single pretty-printed JSON document
2770    ///   `{info: SessionInfo, messages:[{info: Message, parts:[Part]}, …]}`
2771    ///   — the `opencode export`/`import` interchange shape, and EXACTLY
2772    ///   what the OpenCode writer emits.
2773    ///
2774    /// Both forms are parsed into the same `(session_info, side_records,
2775    /// Vec<OcMsg>)` shape and funnel through the SAME shared canonicalizer,
2776    /// `opencode_session_from_records` — so the same underlying records
2777    /// produce identical `messages` regardless of which surface carried
2778    /// them in. This is what makes `load(to_opencode_jsonl(S))` round-trip
2779    /// (§4.1's OpenCode diagonal, the exact circuit the fidelity matrix
2780    /// exercises): previously this function parsed the envelope form only
2781    /// and silently returned an empty-but-`Ok` `Session` for an export
2782    /// document — the confirmed footgun this now closes.
2783    ///
2784    /// Record classification (envelope form) is driven by the envelope
2785    /// `key`'s first component (`"session"` / `"message"` / `"part"` /
2786    /// `"session_diff"` / `"todo"`) — the frozen key scheme for all three
2787    /// storage generations plus SQLite rows (§1.2 S9c: a SQLite row's
2788    /// envelope synthesizes `key:["<table>","<ses>",...ids]` and MUST carry
2789    /// every column, `data` and non-`data` alike — e.g. the `session` row's
2790    /// `revert` column under the V2 `Revert.State` schema, whose extra
2791    /// `files` field the CLI's own row→V1 reconstruction drops; the
2792    /// envelope's `raw` capture keeps that raw column value regardless of
2793    /// what this loader's canonicalization understands).
2794    ///
2795    /// Mapping to canonical `messages` (§2.1, shared by both forms via
2796    /// `push_opencode_user`/`push_opencode_assistant`): `User`/`Assistant`
2797    /// text parts → `content`; a `User` `file` part whose `mime` is an image
2798    /// and whose `url` is a `data:` URI → an `image_url` `content_parts`
2799    /// entry; an `Assistant` `tool` part's `callID` + `state.*.input` → a
2800    /// `ToolCall`, and the SAME part's `state.completed.output` /
2801    /// `state.error.error` → a paired `Tool` message split by `callID`
2802    /// (opencode keeps call+result on one record; this loader splits it
2803    /// into the two OpenAI-shape messages the other loaders already
2804    /// produce).
2805    ///
2806    /// **S1 (`time.compacted`):** when a `tool` part's
2807    /// `state.completed.time.compacted` is set, the emitted `Tool`
2808    /// message's `content` is the placeholder
2809    /// [`OPENCODE_COMPACTED_TOOL_PLACEHOLDER`] — mirroring what opencode's
2810    /// own `toModelMessage` replays — while the REAL output survives in
2811    /// `raw` (always) and in `metadata["oc_tool_output_compacted"]` (full
2812    /// text) + `metadata["oc_tool_time_compacted"]` (the mask timestamp), so
2813    /// it is reversible, never actually lost.
2814    ///
2815    /// **Compaction boundary:** a `compaction` part's `tail_start_id` marks
2816    /// every message strictly before that message id
2817    /// `metadata["compacted_out"]="true"` (honored uniformly by
2818    /// `is_replay_excluded`) — except a `summary:true` `Assistant`
2819    /// message, which opencode itself hoists in FRONT of the retained tail
2820    /// on replay (`message-v2.ts:521-572`) and so must never be excluded
2821    /// regardless of its position, mirroring pi's identical exemption for
2822    /// its own compaction/branch-summary entries.
2823    ///
2824    /// **Unknown part `type` or unknown `tool.state.status`:** never
2825    /// canonicalized — raw-only survival, exactly like an unmodeled Pi
2826    /// `message.role` (S6-style fail-loud). The OpenCode corpus audit
2827    /// is what turns that into a visible coverage failure rather than a
2828    /// silent drop.
2829    ///
2830    /// **Export-document `raw`:** an export document is a single
2831    /// pretty-printed JSON value with no per-line envelope structure of its
2832    /// own to capture verbatim, so `raw` here is RE-SYNTHESIZED — one
2833    /// envelope line per `session`/`message`/`part` record found in the
2834    /// document, in the exact `{"key":[...],"value":...}` shape the native
2835    /// envelope form uses — so every native/T1-value-tier path
2836    /// (`to_native_jsonl`, `opencode_records_from_raw`, the
2837    /// splice/direct-write writers) stays consistent regardless of which
2838    /// read surface produced this `Session`.
2839    ///
2840    /// **Malformed input:** input that reaches this function non-empty but
2841    /// yields zero session/message/part records under EITHER form returns a
2842    /// clear `Err` rather than a silently-empty `Ok(Session)` — the
2843    /// confirmed footgun (`supercode resume`/`convert`/`inspect` on such
2844    /// input must not silently succeed with an empty session). A
2845    /// legitimately-empty session — a real `session` record with zero
2846    /// messages, or a valid export document with an empty `messages` array
2847    /// — is not an error.
2848    pub fn from_opencode_str(text: &str) -> Result<Session> {
2849        let trimmed = text.trim();
2850
2851        // Export-document form first (§1.2/S9a) — mirrors `detect_source`'s
2852        // own precedence: try the whole-text parse before the per-line
2853        // envelope loop below, since a pretty-printed multi-line document
2854        // has no individually-valid-JSON lines for that loop to match.
2855        if let Ok(doc) = serde_json::from_str::<Value>(trimmed) {
2856            if doc.get("info").is_some() && doc.get("messages").and_then(Value::as_array).is_some()
2857            {
2858                return Self::from_opencode_export_doc(&doc);
2859            }
2860        }
2861
2862        // Envelope form: each line `{"key":[<storage key path>],"value":<record>}`.
2863        // IX-1: `raw` is captured STRICT-VERBATIM — separate from the
2864        // blank-skipping PARSE walk just below, which keeps skipping
2865        // blank/whitespace-only lines when it looks for envelope records.
2866        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(text);
2867        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
2868        let mut session_info: Option<Value> = None;
2869        let mut side_records: Vec<Value> = Vec::new();
2870        let mut msgs: Vec<OcMsg> = Vec::new();
2871        let mut msg_index: HashMap<String, usize> = HashMap::new();
2872        // PARITY-15: see `from_claude_code_str`'s identical counter — only
2873        // a genuinely malformed line (fails to deserialize as JSON at all),
2874        // not a well-formed envelope this loader simply doesn't recognize.
2875        let mut parse_error_lines = 0usize;
2876
2877        for line in non_empty_lines(text) {
2878            let Ok(env) = serde_json::from_str::<Value>(line) else {
2879                parse_error_lines += 1;
2880                continue; // malformed line — raw-only, exactly like the other loaders
2881            };
2882            let Some(key) = env.get("key").and_then(Value::as_array) else {
2883                continue; // not an envelope record — raw-only
2884            };
2885            let value = env.get("value").cloned().unwrap_or(Value::Null);
2886            match key.first().and_then(Value::as_str) {
2887                Some("session") => session_info = Some(value),
2888                Some("message") => {
2889                    let Some(id) = value.get("id").and_then(Value::as_str) else {
2890                        continue;
2891                    };
2892                    let time_created = value
2893                        .get("time")
2894                        .and_then(|t| t.get("created"))
2895                        .and_then(Value::as_i64)
2896                        .unwrap_or(0);
2897                    msg_index.insert(id.to_string(), msgs.len());
2898                    msgs.push(OcMsg {
2899                        id: id.to_string(),
2900                        time_created,
2901                        value,
2902                        parts: Vec::new(),
2903                    });
2904                }
2905                Some("part") => {
2906                    if let Some(msg_id) = value.get("messageID").and_then(Value::as_str) {
2907                        if let Some(&idx) = msg_index.get(msg_id) {
2908                            msgs[idx].parts.push(value);
2909                        }
2910                        // A part whose message wasn't captured (out-of-order
2911                        // envelope) — still fully present in `raw`, just not
2912                        // attached to a canonical message.
2913                    }
2914                }
2915                Some("session_diff") | Some("todo") => {
2916                    side_records.push(serde_json::json!({"key": key, "value": value}));
2917                }
2918                _ => {} // unrecognized top-level key — raw-only
2919            }
2920        }
2921
2922        opencode_guard_against_silent_empty(
2923            !trimmed.is_empty(),
2924            &session_info,
2925            &msgs,
2926            &side_records,
2927        )?;
2928        opencode_session_from_records(
2929            session_info,
2930            side_records,
2931            msgs,
2932            raw,
2933            raw_trailing_newline,
2934            // Envelope form: `raw` is split directly out of the source text
2935            // (strict-verbatim, IX-1) — genuinely reproduces the original
2936            // bytes on replay.
2937            true,
2938            parse_error_lines,
2939        )
2940    }
2941
2942    /// The **export-document** read surface of [`Self::from_opencode_str`]
2943    /// — see that function's doc comment for the shared canonicalization
2944    /// and the `raw` re-synthesis this performs. `doc` is already known to
2945    /// have the `{info, messages:[...]}` shape (the caller checks this,
2946    /// matching `detect_source`'s own S9a check) before calling this.
2947    fn from_opencode_export_doc(doc: &Value) -> Result<Session> {
2948        let session_info = doc.get("info").cloned().filter(|v| !v.is_null());
2949        let messages_arr = doc
2950            .get("messages")
2951            .and_then(Value::as_array)
2952            .cloned()
2953            .unwrap_or_default();
2954
2955        let session_id = session_info
2956            .as_ref()
2957            .and_then(|si| si.get("id"))
2958            .and_then(Value::as_str)
2959            .unwrap_or("ses_unknown")
2960            .to_string();
2961        let project_id = session_info
2962            .as_ref()
2963            .and_then(|si| si.get("projectID"))
2964            .and_then(Value::as_str)
2965            .unwrap_or("global")
2966            .to_string();
2967
2968        // Re-synthesize one envelope line per record — see the doc comment
2969        // on `from_opencode_str` ("Export-document `raw`").
2970        let mut raw: Vec<String> = Vec::new();
2971        if let Some(si) = &session_info {
2972            raw.push(
2973                serde_json::json!({"key": ["session", project_id, session_id], "value": si})
2974                    .to_string(),
2975            );
2976        }
2977
2978        let mut msgs: Vec<OcMsg> = Vec::new();
2979        for entry in &messages_arr {
2980            let Some(info) = entry.get("info") else {
2981                continue; // malformed message entry — no clean home, raw-only
2982            };
2983            let Some(id) = info.get("id").and_then(Value::as_str) else {
2984                continue;
2985            };
2986            let time_created = info
2987                .get("time")
2988                .and_then(|t| t.get("created"))
2989                .and_then(Value::as_i64)
2990                .unwrap_or(0);
2991            let parts: Vec<Value> = entry
2992                .get("parts")
2993                .and_then(Value::as_array)
2994                .cloned()
2995                .unwrap_or_default();
2996
2997            raw.push(
2998                serde_json::json!({"key": ["message", session_id, id], "value": info}).to_string(),
2999            );
3000            for p in &parts {
3001                let part_id = p.get("id").and_then(Value::as_str).unwrap_or("");
3002                raw.push(serde_json::json!({"key": ["part", id, part_id], "value": p}).to_string());
3003            }
3004
3005            msgs.push(OcMsg {
3006                id: id.to_string(),
3007                time_created,
3008                value: info.clone(),
3009                parts,
3010            });
3011        }
3012
3013        opencode_guard_against_silent_empty(true, &session_info, &msgs, &[])?;
3014        opencode_session_from_records(
3015            session_info,
3016            Vec::new(),
3017            msgs,
3018            raw,
3019            // Synthesized `raw` (§1.2's "Export-document raw" — one envelope
3020            // line re-derived per record, no real per-line source bytes to
3021            // measure) — matches the historical always-newline-terminated
3022            // behavior; see `Session::raw_trailing_newline`'s doc comment.
3023            true,
3024            // Export-document form: `raw` above is RE-SYNTHESIZED, one
3025            // envelope line derived per record — not the original document's
3026            // bytes (see this function's doc comment). `convert`'s
3027            // byte-identical claim must not fire on this diagonal.
3028            false,
3029            // PARITY-15: a pretty-printed export document is parsed WHOLE
3030            // (the caller's `if let Ok(doc) = serde_json::from_str(...)`) —
3031            // there's no per-line parse-loss concept here; a malformed
3032            // document fails that top-level parse and never reaches this
3033            // function at all.
3034            0,
3035        )
3036    }
3037
3038    /// P5-5 (design §2 module 21 `session.tree`, §2.1 D-6 "session.tree →
3039    /// core.session(tree-addressable transcript)"): materialize this
3040    /// session's linear [`Self::messages`] into a native in-place
3041    /// [`crate::session_tree::SessionTree`] — the bridge a caller uses the
3042    /// FIRST time it wants to run a tree operation (rewind/branch/label)
3043    /// against an otherwise-linear [`Session`]. `created_at_ms` stamps every
3044    /// synthesized node (see
3045    /// [`crate::session_tree::SessionTree::from_linear`]'s doc comment for
3046    /// why a single timestamp is used: the source linear messages carry no
3047    /// per-turn timestamp of their own here).
3048    ///
3049    /// This does not mutate `self` or persist anything — see
3050    /// the composition layer's session-store tree writer for persistence, and
3051    /// [`Self::apply_session_tree`] for the inverse bridge.
3052    pub fn to_session_tree(&self, created_at_ms: i64) -> crate::session_tree::SessionTree {
3053        crate::session_tree::SessionTree::from_linear(&self.messages, created_at_ms)
3054    }
3055
3056    /// P5-5: the inverse of [`Self::to_session_tree`] — overwrite
3057    /// [`Self::messages`] with `tree`'s ACTIVE branch's linear projection
3058    /// (C7's "tree-with-linear-projection": this is exactly what keeps every
3059    /// existing linear consumer — the agent loop, exporters — working
3060    /// unchanged after a tree operation runs). Nothing else on `self`
3061    /// (`meta`, `raw`, ...) is touched.
3062    ///
3063    /// **Fail-closed.** Propagates [`crate::session_tree::SessionTree::linear_projection`]'s
3064    /// `Err` rather than applying anything — a structurally-corrupt tree
3065    /// (a cycle, a dangling leaf, an active branch pointing at nothing) must
3066    /// error, not silently overwrite [`Self::messages`] with an empty `Vec`.
3067    /// `self` is left untouched on `Err` (the assignment only happens after
3068    /// the projection has already succeeded).
3069    pub fn apply_session_tree(&mut self, tree: &crate::session_tree::SessionTree) -> Result<()> {
3070        self.messages = tree.linear_projection()?;
3071        Ok(())
3072    }
3073}
3074
3075/// One opencode `message` record plus its `part` children, gathered from
3076/// EITHER read surface (envelope-form records or export-document
3077/// `{info, parts}` entries) before the shared per-record canonicalization
3078/// in [`opencode_session_from_records`].
3079struct OcMsg {
3080    id: String,
3081    time_created: i64,
3082    value: Value,
3083    parts: Vec<Value>,
3084}
3085
3086const OPENCODE_SUPERCODE_MESSAGE_POSITION: &str = "_supercode_message_position";
3087const OPENCODE_SUPERCODE_RESULT_POSITION: &str = "_supercode_result_position";
3088const OPENCODE_INTERNAL_ORIGINAL_POSITION: &str = "__supercode_original_position";
3089
3090/// Guard against the confirmed footgun: input that reached
3091/// [`Session::from_opencode_str`] non-empty but produced no
3092/// session/message/part record under either read surface returns `Err`
3093/// instead of a silently-empty `Ok(Session)`. A legitimately-empty session
3094/// (a real session record with zero messages, or a valid empty `messages`
3095/// array) is not an error — only genuinely unparseable content is.
3096fn opencode_guard_against_silent_empty(
3097    non_empty_input: bool,
3098    session_info: &Option<Value>,
3099    msgs: &[OcMsg],
3100    side_records: &[Value],
3101) -> Result<()> {
3102    let has_any_record = session_info.as_ref().is_some_and(|v| !v.is_null())
3103        || !msgs.is_empty()
3104        || !side_records.is_empty();
3105    if non_empty_input && !has_any_record {
3106        return Err(crate::Error::Other(
3107            "opencode input was recognized as an OpenCode source (envelope or \
3108             export-document form) but no session/message/part record could be parsed from \
3109             it — refusing to silently return an empty session"
3110                .to_string(),
3111        ));
3112    }
3113    Ok(())
3114}
3115
3116/// The shared per-record canonicalization for BOTH of
3117/// [`Session::from_opencode_str`]'s read surfaces (envelope form and
3118/// export-document form): frozen ordering, `SessionMeta` capture, the
3119/// compaction boundary pass, and the `User`/`Assistant` → `messages`
3120/// mapping via [`push_opencode_user`]/[`push_opencode_assistant`]. Fed the
3121/// same underlying `(session_info, side_records, msgs)` regardless of which
3122/// surface produced them, this produces byte-for-byte identical `messages`
3123/// — the invariant that makes `load(to_opencode_jsonl(S))` round-trip.
3124fn opencode_session_from_records(
3125    session_info: Option<Value>,
3126    side_records: Vec<Value>,
3127    mut msgs: Vec<OcMsg>,
3128    raw: Vec<String>,
3129    raw_trailing_newline: bool,
3130    raw_is_verbatim: bool,
3131    parse_error_lines: usize,
3132) -> Result<Session> {
3133    let mut meta = SessionMeta::new(SessionSource::OpenCode);
3134
3135    // `msg_index` is captured BEFORE the frozen-order sort below, mapping
3136    // each message id to its PRE-sort position — used only to resolve a
3137    // `tail_start_id` reference in the compaction-boundary pass further
3138    // down. In every real opencode session (either surface) records
3139    // already arrive/are listed in creation order, so pre- and post-sort
3140    // positions coincide; this mirrors the original envelope-only
3141    // implementation's behavior exactly (not a new invariant introduced by
3142    // sharing this code across both surfaces).
3143    let msg_index: HashMap<String, usize> = msgs
3144        .iter()
3145        .enumerate()
3146        .map(|(i, m)| (m.id.clone(), i))
3147        .collect();
3148
3149    // Frozen order (§1.2): messages by (time.created, id); each
3150    // message's parts by id.
3151    msgs.sort_by(|a, b| {
3152        a.time_created
3153            .cmp(&b.time_created)
3154            .then_with(|| a.id.cmp(&b.id))
3155    });
3156    for m in &mut msgs {
3157        m.parts.sort_by(|a, b| {
3158            let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
3159            let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
3160            ai.cmp(bi)
3161        });
3162    }
3163
3164    meta.opencode_headers
3165        .push(session_info.clone().unwrap_or(Value::Null));
3166    meta.opencode_headers.extend(side_records);
3167    if let Some(si) = &session_info {
3168        capture_opencode_session_info(si, &mut meta)?;
3169    }
3170
3171    // Compaction boundary (§2.1/§2.2 S3): the LATEST `tail_start_id`
3172    // seen — mirrors pi's `kept_from_pos` discipline (there is only one
3173    // active path in opencode's own linear message list, so no branch
3174    // walk is needed the way pi's tree requires).
3175    let mut tail_start_pos: Option<usize> = None;
3176    for m in &msgs {
3177        for p in &m.parts {
3178            if p.get("type").and_then(Value::as_str) == Some("compaction") {
3179                if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
3180                    if let Some(&tp) = msg_index.get(t) {
3181                        tail_start_pos = Some(tail_start_pos.map_or(tp, |cur| cur.max(tp)));
3182                    }
3183                }
3184            }
3185        }
3186    }
3187
3188    let mut messages = Vec::new();
3189    let mut first_system_seen = false;
3190    for (pos, m) in msgs.iter().enumerate() {
3191        let before = messages.len();
3192        match m.value.get("role").and_then(Value::as_str) {
3193            // B4: a `User` message that's actually
3194            // `append_synthesized_opencode_messages`'s own re-materialized
3195            // Claude `system` record (one `synthetic: true` text part
3196            // carrying the supercode marker key — see
3197            // `opencode_claude_system_subtype`'s doc comment) restores
3198            // `Role::System`, not a genuine user turn.
3199            Some("user") => match opencode_claude_system_subtype(&m.parts) {
3200                Some(subtype) => {
3201                    push_opencode_claude_system(&m.value, &m.parts, subtype, &mut messages)
3202                }
3203                None => push_opencode_user(
3204                    &m.value,
3205                    &m.parts,
3206                    &mut messages,
3207                    &mut meta,
3208                    &mut first_system_seen,
3209                ),
3210            },
3211            Some("assistant") => {
3212                push_opencode_assistant(&m.value, &m.parts, &mut messages, &mut meta)
3213            }
3214            // Unrecognized/missing role — raw-only survival;
3215            // `audit::Corpus::OpenCode` scores this as Unmodeled.
3216            _ => {}
3217        }
3218        if let Some(original_position) = m
3219            .value
3220            .get(OPENCODE_SUPERCODE_MESSAGE_POSITION)
3221            .and_then(Value::as_u64)
3222        {
3223            if let Some(message) = messages[before..]
3224                .iter_mut()
3225                .find(|message| message.role != Role::Tool)
3226            {
3227                message.metadata.insert(
3228                    OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
3229                    original_position.to_string(),
3230                );
3231            }
3232        }
3233        for msg in &mut messages[before..] {
3234            let is_summary = msg.metadata.get("is_summary").map(String::as_str) == Some("true");
3235            if !is_summary {
3236                if let Some(tsp) = tail_start_pos {
3237                    if pos < tsp {
3238                        msg.metadata
3239                            .insert("compacted_out".to_string(), "true".to_string());
3240                    }
3241                }
3242            }
3243        }
3244    }
3245
3246    let marked_slots = messages
3247        .iter()
3248        .enumerate()
3249        .filter_map(|(index, message)| {
3250            message
3251                .metadata
3252                .contains_key(OPENCODE_INTERNAL_ORIGINAL_POSITION)
3253                .then_some(index)
3254        })
3255        .collect::<Vec<_>>();
3256    if !marked_slots.is_empty() {
3257        // A spliced OpenCode export can contain an unmarked native prefix
3258        // followed by a marked synthesized tail. Reorder only among the
3259        // marked slots so the tail never jumps in front of its raw prefix.
3260        let mut marked_messages = marked_slots
3261            .iter()
3262            .map(|index| messages[*index].clone())
3263            .collect::<Vec<_>>();
3264        marked_messages.sort_by_key(|message| {
3265            message
3266                .metadata
3267                .get(OPENCODE_INTERNAL_ORIGINAL_POSITION)
3268                .and_then(|position| position.parse::<usize>().ok())
3269                .unwrap_or(usize::MAX)
3270        });
3271        for (slot, message) in marked_slots.into_iter().zip(marked_messages) {
3272            messages[slot] = message;
3273        }
3274        for message in &mut messages {
3275            message.metadata.remove(OPENCODE_INTERNAL_ORIGINAL_POSITION);
3276        }
3277    }
3278    ensure_tool_results_paired(&mut messages);
3279    let imported_message_count = Some(messages.len());
3280    Ok(Session {
3281        meta,
3282        messages,
3283        subagents: Vec::new(),
3284        raw,
3285        raw_trailing_newline,
3286        imported_message_count,
3287        raw_is_verbatim,
3288        parse_error_lines,
3289        load_residue: Vec::new(),
3290    })
3291}
3292
3293/// Resolve each opencode subagent (`task`) child session's
3294/// `meta.parent_tool_use_id` from its parent's own `task` tool part
3295/// `callID` (`docs/interop/opencode-pi-spec.md` §2.1 — the analogue of
3296/// Claude Code's `agentId`/`parent_tool_use_id` linkage,
3297/// `opencode-fields.md` `task.ts:145,171-176`).
3298///
3299/// Nesting itself needs no opencode-specific pass:
3300/// `capture_opencode_session_info` already mirrors `SessionInfo.parentID`
3301/// into `lineage["parent_thread_id"]` (the same key Codex subagents use),
3302/// so the existing generic [`Session::reconstruct_tree`] nests these
3303/// sessions correctly on its own. Call this FIRST — it only reads
3304/// `session_id`/`messages`/metadata, never reorders `sessions` — then feed
3305/// the same `Vec` to `reconstruct_tree`.
3306pub fn resolve_opencode_parent_tool_use_ids(sessions: &mut [Session]) {
3307    let ids: Vec<Option<String>> = sessions.iter().map(|s| s.meta.session_id.clone()).collect();
3308    for i in 0..sessions.len() {
3309        let child_id = sessions[i].meta.session_id.clone();
3310        let parent_id = sessions[i].meta.lineage.get("parent_session_id").cloned();
3311        let (Some(child_id), Some(parent_id)) = (child_id, parent_id) else {
3312            continue;
3313        };
3314        let Some(parent_idx) = ids
3315            .iter()
3316            .position(|id| id.as_deref() == Some(parent_id.as_str()))
3317        else {
3318            continue;
3319        };
3320        for m in &sessions[parent_idx].messages {
3321            for (k, v) in &m.metadata {
3322                if let Some(call_id) = k.strip_prefix("oc_task_child_session_id__") {
3323                    if v == &child_id {
3324                        sessions[i].meta.parent_tool_use_id = Some(call_id.to_string());
3325                    }
3326                }
3327            }
3328        }
3329    }
3330}
3331
3332/// D7: repair tool-result/tool-call ORDERING for the Claude Code loader.
3333///
3334/// Real Claude Code transcripts are well-formed by `uuid`/`parentUuid` tree
3335/// structure but are NOT guaranteed to be well-formed in raw file order: async
3336/// tool-result delivery (or an edited turn) can write a `tool_result` `user`
3337/// line BEFORE the assistant `tool_use` line that owns it, even though the
3338/// parent/child tree itself is fine. The active-branch projection restores
3339/// parent-before-child order, but a result can still trail a later assistant
3340/// turn instead of sitting next to the assistant that owns it. Both OpenAI-
3341/// and Anthropic-shaped wire APIs reject either shape on `resume`. Measured
3342/// on a real corpus: 21/450 sessions (4.7%), 89 inverted pairs.
3343///
3344/// This reorders `messages` so every OWNED `Role::Tool` result (its
3345/// `tool_call_id` matches a `tool_calls` entry on some `Role::Assistant`
3346/// message anywhere in the list) sits immediately after the `Role::Assistant`
3347/// message that owns it, while leaving every other message's relative order
3348/// untouched. Orphan tool results — no matching call anywhere in the list —
3349/// are left in their ORIGINAL position, untouched; they are never moved. It
3350/// is a pure reorder: same message count, same multiset of messages, in/out.
3351///
3352/// Tool-call ids (`toolu_*`) are globally unique within a Claude Code session
3353/// — each appears exactly once as a call and once as its result — so a
3354/// simple id -> owning-assistant map is sufficient; no special-casing is
3355/// needed for sidechain (`isSidechain`) messages, since `push_claude_*`
3356/// already pushes those inline with their own distinct ids.
3357///
3358/// Results whose matching call is missing entirely (no owner found) are left
3359/// in place untouched — `ensure_tool_results_paired` (which runs right after
3360/// this) is responsible for synthesizing a placeholder result for any call
3361/// that ends up unanswered; this pass never drops or fabricates anything.
3362fn reorder_tool_results_after_calls(messages: &mut Vec<ChatMessage>) {
3363    // 0. First pass: which tool_call_ids are actually "owned" — emitted by
3364    //    some assistant message anywhere in the list — and the position of
3365    //    that owning assistant. Owned as `String` (not borrowed) so this map
3366    //    can outlive the later `messages.drain(..)`.
3367    let mut owner_positions: HashMap<String, usize> = HashMap::new();
3368    for (index, m) in messages.iter().enumerate() {
3369        if m.role == Role::Assistant {
3370            for c in m.tool_calls() {
3371                if !c.id.is_empty() {
3372                    owner_positions.entry(c.id.clone()).or_insert(index);
3373                }
3374            }
3375        }
3376    }
3377
3378    // Fast, cheap detection of "nothing to do": every owned result must be
3379    // in the contiguous tool-result block immediately following its owning
3380    // assistant. Checking only result-before-owner inversions is insufficient
3381    // after Claude's active-branch projection: that projection can put the
3382    // owner first while leaving its result behind a later assistant turn.
3383    // Mere orphans never set this flag. A canonical session returns with
3384    // `messages` byte-for-byte unchanged, mirroring
3385    // `ensure_tool_results_paired`'s own no-op guard.
3386    let mut contiguous_owner = None;
3387    let needs_reorder =
3388        messages
3389            .iter()
3390            .enumerate()
3391            .any(|(message_index, message)| match message.role {
3392                Role::Assistant => {
3393                    contiguous_owner = Some(message_index);
3394                    false
3395                }
3396                Role::Tool => match message
3397                    .tool_call_id
3398                    .as_deref()
3399                    .and_then(|id| owner_positions.get(id))
3400                    .copied()
3401                {
3402                    Some(owner) => Some(owner) != contiguous_owner,
3403                    None => {
3404                        // An orphan or unlinked tool message interrupts the
3405                        // owner's contiguous result block but never moves by
3406                        // itself.
3407                        contiguous_owner = None;
3408                        false
3409                    }
3410                },
3411                _ => {
3412                    contiguous_owner = None;
3413                    false
3414                }
3415            });
3416    if !needs_reorder {
3417        return;
3418    }
3419
3420    // 1. Second pass: route messages into the "spine" (everything that stays
3421    //    at its own position — non-tool messages AND orphan tool results)
3422    //    versus owned tool results (pulled out, to be reattached right after
3423    //    their owner). Record, for each spine index that's an assistant, the
3424    //    set of tool_call_ids it owns.
3425    let mut spine: Vec<ChatMessage> = Vec::with_capacity(messages.len());
3426    let mut call_owner: HashMap<String, usize> = HashMap::new();
3427    // Buffer of (original_position, message) for every OWNED tool result,
3428    // built alongside the spine; a result can reference a call emitted later
3429    // in file order, so owner spine-index is resolved in a later step once
3430    // `call_owner` is complete.
3431    let mut owned_results: Vec<(usize, ChatMessage)> = Vec::new();
3432
3433    let drained: Vec<ChatMessage> = std::mem::take(messages);
3434    for (orig_pos, msg) in drained.into_iter().enumerate() {
3435        if msg.role == Role::Tool {
3436            let is_owned = msg
3437                .tool_call_id
3438                .as_deref()
3439                .map(|id| !id.is_empty() && owner_positions.contains_key(id))
3440                .unwrap_or(false);
3441            if is_owned {
3442                owned_results.push((orig_pos, msg));
3443                continue;
3444            }
3445            // Orphan: no matching call anywhere. Treat exactly like a
3446            // non-tool message for placement — it joins the spine at its
3447            // current position and is never moved.
3448            spine.push(msg);
3449            continue;
3450        }
3451        if msg.role == Role::Assistant {
3452            let spine_idx = spine.len();
3453            for c in msg.tool_calls() {
3454                if !c.id.is_empty() {
3455                    call_owner.entry(c.id.clone()).or_insert(spine_idx);
3456                }
3457            }
3458        }
3459        spine.push(msg);
3460    }
3461
3462    // 2. Resolve each owned result's owner spine-index now that `call_owner`
3463    //    is complete, then bucket results by owner spine-index. Every result
3464    //    here was routed as "owned" because its id was found in `owned_ids`,
3465    //    which was built from the exact same `tool_calls()` scan that
3466    //    populates `call_owner` below, so the lookup is guaranteed to hit.
3467    let mut buckets: HashMap<usize, Vec<(usize, ChatMessage)>> = HashMap::new();
3468    for (orig_pos, msg) in owned_results.into_iter() {
3469        let id = msg
3470            .tool_call_id
3471            .as_deref()
3472            .filter(|id| !id.is_empty())
3473            .expect("routed as owned, so tool_call_id must be a non-empty owned id");
3474        let idx = *call_owner
3475            .get(id)
3476            .expect("owned id must have an owning assistant in call_owner");
3477        buckets.entry(idx).or_default().push((orig_pos, msg));
3478    }
3479    // Keep each bucket's results in their original relative file order.
3480    for v in buckets.values_mut() {
3481        v.sort_by_key(|(pos, _)| *pos);
3482    }
3483
3484    // 3. Rebuild: emit each spine message (which now includes orphans at
3485    //    their original position, untouched) in order; immediately after
3486    //    emitting an assistant message that owns one or more tool results,
3487    //    emit its owned results, in original relative order.
3488    let mut out: Vec<ChatMessage> = Vec::with_capacity(spine.len() + buckets.len());
3489    for (idx, msg) in spine.into_iter().enumerate() {
3490        out.push(msg);
3491        if let Some(results) = buckets.remove(&idx) {
3492            for (_, r) in results {
3493                out.push(r);
3494            }
3495        }
3496    }
3497    *messages = out;
3498}
3499
3500/// Guarantee every assistant `tool_calls` entry is answered by a following tool
3501/// result. Interrupted/aborted turns leave a tool call with no result, which
3502/// many chat-completions endpoints reject when the conversation is replayed.
3503/// We insert a synthetic placeholder result immediately after the assistant
3504/// turn so the transcript stays valid for continuation. (Orphan results — a
3505/// tool message with no preceding call — do not occur in practice and are left
3506/// untouched.)
3507fn ensure_tool_results_paired(messages: &mut Vec<ChatMessage>) {
3508    let answered: HashSet<String> = messages
3509        .iter()
3510        .filter(|m| m.role == Role::Tool)
3511        .filter_map(|m| m.tool_call_id.clone())
3512        .collect();
3513
3514    // Nothing missing? Leave the vector byte-for-byte unchanged.
3515    let any_missing = messages.iter().any(|m| {
3516        m.tool_calls()
3517            .iter()
3518            .any(|c| !c.id.is_empty() && !answered.contains(&c.id))
3519    });
3520    if !any_missing {
3521        return;
3522    }
3523
3524    let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len() + 4);
3525    for msg in messages.drain(..) {
3526        let synth: Vec<ChatMessage> = msg
3527            .tool_calls()
3528            .iter()
3529            .filter(|c| !c.id.is_empty() && !answered.contains(&c.id))
3530            .map(|c| {
3531                let mut m = ChatMessage::tool_result(
3532                    c.id.clone(),
3533                    c.function.name.clone(),
3534                    "[no tool result recorded — turn interrupted]".to_string(),
3535                );
3536                // TR-10: an interrupted call never executed to completion —
3537                // never a candidate for `ReductionKind::ToolInputElided`
3538                // (the "still-pending calls are never input-elided"
3539                // boundary).
3540                crate::mark_tool_error(&mut m);
3541                m
3542            })
3543            .collect();
3544        out.push(msg);
3545        out.extend(synth);
3546    }
3547    *messages = out;
3548}
3549
3550/// Whether `msg` is excluded from every replay/export path — the frozen
3551/// cross-format discipline (`docs/interop/opencode-pi-spec.md` §2.1/§2.2/§6):
3552/// a message marked `compacted_out` (pre-compaction history a source harness
3553/// itself no longer replays) or `pi_exclude_from_context` (a pi `!!` bash
3554/// escape) must never reach `to_jsonl`/`to_jsonl_spliced` for ANY target
3555/// format, not just the one that produced the marker — so a translated
3556/// compacted session replays the same sliced context the source harness
3557/// would, instead of double-including history plus its own summary.
3558fn is_replay_excluded(msg: &ChatMessage) -> bool {
3559    msg.metadata.get("compacted_out").map(String::as_str) == Some("true")
3560        || msg
3561            .metadata
3562            .get("pi_exclude_from_context")
3563            .map(String::as_str)
3564            == Some("true")
3565}
3566
3567// ---- detection ------------------------------------------------------------
3568
3569fn detect_source(text: &str) -> Option<SessionSource> {
3570    // OpenCode export-document form (`docs/interop/opencode-pi-spec.md`
3571    // §1.2/S9a): `{info: SessionInfo, messages: [...]}` — a single
3572    // pretty-printed, MULTI-LINE JSON document, unlike every other format
3573    // this crate reads. It cannot be recognized by the per-line loop below
3574    // (no individual line of a pretty-printed document is itself valid
3575    // JSON), so it gets its own whole-text parse attempt up front. Cheap to
3576    // attempt: a real JSONL file (many newline-separated objects) fails this
3577    // parse immediately (trailing-data error) and falls through unaffected.
3578    if let Ok(v) = serde_json::from_str::<Value>(text.trim()) {
3579        if v.get("conversation").and_then(Value::as_array).is_some()
3580            && (v.get("working_dir").is_some() || v.get("workingDir").is_some())
3581        {
3582            return Some(SessionSource::Goose);
3583        }
3584        if v.get("info").is_some() && v.get("messages").and_then(Value::as_array).is_some() {
3585            return Some(SessionSource::OpenCode);
3586        }
3587    }
3588    for line in non_empty_lines(text) {
3589        // Tolerate a corrupt/truncated line (e.g. a partial first line) rather
3590        // than abandoning detection — the loaders themselves skip bad lines, so
3591        // bailing here would silently misroute an otherwise-valid Codex file.
3592        let Ok(v) = serde_json::from_str::<Value>(line) else {
3593            continue;
3594        };
3595        // OpenCode's frozen envelope form (§1.2): `{"key":[...],"value":...}`,
3596        // one record per line — the synthesized raw-capture unit for the
3597        // JSON-tree/SQLite generations alike. No other format's lines carry
3598        // both a top-level `key` ARRAY and a `value` field, so this is
3599        // unambiguous against Codex/Pi/Claude Code.
3600        if v.get("key").and_then(Value::as_array).is_some() && v.get("value").is_some() {
3601            return Some(SessionSource::OpenCode);
3602        }
3603        // Codex envelopes always carry a `payload`; Claude Code lines never do.
3604        if v.get("payload").is_some() {
3605            return Some(SessionSource::Codex);
3606        }
3607        // Gemini CLI starts with an untyped session header. Its project hash
3608        // and timestamps distinguish it from Claude Code records that also
3609        // carry `sessionId`.
3610        if v.get("sessionId").and_then(Value::as_str).is_some()
3611            && (v.get("projectHash").is_some()
3612                || v.get("startTime").is_some()
3613                || v.get("lastUpdated").is_some())
3614            && v.get("type").is_none()
3615        {
3616            return Some(SessionSource::Gemini);
3617        }
3618        // Grok's resumable `chat_history.jsonl` stores the role/type and
3619        // content directly on each record. Claude Code uses a nested
3620        // `message` envelope for the overlapping `user`/`assistant` tags.
3621        let tag = v.get("type").and_then(Value::as_str);
3622        if tag == Some("gemini") && v.get("content").is_some() {
3623            return Some(SessionSource::Gemini);
3624        }
3625        if v.get("message").is_none()
3626            && v.get("uuid").is_none()
3627            && v.get("sessionId").is_none()
3628            && matches!(
3629                tag,
3630                Some(
3631                    "system"
3632                        | "user"
3633                        | "assistant"
3634                        | "tool_result"
3635                        | "reasoning"
3636                        | "backend_tool_call"
3637                )
3638            )
3639            && (v.get("content").is_some()
3640                || v.get("tool_calls").is_some()
3641                || v.get("tool_call_id").is_some()
3642                || v.get("encrypted_content").is_some()
3643                || v.get("kind").is_some())
3644        {
3645            return Some(SessionSource::Grok);
3646        }
3647        // Pi's `SessionHeader` (line 1, always `type:"session"`): a bare `id`
3648        // (the session id) with no `message`/`uuid` — Claude Code's own
3649        // `type`-bearing lines always carry one or the other, never a
3650        // `type:"session"` header shape (`docs/interop/research/pi-fields.md`
3651        // §1).
3652        if v.get("type").and_then(Value::as_str) == Some("session")
3653            && v.get("id").and_then(Value::as_str).is_some()
3654            && v.get("message").is_none()
3655            && v.get("uuid").is_none()
3656        {
3657            return Some(SessionSource::Pi);
3658        }
3659        if v.get("type").is_some() || v.get("message").is_some() {
3660            return Some(SessionSource::ClaudeCode);
3661        }
3662    }
3663    None
3664}
3665
3666/// Which on-disk OpenCode storage surface is present under a data root
3667/// (`docs/interop/opencode-pi-spec.md` §1.2/S9a): SQLite `opencode*.db` (or
3668/// `$OPENCODE_DB`), legacy JSON tree generation B, or legacy JSON tree
3669/// generation A. This is a **filesystem classifier only** — it answers
3670/// "which generation is this?" for a corpus-discovery tool; it does not
3671/// itself read/parse the surface. See [`Session::from_opencode_str`]'s docs
3672/// for the envelope form any of these three surfaces synthesizes into, and
3673/// [`Session::from_opencode_sqlite`] (PARITY-3/PARITY-16) for the `rusqlite`
3674/// reader that reconstructs that same envelope form from `Sqlite`'s rows —
3675/// `JsonTreeA`/`JsonTreeB` remain classifier-only (their `session_diff`
3676/// round-trips via the JSON store per upstream's own behavior even on a
3677/// SQLite install, so nothing is silently lost by not reading the legacy
3678/// trees directly).
3679#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3680pub enum OpenCodeStorageSurface {
3681    /// SQLite `<data>/opencode*.db` (or `$OPENCODE_DB` override). Read by
3682    /// [`Session::from_opencode_sqlite`] / [`opencode_sqlite_store_stats`] /
3683    /// [`opencode_sqlite_corpus_envelope_text`].
3684    Sqlite,
3685    /// Legacy JSON tree, generation B: `<data>/storage/{session,message,part,session_diff}/…`,
3686    /// marker file `storage/migration`.
3687    JsonTreeB,
3688    /// Legacy JSON tree, generation A: `<data>/project/<slug>/storage/session/{info,message,part}/…`.
3689    JsonTreeA,
3690}
3691
3692/// Probe `data_root` (e.g. `~/.local/share/opencode`) for the OpenCode
3693/// storage surface present, per the discovery rules frozen in
3694/// `docs/interop/opencode-pi-spec.md` §1.2/S9a: `$OPENCODE_DB` wins outright;
3695/// otherwise glob `opencode*.db` (not just `opencode.db` — dev/beta channels
3696/// suffix the filename, `database.ts:43-55`); otherwise look for the JSON
3697/// tree generation-B marker (`storage/migration`); otherwise generation-A's
3698/// `project/` subtree. Returns `None` if nothing is found.
3699pub fn detect_opencode_storage_surface(
3700    data_root: &Path,
3701) -> Option<(OpenCodeStorageSurface, PathBuf)> {
3702    if let Ok(p) = std::env::var("OPENCODE_DB") {
3703        let pb = PathBuf::from(p);
3704        if pb.is_file() {
3705            return Some((OpenCodeStorageSurface::Sqlite, pb));
3706        }
3707    }
3708    if let Ok(entries) = std::fs::read_dir(data_root) {
3709        // D8: `std::fs::read_dir`'s iteration order is filesystem-dependent,
3710        // NOT deterministic — a store with both a default-channel
3711        // `opencode.db` and a channel-suffixed `opencode-dev.db` (S9a: both
3712        // are legal, e.g. after switching install channels) previously
3713        // returned "whichever the OS happened to list first", which could
3714        // differ between two `inspect`/`audit`/`convert` runs against the
3715        // exact same directory. Collect every `opencode*.db` candidate and
3716        // pick deterministically: the exact `opencode.db` name wins if
3717        // present (the default/most-common channel); otherwise the
3718        // lexicographically-smallest match, so repeated runs always agree.
3719        let mut candidates: Vec<PathBuf> = entries
3720            .flatten()
3721            .map(|entry| entry.path())
3722            .filter(|p| {
3723                p.file_name()
3724                    .and_then(|n| n.to_str())
3725                    .is_some_and(|name| name.starts_with("opencode") && name.ends_with(".db"))
3726            })
3727            .collect();
3728        candidates.sort();
3729        if let Some(exact) = candidates
3730            .iter()
3731            .find(|p| p.file_name().and_then(|n| n.to_str()) == Some("opencode.db"))
3732        {
3733            return Some((OpenCodeStorageSurface::Sqlite, exact.clone()));
3734        }
3735        if let Some(first) = candidates.into_iter().next() {
3736            return Some((OpenCodeStorageSurface::Sqlite, first));
3737        }
3738    }
3739    let storage = data_root.join("storage");
3740    if storage.join("migration").is_file() {
3741        return Some((OpenCodeStorageSurface::JsonTreeB, storage));
3742    }
3743    let project_dir = data_root.join("project");
3744    if project_dir.is_dir() {
3745        return Some((OpenCodeStorageSurface::JsonTreeA, project_dir));
3746    }
3747    None
3748}
3749
3750/// STRICT-VERBATIM line split for `raw` capture (IX-1) — the counterpart to
3751/// [`non_empty_lines`]'s blank-skipping/trimming PARSE view.
3752///
3753/// Splits only on `\n` (never treats `\r\n` specially), so a CRLF source's
3754/// `\r` survives as part of the returned line's own content; blank lines and
3755/// trailing-whitespace-only lines are kept verbatim rather than dropped or
3756/// trimmed. This is what makes `Session.raw` — populated from this at every
3757/// raw-capture site — byte-for-byte faithful to the source for ANY input, not
3758/// just well-formed LF JSONL with no blank lines.
3759///
3760/// Returns `(lines, ends_with_newline)`. `lines.join("\n")` alone cannot
3761/// distinguish a source that ended with a trailing newline from one that
3762/// didn't (both split into the same line list), so `ends_with_newline`
3763/// records that fact out-of-band — [`join_lines_verbatim`] is the exact
3764/// inverse, given both. `text == ""` yields `(vec![], false)`: an empty
3765/// source has zero lines, not one blank line.
3766fn split_lines_verbatim(text: &str) -> (Vec<&str>, bool) {
3767    if text.is_empty() {
3768        return (Vec::new(), false);
3769    }
3770    let ends_with_newline = text.ends_with('\n');
3771    let body = if ends_with_newline {
3772        &text[..text.len() - 1]
3773    } else {
3774        text
3775    };
3776    (body.split('\n').collect(), ends_with_newline)
3777}
3778
3779/// The exact inverse of [`split_lines_verbatim`]: reconstruct the original
3780/// source bytes from its verbatim lines plus the trailing-newline flag.
3781fn join_lines_verbatim(lines: &[String], ends_with_newline: bool) -> String {
3782    let mut out = lines.join("\n");
3783    if ends_with_newline {
3784        out.push('\n');
3785    }
3786    out
3787}
3788
3789// ---- OpenCode SQLite (PARITY-3/PARITY-4/PARITY-16) -------------------------
3790//
3791// Reads a real `opencode*.db` store directly with `rusqlite` (bundled
3792// SQLite — no system library dependency) and reconstructs the SAME envelope
3793// form `{"key":[...],"value":...}` that `Session::from_opencode_str` already
3794// parses for the JSON-tree surfaces (`docs/interop/opencode-pi-spec.md`
3795// §1.2/S9c). Row -> envelope reconstruction mirrors the real app's own
3796// `session.ts` `fromRow` (session table: columnar fields recombined into the
3797// camelCase `SessionInfo` shape) with ONE deliberate divergence: `revert` is
3798// carried as the RAW column value, not upstream's own `fromRow`
3799// reconstruction — which silently drops the V2 `Revert.State` schema's extra
3800// `files` field (`session.ts:70-76`, `revert.ts:18-24`, spec finding S9c).
3801// `message`/`part` rows are simpler: their `data` column is already the V1
3802// `Info`/`Part` JSON minus the id columns hoisted out by the schema
3803// (`session/sql.ts:68-98`), so reconstruction is just re-injecting
3804// `id`/`sessionID`(/`messageID`).
3805
3806/// First 16 bytes of every SQLite database file — the format's own magic,
3807/// independent of file extension.
3808const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0";
3809
3810/// Whether `path` should be routed to the OpenCode SQLite loader instead of
3811/// the UTF-8 text path (PARITY-16): true when the file's first 16 bytes are
3812/// the SQLite magic, OR its extension is `.db` — the latter so a
3813/// corrupted/truncated `opencode*.db` still gets `rusqlite`'s own "not a
3814/// database" diagnostic (PARITY-3 AC03) instead of a confusing UTF-8 error.
3815/// A non-existent path is NOT considered SQLite here — the missing-file
3816/// diagnostic in that case comes from the normal load path (`with_context`
3817/// at the CLI call sites), which already names the path clearly.
3818pub fn looks_like_sqlite(path: &Path) -> bool {
3819    if !path.is_file() {
3820        return false;
3821    }
3822    if path.extension().and_then(|e| e.to_str()) == Some("db") {
3823        return true;
3824    }
3825    use std::io::Read;
3826    let Ok(mut f) = std::fs::File::open(path) else {
3827        return false;
3828    };
3829    let mut buf = [0u8; 16];
3830    f.read_exact(&mut buf).is_ok() && &buf == SQLITE_MAGIC
3831}
3832
3833/// Read `path` as UTF-8 text, translating a non-UTF-8 failure into a clear,
3834/// format-aware diagnostic (PARITY-16) instead of the raw "stream did not
3835/// contain valid UTF-8" `io::Error` — named path, and what supercode DOES
3836/// accept there. Binary SQLite input never reaches this function: callers
3837/// check [`looks_like_sqlite`] first and route to
3838/// [`Session::from_opencode_sqlite`] instead.
3839fn read_utf8_or_diagnose(path: &Path) -> Result<String> {
3840    let bytes = std::fs::read(path)?;
3841    String::from_utf8(bytes).map_err(|_| {
3842        crate::Error::Other(format!(
3843            "{} is not valid UTF-8 text, and is not a recognized OpenCode SQLite store \
3844             (no `SQLite format 3` header) — supercode reads Claude Code / Codex / Pi / \
3845             OpenCode session logs as UTF-8 JSONL, or an OpenCode `opencode*.db` SQLite file",
3846            path.display()
3847        ))
3848    })
3849}
3850
3851/// Read only the portion of a JSONL transcript a bounded scrollback can use.
3852///
3853/// The first record carries durable session metadata (especially for Codex),
3854/// while the trailing window carries the messages the viewport will render.
3855/// Full lossless loaders intentionally continue to read every byte.
3856fn read_display_jsonl(
3857    path: &Path,
3858    message_limit: usize,
3859) -> Result<(Option<SessionSource>, String, bool)> {
3860    const MIN_TAIL_BYTES: u64 = 4 * 1024 * 1024;
3861    const MAX_TAIL_BYTES: u64 = 64 * 1024 * 1024;
3862    const BYTES_PER_MESSAGE: u64 = 16 * 1024;
3863
3864    let mut first = String::new();
3865    BufReader::new(std::fs::File::open(path)?).read_line(&mut first)?;
3866    let source = detect_source(&first);
3867    if !matches!(
3868        source,
3869        Some(SessionSource::ClaudeCode | SessionSource::Codex | SessionSource::Gemini)
3870    ) {
3871        let text = read_utf8_or_diagnose(path)?;
3872        return Ok((detect_source(&text), text, false));
3873    }
3874
3875    let mut file = std::fs::File::open(path)?;
3876    let file_len = file.metadata()?.len();
3877    let requested = (message_limit.max(1) as u64)
3878        .saturating_mul(BYTES_PER_MESSAGE)
3879        .clamp(MIN_TAIL_BYTES, MAX_TAIL_BYTES);
3880    if file_len <= requested {
3881        let text = read_utf8_or_diagnose(path)?;
3882        return Ok((source, text, false));
3883    }
3884
3885    let start = file_len - requested;
3886    file.seek(SeekFrom::Start(start))?;
3887    let mut bytes = Vec::with_capacity(requested as usize);
3888    file.read_to_end(&mut bytes)?;
3889    // The window normally starts in the middle of a JSON record. Discard that
3890    // partial prefix so every line passed to the existing parsers is valid.
3891    if let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') {
3892        bytes.drain(..=newline);
3893    }
3894    let mut tail = String::from_utf8(bytes).map_err(|_| {
3895        crate::Error::Other(format!(
3896            "{} contains non-UTF-8 data in its display window",
3897            path.display()
3898        ))
3899    })?;
3900    if !tail
3901        .lines()
3902        .any(|line| native_display_human_line(line, source))
3903    {
3904        // A single tool-heavy turn can exceed the ordinary byte window. Search backward through a
3905        // separately bounded native slice for only its nearest human record, then prepend that one
3906        // line to the cheap tail. The skipped megabytes are never normalized or sent over RPC.
3907        let search_bytes = file_len.min(requested.saturating_mul(2).min(MAX_TAIL_BYTES));
3908        let search_start = file_len - search_bytes;
3909        file.seek(SeekFrom::Start(search_start))?;
3910        let mut search = Vec::with_capacity(search_bytes as usize);
3911        file.read_to_end(&mut search)?;
3912        if search_start > 0 {
3913            if let Some(newline) = search.iter().position(|byte| *byte == b'\n') {
3914                search.drain(..=newline);
3915            }
3916        }
3917        if let Ok(search) = std::str::from_utf8(&search) {
3918            if let Some(anchor) = search
3919                .lines()
3920                .rev()
3921                .find(|line| native_display_human_line(line, source))
3922            {
3923                tail = format!("{anchor}\n{tail}");
3924            }
3925        }
3926    }
3927    let text = if matches!(source, Some(SessionSource::Codex | SessionSource::Gemini)) {
3928        format!("{first}{tail}")
3929    } else {
3930        tail
3931    };
3932    Ok((source, text, true))
3933}
3934
3935fn native_display_human_line(line: &str, source: Option<SessionSource>) -> bool {
3936    if !line
3937        .as_bytes()
3938        .windows(6)
3939        .any(|window| window == b"\"user\"")
3940    {
3941        return false;
3942    }
3943    let Ok(value) = serde_json::from_str::<Value>(line) else {
3944        return false;
3945    };
3946    match source {
3947        Some(SessionSource::Codex) => {
3948            value.get("type").and_then(Value::as_str) == Some("response_item")
3949                && value
3950                    .get("payload")
3951                    .and_then(|payload| payload.get("type"))
3952                    .and_then(Value::as_str)
3953                    == Some("message")
3954                && value
3955                    .get("payload")
3956                    .and_then(|payload| payload.get("role"))
3957                    .and_then(Value::as_str)
3958                    == Some("user")
3959        }
3960        Some(SessionSource::ClaudeCode) => {
3961            value.get("type").and_then(Value::as_str) == Some("user")
3962                && value
3963                    .get("message")
3964                    .and_then(|message| message.get("content"))
3965                    .is_some_and(|content| match content {
3966                        Value::String(text) => !text.trim().is_empty(),
3967                        Value::Array(parts) => parts.iter().any(|part| {
3968                            part.get("type").and_then(Value::as_str) == Some("text")
3969                                && part
3970                                    .get("text")
3971                                    .and_then(Value::as_str)
3972                                    .is_some_and(|text| !text.trim().is_empty())
3973                        }),
3974                        _ => false,
3975                    })
3976        }
3977        Some(SessionSource::Gemini) => {
3978            value.get("type").and_then(Value::as_str) == Some("user")
3979                && value.get("content").is_some_and(|content| match content {
3980                    Value::String(text) => !text.trim().is_empty(),
3981                    Value::Array(parts) => parts.iter().any(|part| {
3982                        part.get("text")
3983                            .and_then(Value::as_str)
3984                            .is_some_and(|text| !text.trim().is_empty())
3985                    }),
3986                    _ => false,
3987                })
3988        }
3989        _ => false,
3990    }
3991}
3992
3993fn opencode_sql_err(e: rusqlite::Error, context: &str) -> crate::Error {
3994    crate::Error::Other(format!("OpenCode SQLite error while {context}: {e}"))
3995}
3996
3997/// Open `db_path` read-only and confirm it carries the expected V1 schema
3998/// (a `session` table) — the shared entry point for every SQLite read below,
3999/// so every caller gets the SAME clear diagnostics (PARITY-3 AC03): missing
4000/// path, not-a-database, and wrong/unsupported schema are each named
4001/// distinctly rather than surfacing later as "zero sessions" or a generic
4002/// parse failure.
4003fn opencode_sqlite_open(db_path: &Path) -> Result<Connection> {
4004    if !db_path.is_file() {
4005        return Err(crate::Error::Other(format!(
4006            "OpenCode SQLite store not found at {} — expected an `opencode*.db` file \
4007             (see `docs/interop/opencode-pi-spec.md` §1.2)",
4008            db_path.display()
4009        )));
4010    }
4011    let conn = Connection::open_with_flags(
4012        db_path,
4013        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
4014    )
4015    .map_err(|e| {
4016        crate::Error::Other(format!(
4017            "{} does not look like a valid OpenCode SQLite database: {e}",
4018            db_path.display()
4019        ))
4020    })?;
4021    let has_session_table: i64 = conn
4022        .query_row(
4023            "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='session'",
4024            [],
4025            |r| r.get(0),
4026        )
4027        .map_err(|e| {
4028            crate::Error::Other(format!(
4029                "failed to read the OpenCode SQLite schema at {}: {e}",
4030                db_path.display()
4031            ))
4032        })?;
4033    if has_session_table == 0 {
4034        return Err(crate::Error::Other(format!(
4035            "{} is a SQLite database but has no `session` table — not a recognized \
4036             OpenCode store (wrong file, or an unsupported/pre-SQLite OpenCode version)",
4037            db_path.display()
4038        )));
4039    }
4040    Ok(conn)
4041}
4042
4043/// Parse a nullable JSON-typed SQLite column (`metadata`, `revert`, `model`,
4044/// …). D7: an unparseable non-empty column previously degraded to
4045/// `Value::Null` with NO diagnostic — indistinguishable from a genuinely
4046/// absent/NULL column, so a corrupt `data`/`metadata` value silently
4047/// vanished (e.g. a message whose `data` fails to parse loses its entire
4048/// canonical content with no trace). A `tracing::warn!` now surfaces the
4049/// column name and context (session/record id) whenever this happens, so
4050/// the failure is diagnosable — `Value::Null` is kept as the parsed VALUE
4051/// (still the least-wrong placeholder for a broken column; changing it to a
4052/// sentinel would risk misleading every legitimate `.is_null()` check
4053/// elsewhere) but the frontend/log now knows it happened.
4054fn opencode_json_col(s: Option<String>, col: &str, context: &str) -> Value {
4055    match s.as_deref() {
4056        None => Value::Null,
4057        Some(t) => match serde_json::from_str::<Value>(t) {
4058            Ok(v) => v,
4059            Err(e) => {
4060                tracing::warn!(
4061                    column = col,
4062                    context,
4063                    error = %e,
4064                    "opencode SQLite column failed to parse as JSON — treating as absent (D7)"
4065                );
4066                Value::Null
4067            }
4068        },
4069    }
4070}
4071
4072/// Columns the `session` table has in a GIVEN store, read once per session
4073/// load via `PRAGMA table_info` (D3: real-world stores vary — a lean/older
4074/// `opencode` generation may lack columns the newest schema added, e.g.
4075/// `workspace_id`; rusqlite's `Row::get(name)` hard-errors
4076/// "Invalid column name" on an absent column, so callers must check
4077/// membership before reading a not-guaranteed column instead of reading it
4078/// unconditionally).
4079fn opencode_session_columns(
4080    conn: &Connection,
4081) -> rusqlite::Result<std::collections::HashSet<String>> {
4082    let mut stmt = conn.prepare("PRAGMA table_info(session)")?;
4083    let names = stmt.query_map([], |r| r.get::<_, String>(1))?; // column 1 = name
4084    names.collect()
4085}
4086
4087/// Reconstruct one `session` row into the camelCase `SessionInfo` envelope
4088/// value [`Session::from_opencode_str`]'s `capture_opencode_session_info`
4089/// already parses — mirrors upstream's own `session.ts` `fromRow`, EXCEPT
4090/// `revert` carries the raw column value verbatim rather than upstream's
4091/// field-selecting reconstruction (spec S9c: that reconstruction silently
4092/// drops the V2 `Revert.State` schema's extra `files` field).
4093///
4094/// D3: not every column this loader would like to read is guaranteed to
4095/// exist — a real, older/leaner `opencode` install's `session` table (e.g.
4096/// v1.2.15) lacks `workspace_id`/`path`/`metadata`/`cost`/`tokens_*`/
4097/// `agent`/`model` entirely. Those are read defensively (guarded by
4098/// [`opencode_session_columns`]); columns present in EVERY `opencode`
4099/// generation this loader has ever targeted are still read unconditionally.
4100fn opencode_row_session_info(conn: &Connection, session_id: &str) -> Result<Value> {
4101    let cols = opencode_session_columns(conn)
4102        .map_err(|e| opencode_sql_err(e, &format!("reading session `{session_id}` schema")))?;
4103    let has = |name: &str| cols.contains(name);
4104
4105    conn.query_row("SELECT * FROM session WHERE id = ?1", [session_id], |r| {
4106        let id: String = r.get("id")?;
4107        let project_id: String = r.get("project_id")?;
4108        let workspace_id: Option<String> = if has("workspace_id") {
4109            r.get("workspace_id")?
4110        } else {
4111            None
4112        };
4113        let parent_id: Option<String> = r.get("parent_id")?;
4114        let slug: String = r.get("slug")?;
4115        let directory: String = r.get("directory")?;
4116        let path: Option<String> = if has("path") { r.get("path")? } else { None };
4117        let title: String = r.get("title")?;
4118        let version: String = r.get("version")?;
4119        let share_url: Option<String> = r.get("share_url")?;
4120        let summary_additions: Option<i64> = r.get("summary_additions")?;
4121        let summary_deletions: Option<i64> = r.get("summary_deletions")?;
4122        let summary_files: Option<i64> = r.get("summary_files")?;
4123        let summary_diffs: Option<String> = r.get("summary_diffs")?;
4124        let metadata: Option<String> = if has("metadata") {
4125            r.get("metadata")?
4126        } else {
4127            None
4128        };
4129        let cost: f64 = if has("cost") { r.get("cost")? } else { 0.0 };
4130        let tokens_input: i64 = if has("tokens_input") {
4131            r.get("tokens_input")?
4132        } else {
4133            0
4134        };
4135        let tokens_output: i64 = if has("tokens_output") {
4136            r.get("tokens_output")?
4137        } else {
4138            0
4139        };
4140        let tokens_reasoning: i64 = if has("tokens_reasoning") {
4141            r.get("tokens_reasoning")?
4142        } else {
4143            0
4144        };
4145        let tokens_cache_read: i64 = if has("tokens_cache_read") {
4146            r.get("tokens_cache_read")?
4147        } else {
4148            0
4149        };
4150        let tokens_cache_write: i64 = if has("tokens_cache_write") {
4151            r.get("tokens_cache_write")?
4152        } else {
4153            0
4154        };
4155        let revert: Option<String> = r.get("revert")?;
4156        let permission: Option<String> = if has("permission") {
4157            r.get("permission")?
4158        } else {
4159            None
4160        };
4161        let agent: Option<String> = if has("agent") { r.get("agent")? } else { None };
4162        let model: Option<String> = if has("model") { r.get("model")? } else { None };
4163        let time_created: i64 = r.get("time_created")?;
4164        let time_updated: i64 = r.get("time_updated")?;
4165        let time_compacting: Option<i64> = if has("time_compacting") {
4166            r.get("time_compacting")?
4167        } else {
4168            None
4169        };
4170        let time_archived: Option<i64> = if has("time_archived") {
4171            r.get("time_archived")?
4172        } else {
4173            None
4174        };
4175
4176        let summary =
4177            (summary_additions.is_some() || summary_deletions.is_some() || summary_files.is_some())
4178                .then(|| {
4179                    serde_json::json!({
4180                        "additions": summary_additions.unwrap_or(0),
4181                        "deletions": summary_deletions.unwrap_or(0),
4182                        "files": summary_files.unwrap_or(0),
4183                        "diffs": opencode_json_col(summary_diffs, "summary_diffs", session_id),
4184                    })
4185                });
4186        let share = share_url.map(|u| serde_json::json!({"url": u}));
4187
4188        Ok(serde_json::json!({
4189            "id": id,
4190            "slug": slug,
4191            "projectID": project_id,
4192            "workspaceID": workspace_id,
4193            "directory": directory,
4194            "path": path,
4195            "parentID": parent_id,
4196            "summary": summary,
4197            "cost": cost,
4198            "tokens": {
4199                "input": tokens_input,
4200                "output": tokens_output,
4201                "reasoning": tokens_reasoning,
4202                "cache": {"read": tokens_cache_read, "write": tokens_cache_write},
4203            },
4204            "share": share,
4205            "title": title,
4206            "agent": agent,
4207            "model": opencode_json_col(model, "model", session_id),
4208            "version": version,
4209            "metadata": opencode_json_col(metadata, "metadata", session_id),
4210            "time": {
4211                "created": time_created,
4212                "updated": time_updated,
4213                "compacting": time_compacting,
4214                "archived": time_archived,
4215            },
4216            "permission": opencode_json_col(permission, "permission", session_id),
4217            // S9c: raw column value, not a field-selecting reconstruction —
4218            // see this function's doc comment.
4219            "revert": opencode_json_col(revert, "revert", session_id),
4220        }))
4221    })
4222    .map_err(|e| match e {
4223        rusqlite::Error::QueryReturnedNoRows => crate::Error::Other(format!(
4224            "OpenCode session `{session_id}` not found in this SQLite store"
4225        )),
4226        e => opencode_sql_err(e, &format!("reading session `{session_id}`")),
4227    })
4228}
4229
4230/// `message.data` / `part.data` already ARE the V1 `Info`/`Part` JSON minus
4231/// the id columns the schema hoists out (`session/sql.ts:68-98`) — just
4232/// re-inject them, matching what a JSON-tree file (or the export document)
4233/// carries at this same key. Also re-injects the row's own `time_created`/
4234/// `time_updated` columns (D2/S9c: these live OUTSIDE `data` in the real
4235/// schema — `Timestamps` in `database/schema.sql.ts` — and MUST be carried
4236/// in the envelope so `raw` is value-complete and re-writable without
4237/// re-minting timestamps; distinct keys from `data`'s own `"time"` object,
4238/// which is a different, in-schema field with different semantics).
4239fn opencode_row_message_value(
4240    id: &str,
4241    session_id: &str,
4242    data_json: &str,
4243    time_created: i64,
4244    time_updated: i64,
4245) -> Value {
4246    let mut v = opencode_json_col(Some(data_json.to_string()), "message.data", id);
4247    if let Value::Object(map) = &mut v {
4248        map.insert("id".to_string(), Value::String(id.to_string()));
4249        map.insert(
4250            "sessionID".to_string(),
4251            Value::String(session_id.to_string()),
4252        );
4253        map.insert("time_created".to_string(), Value::from(time_created));
4254        map.insert("time_updated".to_string(), Value::from(time_updated));
4255    }
4256    v
4257}
4258
4259fn opencode_row_part_value(
4260    id: &str,
4261    session_id: &str,
4262    message_id: &str,
4263    data_json: &str,
4264    time_created: i64,
4265    time_updated: i64,
4266) -> Value {
4267    let mut v = opencode_json_col(Some(data_json.to_string()), "part.data", id);
4268    if let Value::Object(map) = &mut v {
4269        map.insert("id".to_string(), Value::String(id.to_string()));
4270        map.insert(
4271            "sessionID".to_string(),
4272            Value::String(session_id.to_string()),
4273        );
4274        map.insert(
4275            "messageID".to_string(),
4276            Value::String(message_id.to_string()),
4277        );
4278        map.insert("time_created".to_string(), Value::from(time_created));
4279        map.insert("time_updated".to_string(), Value::from(time_updated));
4280    }
4281    v
4282}
4283
4284/// Ordered envelope lines for one session's `session`/`message`/`part`/`todo`
4285/// records (`docs/interop/opencode-pi-spec.md` §1.2's frozen order): session
4286/// info first, then each message (by `time_created, id`) immediately
4287/// followed by its own parts (by `id`) — parts MUST directly follow their
4288/// owning message line, since `Session::from_opencode_str`'s envelope parser
4289/// attaches a `part` line to whichever message id is already in its index
4290/// and silently leaves an out-of-order part `raw`-only otherwise — then
4291/// `todo` side-records, then a `session_diff` side-record if the JSON
4292/// sidecar file for this session exists (order-independent).
4293///
4294/// `db_path` is needed only for the `session_diff` sidecar (D3): §1.3 says
4295/// it "is still JSON-written even on SQLite installs" — verified against
4296/// `packages/opencode/src/session/revert.ts:76` /
4297/// `packages/opencode/src/storage/storage.ts:192` at the pinned schema
4298/// commit, which write it to `<data>/storage/session_diff/<session>.json`
4299/// (`<data>` being `db_path`'s parent directory) on every revert, entirely
4300/// separate from the `session.revert` DB column this loader already
4301/// captures. Without this, revert diffs vanish from `raw` and audit
4302/// under-counts `session_diff` records for real reverted sessions.
4303fn opencode_sqlite_session_envelope_lines(
4304    conn: &Connection,
4305    db_path: &Path,
4306    session_id: &str,
4307) -> Result<Vec<String>> {
4308    let mut lines = Vec::new();
4309
4310    let session_info = opencode_row_session_info(conn, session_id)?;
4311    let project_id = session_info
4312        .get("projectID")
4313        .and_then(Value::as_str)
4314        .unwrap_or("global")
4315        .to_string();
4316    lines.push(
4317        serde_json::json!({"key": ["session", project_id, session_id], "value": session_info})
4318            .to_string(),
4319    );
4320
4321    let mut msg_stmt = conn
4322        .prepare(
4323            "SELECT id, data, time_created, time_updated FROM message \
4324             WHERE session_id = ?1 ORDER BY time_created, id",
4325        )
4326        .map_err(|e| opencode_sql_err(e, "preparing the message query"))?;
4327    let msg_rows = msg_stmt
4328        .query_map([session_id], |r| {
4329            let id: String = r.get("id")?;
4330            let data: String = r.get("data")?;
4331            let time_created: i64 = r.get("time_created")?;
4332            let time_updated: i64 = r.get("time_updated")?;
4333            Ok((id, data, time_created, time_updated))
4334        })
4335        .map_err(|e| opencode_sql_err(e, "querying messages"))?;
4336
4337    let mut part_stmt = conn
4338        .prepare(
4339            "SELECT id, data, time_created, time_updated FROM part \
4340             WHERE message_id = ?1 ORDER BY id",
4341        )
4342        .map_err(|e| opencode_sql_err(e, "preparing the part query"))?;
4343
4344    for row in msg_rows {
4345        let (msg_id, data, msg_time_created, msg_time_updated) =
4346            row.map_err(|e| opencode_sql_err(e, "reading a message row"))?;
4347        let msg_value = opencode_row_message_value(
4348            &msg_id,
4349            session_id,
4350            &data,
4351            msg_time_created,
4352            msg_time_updated,
4353        );
4354        lines.push(
4355            serde_json::json!({"key": ["message", session_id, msg_id], "value": msg_value})
4356                .to_string(),
4357        );
4358
4359        let part_rows = part_stmt
4360            .query_map([&msg_id], |r| {
4361                let id: String = r.get("id")?;
4362                let data: String = r.get("data")?;
4363                let time_created: i64 = r.get("time_created")?;
4364                let time_updated: i64 = r.get("time_updated")?;
4365                Ok((id, data, time_created, time_updated))
4366            })
4367            .map_err(|e| opencode_sql_err(e, "querying parts"))?;
4368        for prow in part_rows {
4369            let (part_id, pdata, part_time_created, part_time_updated) =
4370                prow.map_err(|e| opencode_sql_err(e, "reading a part row"))?;
4371            let part_value = opencode_row_part_value(
4372                &part_id,
4373                session_id,
4374                &msg_id,
4375                &pdata,
4376                part_time_created,
4377                part_time_updated,
4378            );
4379            lines.push(
4380                serde_json::json!({"key": ["part", msg_id, part_id], "value": part_value})
4381                    .to_string(),
4382            );
4383        }
4384    }
4385
4386    let mut todo_stmt = conn
4387        .prepare(
4388            "SELECT content, status, priority, position, time_created, time_updated \
4389             FROM todo WHERE session_id = ?1 ORDER BY position",
4390        )
4391        .map_err(|e| opencode_sql_err(e, "preparing the todo query"))?;
4392    let todo_rows = todo_stmt
4393        .query_map([session_id], |r| {
4394            let content: String = r.get("content")?;
4395            let status: String = r.get("status")?;
4396            let priority: String = r.get("priority")?;
4397            let position: i64 = r.get("position")?;
4398            let time_created: i64 = r.get("time_created")?;
4399            let time_updated: i64 = r.get("time_updated")?;
4400            Ok(serde_json::json!({
4401                "sessionID": session_id,
4402                "content": content,
4403                "status": status,
4404                "priority": priority,
4405                "position": position,
4406                "time": {"created": time_created, "updated": time_updated},
4407            }))
4408        })
4409        .map_err(|e| opencode_sql_err(e, "querying todos"))?;
4410    for trow in todo_rows {
4411        let tv = trow.map_err(|e| opencode_sql_err(e, "reading a todo row"))?;
4412        let position = tv.get("position").cloned().unwrap_or(Value::Null);
4413        lines.push(
4414            serde_json::json!({"key": ["todo", session_id, position], "value": tv}).to_string(),
4415        );
4416    }
4417
4418    if let Some(diff_value) = opencode_read_session_diff_sidecar(db_path, session_id) {
4419        lines.push(
4420            serde_json::json!({"key": ["session_diff", session_id], "value": diff_value})
4421                .to_string(),
4422        );
4423    }
4424
4425    Ok(lines)
4426}
4427
4428/// D3: read the `session_diff` JSON sidecar file for `session_id`, if
4429/// present, from `<db_path's parent>/storage/session_diff/<session_id>.json`
4430/// — the real on-disk location (`storage.ts`'s `Global.Path.data` +
4431/// `"storage"`, with `db_path` itself living at `Global.Path.data/opencode.db`
4432/// or `opencode-<channel>.db`). Best-effort: a missing file is the common
4433/// case (most sessions never revert) and is not an error; an existing-but-
4434/// unparseable file surfaces a diagnostic (D7-style) rather than silently
4435/// vanishing.
4436fn opencode_read_session_diff_sidecar(db_path: &Path, session_id: &str) -> Option<Value> {
4437    let dir = db_path.parent()?;
4438    let sidecar = dir
4439        .join("storage")
4440        .join("session_diff")
4441        .join(format!("{session_id}.json"));
4442    let text = std::fs::read_to_string(&sidecar).ok()?;
4443    match serde_json::from_str::<Value>(&text) {
4444        Ok(v) => Some(v),
4445        Err(e) => {
4446            tracing::warn!(
4447                path = %sidecar.display(),
4448                error = %e,
4449                "opencode session_diff sidecar failed to parse as JSON — skipping (D7)"
4450            );
4451            None
4452        }
4453    }
4454}
4455
4456/// Pick the "primary" session for a bare `.db` path with no explicit session
4457/// id (`Session::load`'s auto-detect entry point): the most-recently-updated
4458/// TOP-LEVEL session (`parent_id IS NULL` sorts first, then `time_updated`
4459/// descending) — a subagent/task child session is never picked over an
4460/// available root session, mirroring `most_recent_session`'s "latest wins"
4461/// convention used elsewhere in this crate for supercode's own store.
4462fn opencode_sqlite_primary_session_id(conn: &Connection) -> Result<String> {
4463    conn.query_row(
4464        "SELECT id FROM session ORDER BY (parent_id IS NULL) DESC, time_updated DESC LIMIT 1",
4465        [],
4466        |r| r.get::<_, String>(0),
4467    )
4468    .map_err(|e| match e {
4469        rusqlite::Error::QueryReturnedNoRows => {
4470            crate::Error::Other("OpenCode SQLite store contains no sessions".to_string())
4471        }
4472        e => opencode_sql_err(e, "selecting the primary session"),
4473    })
4474}
4475
4476fn opencode_sqlite_all_session_ids(conn: &Connection, limit: Option<usize>) -> Result<Vec<String>> {
4477    let mut stmt = conn
4478        .prepare("SELECT id FROM session ORDER BY time_created, id")
4479        .map_err(|e| opencode_sql_err(e, "listing sessions"))?;
4480    let rows = stmt
4481        .query_map([], |r| r.get::<_, String>(0))
4482        .map_err(|e| opencode_sql_err(e, "listing sessions"))?;
4483    let mut ids = Vec::new();
4484    for row in rows {
4485        ids.push(row.map_err(|e| opencode_sql_err(e, "reading a session id"))?);
4486        if limit.is_some_and(|n| ids.len() >= n) {
4487            break;
4488        }
4489    }
4490    Ok(ids)
4491}
4492
4493/// D6: list every session id in an OpenCode SQLite store (oldest first) —
4494/// exposed so CLI callers (`convert`, `inspect`) can detect a multi-session
4495/// store and warn before [`Session::load`]/[`Session::from_opencode_sqlite`]
4496/// silently picks just the primary one. Previously nothing surfaced this:
4497/// `convert opencode.db --to X` converted 1-of-N sessions with no warning
4498/// and no way to name a different one.
4499pub fn opencode_sqlite_session_ids(db_path: &Path) -> Result<Vec<String>> {
4500    let conn = opencode_sqlite_open(db_path)?;
4501    opencode_sqlite_all_session_ids(&conn, None)
4502}
4503
4504/// D6: the same "most-recently-updated top-level session" selection
4505/// [`Session::load`]/[`Session::from_opencode_sqlite`] make by default when
4506/// no explicit session id is given — exposed so a CLI-level warning can name
4507/// which one was chosen.
4508pub fn opencode_sqlite_primary_id(db_path: &Path) -> Result<String> {
4509    let conn = opencode_sqlite_open(db_path)?;
4510    opencode_sqlite_primary_session_id(&conn)
4511}
4512
4513/// Cheap store-level counts — `COUNT(*)` only, no row hydration — for
4514/// `inspect`'s "reports the audited real store's sessions, messages, and
4515/// parts" summary (PARITY-3 AC01).
4516#[derive(Debug, Clone, Copy, Default)]
4517#[non_exhaustive]
4518pub struct OpenCodeSqliteStoreStats {
4519    /// Row count of the `session` table.
4520    pub sessions: u64,
4521    /// Row count of the `message` table.
4522    pub messages: u64,
4523    /// Row count of the `part` table.
4524    pub parts: u64,
4525    /// Row count of the `todo` table.
4526    pub todos: u64,
4527}
4528
4529/// Count sessions/messages/parts/todos in a real OpenCode SQLite store
4530/// without loading any of them (PARITY-3 AC01).
4531pub fn opencode_sqlite_store_stats(db_path: &Path) -> Result<OpenCodeSqliteStoreStats> {
4532    let conn = opencode_sqlite_open(db_path)?;
4533    let count = |table: &str| -> Result<u64> {
4534        let sql = format!("SELECT count(*) FROM {table}");
4535        conn.query_row(&sql, [], |r| r.get::<_, i64>(0))
4536            .map(|n| n.max(0) as u64)
4537            .map_err(|e| opencode_sql_err(e, &format!("counting `{table}` rows")))
4538    };
4539    Ok(OpenCodeSqliteStoreStats {
4540        sessions: count("session")?,
4541        messages: count("message")?,
4542        parts: count("part")?,
4543        todos: count("todo")?,
4544    })
4545}
4546
4547/// Combined envelope text spanning every session in `db_path` (or up to
4548/// `limit_sessions`) — for corpus-style scanning
4549/// (the OpenCode SQLite corpus-audit path, PARITY-4).
4550/// Safe to concatenate multiple sessions' records into one text even though
4551/// [`Session::from_opencode_str`] itself only keeps the LAST `session` line
4552/// (single-session semantics) — the audit line-classifier
4553/// (`audit_opencode_line`) scores each line independently and doesn't care
4554/// about session boundaries. Use [`Session::from_opencode_sqlite`] to load
4555/// one session as a real [`Session`].
4556pub fn opencode_sqlite_corpus_envelope_text(
4557    db_path: &Path,
4558    limit_sessions: Option<usize>,
4559) -> Result<String> {
4560    let conn = opencode_sqlite_open(db_path)?;
4561    let ids = opencode_sqlite_all_session_ids(&conn, limit_sessions)?;
4562    let mut out = String::new();
4563    for id in ids {
4564        for line in opencode_sqlite_session_envelope_lines(&conn, db_path, &id)? {
4565            out.push_str(&line);
4566            out.push('\n');
4567        }
4568    }
4569    Ok(out)
4570}
4571
4572/// PARSE-iteration view (blank/whitespace-only lines skipped, each line
4573/// trimmed, `\r` line endings normalized away by [`str::lines`]) — used
4574/// everywhere a loader walks lines looking for JSON *records*, where a blank
4575/// line is simply not a record and must not become a spurious parse
4576/// failure/empty entry. Deliberately NOT used for `raw` capture any more
4577/// (IX-1) — see [`split_lines_verbatim`] for that.
4578fn non_empty_lines(text: &str) -> impl Iterator<Item = &str> {
4579    text.lines().map(str::trim).filter(|l| !l.is_empty())
4580}
4581
4582// ---- Claude Code ----------------------------------------------------------
4583
4584/// The `subagents/` directory for a Claude Code transcript at `<dir>/<stem>.jsonl`
4585/// is `<dir>/<stem>/subagents/`. Returns it only if it exists.
4586fn subagents_dir_for(main_path: &Path) -> Option<PathBuf> {
4587    let dir = main_path.parent()?;
4588    let stem = main_path.file_stem()?.to_str()?;
4589    let candidate = dir.join(stem).join("subagents");
4590    candidate.is_dir().then_some(candidate)
4591}
4592
4593/// The first `agentId` recorded in a subagent transcript.
4594fn first_agent_id(jsonl: &str) -> Option<String> {
4595    for line in non_empty_lines(jsonl) {
4596        if let Ok(v) = serde_json::from_str::<Value>(line) {
4597            if let Some(id) = v.get("agentId").and_then(Value::as_str) {
4598                return Some(id.to_string());
4599            }
4600        }
4601    }
4602    None
4603}
4604
4605/// Find the `tool_use_id` of each parent `Task` call that spawned one of
4606/// `agent_ids`, by locating the parent transcript's `tool_result` whose
4607/// serialized content mentions the agent id. Best effort: an id with no
4608/// qualifying match is simply absent from the returned map.
4609///
4610/// Single pass over `main_text` — each line is parsed at most once,
4611/// regardless of how many agent ids are being sought — with each id's result
4612/// reproducing exactly what a per-id scan-and-parse-per-hit-line search would
4613/// return: the first line (in file order) whose raw text contains the id and
4614/// which — the first qualifying `tool_result` block in that line, in block
4615/// order — has a string `tool_use_id` and a serialized form that also
4616/// contains the id. A `tool_result` block matching on raw-line/serialized
4617/// containment but lacking a `tool_use_id` yields nothing for that id and
4618/// does not shadow a later match.
4619fn parent_tool_use_index(main_text: &str, agent_ids: &[String]) -> HashMap<String, String> {
4620    let mut index: HashMap<String, String> = HashMap::new();
4621    if agent_ids.is_empty() {
4622        return index;
4623    }
4624
4625    for line in non_empty_lines(main_text) {
4626        if index.len() == agent_ids.len() {
4627            break;
4628        }
4629        // Cheap prefilter: every match this function can ever return comes
4630        // from a block whose raw line carries the literal JSON string value
4631        // `tool_result` (no JSON-escape variants of that ASCII literal).
4632        if !line.contains("tool_result") {
4633            continue;
4634        }
4635        let still_unmapped: Vec<&String> = agent_ids
4636            .iter()
4637            .filter(|id| !index.contains_key(id.as_str()))
4638            .collect();
4639        if still_unmapped.is_empty() {
4640            break;
4641        }
4642        let Ok(v) = serde_json::from_str::<Value>(line) else {
4643            continue;
4644        };
4645        let content = v.get("message").and_then(|m| m.get("content"));
4646        let Some(Value::Array(blocks)) = content else {
4647            continue;
4648        };
4649        for b in blocks {
4650            if b.get("type").and_then(Value::as_str) != Some("tool_result") {
4651                continue;
4652            }
4653            let Some(tool_use_id) = b.get("tool_use_id").and_then(Value::as_str) else {
4654                continue;
4655            };
4656            let block_str = b.to_string();
4657            for id in &still_unmapped {
4658                if index.contains_key(id.as_str()) {
4659                    continue;
4660                }
4661                if line.contains(id.as_str()) && block_str.contains(id.as_str()) {
4662                    index.insert((*id).clone(), tool_use_id.to_string());
4663                }
4664            }
4665        }
4666    }
4667
4668    index
4669}
4670
4671#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4672enum ClaudeReplayKind {
4673    User,
4674    Assistant,
4675    Attachment,
4676    System,
4677}
4678
4679impl ClaudeReplayKind {
4680    fn is_conversation(self) -> bool {
4681        matches!(self, Self::User | Self::Assistant)
4682    }
4683}
4684
4685#[derive(Debug, Clone)]
4686struct ClaudeReplayNode {
4687    line_index: usize,
4688    uuid: String,
4689    parent_uuid: Option<String>,
4690    kind: ClaudeReplayKind,
4691    is_sidechain: bool,
4692    assistant_message_id: Option<String>,
4693    is_tool_result: bool,
4694    compact: Option<ClaudeCompactBoundary>,
4695}
4696
4697#[derive(Debug, Clone)]
4698struct ClaudeCompactBoundary {
4699    anchor_uuid: Option<String>,
4700    preserved_uuids: Vec<String>,
4701    preserved_segment: Option<(String, String)>,
4702}
4703
4704/// One projection of a Claude transcript graph: the source lines to replay,
4705/// plus whatever the projection had to give up to produce them (always empty
4706/// below [`Fidelity::Semantic`], which is the only level that degrades
4707/// instead of failing).
4708#[derive(Debug, Default)]
4709struct ClaudeReplaySelection {
4710    lines: Vec<usize>,
4711    residue: Vec<String>,
4712}
4713
4714#[derive(Debug, Default)]
4715struct ClaudeReplayIndex {
4716    nodes: Vec<ClaudeReplayNode>,
4717    by_uuid: HashMap<String, usize>,
4718    segment_anchors: HashSet<String>,
4719    last_prompt: Option<(String, bool)>,
4720    linear_lines: Vec<usize>,
4721}
4722
4723impl ClaudeReplayIndex {
4724    fn observe(&mut self, line_index: usize, v: &Value) -> Result<()> {
4725        if v.get("type").and_then(Value::as_str) == Some("last-prompt") {
4726            if let Some(leaf) = v.get("leafUuid").and_then(Value::as_str) {
4727                self.last_prompt = Some((
4728                    leaf.to_string(),
4729                    v.get("explicit").and_then(Value::as_bool) == Some(true),
4730                ));
4731            }
4732            return Ok(());
4733        }
4734
4735        // A fork-context-ref is a real Claude graph anchor, but not a replay
4736        // message. Its child is the first conversational record in the
4737        // exported fork, so reaching this UUID terminates the locally
4738        // replayable segment rather than indicating a broken parent edge.
4739        if v.get("type").and_then(Value::as_str) == Some("fork-context-ref") {
4740            if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
4741                self.segment_anchors.insert(uuid.to_string());
4742            }
4743            return Ok(());
4744        }
4745
4746        let kind = match v.get("type").and_then(Value::as_str) {
4747            Some("user") => ClaudeReplayKind::User,
4748            Some("assistant") => ClaudeReplayKind::Assistant,
4749            Some("attachment") => ClaudeReplayKind::Attachment,
4750            Some("system") => ClaudeReplayKind::System,
4751            _ => return Ok(()),
4752        };
4753        self.linear_lines.push(line_index);
4754        let Some(uuid) = v.get("uuid").and_then(Value::as_str) else {
4755            return Ok(());
4756        };
4757        if self.by_uuid.contains_key(uuid) {
4758            return Err(claude_replay_error(format!(
4759                "duplicate uuid `{uuid}` in Claude transcript"
4760            )));
4761        }
4762
4763        let compact = (kind == ClaudeReplayKind::System
4764            && v.get("subtype").and_then(Value::as_str) == Some("compact_boundary"))
4765        .then(|| ClaudeCompactBoundary::from_value(v));
4766        let assistant_message_id = (kind == ClaudeReplayKind::Assistant)
4767            .then(|| claude_assistant_message_id(v).map(str::to_string))
4768            .flatten();
4769        let is_tool_result = kind == ClaudeReplayKind::User
4770            && v.get("message")
4771                .and_then(|m| m.get("content"))
4772                .and_then(Value::as_array)
4773                .is_some_and(|blocks| {
4774                    blocks
4775                        .iter()
4776                        .any(|b| b.get("type").and_then(Value::as_str) == Some("tool_result"))
4777                });
4778        let node = ClaudeReplayNode {
4779            line_index,
4780            uuid: uuid.to_string(),
4781            parent_uuid: v
4782                .get("parentUuid")
4783                .and_then(Value::as_str)
4784                .map(str::to_string),
4785            kind,
4786            is_sidechain: v.get("isSidechain").and_then(Value::as_bool) == Some(true),
4787            assistant_message_id,
4788            is_tool_result,
4789            compact,
4790        };
4791        self.by_uuid.insert(uuid.to_string(), self.nodes.len());
4792        self.nodes.push(node);
4793        Ok(())
4794    }
4795
4796    /// Project the transcript at `fidelity`.
4797    ///
4798    /// Below [`Fidelity::Semantic`] this is the STRICT projection every
4799    /// continuation, transfer and export path depends on: reconstruct
4800    /// Claude's own single active post-compaction branch, or fail naming what
4801    /// could not be reconstructed.
4802    ///
4803    /// [`Fidelity::Semantic`] is the read-only VIEW mode. A Claude transcript
4804    /// that has been compacted, summarized, or resumed across files routinely
4805    /// contains a live record whose `parentUuid` names a record that is no
4806    /// longer on disk. Strict projection rightly refuses — a continuation
4807    /// built on a guessed graph is silent loss — but a VIEW does not need a
4808    /// continuation, so this mode anchors each dangling edge as a segment
4809    /// root, projects every severed segment exactly as the active branch is
4810    /// projected, splices them back together in transcript order, and names
4811    /// every degradation in the returned residue instead of erroring.
4812    fn select_lines(mut self, fidelity: Fidelity) -> Result<ClaudeReplaySelection> {
4813        let lenient = fidelity.tolerates_residue();
4814        let mut residue = Vec::new();
4815        if self.nodes.is_empty() {
4816            // Older exports and many hand-authored compatibility fixtures do
4817            // not carry Claude's `uuid`/`parentUuid` graph fields. There is no
4818            // branch information to project in that shape, so preserve the
4819            // historical linear normalization behavior. Native graph-bearing
4820            // transcripts always take the projection below.
4821            return Ok(ClaudeReplaySelection {
4822                lines: self.linear_lines,
4823                residue,
4824            });
4825        }
4826        if lenient {
4827            self.anchor_dangling_parents(&mut residue);
4828        }
4829        // Last resort for a VIEW: a transcript whose graph is unprojectable
4830        // for some OTHER reason (a cycle, an unresolvable compact boundary)
4831        // still renders as the file's own record order. A read-only mirror
4832        // that cannot open a session at all is the defect this mode exists
4833        // to remove, so `Semantic` never returns an error.
4834        let fallback = lenient.then(|| self.linear_lines.clone());
4835        match self.project(lenient, &mut residue) {
4836            Ok(lines) => Ok(ClaudeReplaySelection { lines, residue }),
4837            Err(error) => match fallback {
4838                Some(lines) => {
4839                    residue.push(format!(
4840                        "the Claude record graph could not be projected ({error}); \
4841                         every record was stitched in transcript order instead"
4842                    ));
4843                    Ok(ClaudeReplaySelection { lines, residue })
4844                }
4845                None => Err(error),
4846            },
4847        }
4848    }
4849
4850    /// Turn every edge that points outside the transcript into a segment
4851    /// root, naming the dangling uuids as residue.
4852    ///
4853    /// A `fork-context-ref` anchor is already a declared segment boundary,
4854    /// not a break, so it is left alone.
4855    fn anchor_dangling_parents(&mut self, residue: &mut Vec<String>) {
4856        let mut dangling = Vec::new();
4857        for idx in 0..self.nodes.len() {
4858            let Some(parent) = self.nodes[idx].parent_uuid.as_deref() else {
4859                continue;
4860            };
4861            if self.by_uuid.contains_key(parent) || self.segment_anchors.contains(parent) {
4862                continue;
4863            }
4864            dangling.push(format!("`{}` → `{parent}`", self.nodes[idx].uuid));
4865            self.nodes[idx].parent_uuid = None;
4866        }
4867        if dangling.is_empty() {
4868            return;
4869        }
4870        const NAMED: usize = 8;
4871        let total = dangling.len();
4872        let overflow = total.saturating_sub(NAMED);
4873        dangling.truncate(NAMED);
4874        let mut listed = dangling.join(", ");
4875        if overflow > 0 {
4876            listed.push_str(&format!(", and {overflow} more"));
4877        }
4878        residue.push(format!(
4879            "{total} Claude record(s) reference a parentUuid absent from the transcript and were \
4880             anchored as segment roots: {listed}"
4881        ));
4882    }
4883
4884    fn project(&mut self, lenient: bool, residue: &mut Vec<String>) -> Result<Vec<usize>> {
4885        let mut retained = vec![true; self.nodes.len()];
4886        if let Some(boundary_index) = self.nodes.iter().rposition(|n| n.compact.is_some()) {
4887            let parents: Option<Vec<Option<String>>> = lenient.then(|| {
4888                self.nodes
4889                    .iter()
4890                    .map(|node| node.parent_uuid.clone())
4891                    .collect()
4892            });
4893            if let Err(error) = self.apply_latest_compaction(boundary_index, &mut retained) {
4894                let Some(parents) = parents else {
4895                    return Err(error);
4896                };
4897                // The boundary rewrites parents as it goes, so restore the
4898                // graph it half-edited before continuing without it.
4899                for (node, parent) in self.nodes.iter_mut().zip(parents) {
4900                    node.parent_uuid = parent;
4901                }
4902                retained.iter_mut().for_each(|keep| *keep = true);
4903                residue.push(format!(
4904                    "the latest Claude compact boundary could not be projected ({error}); \
4905                     no pre-compaction record was pruned from this view"
4906                ));
4907            }
4908        }
4909        let sidechain_only = self
4910            .nodes
4911            .iter()
4912            .enumerate()
4913            .filter(|(idx, node)| retained[*idx] && node.kind.is_conversation())
4914            .all(|(_, node)| node.is_sidechain);
4915
4916        let explicit_leaf = self
4917            .last_prompt
4918            .as_ref()
4919            .filter(|(_, explicit)| *explicit)
4920            .and_then(|(uuid, _)| self.by_uuid.get(uuid).copied())
4921            .filter(|idx| retained[*idx]);
4922        let newest_non_sidechain = self
4923            .nodes
4924            .iter()
4925            .enumerate()
4926            .rev()
4927            .find(|(idx, node)| retained[*idx] && !node.is_sidechain)
4928            .map(|(idx, _)| idx);
4929        // Dedicated Claude subagent transcripts are sidechains by design:
4930        // every record, including their root user prompt, has
4931        // `isSidechain:true`. When there is no main-chain candidate, resume
4932        // the newest retained sidechain leaf instead of rejecting the child.
4933        let newest_sidechain = self
4934            .nodes
4935            .iter()
4936            .enumerate()
4937            .rev()
4938            .find(|(idx, node)| retained[*idx] && node.is_sidechain && node.kind.is_conversation())
4939            .map(|(idx, _)| idx);
4940        let mut active = explicit_leaf
4941            .or(newest_non_sidechain)
4942            .or(newest_sidechain)
4943            .ok_or_else(|| claude_replay_error("no Claude record remains after compaction"))?;
4944
4945        // Metadata descendants such as turn_duration are leaves in the raw
4946        // graph. Claude resumes from their nearest user/assistant ancestor,
4947        // then appends those descendants to the reconstructed chain.
4948        let mut seeking = HashSet::new();
4949        while !self.nodes[active].kind.is_conversation() {
4950            if !seeking.insert(active) {
4951                return Err(claude_replay_error(
4952                    "cycle while resolving active Claude leaf",
4953                ));
4954            }
4955            active = self.parent_index(active, &retained)?;
4956        }
4957
4958        let mut segments =
4959            vec![self.project_segment(active, &retained, sidechain_only, lenient)?];
4960        if lenient {
4961            for leaf in self.severed_segment_leaves(active, &retained) {
4962                segments.push(self.project_segment(leaf, &retained, sidechain_only, lenient)?);
4963            }
4964            if segments.len() > 1 {
4965                residue.push(format!(
4966                    "{} conversation segments were stitched in transcript order because the \
4967                     Claude record graph is severed",
4968                    segments.len()
4969                ));
4970            }
4971        }
4972        // Each segment keeps its own reconstructed order; the segments
4973        // themselves are spliced by where they start in the file.
4974        segments.retain(|segment| !segment.is_empty());
4975        segments.sort_by_key(|segment| {
4976            segment
4977                .iter()
4978                .map(|idx| self.nodes[*idx].line_index)
4979                .min()
4980                .unwrap_or(usize::MAX)
4981        });
4982        let mut ordered = Vec::new();
4983        let mut placed = HashSet::new();
4984        for idx in segments.into_iter().flatten() {
4985            if placed.insert(idx) {
4986                ordered.push(idx);
4987            }
4988        }
4989
4990        self.recover_parallel_assistant_chunks(ordered, &retained)
4991            .map(|indices| {
4992                indices
4993                    .into_iter()
4994                    .map(|idx| self.nodes[idx].line_index)
4995                    .collect()
4996            })
4997    }
4998
4999    /// Reconstruct one segment: `leaf`'s parent chain, oldest-first, plus the
5000    /// non-conversation descendants rooted at it.
5001    fn project_segment(
5002        &self,
5003        leaf: usize,
5004        retained: &[bool],
5005        sidechain_only: bool,
5006        lenient: bool,
5007    ) -> Result<Vec<usize>> {
5008        let mut reversed = Vec::new();
5009        let mut seen = HashSet::new();
5010        let mut cursor = Some(leaf);
5011        while let Some(idx) = cursor {
5012            if !seen.insert(idx) {
5013                return Err(claude_replay_error(format!(
5014                    "cycle in active Claude parentUuid chain at `{}`",
5015                    self.nodes[idx].uuid
5016                )));
5017            }
5018            reversed.push(idx);
5019            cursor = match self.nodes[idx].parent_uuid.as_deref() {
5020                Some(parent) => match self.by_uuid.get(parent).copied() {
5021                    Some(parent) => Some(parent),
5022                    None if self.segment_anchors.contains(parent) => None,
5023                    // Claude can resume a background child in-place while
5024                    // retaining only the new segment in that child's JSONL.
5025                    // Its first record then points to a UUID not present in
5026                    // the sidechain file. That external edge is a segment
5027                    // boundary, not corruption; the complete source remains
5028                    // available byte-for-byte in `raw`.
5029                    None if sidechain_only => None,
5030                    None => {
5031                        return Err(claude_replay_error(format!(
5032                            "active Claude record `{}` has missing parentUuid `{parent}`",
5033                            self.nodes[idx].uuid
5034                        )));
5035                    }
5036                },
5037                None => None,
5038            };
5039            if cursor.is_some_and(|parent| !retained[parent]) {
5040                if lenient {
5041                    // A compaction boundary is where this segment ends; the
5042                    // records it pruned stay pruned.
5043                    break;
5044                }
5045                return Err(claude_replay_error(format!(
5046                    "active Claude chain crosses an excluded compaction record from `{}`",
5047                    self.nodes[idx].uuid
5048                )));
5049            }
5050        }
5051        reversed.reverse();
5052
5053        // Include non-conversation descendants rooted at the segment's leaf
5054        // (turn_duration, attachments, etc.), matching Claude's QYH/n1T.
5055        let mut descendants = Vec::new();
5056        let mut frontier = vec![leaf];
5057        let mut head = 0;
5058        while head < frontier.len() {
5059            let parent = frontier[head];
5060            head += 1;
5061            for (idx, node) in self.nodes.iter().enumerate() {
5062                if !retained[idx]
5063                    || node.kind.is_conversation()
5064                    || seen.contains(&idx)
5065                    || node.parent_uuid.as_deref() != Some(self.nodes[parent].uuid.as_str())
5066                {
5067                    continue;
5068                }
5069                seen.insert(idx);
5070                descendants.push(idx);
5071                frontier.push(idx);
5072            }
5073        }
5074        descendants.sort_by_key(|idx| self.nodes[*idx].line_index);
5075        reversed.extend(descendants);
5076        Ok(reversed)
5077    }
5078
5079    /// The newest retained conversation record of every component the active
5080    /// leaf's own component cannot reach.
5081    ///
5082    /// Only a severed graph produces any: a healthy transcript is one
5083    /// component, so the abandoned branches a rewind left behind stay
5084    /// abandoned here exactly as they do under strict projection.
5085    fn severed_segment_leaves(&self, active: usize, retained: &[bool]) -> Vec<usize> {
5086        let active_root = self.component_root(active, retained);
5087        let mut newest_by_root: BTreeMap<usize, usize> = BTreeMap::new();
5088        for idx in 0..self.nodes.len() {
5089            if !retained[idx] || !self.nodes[idx].kind.is_conversation() {
5090                continue;
5091            }
5092            let Some(root) = self.component_root(idx, retained) else {
5093                continue;
5094            };
5095            if Some(root) == active_root {
5096                continue;
5097            }
5098            let newest = newest_by_root.entry(root).or_insert(idx);
5099            if self.nodes[idx].line_index > self.nodes[*newest].line_index {
5100                *newest = idx;
5101            }
5102        }
5103        newest_by_root.into_values().collect()
5104    }
5105
5106    /// Walk `idx` up to the record that anchors its component, stopping at a
5107    /// root, an edge that leaves the transcript, or a pruned parent. `None`
5108    /// when the walk cycles.
5109    fn component_root(&self, idx: usize, retained: &[bool]) -> Option<usize> {
5110        let mut cursor = idx;
5111        let mut seen = HashSet::new();
5112        loop {
5113            if !seen.insert(cursor) {
5114                return None;
5115            }
5116            let next = self.nodes[cursor]
5117                .parent_uuid
5118                .as_deref()
5119                .and_then(|parent| self.by_uuid.get(parent).copied())
5120                .filter(|parent| retained[*parent]);
5121            match next {
5122                Some(parent) => cursor = parent,
5123                None => return Some(cursor),
5124            }
5125        }
5126    }
5127
5128    fn apply_latest_compaction(
5129        &mut self,
5130        boundary_index: usize,
5131        retained: &mut [bool],
5132    ) -> Result<()> {
5133        let compact = self.nodes[boundary_index]
5134            .compact
5135            .clone()
5136            .expect("called with compact boundary");
5137        let mut preserved = compact.preserved_uuids;
5138        if preserved.is_empty() {
5139            if let Some((head, tail)) = compact.preserved_segment {
5140                preserved = self.walk_preserved_segment(&head, &tail)?;
5141            }
5142        }
5143
5144        let preserved_set: HashSet<String> = preserved.iter().cloned().collect();
5145        for uuid in &preserved {
5146            if !self.by_uuid.contains_key(uuid) {
5147                return Err(claude_replay_error(format!(
5148                    "latest compact boundary references missing preserved uuid `{uuid}`"
5149                )));
5150            }
5151        }
5152
5153        let removed_uuids: HashSet<String> = self
5154            .nodes
5155            .iter()
5156            .enumerate()
5157            .filter(|(idx, node)| *idx < boundary_index && !preserved_set.contains(&node.uuid))
5158            .map(|(_, node)| node.uuid.clone())
5159            .collect();
5160        for (idx, node) in self.nodes.iter().enumerate() {
5161            if idx < boundary_index && !preserved_set.contains(&node.uuid) {
5162                retained[idx] = false;
5163            }
5164        }
5165
5166        if preserved.is_empty() {
5167            return Ok(());
5168        }
5169        let anchor = compact.anchor_uuid.ok_or_else(|| {
5170            claude_replay_error("preserved compact boundary is missing anchorUuid")
5171        })?;
5172        if !self.by_uuid.contains_key(&anchor) {
5173            return Err(claude_replay_error(format!(
5174                "latest compact boundary references missing anchor uuid `{anchor}`"
5175            )));
5176        }
5177        let tail = preserved.last().cloned().expect("non-empty preserved list");
5178        let mut parent = anchor.clone();
5179        for uuid in &preserved {
5180            let idx = self.by_uuid[uuid];
5181            self.nodes[idx].parent_uuid = Some(parent);
5182            parent = uuid.clone();
5183        }
5184        let first = &preserved[0];
5185        for node in &mut self.nodes {
5186            if node.parent_uuid.as_deref() == Some(anchor.as_str()) && node.uuid != *first {
5187                node.parent_uuid = Some(tail.clone());
5188            }
5189        }
5190        for node in &mut self.nodes {
5191            if node.kind.is_conversation()
5192                && node
5193                    .parent_uuid
5194                    .as_ref()
5195                    .is_some_and(|parent| removed_uuids.contains(parent))
5196            {
5197                node.parent_uuid = Some(tail.clone());
5198            }
5199        }
5200        Ok(())
5201    }
5202
5203    fn walk_preserved_segment(&self, head: &str, tail: &str) -> Result<Vec<String>> {
5204        let mut reversed = Vec::new();
5205        let mut seen = HashSet::new();
5206        let mut cursor = tail;
5207        loop {
5208            if !seen.insert(cursor.to_string()) {
5209                return Err(claude_replay_error("cycle in compact preservedSegment"));
5210            }
5211            let idx = *self.by_uuid.get(cursor).ok_or_else(|| {
5212                claude_replay_error(format!(
5213                    "compact preservedSegment references missing uuid `{cursor}`"
5214                ))
5215            })?;
5216            reversed.push(cursor.to_string());
5217            if cursor == head {
5218                reversed.reverse();
5219                return Ok(reversed);
5220            }
5221            cursor = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
5222                claude_replay_error(format!(
5223                    "compact preservedSegment tail `{tail}` does not reach head `{head}`"
5224                ))
5225            })?;
5226        }
5227    }
5228
5229    fn parent_index(&self, idx: usize, retained: &[bool]) -> Result<usize> {
5230        let parent = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
5231            claude_replay_error(format!(
5232                "Claude record `{}` has no conversational ancestor",
5233                self.nodes[idx].uuid
5234            ))
5235        })?;
5236        let parent_idx = *self.by_uuid.get(parent).ok_or_else(|| {
5237            claude_replay_error(format!(
5238                "Claude record `{}` has missing parentUuid `{parent}`",
5239                self.nodes[idx].uuid
5240            ))
5241        })?;
5242        if !retained[parent_idx] {
5243            return Err(claude_replay_error(format!(
5244                "Claude record `{}` points into compacted-out history",
5245                self.nodes[idx].uuid
5246            )));
5247        }
5248        Ok(parent_idx)
5249    }
5250
5251    fn recover_parallel_assistant_chunks(
5252        &self,
5253        base: Vec<usize>,
5254        retained: &[bool],
5255    ) -> Result<Vec<usize>> {
5256        let selected: HashSet<usize> = base.iter().copied().collect();
5257        let mut replacements: HashMap<usize, Vec<usize>> = HashMap::new();
5258        let mut skipped_positions = HashSet::new();
5259        let mut handled_ids = HashSet::new();
5260
5261        for (base_pos, idx) in base.iter().copied().enumerate() {
5262            let Some(message_id) = self.nodes[idx].assistant_message_id.as_deref() else {
5263                continue;
5264            };
5265            if !handled_ids.insert(message_id.to_string()) {
5266                continue;
5267            }
5268            let base_positions: Vec<usize> = base
5269                .iter()
5270                .enumerate()
5271                .filter(|(_, candidate)| {
5272                    self.nodes[**candidate].assistant_message_id.as_deref() == Some(message_id)
5273                })
5274                .map(|(pos, _)| pos)
5275                .collect();
5276            let anchor_pos = base_positions.first().copied().unwrap_or(base_pos);
5277            skipped_positions.extend(base_positions.iter().copied().skip(1));
5278
5279            // A streamed Anthropic response can be stored as sibling records
5280            // rather than a literal parent chain. Reassemble every chunk at
5281            // the first active occurrence and restore raw chunk order before
5282            // the normalizer coalesces their content blocks.
5283            let mut chunks: Vec<usize> = self
5284                .nodes
5285                .iter()
5286                .enumerate()
5287                .filter(|(candidate, node)| {
5288                    retained[*candidate] && node.assistant_message_id.as_deref() == Some(message_id)
5289                })
5290                .map(|(candidate, _)| candidate)
5291                .collect();
5292            chunks.sort_by_key(|candidate| self.nodes[*candidate].line_index);
5293
5294            let assistant_uuids: HashSet<&str> = self
5295                .nodes
5296                .iter()
5297                .filter(|node| node.assistant_message_id.as_deref() == Some(message_id))
5298                .map(|node| node.uuid.as_str())
5299                .collect();
5300            let mut results: Vec<usize> = self
5301                .nodes
5302                .iter()
5303                .enumerate()
5304                .filter(|(candidate, node)| {
5305                    retained[*candidate]
5306                        && !selected.contains(candidate)
5307                        && node.is_tool_result
5308                        && node
5309                            .parent_uuid
5310                            .as_deref()
5311                            .is_some_and(|parent| assistant_uuids.contains(parent))
5312                })
5313                .map(|(candidate, _)| candidate)
5314                .collect();
5315            results.sort_by_key(|candidate| self.nodes[*candidate].line_index);
5316            chunks.extend(results);
5317            replacements.insert(anchor_pos, chunks);
5318        }
5319
5320        let mut out = Vec::with_capacity(selected.len());
5321        for (pos, idx) in base.into_iter().enumerate() {
5322            if let Some(replacement) = replacements.remove(&pos) {
5323                out.extend(replacement);
5324            } else if !skipped_positions.contains(&pos) {
5325                out.push(idx);
5326            }
5327        }
5328        Ok(out)
5329    }
5330}
5331
5332impl ClaudeCompactBoundary {
5333    fn from_value(v: &Value) -> Self {
5334        let metadata = v.get("compactMetadata");
5335        let preserved_messages = metadata.and_then(|m| m.get("preservedMessages"));
5336        let anchor_uuid = preserved_messages
5337            .and_then(|p| p.get("anchorUuid"))
5338            .and_then(Value::as_str)
5339            .or_else(|| {
5340                metadata
5341                    .and_then(|m| m.get("preservedSegment"))
5342                    .and_then(|p| p.get("anchorUuid"))
5343                    .and_then(Value::as_str)
5344            })
5345            .map(str::to_string);
5346        let preserved_uuids = preserved_messages
5347            .and_then(|p| p.get("uuids"))
5348            .and_then(Value::as_array)
5349            .map(|uuids| {
5350                uuids
5351                    .iter()
5352                    .filter_map(Value::as_str)
5353                    .map(str::to_string)
5354                    .collect()
5355            })
5356            .unwrap_or_default();
5357        let preserved_segment =
5358            metadata
5359                .and_then(|m| m.get("preservedSegment"))
5360                .and_then(|segment| {
5361                    Some((
5362                        segment.get("headUuid")?.as_str()?.to_string(),
5363                        segment.get("tailUuid")?.as_str()?.to_string(),
5364                    ))
5365                });
5366        Self {
5367            anchor_uuid,
5368            preserved_uuids,
5369            preserved_segment,
5370        }
5371    }
5372}
5373
5374fn claude_replay_error(message: impl Into<String>) -> crate::Error {
5375    crate::Error::Other(format!(
5376        "cannot reconstruct lossless Claude continuation: {}",
5377        message.into()
5378    ))
5379}
5380
5381fn claude_assistant_message_id(v: &Value) -> Option<&str> {
5382    v.get("message")
5383        .and_then(|message| message.get("id"))
5384        .and_then(Value::as_str)
5385}
5386
5387fn merge_claude_assistant_chunk(target: &mut Value, chunk: &Value) {
5388    let Some(target_message) = target.get_mut("message") else {
5389        return;
5390    };
5391    let Some(chunk_message) = chunk.get("message") else {
5392        return;
5393    };
5394    let mut content = target_message
5395        .get("content")
5396        .and_then(Value::as_array)
5397        .cloned()
5398        .unwrap_or_default();
5399    if let Some(blocks) = chunk_message.get("content").and_then(Value::as_array) {
5400        content.extend(blocks.iter().cloned());
5401    }
5402    let mut merged_message = chunk_message.clone();
5403    merged_message["content"] = Value::Array(content);
5404    *target_message = merged_message;
5405}
5406
5407fn flush_claude_assistant(pending: &mut Option<Value>, out: &mut Vec<ChatMessage>) {
5408    let Some(v) = pending.take() else {
5409        return;
5410    };
5411    let reasoning_only = claude_assistant_message_id(&v).is_some()
5412        && v.get("message")
5413            .and_then(|message| message.get("content"))
5414            .and_then(Value::as_array)
5415            .is_some_and(|blocks| {
5416                !blocks.is_empty()
5417                    && blocks.iter().all(|block| {
5418                        matches!(
5419                            block.get("type").and_then(Value::as_str),
5420                            Some("thinking" | "redacted_thinking")
5421                        )
5422                    })
5423            });
5424    if reasoning_only {
5425        return;
5426    }
5427    let before = out.len();
5428    push_claude_assistant(&v, out);
5429    capture_claude_record_provenance(&v, &mut out[before..]);
5430    restore_single_grok_message(&v, &mut out[before..]);
5431}
5432
5433/// Attach the record identity, clock, and actual assistant model to every
5434/// canonical message produced from one Claude JSONL record. These fields are
5435/// deliberately per-message: a continued transcript can cross a provider
5436/// boundary, so the session-level source model is not authoritative for its
5437/// appended tail.
5438fn capture_claude_record_provenance(v: &Value, messages: &mut [ChatMessage]) {
5439    let timestamp = v.get("timestamp").and_then(Value::as_str);
5440    let uuid = v.get("uuid").and_then(Value::as_str);
5441    let model = v
5442        .get("message")
5443        .and_then(|message| message.get("model"))
5444        .and_then(Value::as_str);
5445    for message in messages {
5446        if let Some(timestamp) = timestamp {
5447            message
5448                .metadata
5449                .entry("timestamp".to_string())
5450                .or_insert_with(|| timestamp.to_string());
5451        }
5452        if let Some(uuid) = uuid {
5453            message
5454                .metadata
5455                .entry("claude_uuid".to_string())
5456                .or_insert_with(|| uuid.to_string());
5457        }
5458        if let Some(model) = model {
5459            message
5460                .metadata
5461                .entry("model".to_string())
5462                .or_insert_with(|| model.to_string());
5463        }
5464    }
5465}
5466
5467fn capture_claude_meta(v: &Value, meta: &mut SessionMeta, raw_line: &str) -> Result<()> {
5468    restore_codex_provenance_from_top_level(v, meta)?;
5469    if meta.session_id.is_none() {
5470        if let Some(id) = v.get("sessionId").and_then(Value::as_str) {
5471            meta.session_id = Some(id.to_string());
5472        }
5473    }
5474    if meta.cwd.is_none() {
5475        if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
5476            meta.cwd = Some(PathBuf::from(cwd));
5477        }
5478    }
5479    if meta.model.is_none() {
5480        if let Some(model) = v
5481            .get("message")
5482            .and_then(|m| m.get("model"))
5483            .and_then(Value::as_str)
5484        {
5485            meta.model = Some(model.to_string());
5486        }
5487    }
5488    // PARITY-10 (provenance P010): `fork-context-ref` is an extremely rare
5489    // real Claude Code record with no confirmed field shape (see
5490    // `ClaudeRecord::ForkContextRef`'s doc comment) — rather than guess at
5491    // named fields and risk silently mis-modeling it, stash the WHOLE raw
5492    // line verbatim under a lineage key. `write_claude_code_records` (below)
5493    // re-emits it byte-for-byte, so the record survives the Claude Code
5494    // semantic writer (not just the CLI's raw-passthrough diagonal path) —
5495    // and `to_codex_jsonl`'s synthesized header carries a namespaced copy
5496    // so a Claude -> Codex -> Claude round trip can still reconstruct it
5497    // (dev/03). A session can only fork from one context, so the first one
5498    // seen wins, matching every other "first wins" field above.
5499    //
5500    // D4 (Fable-5 review, confirmed): this used to store `v.to_string()` —
5501    // a RE-SERIALIZATION of the parsed `Value`, not the original source
5502    // text. `serde_json::Value` here has no `preserve_order` feature (see
5503    // `Cargo.toml`), so its object keys are a `BTreeMap` and get silently
5504    // REORDERED (alphabetized) on re-emit — the "byte-for-byte" claim in
5505    // this very comment was false. Fixed the cheap+honest way: store the
5506    // caller's own already-verbatim source `raw_line` text instead of
5507    // re-serializing `v` at all, so this is now ACTUALLY byte-for-byte
5508    // (key order, spacing, everything) rather than merely
5509    // structurally-equivalent JSON.
5510    if v.get("type").and_then(Value::as_str) == Some("fork-context-ref")
5511        && !meta.lineage.contains_key("claude_fork_context_ref_raw")
5512    {
5513        meta.lineage.insert(
5514            "claude_fork_context_ref_raw".to_string(),
5515            raw_line.to_string(),
5516        );
5517    }
5518    Ok(())
5519}
5520
5521fn push_claude_user(v: &Value, out: &mut Vec<ChatMessage>) {
5522    let content = v.get("message").and_then(|m| m.get("content"));
5523    let provenance = claude_user_provenance(v);
5524    match content {
5525        Some(Value::String(s)) => {
5526            if !s.trim().is_empty() {
5527                out.push(ChatMessage::user(s.clone()).with_metas(&provenance));
5528            }
5529        }
5530        Some(Value::Array(blocks)) => {
5531            let mut text = String::new();
5532            // IX-5: image blocks alongside/instead of text — collected
5533            // separately (never synthesized on a malformed shape, see
5534            // `claude_image_block_to_part`) so a multimodal user turn
5535            // survives as `content_parts` instead of the image silently
5536            // vanishing.
5537            let mut images: Vec<Value> = Vec::new();
5538            // D5: an `image` block whose `source` isn't base64/url (e.g. a
5539            // Files-API `{"source":{"type":"file","file_id":..}}`
5540            // reference) makes `claude_image_block_to_part` return `None` —
5541            // track that it was SEEN even though it couldn't be converted,
5542            // so an image-ONLY record (no text, no convertible image) isn't
5543            // silently dropped below (the same vanishing-record bug-class
5544            // PARITY-11 fixed for reasoning-only turns).
5545            let mut saw_unconvertible_image = false;
5546            for b in blocks {
5547                match b.get("type").and_then(Value::as_str) {
5548                    Some("text") => push_text(&mut text, b.get("text")),
5549                    Some("tool_result") => {
5550                        let id = b
5551                            .get("tool_use_id")
5552                            .and_then(Value::as_str)
5553                            .unwrap_or_default();
5554                        // PARITY-11 (nested images): `extract_tool_result_content`
5555                        // captures any `image` blocks nested inside this
5556                        // `tool_result` into `content_parts` (via
5557                        // `claude_image_block_to_part`, the same conversion the
5558                        // top-level `image` block path already uses) instead of
5559                        // flattening them to the bare `[image]` marker text the
5560                        // old `extract_tool_result` emitted — the everyday
5561                        // "Read a PNG / screenshot tool output" shape.
5562                        let (result, images) =
5563                            extract_tool_result_content(b.get("content"), v.get("toolUseResult"));
5564                        let mut msg = tool_message(id, result);
5565                        if !images.is_empty() {
5566                            // D-mix (Fable review, must-fix): `content_parts`
5567                            // is a self-contained contract — the pi writer
5568                            // (`pi_content_value`) reads ONLY `content_parts`
5569                            // for a `Role::Tool` message and never falls back
5570                            // to `msg.content`, so on a MIXED text+image
5571                            // tool_result a bare `content_parts: [image]`
5572                            // silently drops the sibling text on `convert
5573                            // --to pi` (a regression vs. the pre-PARITY-11
5574                            // baseline, which at least preserved the text).
5575                            // Prepend the text as part 0, exactly mirroring
5576                            // `pi_content_to_text_and_parts` and
5577                            // `push_opencode_user`'s identical
5578                            // self-contained-parts construction. `msg.content`
5579                            // keeps the text too (unchanged) for the writers
5580                            // that read text from `msg.content` and only scan
5581                            // `content_parts` for `image_url` entries
5582                            // (`claude_tool_result_content_value`,
5583                            // `codex_tool_output_text`, the opencode
5584                            // assistant writer) — those already filter
5585                            // strictly on `image_url`/text-typed lookups, so
5586                            // this text part is never double-counted.
5587                            let mut parts = Vec::new();
5588                            if let Some(t) = &msg.content {
5589                                if !t.is_empty() {
5590                                    parts.push(serde_json::json!({"type": "text", "text": t}));
5591                                }
5592                            }
5593                            parts.extend(images);
5594                            msg.content_parts = Some(parts);
5595                        }
5596                        // The assistant turn that issued this tool call — the
5597                        // tool-pairing graph edge (parallel to parentUuid).
5598                        if let Some(src) = v.get("sourceToolAssistantUUID").and_then(Value::as_str)
5599                        {
5600                            msg.metadata
5601                                .insert("sourceToolAssistantUUID".to_string(), src.to_string());
5602                        }
5603                        // TR-10: preserve the Claude wire `is_error` flag so
5604                        // the reduction layer's success/failure boundary
5605                        // (`ReductionKind::ToolInputElided` must never target
5606                        // an errored call) survives import — `ChatMessage`
5607                        // otherwise has no structural slot for it.
5608                        if b.get("is_error").and_then(Value::as_bool) == Some(true) {
5609                            crate::mark_tool_error(&mut msg);
5610                        } else {
5611                            restore_tool_outcome_extension(v, &mut msg);
5612                        }
5613                        out.push(msg);
5614                    }
5615                    Some("image") => match claude_image_block_to_part(b) {
5616                        Some(part) => images.push(part),
5617                        None => saw_unconvertible_image = true,
5618                    },
5619                    _ => {} // document / unknown — skip
5620                }
5621            }
5622            // D5: nothing convertible landed in `text`/`images` but an
5623            // image block WAS present — fold in the same short bracketed
5624            // marker convention already used for `[web_search]`/`[model
5625            // fallback: ...]` rather than letting the record vanish.
5626            if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
5627                push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
5628            }
5629            let before = out.len();
5630            if !images.is_empty() {
5631                let mut parts = Vec::new();
5632                if !text.trim().is_empty() {
5633                    parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
5634                }
5635                parts.extend(images);
5636                out.push(
5637                    ChatMessage {
5638                        role: Role::User,
5639                        content: None,
5640                        content_parts: Some(parts),
5641                        tool_calls: None,
5642                        tool_call_id: None,
5643                        name: None,
5644                        metadata: Default::default(),
5645                    }
5646                    .with_metas(&provenance),
5647                );
5648            } else if !text.trim().is_empty() {
5649                out.push(ChatMessage::user(text).with_metas(&provenance));
5650            }
5651            if saw_unconvertible_image && out.len() > before {
5652                if let Some(msg) = out.last_mut() {
5653                    msg.metadata
5654                        .insert("image_source_unconvertible".to_string(), "true".to_string());
5655                }
5656            }
5657        }
5658        _ => {}
5659    }
5660}
5661
5662/// D5 (Fable-5 review): the bracketed marker folded into `text` when an
5663/// `image` block is SEEN but [`claude_image_block_to_part`] can't convert it
5664/// (e.g. a Files-API `{"source":{"type":"file",...}}` reference) and nothing
5665/// else in the record survives either — matches the existing
5666/// `[web_search]`/`[model fallback: ...]` convention rather than letting the
5667/// whole record vanish (`push_claude_user`/`push_claude_assistant`).
5668const UNCONVERTIBLE_IMAGE_MARKER: &str =
5669    "[image: source not captured — unsupported/unconvertible image reference]";
5670
5671/// Parse a Claude Code user-turn `image` content block
5672/// (`{"type":"image","source":{"type":"base64","media_type":..,"data":..}}`
5673/// or `{"type":"image","source":{"type":"url","url":..}}`) into a
5674/// `content_parts` `image_url` entry (a `data:` URI for the base64 form, the
5675/// bare URL for the url form) — the inverse of
5676/// [`claude_user_content_value`]'s emission. Only a well-formed source
5677/// (non-empty `media_type`/`data`, or non-empty `url`) is recognized;
5678/// anything else — including a well-formed but unconvertible source like a
5679/// Files-API `{"type":"file",...}` reference (D5) — is left as raw-only
5680/// residue rather than synthesizing a corrupt/empty part (mirrors the
5681/// pi/opencode loaders' `pi_image_shape`/`opencode_file_image_part`
5682/// discipline). Callers must not let that turn the record invisible though:
5683/// see [`UNCONVERTIBLE_IMAGE_MARKER`].
5684fn claude_image_block_to_part(b: &Value) -> Option<Value> {
5685    let source = b.get("source")?;
5686    match source.get("type").and_then(Value::as_str) {
5687        Some("base64") => {
5688            let mime = source.get("media_type").and_then(Value::as_str)?;
5689            let data = source.get("data").and_then(Value::as_str)?;
5690            if mime.is_empty() || data.is_empty() {
5691                return None;
5692            }
5693            Some(serde_json::json!({
5694                "type": "image_url",
5695                "image_url": {"url": format!("data:{mime};base64,{data}")},
5696            }))
5697        }
5698        Some("url") => {
5699            let url = source.get("url").and_then(Value::as_str)?;
5700            if url.is_empty() {
5701                return None;
5702            }
5703            Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
5704        }
5705        _ => None,
5706    }
5707}
5708
5709/// Rebuild a Claude Code user-turn `message.content` value from a
5710/// `ChatMessage` — the inverse of `push_claude_user`'s parse (text blocks +
5711/// [`claude_image_block_to_part`]). When `content_parts` is absent this
5712/// MUST reproduce the historical plain-string `content` exactly (IX-5's
5713/// overriding constraint: a text-only message's export stays byte-identical)
5714/// — only a multimodal message (`content_parts` present, e.g. imported from
5715/// Pi/OpenCode or a real Claude Code image turn) gets the Anthropic
5716/// content-array shape, one `text` block (if any non-empty text part) plus
5717/// one `image` block per `image_url` part (`data:` URI → `source.base64`;
5718/// any other URL → `source.url`).
5719fn claude_user_content_value(msg: &ChatMessage) -> Value {
5720    match &msg.content_parts {
5721        Some(parts) => {
5722            let mut blocks = Vec::new();
5723            for p in parts {
5724                match p.get("type").and_then(Value::as_str) {
5725                    Some("text") => {
5726                        if let Some(t) = p.get("text").and_then(Value::as_str) {
5727                            if !t.is_empty() {
5728                                blocks.push(serde_json::json!({"type": "text", "text": t}));
5729                            }
5730                        }
5731                    }
5732                    Some("image_url") => {
5733                        if let Some(url) = p
5734                            .get("image_url")
5735                            .and_then(|u| u.get("url"))
5736                            .and_then(Value::as_str)
5737                        {
5738                            blocks.push(match parse_data_uri(url) {
5739                                Some((mime, data)) => serde_json::json!({
5740                                    "type": "image",
5741                                    "source": {"type": "base64", "media_type": mime, "data": data},
5742                                }),
5743                                None => serde_json::json!({
5744                                    "type": "image",
5745                                    "source": {"type": "url", "url": url},
5746                                }),
5747                            });
5748                        }
5749                    }
5750                    _ => {}
5751                }
5752            }
5753            Value::Array(blocks)
5754        }
5755        None => Value::String(msg.content.clone().unwrap_or_default()),
5756    }
5757}
5758
5759/// Rebuild a Claude Code `tool_result` block's `content` value from a `Tool`
5760/// `ChatMessage` — the inverse of [`extract_tool_result_content`]'s parse
5761/// (PARITY-11). When `content_parts` is absent (or empty) this MUST reproduce
5762/// the historical plain-string `content` exactly (same IX-5-style constraint
5763/// `claude_user_content_value` follows) — only a `tool_result` that actually
5764/// carries a captured nested image gets the Anthropic content-array shape,
5765/// one `text` block (the existing `msg.content`, if any) plus one `image`
5766/// block per `image_url` part (mirrors `claude_user_content_value`'s
5767/// `data:` URI -> `source.base64` / other URL -> `source.url` mapping).
5768fn claude_tool_result_content_value(msg: &ChatMessage) -> Value {
5769    match &msg.content_parts {
5770        Some(parts) if !parts.is_empty() => {
5771            let mut blocks = Vec::new();
5772            if let Some(t) = &msg.content {
5773                if !t.is_empty() {
5774                    blocks.push(serde_json::json!({"type": "text", "text": t}));
5775                }
5776            }
5777            for p in parts {
5778                if p.get("type").and_then(Value::as_str) == Some("image_url") {
5779                    if let Some(url) = p
5780                        .get("image_url")
5781                        .and_then(|u| u.get("url"))
5782                        .and_then(Value::as_str)
5783                    {
5784                        blocks.push(match parse_data_uri(url) {
5785                            Some((mime, data)) => serde_json::json!({
5786                                "type": "image",
5787                                "source": {"type": "base64", "media_type": mime, "data": data},
5788                            }),
5789                            None => serde_json::json!({
5790                                "type": "image",
5791                                "source": {"type": "url", "url": url},
5792                            }),
5793                        });
5794                    }
5795                }
5796            }
5797            Value::Array(blocks)
5798        }
5799        _ => Value::String(msg.content.clone().unwrap_or_default()),
5800    }
5801}
5802
5803/// Collect the Claude Code user-turn provenance fields that distinguish real
5804/// human input from system-injected turns and record replay-relevant state.
5805pub(crate) fn claude_user_provenance(v: &Value) -> Vec<(String, String)> {
5806    let mut out = Vec::new();
5807    let mut take_str = |key: &str| {
5808        if let Some(s) = v.get(key).and_then(Value::as_str) {
5809            out.push((key.to_string(), s.to_string()));
5810        }
5811    };
5812    take_str("promptSource"); // typed | queued | system | sdk
5813    take_str("interruptedMessageId");
5814    take_str("sourceToolUseID");
5815    for flag in ["isMeta", "isCompactSummary", "isVisibleInTranscriptOnly"] {
5816        if v.get(flag).and_then(Value::as_bool) == Some(true) {
5817            out.push((flag.to_string(), "true".to_string()));
5818        }
5819    }
5820    if let Some(n) = v.get("queuePriority").and_then(Value::as_i64) {
5821        out.push(("queuePriority".to_string(), n.to_string()));
5822    }
5823    // `origin` is an object like {"kind":"task-notification"} — keep its kind.
5824    if let Some(kind) = v
5825        .get("origin")
5826        .and_then(|o| o.get("kind"))
5827        .and_then(Value::as_str)
5828    {
5829        out.push(("origin".to_string(), kind.to_string()));
5830    }
5831    out
5832}
5833
5834/// Content-bearing Claude `system` events (`scheduled_task_fire`,
5835/// `local_command`, `away_summary`) carry real text that's part of the
5836/// interaction; fold them in as system context. Marker/metric subtypes
5837/// (`turn_duration`, `compact_boundary`, `api_error`, `stop_hook_summary`) carry
5838/// no conversational content and are skipped.
5839fn push_claude_system(v: &Value, out: &mut Vec<ChatMessage>) {
5840    let keep = matches!(
5841        v.get("subtype").and_then(Value::as_str),
5842        Some("scheduled_task_fire") | Some("local_command") | Some("away_summary")
5843    );
5844    if !keep {
5845        return;
5846    }
5847    if let Some(content) = v.get("content").and_then(Value::as_str) {
5848        if !content.trim().is_empty() {
5849            let subtype = v.get("subtype").and_then(Value::as_str).unwrap_or("system");
5850            out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
5851        }
5852    }
5853}
5854
5855/// Fold content-bearing Claude Code `attachment` records into the conversation
5856/// as user-role messages. Most attachment subtypes (`task_reminder`,
5857/// `deferred_tools_delta`, `skill_listing`, `hook_*`, `command_permissions`, …)
5858/// are regenerable system injections and are skipped; only the four that carry
5859/// non-regenerable user/external content are kept.
5860fn push_claude_attachment(v: &Value, out: &mut Vec<ChatMessage>) {
5861    let att = match v.get("attachment") {
5862        Some(a) => a,
5863        None => return,
5864    };
5865    let kind = match att.get("type").and_then(Value::as_str) {
5866        Some(kind) => kind,
5867        None => return,
5868    };
5869    let text = match kind {
5870        // A queued prompt. `commandMode` says whose: `prompt` is the person's
5871        // own text, `task-notification` is the runtime reporting a finished
5872        // background task. Kept verbatim below.
5873        "queued_command" => att
5874            .get("prompt")
5875            .and_then(Value::as_str)
5876            .map(str::to_string),
5877        // A file the user attached: header + contents.
5878        "file" => attachment_with_path(att, "attached file", "filename", "content"),
5879        // A user-edited file snippet.
5880        "edited_text_file" => attachment_with_path(att, "edited file", "filename", "snippet"),
5881        // Injected project memory (CLAUDE.md), point-in-time.
5882        "nested_memory" => attachment_with_path(att, "project memory", "path", "content"),
5883        _ => None, // regenerable system injection — skip
5884    };
5885    let Some(text) = text else { return };
5886    if text.trim().is_empty() {
5887        return;
5888    }
5889    // An attachment record wears the user's ROLE, but the record itself says
5890    // who actually spoke — and that fact is lost the moment the attachment is
5891    // flattened to `[label: path]` text, so carry it as metadata the way
5892    // `push_claude_system` carries `systemSubtype`. Two fields, both verbatim:
5893    //
5894    //   `attachmentType`  the subtype. `file` / `edited_text_file` /
5895    //                     `nested_memory` are envelopes the runtime built
5896    //                     around a file body; a frontend that trusts the role
5897    //                     shows the reader a numbered source listing in a
5898    //                     chat bubble apparently sent by themselves.
5899    //   `commandMode`     present on `queued_command` only, and the whole
5900    //                     story for it. Measured over the local Claude Code
5901    //                     corpus (2,512 `queued_command` attachments): 926
5902    //                     `prompt`, every one of them plain human text, and
5903    //                     1,586 `task-notification`, every one of them a
5904    //                     `<task-notification>` frame — the same text Claude
5905    //                     Code also writes as a `type:"user"` record stamped
5906    //                     `origin.kind = "task-notification"`.
5907    //
5908    // Presentation policy (which of these a frontend hides) belongs to the
5909    // frontend — `isContextMessage` in `sdk/client/client.mjs`. The loader's
5910    // job is to stop discarding the producer's own answer.
5911    let mut message = ChatMessage::user(text).with_meta("attachmentType", kind);
5912    if let Some(mode) = att.get("commandMode").and_then(Value::as_str) {
5913        message = message.with_meta("commandMode", mode);
5914    }
5915    out.push(message);
5916}
5917
5918/// Format an attachment as `[<label>: <path>]\n<body>`.
5919fn attachment_with_path(
5920    att: &Value,
5921    label: &str,
5922    path_key: &str,
5923    body_key: &str,
5924) -> Option<String> {
5925    let body = att.get(body_key).and_then(Value::as_str)?;
5926    let path = att
5927        .get(path_key)
5928        .or_else(|| att.get("displayPath"))
5929        .and_then(Value::as_str)
5930        .unwrap_or("");
5931    Some(format!("[{label}: {path}]\n{body}"))
5932}
5933
5934fn push_str_field(buf: &mut String, s: &str) {
5935    if !buf.is_empty() {
5936        buf.push('\n');
5937    }
5938    buf.push_str(s);
5939}
5940
5941/// N3: build a synthesized message for reasoning that could not attach to a
5942/// following assistant turn — either interrupted mid-stream by a
5943/// non-assistant item, or dangling at EOF (an aborted-turn shape). Drains
5944/// the three pending buffers (all empty/`false` afterward) so callers don't
5945/// separately have to remember to clear them.
5946fn orphaned_reasoning_message(
5947    reasoning: &mut String,
5948    reasoning_content: &mut String,
5949    encrypted: &mut bool,
5950) -> ChatMessage {
5951    let mut msg = ChatMessage::system("[reasoning] (turn ended without a reply)".to_string());
5952    if !reasoning.is_empty() {
5953        msg = msg.with_meta("reasoning", std::mem::take(reasoning));
5954    }
5955    if !reasoning_content.is_empty() {
5956        msg = msg.with_meta("reasoning_content", std::mem::take(reasoning_content));
5957    }
5958    if *encrypted {
5959        msg = msg.with_meta("reasoning_encrypted", "true");
5960        *encrypted = false;
5961    }
5962    msg
5963}
5964
5965fn push_claude_assistant(v: &Value, out: &mut Vec<ChatMessage>) {
5966    let content = v.get("message").and_then(|m| m.get("content"));
5967    let mut text = String::new();
5968    let mut calls: Vec<ToolCall> = Vec::new();
5969    // Legacy singular fields — kept for backward compatibility with every
5970    // existing consumer of `metadata["thinking"]`/`["thinking_signature"]`/
5971    // `["redacted_thinking"]` (a concatenation of all thinking text, and the
5972    // LAST block's signature/data). D8 (Fable-5 review, confirmed): when a
5973    // message carries MULTIPLE `thinking` blocks, collapsing them down to
5974    // these singular fields silently drops every signature but the last
5975    // one's — a real Anthropic `thinking` block's `signature` cryptographically
5976    // covers ONLY that block's own text, so re-emitting block 1's text under
5977    // block 2's signature (or vice versa) produces a signature that will
5978    // never verify. `thinking_blocks` below is the fix: every block
5979    // preserved SEPARATELY, in order, each with its own (optional)
5980    // signature/data — the writer prefers it over the legacy fields
5981    // whenever present.
5982    let mut thinking = String::new();
5983    let mut signature: Option<String> = None;
5984    // PARITY-11: real Claude corpora also carry `redacted_thinking` and
5985    // `image` assistant blocks, and (rarely) a `fallback` model-routing
5986    // marker — none handled before, all silently vanishing (audit's own
5987    // census: 132,927 `thinking` / 67 `redacted_thinking` / 9 `image` / 4
5988    // `fallback` blocks in the reference corpus).
5989    //
5990    // D8: `redacted_thinking` is real data ONLY — never a fabricated
5991    // placeholder. The pre-fix code defaulted a missing `data` field to the
5992    // literal string `"<redacted>"`, which is indistinguishable from an
5993    // actual (if oddly-named) opaque payload on re-emit — a caller reading
5994    // it back has no way to tell "no data was ever captured" from "the
5995    // provider's own opaque blob happens to be the string `<redacted>`".
5996    // `redacted_thinking_seen` tracks block PRESENCE independently of
5997    // whether it had real data, so the reasoning-only-turn rescue below
5998    // still fires even when no block had a `data` field at all.
5999    let mut redacted_thinking: Option<String> = None;
6000    let mut redacted_thinking_seen = false;
6001    let mut images: Vec<Value> = Vec::new();
6002    // Real Anthropic `thinking` blocks very commonly carry an EMPTY
6003    // `thinking` string alongside a real `signature` (the summarized/
6004    // redacted-in-the-clear-but-replayable case) — `thinking.trim()` alone
6005    // would miss those, so track "a thinking block existed at all"
6006    // separately from whether it had visible text.
6007    let mut thinking_block_seen = false;
6008    // D8: every `thinking`/`redacted_thinking` block, preserved SEPARATELY
6009    // and IN ORDER — see the comment on `thinking`/`redacted_thinking`
6010    // above. Serialized as a single JSON-array metadata string
6011    // (`ChatMessage::metadata` is a flat string map) under
6012    // `"thinking_blocks"`.
6013    let mut thinking_blocks: Vec<Value> = Vec::new();
6014    // D5: mirrors `push_claude_user`'s tracking — an `image` block whose
6015    // source isn't base64/url (e.g. Files-API `{"type":"file",...}`) must
6016    // not silently vanish the whole record when nothing else survives.
6017    let mut saw_unconvertible_image = false;
6018
6019    match content {
6020        Some(Value::String(s)) => push_text(&mut text, Some(&Value::String(s.clone()))),
6021        Some(Value::Array(blocks)) => {
6022            for b in blocks {
6023                match b.get("type").and_then(Value::as_str) {
6024                    Some("text") => push_text(&mut text, b.get("text")),
6025                    Some("tool_use") => {
6026                        let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
6027                        let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
6028                        let args = b
6029                            .get("input")
6030                            .map(|i| i.to_string())
6031                            .unwrap_or_else(|| "{}".to_string());
6032                        calls.push(function_call(id, name, args));
6033                    }
6034                    // Thinking is not replayed across providers, but retain it in
6035                    // (skip-serialized) metadata so a same-model continuation can
6036                    // re-inject it. See P3.
6037                    Some("thinking") => {
6038                        thinking_block_seen = true;
6039                        let t = b.get("thinking").and_then(Value::as_str).unwrap_or("");
6040                        if !t.is_empty() {
6041                            push_str_field(&mut thinking, t); // legacy concatenated field
6042                        }
6043                        let sig = b.get("signature").and_then(Value::as_str);
6044                        if let Some(s) = sig {
6045                            signature = Some(s.to_string()); // legacy last-wins field
6046                        }
6047                        // D8: this block's OWN text + signature, not folded
6048                        // into the running concatenation above.
6049                        let mut block = serde_json::json!({"type": "thinking", "thinking": t});
6050                        if let Some(s) = sig {
6051                            block["signature"] = Value::String(s.to_string());
6052                        }
6053                        thinking_blocks.push(block);
6054                    }
6055                    // Anthropic's redacted reasoning: an opaque, provider-private
6056                    // payload (flagged content the API declines to show in the
6057                    // clear). Like `thinking`, it's not replayable, but the raw
6058                    // `data` is retained in metadata rather than silently
6059                    // vanishing — a same-model continuation can still replay it
6060                    // verbatim even though supercode never renders it.
6061                    Some("redacted_thinking") => {
6062                        redacted_thinking_seen = true;
6063                        let data = b.get("data").and_then(Value::as_str);
6064                        // D8: no fabricated fallback — `data` is only ever
6065                        // the real captured payload, or genuinely absent.
6066                        if let Some(d) = data {
6067                            redacted_thinking = Some(d.to_string()); // legacy last-wins field
6068                        }
6069                        let mut block = serde_json::json!({"type": "redacted_thinking"});
6070                        if let Some(d) = data {
6071                            block["data"] = Value::String(d.to_string());
6072                        }
6073                        thinking_blocks.push(block);
6074                    }
6075                    // An assistant-emitted image block (e.g. a generated
6076                    // image) — collected exactly like `push_claude_user`'s
6077                    // user-turn image handling (`claude_image_block_to_part`
6078                    // is role-general), so it survives as `content_parts`
6079                    // instead of vanishing.
6080                    Some("image") => match claude_image_block_to_part(b) {
6081                        Some(part) => images.push(part),
6082                        None => saw_unconvertible_image = true,
6083                    },
6084                    // A provider-routing note (real shape:
6085                    // `{"type":"fallback","from":{"model":..},"to":{"model":..}}`
6086                    // — a mid-generation model swap, e.g. an overloaded model
6087                    // falling back to another). Carries no replayable
6088                    // conversational content, but folding it into `text` as a
6089                    // short bracketed marker — the same convention the Codex
6090                    // loader already uses for `[web_search]`/
6091                    // `[image_generation] ...` — keeps it visible instead of
6092                    // silently vanishing, including the case where it's the
6093                    // ONLY block in the turn (see the reasoning-only-turn fix
6094                    // below: before this, that shape dropped the entire
6095                    // message).
6096                    Some("fallback") => {
6097                        let from = b
6098                            .get("from")
6099                            .and_then(|f| f.get("model"))
6100                            .and_then(Value::as_str)
6101                            .unwrap_or("?");
6102                        let to = b
6103                            .get("to")
6104                            .and_then(|t| t.get("model"))
6105                            .and_then(Value::as_str)
6106                            .unwrap_or("?");
6107                        push_str_field(&mut text, &format!("[model fallback: {from} -> {to}]"));
6108                    }
6109                    _ => {}
6110                }
6111            }
6112        }
6113        _ => {}
6114    }
6115
6116    // D5: nothing convertible landed in `text`/`images` but an image block
6117    // WAS present — fold in the same bracketed-marker convention `fallback`
6118    // uses above, so a genuinely image-only (unconvertible source) turn
6119    // doesn't vanish (mirrors `push_claude_user`'s identical fix).
6120    if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
6121        push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
6122    }
6123
6124    let before = out.len();
6125    if !images.is_empty() {
6126        let mut parts = Vec::new();
6127        if !text.trim().is_empty() {
6128            parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
6129        }
6130        parts.extend(images);
6131        out.push(ChatMessage {
6132            role: Role::Assistant,
6133            content: None,
6134            content_parts: Some(parts),
6135            tool_calls: (!calls.is_empty()).then_some(calls),
6136            tool_call_id: None,
6137            name: None,
6138            metadata: Default::default(),
6139        });
6140    } else {
6141        push_assistant(out, text, calls);
6142        // A recognized native assistant record remains transcript state even
6143        // when its content array is empty (for example, an interrupted model
6144        // turn). Force a bare message whenever `push_assistant` had nothing
6145        // to emit. This includes the reasoning-only case and also preserves
6146        // genuinely part-less records instead of silently changing turn
6147        // count/order during translation.
6148        if out.len() == before {
6149            let mut empty = ChatMessage {
6150                role: Role::Assistant,
6151                content: None,
6152                content_parts: None,
6153                tool_calls: None,
6154                tool_call_id: None,
6155                name: None,
6156                metadata: Default::default(),
6157            };
6158            if !thinking_block_seen && !redacted_thinking_seen {
6159                empty
6160                    .metadata
6161                    .insert("empty_assistant_record".to_string(), "true".to_string());
6162            }
6163            out.push(empty);
6164        }
6165    }
6166    // Attach retained reasoning + attribution to the message we just produced.
6167    if out.len() > before {
6168        if let Some(msg) = out.last_mut() {
6169            // Insert "thinking" (even as an empty string) whenever a
6170            // `thinking` block was actually seen, not just when it had
6171            // visible text — a real `thinking` block commonly carries an
6172            // empty `thinking` string alongside a real `signature` (the
6173            // summarized-away-but-still-replayable case), and the writer
6174            // below keys its re-emission decision off this metadata key's
6175            // PRESENCE, not its content.
6176            if thinking_block_seen {
6177                msg.metadata.insert("thinking".to_string(), thinking);
6178            }
6179            if let Some(sig) = signature {
6180                msg.metadata.insert("thinking_signature".to_string(), sig);
6181            }
6182            if let Some(rt) = redacted_thinking {
6183                msg.metadata.insert("redacted_thinking".to_string(), rt);
6184            }
6185            // D8: exact per-block re-emission list — every `thinking`/
6186            // `redacted_thinking` block preserved separately, in order, each
6187            // with its own (optional) signature/data. The writer prefers
6188            // this over the legacy singular fields above whenever present,
6189            // so a multi-block message round-trips losslessly instead of
6190            // collapsing to one block under one (now-unverifiable)
6191            // signature.
6192            if !thinking_blocks.is_empty() {
6193                msg.metadata.insert(
6194                    "thinking_blocks".to_string(),
6195                    Value::Array(thinking_blocks).to_string(),
6196                );
6197            }
6198            // D5: honest signal that this message contained an image block
6199            // whose source this loader couldn't convert — the actual image
6200            // content is NOT captured, only a marker/partial record.
6201            if saw_unconvertible_image {
6202                msg.metadata
6203                    .insert("image_source_unconvertible".to_string(), "true".to_string());
6204            }
6205            // Attribution: which skill / subagent / MCP server+tool produced
6206            // this turn, plus the model `slug`.
6207            for key in [
6208                "attributionSkill",
6209                "attributionAgent",
6210                "attributionMcpServer",
6211                "attributionMcpTool",
6212                "slug",
6213            ] {
6214                if let Some(s) = v.get(key).and_then(Value::as_str) {
6215                    msg.metadata.insert(key.to_string(), s.to_string());
6216                }
6217            }
6218        }
6219    }
6220}
6221
6222// ---- Codex ----------------------------------------------------------------
6223
6224const SUPERCODE_CODEX_PROVENANCE_KEY: &str = "_supercode_codex_provenance";
6225
6226fn codex_provenance_kind(record: &Value) -> Option<&str> {
6227    match record.get("type").and_then(Value::as_str) {
6228        Some("session_meta") => Some("session_meta"),
6229        Some("turn_context") => Some("turn_context"),
6230        Some("compacted") => Some("compacted"),
6231        Some("event_msg") => match record
6232            .get("payload")
6233            .and_then(|payload| payload.get("type"))
6234            .and_then(Value::as_str)
6235        {
6236            Some("thread_rolled_back") => Some("event_msg/thread_rolled_back"),
6237            Some("thread_goal_updated") => Some("event_msg/thread_goal_updated"),
6238            Some("entered_review_mode") => Some("event_msg/entered_review_mode"),
6239            Some("exited_review_mode") => Some("event_msg/exited_review_mode"),
6240            _ => None,
6241        },
6242        _ => None,
6243    }
6244}
6245
6246fn capture_codex_provenance_record(
6247    meta: &mut SessionMeta,
6248    record_index: usize,
6249    raw_line: &str,
6250    record: &Value,
6251) {
6252    let Some(kind) = codex_provenance_kind(record) else {
6253        return;
6254    };
6255    meta.codex_provenance.push(serde_json::json!({
6256        "record_index": record_index,
6257        "kind": kind,
6258        "raw": raw_line,
6259    }));
6260}
6261
6262fn codex_provenance_envelope(meta: &SessionMeta) -> Option<Value> {
6263    (!meta.codex_provenance.is_empty()).then(|| {
6264        serde_json::json!({
6265            "version": 1,
6266            "records": &meta.codex_provenance,
6267        })
6268    })
6269}
6270
6271fn restore_codex_provenance(extension: &Value, meta: &mut SessionMeta) -> Result<bool> {
6272    if extension.get("version").and_then(Value::as_u64) != Some(1) {
6273        return Err(Error::InvalidSession(
6274            "invalid portable Codex provenance: expected version 1".to_string(),
6275        ));
6276    }
6277    let Some(records) = extension.get("records").and_then(Value::as_array) else {
6278        return Err(Error::InvalidSession(
6279            "invalid portable Codex provenance: `records` must be an array".to_string(),
6280        ));
6281    };
6282    if records.is_empty() {
6283        return Err(Error::InvalidSession(
6284            "invalid portable Codex provenance: `records` must not be empty".to_string(),
6285        ));
6286    }
6287    let mut restored = Vec::with_capacity(records.len());
6288    for entry in records {
6289        let Some(_record_index) = entry.get("record_index").and_then(Value::as_u64) else {
6290            return Err(Error::InvalidSession(
6291                "invalid portable Codex provenance: record_index must be an integer".to_string(),
6292            ));
6293        };
6294        let Some(kind) = entry.get("kind").and_then(Value::as_str) else {
6295            return Err(Error::InvalidSession(
6296                "invalid portable Codex provenance: kind must be a string".to_string(),
6297            ));
6298        };
6299        let Some(raw) = entry.get("raw").and_then(Value::as_str) else {
6300            return Err(Error::InvalidSession(
6301                "invalid portable Codex provenance: raw must be a string".to_string(),
6302            ));
6303        };
6304        let Ok(record) = serde_json::from_str::<Value>(raw) else {
6305            return Err(Error::InvalidSession(
6306                "invalid portable Codex provenance: raw is not valid JSON".to_string(),
6307            ));
6308        };
6309        if codex_provenance_kind(&record) != Some(kind) {
6310            return Err(Error::InvalidSession(format!(
6311                "invalid portable Codex provenance: kind `{kind}` does not match raw record"
6312            )));
6313        }
6314        restored.push(entry.clone());
6315    }
6316    meta.codex_provenance = restored;
6317    meta.codex_headers.clear();
6318    for entry in &meta.codex_provenance {
6319        let Some(raw) = entry.get("raw").and_then(Value::as_str) else {
6320            continue;
6321        };
6322        let Ok(record) = serde_json::from_str::<Value>(raw) else {
6323            continue;
6324        };
6325        if matches!(
6326            record.get("type").and_then(Value::as_str),
6327            Some("session_meta") | Some("turn_context")
6328        ) {
6329            meta.codex_headers.push(record);
6330        }
6331    }
6332    Ok(true)
6333}
6334
6335fn restore_codex_provenance_from_top_level(record: &Value, meta: &mut SessionMeta) -> Result<bool> {
6336    match record.get(SUPERCODE_CODEX_PROVENANCE_KEY) {
6337        Some(extension) => restore_codex_provenance(extension, meta),
6338        None => Ok(false),
6339    }
6340}
6341
6342fn inject_first_jsonl_top_level(out: &mut String, key: &str, extension: Value) {
6343    let Some(line_end) = out.find('\n') else {
6344        return;
6345    };
6346    let Ok(mut record) = serde_json::from_str::<Value>(&out[..line_end]) else {
6347        return;
6348    };
6349    let Some(object) = record.as_object_mut() else {
6350        return;
6351    };
6352    object.insert(key.to_string(), extension);
6353    out.replace_range(..line_end, &record.to_string());
6354}
6355
6356fn inject_codex_provenance(out: &mut String, extension: Value) {
6357    let Some(line_end) = out.find('\n') else {
6358        return;
6359    };
6360    let Ok(mut record) = serde_json::from_str::<Value>(&out[..line_end]) else {
6361        return;
6362    };
6363    if record.get("type").and_then(Value::as_str) != Some("session_meta") {
6364        return;
6365    }
6366    let Some(payload) = record.get_mut("payload").and_then(Value::as_object_mut) else {
6367        return;
6368    };
6369    payload.insert(SUPERCODE_CODEX_PROVENANCE_KEY.to_string(), extension);
6370    out.replace_range(..line_end, &record.to_string());
6371}
6372
6373/// Remove the last conversational turn from `messages`: everything from the
6374/// last `user` message to the end (the user prompt plus the assistant's
6375/// response and any tool calls/results it triggered).
6376fn remove_last_turn(messages: &mut Vec<ChatMessage>) {
6377    if let Some(idx) = messages.iter().rposition(|m| m.role == Role::User) {
6378        messages.truncate(idx);
6379    } else {
6380        messages.clear();
6381    }
6382    // IX-6 fix: the new tail exposed by `truncate` may still carry
6383    // `__codex_open_turn` from when it was marked (it was NOT the last
6384    // message at that time — items after it, now removed by the rollback,
6385    // intervened). A bare `function_call` arriving after the rollback is a
6386    // genuinely NEW turn and must get its own message, not merge into this
6387    // stale marked tail — close it out here so `push_codex_item`'s
6388    // adjacency check (`out.last()` + marker) can't be fooled by the
6389    // truncation re-exposing it.
6390    if let Some(last) = messages.last_mut() {
6391        last.metadata.remove("__codex_open_turn");
6392    }
6393}
6394
6395fn truncate_messages(messages: &mut Vec<ChatMessage>, message_limit: usize) {
6396    truncate_messages_with_anchor(messages, message_limit, Vec::new());
6397}
6398
6399fn truncate_messages_with_anchor(
6400    messages: &mut Vec<ChatMessage>,
6401    message_limit: usize,
6402    preceding_users: Vec<ChatMessage>,
6403) {
6404    let limit = message_limit.max(1);
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 needed_preceding = anchor_limit.saturating_sub(anchor_indices.len());
6420    let mut preceding_anchors = preceding_users
6421        .into_iter()
6422        .rev()
6423        .take(needed_preceding)
6424        .collect::<Vec<_>>();
6425    preceding_anchors.reverse();
6426    if messages.len() + preceding_anchors.len() <= limit {
6427        preceding_anchors.append(messages);
6428        *messages = preceding_anchors;
6429        return;
6430    }
6431    if messages.len() <= limit && preceding_anchors.is_empty() {
6432        return;
6433    }
6434    let anchor_count = anchor_indices.len() + preceding_anchors.len();
6435    let target_index_count = limit.saturating_sub(preceding_anchors.len());
6436    let mut selected_indices = anchor_indices.clone();
6437    for index in (0..messages.len()).rev() {
6438        if selected_indices.len() >= target_index_count || anchor_indices.contains(&index) {
6439            continue;
6440        }
6441        selected_indices.push(index);
6442    }
6443    selected_indices.sort_unstable();
6444    let mut selected = Vec::with_capacity(limit);
6445    selected.append(&mut preceding_anchors);
6446    selected.extend(
6447        selected_indices
6448            .into_iter()
6449            .map(|index| messages[index].clone()),
6450    );
6451    debug_assert_eq!(selected.len(), limit.max(anchor_count));
6452    *messages = selected;
6453}
6454
6455fn truncate_session_messages(session: &mut Session, message_limit: usize) {
6456    truncate_messages(&mut session.messages, message_limit);
6457}
6458
6459/// The text of a Codex `agent_message` event. `message` is usually a string but
6460/// can be a structured object (e.g. review output) — fall back to its JSON.
6461fn agent_message_text(payload: &Value) -> String {
6462    match payload.get("message") {
6463        Some(Value::String(s)) => s.clone(),
6464        Some(other) => extract_text_content(Some(other)),
6465        None => String::new(),
6466    }
6467}
6468
6469/// Trimmed texts of all assistant messages present as `response_item` — the
6470/// dedup set for recovering collab-only `agent_message` narration.
6471fn collect_codex_assistant_texts(jsonl: &str) -> std::collections::HashSet<String> {
6472    let mut set = std::collections::HashSet::new();
6473    for line in non_empty_lines(jsonl) {
6474        let Ok(v) = serde_json::from_str::<Value>(line) else {
6475            continue;
6476        };
6477        if v.get("type").and_then(Value::as_str) != Some("response_item") {
6478            continue;
6479        }
6480        let payload = v.get("payload").unwrap_or(&Value::Null);
6481        if payload.get("type").and_then(Value::as_str) == Some("message")
6482            && payload.get("role").and_then(Value::as_str) == Some("assistant")
6483        {
6484            let text = extract_text_content(payload.get("content"));
6485            if !text.trim().is_empty() {
6486                set.insert(text.trim().to_string());
6487            }
6488        }
6489    }
6490    set
6491}
6492
6493fn capture_codex_session_meta(payload: &Value, meta: &mut SessionMeta) {
6494    if meta.session_id.is_none() {
6495        if let Some(id) = payload.get("id").and_then(Value::as_str) {
6496            meta.session_id = Some(id.to_string());
6497        }
6498    }
6499    if meta.cwd.is_none() {
6500        if let Some(cwd) = payload.get("cwd").and_then(Value::as_str) {
6501            meta.cwd = Some(PathBuf::from(cwd));
6502        }
6503    }
6504    if meta.system_prompt.is_none() {
6505        // `base_instructions` may be a string or `{ "text": "..." }`.
6506        let bi = payload.get("base_instructions");
6507        let text = match bi {
6508            Some(Value::String(s)) => Some(s.clone()),
6509            Some(Value::Object(_)) => bi
6510                .and_then(|b| b.get("text"))
6511                .and_then(Value::as_str)
6512                .map(str::to_string),
6513            _ => None,
6514        };
6515        meta.system_prompt = text;
6516    }
6517    if meta.model.is_none() {
6518        if let Some(m) = payload.get("model").and_then(Value::as_str) {
6519            meta.model = Some(m.to_string());
6520        }
6521    }
6522    // Cross-file lineage keys for multi-agent / forked sessions.
6523    let mut put = |key: &str, v: Option<&Value>| {
6524        if let Some(s) = v.and_then(Value::as_str) {
6525            meta.lineage.insert(key.to_string(), s.to_string());
6526        }
6527    };
6528    put("parent_thread_id", payload.get("parent_thread_id"));
6529    put("forked_from_id", payload.get("forked_from_id"));
6530    put("thread_source", payload.get("thread_source"));
6531    // PARITY-10 dev/03: the other half of `write_synthesized_codex_header`'s
6532    // passthrough — restores a captured Claude `fork-context-ref` so a
6533    // Claude -> Codex -> Claude round trip reconstructs the original record
6534    // (`to_claude_code_jsonl` re-emits whatever lands in this lineage key).
6535    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
6536        if let Some(v) = payload.get("claude_fork_context_ref") {
6537            meta.lineage
6538                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
6539        }
6540    }
6541    if let Some(spawn) = payload
6542        .get("source")
6543        .and_then(|s| s.get("subagent"))
6544        .and_then(|s| s.get("thread_spawn"))
6545    {
6546        // parent_thread_id can also live here (preferred when both present).
6547        if let Some(p) = spawn.get("parent_thread_id").and_then(Value::as_str) {
6548            meta.lineage
6549                .insert("parent_thread_id".to_string(), p.to_string());
6550        }
6551        for k in ["agent_role", "agent_nickname"] {
6552            if let Some(s) = spawn.get(k).and_then(Value::as_str) {
6553                meta.lineage.insert(k.to_string(), s.to_string());
6554            }
6555        }
6556        if let Some(d) = spawn.get("depth").and_then(Value::as_i64) {
6557            meta.lineage.insert("depth".to_string(), d.to_string());
6558        }
6559    }
6560}
6561
6562/// Depth of a node in the parent forest (root = 0), bounded against cycles.
6563fn depth_of(mut i: usize, parent_of: &[Option<usize>]) -> usize {
6564    let mut d = 0;
6565    let mut guard = 0;
6566    while let Some(p) = parent_of[i] {
6567        if p == i || guard > parent_of.len() {
6568            break;
6569        }
6570        i = p;
6571        d += 1;
6572        guard += 1;
6573    }
6574    d
6575}
6576
6577/// Codex per-turn grouping id, stored under `payload.metadata.turn_id`.
6578fn codex_turn_id(payload: &Value) -> Option<&str> {
6579    payload
6580        .get("metadata")
6581        .and_then(|m| m.get("turn_id"))
6582        .and_then(Value::as_str)
6583}
6584
6585/// N2 (spliced-export hardening): every Codex group id already present in
6586/// `raw_prefix` — the verbatim RAW lines [`Session::to_codex_jsonl_spliced`]
6587/// replays ahead of the appended tail it synthesizes via
6588/// `Session::write_codex_records`. This is the GROUND TRUTH of what
6589/// physically lands in the exported `out` string for the prefix: each line
6590/// is parsed as a Codex envelope and its own `payload.metadata.turn_id` (the
6591/// exact field `codex_turn_id` reads, whether it's a real native `turn_id`
6592/// or one of OUR OWN fabricated `sc-grp-N`/`<real>~dupN` ids from a prior
6593/// export) is extracted directly — no re-derivation from `self.messages`
6594/// needed (that would have to reconstruct which ids the ORIGINAL export
6595/// happened to assign, which this sidesteps entirely by reading them back
6596/// out of the bytes themselves). A line that fails to parse, isn't a
6597/// `response_item`, or carries no `turn_id` contributes nothing — headers
6598/// and non-message/call records (e.g. `session_meta`, `function_call_output`)
6599/// never carry this field to begin with.
6600fn collect_codex_group_ids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
6601    let mut ids = HashSet::new();
6602    for line in raw_prefix {
6603        if let Ok(v) = serde_json::from_str::<Value>(line) {
6604            if let Some(payload) = v.get("payload") {
6605                if let Some(tid) = codex_turn_id(payload) {
6606                    ids.insert(tid.to_string());
6607                }
6608            }
6609        }
6610    }
6611    ids
6612}
6613
6614/// Stamp every `ChatMessage` appended to `messages` since index `from` with
6615/// `ts` (a Codex record's own top-level `timestamp`, ISO-8601) as the
6616/// canonical `metadata["timestamp"]` (WAVE-2 item 1) — the same
6617/// `entry(...).or_insert_with` discipline the pi/Claude loaders use, so a
6618/// message that already carries a more specific timestamp of its own is
6619/// never overwritten (none currently do on the Codex side, but this keeps
6620/// every loader consistent). A no-op when `ts` is `None` (a line with no
6621/// `timestamp` field) or `from >= messages.len()` (nothing new was pushed).
6622fn stamp_new_codex_messages(messages: &mut [ChatMessage], from: usize, ts: Option<&str>) {
6623    let Some(ts) = ts else { return };
6624    let Some(slice) = messages.get_mut(from..) else {
6625        return;
6626    };
6627    for m in slice {
6628        m.metadata
6629            .entry("timestamp".to_string())
6630            .or_insert_with(|| ts.to_string());
6631    }
6632}
6633
6634fn push_codex_item(payload: &Value, out: &mut Vec<ChatMessage>) {
6635    match payload.get("type").and_then(Value::as_str) {
6636        Some("message") => {
6637            let role = match payload.get("role").and_then(Value::as_str) {
6638                Some("user") => Role::User,
6639                Some("assistant") => Role::Assistant,
6640                // "developer" and "system" both carry operator instructions.
6641                _ => Role::System,
6642            };
6643            let content = payload.get("content");
6644            let text = extract_text_content(content);
6645            // IX-5: `input_image` blocks alongside/instead of text — see
6646            // `codex_extract_images`. A text-only message (no image blocks)
6647            // takes the historical `content: Some(text)` shape unchanged.
6648            let images = codex_extract_images(content);
6649            let is_empty_assistant =
6650                role == Role::Assistant && text.trim().is_empty() && images.is_empty();
6651            if !text.trim().is_empty() || !images.is_empty() || is_empty_assistant {
6652                let content_parts = if images.is_empty() {
6653                    None
6654                } else {
6655                    let mut parts = Vec::new();
6656                    if !text.trim().is_empty() {
6657                        parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
6658                    }
6659                    parts.extend(images);
6660                    Some(parts)
6661                };
6662                let mut msg = ChatMessage {
6663                    role,
6664                    content: if content_parts.is_some() || text.is_empty() {
6665                        None
6666                    } else {
6667                        Some(text)
6668                    },
6669                    content_parts,
6670                    tool_calls: None,
6671                    tool_call_id: None,
6672                    name: None,
6673                    metadata: Default::default(),
6674                };
6675                // Preserve the assistant `phase` (commentary vs final_answer) so
6676                // a reloaded transcript can distinguish narration from the answer.
6677                if role == Role::Assistant {
6678                    if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
6679                        msg.metadata.insert("phase".to_string(), phase.to_string());
6680                    }
6681                    // IX-6: mark this as an open, mergeable combined-turn
6682                    // candidate — a `function_call` response_item found
6683                    // immediately after (still `out.last()` when reached,
6684                    // i.e. no other item intervened) merges into this SAME
6685                    // `ChatMessage` instead of splitting into a second one,
6686                    // matching how Claude's parser keeps a text+tool_use
6687                    // turn together. Stripped again before the loaded
6688                    // `Session` is returned (`from_codex_str`), so it never
6689                    // leaks as visible metadata.
6690                    msg.metadata
6691                        .insert("__codex_open_turn".to_string(), "true".to_string());
6692                }
6693                // The per-turn grouping key (Codex batches items by turn_id).
6694                if let Some(tid) = codex_turn_id(payload) {
6695                    msg.metadata.insert("turn_id".to_string(), tid.to_string());
6696                }
6697                // PARITY-6 dev/02: restore the original Claude
6698                // `systemSubtype` for a `developer`/`system` message that
6699                // was itself synthesized FROM a real Claude system record
6700                // (`write_codex_records`'s `Role::System` arm stamps
6701                // `claude_system_subtype`) — the exact inverse, so
6702                // `write_claude_code_records`'s `Role::System` arm can
6703                // re-materialize the real Claude `type: "system"` record
6704                // faithfully on a Codex -> Claude Code hop instead of
6705                // guessing a fallback subtype.
6706                if role == Role::System {
6707                    if let Some(subtype) = payload
6708                        .get("metadata")
6709                        .and_then(|m| m.get("claude_system_subtype"))
6710                        .and_then(Value::as_str)
6711                    {
6712                        msg.metadata
6713                            .insert("systemSubtype".to_string(), subtype.to_string());
6714                    }
6715                }
6716                if is_empty_assistant {
6717                    msg.metadata
6718                        .insert("empty_assistant_record".to_string(), "true".to_string());
6719                }
6720                out.push(msg);
6721            }
6722        }
6723        Some("function_call") => {
6724            let id = payload
6725                .get("call_id")
6726                .and_then(Value::as_str)
6727                .unwrap_or_default();
6728            let raw_name = payload
6729                .get("name")
6730                .and_then(Value::as_str)
6731                .unwrap_or_default();
6732            // Preserve the MCP `namespace` by qualifying the tool name
6733            // (`<namespace>__<name>`, matching the mcp__server__tool convention),
6734            // so the tool identity isn't ambiguous on round-trip.
6735            let qualified;
6736            let name = match payload.get("namespace").and_then(Value::as_str) {
6737                Some(ns) if !ns.is_empty() && !raw_name.starts_with(ns) => {
6738                    qualified = format!("{ns}__{raw_name}");
6739                    qualified.as_str()
6740                }
6741                _ => raw_name,
6742            };
6743            let args = payload
6744                .get("arguments")
6745                .map(value_to_arg_string)
6746                .unwrap_or_else(|| "{}".to_string());
6747            let call = function_call(id, name, args);
6748            // IX-6: a `function_call` immediately after an assistant `message`
6749            // in the SAME turn (still `out.last()`, marked `__codex_open_turn`
6750            // by the "message" arm above, and not yet closed by anything else)
6751            // merges into that ONE `ChatMessage` — text→`content`,
6752            // call→`tool_calls` — instead of splitting into a second message.
6753            // A bare `function_call` with no such preceding turn (the marker
6754            // absent, or `out.last()` not an assistant message) is unaffected:
6755            // it still gets its own synthesized message, exactly as before.
6756            //
6757            // Belt-and-suspenders (PARITY-6/7 tightened): if this
6758            // `function_call` response_item itself carries a `turn_id` (rare
6759            // in observed real-native-Codex corpora — Codex usually only
6760            // stamps it on `message` payloads — but ALWAYS present on OUR
6761            // OWN synthesized export whenever a `ChatMessage`'s own tool
6762            // calls need merge disambiguation, see `write_codex_records`),
6763            // it must match the marked assistant message's recorded
6764            // `turn_id` EXACTLY — including "the marked message has none at
6765            // all" counting as a mismatch. That's exactly the shape of two
6766            // genuinely separate, adjacent `ChatMessage`s (an unrelated
6767            // text-only turn immediately followed by a different,
6768            // tool-call-only turn): the tool-only turn's own `function_call`s
6769            // carry a synthetic id while the unrelated preceding text
6770            // message carries none, so this correctly refuses the merge
6771            // instead of falling through to a permissive default. Only when
6772            // this `function_call` carries NO `turn_id` at all (the ordinary
6773            // real-native-Codex shape) does this fall back to the original
6774            // permissive "adjacency + open marker is enough" rule —
6775            // unchanged from before for the vast majority of real Codex
6776            // data. The truncation/clear strip above is what actually closes
6777            // the marker across rollback/compaction boundaries; this is only
6778            // an extra guard for the case where a stale-but-unstripped
6779            // marker and a turn_id mismatch coincide.
6780            let can_merge = out.last().is_some_and(|last| {
6781                last.role == Role::Assistant
6782                    && last.metadata.contains_key("__codex_open_turn")
6783                    && match codex_turn_id(payload) {
6784                        Some(fc_tid) => {
6785                            last.metadata.get("turn_id").map(String::as_str) == Some(fc_tid)
6786                        }
6787                        None => true,
6788                    }
6789            });
6790            if can_merge {
6791                out.last_mut()
6792                    .expect("can_merge implies out.last() is Some")
6793                    .tool_calls
6794                    .get_or_insert_with(Vec::new)
6795                    .push(call);
6796            } else {
6797                push_assistant(out, String::new(), vec![call]);
6798                // PARITY-6/7: a BARE tool-call turn (no preceding `message`
6799                // in this turn, so nothing set `__codex_open_turn` above) can
6800                // still be the FIRST of several tool calls that all belong to
6801                // the SAME original `ChatMessage` (`write_codex_records`
6802                // stamps every one of a message's own tool calls with the
6803                // identical synthetic `turn_id`). Re-open THIS freshly
6804                // created message — but ONLY when a real `turn_id` is
6805                // present — so the NEXT `function_call` in the same group
6806                // merges into it instead of becoming its own message too.
6807                // Gated on `codex_turn_id(payload).is_some()` (not the bare
6808                // default `true` the belt-and-suspenders check above uses)
6809                // so real native Codex data — which almost never carries
6810                // this field on `function_call` payloads (see the comment
6811                // above) — keeps its existing "every bare tool call is its
6812                // own turn" behavior exactly as before.
6813                if let Some(tid) = codex_turn_id(payload) {
6814                    if let Some(last) = out.last_mut() {
6815                        last.metadata
6816                            .insert("__codex_open_turn".to_string(), "true".to_string());
6817                        last.metadata.insert("turn_id".to_string(), tid.to_string());
6818                    }
6819                }
6820            }
6821        }
6822        Some("function_call_output") => {
6823            let id = payload
6824                .get("call_id")
6825                .and_then(Value::as_str)
6826                .unwrap_or_default();
6827            let result = match payload.get("output") {
6828                Some(Value::String(s)) => s.clone(),
6829                Some(v) => extract_text_content(Some(v)),
6830                None => String::new(),
6831            };
6832            let mut message = tool_message(id, result);
6833            // TR-13: Codex v1 exposes no structured success/error field on
6834            // this record. Free-text output is not a safe classifier, so the
6835            // reduction engine must treat the outcome as explicitly unknown
6836            // and fail closed on both success-only and error-only pruning.
6837            crate::mark_tool_outcome_unknown(&mut message);
6838            out.push(message);
6839        }
6840        // Custom / MCP tool calls are shaped like function calls but carry their
6841        // arguments under `input` (a JSON-encoded string). Normalize them the
6842        // same way so MCP-using sessions don't lose those turns.
6843        Some("custom_tool_call") => {
6844            let id = payload
6845                .get("call_id")
6846                .and_then(Value::as_str)
6847                .unwrap_or_default();
6848            let name = payload
6849                .get("name")
6850                .and_then(Value::as_str)
6851                .unwrap_or_default();
6852            // Unlike `function_call.arguments`, Codex custom tools accept a
6853            // free-form `input` string (apply_patch is the common case).
6854            // Canonical `FunctionCall::arguments` must remain valid JSON, so
6855            // retain the input's JSON type instead of treating a free-form
6856            // string as if it were already a JSON document. This lets every
6857            // target harness carry the value rather than silently replacing
6858            // it with `{}` when `parsed_arguments()` fails.
6859            let args = payload
6860                .get("input")
6861                .map(Value::to_string)
6862                .unwrap_or_else(|| "{}".to_string());
6863            push_assistant(out, String::new(), vec![function_call(id, name, args)]);
6864            if let Some(message) = out.last_mut() {
6865                message.metadata.insert(
6866                    "codex_custom_tool_call_ids".to_string(),
6867                    serde_json::json!([id]).to_string(),
6868                );
6869            }
6870        }
6871        Some("custom_tool_call_output") => {
6872            let id = payload
6873                .get("call_id")
6874                .and_then(Value::as_str)
6875                .unwrap_or_default();
6876            let result = match payload.get("output") {
6877                Some(Value::String(s)) => s.clone(),
6878                Some(v) => extract_text_content(Some(v)),
6879                None => String::new(),
6880            };
6881            let mut message = tool_message(id, result);
6882            crate::mark_tool_outcome_unknown(&mut message);
6883            out.push(message);
6884        }
6885        // Tool-search is a clean call/output pair keyed by call_id.
6886        //
6887        // D1: this arm used to ALWAYS start a brand-new `ChatMessage`,
6888        // ignoring the `turn_id` merge stamps `write_codex_records` puts on
6889        // its own synthesized `tool_search_call` records (see the PARITY-6/7
6890        // comment there and on `codex_turn_id`/the `function_call` arm
6891        // above). That left the same bug-class the turn_id work fixed for
6892        // `function_call` half-done here: a single Claude assistant message
6893        // containing text + a `tool_search` block reloaded as 2 messages
6894        // (1 -> 2 inflation), and a message with 2 `tool_search` blocks
6895        // reloaded as 3. Mirror the `function_call` arm's merge check
6896        // exactly so a `tool_search_call` immediately following an open
6897        // assistant turn (or another tool call sharing the same `turn_id`)
6898        // merges into that SAME `ChatMessage` instead of splitting.
6899        Some("tool_search_call") => {
6900            let id = payload
6901                .get("call_id")
6902                .and_then(Value::as_str)
6903                .unwrap_or_default();
6904            let args = payload
6905                .get("arguments")
6906                .map(value_to_arg_string)
6907                .unwrap_or_else(|| "{}".to_string());
6908            let call = function_call(id, "tool_search", args);
6909            let can_merge = out.last().is_some_and(|last| {
6910                last.role == Role::Assistant
6911                    && last.metadata.contains_key("__codex_open_turn")
6912                    && match codex_turn_id(payload) {
6913                        Some(fc_tid) => {
6914                            last.metadata.get("turn_id").map(String::as_str) == Some(fc_tid)
6915                        }
6916                        None => true,
6917                    }
6918            });
6919            if can_merge {
6920                out.last_mut()
6921                    .expect("can_merge implies out.last() is Some")
6922                    .tool_calls
6923                    .get_or_insert_with(Vec::new)
6924                    .push(call);
6925            } else {
6926                push_assistant(out, String::new(), vec![call]);
6927                // Re-open the freshly created message so a FOLLOWING
6928                // `function_call`/`tool_search_call` sharing this same
6929                // `turn_id` merges into it too — matching the bare
6930                // `function_call` case's own re-open logic above.
6931                if let Some(tid) = codex_turn_id(payload) {
6932                    if let Some(last) = out.last_mut() {
6933                        last.metadata
6934                            .insert("__codex_open_turn".to_string(), "true".to_string());
6935                        last.metadata.insert("turn_id".to_string(), tid.to_string());
6936                    }
6937                }
6938            }
6939        }
6940        Some("tool_search_output") => {
6941            let id = payload
6942                .get("call_id")
6943                .and_then(Value::as_str)
6944                .unwrap_or_default();
6945            let result = payload
6946                .get("tools")
6947                .map(value_to_arg_string)
6948                .unwrap_or_default();
6949            out.push(tool_message(id, result));
6950        }
6951        // Web-search / image-generation response_items carry no paired output
6952        // here (results live in event_msg), so emit an assistant marker rather
6953        // than a dangling unanswered tool call.
6954        Some("web_search_call") => {
6955            push_assistant(out, "[web_search]".to_string(), Vec::new());
6956        }
6957        Some("image_generation_call") => {
6958            let prompt = payload
6959                .get("revised_prompt")
6960                .and_then(Value::as_str)
6961                .unwrap_or("");
6962            push_assistant(
6963                out,
6964                format!("[image_generation] {prompt}").trim().to_string(),
6965                Vec::new(),
6966            );
6967        }
6968        // "reasoning" and anything else — dropped.
6969        _ => {}
6970    }
6971}
6972
6973// ---- Grok -------------------------------------------------------------
6974
6975const SUPERCODE_GEMINI_MESSAGE_KEY: &str = "_supercode_gemini_message";
6976
6977fn set_gemini_message_extension(value: &mut Value, message: &ChatMessage) {
6978    value[SUPERCODE_GEMINI_MESSAGE_KEY] = serde_json::json!({
6979        "schema": 1,
6980        "role": message.role,
6981        "content": message.content,
6982        "content_parts": message.content_parts,
6983        "tool_calls": message.tool_calls,
6984        "tool_call_id": message.tool_call_id,
6985        "name": message.name,
6986        "metadata": message.metadata,
6987    });
6988}
6989
6990fn restore_gemini_message_extension(value: &Value, message: &mut ChatMessage) {
6991    let Some(extension) = value.get(SUPERCODE_GEMINI_MESSAGE_KEY) else {
6992        return;
6993    };
6994    if extension.get("schema").and_then(Value::as_u64) != Some(1) {
6995        return;
6996    }
6997    if let Some(role) = extension
6998        .get("role")
6999        .and_then(|value| serde_json::from_value(value.clone()).ok())
7000    {
7001        message.role = role;
7002    }
7003    message.content = extension
7004        .get("content")
7005        .and_then(Value::as_str)
7006        .map(str::to_string);
7007    message.content_parts = extension
7008        .get("content_parts")
7009        .and_then(|value| serde_json::from_value(value.clone()).ok());
7010    message.tool_calls = extension
7011        .get("tool_calls")
7012        .and_then(|value| serde_json::from_value(value.clone()).ok());
7013    message.tool_call_id = extension
7014        .get("tool_call_id")
7015        .and_then(Value::as_str)
7016        .map(str::to_string);
7017    message.name = extension
7018        .get("name")
7019        .and_then(Value::as_str)
7020        .map(str::to_string);
7021    message.metadata.clear();
7022    if let Some(metadata) = extension.get("metadata").and_then(Value::as_object) {
7023        for (key, value) in metadata {
7024            if let Some(value) = value.as_str() {
7025                message.metadata.insert(key.clone(), value.to_string());
7026            }
7027        }
7028    }
7029}
7030
7031fn capture_grok_scalar_metadata(value: &Value, message: &mut ChatMessage, keys: &[&str]) {
7032    for key in keys {
7033        if let Some(value) = value.get(*key) {
7034            message.metadata.insert(
7035                format!("grok_{key}"),
7036                value
7037                    .as_str()
7038                    .map(str::to_string)
7039                    .unwrap_or_else(|| value.to_string()),
7040            );
7041        }
7042    }
7043}
7044
7045fn grok_human_user_text(raw: &str) -> Option<String> {
7046    let text = raw.trim();
7047    if text.is_empty() || text.starts_with("<user_info>") || text.starts_with("<system-reminder>") {
7048        return None;
7049    }
7050    let unwrapped = text
7051        .strip_prefix("<user_query>")
7052        .and_then(|value| value.strip_suffix("</user_query>"))
7053        .map(str::trim)
7054        .unwrap_or(text);
7055    (!unwrapped.is_empty()).then(|| unwrapped.to_string())
7056}
7057
7058/// Portable extension for messages whose canonical fields cannot be expressed
7059/// by the target's stock schema. It was introduced for Grok and retains that
7060/// on-disk key for compatibility. Gemini has the same need: Claude Code and
7061/// Codex have no native slot for a tool-result name or Gemini-only metadata.
7062/// Their readers tolerate unknown namespaced fields, so forwarding this
7063/// adapter-owned envelope keeps those cross-format hops reversible without
7064/// pretending the stock schemas represent the fields directly.
7065const SUPERCODE_GROK_MESSAGE_KEY: &str = "_supercode_grok_message";
7066
7067/// Namespaced line-level extension carrying the one tool-result outcome state
7068/// Claude cannot represent natively. Keeping this narrower than the full Grok
7069/// portability envelope avoids changing unrelated target-message projection.
7070const SUPERCODE_TOOL_OUTCOME_KEY: &str = "_supercode_tool_outcome";
7071
7072fn restore_tool_outcome_extension(value: &Value, message: &mut ChatMessage) {
7073    if !crate::is_tool_error(message)
7074        && value
7075            .get(SUPERCODE_TOOL_OUTCOME_KEY)
7076            .and_then(Value::as_str)
7077            == Some("unknown")
7078    {
7079        crate::mark_tool_outcome_unknown(message);
7080    }
7081}
7082
7083fn grok_message_extension(source: SessionSource, message: &ChatMessage) -> Option<Value> {
7084    let metadata = message
7085        .metadata
7086        .iter()
7087        .filter(|(key, _)| {
7088            key.starts_with("gemini_") || key.starts_with("grok_") || key.starts_with("goose_")
7089        })
7090        .map(|(key, value)| (key.clone(), Value::String(value.clone())))
7091        .collect::<serde_json::Map<_, _>>();
7092
7093    // `meta.source` changes after every reload. Keying portability only on
7094    // the immediate source therefore made Grok metadata survive one hop but
7095    // disappear on A -> B -> C translations. Once Grok-owned fields are
7096    // present, keep forwarding them regardless of the current container.
7097    let has_portable_fields =
7098        !metadata.is_empty() || message.metadata.contains_key("codex_custom_tool_call_ids");
7099    (matches!(
7100        source,
7101        SessionSource::Gemini | SessionSource::Grok | SessionSource::Goose
7102    ) || has_portable_fields
7103        || message.content_parts.is_some())
7104    .then(|| {
7105        serde_json::json!({
7106            "schema": 2,
7107            "role": message.role,
7108            "content": message.content,
7109            "content_parts": message.content_parts,
7110            "tool_calls": message.tool_calls,
7111            "tool_call_id": message.tool_call_id,
7112            "name": message.name,
7113            "metadata": message.metadata,
7114        })
7115    })
7116}
7117
7118fn set_grok_target_message_extension(value: &mut Value, message: &ChatMessage) {
7119    value[SUPERCODE_GROK_MESSAGE_KEY] = serde_json::json!({
7120        "schema": 2,
7121        "role": message.role,
7122        "content": message.content,
7123        "content_parts": message.content_parts,
7124        "tool_calls": message.tool_calls,
7125        "tool_call_id": message.tool_call_id,
7126        "name": message.name,
7127        "metadata": message.metadata,
7128    });
7129}
7130
7131fn set_grok_message_extension(value: &mut Value, source: SessionSource, message: &ChatMessage) {
7132    if let Some(extension) = grok_message_extension(source, message) {
7133        value[SUPERCODE_GROK_MESSAGE_KEY] = extension;
7134    }
7135}
7136
7137fn restore_grok_message_extension(value: &Value, message: &mut ChatMessage) {
7138    let Some(extension) = value.get(SUPERCODE_GROK_MESSAGE_KEY) else {
7139        return;
7140    };
7141    // Codex temporarily marks a text assistant item so immediately-following
7142    // function-call items can merge back into the same canonical turn. The
7143    // portable envelope must not erase that loader-private marker before the
7144    // merge happens; `from_codex_str` removes it before returning.
7145    let codex_open_turn = message.metadata.get("__codex_open_turn").cloned();
7146    let codex_turn_id = message.metadata.get("turn_id").cloned();
7147    let extension_has_turn_id = extension
7148        .get("metadata")
7149        .and_then(Value::as_object)
7150        .is_some_and(|metadata| metadata.contains_key("turn_id"));
7151    if extension.get("schema").and_then(Value::as_u64) == Some(2) {
7152        if let Some(role) = extension
7153            .get("role")
7154            .and_then(|value| serde_json::from_value(value.clone()).ok())
7155        {
7156            message.role = role;
7157        }
7158        message.content = extension
7159            .get("content")
7160            .and_then(Value::as_str)
7161            .map(str::to_string);
7162        message.content_parts = extension
7163            .get("content_parts")
7164            .and_then(|value| serde_json::from_value(value.clone()).ok());
7165        // Tool calls are shared native structure in every supported format.
7166        // Keep the loader's reconstruction instead of restoring this copy:
7167        // Codex stores a combined text+tool turn across multiple records, so
7168        // eagerly restoring calls on its text record would duplicate them
7169        // when the following function-call records merge.
7170        message.tool_call_id = extension
7171            .get("tool_call_id")
7172            .and_then(Value::as_str)
7173            .map(str::to_string);
7174        message.name = extension
7175            .get("name")
7176            .and_then(Value::as_str)
7177            .map(str::to_string);
7178        message.metadata.clear();
7179    }
7180    if let Some(metadata) = extension.get("metadata").and_then(Value::as_object) {
7181        for (key, value) in metadata {
7182            if let Some(value) = value.as_str() {
7183                message.metadata.insert(key.clone(), value.to_string());
7184            }
7185        }
7186    }
7187    if let Some(name) = extension.get("name").and_then(Value::as_str) {
7188        message.name = Some(name.to_string());
7189    }
7190    if let Some(marker) = codex_open_turn {
7191        message
7192            .metadata
7193            .insert("__codex_open_turn".to_string(), marker);
7194    }
7195    if let Some(turn_id) = codex_turn_id {
7196        message.metadata.insert("turn_id".to_string(), turn_id);
7197        if !extension_has_turn_id {
7198            message.metadata.insert(
7199                "__grok_remove_synthetic_turn_id".to_string(),
7200                "true".to_string(),
7201            );
7202        }
7203    }
7204}
7205
7206fn restore_single_grok_message(value: &Value, messages: &mut [ChatMessage]) {
7207    if let [message] = messages {
7208        restore_grok_message_extension(value, message);
7209    }
7210}
7211
7212fn normalize_goose_message(native: &Value, native_index: usize, out: &mut Vec<ChatMessage>) {
7213    let role = match native.get("role").and_then(Value::as_str) {
7214        Some("assistant") => Role::Assistant,
7215        _ => Role::User,
7216    };
7217    let created = native.get("created").and_then(Value::as_i64);
7218    let native_id = native.get("id").and_then(Value::as_str);
7219    let mut text = Vec::new();
7220    let mut content_parts = Vec::new();
7221    let mut tool_calls = Vec::new();
7222    let mut tool_results = Vec::new();
7223
7224    for (block_index, block) in native
7225        .get("content")
7226        .and_then(Value::as_array)
7227        .into_iter()
7228        .flatten()
7229        .enumerate()
7230    {
7231        match block.get("type").and_then(Value::as_str) {
7232            Some("text") => {
7233                if let Some(value) = block.get("text").and_then(Value::as_str) {
7234                    text.push(value.to_string());
7235                    content_parts.push(serde_json::json!({"type": "text", "text": value}));
7236                }
7237            }
7238            Some("image") => {
7239                let data = block
7240                    .get("data")
7241                    .and_then(Value::as_str)
7242                    .unwrap_or_default();
7243                let media_type = block
7244                    .get("mimeType")
7245                    .or_else(|| block.get("mime_type"))
7246                    .and_then(Value::as_str)
7247                    .unwrap_or("application/octet-stream");
7248                content_parts.push(serde_json::json!({
7249                    "type": "image_url",
7250                    "image_url": {"url": format!("data:{media_type};base64,{data}")},
7251                }));
7252            }
7253            Some("toolRequest" | "frontendToolRequest") => {
7254                let id = block
7255                    .get("id")
7256                    .and_then(Value::as_str)
7257                    .map(str::to_string)
7258                    .unwrap_or_else(|| format!("goose-{native_index}-{block_index}"));
7259                let call = block
7260                    .get("toolCall")
7261                    .and_then(|call| {
7262                        (call.get("status").and_then(Value::as_str) == Some("success"))
7263                            .then(|| call.get("value"))
7264                            .flatten()
7265                    })
7266                    .or_else(|| block.get("toolCall"));
7267                let Some(call) = call else { continue };
7268                let name = call.get("name").and_then(Value::as_str).unwrap_or("tool");
7269                let arguments = call
7270                    .get("arguments")
7271                    .map(value_to_arg_string)
7272                    .unwrap_or_else(|| "{}".to_string());
7273                tool_calls.push(function_call(&id, name, arguments));
7274            }
7275            Some("toolResponse") => tool_results.push(block.clone()),
7276            _ => {}
7277        }
7278    }
7279
7280    if !text.is_empty() || !content_parts.is_empty() || !tool_calls.is_empty() {
7281        let has_non_text = content_parts
7282            .iter()
7283            .any(|part| part.get("type").and_then(Value::as_str) != Some("text"));
7284        let mut message = ChatMessage {
7285            role,
7286            content: (!text.is_empty()).then(|| text.join("\n")),
7287            content_parts: has_non_text.then_some(content_parts),
7288            tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
7289            tool_call_id: None,
7290            name: None,
7291            metadata: Default::default(),
7292        };
7293        capture_goose_message_metadata(native, created, native_id, &mut message);
7294        out.push(message);
7295    }
7296
7297    for (result_index, block) in tool_results.into_iter().enumerate() {
7298        let id = block
7299            .get("id")
7300            .and_then(Value::as_str)
7301            .map(str::to_string)
7302            .unwrap_or_else(|| format!("goose-{native_index}-result-{result_index}"));
7303        let result = block.get("toolResult").unwrap_or(&Value::Null);
7304        let status_error = result.get("status").and_then(Value::as_str) == Some("error");
7305        let value = result.get("value").unwrap_or(result);
7306        let is_error = status_error || value.get("isError").and_then(Value::as_bool) == Some(true);
7307        let output = if status_error {
7308            result
7309                .get("error")
7310                .and_then(Value::as_str)
7311                .unwrap_or("Goose tool call failed")
7312                .to_string()
7313        } else {
7314            value
7315                .get("content")
7316                .and_then(Value::as_array)
7317                .map(|content| {
7318                    content
7319                        .iter()
7320                        .filter_map(|part| {
7321                            part.get("text")
7322                                .and_then(Value::as_str)
7323                                .map(str::to_string)
7324                                .or_else(|| Some(part.to_string()))
7325                        })
7326                        .collect::<Vec<_>>()
7327                        .join("\n")
7328                })
7329                .unwrap_or_else(|| value.to_string())
7330        };
7331        let mut message = tool_message(&id, output);
7332        if is_error {
7333            crate::mark_tool_error(&mut message);
7334        }
7335        capture_goose_message_metadata(native, created, native_id, &mut message);
7336        out.push(message);
7337    }
7338}
7339
7340fn capture_goose_message_metadata(
7341    native: &Value,
7342    created: Option<i64>,
7343    native_id: Option<&str>,
7344    message: &mut ChatMessage,
7345) {
7346    if let Some(created) = created {
7347        message
7348            .metadata
7349            .insert("goose_created".to_string(), created.to_string());
7350    }
7351    if let Some(native_id) = native_id {
7352        message
7353            .metadata
7354            .insert("goose_message_id".to_string(), native_id.to_string());
7355    }
7356    if let Some(metadata) = native.get("metadata") {
7357        message
7358            .metadata
7359            .insert("goose_metadata".to_string(), metadata.to_string());
7360    }
7361}
7362
7363#[doc(hidden)]
7364pub fn percent_decode_path(encoded: &str) -> Option<String> {
7365    fn hex(byte: u8) -> Option<u8> {
7366        match byte {
7367            b'0'..=b'9' => Some(byte - b'0'),
7368            b'a'..=b'f' => Some(byte - b'a' + 10),
7369            b'A'..=b'F' => Some(byte - b'A' + 10),
7370            _ => None,
7371        }
7372    }
7373
7374    let bytes = encoded.as_bytes();
7375    let mut decoded = Vec::with_capacity(bytes.len());
7376    let mut index = 0usize;
7377    while index < bytes.len() {
7378        if bytes[index] == b'%' {
7379            let high = *bytes.get(index + 1)?;
7380            let low = *bytes.get(index + 2)?;
7381            decoded.push(hex(high)? * 16 + hex(low)?);
7382            index += 3;
7383        } else {
7384            decoded.push(bytes[index]);
7385            index += 1;
7386        }
7387    }
7388    String::from_utf8(decoded).ok()
7389}
7390
7391// ---- Pi ---------------------------------------------------------------
7392
7393fn capture_pi_header(v: &Value, meta: &mut SessionMeta) -> Result<()> {
7394    restore_codex_provenance_from_top_level(v, meta)?;
7395    if let Some(id) = v.get("id").and_then(Value::as_str) {
7396        meta.session_id = Some(id.to_string());
7397    }
7398    if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
7399        meta.cwd = Some(PathBuf::from(cwd));
7400    }
7401    // Absent `version` means a pre-v3 file (`pi-fields.md` §1: "absent = v1").
7402    let version = v
7403        .get("version")
7404        .and_then(Value::as_u64)
7405        .map(|n| n.to_string())
7406        .unwrap_or_else(|| "1".to_string());
7407    meta.lineage.insert("pi_version".to_string(), version);
7408    if let Some(ts) = v.get("timestamp").and_then(Value::as_str) {
7409        meta.lineage
7410            .insert("created_at".to_string(), ts.to_string());
7411    }
7412    if let Some(ps) = v.get("parentSession").and_then(Value::as_str) {
7413        meta.lineage
7414            .insert("parent_session_path".to_string(), ps.to_string());
7415    }
7416    // D7: the other half of `push_pi_header`'s passthrough — restores a
7417    // captured Claude `fork-context-ref` so a Claude -> Pi -> Claude round
7418    // trip reconstructs the original record (mirrors
7419    // `capture_codex_session_meta`'s identical `claude_fork_context_ref`
7420    // restore for the Codex hop).
7421    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
7422        if let Some(v) = v.get("claude_fork_context_ref") {
7423            meta.lineage
7424                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
7425        }
7426    }
7427    Ok(())
7428}
7429
7430/// Validate one `ImageContent` block's `{mimeType, data}` shape and return
7431/// `(mime, data)` when it looks like a real image payload.
7432///
7433/// **This shape is UNVERIFIED against real pi output**: `pi-fields.md` cites
7434/// `ai:316-350` for the `ImageContent` content-block union but does not
7435/// enumerate `ImageContent`'s own fields (only `TextContent`/`ThinkingContent`/
7436/// `ToolCall` are itemized there) — `{mimeType, data}` (mirroring the OpenAI/
7437/// Anthropic multimodal wire shape) is this loader's best guess, not a
7438/// frozen-spec fact. A follow-up TR tracks confirming/correcting this shape
7439/// against a real pi corpus. Until then this function VALIDATES rather than
7440/// assumes: both fields must be present, non-empty strings, and `data` must
7441/// look like base64 (only the base64 alphabet, incl. `=` padding) — anything
7442/// else is an unknown/unexpected image shape, and the caller must route the
7443/// whole message to raw-only survival (S6-style fail loud) instead of
7444/// silently synthesizing a corrupt/empty `image_url` part.
7445fn pi_image_shape(item: &Value) -> Option<(String, String)> {
7446    let mime = item.get("mimeType").and_then(Value::as_str)?;
7447    let data = item.get("data").and_then(Value::as_str)?;
7448    if mime.is_empty() || data.is_empty() {
7449        return None;
7450    }
7451    if !data
7452        .bytes()
7453        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
7454    {
7455        return None;
7456    }
7457    Some((mime.to_string(), data.to_string()))
7458}
7459
7460/// True if `content` (a pi content value: bare string or
7461/// `(TextContent|ImageContent)[]`) contains at least one `type:"image"` block
7462/// that does not match [`pi_image_shape`] — shared by the loader (which
7463/// routes such a message to raw-only survival, never a synthesized-empty
7464/// part) and the coverage audit (`audit::Corpus::Pi`), which scores it as
7465/// `message/UnknownImageShape` instead of silently `Normalized`, so the shape
7466/// mismatch surfaces as a coverage FAILURE rather than vanishing.
7467#[doc(hidden)]
7468pub fn pi_content_has_unknown_image_shape(content: Option<&Value>) -> bool {
7469    let Some(Value::Array(items)) = content else {
7470        return false;
7471    };
7472    items.iter().any(|item| {
7473        item.get("type").and_then(Value::as_str) == Some("image") && pi_image_shape(item).is_none()
7474    })
7475}
7476
7477/// Split a pi `(TextContent|ImageContent)[]` (or bare string) content value
7478/// into concatenated text plus, when a WELL-FORMED image block is present,
7479/// the full `content_parts` array (leading text block + one `image_url` part
7480/// per image, its `data:` URI carrying the exact `mimeType`/`data` bytes pi
7481/// stored) — shared by `user`/`toolResult`/`custom*` content, which all use
7482/// the identical union (`pi-fields.md` §3a/§3c/§3e).
7483///
7484/// Returns `(text, parts, unknown_image_shape)`. When an image block does NOT
7485/// match [`pi_image_shape`] (missing/empty `data`/`mimeType`, or a `data`
7486/// value that isn't recognizable base64), this NEVER synthesizes an empty/
7487/// corrupt `image_url` part — it reports `unknown_image_shape = true` and
7488/// every caller must treat that as raw-only survival for the whole message
7489/// (mirroring the unknown-`message.role` rule, S6), so a shape this loader
7490/// guessed wrong fails loud instead of silently dropping/corrupting the
7491/// image.
7492fn pi_content_to_text_and_parts(content: Option<&Value>) -> (String, Option<Vec<Value>>, bool) {
7493    match content {
7494        Some(Value::String(s)) => (s.clone(), None, false),
7495        Some(Value::Array(items)) => {
7496            let mut text = String::new();
7497            let mut parts: Vec<Value> = Vec::new();
7498            let mut has_image = false;
7499            let mut unknown_image_shape = false;
7500            for item in items {
7501                match item.get("type").and_then(Value::as_str) {
7502                    Some("text") => {
7503                        if let Some(t) = item.get("text").and_then(Value::as_str) {
7504                            push_str_field(&mut text, t);
7505                        }
7506                    }
7507                    Some("image") => {
7508                        has_image = true;
7509                        match pi_image_shape(item) {
7510                            Some((mime, data)) => {
7511                                parts.push(serde_json::json!({
7512                                    "type": "image_url",
7513                                    "image_url": {"url": format!("data:{mime};base64,{data}")},
7514                                }));
7515                            }
7516                            None => unknown_image_shape = true,
7517                        }
7518                    }
7519                    _ => {}
7520                }
7521            }
7522            if unknown_image_shape {
7523                // Never synthesize an empty/corrupt part for a shape we
7524                // don't recognize — raw-only survival for the whole message;
7525                // the coverage guard is what turns this into a visible
7526                // failure (S6-style).
7527                return (String::new(), None, true);
7528            }
7529            if has_image {
7530                if !text.trim().is_empty() {
7531                    parts.insert(0, serde_json::json!({"type": "text", "text": text.clone()}));
7532                }
7533                (text, Some(parts), false)
7534            } else {
7535                (text, None, false)
7536            }
7537        }
7538        _ => (String::new(), None, false),
7539    }
7540}
7541
7542fn push_pi_user(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7543    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
7544    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7545    // survival, never a synthesized-empty part. `audit::Corpus::Pi`'s
7546    // `message/UnknownImageShape` bucket is what turns this into a visible
7547    // coverage failure.
7548    if unknown_image_shape {
7549        return;
7550    }
7551    if text.trim().is_empty() && parts.is_none() {
7552        return;
7553    }
7554    let mut msg = match parts {
7555        Some(parts) => ChatMessage {
7556            role: Role::User,
7557            content: None,
7558            content_parts: Some(parts),
7559            tool_calls: None,
7560            tool_call_id: None,
7561            name: None,
7562            metadata: Default::default(),
7563        },
7564        None => ChatMessage::user(text),
7565    };
7566    // WAVE-2 fidelity fix: pi's message-level unix-ms clock
7567    // (`message.timestamp`) is a DISTINCT field from the canonical
7568    // entry-level ISO `metadata["timestamp"]` WAVE-2 item 1 wires — the two
7569    // carry genuinely different values in real corpora (the fixture's are
7570    // ~6 months apart). Preserve it separately so it isn't silently lost for
7571    // every pi session; see `msg_pi_native_timestamp_ms` (the pi writer's
7572    // native round-trip consumer) and the INHERENT residue note on
7573    // `pi_dropped_keys_cross` in `interop_fidelity_matrix.rs`.
7574    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7575        msg.metadata
7576            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7577    }
7578    out.push(msg);
7579}
7580
7581fn push_pi_assistant(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7582    let mut text = String::new();
7583    let mut calls: Vec<ToolCall> = Vec::new();
7584    let mut thinking = String::new();
7585    let mut thinking_seen = false;
7586    let mut thinking_sig: Option<String> = None;
7587    let mut thinking_redacted = false;
7588    let mut text_sig: Option<String> = None;
7589    let mut thought_sig: Option<String> = None;
7590
7591    if let Some(Value::Array(blocks)) = msg_v.get("content") {
7592        for b in blocks {
7593            match b.get("type").and_then(Value::as_str) {
7594                Some("text") => {
7595                    if let Some(t) = b.get("text").and_then(Value::as_str) {
7596                        push_str_field(&mut text, t);
7597                    }
7598                    if let Some(sig) = b.get("textSignature") {
7599                        text_sig = Some(match sig {
7600                            Value::String(s) => s.clone(),
7601                            other => other.to_string(),
7602                        });
7603                    }
7604                }
7605                Some("thinking") => {
7606                    thinking_seen = true;
7607                    if let Some(t) = b.get("thinking").and_then(Value::as_str) {
7608                        push_str_field(&mut thinking, t);
7609                    }
7610                    if let Some(sig) = b.get("thinkingSignature").and_then(Value::as_str) {
7611                        thinking_sig = Some(sig.to_string());
7612                    }
7613                    if b.get("redacted").and_then(Value::as_bool) == Some(true) {
7614                        thinking_redacted = true;
7615                    }
7616                }
7617                Some("toolCall") => {
7618                    let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
7619                    let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
7620                    // `arguments` is a JSON OBJECT on pi's wire, not a string
7621                    // (`pi-fields.md` §3b open question 4) — serialize to the
7622                    // string `FunctionCall::arguments` expects.
7623                    let args = b
7624                        .get("arguments")
7625                        .cloned()
7626                        .unwrap_or_else(|| Value::Object(Default::default()));
7627                    calls.push(function_call(id, name, args.to_string()));
7628                    if let Some(sig) = b.get("thoughtSignature").and_then(Value::as_str) {
7629                        thought_sig = Some(sig.to_string());
7630                    }
7631                }
7632                _ => {}
7633            }
7634        }
7635    }
7636
7637    let before = out.len();
7638    push_assistant(out, text, calls);
7639    // A recognized native assistant entry remains transcript state even
7640    // when its content array is empty, except Pi's explicit empty error
7641    // response: that record has no replayable content and is established
7642    // raw-only residue (`pi_real_corpus_error_retry`). Preserve empty
7643    // non-error turns and Pi's standalone thinking-block shape.
7644    let is_empty_error =
7645        !thinking_seen && msg_v.get("stopReason").and_then(Value::as_str) == Some("error");
7646    if out.len() == before && !is_empty_error {
7647        let mut empty = ChatMessage {
7648            role: Role::Assistant,
7649            content: None,
7650            content_parts: None,
7651            tool_calls: None,
7652            tool_call_id: None,
7653            name: None,
7654            metadata: Default::default(),
7655        };
7656        if !thinking_seen {
7657            empty
7658                .metadata
7659                .insert("empty_assistant_record".to_string(), "true".to_string());
7660        }
7661        out.push(empty);
7662    }
7663    if out.len() > before {
7664        let msg = out.last_mut().expect("just pushed");
7665        if thinking_seen {
7666            msg.metadata.insert("thinking".to_string(), thinking);
7667        }
7668        if let Some(s) = thinking_sig {
7669            msg.metadata.insert("thinking_signature".to_string(), s);
7670        }
7671        if thinking_redacted {
7672            msg.metadata
7673                .insert("pi_thinking_redacted".to_string(), "true".to_string());
7674        }
7675        if let Some(s) = text_sig {
7676            msg.metadata.insert("pi_text_signature".to_string(), s);
7677        }
7678        if let Some(s) = thought_sig {
7679            msg.metadata.insert("pi_thought_signature".to_string(), s);
7680        }
7681        for (key, field) in [
7682            ("pi_api", "api"),
7683            ("pi_provider", "provider"),
7684            ("pi_response_model", "responseModel"),
7685            ("pi_response_id", "responseId"),
7686            ("pi_stop_reason", "stopReason"),
7687            ("pi_error_message", "errorMessage"),
7688        ] {
7689            if let Some(s) = msg_v.get(field).and_then(Value::as_str) {
7690                msg.metadata.insert(key.to_string(), s.to_string());
7691            }
7692        }
7693        if let Some(diag) = msg_v.get("diagnostics") {
7694            if !diag.is_null() {
7695                msg.metadata
7696                    .insert("pi_diagnostics".to_string(), diag.to_string());
7697            }
7698        }
7699        if let Some(usage) = msg_v.get("usage") {
7700            if !usage.is_null() {
7701                msg.metadata
7702                    .insert("pi_usage".to_string(), usage.to_string());
7703            }
7704        }
7705        // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7706        // separately from the canonical entry-level ISO `timestamp` — see
7707        // `push_pi_user`.
7708        if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7709            msg.metadata
7710                .insert("pi_msg_timestamp".to_string(), ts.to_string());
7711        }
7712    }
7713}
7714
7715fn push_pi_tool_result(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7716    let id = msg_v
7717        .get("toolCallId")
7718        .and_then(Value::as_str)
7719        .unwrap_or_default();
7720    let name = msg_v
7721        .get("toolName")
7722        .and_then(Value::as_str)
7723        .unwrap_or_default();
7724    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
7725    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7726    // survival, never a synthesized-empty part. Dropping the toolResult
7727    // message here leaves its `toolCallId` unanswered, which
7728    // `ensure_tool_results_paired` already turns into a visible
7729    // "[no tool result recorded — turn interrupted]" placeholder — a loud
7730    // failure mode, not a silent one.
7731    if unknown_image_shape {
7732        return;
7733    }
7734    let mut msg = ChatMessage {
7735        role: Role::Tool,
7736        content: Some(text),
7737        content_parts: parts,
7738        tool_calls: None,
7739        tool_call_id: Some(id.to_string()),
7740        name: Some(name.to_string()),
7741        metadata: Default::default(),
7742    };
7743    if let Some(details) = msg_v.get("details") {
7744        if !details.is_null() {
7745            msg.metadata
7746                .insert("pi_tool_details".to_string(), details.to_string());
7747        }
7748    }
7749    let is_error = msg_v
7750        .get("isError")
7751        .and_then(Value::as_bool)
7752        .unwrap_or(false);
7753    msg.metadata
7754        .insert("pi_is_error".to_string(), is_error.to_string());
7755    if is_error {
7756        crate::mark_tool_error(&mut msg);
7757    }
7758    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7759    // separately from the canonical entry-level ISO `timestamp` — see
7760    // `push_pi_user`.
7761    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7762        msg.metadata
7763            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7764    }
7765    out.push(msg);
7766}
7767
7768/// Render a pi `bashExecution` message (`!`/`!!` shell escape) to the text
7769/// pi itself sends the model, mirroring `bashExecutionToText`
7770/// (`pi-fields.md` §3d, `msg:82-98`). The exact upstream string constants
7771/// aren't reproduced in the frozen research doc (only cited by file:line),
7772/// so this is a faithful, clearly-labeled reconstruction — every structured
7773/// field is additionally preserved verbatim in `metadata`/`raw` regardless.
7774fn push_pi_bash(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7775    let command = msg_v.get("command").and_then(Value::as_str).unwrap_or("");
7776    let output = msg_v.get("output").and_then(Value::as_str).unwrap_or("");
7777    let exit_code = msg_v.get("exitCode").and_then(Value::as_i64);
7778    let cancelled = msg_v
7779        .get("cancelled")
7780        .and_then(Value::as_bool)
7781        .unwrap_or(false);
7782    let truncated = msg_v
7783        .get("truncated")
7784        .and_then(Value::as_bool)
7785        .unwrap_or(false);
7786
7787    let mut text = format!("$ {command}\n{output}");
7788    if let Some(code) = exit_code {
7789        if code != 0 {
7790            text.push_str(&format!("\n[exit code: {code}]"));
7791        }
7792    }
7793    if cancelled {
7794        text.push_str("\n[cancelled]");
7795    }
7796    if truncated {
7797        text.push_str("\n[truncated]");
7798    }
7799
7800    let mut msg = ChatMessage::user(text);
7801    msg.metadata
7802        .insert("pi_bash_command".to_string(), command.to_string());
7803    msg.metadata
7804        .insert("pi_bash_output".to_string(), output.to_string());
7805    if let Some(code) = exit_code {
7806        msg.metadata
7807            .insert("pi_bash_exit_code".to_string(), code.to_string());
7808    }
7809    msg.metadata
7810        .insert("pi_bash_cancelled".to_string(), cancelled.to_string());
7811    msg.metadata
7812        .insert("pi_bash_truncated".to_string(), truncated.to_string());
7813    if let Some(p) = msg_v.get("fullOutputPath").and_then(Value::as_str) {
7814        msg.metadata
7815            .insert("pi_bash_full_output_path".to_string(), p.to_string());
7816    }
7817    // `!!` — hidden from the model context; honored by `is_replay_excluded`
7818    // on every writer, not just pi's own (§2.2).
7819    if msg_v.get("excludeFromContext").and_then(Value::as_bool) == Some(true) {
7820        msg.metadata
7821            .insert("pi_exclude_from_context".to_string(), "true".to_string());
7822    }
7823    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7824    // separately from the canonical entry-level ISO `timestamp` — see
7825    // `push_pi_user`.
7826    if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7827        msg.metadata
7828            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7829    }
7830    out.push(msg);
7831}
7832
7833/// B4: the `customType` marker `write_pi_entries`'s `Role::System` arm
7834/// stamps on a re-materialized content-bearing Claude `system` record (see
7835/// that arm's doc comment). Namespaced (`supercode_`-prefixed) so it can
7836/// never collide with a real pi `CustomMessage.customType` — pi's own
7837/// hook-injected custom types are hook/extension names (e.g. `hookMessage`
7838/// migration targets), never this literal string.
7839const PI_CLAUDE_SYSTEM_CUSTOM_TYPE: &str = "supercode_claude_system";
7840
7841/// Shared mapping for pi's `role:"custom"` message (§3e) and top-level
7842/// `custom_message` entries (§9) — both enter context as a `User` message
7843/// with the same `customType`/`display`/`details` residue.
7844///
7845/// B4 exception: when `customType` is [`PI_CLAUDE_SYSTEM_CUSTOM_TYPE`] (our
7846/// own marker — see `write_pi_entries`'s `Role::System` arm), this is
7847/// actually a re-materialized content-bearing Claude `system` record round-
7848/// tripping through pi, not a genuine pi extension message — restore
7849/// `Role::System` + `metadata["systemSubtype"]` (from `details.
7850/// claude_system_subtype`, falling back to `local_command` — still one of
7851/// `push_claude_system`'s own keep subtypes — exactly like
7852/// `write_codex_records`'s Codex-leg fallback) instead of the generic
7853/// `Role::User` path below, so a Claude -> Pi -> Claude round trip restores
7854/// the exact original role, not just the text.
7855fn push_pi_custom_common(v: &Value, out: &mut Vec<ChatMessage>) {
7856    if v.get("customType").and_then(Value::as_str) == Some(PI_CLAUDE_SYSTEM_CUSTOM_TYPE) {
7857        let content = v.get("content").and_then(Value::as_str).unwrap_or("");
7858        if content.trim().is_empty() {
7859            return;
7860        }
7861        let subtype = v
7862            .get("details")
7863            .and_then(|d| d.get("claude_system_subtype"))
7864            .and_then(Value::as_str)
7865            .unwrap_or("local_command");
7866        out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
7867        return;
7868    }
7869    let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(v.get("content"));
7870    // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7871    // survival, never a synthesized-empty part.
7872    if unknown_image_shape {
7873        return;
7874    }
7875    if text.trim().is_empty() && parts.is_none() {
7876        return;
7877    }
7878    let mut msg = match parts {
7879        Some(parts) => ChatMessage {
7880            role: Role::User,
7881            content: None,
7882            content_parts: Some(parts),
7883            tool_calls: None,
7884            tool_call_id: None,
7885            name: None,
7886            metadata: Default::default(),
7887        },
7888        None => ChatMessage::user(text),
7889    };
7890    if let Some(ct) = v.get("customType").and_then(Value::as_str) {
7891        msg.metadata
7892            .insert("pi_custom_type".to_string(), ct.to_string());
7893    }
7894    if let Some(d) = v.get("display").and_then(Value::as_bool) {
7895        msg.metadata.insert("pi_display".to_string(), d.to_string());
7896    }
7897    if let Some(details) = v.get("details") {
7898        if !details.is_null() {
7899            msg.metadata
7900                .insert("pi_details".to_string(), details.to_string());
7901        }
7902    }
7903    // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7904    // separately from the canonical entry-level ISO `timestamp` — see
7905    // `push_pi_user`. `v` here is the `message` object for the `role:
7906    // "custom"` case; for the top-level `custom_message` case `v` is the
7907    // entry itself, whose `timestamp` is the entry-level ISO string (not a
7908    // u64), so this is a no-op there — matching pre-WAVE-2 behavior.
7909    if let Some(ts) = v.get("timestamp").and_then(Value::as_u64) {
7910        msg.metadata
7911            .insert("pi_msg_timestamp".to_string(), ts.to_string());
7912    }
7913    out.push(msg);
7914}
7915
7916/// pi's own prefix-wrapped user text for a `compaction` entry summary
7917/// (§2.1: "compaction/branch summaries (pi's own prefix-wrapped user text)").
7918/// The exact upstream wrapper string is cited (`msg:11-17`) but not
7919/// reproduced in the frozen research doc; this is a clearly-labeled
7920/// reconstruction, not pi's literal bytes — see build-report ambiguity note.
7921fn push_pi_compaction(entry_v: &Value, out: &mut Vec<ChatMessage>) {
7922    let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
7923    if summary.trim().is_empty() {
7924        return;
7925    }
7926    let mut msg = ChatMessage::user(format!("[compaction summary]\n{summary}"));
7927    msg.metadata
7928        .insert("pi_type".to_string(), "compaction".to_string());
7929    if let Some(fk) = entry_v.get("firstKeptEntryId").and_then(Value::as_str) {
7930        msg.metadata
7931            .insert("pi_first_kept_entry_id".to_string(), fk.to_string());
7932    }
7933    if let Some(tb) = entry_v.get("tokensBefore").and_then(Value::as_u64) {
7934        msg.metadata
7935            .insert("pi_tokens_before".to_string(), tb.to_string());
7936    }
7937    if let Some(d) = entry_v.get("details") {
7938        if !d.is_null() {
7939            msg.metadata.insert("pi_details".to_string(), d.to_string());
7940        }
7941    }
7942    if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
7943        msg.metadata
7944            .insert("pi_from_hook".to_string(), "true".to_string());
7945    }
7946    out.push(msg);
7947}
7948
7949/// pi's own prefix-wrapped user text for a `branch_summary` entry (a
7950/// rewind-with-summary) — same reconstruction caveat as
7951/// [`push_pi_compaction`].
7952fn push_pi_branch_summary(entry_v: &Value, out: &mut Vec<ChatMessage>) {
7953    let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
7954    if summary.trim().is_empty() {
7955        return;
7956    }
7957    let mut msg = ChatMessage::user(format!("[branch summary]\n{summary}"));
7958    msg.metadata
7959        .insert("pi_type".to_string(), "branch_summary".to_string());
7960    if let Some(f) = entry_v.get("fromId").and_then(Value::as_str) {
7961        msg.metadata.insert("pi_from_id".to_string(), f.to_string());
7962    }
7963    if let Some(d) = entry_v.get("details") {
7964        if !d.is_null() {
7965            msg.metadata.insert("pi_details".to_string(), d.to_string());
7966        }
7967    }
7968    if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
7969        msg.metadata
7970            .insert("pi_from_hook".to_string(), "true".to_string());
7971    }
7972    out.push(msg);
7973}
7974
7975// ---- OpenCode ---------------------------------------------------------
7976
7977/// The placeholder opencode's own replay substitutes for a `tool` part's
7978/// output once `state.completed.time.compacted` is set
7979/// (`message-v2.ts:293-296 @fd9ee43`) — the REAL output is never actually
7980/// erased from the record (S1); it survives in `raw` and in this loader's
7981/// `metadata["oc_tool_output_compacted"]`.
7982pub const OPENCODE_COMPACTED_TOOL_PLACEHOLDER: &str = "[Old tool result content cleared]";
7983
7984fn capture_opencode_session_info(si: &Value, meta: &mut SessionMeta) -> Result<()> {
7985    restore_codex_provenance_from_top_level(si, meta)?;
7986    if let Some(id) = si.get("id").and_then(Value::as_str) {
7987        meta.session_id = Some(id.to_string());
7988    }
7989    if let Some(dir) = si.get("directory").and_then(Value::as_str) {
7990        meta.cwd = Some(PathBuf::from(dir));
7991    }
7992    if let Some(agent) = si.get("agent").and_then(Value::as_str) {
7993        meta.agent_id = Some(agent.to_string());
7994    }
7995    if let Some(model) = si.get("model") {
7996        let provider = model.get("providerID").and_then(Value::as_str);
7997        let id = model.get("id").and_then(Value::as_str);
7998        if let (Some(p), Some(i)) = (provider, id) {
7999            meta.model = Some(format!("{p}/{i}"));
8000        }
8001    }
8002    if let Some(project_id) = si.get("projectID").and_then(Value::as_str) {
8003        meta.lineage
8004            .insert("projectID".to_string(), project_id.to_string());
8005    }
8006    if let Some(slug) = si.get("slug").and_then(Value::as_str) {
8007        meta.lineage.insert("slug".to_string(), slug.to_string());
8008    }
8009    if let Some(ws) = si.get("workspaceID").and_then(Value::as_str) {
8010        meta.lineage
8011            .insert("workspaceID".to_string(), ws.to_string());
8012    }
8013    if let Some(parent) = si.get("parentID").and_then(Value::as_str) {
8014        meta.lineage
8015            .insert("parent_session_id".to_string(), parent.to_string());
8016        // Mirrored under the Codex-originated lineage key so the existing
8017        // generic `Session::reconstruct_tree` nests opencode subagent
8018        // sessions too, with no format-specific nesting pass (§2.1: "child
8019        // session's parentID ... → drives reconstruct_tree").
8020        meta.lineage
8021            .insert("parent_thread_id".to_string(), parent.to_string());
8022    }
8023    // D7: the other half of `synthesized_opencode_info`'s passthrough —
8024    // restores a captured Claude `fork-context-ref` so a Claude -> OpenCode
8025    // -> Claude round trip reconstructs the original record (mirrors
8026    // `capture_codex_session_meta`/`capture_pi_header`'s identical
8027    // `claude_fork_context_ref` restore for the Codex/Pi hops).
8028    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
8029        if let Some(v) = si.get("claude_fork_context_ref") {
8030            meta.lineage
8031                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
8032        }
8033    }
8034    Ok(())
8035}
8036
8037/// An opencode `User`/`Assistant` `file` part's image data-URI →
8038/// `content_parts` `image_url` entry (§2.1). Only `data:` URIs with an
8039/// `image/*` mime are mapped ("T3 clean for images; non-media residue") —
8040/// a bare filesystem path, an `https:` link, or a non-image mime is left as
8041/// raw-only residue (§2.3), never a corrupt/guessed `image_url`. `pub(crate)`
8042/// so [`crate::audit::audit_opencode_line`] can classify a `file` part's
8043/// coverage with the SAME test this loader uses to canonicalize it (D5) —
8044/// one definition of "is this file part actually replayed", not two.
8045#[doc(hidden)]
8046pub fn opencode_file_image_part(part: &Value) -> Option<Value> {
8047    let mime = part.get("mime").and_then(Value::as_str)?;
8048    let url = part.get("url").and_then(Value::as_str)?;
8049    if !mime.starts_with("image/") || !url.starts_with("data:") {
8050        return None;
8051    }
8052    Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
8053}
8054
8055/// B4: the part-`metadata` key `append_synthesized_opencode_messages`'s
8056/// `Role::System` arm stamps on the one `synthetic: true` text part of a
8057/// re-materialized content-bearing Claude `system` record (see that arm's
8058/// doc comment). Namespaced (`supercode_`-prefixed) so it can never collide
8059/// with real opencode/provider part metadata (e.g. `anthropic.signature`).
8060const OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: &str = "supercode_claude_system_subtype";
8061
8062/// Detect `append_synthesized_opencode_messages`'s own marker shape: a
8063/// `User` message with EXACTLY one `synthetic: true` text part carrying
8064/// [`OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY`] in its part-level `metadata`. Real
8065/// opencode data is never misclassified — a genuine opencode `synthetic`
8066/// text part never carries this supercode-namespaced key, and a real
8067/// multi-part user message (text + an attached file, say) never matches
8068/// (`parts.len() != 1` bails). Returns the original Claude `systemSubtype`
8069/// (e.g. `local_command`) on a match.
8070fn opencode_claude_system_subtype(parts: &[Value]) -> Option<String> {
8071    let [part] = parts else { return None };
8072    if part.get("type").and_then(Value::as_str) != Some("text") {
8073        return None;
8074    }
8075    if part.get("synthetic").and_then(Value::as_bool) != Some(true) {
8076        return None;
8077    }
8078    part.get("metadata")
8079        .and_then(|m| m.get(OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY))
8080        .and_then(Value::as_str)
8081        .map(str::to_string)
8082}
8083
8084/// The [`opencode_claude_system_subtype`] match arm: restores `Role::System`
8085/// and `metadata["systemSubtype"]` from the marked text part instead of
8086/// `push_opencode_user`'s generic `Role::User` path, so a Claude ->
8087/// OpenCode -> Claude round trip restores the exact original role, not just
8088/// the text. Content is never fabricated — only emitted when non-empty.
8089fn push_opencode_claude_system(
8090    msg_value: &Value,
8091    parts: &[Value],
8092    subtype: String,
8093    out: &mut Vec<ChatMessage>,
8094) {
8095    let Some(text) = parts
8096        .first()
8097        .and_then(|p| p.get("text"))
8098        .and_then(Value::as_str)
8099    else {
8100        return;
8101    };
8102    if text.trim().is_empty() {
8103        return;
8104    }
8105    let mut msg = ChatMessage::system(text.to_string()).with_meta("systemSubtype", subtype);
8106    set_opencode_msg_timestamp(&mut msg, msg_value);
8107    out.push(msg);
8108}
8109
8110/// Map an opencode `User` message (`msg_value`) + its parts to zero or one
8111/// canonical `Role::User` `ChatMessage` (§2.1). `text` parts concatenate
8112/// (an `ignored` one is never included — §2.2, "must not be re-emitted to
8113/// the model"); `file` parts with a recognized image shape become
8114/// `content_parts`. `System` (opencode's per-turn `User.system`) fills
8115/// `SessionMeta.system_prompt` on the first turn that carries it, and
8116/// `metadata["system"]` on every turn that does (§2.1: "System prompt is
8117/// per-user-message, not per-session").
8118/// Fold an opencode message envelope's `time.created` (unix-ms) into the
8119/// canonical `metadata["timestamp"]` (ISO-8601, WAVE-2 item 1) — the same
8120/// field claude/codex/pi loaders populate. Lossless to millisecond precision
8121/// (opencode's own wire granularity); a `None`/malformed `time.created`
8122/// leaves `metadata["timestamp"]` unset, so the writer falls back to
8123/// `SYNTH_TS`/`SYNTH_TS_MS`.
8124fn set_opencode_msg_timestamp(msg: &mut ChatMessage, msg_value: &Value) {
8125    if let Some(ms) = msg_value
8126        .get("time")
8127        .and_then(|t| t.get("created"))
8128        .and_then(Value::as_i64)
8129    {
8130        msg.metadata
8131            .insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
8132    }
8133}
8134
8135fn push_opencode_user(
8136    msg_value: &Value,
8137    parts: &[Value],
8138    out: &mut Vec<ChatMessage>,
8139    meta: &mut SessionMeta,
8140    first_system_seen: &mut bool,
8141) {
8142    let mut text = String::new();
8143    let mut image_parts: Vec<Value> = Vec::new();
8144    let mut has_ignored = false;
8145    for p in parts {
8146        match p.get("type").and_then(Value::as_str) {
8147            Some("text") => {
8148                if p.get("ignored").and_then(Value::as_bool) == Some(true) {
8149                    has_ignored = true;
8150                    continue; // must never be replayed (§2.2)
8151                }
8152                if let Some(t) = p.get("text").and_then(Value::as_str) {
8153                    push_str_field(&mut text, t);
8154                }
8155            }
8156            Some("file") => {
8157                if let Some(img) = opencode_file_image_part(p) {
8158                    image_parts.push(img);
8159                }
8160            }
8161            // reasoning/tool never appear on a User message; step-start,
8162            // step-finish, snapshot, patch, agent, subtask, retry have no
8163            // clean home (§2.3); compaction is read separately by the
8164            // caller (tail_start_id) and tagged onto the message below.
8165            _ => {}
8166        }
8167    }
8168
8169    let has_images = !image_parts.is_empty();
8170    if text.trim().is_empty() && !has_images {
8171        return;
8172    }
8173    let mut msg = if has_images {
8174        let mut all = Vec::new();
8175        if !text.trim().is_empty() {
8176            all.push(serde_json::json!({"type": "text", "text": text.clone()}));
8177        }
8178        all.extend(image_parts);
8179        ChatMessage {
8180            role: Role::User,
8181            content: None,
8182            content_parts: Some(all),
8183            tool_calls: None,
8184            tool_call_id: None,
8185            name: None,
8186            metadata: Default::default(),
8187        }
8188    } else {
8189        ChatMessage::user(text)
8190    };
8191
8192    if has_ignored {
8193        msg.metadata
8194            .insert("oc_has_ignored_part".to_string(), "true".to_string());
8195    }
8196    if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
8197        msg.metadata
8198            .insert("oc_message_id".to_string(), id.to_string());
8199    }
8200    if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
8201        msg.metadata.insert("agent".to_string(), agent.to_string());
8202    }
8203    if let Some(model) = msg_value.get("model") {
8204        if !model.is_null() {
8205            msg.metadata.insert("model".to_string(), model.to_string());
8206        }
8207    }
8208    if let Some(system) = msg_value.get("system").and_then(Value::as_str) {
8209        if !*first_system_seen {
8210            meta.system_prompt = Some(system.to_string());
8211            *first_system_seen = true;
8212        }
8213        msg.metadata
8214            .insert("system".to_string(), system.to_string());
8215    }
8216    for p in parts {
8217        if p.get("type").and_then(Value::as_str) == Some("compaction") {
8218            msg.metadata
8219                .insert("phase".to_string(), "compaction".to_string());
8220            if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
8221                msg.metadata
8222                    .insert("tail_start_id".to_string(), t.to_string());
8223            }
8224        }
8225    }
8226    set_opencode_msg_timestamp(&mut msg, msg_value);
8227    restore_grok_message_extension(msg_value, &mut msg);
8228    out.push(msg);
8229}
8230
8231/// Map an opencode `Assistant` message + its parts to a canonical
8232/// `Role::Assistant` `ChatMessage` (text + `tool_calls`), followed
8233/// immediately by one `Role::Tool` `ChatMessage` per `tool` part that
8234/// reached `completed`/`error` — the split-by-`callID` opencode's single
8235/// part→two-messages mapping (§2.1). `pending`/`running` calls (an
8236/// interrupted turn) synthesize no tool call/result of their own here; the
8237/// shared [`ensure_tool_results_paired`] fills that gap uniformly, exactly
8238/// like the other three loaders. A `tool` part whose `state.status` is none
8239/// of the four known values is skipped entirely — raw-only survival, never
8240/// guessed — so [`crate::audit::Corpus::OpenCode`] can flag it.
8241fn push_opencode_assistant(
8242    msg_value: &Value,
8243    parts: &[Value],
8244    out: &mut Vec<ChatMessage>,
8245    meta: &mut SessionMeta,
8246) {
8247    let mut text = String::new();
8248    let mut calls: Vec<ToolCall> = Vec::new();
8249    let mut thinking = String::new();
8250    let mut reasoning_seen = false;
8251    let mut thinking_sig: Option<String> = None;
8252    // (call_id, tool_name, the tool part itself) — deferred so the
8253    // assistant message (carrying `tool_calls`) is pushed FIRST, matching
8254    // every other loader's message ordering (call, then result).
8255    let mut tool_results: Vec<(String, String, Value)> = Vec::new();
8256
8257    for p in parts {
8258        match p.get("type").and_then(Value::as_str) {
8259            Some("text") => {
8260                if p.get("ignored").and_then(Value::as_bool) == Some(true) {
8261                    continue;
8262                }
8263                if let Some(t) = p.get("text").and_then(Value::as_str) {
8264                    push_str_field(&mut text, t);
8265                }
8266            }
8267            Some("reasoning") => {
8268                reasoning_seen = true;
8269                if let Some(t) = p.get("text").and_then(Value::as_str) {
8270                    push_str_field(&mut thinking, t);
8271                }
8272                if let Some(sig) = p
8273                    .get("metadata")
8274                    .and_then(|m| m.get("anthropic"))
8275                    .and_then(|a| a.get("signature"))
8276                    .and_then(Value::as_str)
8277                {
8278                    thinking_sig = Some(sig.to_string());
8279                }
8280            }
8281            Some("tool") => {
8282                let call_id = p.get("callID").and_then(Value::as_str).unwrap_or_default();
8283                let tool_name = p.get("tool").and_then(Value::as_str).unwrap_or_default();
8284                let status = p
8285                    .get("state")
8286                    .and_then(|s| s.get("status"))
8287                    .and_then(Value::as_str);
8288                let known_status = matches!(
8289                    status,
8290                    Some("pending") | Some("running") | Some("completed") | Some("error")
8291                );
8292                if call_id.is_empty() || !known_status {
8293                    // Unknown/unrecognized status, or a malformed part with
8294                    // no callID — raw-only survival, never synthesized.
8295                    continue;
8296                }
8297                let input = p
8298                    .get("state")
8299                    .and_then(|s| s.get("input"))
8300                    .cloned()
8301                    .unwrap_or_else(|| Value::Object(Default::default()));
8302                calls.push(function_call(call_id, tool_name, input.to_string()));
8303                if matches!(status, Some("completed") | Some("error")) {
8304                    tool_results.push((call_id.to_string(), tool_name.to_string(), p.clone()));
8305                }
8306            }
8307            // file/step-start/step-finish/snapshot/patch/agent/subtask/retry
8308            // — no clean home on an Assistant turn (§2.3).
8309            _ => {}
8310        }
8311    }
8312
8313    let before = out.len();
8314    push_assistant(out, text, calls);
8315    // A native OpenCode assistant record is transcript state even when it
8316    // has no parts. Real stores contain these after an interrupted/empty
8317    // model turn; dropping the record here loses its id, timestamp, model,
8318    // token/cost metadata, and shifts the conversation on every export.
8319    // Keep one empty canonical assistant message so all target writers can
8320    // preserve the turn. This also covers reasoning-only records (whose
8321    // reasoning payload is attached as metadata just below).
8322    if out.len() == before {
8323        let mut empty = ChatMessage {
8324            role: Role::Assistant,
8325            content: None,
8326            content_parts: None,
8327            tool_calls: None,
8328            tool_call_id: None,
8329            name: None,
8330            metadata: Default::default(),
8331        };
8332        if !reasoning_seen {
8333            empty
8334                .metadata
8335                .insert("empty_assistant_record".to_string(), "true".to_string());
8336        }
8337        out.push(empty);
8338    }
8339    if out.len() > before {
8340        let msg = out.last_mut().expect("just pushed");
8341        if reasoning_seen {
8342            msg.metadata.insert("thinking".to_string(), thinking);
8343        }
8344        if let Some(sig) = thinking_sig {
8345            msg.metadata.insert("thinking_signature".to_string(), sig);
8346        }
8347        if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
8348            msg.metadata
8349                .insert("oc_message_id".to_string(), id.to_string());
8350        }
8351        if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
8352            msg.metadata.insert("agent".to_string(), agent.to_string());
8353            if meta.agent_id.is_none() {
8354                meta.agent_id = Some(agent.to_string());
8355            }
8356        }
8357        let provider = msg_value.get("providerID").and_then(Value::as_str);
8358        let model_id = msg_value.get("modelID").and_then(Value::as_str);
8359        if let (Some(p), Some(i)) = (provider, model_id) {
8360            let full = format!("{p}/{i}");
8361            msg.metadata.insert("model".to_string(), full.clone());
8362            if meta.model.is_none() {
8363                meta.model = Some(full);
8364            }
8365        }
8366        if let Some(cwd) = msg_value
8367            .get("path")
8368            .and_then(|p| p.get("cwd"))
8369            .and_then(Value::as_str)
8370        {
8371            if meta.cwd.is_none() {
8372                meta.cwd = Some(PathBuf::from(cwd));
8373            }
8374        }
8375        if msg_value.get("summary").and_then(Value::as_bool) == Some(true) {
8376            msg.metadata
8377                .insert("is_summary".to_string(), "true".to_string());
8378        }
8379        for (key, field) in [
8380            ("finish", "finish"),
8381            ("variant", "variant"),
8382            ("mode", "mode"),
8383        ] {
8384            if let Some(s) = msg_value.get(field).and_then(Value::as_str) {
8385                msg.metadata.insert(key.to_string(), s.to_string());
8386            }
8387        }
8388        for (key, field) in [
8389            ("cost", "cost"),
8390            ("tokens", "tokens"),
8391            ("error", "error"),
8392            ("structured", "structured"),
8393        ] {
8394            if let Some(v) = msg_value.get(field) {
8395                if !v.is_null() {
8396                    msg.metadata.insert(key.to_string(), v.to_string());
8397                }
8398            }
8399        }
8400        // Subagent linkage (§2.1): a `task` tool's own `metadata` carries
8401        // the spawned child session id — keyed by callID so multiple `task`
8402        // calls in one message never collide.
8403        // `resolve_opencode_parent_tool_use_ids` reads these back once a
8404        // whole session set is loaded.
8405        for p in parts {
8406            if p.get("type").and_then(Value::as_str) == Some("tool")
8407                && p.get("tool").and_then(Value::as_str) == Some("task")
8408            {
8409                if let (Some(call_id), Some(child)) = (
8410                    p.get("callID").and_then(Value::as_str),
8411                    p.get("metadata")
8412                        .and_then(|m| m.get("sessionId"))
8413                        .and_then(Value::as_str),
8414                ) {
8415                    msg.metadata.insert(
8416                        format!("oc_task_child_session_id__{call_id}"),
8417                        child.to_string(),
8418                    );
8419                }
8420            }
8421        }
8422        set_opencode_msg_timestamp(msg, msg_value);
8423        restore_grok_message_extension(msg_value, msg);
8424    }
8425
8426    // Second pass: the paired Tool-role message for each completed/error
8427    // tool part, split by callID (§2.1 — "the SAME part carries call and
8428    // result").
8429    for (call_id, tool_name, part) in tool_results {
8430        let status = part
8431            .get("state")
8432            .and_then(|s| s.get("status"))
8433            .and_then(Value::as_str);
8434        let compacted_at = part
8435            .get("state")
8436            .and_then(|s| s.get("time"))
8437            .and_then(|t| t.get("compacted"))
8438            .and_then(Value::as_i64);
8439        let real_output = part
8440            .get("state")
8441            .and_then(|s| s.get("output"))
8442            .and_then(Value::as_str)
8443            .unwrap_or("")
8444            .to_string();
8445        let (content, is_error) = match status {
8446            Some("completed") => {
8447                if compacted_at.is_some() {
8448                    (OPENCODE_COMPACTED_TOOL_PLACEHOLDER.to_string(), false)
8449                } else {
8450                    (real_output.clone(), false)
8451                }
8452            }
8453            Some("error") => {
8454                let err = part
8455                    .get("state")
8456                    .and_then(|s| s.get("error"))
8457                    .and_then(Value::as_str)
8458                    .unwrap_or("")
8459                    .to_string();
8460                (err, true)
8461            }
8462            _ => (String::new(), false),
8463        };
8464        let mut tmsg = ChatMessage {
8465            role: Role::Tool,
8466            content: Some(content),
8467            content_parts: None,
8468            tool_calls: None,
8469            tool_call_id: Some(call_id),
8470            name: Some(tool_name),
8471            metadata: Default::default(),
8472        };
8473        if let Some(original_position) = part
8474            .get(OPENCODE_SUPERCODE_RESULT_POSITION)
8475            .and_then(Value::as_u64)
8476        {
8477            tmsg.metadata.insert(
8478                OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
8479                original_position.to_string(),
8480            );
8481        }
8482        if is_error {
8483            crate::mark_tool_error(&mut tmsg);
8484        }
8485        restore_tool_outcome_extension(&part, &mut tmsg);
8486        if let Some(ts) = compacted_at {
8487            // S1: the real output is preserved — reversible, never erased.
8488            tmsg.metadata
8489                .insert("oc_tool_output_compacted".to_string(), real_output);
8490            tmsg.metadata
8491                .insert("oc_tool_time_compacted".to_string(), ts.to_string());
8492        }
8493        if status == Some("completed") {
8494            if let Some(atts) = part
8495                .get("state")
8496                .and_then(|s| s.get("attachments"))
8497                .and_then(Value::as_array)
8498            {
8499                let images: Vec<Value> = atts.iter().filter_map(opencode_file_image_part).collect();
8500                if !images.is_empty() {
8501                    // D-mix consistency fix (Fable-recommended, same
8502                    // pattern as `push_claude_user`'s tool_result arm above):
8503                    // a completed opencode tool part with BOTH `state.output`
8504                    // text and `state.attachments` images is the same
8505                    // non-self-contained hybrid shape — `content_parts` here
8506                    // used to hold images only, so opencode -> pi silently
8507                    // dropped the output text (`pi_content_value` reads
8508                    // `content_parts` exclusively for `Role::Tool`). Prepend
8509                    // the text as part 0 so `content_parts` is
8510                    // self-contained; `tmsg.content` keeps the text too,
8511                    // unchanged, for writers that read it from there and
8512                    // only scan `content_parts` for `image_url` entries.
8513                    let mut parts = Vec::new();
8514                    if let Some(t) = &tmsg.content {
8515                        if !t.is_empty() {
8516                            parts.push(serde_json::json!({"type": "text", "text": t}));
8517                        }
8518                    }
8519                    parts.extend(images);
8520                    tmsg.content_parts = Some(parts);
8521                }
8522            }
8523        }
8524        if let Some(id) = part.get("id").and_then(Value::as_str) {
8525            tmsg.metadata
8526                .insert("oc_part_id".to_string(), id.to_string());
8527        }
8528        // WAVE-2 item 1: a tool part's own `state.time.{end,start}` (unix-ms,
8529        // real opencode wire shape — `pi-fields.md`/`opencode-pi-spec.md`
8530        // cite `state.time.compacted`, but the SAME object also carries
8531        // `start`/`end` on every completed/error call) is this Tool
8532        // message's real source timestamp; prefer `end` (completion, closer
8533        // to when the RESULT — this message's content — was produced) and
8534        // fall back to `start` when only that is present.
8535        let tool_ts = part
8536            .get("state")
8537            .and_then(|s| s.get("time"))
8538            .and_then(|t| t.get("end").or_else(|| t.get("start")))
8539            .and_then(Value::as_i64);
8540        if let Some(ms) = tool_ts {
8541            tmsg.metadata
8542                .insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
8543        }
8544        // OpenCode folds a canonical tool result into the assistant's tool
8545        // part. Restore the portable envelope from that part after native
8546        // fields have been captured so A -> OpenCode -> A retains fields
8547        // OpenCode does not model independently (for example Goose's
8548        // message-level metadata and an intentionally absent tool name).
8549        restore_grok_message_extension(&part, &mut tmsg);
8550        out.push(tmsg);
8551    }
8552}
8553
8554// ---- shared helpers -------------------------------------------------------
8555
8556fn push_text(buf: &mut String, v: Option<&Value>) {
8557    if let Some(Value::String(s)) = v {
8558        if !buf.is_empty() {
8559            buf.push('\n');
8560        }
8561        buf.push_str(s);
8562    }
8563}
8564
8565/// Extract a Claude `tool_result` block's content, preserving non-text items
8566/// instead of silently dropping them:
8567///
8568/// - text blocks are concatenated;
8569/// - PARITY-11 (nested images, real-session-confirmed — the everyday "Read a
8570///   PNG / screenshot tool output" shape): `image` blocks are captured into
8571///   the returned `content_parts`-shaped `Vec<Value>` via
8572///   [`claude_image_block_to_part`] — the SAME base64/url conversion the
8573///   top-level `image` content-block path (`push_claude_user`) already uses
8574///   — instead of being flattened to the bare `[image]` marker text that used
8575///   to make the data unrecoverable from every writer. An unconvertible
8576///   source (D5 discipline — a Files-API `{"type":"file",...}` reference,
8577///   etc.) folds [`UNCONVERTIBLE_IMAGE_MARKER`] into the text instead of
8578///   vanishing, exactly like the top-level path;
8579/// - `tool_reference` blocks become `[tool_reference: <name>]`;
8580///
8581/// and if the block yields no text/images at all, fall back to the record's
8582/// `toolUseResult` field (string used directly, structured value serialized),
8583/// which is where Claude Code stores the actual result in many cases.
8584///
8585/// Returns `(text, images)`; callers that only need the old text-only
8586/// behavior can ignore the second element — every caller MUST fold non-empty
8587/// `images` into the resulting `ChatMessage.content_parts` themselves (this
8588/// function has no `ChatMessage` to attach to).
8589fn extract_tool_result_content(
8590    content: Option<&Value>,
8591    tool_use_result: Option<&Value>,
8592) -> (String, Vec<Value>) {
8593    let mut parts: Vec<String> = Vec::new();
8594    let mut images: Vec<Value> = Vec::new();
8595    match content {
8596        Some(Value::String(s)) => {
8597            if !s.is_empty() {
8598                parts.push(s.clone());
8599            }
8600        }
8601        Some(Value::Array(items)) => {
8602            for item in items {
8603                match item.get("type").and_then(Value::as_str) {
8604                    Some("text") => {
8605                        if let Some(t) = item.get("text").and_then(Value::as_str) {
8606                            parts.push(t.to_string());
8607                        }
8608                    }
8609                    Some("image") => match claude_image_block_to_part(item) {
8610                        Some(part) => images.push(part),
8611                        None => parts.push(UNCONVERTIBLE_IMAGE_MARKER.to_string()),
8612                    },
8613                    Some("tool_reference") => {
8614                        let name = item.get("tool_name").and_then(Value::as_str).unwrap_or("?");
8615                        parts.push(format!("[tool_reference: {name}]"));
8616                    }
8617                    _ => {
8618                        if let Some(s) = item.as_str() {
8619                            parts.push(s.to_string());
8620                        }
8621                    }
8622                }
8623            }
8624        }
8625        Some(other) => parts.push(other.to_string()),
8626        None => {}
8627    }
8628
8629    let joined = parts.join("\n");
8630    if !joined.trim().is_empty() || !images.is_empty() {
8631        return (joined, images);
8632    }
8633    // Empty tool_result content — recover from toolUseResult.
8634    match tool_use_result {
8635        Some(Value::String(s)) => (s.clone(), images),
8636        Some(v) => (v.to_string(), images),
8637        None => (joined, images),
8638    }
8639}
8640
8641/// Pull readable text out of a content value that may be a plain string or an
8642/// array of `{ "text": "..." }`-bearing blocks (any block type).
8643fn extract_text_content(v: Option<&Value>) -> String {
8644    match v {
8645        Some(Value::String(s)) => s.clone(),
8646        Some(Value::Array(items)) => {
8647            let mut parts = Vec::new();
8648            for item in items {
8649                if let Some(t) = item.get("text").and_then(Value::as_str) {
8650                    parts.push(t.to_string());
8651                } else if let Some(s) = item.as_str() {
8652                    parts.push(s.to_string());
8653                }
8654            }
8655            parts.join("\n")
8656        }
8657        Some(other) => other.to_string(),
8658        None => String::new(),
8659    }
8660}
8661
8662/// Extract Codex `input_image` content blocks from a `message` response_item's
8663/// `content` value into `content_parts` `image_url` entries — the inverse of
8664/// [`codex_message_content_blocks`]'s `input_image` emission (IX-5). Only a
8665/// block whose `image_url` is a non-empty string is recognized; anything else
8666/// (a missing/malformed `image_url`, a `file_id`-only reference) is left as
8667/// raw-only residue rather than synthesizing a corrupt/empty part — mirroring
8668/// the pi/opencode/Claude loaders' image-shape discipline.
8669fn codex_extract_images(content: Option<&Value>) -> Vec<Value> {
8670    let Some(Value::Array(items)) = content else {
8671        return Vec::new();
8672    };
8673    items
8674        .iter()
8675        .filter(|item| item.get("type").and_then(Value::as_str) == Some("input_image"))
8676        .filter_map(|item| {
8677            let url = item.get("image_url").and_then(Value::as_str)?;
8678            if url.is_empty() {
8679                return None;
8680            }
8681            Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
8682        })
8683        .collect()
8684}
8685
8686fn value_to_arg_string(v: &Value) -> String {
8687    match v {
8688        Value::String(s) => s.clone(),
8689        other => other.to_string(),
8690    }
8691}
8692
8693fn push_gemini_user_parts(
8694    messages: &mut Vec<ChatMessage>,
8695    content_parts: Vec<Value>,
8696    timestamp: Option<&str>,
8697    source: &Value,
8698) {
8699    if content_parts.is_empty() {
8700        return;
8701    }
8702    let mut message = ChatMessage {
8703        role: Role::User,
8704        content: None,
8705        content_parts: Some(content_parts),
8706        tool_calls: None,
8707        tool_call_id: None,
8708        name: None,
8709        metadata: Default::default(),
8710    };
8711    if let Some(timestamp) = timestamp {
8712        message
8713            .metadata
8714            .insert("timestamp".into(), timestamp.into());
8715    }
8716    restore_gemini_message_extension(source, &mut message);
8717    messages.push(message);
8718}
8719
8720fn function_call(id: &str, name: &str, arguments: String) -> ToolCall {
8721    ToolCall {
8722        id: id.to_string(),
8723        kind: "function".to_string(),
8724        function: FunctionCall {
8725            name: name.to_string(),
8726            arguments,
8727        },
8728    }
8729}
8730
8731fn tool_message(tool_call_id: &str, content: String) -> ChatMessage {
8732    ChatMessage {
8733        role: Role::Tool,
8734        content: Some(content),
8735        content_parts: None,
8736        tool_calls: None,
8737        tool_call_id: Some(tool_call_id.to_string()),
8738        name: None,
8739        metadata: Default::default(),
8740    }
8741}
8742
8743/// Emit a single assistant message combining accumulated text and tool calls.
8744/// A turn with neither (e.g. thinking-only) produces nothing.
8745fn push_assistant(out: &mut Vec<ChatMessage>, text: String, calls: Vec<ToolCall>) {
8746    let has_text = !text.trim().is_empty();
8747    if !has_text && calls.is_empty() {
8748        return;
8749    }
8750    out.push(ChatMessage {
8751        role: Role::Assistant,
8752        content: has_text.then_some(text),
8753        content_parts: None,
8754        tool_calls: (!calls.is_empty()).then_some(calls),
8755        tool_call_id: None,
8756        name: None,
8757        metadata: Default::default(),
8758    });
8759}
8760
8761// ---- writers --------------------------------------------------------------
8762
8763/// A placeholder timestamp for SYNTHESIZED records only — WAVE-2 item 1's
8764/// fallback (`docs/interop` build brief): every writer now emits a message's
8765/// REAL source timestamp (`metadata["timestamp"]`, the canonical ISO-8601
8766/// field every loader populates) when one is present. `SYNTH_TS` fires only
8767/// for a message with no source timestamp at all — a turn synthesized/
8768/// appended after import (the live agent loop, a splice's appended tail,
8769/// ...), which was never loaded from a real per-message timestamp to begin
8770/// with. Both tools tolerate identical timestamps; callers that need real
8771/// ones for a synthesized turn can post-process.
8772const SYNTH_TS: &str = "2026-01-01T00:00:00.000Z";
8773
8774/// The unix-millisecond twin of [`SYNTH_TS`], for OpenCode's numeric
8775/// `time.created`/`time.updated` fields.
8776const SYNTH_TS_MS: i64 = 1_767_225_600_000;
8777
8778/// `msg`'s real source timestamp (`metadata["timestamp"]`, canonical
8779/// ISO-8601 — WAVE-2 item 1) when present AND well-formed, else the
8780/// [`SYNTH_TS`] fallback. Validated via [`crate::sidecar::rfc3339_to_ms`] (a
8781/// parse, not just a presence check) so an absent, empty, or malformed
8782/// source value all degrade to the same documented fallback rather than
8783/// propagating garbage verbatim. Used by every writer that emits an
8784/// ISO-8601 timestamp field
8785/// (Claude Code, Codex, pi's entry-level `timestamp`).
8786fn msg_timestamp_or_synth(msg: &ChatMessage) -> &str {
8787    match msg.metadata.get("timestamp") {
8788        Some(ts) if crate::sidecar::rfc3339_to_ms(ts).is_some() => ts.as_str(),
8789        _ => SYNTH_TS,
8790    }
8791}
8792
8793/// OpenCode reloads an export document by sorting messages on
8794/// `time.created`, so a timestamp-less appended continuation cannot reuse
8795/// the fixed historical [`SYNTH_TS_MS`] fallback when its imported prefix is
8796/// newer. Advance a deterministic cursor for synthesized clocks while still
8797/// preserving every real source timestamp verbatim.
8798fn opencode_message_timestamp(msg: &ChatMessage, cursor: &mut i64) -> Result<i64> {
8799    if let Some(real) = msg
8800        .metadata
8801        .get("timestamp")
8802        .and_then(|s| crate::sidecar::rfc3339_to_ms(s))
8803    {
8804        // A NativeTurn timestamp is durable provenance minted by supercode,
8805        // not an OpenCode source clock that must be replayed verbatim.
8806        // Multiple turns may be recorded in the same millisecond, while
8807        // OpenCode sorts solely by `time.created`; allocate such turns after
8808        // the existing cursor so their persisted order cannot collapse. This
8809        // also preserves the fail-closed i64::MAX exhaustion behavior.
8810        if msg.metadata.contains_key("supercode_native_uuid") && real <= *cursor {
8811            *cursor = cursor.checked_add(1).ok_or_else(|| {
8812                crate::Error::Other(
8813                    "cannot synthesize an OpenCode continuation timestamp after i64::MAX"
8814                        .to_string(),
8815                )
8816            })?;
8817            return Ok(*cursor);
8818        }
8819        *cursor = (*cursor).max(real);
8820        return Ok(real);
8821    }
8822    let next = cursor.checked_add(1).ok_or_else(|| {
8823        crate::Error::Other(
8824            "cannot synthesize an OpenCode continuation timestamp after i64::MAX".to_string(),
8825        )
8826    })?;
8827    *cursor = next.max(SYNTH_TS_MS);
8828    Ok(*cursor)
8829}
8830
8831/// Largest integer nested under any OpenCode `time` object. Imported
8832/// prefixes carry more clocks than `message.time.created` (assistant
8833/// completion, tool start/end, session updated); a synthesized continuation
8834/// must follow all of them, not merely sort after message creation times.
8835fn opencode_max_timestamp(value: &Value) -> Option<i64> {
8836    fn max_number(value: &Value) -> Option<i64> {
8837        match value {
8838            Value::Number(n) => n.as_i64(),
8839            Value::Array(values) => values.iter().filter_map(max_number).max(),
8840            Value::Object(fields) => fields.values().filter_map(max_number).max(),
8841            _ => None,
8842        }
8843    }
8844
8845    match value {
8846        Value::Array(values) => values.iter().filter_map(opencode_max_timestamp).max(),
8847        Value::Object(fields) => fields
8848            .iter()
8849            .filter_map(|(key, value)| {
8850                if key == "time" {
8851                    max_number(value)
8852                } else {
8853                    opencode_max_timestamp(value)
8854                }
8855            })
8856            .max(),
8857        _ => None,
8858    }
8859}
8860
8861/// pi's own message-level unix-ms clock (`metadata["pi_msg_timestamp"]`,
8862/// restored by the pi loader's `push_pi_*` helpers) — DISTINCT from the
8863/// canonical entry-level ISO `metadata["timestamp"]` [`msg_timestamp_or_synth`]
8864/// reads. The two carry genuinely different values in real pi corpora (a
8865/// message-level clock reading vs. the entry's own wall-clock stamp), so this
8866/// is intentionally its own accessor. Used ONLY by [`Session::write_pi_entries`]'s
8867/// nested `message.timestamp` field, so a pi -> pi native round-trip
8868/// preserves the source message-level clock value-exact instead of deriving
8869/// it from the (distinct) entry-level timestamp. Falls back to
8870/// [`SYNTH_TS_MS`] for a message that never carried a pi message-level clock
8871/// reading (non-pi-sourced, or a synthesized/appended turn).
8872fn msg_pi_native_timestamp_ms(msg: &ChatMessage) -> i64 {
8873    msg.metadata
8874        .get("pi_msg_timestamp")
8875        .and_then(|s| s.parse::<i64>().ok())
8876        .unwrap_or(SYNTH_TS_MS)
8877}
8878
8879/// A format-valid (v4-shaped) but deterministic uuid, derived from a counter.
8880fn synth_uuid(n: usize) -> String {
8881    format!("00000000-0000-4000-8000-{n:012x}")
8882}
8883
8884/// R1 (Skeptic B, spliced-export uuid-collision hardening — the same bug
8885/// class N2 closed for the Codex spliced path's group ids, see
8886/// `collect_codex_group_ids_from_raw`/`write_codex_records`): mint the next
8887/// `synth_uuid`, skipping any value already present in `seed_used_ids` — the
8888/// uuids [`collect_claude_uuids_from_raw`] found already sitting in the
8889/// verbatim raw prefix [`Session::to_claude_code_jsonl_spliced`] replays
8890/// ahead of the tail this counter mints. Without this, re-splicing a
8891/// previously-exported-then-reimported session (export -> reimport -> append
8892/// -> export again) restarts `counter` at 1 with no memory of the prior
8893/// export's tail uuids now sitting in the prefix, so the second tail
8894/// fabricates the SAME `00000000-0000-4000-8000-…` values the first one did
8895/// — a uuid collision across prefix and tail that can mis-link any
8896/// uuid-keyed consumer (fork/tree lineage, `parentUuid`). `counter` keeps
8897/// climbing monotonically even across skips. `used_ids` is also updated for
8898/// each minted or metadata-backed identity, so collisions are prevented both
8899/// against the replayed prefix and within the appended tail.
8900fn next_claude_uuid(counter: &mut usize, used_ids: &mut HashSet<String>) -> String {
8901    loop {
8902        let candidate = synth_uuid(*counter);
8903        *counter += 1;
8904        if used_ids.insert(candidate.clone()) {
8905            return candidate;
8906        }
8907    }
8908}
8909
8910/// Reuse a message's durable native/source UUID when available, falling back
8911/// to the deterministic synthesized sequence only for hand-built or legacy
8912/// messages that never carried identity metadata.
8913fn claude_message_uuid(
8914    msg: &ChatMessage,
8915    counter: &mut usize,
8916    used_ids: &mut HashSet<String>,
8917) -> String {
8918    for key in ["claude_uuid", "supercode_native_uuid"] {
8919        if let Some(candidate) = msg.metadata.get(key) {
8920            if !candidate.is_empty() && used_ids.insert(candidate.clone()) {
8921                return candidate.clone();
8922            }
8923        }
8924    }
8925    next_claude_uuid(counter, used_ids)
8926}
8927
8928/// Companion to [`next_claude_uuid`]: every `uuid` already present in
8929/// `raw_prefix` — the verbatim RAW lines
8930/// [`Session::to_claude_code_jsonl_spliced`] replays ahead of the appended
8931/// tail it synthesizes via [`Session::write_claude_code_records`]. This is
8932/// the GROUND TRUTH of what physically lands in the exported `out` string
8933/// for the prefix (mirrors `collect_codex_group_ids_from_raw`'s approach on
8934/// the Codex side): each line is parsed as a Claude Code JSONL record and
8935/// its own top-level `uuid` field is read back out of the bytes directly, no
8936/// re-derivation from `self.messages` needed. A line that fails to parse, or
8937/// parses but carries no `uuid` (e.g. a trailing `file-history-snapshot`
8938/// record), contributes nothing.
8939fn collect_claude_uuids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
8940    let mut ids = HashSet::new();
8941    for line in raw_prefix {
8942        if let Ok(v) = serde_json::from_str::<Value>(line) {
8943            if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
8944                ids.insert(uuid.to_string());
8945            }
8946        }
8947    }
8948    ids
8949}
8950
8951fn push_jsonl(out: &mut String, value: &Value) {
8952    out.push_str(&value.to_string());
8953    out.push('\n');
8954}
8955
8956/// Replay a raw (verbatim) JSONL `line` into `out`, rewriting `key` to
8957/// `new_id` when the line parses as a JSON object carrying that key — used
8958/// by A12's Claude Code splice, where the session id lives at the top level
8959/// of (almost) every record under `key = "sessionId"`. A line that fails to
8960/// parse, or parses but lacks `key`, is copied through byte-for-byte
8961/// (nothing to patch, so nothing is reserialized).
8962fn push_spliced_line(out: &mut String, line: &str, new_id: Option<&str>, key: &str) {
8963    if let Some(new_id) = new_id {
8964        if let Ok(mut v) = serde_json::from_str::<Value>(line) {
8965            if v.get(key).is_some() {
8966                v[key] = Value::String(new_id.to_string());
8967                out.push_str(&v.to_string());
8968                out.push('\n');
8969                return;
8970            }
8971        }
8972    }
8973    out.push_str(line);
8974    out.push('\n');
8975}
8976
8977impl Session {
8978    fn cwd_string(&self) -> String {
8979        self.meta
8980            .cwd
8981            .as_ref()
8982            .map(|p| p.to_string_lossy().into_owned())
8983            .unwrap_or_else(|| ".".to_string())
8984    }
8985
8986    /// `(raw_prefix_len, message_prefix_len)` for A12 splicing: how many
8987    /// leading `raw` lines / `messages` came from the imported log, as
8988    /// opposed to being appended after import.
8989    ///
8990    /// `imported_message_count` (see its doc comment) pins the message-side
8991    /// boundary directly. The raw-side boundary isn't separately tracked —
8992    /// [`Self::from_native_str`]'s turn-appending loop pushes exactly one
8993    /// `raw` line per appended message, so the two lists grow by the same
8994    /// `appended_count` from the same starting point, and
8995    /// `raw.len() - appended_count` recovers it without a second counter.
8996    fn spliced_prefix_lens(&self) -> (usize, usize) {
8997        let message_prefix_len = self
8998            .imported_message_count
8999            .unwrap_or(self.messages.len())
9000            .min(self.messages.len());
9001        let appended_count = self.messages.len() - message_prefix_len;
9002        let raw_prefix_len = self.raw.len().saturating_sub(appended_count);
9003        (raw_prefix_len, message_prefix_len)
9004    }
9005
9006    /// Synthesize a Claude Code transcript.
9007    ///
9008    /// Claude Code transcripts have no slot for the *session-level system
9009    /// prompt* (that lives in the CLI, not the log) — but PARITY-6 dev/02
9010    /// (Fable-5 corpus audit) surfaced that content-bearing `System`
9011    /// `ChatMessage`s (Claude's own `type: "system"` records with a
9012    /// content-bearing `subtype` like `local_command`/`scheduled_task_fire`/
9013    /// `away_summary` — see `push_claude_system`, the exact inverse of what
9014    /// this writer now does) DO have a first-class slot: the real `type:
9015    /// "system"` record itself. This function used to unconditionally drop
9016    /// every `System` message, silently losing e.g. a real
9017    /// `<local-command-stdout>` record on any format -> Claude Code hop
9018    /// (confirmed on a real 2,982-message corpus session: Codex -> Claude
9019    /// Code dropped its one surviving `system` message, 2215 -> 2214, with
9020    /// no loss manifest). `write_claude_code_records`'s `Role::System` arm
9021    /// now re-materializes it instead.
9022    fn to_claude_code_jsonl(&self) -> String {
9023        let session_id = self
9024            .meta
9025            .session_id
9026            .clone()
9027            .unwrap_or_else(|| synth_uuid(0));
9028        let cwd = self.cwd_string();
9029        let mut out = String::new();
9030        // PARITY-10: a captured `fork-context-ref` (see `capture_claude_meta`)
9031        // re-emitted byte-for-byte, ahead of the conversation it applies to —
9032        // this is what makes the record survive the SEMANTIC Claude Code
9033        // writer (the raw-passthrough diagonal in `crates/cli` already
9034        // preserves it by construction; this covers the library `to_jsonl`
9035        // path too, e.g. a `--session-id` override that forces the semantic
9036        // writer).
9037        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
9038            out.push_str(raw);
9039            out.push('\n');
9040        }
9041        // Full synthesis: `out` at this point has no raw prefix ahead of it
9042        // (unlike the A12 splice below), so there are no uuids yet in play
9043        // to seed against — see `next_claude_uuid`'s doc comment.
9044        self.write_claude_code_records(
9045            &mut out,
9046            &self.messages,
9047            &session_id,
9048            &cwd,
9049            None,
9050            1,
9051            &HashSet::new(),
9052        );
9053        if let Some(extension) = codex_provenance_envelope(&self.meta) {
9054            if out.is_empty() {
9055                push_jsonl(
9056                    &mut out,
9057                    &serde_json::json!({
9058                        "type": "file-history-snapshot",
9059                        "messageId": synth_uuid(1),
9060                        "snapshot": {},
9061                        "sessionId": session_id,
9062                        "cwd": cwd,
9063                        "timestamp": SYNTH_TS,
9064                    }),
9065                );
9066            }
9067            inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
9068        }
9069        out
9070    }
9071
9072    /// Synthesize Claude Code records for `messages` (a full session or an
9073    /// appended tail — A12's [`Self::to_jsonl_spliced`] reuses this for just
9074    /// the latter), starting the `parentUuid` chain at `parent` and the
9075    /// `synth_uuid` counter at `counter`. Factored out of
9076    /// [`Self::to_claude_code_jsonl`] so the record shape is defined once.
9077    ///
9078    /// `seed_used_ids` primes [`next_claude_uuid`]'s collision guard with
9079    /// every uuid that will ALREADY be present in `out` before this call
9080    /// ever runs — see that function's doc comment for why the A12 splice
9081    /// path needs this and full synthesis doesn't.
9082    // R1: this was already at clippy's `too_many_arguments` threshold (7,
9083    // including `&self`) before the fix; the added `seed_used_ids` param
9084    // pushes it to 8. Every argument here is independently meaningful (two
9085    // record-shape inputs, two id/parent-chain threading values, and now
9086    // the collision seed) — bundling them into a params struct is a larger
9087    // refactor of this already-widely-called private helper than the R1 fix
9088    // warrants, so this is allowed rather than restructured.
9089    #[allow(clippy::too_many_arguments)]
9090    fn write_claude_code_records(
9091        &self,
9092        out: &mut String,
9093        messages: &[ChatMessage],
9094        session_id: &str,
9095        cwd: &str,
9096        mut parent: Option<String>,
9097        mut counter: usize,
9098        seed_used_ids: &HashSet<String>,
9099    ) {
9100        let mut used_ids = seed_used_ids.clone();
9101        for msg in messages {
9102            if is_replay_excluded(msg) {
9103                continue;
9104            }
9105            let blocks: Vec<Value> = match msg.role {
9106                // PARITY-6 dev/02: re-materialize a content-bearing System
9107                // `ChatMessage` as a real Claude Code `type: "system"`
9108                // record — the exact inverse of `push_claude_system`, which
9109                // is what produced it in the first place for a message
9110                // loaded FROM a real Claude Code transcript. `subtype`
9111                // prefers the original `systemSubtype` metadata
9112                // (`push_claude_system`'s `.with_meta`, round-tripped
9113                // through the Codex hop via `write_codex_records`'s
9114                // `claude_system_subtype` metadata channel and restored by
9115                // `push_codex_item`); when that channel didn't carry it
9116                // (e.g. a genuinely native, non-Claude-origin developer
9117                // message), fall back to `local_command` — the observed
9118                // common case, and still one of `push_claude_system`'s own
9119                // `keep` subtypes, so the record survives a *subsequent*
9120                // reload rather than being silently re-dropped. This never
9121                // fabricates content: the real text is always carried
9122                // verbatim, only the subtype label is a best-effort guess
9123                // when the true one wasn't recoverable.
9124                Role::System => {
9125                    let content = msg.content.clone().unwrap_or_default();
9126                    if content.trim().is_empty() {
9127                        continue;
9128                    }
9129                    let subtype = msg
9130                        .metadata
9131                        .get("systemSubtype")
9132                        .cloned()
9133                        .unwrap_or_else(|| "local_command".to_string());
9134                    // R1/B3 union: this mint must ALSO route through
9135                    // `next_claude_uuid` + `seed_used_ids` like the other
9136                    // three arms below — otherwise this System arm (added by
9137                    // B3 after R1 landed) mints a raw `synth_uuid` that can
9138                    // collide with a uuid already sitting in the A12 splice's
9139                    // raw prefix (see `next_claude_uuid`'s doc comment).
9140                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9141                    let mut line = serde_json::json!({
9142                        "parentUuid": parent,
9143                        "type": "system",
9144                        "subtype": subtype,
9145                        "content": content,
9146                        "uuid": uuid,
9147                        "sessionId": session_id,
9148                        "cwd": cwd,
9149                        "timestamp": msg_timestamp_or_synth(msg),
9150                    });
9151                    set_grok_message_extension(&mut line, self.meta.source, msg);
9152                    push_jsonl(out, &line);
9153                    parent = Some(uuid);
9154                    continue;
9155                }
9156                Role::User => {
9157                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9158                    let mut line = serde_json::json!({
9159                        "parentUuid": parent,
9160                        "type": "user",
9161                        "message": {
9162                            "role": "user",
9163                            "content": claude_user_content_value(msg),
9164                        },
9165                        "uuid": uuid,
9166                        "sessionId": session_id,
9167                        "cwd": cwd,
9168                        "timestamp": msg_timestamp_or_synth(msg),
9169                    });
9170                    set_grok_message_extension(&mut line, self.meta.source, msg);
9171                    push_jsonl(out, &line);
9172                    parent = Some(uuid);
9173                    continue;
9174                }
9175                Role::Tool => {
9176                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9177                    let mut line = serde_json::json!({
9178                        "parentUuid": parent,
9179                        "type": "user",
9180                        "message": {
9181                            "role": "user",
9182                            "content": [{
9183                                "type": "tool_result",
9184                                "tool_use_id": msg.tool_call_id.clone().unwrap_or_default(),
9185                                "content": claude_tool_result_content_value(msg),
9186                            }],
9187                        },
9188                        "uuid": uuid,
9189                        "sessionId": session_id,
9190                        "cwd": cwd,
9191                        "timestamp": msg_timestamp_or_synth(msg),
9192                    });
9193                    if crate::tool_outcome(msg) == crate::ToolOutcome::Unknown {
9194                        line[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
9195                    }
9196                    set_grok_message_extension(&mut line, self.meta.source, msg);
9197                    push_jsonl(out, &line);
9198                    parent = Some(uuid);
9199                    continue;
9200                }
9201                Role::Assistant => {
9202                    let mut blocks = Vec::new();
9203                    // PARITY-16 (found via the REAL pi corpus, PARITY-5
9204                    // dev/01): thinking/redacted_thinking must be re-emitted
9205                    // BEFORE text/tool_use, unconditionally whenever
9206                    // retained metadata is present — not only when `blocks`
9207                    // is otherwise empty. The previous `if blocks.is_empty()`
9208                    // gate (now below, applied unconditionally instead)
9209                    // meant a turn that thinks AND THEN answers/calls a tool
9210                    // in the SAME turn — pi's own default emission shape,
9211                    // and the overwhelmingly common real-world case for any
9212                    // reasoning model, not the rare reasoning-only edge case
9213                    // this gate's comment described — silently dropped its
9214                    // entire `thinking` block on Pi -> Claude Code export. A
9215                    // genuine multi-turn pi session driven through pi's own
9216                    // real Agent loop (faux provider, see
9217                    // `pi_interop.rs`'s live-corpus tests) exposed this: its
9218                    // thinking+text turns lost the thinking block entirely.
9219                    // D8: prefer the exact per-block list when present —
9220                    // every `thinking`/`redacted_thinking` block re-emitted
9221                    // SEPARATELY with its own signature/data, exactly as
9222                    // captured (`push_claude_assistant`), instead of the
9223                    // legacy singular fields' lossy collapse (which drops
9224                    // every signature but the last one's on a multi-block
9225                    // message). Falls back to the legacy fields only for a
9226                    // `Session` that never populated `thinking_blocks` (e.g.
9227                    // hand-constructed in another loader/test, or loaded
9228                    // from a non-Claude-Code source like Pi).
9229                    match msg
9230                        .metadata
9231                        .get("thinking_blocks")
9232                        .and_then(|s| serde_json::from_str::<Value>(s).ok())
9233                        .and_then(|v| v.as_array().cloned())
9234                    {
9235                        Some(saved_blocks) => blocks.extend(saved_blocks),
9236                        None => {
9237                            if let Some(t) = msg.metadata.get("thinking") {
9238                                let mut block =
9239                                    serde_json::json!({"type": "thinking", "thinking": t});
9240                                if let Some(sig) = msg.metadata.get("thinking_signature") {
9241                                    block["signature"] = Value::String(sig.clone());
9242                                }
9243                                blocks.push(block);
9244                            }
9245                            if let Some(rt) = msg.metadata.get("redacted_thinking") {
9246                                blocks.push(
9247                                    serde_json::json!({"type": "redacted_thinking", "data": rt}),
9248                                );
9249                            }
9250                        }
9251                    }
9252                    if let Some(t) = &msg.content {
9253                        if !t.is_empty() {
9254                            blocks.push(serde_json::json!({"type": "text", "text": t}));
9255                        }
9256                    }
9257                    // PARITY-11: an assistant-emitted image (`content_parts`,
9258                    // e.g. a generated image — `push_claude_assistant`'s
9259                    // load-side counterpart) has no slot in `msg.content`;
9260                    // without this, `blocks` stayed empty for an image-only
9261                    // turn and the whole message vanished on Claude Code
9262                    // semantic export, same failure mode the IX-6 Codex
9263                    // writer fix already closed on that side.
9264                    if let Some(parts) = &msg.content_parts {
9265                        for p in parts {
9266                            if p.get("type").and_then(Value::as_str) == Some("image_url") {
9267                                if let Some(url) = p
9268                                    .get("image_url")
9269                                    .and_then(|u| u.get("url"))
9270                                    .and_then(Value::as_str)
9271                                {
9272                                    blocks.push(match parse_data_uri(url) {
9273                                        Some((mime, data)) => serde_json::json!({
9274                                            "type": "image",
9275                                            "source": {"type": "base64", "media_type": mime, "data": data},
9276                                        }),
9277                                        None => serde_json::json!({
9278                                            "type": "image",
9279                                            "source": {"type": "url", "url": url},
9280                                        }),
9281                                    });
9282                                }
9283                            }
9284                        }
9285                    }
9286                    for tc in msg.tool_calls() {
9287                        let input = tc
9288                            .function
9289                            .parsed_arguments()
9290                            .unwrap_or_else(|_| Value::Object(Default::default()));
9291                        blocks.push(serde_json::json!({
9292                            "type": "tool_use",
9293                            "id": tc.id,
9294                            "name": tc.function.name,
9295                            "input": input,
9296                        }));
9297                    }
9298                    // PARITY-11 / PARITY-16: a genuinely reasoning-only turn
9299                    // (no text, no tool_use, no image) still doesn't vanish
9300                    // — the thinking/redacted_thinking prepend above already
9301                    // ran unconditionally, so `blocks` is non-empty here
9302                    // whenever any of those were present.
9303                    blocks
9304                }
9305            };
9306
9307            // An empty assistant content array is a valid native interrupted
9308            // turn and must remain a record. Every non-assistant arm above
9309            // already `continue`s after writing its own shape, so an empty
9310            // `blocks` value here belongs specifically to that assistant.
9311            let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9312            let mut message = serde_json::json!({"role": "assistant", "content": blocks});
9313            if let Some(model) = msg.metadata.get("model").or(self.meta.model.as_ref()) {
9314                message["model"] = Value::String(model.clone());
9315            }
9316            let mut line = serde_json::json!({
9317                "parentUuid": parent,
9318                "type": "assistant",
9319                "message": message,
9320                "uuid": uuid,
9321                "sessionId": session_id,
9322                "cwd": cwd,
9323                "timestamp": msg_timestamp_or_synth(msg),
9324            });
9325            set_grok_message_extension(&mut line, self.meta.source, msg);
9326            push_jsonl(out, &line);
9327            parent = Some(uuid);
9328        }
9329    }
9330
9331    /// A12 splice: replay the imported Claude Code `raw` prefix verbatim
9332    /// (patching `sessionId` on each line when `session_id` is `Some`), then
9333    /// synthesize records only for the appended tail, via
9334    /// [`Self::write_claude_code_records`] — chaining `parentUuid` from the
9335    /// last original `uuid` found anywhere in the raw prefix (not just its
9336    /// final line: a trailing loader-skipped record, e.g.
9337    /// `file-history-snapshot`, may carry no `uuid` of its own).
9338    fn to_claude_code_jsonl_spliced(&self, session_id: Option<&str>) -> String {
9339        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
9340        let sid = session_id
9341            .map(str::to_string)
9342            .or_else(|| self.meta.session_id.clone())
9343            .unwrap_or_else(|| synth_uuid(0));
9344        let cwd = self.cwd_string();
9345
9346        let mut out = String::new();
9347        let mut parent: Option<String> = None;
9348        for line in &self.raw[..raw_prefix_len] {
9349            push_spliced_line(&mut out, line, session_id, "sessionId");
9350            if let Ok(v) = serde_json::from_str::<Value>(line) {
9351                if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
9352                    parent = Some(uuid.to_string());
9353                }
9354            }
9355        }
9356
9357        // R1 (spliced-path hardening, mirrors the Codex N2 fix above): seed
9358        // the tail's collision guard with every uuid the just-replayed RAW
9359        // prefix already carries, so `write_claude_code_records` never
9360        // fabricates a `synth_uuid` for the appended tail that collides with
9361        // one already sitting in the prefix (see `next_claude_uuid`'s and
9362        // `collect_claude_uuids_from_raw`'s doc comments).
9363        let seed_used_ids = collect_claude_uuids_from_raw(&self.raw[..raw_prefix_len]);
9364        self.write_claude_code_records(
9365            &mut out,
9366            &self.messages[message_prefix_len..],
9367            &sid,
9368            &cwd,
9369            parent,
9370            1,
9371            &seed_used_ids,
9372        );
9373        out
9374    }
9375
9376    /// Synthesize a Codex rollout.
9377    fn to_codex_jsonl(&self) -> String {
9378        let mut out = String::new();
9379
9380        if self.meta.codex_headers.is_empty() {
9381            self.write_synthesized_codex_header(&mut out);
9382        } else {
9383            // Replay the exact header records the original tool wrote — Codex's
9384            // reader validates the header shape strictly — overriding only the
9385            // session id when the caller changed it.
9386            for header in &self.meta.codex_headers {
9387                let mut header = header.clone();
9388                if header.get("type").and_then(Value::as_str) == Some("session_meta") {
9389                    if let Some(id) = &self.meta.session_id {
9390                        if let Some(payload) = header.get_mut("payload") {
9391                            payload["id"] = Value::String(id.clone());
9392                        }
9393                    }
9394                }
9395                push_jsonl(&mut out, &header);
9396            }
9397        }
9398
9399        // Full synthesis: `out` at this point is only the header, so there
9400        // are no group ids yet in play to seed against (see
9401        // `write_codex_records`'s doc comment).
9402        self.write_codex_records(&mut out, &self.messages, &std::collections::HashSet::new());
9403        if let Some(extension) = codex_provenance_envelope(&self.meta) {
9404            inject_codex_provenance(&mut out, extension);
9405        }
9406        out
9407    }
9408
9409    /// Synthesize Codex `response_item` records for `messages` (a full
9410    /// session or an appended tail — A12's [`Self::to_jsonl_spliced`] reuses
9411    /// this for just the latter). Factored out of [`Self::to_codex_jsonl`] so
9412    /// the record shape is defined once; `tool_search_call_ids` pairing is
9413    /// scoped to this call's `messages`, matching the header-replay
9414    /// contract that only appended records need synthesizing.
9415    ///
9416    /// `seed_used_ids` primes the N2 collision guard below with every group
9417    /// id that will ALREADY be present in `out` before this call ever runs —
9418    /// [`Self::to_codex_jsonl`] (full synthesis, `out` starts as just the
9419    /// header) passes an empty set, since every group id in that case is
9420    /// assigned by this very loop. [`Self::to_codex_jsonl_spliced`] (the A12
9421    /// splice) passes the ids already used by the verbatim RAW prefix it
9422    /// replayed into `out` just before calling this for the appended tail —
9423    /// without that seed, the tail's own `used_group_ids`/`next_group_id`
9424    /// start blind to the prefix and can fabricate/reuse a group id that
9425    /// COLLIDES with one still "open" at the end of the prefix, letting
9426    /// reimport's merge check (`can_merge`, `push_codex_item`) wrongly splice
9427    /// an unrelated appended message into a historical one — the same
9428    /// bug-class N2 closed for full synthesis, reopened here because the
9429    /// spliced tail's tracking set used to always start empty regardless of
9430    /// what the replayed prefix already contained.
9431    fn write_codex_records(
9432        &self,
9433        out: &mut String,
9434        messages: &[ChatMessage],
9435        seed_used_ids: &std::collections::HashSet<String>,
9436    ) {
9437        // Call ids of assistant `tool_search` calls (B6 agent intrinsic), so
9438        // the matching tool result below can be emitted as the paired
9439        // `tool_search_output` record rather than a generic
9440        // `function_call_output` — the exact inverse of the importer's
9441        // `tool_search_call`/`tool_search_output` normalization
9442        // (`push_codex_item`, above).
9443        let mut tool_search_call_ids: std::collections::HashSet<String> = Default::default();
9444        // PARITY-6/7: two ADJACENT but genuinely SEPARATE Claude assistant
9445        // records (e.g. a text-only narration turn immediately followed by a
9446        // bare tool-call turn, no user turn between — a real, common Claude
9447        // Code shape) each become their own Codex `message`/`function_call`
9448        // response_item(s) here. Codex's own reader (`push_codex_item`, IX-6)
9449        // opportunistically RE-MERGES an assistant `message` immediately
9450        // followed by a `function_call` back into ONE `ChatMessage`, to match
9451        // how a genuinely single Claude turn (text+tool_use in the SAME
9452        // record) round-trips — but with no distinguishing signal, it can't
9453        // tell that case apart from two originally-separate records that
9454        // just happen to be adjacent, so it wrongly recombines them too,
9455        // silently shrinking the message count on every Claude -> Codex ->
9456        // (inspect) hop. Fix: stamp a synthetic `metadata.turn_id` — unique
9457        // per ORIGINAL `ChatMessage` — onto the `message` record AND every
9458        // `function_call`/`tool_search_call` record THAT SAME `ChatMessage`
9459        // itself emits. `push_codex_item`'s merge already treats a turn_id
9460        // mismatch as "different turn, do not merge" (the pre-existing
9461        // belt-and-suspenders check); real native Codex data almost never
9462        // carries this field (per that check's own comment), so this is a
9463        // no-op there and only sharpens fidelity for OUR OWN synthesized
9464        // export.
9465        let mut next_group_id: u64 = 0;
9466        // N2 (Fable-5 review, turn_id-collision hardening): every group id
9467        // this export has already assigned — whether REUSED from a real
9468        // `turn_id` or FABRICATED as `sc-grp-N` — so a later assistant
9469        // `ChatMessage` never emits one that's already in use. Two concrete
9470        // mis-merge scenarios motivate this:
9471        //
9472        // (a) Claude->Codex export fabricates `sc-grp-0` for msg A (msg A's
9473        //     own text+tool_use); reload makes A carry REAL turn_id
9474        //     `sc-grp-0`. A bare tool-call msg B (metadata has no turn_id of
9475        //     its own) is then appended. Re-export: A reuses its real
9476        //     `sc-grp-0`, but B independently fabricates a FRESH id starting
9477        //     from `next_group_id == 0` again (nothing bumped it when A's id
9478        //     was reused rather than fabricated) — also `sc-grp-0`.
9479        //     Collision. If A's call has no output (interrupted session),
9480        //     reimport sees message(sc-grp-0)+call(sc-grp-0)+call(sc-grp-0)
9481        //     adjacent with nothing to break the run and merges all three
9482        //     into ONE message (2 -> 1).
9483        // (b) Native Codex: `message(turn-7)` opens the merge marker, a
9484        //     truncation/clear event strips `__codex_open_turn` (closing the
9485        //     turn without changing the id), then `function_call(turn-7)`
9486        //     loads as a SECOND, separate `ChatMessage` that still carries
9487        //     the SAME real `turn_id` (the reopen step in `push_codex_item`
9488        //     restamps it). Full-synthesis export naively reuses `turn-7`
9489        //     verbatim for BOTH messages (they're two different loop
9490        //     iterations, each independently reusing its own `real_turn_id`)
9491        //     and emits them adjacent — reimport's merge check can't tell
9492        //     this apart from a single message's own multi-call turn and
9493        //     recombines them (2 -> 1).
9494        //
9495        // Fix: the fabricated-id counter is advanced (skipped) past any id
9496        // already in `used_group_ids`, AND a real id that's already been
9497        // used gets disambiguated (`<real>~dupN`) instead of reused verbatim
9498        // — never letting two DIFFERENT `ChatMessage`s in this export share
9499        // one group id, since `push_codex_item`'s merge check treats a
9500        // shared id as "same turn, merge". A single `ChatMessage`'s own
9501        // message record + its own tool call records still share ONE group
9502        // id (computed once per loop iteration below, before insertion), so
9503        // the D1 tool_search merge and ordinary same-turn multi-call
9504        // grouping are unaffected — this only stops REUSE across iterations.
9505        //
9506        // Seeded from `seed_used_ids` (see this fn's doc comment) so the
9507        // spliced-export tail is likewise blind-proof against the prefix it
9508        // doesn't itself write.
9509        let mut used_group_ids: std::collections::HashSet<String> = seed_used_ids.clone();
9510
9511        for msg in messages {
9512            if is_replay_excluded(msg) {
9513                continue;
9514            }
9515            // D3 (Fable-5 review): a message loaded FROM real native Codex
9516            // carries its OWN real `turn_id` in `msg.metadata["turn_id"]`
9517            // (`push_codex_item`'s "message" arm stamps it whenever the
9518            // source record itself has one). The group-id logic below used
9519            // to ALWAYS fabricate a fresh `"sc-grp-N"` value regardless,
9520            // silently overwriting/discarding that real id on any
9521            // native-Codex -> load -> export-Codex hop. Reuse it verbatim
9522            // when present; only fabricate a synthetic id as a fallback for
9523            // our own merge-disambiguation need (PARITY-6/7) when the
9524            // message has no real one of its own.
9525            let real_turn_id = msg.metadata.get("turn_id").map(String::as_str);
9526            match msg.role {
9527                Role::System => {
9528                    // PARITY-6 dev/02: carry the original Claude
9529                    // `systemSubtype` (`push_claude_system`'s `.with_meta`)
9530                    // through as `metadata.claude_system_subtype`, so
9531                    // `push_codex_item`'s reverse load can restore it and
9532                    // `write_claude_code_records`'s `Role::System` arm can
9533                    // re-materialize the EXACT original subtype rather than
9534                    // guessing on a Codex -> Claude hop.
9535                    let subtype_meta = msg
9536                        .metadata
9537                        .get("systemSubtype")
9538                        .map(|s| ("claude_system_subtype", s.as_str()));
9539                    self.push_codex_message(
9540                        out,
9541                        "developer",
9542                        "input_text",
9543                        msg,
9544                        real_turn_id,
9545                        subtype_meta,
9546                    )
9547                }
9548                Role::User => {
9549                    self.push_codex_message(out, "user", "input_text", msg, real_turn_id, None)
9550                }
9551                Role::Assistant => {
9552                    // Emit the message record whenever there is text OR
9553                    // content_parts (IX-6 follow-up): an image-only assistant
9554                    // message has `content: None, content_parts:
9555                    // Some([image])` (the loader's `codex_extract_images` is
9556                    // role-general, so this shape can occur on the assistant
9557                    // side too) — gating on `msg.content` alone silently
9558                    // dropped the whole message, image included. A
9559                    // text-only message (content_parts: None) keeps taking
9560                    // the historical byte-identical path via
9561                    // `codex_message_content_blocks`'s `None` arm. A real
9562                    // empty native assistant record carries the
9563                    // loader's explicit marker and must also be emitted.
9564                    // Reasoning-only cross-provider turns deliberately lack
9565                    // that marker and keep the documented Codex residue.
9566                    let has_text = msg.content.as_deref().is_some_and(|t| !t.is_empty());
9567                    let has_message_record = has_text
9568                        || msg.content_parts.is_some()
9569                        || msg.metadata.contains_key("empty_assistant_record");
9570                    // Only assign a synthetic group id when there's actual
9571                    // merge ambiguity to resolve (a message AND its own tool
9572                    // calls, or 2+ of this message's own tool calls) — a
9573                    // pure-text message with no tool calls, or a lone tool
9574                    // call with nothing else from the same `ChatMessage`,
9575                    // has nothing to disambiguate, so it keeps the exact
9576                    // historical byte shape (no `metadata` key at all).
9577                    let group_id: Option<String> = if let Some(real) = real_turn_id {
9578                        if used_group_ids.contains(real) {
9579                            // N2: this real turn_id was already used by an
9580                            // earlier (now-closed) `ChatMessage` in this same
9581                            // export — reusing it verbatim would let the
9582                            // reimport merge check recombine two originally
9583                            // separate messages (see the doc comment above).
9584                            let mut n = 1u64;
9585                            let mut candidate = format!("{real}~dup{n}");
9586                            while used_group_ids.contains(&candidate) {
9587                                n += 1;
9588                                candidate = format!("{real}~dup{n}");
9589                            }
9590                            Some(candidate)
9591                        } else {
9592                            Some(real.to_string())
9593                        }
9594                    } else if !msg.tool_calls().is_empty() {
9595                        // N2: skip past any id already used (e.g. a REAL
9596                        // turn_id that happens to look like `sc-grp-N`, or an
9597                        // id an earlier reused-real case landed on).
9598                        let mut candidate = format!("sc-grp-{next_group_id}");
9599                        next_group_id += 1;
9600                        while used_group_ids.contains(&candidate) {
9601                            candidate = format!("sc-grp-{next_group_id}");
9602                            next_group_id += 1;
9603                        }
9604                        Some(candidate)
9605                    } else {
9606                        None
9607                    };
9608                    if let Some(g) = &group_id {
9609                        used_group_ids.insert(g.clone());
9610                    }
9611                    if has_message_record {
9612                        self.push_codex_message(
9613                            out,
9614                            "assistant",
9615                            "output_text",
9616                            msg,
9617                            group_id.as_deref(),
9618                            None,
9619                        );
9620                    }
9621                    for tc in msg.tool_calls() {
9622                        let custom_tool_call = msg
9623                            .metadata
9624                            .get("codex_custom_tool_call_ids")
9625                            .and_then(|raw| serde_json::from_str::<Vec<String>>(raw).ok())
9626                            .is_some_and(|ids| ids.iter().any(|id| id == &tc.id));
9627                        if custom_tool_call {
9628                            let input = tc
9629                                .function
9630                                .parsed_arguments()
9631                                .unwrap_or_else(|_| Value::String(tc.function.arguments.clone()));
9632                            let mut payload = with_turn_id(
9633                                serde_json::json!({
9634                                    "type": "custom_tool_call",
9635                                    "name": tc.function.name,
9636                                    "input": input,
9637                                    "call_id": tc.id,
9638                                }),
9639                                group_id.as_deref(),
9640                            );
9641                            set_grok_message_extension(&mut payload, self.meta.source, msg);
9642                            push_jsonl(
9643                                out,
9644                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9645                            );
9646                        } else if tc.function.name == "tool_search" {
9647                            tool_search_call_ids.insert(tc.id.clone());
9648                            let mut payload = with_turn_id(
9649                                serde_json::json!({
9650                                    "type": "tool_search_call",
9651                                    "arguments": tc.function.arguments,
9652                                    "call_id": tc.id,
9653                                }),
9654                                group_id.as_deref(),
9655                            );
9656                            set_grok_message_extension(&mut payload, self.meta.source, msg);
9657                            push_jsonl(
9658                                out,
9659                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9660                            );
9661                        } else {
9662                            let mut payload = with_turn_id(
9663                                serde_json::json!({
9664                                    "type": "function_call",
9665                                    "name": tc.function.name,
9666                                    "arguments": tc.function.arguments,
9667                                    "call_id": tc.id,
9668                                }),
9669                                group_id.as_deref(),
9670                            );
9671                            set_grok_message_extension(&mut payload, self.meta.source, msg);
9672                            push_jsonl(
9673                                out,
9674                                &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9675                            );
9676                        }
9677                    }
9678                    // PARITY-11: a genuinely reasoning-only turn (Claude
9679                    // `thinking`/`redacted_thinking` with no text, tool_use,
9680                    // or image — `push_claude_assistant`'s load-side fix for
9681                    // the ~21% of real assistant records that are exactly
9682                    // this shape) has no message record and no tool calls,
9683                    // so nothing above writes anything for it. This is
9684                    // DELIBERATE, not a residual gap: Codex's `reasoning`
9685                    // response_item is understood on import (see the
9686                    // `response_item`/`"reasoning"` arm above), but its
9687                    // real-native semantics is "the reasoning immediately
9688                    // BEFORE the next turn" — the reader attaches it to
9689                    // whatever response_item comes next, unconditionally.
9690                    // For a genuinely standalone Claude reasoning-only turn
9691                    // (no related turn follows in Codex's export at all),
9692                    // emitting one here would get silently misattributed as
9693                    // belonging to some later, unrelated turn instead —
9694                    // strictly worse than the current honest, accounted-for
9695                    // absence (thinking/redacted_thinking is provider-
9696                    // private and "not replayed across providers" by
9697                    // original design; the audit correctly classifies it
9698                    // `Coverage::Dropped`, not `Unmodeled`). See the
9699                    // PARITY-6/7 corpus test's `is_replayable` filter for
9700                    // why this doesn't count as a message-count regression.
9701                }
9702                Role::Tool
9703                    if msg
9704                        .tool_call_id
9705                        .as_deref()
9706                        .is_some_and(|id| tool_search_call_ids.contains(id)) =>
9707                {
9708                    let content = msg.content.clone().unwrap_or_default();
9709                    let tools =
9710                        serde_json::from_str::<Value>(&content).unwrap_or(Value::String(content));
9711                    let mut payload = serde_json::json!({
9712                        "type": "tool_search_output",
9713                        "call_id": msg.tool_call_id.clone().unwrap_or_default(),
9714                        "tools": tools,
9715                    });
9716                    set_grok_message_extension(&mut payload, self.meta.source, msg);
9717                    push_jsonl(
9718                        out,
9719                        &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9720                    );
9721                }
9722                Role::Tool => {
9723                    let mut payload = serde_json::json!({
9724                        "type": "function_call_output",
9725                        "call_id": msg.tool_call_id.clone().unwrap_or_default(),
9726                        "output": codex_tool_output_text(msg),
9727                    });
9728                    set_grok_message_extension(&mut payload, self.meta.source, msg);
9729                    push_jsonl(
9730                        out,
9731                        &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9732                    );
9733                }
9734            }
9735        }
9736    }
9737
9738    /// A12 splice: replay the imported Codex `raw` prefix verbatim — every
9739    /// line, not just the `session_meta`/`turn_context` headers
9740    /// [`Self::to_codex_jsonl`] replays — overriding only
9741    /// `session_meta.payload.id` when `session_id` is `Some` (every other
9742    /// line, including `response_item`s the stock synthesis would otherwise
9743    /// rebuild from scratch, is untouched byte-for-byte). Then synthesizes
9744    /// `response_item` records only for the appended tail, via
9745    /// [`Self::write_codex_records`].
9746    fn to_codex_jsonl_spliced(&self, session_id: Option<&str>) -> String {
9747        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
9748
9749        let mut out = String::new();
9750        for line in &self.raw[..raw_prefix_len] {
9751            match session_id {
9752                Some(id) => {
9753                    let patched = serde_json::from_str::<Value>(line)
9754                        .ok()
9755                        .filter(|v| v.get("type").and_then(Value::as_str) == Some("session_meta"))
9756                        .map(|mut v| {
9757                            if let Some(payload) = v.get_mut("payload") {
9758                                payload["id"] = Value::String(id.to_string());
9759                            }
9760                            v.to_string()
9761                        });
9762                    out.push_str(patched.as_deref().unwrap_or(line));
9763                }
9764                None => out.push_str(line),
9765            }
9766            out.push('\n');
9767        }
9768
9769        // N2 (spliced-path hardening): seed the tail's collision guard with
9770        // every group id the just-replayed RAW prefix already carries, so
9771        // `write_codex_records` never fabricates/reuses an id for the
9772        // appended tail that collides with one still open at the end of the
9773        // prefix (see that fn's doc comment, and
9774        // `collect_codex_group_ids_from_raw`'s).
9775        let mut seed_used_ids = collect_codex_group_ids_from_raw(&self.raw[..raw_prefix_len]);
9776        // Belt-and-suspenders: also union in the prefix `messages`' own
9777        // recorded `turn_id` metadata. In the ordinary case this is already
9778        // a subset of what the raw-line scan above found (the loader stamps
9779        // `metadata["turn_id"]` from the very same `payload.metadata.turn_id`
9780        // field the scan reads) — but scanning `messages` too costs nothing
9781        // and means this stays correct even if some future loader path ever
9782        // derives a message's `turn_id` by some means other than a literal
9783        // `payload.metadata.turn_id` copy.
9784        for msg in &self.messages[..message_prefix_len] {
9785            if let Some(tid) = msg.metadata.get("turn_id") {
9786                seed_used_ids.insert(tid.clone());
9787            }
9788        }
9789        self.write_codex_records(
9790            &mut out,
9791            &self.messages[message_prefix_len..],
9792            &seed_used_ids,
9793        );
9794        out
9795    }
9796
9797    /// Build a Codex header from scratch (used when converting from another
9798    /// format, where no original Codex header exists to replay). Emits the
9799    /// fields Codex requires on `session_meta`.
9800    fn write_synthesized_codex_header(&self, out: &mut String) {
9801        let mut meta_payload = serde_json::json!({
9802            "id": self.meta.session_id.clone().unwrap_or_else(|| synth_uuid(0)),
9803            "timestamp": SYNTH_TS,
9804            "cwd": self.cwd_string(),
9805            "originator": "supercode",
9806            "cli_version": env!("CARGO_PKG_VERSION"),
9807            "source": "exec",
9808            "thread_source": "user",
9809            "model_provider": "openai",
9810        });
9811        if let Some(sp) = &self.meta.system_prompt {
9812            meta_payload["base_instructions"] = serde_json::json!({"text": sp});
9813        }
9814        // PARITY-10 dev/03: carry a captured Claude `fork-context-ref` (see
9815        // `capture_claude_meta`) through the Codex hop under a clearly
9816        // namespaced custom field — real Codex tooling ignores unknown
9817        // `session_meta.payload` keys, and `capture_codex_session_meta`
9818        // reads this same key back on import, so a Claude -> Codex -> Claude
9819        // round trip still reconstructs the original record instead of
9820        // silently losing the lineage note on the cross-format hop.
9821        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
9822            meta_payload["claude_fork_context_ref"] =
9823                serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
9824        }
9825        push_jsonl(
9826            out,
9827            &serde_json::json!({"timestamp": SYNTH_TS, "type": "session_meta", "payload": meta_payload}),
9828        );
9829        if let Some(model) = &self.meta.model {
9830            push_jsonl(
9831                out,
9832                &serde_json::json!({
9833                    "timestamp": SYNTH_TS,
9834                    "type": "turn_context",
9835                    "payload": {"model": model, "cwd": self.cwd_string()},
9836                }),
9837            );
9838        }
9839    }
9840
9841    /// `turn_id`: see the PARITY-6/7 (and D3) comment on
9842    /// [`Self::write_codex_records`] — `Some` when the source message
9843    /// carries its own REAL `turn_id` (a native-Codex round-trip), or
9844    /// (assistant only) a synthetic disambiguation id when it owns tool
9845    /// calls needing merge disambiguation and has no real id of its own;
9846    /// `None` reproduces the exact historical shape (no `metadata` key at
9847    /// all).
9848    /// `extra_metadata`: PARITY-6 dev/02 — an additional `(key, value)`
9849    /// pair folded into `payload.metadata` alongside `turn_id` (used by the
9850    /// `Role::System` case in [`Self::write_codex_records`] to carry
9851    /// `claude_system_subtype`, so a Claude `<local-command-stdout>`-style
9852    /// system record's subtype survives the Claude -> Codex -> Claude round
9853    /// trip instead of only its text; `None` for every other caller,
9854    /// preserving the exact historical shape).
9855    fn push_codex_message(
9856        &self,
9857        out: &mut String,
9858        role: &str,
9859        text_type: &str,
9860        msg: &ChatMessage,
9861        turn_id: Option<&str>,
9862        extra_metadata: Option<(&str, &str)>,
9863    ) {
9864        let mut payload = with_turn_id(
9865            serde_json::json!({
9866                "type": "message",
9867                "role": role,
9868                "content": codex_message_content_blocks(text_type, msg),
9869            }),
9870            turn_id,
9871        );
9872        if let Some((k, v)) = extra_metadata {
9873            if payload.get("metadata").is_none() {
9874                payload["metadata"] = serde_json::json!({});
9875            }
9876            payload["metadata"][k] = serde_json::json!(v);
9877        }
9878        set_grok_message_extension(&mut payload, self.meta.source, msg);
9879        push_jsonl(
9880            out,
9881            &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9882        );
9883    }
9884
9885    /// Synthesize a fresh pi v3 session from the canonical `messages`
9886    /// (T3 cross-format/full synthesis — `to_jsonl(other)`'s "view round-trip"
9887    /// tier, §3/§4.1: this is NOT the byte-lossless native path, which goes
9888    /// through `raw` + `to_native_jsonl(_v2)` instead).
9889    fn to_pi_jsonl(&self) -> String {
9890        let session_id = self
9891            .meta
9892            .session_id
9893            .clone()
9894            .unwrap_or_else(|| synth_uuid(0));
9895        let cwd = self.cwd_string();
9896        let mut out = String::new();
9897        push_pi_header(
9898            &mut out,
9899            &session_id,
9900            &cwd,
9901            self.meta
9902                .lineage
9903                .get("parent_session_path")
9904                .map(String::as_str),
9905            self.meta.lineage.get("created_at").map(String::as_str),
9906            // D7: carry a captured Claude `fork-context-ref` (see
9907            // `capture_claude_meta`) through the Pi hop too — mirrors the
9908            // Codex hop's `claude_fork_context_ref` passthrough
9909            // (`write_synthesized_codex_header`) so a Claude -> Pi -> Claude
9910            // round trip doesn't silently lose fork lineage just because Pi
9911            // has no native slot for it.
9912            self.meta
9913                .lineage
9914                .get("claude_fork_context_ref_raw")
9915                .map(String::as_str),
9916        );
9917        let mut used_ids: HashSet<String> = HashSet::new();
9918        let mut counter: u64 = 0;
9919        self.write_pi_entries(&mut out, &self.messages, None, &mut used_ids, &mut counter);
9920        if let Some(extension) = codex_provenance_envelope(&self.meta) {
9921            inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
9922        }
9923        out
9924    }
9925
9926    /// Synthesize pi `message` entries for `messages` (a full session, or —
9927    /// for [`Self::to_pi_jsonl_spliced`] — just the appended tail), chaining
9928    /// `parentId` from `parent` and drawing fresh ids from `used_ids`/
9929    /// `counter`. Skips [`is_replay_excluded`] messages (`compacted_out`,
9930    /// `pi_exclude_from_context`) exactly like the Claude/Codex writers.
9931    fn write_pi_entries(
9932        &self,
9933        out: &mut String,
9934        messages: &[ChatMessage],
9935        mut parent: Option<String>,
9936        used_ids: &mut HashSet<String>,
9937        counter: &mut u64,
9938    ) {
9939        // Claude Code and Codex do not repeat the tool name on their native
9940        // tool-result records. Recover that redundant Pi field from the
9941        // paired assistant call when a cross-format round trip therefore
9942        // returns a canonical Tool message with `name == None`.
9943        let mut paired_tool_names = HashMap::<String, String>::new();
9944        for msg in messages {
9945            if is_replay_excluded(msg) {
9946                continue;
9947            }
9948            for call in msg.tool_calls() {
9949                paired_tool_names.insert(call.id.clone(), call.function.name.clone());
9950            }
9951            let id = pi_fresh_id(used_ids, counter);
9952            let mut entry = match msg.role {
9953                // B4: pi has no session-level system/developer PROMPT slot
9954                // (§1.1: "no system-prompt... rebuilt at runtime"), but a
9955                // content-bearing `Role::System` message loaded from a real
9956                // Claude Code `type: "system"` record (`push_claude_system`'s
9957                // keep-listed subtypes: `local_command`, `scheduled_task_fire`,
9958                // `away_summary`) is NOT a system prompt — it's a real,
9959                // non-regenerable transcript event. Pi's own `role:"custom"`
9960                // `CustomMessage` (§3e: "extension-injected... sent to the LLM
9961                // as a user message") is the closest existing, non-fabricated
9962                // slot pi's own parser already understands, so this
9963                // re-materializes the record there instead of silently
9964                // dropping it — the exact allowance push_claude_system's own
9965                // doc comment describes in reverse. `customType` is a
9966                // supercode-namespaced marker (`push_pi_custom_common`
9967                // recognizes it on reload and restores `Role::System` +
9968                // `metadata["systemSubtype"]`, exactly like `push_claude_system`
9969                // produced in the first place); a real pi customType never
9970                // collides with this name. `details.claude_system_subtype`
9971                // carries the original subtype losslessly through the pi leg
9972                // (mirrors `write_codex_records`'s `claude_system_subtype`
9973                // metadata channel on the Codex leg, PARITY-6 dev/02). Content
9974                // is never fabricated — only emitted when non-empty.
9975                Role::System => {
9976                    let content = msg.content.clone().unwrap_or_default();
9977                    if content.trim().is_empty() {
9978                        continue;
9979                    }
9980                    let subtype = msg
9981                        .metadata
9982                        .get("systemSubtype")
9983                        .cloned()
9984                        .unwrap_or_else(|| "local_command".to_string());
9985                    serde_json::json!({
9986                        "type": "message",
9987                        "id": id,
9988                        "parentId": parent,
9989                        "timestamp": msg_timestamp_or_synth(msg),
9990                        "message": {
9991                            "role": "custom",
9992                            "customType": PI_CLAUDE_SYSTEM_CUSTOM_TYPE,
9993                            "content": content,
9994                            "display": true,
9995                            "details": {"claude_system_subtype": subtype},
9996                            "timestamp": msg_pi_native_timestamp_ms(msg),
9997                        },
9998                    })
9999                }
10000                Role::User => serde_json::json!({
10001                    "type": "message",
10002                    "id": id,
10003                    "parentId": parent,
10004                    "timestamp": msg_timestamp_or_synth(msg),
10005                    "message": {
10006                        "role": "user",
10007                        "content": pi_content_value(msg),
10008                        "timestamp": msg_pi_native_timestamp_ms(msg),
10009                    },
10010                }),
10011                Role::Assistant => {
10012                    let api = msg
10013                        .metadata
10014                        .get("pi_api")
10015                        .cloned()
10016                        .unwrap_or_else(|| "anthropic-messages".to_string());
10017                    let provider = msg
10018                        .metadata
10019                        .get("pi_provider")
10020                        .cloned()
10021                        .unwrap_or_else(|| "anthropic".to_string());
10022                    let model = self
10023                        .meta
10024                        .model
10025                        .clone()
10026                        .unwrap_or_else(|| "unknown".to_string());
10027                    let usage = msg
10028                        .metadata
10029                        .get("pi_usage")
10030                        .and_then(|s| serde_json::from_str::<Value>(s).ok())
10031                        .unwrap_or_else(default_pi_usage);
10032                    let stop_reason = msg
10033                        .metadata
10034                        .get("pi_stop_reason")
10035                        .cloned()
10036                        .unwrap_or_else(|| "stop".to_string());
10037                    serde_json::json!({
10038                        "type": "message",
10039                        "id": id,
10040                        "parentId": parent,
10041                        "timestamp": msg_timestamp_or_synth(msg),
10042                        "message": {
10043                            "role": "assistant",
10044                            "content": pi_assistant_content_value(msg),
10045                            "api": api,
10046                            "provider": provider,
10047                            "model": model,
10048                            "usage": usage,
10049                            "stopReason": stop_reason,
10050                            "timestamp": msg_pi_native_timestamp_ms(msg),
10051                        },
10052                    })
10053                }
10054                Role::Tool => serde_json::json!({
10055                    "type": "message",
10056                    "id": id,
10057                    "parentId": parent,
10058                    "timestamp": msg_timestamp_or_synth(msg),
10059                    "message": {
10060                        "role": "toolResult",
10061                        "toolCallId": msg.tool_call_id.clone().unwrap_or_default(),
10062                        "toolName": msg.name.as_deref().or_else(|| {
10063                            msg.tool_call_id
10064                                .as_deref()
10065                                .and_then(|id| paired_tool_names.get(id).map(String::as_str))
10066                        }).unwrap_or_default(),
10067                        "content": pi_content_value(msg),
10068                        "isError": is_tool_error_flag(msg),
10069                        "timestamp": msg_pi_native_timestamp_ms(msg),
10070                    },
10071                }),
10072            };
10073            if msg.role == Role::Tool && crate::tool_outcome(msg) == crate::ToolOutcome::Unknown {
10074                entry[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
10075            }
10076            set_grok_message_extension(&mut entry, self.meta.source, msg);
10077            push_jsonl(out, &entry);
10078            parent = Some(id);
10079            if msg.role == Role::Tool {
10080                if let Some(call_id) = msg.tool_call_id.as_deref() {
10081                    paired_tool_names.remove(call_id);
10082                }
10083            }
10084        }
10085    }
10086
10087    /// A12-style splice for pi (§1.1/§4.2): replay the imported `raw` prefix
10088    /// **verbatim** — the header line always has its `version` normalized to
10089    /// 3 (§1.3: pi rewrites any pre-v3 file in place on load, destroying
10090    /// byte-identity, so the writer never re-emits one; this intentionally
10091    /// breaks byte-identity for pre-v3 originals only, the accepted
10092    /// trade-off) and its `id` overridden when `session_id` is `Some`. Every
10093    /// other raw line — every entry — is untouched (pi repeats the session
10094    /// id on no other line, `pi-fields.md` §1). Then synthesizes `message`
10095    /// entries only for the appended tail via [`Self::write_pi_entries`],
10096    /// chaining from the last entry `id` found in the raw prefix.
10097    fn to_pi_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
10098        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10099        if raw_prefix_len == 0 {
10100            return Ok(self.to_pi_jsonl());
10101        }
10102
10103        let mut out = String::new();
10104        let mut used_ids: HashSet<String> = HashSet::new();
10105        let mut leaf: Option<String> = None;
10106        for (i, line) in self.raw[..raw_prefix_len].iter().enumerate() {
10107            if i == 0 {
10108                if let Ok(v) = serde_json::from_str::<Value>(line) {
10109                    if v.get("type").and_then(Value::as_str) == Some("session") {
10110                        let needs_v3 = v.get("version").and_then(Value::as_u64) != Some(3);
10111                        // Only reparse+reserialize the header when something
10112                        // actually needs to change — this crate doesn't
10113                        // enable serde_json's `preserve_order`, so a no-op
10114                        // round-trip through `Value` would reorder keys
10115                        // alphabetically and silently break the "prefix
10116                        // bytes unchanged" splice guarantee for the (common)
10117                        // already-v3, no-override case.
10118                        if needs_v3 || session_id.is_some() {
10119                            let mut v = v;
10120                            v["version"] = serde_json::json!(3);
10121                            if let Some(new_id) = session_id {
10122                                v["id"] = Value::String(new_id.to_string());
10123                            }
10124                            out.push_str(&v.to_string());
10125                            out.push('\n');
10126                            continue;
10127                        }
10128                    }
10129                }
10130            }
10131            out.push_str(line);
10132            out.push('\n');
10133            if let Ok(v) = serde_json::from_str::<Value>(line) {
10134                if let Some(id) = v.get("id").and_then(Value::as_str) {
10135                    used_ids.insert(id.to_string());
10136                    leaf = Some(id.to_string());
10137                }
10138            }
10139        }
10140
10141        let mut counter: u64 = 0;
10142        self.write_pi_entries(
10143            &mut out,
10144            &self.messages[message_prefix_len..],
10145            leaf,
10146            &mut used_ids,
10147            &mut counter,
10148        );
10149        Ok(out)
10150    }
10151
10152    // ---- Grok writers -----------------------------------------------
10153
10154    /// Synthesize Grok's resumable `chat_history.jsonl` transcript.
10155    fn to_grok_jsonl(&self) -> String {
10156        let mut out = String::new();
10157        if let Some(prompt) = self
10158            .meta
10159            .system_prompt
10160            .as_deref()
10161            .filter(|prompt| !prompt.is_empty())
10162        {
10163            push_jsonl(
10164                &mut out,
10165                &serde_json::json!({
10166                    "type": "system",
10167                    "content": prompt,
10168                }),
10169            );
10170        }
10171        self.write_grok_records(&mut out, &self.messages);
10172        if let Some(extension) = codex_provenance_envelope(&self.meta) {
10173            if out.is_empty() {
10174                push_jsonl(
10175                    &mut out,
10176                    &serde_json::json!({"type": "system", "content": ""}),
10177                );
10178            }
10179            inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
10180        }
10181        out
10182    }
10183
10184    fn write_grok_records(&self, out: &mut String, messages: &[ChatMessage]) {
10185        for message in messages {
10186            if is_replay_excluded(message) {
10187                continue;
10188            }
10189            let mut value = match message.role {
10190                Role::System => serde_json::json!({
10191                    "type": "user",
10192                    "content": [{"type": "text", "text": message.content.clone().unwrap_or_default()}],
10193                    "synthetic_reason": "supercode_system_event",
10194                }),
10195                Role::User => {
10196                    let mut value = serde_json::json!({
10197                        "type": "user",
10198                        "content": [{"type": "text", "text": message.content.clone().unwrap_or_default()}],
10199                    });
10200                    if let Some(object) = value.as_object_mut() {
10201                        for (metadata, field) in [
10202                            ("grok_prompt_index", "prompt_index"),
10203                            ("grok_prior_turn_interrupt", "prior_turn_interrupt"),
10204                            ("grok_synthetic_reason", "synthetic_reason"),
10205                        ] {
10206                            if let Some(raw) = message.metadata.get(metadata) {
10207                                object.insert(
10208                                    field.to_string(),
10209                                    serde_json::from_str(raw)
10210                                        .unwrap_or_else(|_| Value::String(raw.clone())),
10211                                );
10212                            }
10213                        }
10214                    }
10215                    value
10216                }
10217                Role::Assistant => {
10218                    let calls = message
10219                        .tool_calls()
10220                        .iter()
10221                        .map(|call| {
10222                            serde_json::json!({
10223                                "id": call.id,
10224                                "name": call.function.name,
10225                                "arguments": call.function.arguments,
10226                            })
10227                        })
10228                        .collect::<Vec<_>>();
10229                    let mut value = serde_json::json!({
10230                        "type": "assistant",
10231                        "content": message.content.clone().unwrap_or_default(),
10232                        "tool_calls": calls,
10233                        "model_id": message.metadata.get("grok_model_id")
10234                            .or(self.meta.model.as_ref())
10235                            .cloned()
10236                            .unwrap_or_else(|| "unknown".to_string()),
10237                    });
10238                    if let Some(object) = value.as_object_mut() {
10239                        for (metadata, field) in [
10240                            ("grok_model_fingerprint", "model_fingerprint"),
10241                            ("grok_reasoning_effort", "reasoning_effort"),
10242                        ] {
10243                            if let Some(raw) = message.metadata.get(metadata) {
10244                                object.insert(field.to_string(), Value::String(raw.clone()));
10245                            }
10246                        }
10247                    }
10248                    value
10249                }
10250                Role::Tool => serde_json::json!({
10251                    "type": "tool_result",
10252                    "tool_call_id": message.tool_call_id.clone().unwrap_or_default(),
10253                    "content": message.content.clone().unwrap_or_default(),
10254                }),
10255            };
10256            set_grok_target_message_extension(&mut value, message);
10257            push_jsonl(out, &value);
10258        }
10259    }
10260
10261    /// Replay a Grok imported prefix verbatim, then append newly-created
10262    /// canonical turns. Grok stores the session id in the directory name,
10263    /// not in transcript records, so there is no in-file id to rewrite.
10264    fn to_grok_jsonl_spliced(&self) -> String {
10265        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10266        if raw_prefix_len == 0 {
10267            return self.to_grok_jsonl();
10268        }
10269        let mut out = String::new();
10270        for line in &self.raw[..raw_prefix_len] {
10271            out.push_str(line);
10272            out.push('\n');
10273        }
10274        self.write_grok_records(&mut out, &self.messages[message_prefix_len..]);
10275        out
10276    }
10277
10278    // ---- Gemini writers ---------------------------------------------
10279
10280    fn to_gemini_jsonl(&self) -> String {
10281        let mut out = String::new();
10282        self.write_gemini_header(&mut out, self.meta.session_id.as_deref());
10283        self.write_gemini_records(&mut out, &self.messages);
10284        push_jsonl(
10285            &mut out,
10286            &serde_json::json!({
10287                "$set": {"lastUpdated": SYNTH_TS}
10288            }),
10289        );
10290        out
10291    }
10292
10293    fn write_gemini_header(&self, out: &mut String, session_id: Option<&str>) {
10294        push_jsonl(
10295            out,
10296            &serde_json::json!({
10297                "sessionId": session_id.unwrap_or("supercode-gemini-session"),
10298                "projectHash": self.meta.lineage.get("gemini_project_hash")
10299                    .cloned().unwrap_or_else(|| "supercode".to_string()),
10300                "startTime": self.meta.lineage.get("created_at")
10301                    .cloned().unwrap_or_else(|| SYNTH_TS.to_string()),
10302                "lastUpdated": self.meta.lineage.get("updated_at")
10303                    .cloned().unwrap_or_else(|| SYNTH_TS.to_string()),
10304                "kind": self.meta.lineage.get("gemini_session_kind")
10305                    .cloned().unwrap_or_else(|| "main".to_string()),
10306            }),
10307        );
10308    }
10309
10310    fn write_gemini_records(&self, out: &mut String, messages: &[ChatMessage]) {
10311        let mut call_names = HashMap::new();
10312        for (index, message) in messages.iter().enumerate() {
10313            if is_replay_excluded(message) {
10314                continue;
10315            }
10316            let timestamp = message
10317                .metadata
10318                .get("timestamp")
10319                .cloned()
10320                .unwrap_or_else(|| SYNTH_TS.to_string());
10321            match message.role {
10322                Role::System | Role::User => {
10323                    let mut parts = Vec::new();
10324                    let text = message.content.clone().or_else(|| {
10325                        message.content_parts.as_ref().and_then(|parts| {
10326                            let text = parts
10327                                .iter()
10328                                .filter_map(|part| part.get("text").and_then(Value::as_str))
10329                                .collect::<Vec<_>>()
10330                                .join(" ");
10331                            (!text.is_empty()).then_some(text)
10332                        })
10333                    });
10334                    if let Some(text) = text {
10335                        let text = if message.role == Role::System {
10336                            format!("[System] {text}")
10337                        } else {
10338                            text
10339                        };
10340                        parts.push(serde_json::json!({"text": text}));
10341                    }
10342                    if let Some(content_parts) = &message.content_parts {
10343                        for part in content_parts {
10344                            let Some(url) = part
10345                                .get("image_url")
10346                                .and_then(|value| value.get("url"))
10347                                .and_then(Value::as_str)
10348                            else {
10349                                continue;
10350                            };
10351                            let Some(rest) = url.strip_prefix("data:") else {
10352                                continue;
10353                            };
10354                            let Some((media_type, data)) = rest.split_once(";base64,") else {
10355                                continue;
10356                            };
10357                            parts.push(serde_json::json!({
10358                                "inlineData": {"mimeType": media_type, "data": data}
10359                            }));
10360                        }
10361                    }
10362                    if !parts.is_empty() {
10363                        let mut value = serde_json::json!({
10364                            "id": format!("supercode-user-{index}"),
10365                            "timestamp": timestamp,
10366                            "type": "user",
10367                            "content": parts,
10368                        });
10369                        set_gemini_message_extension(&mut value, message);
10370                        push_jsonl(out, &value);
10371                    }
10372                }
10373                Role::Assistant => {
10374                    let mut tool_calls = Vec::new();
10375                    for call in message.tool_calls() {
10376                        call_names.insert(call.id.clone(), call.function.name.clone());
10377                        let args = serde_json::from_str::<Value>(&call.function.arguments)
10378                            .unwrap_or_else(|_| Value::String(call.function.arguments.clone()));
10379                        tool_calls.push(serde_json::json!({
10380                            "id": call.id,
10381                            "name": call.function.name,
10382                            "args": args,
10383                        }));
10384                    }
10385                    let mut value = serde_json::json!({
10386                        "id": format!("supercode-gemini-{index}"),
10387                        "timestamp": timestamp,
10388                        "type": "gemini",
10389                        "content": message.content.clone().unwrap_or_default(),
10390                        "model": message.metadata.get("gemini_model")
10391                            .or(self.meta.model.as_ref())
10392                            .cloned().unwrap_or_else(|| "unknown".to_string()),
10393                    });
10394                    if !tool_calls.is_empty() {
10395                        value["toolCalls"] = Value::Array(tool_calls);
10396                    }
10397                    if let Some(thoughts) = message.metadata.get("gemini_thoughts") {
10398                        value["thoughts"] = serde_json::from_str(thoughts)
10399                            .unwrap_or_else(|_| Value::String(thoughts.clone()));
10400                    }
10401                    set_gemini_message_extension(&mut value, message);
10402                    push_jsonl(out, &value);
10403                }
10404                Role::Tool => {
10405                    let id = message.tool_call_id.clone().unwrap_or_default();
10406                    let name = message
10407                        .name
10408                        .clone()
10409                        .or_else(|| call_names.get(&id).cloned())
10410                        .unwrap_or_else(|| "tool".to_string());
10411                    let output = message.content.clone().unwrap_or_else(|| {
10412                        message
10413                            .content_parts
10414                            .as_ref()
10415                            .map(|parts| Value::Array(parts.clone()))
10416                            .map(|value| value.to_string())
10417                            .unwrap_or_default()
10418                    });
10419                    let mut value = serde_json::json!({
10420                        "id": format!("supercode-tool-{index}"),
10421                        "timestamp": timestamp,
10422                        "type": "user",
10423                        "content": [{
10424                            "functionResponse": {
10425                                "id": id,
10426                                "name": name,
10427                                "response": {"output": output}
10428                            }
10429                        }],
10430                    });
10431                    set_gemini_message_extension(&mut value, message);
10432                    push_jsonl(out, &value);
10433                }
10434            }
10435        }
10436    }
10437
10438    fn to_gemini_jsonl_spliced(&self, session_id: Option<&str>) -> String {
10439        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10440        if raw_prefix_len == 0 {
10441            let mut out = String::new();
10442            self.write_gemini_header(&mut out, session_id.or(self.meta.session_id.as_deref()));
10443            self.write_gemini_records(&mut out, &self.messages);
10444            return out;
10445        }
10446        let mut out = String::new();
10447        for (index, line) in self.raw[..raw_prefix_len].iter().enumerate() {
10448            if index == 0 && session_id.is_some() {
10449                if let Ok(mut value) = serde_json::from_str::<Value>(line) {
10450                    if value.get("type").is_none() && value.get("sessionId").is_some() {
10451                        value["sessionId"] =
10452                            Value::String(session_id.unwrap_or_default().to_string());
10453                        push_jsonl(&mut out, &value);
10454                        continue;
10455                    }
10456                }
10457            }
10458            out.push_str(line);
10459            out.push('\n');
10460        }
10461        self.write_gemini_records(&mut out, &self.messages[message_prefix_len..]);
10462        out
10463    }
10464
10465    // ---- Goose writers ----------------------------------------------
10466
10467    fn to_goose_json(&self) -> String {
10468        if self.meta.source == SessionSource::Goose
10469            && !self.raw.is_empty()
10470            && self.imported_message_count == Some(self.messages.len())
10471        {
10472            return join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10473        }
10474        self.synthesized_goose_document(None, &self.messages)
10475    }
10476
10477    fn to_goose_json_spliced(&self, session_id: Option<&str>) -> String {
10478        let message_prefix_len = self
10479            .imported_message_count
10480            .unwrap_or(self.messages.len())
10481            .min(self.messages.len());
10482        if self.meta.source == SessionSource::Goose && !self.raw.is_empty() {
10483            if session_id.is_none() && message_prefix_len == self.messages.len() {
10484                return join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10485            }
10486            let source = join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10487            if let Ok(mut document) = serde_json::from_str::<Value>(&source) {
10488                if let Some(session_id) = session_id {
10489                    document["id"] = Value::String(session_id.to_string());
10490                }
10491                let appended = self.goose_conversation(&self.messages[message_prefix_len..]);
10492                if let Some(conversation) = document
10493                    .get_mut("conversation")
10494                    .and_then(Value::as_array_mut)
10495                {
10496                    conversation.extend(appended);
10497                    document["message_count"] = Value::from(conversation.len());
10498                }
10499                return serde_json::to_string_pretty(&document).unwrap_or_else(|_| {
10500                    self.synthesized_goose_document(session_id, &self.messages)
10501                });
10502            }
10503        }
10504        self.synthesized_goose_document(session_id, &self.messages)
10505    }
10506
10507    fn synthesized_goose_document(
10508        &self,
10509        session_id: Option<&str>,
10510        messages: &[ChatMessage],
10511    ) -> String {
10512        let mut document = self
10513            .meta
10514            .goose_header
10515            .clone()
10516            .or_else(|| {
10517                self.messages.iter().find_map(|message| {
10518                    message
10519                        .metadata
10520                        .get("goose_session_header")
10521                        .and_then(|value| serde_json::from_str(value).ok())
10522                })
10523            })
10524            .unwrap_or_else(|| {
10525                serde_json::json!({
10526                    "id": "supercode-goose-session",
10527                    "working_dir": self.cwd_string(),
10528                    "name": "supercode export",
10529                    "user_set_name": false,
10530                    "session_type": "user",
10531                    "created_at": SYNTH_TS,
10532                    "updated_at": SYNTH_TS,
10533                    "extension_data": {},
10534                    "usage": {},
10535                    "accumulated_usage": {},
10536                    "accumulated_cost": Value::Null,
10537                    "schedule_id": Value::Null,
10538                    "recipe": Value::Null,
10539                    "user_recipe_values": Value::Null,
10540                    "message_count": 0,
10541                    "last_message_at": Value::Null,
10542                    "provider_name": Value::Null,
10543                    "model_config": Value::Null,
10544                    "goose_mode": "auto",
10545                    "archived_at": Value::Null,
10546                    "project_id": Value::Null,
10547                    "parent_session_id": Value::Null,
10548                    "last_message_snippet": Value::Null,
10549                })
10550            });
10551        document["id"] = Value::String(
10552            session_id
10553                .map(str::to_string)
10554                .or_else(|| self.meta.session_id.clone())
10555                .unwrap_or_else(|| "supercode-goose-session".to_string()),
10556        );
10557        document["working_dir"] = Value::String(self.cwd_string());
10558        let conversation = self.goose_conversation(messages);
10559        document["message_count"] = Value::from(conversation.len());
10560        document["conversation"] = Value::Array(conversation);
10561        serde_json::to_string_pretty(&document).unwrap_or_else(|_| "{}".to_string())
10562    }
10563
10564    fn goose_conversation(&self, messages: &[ChatMessage]) -> Vec<Value> {
10565        let mut out = Vec::new();
10566        let mut last_native_index: Option<String> = None;
10567        let mut tool_names = HashMap::<String, String>::new();
10568        for (index, message) in messages.iter().enumerate() {
10569            if is_replay_excluded(message) {
10570                continue;
10571            }
10572            if let Some(native_index) = message.metadata.get("goose_native_index") {
10573                if last_native_index.as_ref() == Some(native_index) {
10574                    continue;
10575                }
10576                last_native_index = Some(native_index.clone());
10577                if let Some(native) = message
10578                    .metadata
10579                    .get("goose_native_message")
10580                    .and_then(|value| serde_json::from_str::<Value>(value).ok())
10581                {
10582                    out.push(native);
10583                    continue;
10584                }
10585            } else {
10586                last_native_index = None;
10587            }
10588
10589            for call in message.tool_calls() {
10590                tool_names.insert(call.id.clone(), call.function.name.clone());
10591            }
10592            let created = message
10593                .metadata
10594                .get("goose_created")
10595                .and_then(|value| value.parse::<i64>().ok())
10596                .unwrap_or(SYNTH_TS_MS / 1000 + index as i64);
10597            let role = match message.role {
10598                Role::Assistant => "assistant",
10599                _ => "user",
10600            };
10601            let mut content = Vec::new();
10602            // A Goose tool response carries its output inside
10603            // `toolResult.value.content`; duplicating it as a sibling text
10604            // block makes the loader normalize one Tool message twice.
10605            if message.role != Role::Tool {
10606                if let Some(text) = &message.content {
10607                    let text = if message.role == Role::System {
10608                        format!("[System] {text}")
10609                    } else {
10610                        text.clone()
10611                    };
10612                    content.push(serde_json::json!({"type": "text", "text": text}));
10613                }
10614                if let Some(parts) = &message.content_parts {
10615                    for part in parts {
10616                        if let Some(text) = part.get("text").and_then(Value::as_str) {
10617                            if message.content.is_none() {
10618                                content.push(serde_json::json!({"type": "text", "text": text}));
10619                            }
10620                        }
10621                        let Some(url) = part
10622                            .get("image_url")
10623                            .and_then(|image| image.get("url"))
10624                            .and_then(Value::as_str)
10625                        else {
10626                            continue;
10627                        };
10628                        let Some(data) = url.strip_prefix("data:") else {
10629                            continue;
10630                        };
10631                        let Some((media_type, data)) = data.split_once(";base64,") else {
10632                            continue;
10633                        };
10634                        content.push(serde_json::json!({
10635                            "type": "image",
10636                            "data": data,
10637                            "mimeType": media_type,
10638                        }));
10639                    }
10640                }
10641            }
10642            for call in message.tool_calls() {
10643                let arguments = serde_json::from_str::<Value>(&call.function.arguments)
10644                    .unwrap_or_else(|_| Value::String(call.function.arguments.clone()));
10645                content.push(serde_json::json!({
10646                    "type": "toolRequest",
10647                    "id": call.id,
10648                    "toolCall": {
10649                        "status": "success",
10650                        "value": {"name": call.function.name, "arguments": arguments}
10651                    }
10652                }));
10653            }
10654            if message.role == Role::Tool {
10655                let id = message.tool_call_id.clone().unwrap_or_default();
10656                let output = message.content.clone().unwrap_or_else(|| {
10657                    message
10658                        .content_parts
10659                        .as_ref()
10660                        .map(|parts| Value::Array(parts.clone()).to_string())
10661                        .unwrap_or_default()
10662                });
10663                let tool_result = if crate::is_tool_error(message) {
10664                    serde_json::json!({"status": "error", "error": output})
10665                } else {
10666                    serde_json::json!({
10667                        "status": "success",
10668                        "value": {
10669                            "content": [{"type": "text", "text": output}],
10670                            "isError": false
10671                        }
10672                    })
10673                };
10674                content.push(serde_json::json!({
10675                    "type": "toolResponse",
10676                    "id": id,
10677                    "toolResult": tool_result,
10678                    "metadata": {
10679                        "toolName": message.name.as_ref()
10680                            .or_else(|| tool_names.get(&id))
10681                    }
10682                }));
10683            }
10684            if content.is_empty() {
10685                continue;
10686            }
10687            let metadata = message
10688                .metadata
10689                .get("goose_metadata")
10690                .and_then(|value| serde_json::from_str::<Value>(value).ok())
10691                .unwrap_or_else(|| {
10692                    serde_json::json!({
10693                        "userVisible": true,
10694                        "agentVisible": true
10695                    })
10696                });
10697            let mut native = serde_json::json!({
10698                "id": message.metadata.get("goose_message_id")
10699                    .cloned().unwrap_or_else(|| format!("supercode-goose-{index}")),
10700                "role": role,
10701                "created": created,
10702                "content": content,
10703                "metadata": metadata,
10704            });
10705            // Goose tolerates unknown top-level fields on a conversation
10706            // message. Always carry the canonical envelope when Goose is
10707            // the TARGET so metadata absent from Goose's stock schema can
10708            // make a later Goose -> source round trip without residue.
10709            set_grok_target_message_extension(&mut native, message);
10710            out.push(native);
10711        }
10712        out
10713    }
10714
10715    // ---- OpenCode writers ---------------------------------------------
10716
10717    /// Re-derive the structured OpenCode records (`SessionInfo`, an ordered
10718    /// `(message value, part values)` list) directly from `self.raw`'s
10719    /// envelope lines — the same classification
10720    /// [`Self::from_opencode_str`] performs, but returning the raw VALUES
10721    /// rather than canonical `ChatMessage`s. Used by
10722    /// [`Self::to_opencode_jsonl_spliced`] (§4.2 S5: the imported prefix
10723    /// must be VALUE-EQUAL at its position, not re-synthesized from lossy
10724    /// `messages`) and [`Self::to_opencode_direct_write`] (S5: the fidelity
10725    /// path for excess keys/timestamps/side-records `opencode import`
10726    /// cannot restore).
10727    fn opencode_records_from_raw(&self) -> (Option<Value>, Vec<(Value, Vec<Value>)>) {
10728        let mut session_info: Option<Value> = None;
10729        let mut msg_order: Vec<String> = Vec::new();
10730        let mut msg_values: HashMap<String, Value> = HashMap::new();
10731        let mut msg_parts: HashMap<String, Vec<Value>> = HashMap::new();
10732        for line in &self.raw {
10733            let Ok(env) = serde_json::from_str::<Value>(line) else {
10734                continue;
10735            };
10736            let Some(key) = env.get("key").and_then(Value::as_array) else {
10737                continue;
10738            };
10739            let value = env.get("value").cloned().unwrap_or(Value::Null);
10740            match key.first().and_then(Value::as_str) {
10741                Some("session") => session_info = Some(value),
10742                Some("message") => {
10743                    if let Some(id) = value.get("id").and_then(Value::as_str) {
10744                        if !msg_values.contains_key(id) {
10745                            msg_order.push(id.to_string());
10746                        }
10747                        msg_values.insert(id.to_string(), value);
10748                    }
10749                }
10750                Some("part") => {
10751                    if let Some(mid) = value.get("messageID").and_then(Value::as_str) {
10752                        msg_parts.entry(mid.to_string()).or_default().push(value);
10753                    }
10754                }
10755                _ => {}
10756            }
10757        }
10758        let mut ordered: Vec<(String, i64)> = msg_order
10759            .iter()
10760            .map(|id| {
10761                let tc = msg_values
10762                    .get(id)
10763                    .and_then(|v| v.get("time"))
10764                    .and_then(|t| t.get("created"))
10765                    .and_then(Value::as_i64)
10766                    .unwrap_or(0);
10767                (id.clone(), tc)
10768            })
10769            .collect();
10770        ordered.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
10771        let mut out = Vec::new();
10772        for (id, _) in ordered {
10773            let mut parts = msg_parts.remove(&id).unwrap_or_default();
10774            parts.sort_by(|a, b| {
10775                let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
10776                let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
10777                ai.cmp(bi)
10778            });
10779            if let Some(v) = msg_values.remove(&id) {
10780                out.push((v, parts));
10781            }
10782        }
10783        (session_info, out)
10784    }
10785
10786    /// Best-effort `SessionInfo` synthesized from `self.meta` — used when no
10787    /// `raw` prefix exists to replay (a fresh/cross-format-converted
10788    /// session). T3 tier: only what `SessionMeta` carries survives.
10789    fn synthesized_opencode_info(&self) -> Value {
10790        let id = self
10791            .meta
10792            .session_id
10793            .clone()
10794            .unwrap_or_else(|| "ses_supercode00000000000001".to_string());
10795        let mut info = serde_json::json!({
10796            "id": id,
10797            "projectID": self.meta.lineage.get("projectID").cloned().unwrap_or_else(|| "global".to_string()),
10798            // OpenCode 1.2.15's import path writes this into a NOT NULL
10799            // SQLite column. Preserve a real source slug when available and
10800            // mint a stable, human-readable fallback for foreign sessions.
10801            "slug": self.meta.lineage.get("slug").cloned().unwrap_or_else(|| "supercode-export".to_string()),
10802            "directory": self.cwd_string(),
10803            "title": "supercode export",
10804            "version": env!("CARGO_PKG_VERSION"),
10805            "time": {"created": SYNTH_TS_MS, "updated": SYNTH_TS_MS},
10806        });
10807        if let Some(agent) = &self.meta.agent_id {
10808            info["agent"] = Value::String(agent.clone());
10809        }
10810        if let Some(model) = &self.meta.model {
10811            if let Some((provider, mid)) = model.split_once('/') {
10812                info["model"] = serde_json::json!({"providerID": provider, "id": mid});
10813            }
10814        }
10815        if let Some(parent) = self.meta.lineage.get("parent_session_id") {
10816            info["parentID"] = Value::String(parent.clone());
10817        }
10818        // D7: carry a captured Claude `fork-context-ref` through the
10819        // OpenCode hop too, exactly like the Codex (`claude_fork_context_ref`
10820        // on `session_meta.payload`) and Pi (`claude_fork_context_ref` on
10821        // the `session` header) hops already do — namespaced so real
10822        // OpenCode tooling ignores it, and `capture_opencode_session_info`
10823        // reads this same key back on import so a Claude -> OpenCode ->
10824        // Claude round trip doesn't silently lose fork lineage either.
10825        //
10826        // DOCUMENTED LIMITATION (D7 caveat, per the frozen interop spec —
10827        // `interop-research-spec@f168465`, `opencode-pi-spec.md` ~line 123):
10828        // this `claude_fork_context_ref` key on `SessionInfo` survives
10829        // supercode's OWN round-trip (write here, read back by
10830        // `capture_opencode_session_info` above) but NOT a real upstream
10831        // `opencode import` ingestion — that path decodes with
10832        // `Schema.decodeUnknownSync`, which strips any key its schema
10833        // doesn't declare. The direct-file/DB fallback (bypassing
10834        // `opencode import` entirely) is the per-spec fidelity path for
10835        // this lineage to actually reach real OpenCode.
10836        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
10837            info["claude_fork_context_ref"] =
10838                serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
10839        }
10840        if let Some(extension) = codex_provenance_envelope(&self.meta) {
10841            info[SUPERCODE_CODEX_PROVENANCE_KEY] = extension;
10842        }
10843        info
10844    }
10845
10846    /// OpenCode uses `SessionInfo.time.updated` for recency selection. Any
10847    /// synthesized continuation message therefore has to advance the
10848    /// session clock along with its own `time.created` value.
10849    fn touch_opencode_session_updated(info: &mut Value, timestamp: i64) {
10850        if !info.get("time").is_some_and(Value::is_object) {
10851            info["time"] = serde_json::json!({});
10852        }
10853        info["time"]["updated"] = serde_json::json!(timestamp);
10854    }
10855
10856    /// Synthesize opencode `{info, parts}` message objects for `messages`
10857    /// (T3 tier — full synthesis from canonical `ChatMessage`s, the inverse
10858    /// of [`push_opencode_user`]/[`push_opencode_assistant`]), appending
10859    /// them to `out`. Every `Tool` message anywhere in `messages` is folded
10860    /// back into its call's assistant `tool` part (match by
10861    /// `tool_call_id`/`callID`, pairing repeated ids by occurrence over the
10862    /// whole slice)
10863    /// — the exact inverse of the loader's call/result split. This is a
10864    /// GLOBAL match, not a scan of the contiguous run of `Tool` messages
10865    /// immediately following each assistant: two-or-more consecutive
10866    /// assistant-with-tool-call messages before their results (streamed /
10867    /// parallel tool calls) otherwise strand the earlier call's real result
10868    /// behind a later assistant message, silently downgrading it to
10869    /// "pending" and losing the recorded output. Skips [`is_replay_excluded`]
10870    /// messages exactly like every other writer.
10871    fn append_synthesized_opencode_messages(
10872        &self,
10873        out: &mut Vec<Value>,
10874        messages: &[ChatMessage],
10875        session_id: &str,
10876        counter: &mut u64,
10877        timestamp_cursor: &mut i64,
10878    ) -> Result<()> {
10879        // Pair each Tool message to its call GLOBALLY by `tool_call_id`,
10880        // over the ENTIRE slice being processed, rather than by scanning
10881        // only the contiguous run of `Role::Tool` messages immediately
10882        // following a given assistant message. Two-or-more consecutive
10883        // assistant-with-tool-call messages before their results (streamed
10884        // / parallel tool calls — extremely common in real Claude Code and
10885        // Codex sessions) break the contiguous-run assumption: the first
10886        // assistant's own result(s) land AFTER a second assistant message,
10887        // not immediately after the first, so a contiguous scan starting
10888        // right after the first assistant finds nothing and silently drops
10889        // its real tool output into the `None => "pending"` branch below.
10890        // A single `id -> result` map is still insufficient: long real
10891        // sessions can reuse provider call ids. Last-write-wins then attaches
10892        // the final output to every earlier occurrence. Collect calls and
10893        // results independently and zip their occurrences in transcript
10894        // order, giving every concrete call position its own result.
10895        let mut calls_by_id: HashMap<&str, Vec<(usize, usize)>> = HashMap::new();
10896        let mut results_by_id: HashMap<&str, Vec<(usize, &ChatMessage)>> = HashMap::new();
10897        for (message_index, message) in messages.iter().enumerate() {
10898            if message.role == Role::Assistant {
10899                for (tool_index, call) in message.tool_calls().iter().enumerate() {
10900                    calls_by_id
10901                        .entry(call.id.as_str())
10902                        .or_default()
10903                        .push((message_index, tool_index));
10904                }
10905            } else if message.role == Role::Tool {
10906                if let Some(id) = &message.tool_call_id {
10907                    results_by_id
10908                        .entry(id.as_str())
10909                        .or_default()
10910                        .push((message_index, message));
10911                }
10912            }
10913        }
10914        let mut paired_results: HashMap<(usize, usize), (usize, &ChatMessage)> = HashMap::new();
10915        for (id, calls) in calls_by_id {
10916            let Some(results) = results_by_id.get(id) else {
10917                continue;
10918            };
10919            for (call_position, result) in calls.into_iter().zip(results.iter().copied()) {
10920                paired_results.insert(call_position, result);
10921            }
10922        }
10923        let mut i = 0;
10924        while i < messages.len() {
10925            let msg = &messages[i];
10926            if is_replay_excluded(msg) {
10927                i += 1;
10928                continue;
10929            }
10930            match msg.role {
10931                // B4: opencode V1 has no session-level system-PROMPT slot
10932                // either — `User.system` is a per-turn system-PROMPT
10933                // OVERRIDE (§2.1), a different thing from a content-bearing
10934                // `Role::System` message loaded from a real Claude `type:
10935                // "system"` record (`push_claude_system`'s keep-listed
10936                // subtypes). Stuffing real transcript content into
10937                // `User.system` would be a genuine misuse — it overrides the
10938                // replayed system prompt, not just annotates a turn — so
10939                // this instead reuses opencode's own `text` part `synthetic`
10940                // flag (§3.1: "injected by opencode, not typed by user"),
10941                // which is EXACTLY the right existing, non-fabricated
10942                // semantic for "system-originated content presented as a
10943                // user turn": a dedicated `User` message with one
10944                // `synthetic: true` text part, tagged with a
10945                // supercode-namespaced part-`metadata` key so
10946                // `opencode_claude_system_subtype`/`push_opencode_claude_system`
10947                // recognize it on reload and restore `Role::System` +
10948                // `metadata["systemSubtype"]` rather than treating it as a
10949                // real user turn. Content is never fabricated — only
10950                // emitted when non-empty.
10951                Role::System => {
10952                    let content = msg.content.clone().unwrap_or_default();
10953                    if content.trim().is_empty() {
10954                        i += 1;
10955                        continue;
10956                    }
10957                    let subtype = msg
10958                        .metadata
10959                        .get("systemSubtype")
10960                        .cloned()
10961                        .unwrap_or_else(|| "local_command".to_string());
10962                    let msg_id = opencode_fresh_id("msg", counter);
10963                    let part_id = opencode_fresh_id("prt", counter);
10964                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
10965                    let mut info = serde_json::json!({
10966                        "id": msg_id,
10967                        "sessionID": session_id,
10968                        "role": "user",
10969                        "time": {"created": timestamp},
10970                    });
10971                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
10972                    let parts = vec![serde_json::json!({
10973                        "id": part_id,
10974                        "sessionID": session_id,
10975                        "messageID": msg_id,
10976                        "type": "text",
10977                        "text": content,
10978                        "synthetic": true,
10979                        "metadata": {OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: subtype},
10980                    })];
10981                    out.push(serde_json::json!({"info": info, "parts": parts}));
10982                    i += 1;
10983                }
10984                Role::User => {
10985                    let msg_id = opencode_fresh_id("msg", counter);
10986                    let parts = opencode_user_parts_from_message(msg, &msg_id, session_id, counter);
10987                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
10988                    let mut info = serde_json::json!({
10989                        "id": msg_id,
10990                        "sessionID": session_id,
10991                        "role": "user",
10992                        "time": {"created": timestamp},
10993                    });
10994                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
10995                    opencode_restore_agent_model_fields(
10996                        &mut info, msg, /* is_assistant */ false,
10997                    );
10998                    set_grok_message_extension(&mut info, self.meta.source, msg);
10999                    out.push(serde_json::json!({
11000                        "info": info,
11001                        "parts": parts,
11002                    }));
11003                    i += 1;
11004                }
11005                Role::Assistant => {
11006                    let msg_id = opencode_fresh_id("msg", counter);
11007                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
11008                    let mut parts = Vec::new();
11009                    if let Some(thinking) = msg.metadata.get("thinking") {
11010                        let mut part = serde_json::json!({
11011                            "id": opencode_fresh_id("prt", counter),
11012                            "sessionID": session_id,
11013                            "messageID": msg_id,
11014                            "type": "reasoning",
11015                            "text": thinking,
11016                            // Required by OpenCode V1's native reasoning
11017                            // schema. A synthesized part has no distinct
11018                            // stream start/end, so the source message clock
11019                            // is the honest zero-duration span.
11020                            "time": {"start": timestamp, "end": timestamp},
11021                        });
11022                        if let Some(signature) = msg.metadata.get("thinking_signature") {
11023                            part["metadata"] = serde_json::json!({
11024                                "anthropic": {"signature": signature},
11025                            });
11026                        }
11027                        parts.push(part);
11028                    }
11029                    if let Some(t) = &msg.content {
11030                        if !t.is_empty() {
11031                            parts.push(serde_json::json!({
11032                                "id": opencode_fresh_id("prt", counter),
11033                                "sessionID": session_id,
11034                                "messageID": msg_id,
11035                                "type": "text",
11036                                "text": t,
11037                            }));
11038                        }
11039                    }
11040                    // Fold each tool call's result back into ONE `tool`
11041                    // part, matched by tool_call_id via the GLOBAL
11042                    // `all_results` map built above (not a contiguous scan)
11043                    // — a result may be many messages away when other
11044                    // assistant turns with their own pending calls
11045                    // intervene before it appears.
11046                    for (tool_index, tc) in msg.tool_calls().iter().enumerate() {
11047                        let input = tc
11048                            .function
11049                            .parsed_arguments()
11050                            .unwrap_or_else(|_| Value::Object(Default::default()));
11051                        let paired_result = paired_results.get(&(i, tool_index)).copied();
11052                        let state = match paired_result {
11053                            Some((_, result)) if crate::is_tool_error(result) => {
11054                                let result_timestamp =
11055                                    opencode_message_timestamp(result, timestamp_cursor)?;
11056                                serde_json::json!({
11057                                    "status": "error",
11058                                    "input": input,
11059                                    "error": result.content.clone().unwrap_or_default(),
11060                                    "time": {"end": result_timestamp},
11061                                })
11062                            }
11063                            Some((_, result)) => {
11064                                let result_timestamp =
11065                                    opencode_message_timestamp(result, timestamp_cursor)?;
11066                                let mut s = serde_json::json!({
11067                                    "status": "completed",
11068                                    "input": input,
11069                                    "output": result.content.clone().unwrap_or_default(),
11070                                    "title": tc.function.name,
11071                                    "time": {"end": result_timestamp},
11072                                });
11073                                // PARITY-11 (nested images): the LOADER already
11074                                // reads a completed tool part's
11075                                // `state.attachments` back into `content_parts`
11076                                // (`opencode_file_image_part`, above) — this is
11077                                // the missing WRITE-side inverse. Without it, a
11078                                // Claude `tool_result`'s nested image (now
11079                                // captured into `content_parts` by
11080                                // `extract_tool_result_content`) reached
11081                                // `content_parts` on the canonical `ChatMessage`
11082                                // but was silently dropped again on re-export to
11083                                // OpenCode, because nothing ever read it back
11084                                // out. `mime`/`url` shape matches exactly what
11085                                // `opencode_file_image_part` expects on reload.
11086                                if let Some(cps) = &result.content_parts {
11087                                    let atts: Vec<Value> = cps
11088                                        .iter()
11089                                        .filter(|p| {
11090                                            p.get("type").and_then(Value::as_str)
11091                                                == Some("image_url")
11092                                        })
11093                                        .filter_map(|p| {
11094                                            let url = p
11095                                                .get("image_url")
11096                                                .and_then(|u| u.get("url"))
11097                                                .and_then(Value::as_str)?;
11098                                            let mime = url
11099                                                .strip_prefix("data:")
11100                                                .and_then(|r| r.split_once(','))
11101                                                .map(|(m, _)| m.trim_end_matches(";base64"))
11102                                                .unwrap_or("application/octet-stream");
11103                                            Some(serde_json::json!({
11104                                                "mime": mime,
11105                                                "url": url,
11106                                            }))
11107                                        })
11108                                        .collect();
11109                                    if !atts.is_empty() {
11110                                        s["attachments"] = Value::Array(atts);
11111                                    }
11112                                }
11113                                s
11114                            }
11115                            None => serde_json::json!({"status": "pending", "input": input}),
11116                        };
11117                        let mut part = serde_json::json!({
11118                            "id": opencode_fresh_id("prt", counter),
11119                            "sessionID": session_id,
11120                            "messageID": msg_id,
11121                            "type": "tool",
11122                            "callID": tc.id,
11123                            "tool": tc.function.name,
11124                            "state": state,
11125                        });
11126                        if let Some((result_position, _)) = paired_result {
11127                            part[OPENCODE_SUPERCODE_RESULT_POSITION] =
11128                                serde_json::json!(result_position);
11129                        }
11130                        if paired_result.is_some_and(|(_, result)| {
11131                            crate::tool_outcome(result) == crate::ToolOutcome::Unknown
11132                        }) {
11133                            part[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
11134                        }
11135                        if let Some((_, result)) = paired_result {
11136                            set_grok_message_extension(&mut part, self.meta.source, result);
11137                        }
11138                        parts.push(part);
11139                    }
11140                    let mut info = serde_json::json!({
11141                        "id": msg_id,
11142                        "sessionID": session_id,
11143                        "role": "assistant",
11144                        "time": {"created": timestamp},
11145                    });
11146                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
11147                    opencode_restore_agent_model_fields(
11148                        &mut info, msg, /* is_assistant */ true,
11149                    );
11150                    set_grok_message_extension(&mut info, self.meta.source, msg);
11151                    out.push(serde_json::json!({
11152                        "info": info,
11153                        "parts": parts,
11154                    }));
11155                    i += 1;
11156                }
11157                // A Tool message is always folded into its call's assistant
11158                // `tool` part above (via occurrence-aware global pairing, not
11159                // positional adjacency), so it never needs its own entry
11160                // here — just advance past it.
11161                Role::Tool => i += 1,
11162            }
11163        }
11164        Ok(())
11165    }
11166
11167    /// Synthesize a fresh OpenCode export DOCUMENT from the canonical
11168    /// `messages` (T3 cross-format/full synthesis tier — mirrors
11169    /// [`Self::to_pi_jsonl`]'s doc comment: this is NOT the value-lossless
11170    /// native path, which goes through `raw` + `to_native_jsonl(_v2)`
11171    /// instead). Shape: `{info: SessionInfo, messages: [{info, parts}, …]}`
11172    /// (§1.2 — the `opencode export`/`import` interchange shape).
11173    fn to_opencode_jsonl(&self) -> Result<String> {
11174        let mut info = self.synthesized_opencode_info();
11175        let ses_id = info
11176            .get("id")
11177            .and_then(Value::as_str)
11178            .unwrap_or("ses_new")
11179            .to_string();
11180        let mut messages_json: Vec<Value> = Vec::new();
11181        let mut counter: u64 = 0;
11182        let mut timestamp_cursor =
11183            opencode_max_timestamp(&info).unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11184        self.append_synthesized_opencode_messages(
11185            &mut messages_json,
11186            &self.messages,
11187            &ses_id,
11188            &mut counter,
11189            &mut timestamp_cursor,
11190        )?;
11191        if !messages_json.is_empty() {
11192            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11193        }
11194        let doc = serde_json::json!({"info": info, "messages": messages_json});
11195        Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
11196    }
11197
11198    /// A12-style splice for OpenCode (§1.2/§4.2 point 1, S5): replay the
11199    /// imported records **value-equal at their position** in the export
11200    /// doc's `messages[]`/`parts[]` — reconstructed directly from `self.raw`
11201    /// via [`Self::opencode_records_from_raw`], never re-derived from the
11202    /// lossy canonical `messages` — then append freshly synthesized
11203    /// `{info, parts}` objects for the tail via
11204    /// [`Self::append_synthesized_opencode_messages`]. Unlike the
11205    /// line-oriented formats' splice, `out` here is a single export
11206    /// DOCUMENT, not a line stream (§4.2 restates the "prefix verbatim"
11207    /// assertion accordingly: value-equality at position, not byte
11208    /// equality of a line range).
11209    fn to_opencode_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
11210        if self.raw.is_empty() {
11211            return self.to_opencode_jsonl();
11212        }
11213        let (session_info, records) = self.opencode_records_from_raw();
11214        let (_, message_prefix_len) = self.spliced_prefix_lens();
11215
11216        let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
11217        if let Some(id) = session_id {
11218            info["id"] = Value::String(id.to_string());
11219        }
11220        let ses_id_for_new = info
11221            .get("id")
11222            .and_then(Value::as_str)
11223            .unwrap_or("ses_new")
11224            .to_string();
11225
11226        let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
11227            .chain(records.iter().flat_map(|(msg, parts)| {
11228                std::iter::once(opencode_max_timestamp(msg))
11229                    .chain(parts.iter().map(opencode_max_timestamp))
11230            }))
11231            .flatten()
11232            .max()
11233            .unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11234
11235        let mut messages_json: Vec<Value> = records
11236            .into_iter()
11237            .map(|(msg, parts)| serde_json::json!({"info": msg, "parts": parts}))
11238            .collect();
11239        let imported_len = messages_json.len();
11240
11241        let mut counter: u64 = 0;
11242        self.append_synthesized_opencode_messages(
11243            &mut messages_json,
11244            &self.messages[message_prefix_len..],
11245            &ses_id_for_new,
11246            &mut counter,
11247            &mut timestamp_cursor,
11248        )?;
11249        if messages_json.len() > imported_len {
11250            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11251        }
11252
11253        let doc = serde_json::json!({"info": info, "messages": messages_json});
11254        Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
11255    }
11256
11257    /// The **required** direct-write fallback (S5): write the imported
11258    /// OpenCode records **verbatim** — excess/unknown keys, part-row
11259    /// timestamps, and `session_diff`/`todo` side-records intact — to a
11260    /// generation-B JSON-file storage tree
11261    /// (`docs/interop/opencode-pi-spec.md` §1.2), the fidelity path
11262    /// `opencode import` cannot provide (S5: import re-decodes through a
11263    /// strict schema and STRIPS excess keys; inserts part rows without
11264    /// `time_created`/`time_updated`, so those reset to `Date.now()`; and
11265    /// has no ingestion path for `session_diff`/`todo` at all).
11266    ///
11267    /// Writes the JSON-FILE layout rather than a live SQLite write
11268    /// specifically to avoid a new `rusqlite`-class dependency on this
11269    /// build's memory-constrained box (see the build report); `session_diff`
11270    /// itself is still JSON-written by upstream even on SQLite installs
11271    /// (§1.3), so this is a real fidelity path, not a fictional one.
11272    ///
11273    /// Returns the `storage/session/<projectID>/` directory written to.
11274    pub fn to_opencode_direct_write(&self, data_root: &Path) -> Result<PathBuf> {
11275        let (session_info, mut records) = self.opencode_records_from_raw();
11276        let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
11277        let ses_id = info
11278            .get("id")
11279            .and_then(Value::as_str)
11280            .unwrap_or("ses_new")
11281            .to_string();
11282        if info.get("id").is_none() {
11283            info["id"] = Value::String(ses_id.clone());
11284        }
11285        let project_id = info
11286            .get("projectID")
11287            .and_then(Value::as_str)
11288            .unwrap_or("global")
11289            .to_string();
11290
11291        // Appended tail (messages produced after import): synthesize fresh
11292        // message/part VALUES via the same T3 synthesis the splice writer
11293        // uses, so continuation turns get files too. Do this BEFORE creating
11294        // any directories: timestamp exhaustion must fail atomically rather
11295        // than leave a partial direct-write tree behind.
11296        let (_, message_prefix_len) = self.spliced_prefix_lens();
11297        let mut counter: u64 = 0;
11298        let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
11299            .chain(records.iter().flat_map(|(msg, parts)| {
11300                std::iter::once(opencode_max_timestamp(msg))
11301                    .chain(parts.iter().map(opencode_max_timestamp))
11302            }))
11303            .flatten()
11304            .max()
11305            .unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11306        let mut appended_json: Vec<Value> = Vec::new();
11307        self.append_synthesized_opencode_messages(
11308            &mut appended_json,
11309            &self.messages[message_prefix_len..],
11310            &ses_id,
11311            &mut counter,
11312            &mut timestamp_cursor,
11313        )?;
11314        if !appended_json.is_empty() {
11315            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11316        }
11317        for entry in appended_json {
11318            let msg = entry.get("info").cloned().unwrap_or(Value::Null);
11319            let parts = entry
11320                .get("parts")
11321                .and_then(Value::as_array)
11322                .cloned()
11323                .unwrap_or_default();
11324            records.push((msg, parts));
11325        }
11326
11327        let storage = data_root.join("storage");
11328        let session_dir = storage.join("session").join(&project_id);
11329        std::fs::create_dir_all(&session_dir)?;
11330        std::fs::write(
11331            session_dir.join(format!("{ses_id}.json")),
11332            serde_json::to_string_pretty(&info).unwrap_or_default(),
11333        )?;
11334
11335        let message_dir = storage.join("message").join(&ses_id);
11336        let part_dir = storage.join("part");
11337        std::fs::create_dir_all(&message_dir)?;
11338
11339        for (msg, parts) in &records {
11340            let Some(msg_id) = msg.get("id").and_then(Value::as_str) else {
11341                continue;
11342            };
11343            std::fs::write(
11344                message_dir.join(format!("{msg_id}.json")),
11345                serde_json::to_string_pretty(msg).unwrap_or_default(),
11346            )?;
11347            let this_part_dir = part_dir.join(msg_id);
11348            std::fs::create_dir_all(&this_part_dir)?;
11349            for part in parts {
11350                let Some(part_id) = part.get("id").and_then(Value::as_str) else {
11351                    continue;
11352                };
11353                std::fs::write(
11354                    this_part_dir.join(format!("{part_id}.json")),
11355                    serde_json::to_string_pretty(part).unwrap_or_default(),
11356                )?;
11357            }
11358        }
11359
11360        // Side-records (S5c): session_diff / todo have NO ingestion path via
11361        // `opencode import` at all — the direct write is their only
11362        // fidelity path.
11363        for header in &self.meta.opencode_headers {
11364            let Some(key) = header.get("key").and_then(Value::as_array) else {
11365                continue;
11366            };
11367            let Some(kind) = key.first().and_then(Value::as_str) else {
11368                continue;
11369            };
11370            let value = header.get("value").cloned().unwrap_or(Value::Null);
11371            if !matches!(kind, "session_diff" | "todo") {
11372                continue;
11373            }
11374            let dir = storage.join(kind);
11375            std::fs::create_dir_all(&dir)?;
11376            std::fs::write(
11377                dir.join(format!("{ses_id}.json")),
11378                serde_json::to_string_pretty(&value).unwrap_or_default(),
11379            )?;
11380        }
11381
11382        Ok(session_dir)
11383    }
11384}
11385
11386fn opencode_fresh_id(prefix: &str, counter: &mut u64) -> String {
11387    *counter += 1;
11388    format!("{prefix}_synth{counter:06}")
11389}
11390
11391/// WAVE-2 fidelity item 2: restore the `agent`/`cost`/`finish`/`is_summary`/
11392/// `model`/`tokens` fields onto a synthesized OpenCode `info` record, in the
11393/// EXACT native shape opencode's own loaders (`push_opencode_user` /
11394/// `push_opencode_assistant`, above) parse back out — so an opencode->opencode
11395/// export-doc round-trip (write here, reload via [`Session::from_opencode_str`])
11396/// reconstructs value-identical `ChatMessage.metadata`. Each field is emitted
11397/// ONLY when its metadata key is present (a synthesized continuation turn, or
11398/// a User message that never carried `agent`, stays clean — no spurious
11399/// null/empty fields).
11400///
11401/// - `agent`: plain string on BOTH User (`v1/session.ts:346`) and Assistant
11402///   (`:465`) — `metadata["agent"]` is inserted verbatim by both loaders from
11403///   `msg_value.get("agent")`, so it's re-emitted verbatim here too.
11404/// - `model`: the loaders capture it in TWO DIFFERENT shapes depending on
11405///   role (`docs/interop/research/opencode-fields.md` §3 2a/2b), so the
11406///   inverse must match per-role:
11407///   - User: `push_opencode_user` stores `metadata["model"]` as the
11408///     STRINGIFIED `{providerID, modelID, variant?}` object
11409///     (`msg_value.get("model")...to_string()`) — re-parsed and re-emitted
11410///     as that same object under `"model"`.
11411///   - Assistant: `push_opencode_assistant` stores `metadata["model"]` as
11412///     `"{providerID}/{modelID}"` (from the top-level `providerID`/`modelID`
11413///     fields, joined) — split back on the FIRST `/` (matching `format!`'s
11414///     join; a `modelID` containing further `/`s round-trips correctly since
11415///     `split_once` only consumes the first) and re-emitted as the two
11416///     top-level `providerID`/`modelID` fields the loader actually reads.
11417/// - `is_summary`/`cost`/`finish`/`tokens`: Assistant-only concepts (no such
11418///   fields exist on opencode's `User` schema) — `is_summary` re-expands
11419///   `"true"` back to the native `summary: true` bool (the loader only ever
11420///   sets the metadata key on `Some(true)`, never on absent/false, so the
11421///   inverse never needs to emit `false`); `finish` is a plain string;
11422///   `cost`/`tokens` were captured via `v.to_string()` on the raw JSON
11423///   `Value` (a number and an object respectively), so they're re-parsed
11424///   from that stringified form and re-emitted as the native JSON value —
11425///   NOT as strings — matching `msg_value.get(field)` shape exactly.
11426fn opencode_restore_agent_model_fields(info: &mut Value, msg: &ChatMessage, is_assistant: bool) {
11427    if let Some(agent) = msg.metadata.get("agent") {
11428        info["agent"] = Value::String(agent.clone());
11429    }
11430    if let Some(model) = msg.metadata.get("model") {
11431        if is_assistant {
11432            if let Some((provider, model_id)) = model.split_once('/') {
11433                info["providerID"] = Value::String(provider.to_string());
11434                info["modelID"] = Value::String(model_id.to_string());
11435            }
11436        } else if let Ok(v) = serde_json::from_str::<Value>(model) {
11437            info["model"] = v;
11438        }
11439    }
11440    if !is_assistant {
11441        return;
11442    }
11443    if msg.metadata.get("is_summary").map(String::as_str) == Some("true") {
11444        info["summary"] = Value::Bool(true);
11445    }
11446    if let Some(finish) = msg.metadata.get("finish") {
11447        info["finish"] = Value::String(finish.clone());
11448    }
11449    if let Some(cost) = msg.metadata.get("cost") {
11450        if let Ok(v) = serde_json::from_str::<Value>(cost) {
11451            info["cost"] = v;
11452        }
11453    }
11454    if let Some(tokens) = msg.metadata.get("tokens") {
11455        if let Ok(v) = serde_json::from_str::<Value>(tokens) {
11456            info["tokens"] = v;
11457        }
11458    }
11459}
11460
11461fn opencode_user_parts_from_message(
11462    msg: &ChatMessage,
11463    msg_id: &str,
11464    session_id: &str,
11465    counter: &mut u64,
11466) -> Vec<Value> {
11467    let mut parts = Vec::new();
11468    if let Some(cps) = &msg.content_parts {
11469        for p in cps {
11470            match p.get("type").and_then(Value::as_str) {
11471                Some("text") => {
11472                    if let Some(t) = p.get("text").and_then(Value::as_str) {
11473                        parts.push(serde_json::json!({
11474                            "id": opencode_fresh_id("prt", counter),
11475                            "sessionID": session_id,
11476                            "messageID": msg_id,
11477                            "type": "text",
11478                            "text": t,
11479                        }));
11480                    }
11481                }
11482                Some("image_url") => {
11483                    if let Some(url) = p
11484                        .get("image_url")
11485                        .and_then(|u| u.get("url"))
11486                        .and_then(Value::as_str)
11487                    {
11488                        let mime = url
11489                            .strip_prefix("data:")
11490                            .and_then(|r| r.split_once(','))
11491                            .map(|(m, _)| m.trim_end_matches(";base64"))
11492                            .unwrap_or("application/octet-stream");
11493                        parts.push(serde_json::json!({
11494                            "id": opencode_fresh_id("prt", counter),
11495                            "sessionID": session_id,
11496                            "messageID": msg_id,
11497                            "type": "file",
11498                            "mime": mime,
11499                            "url": url,
11500                        }));
11501                    }
11502                }
11503                _ => {}
11504            }
11505        }
11506    } else if let Some(t) = &msg.content {
11507        if !t.is_empty() {
11508            parts.push(serde_json::json!({
11509                "id": opencode_fresh_id("prt", counter),
11510                "sessionID": session_id,
11511                "messageID": msg_id,
11512                "type": "text",
11513                "text": t,
11514            }));
11515        }
11516    }
11517    parts
11518}
11519
11520fn codex_response_item(payload: Value, ts: &str) -> Value {
11521    serde_json::json!({"timestamp": ts, "type": "response_item", "payload": payload})
11522}
11523
11524/// Stamp `payload.metadata.turn_id` when `turn_id` is `Some` (PARITY-6/7,
11525/// see [`Session::write_codex_records`]); a no-op returning `payload`
11526/// untouched when `None`, so the historical byte shape is preserved for
11527/// every record that has no merge ambiguity to disambiguate.
11528fn with_turn_id(mut payload: Value, turn_id: Option<&str>) -> Value {
11529    if let Some(tid) = turn_id {
11530        payload["metadata"] = serde_json::json!({"turn_id": tid});
11531    }
11532    payload
11533}
11534
11535/// Build a Codex `message` response_item's `content` block array from a
11536/// `ChatMessage` — the inverse of [`codex_extract_images`]/`extract_text_content`'s
11537/// parse. When `content_parts` is `None` this MUST reproduce the historical
11538/// single-block shape exactly (IX-5's overriding constraint: a text-only
11539/// message's export stays byte-identical) — only a multimodal message gets
11540/// one `{text_type}` block per non-empty text part plus one native Codex
11541/// `input_image` block (`{"type":"input_image","image_url":<data:URI or
11542/// URL>}` — the Responses-API-shaped image content Codex's own `input_text`/
11543/// `output_text` blocks already follow the family of) per `image_url` part.
11544fn codex_message_content_blocks(text_type: &str, msg: &ChatMessage) -> Value {
11545    match &msg.content_parts {
11546        Some(parts) => {
11547            let mut blocks = Vec::new();
11548            for p in parts {
11549                match p.get("type").and_then(Value::as_str) {
11550                    Some("text") => {
11551                        if let Some(t) = p.get("text").and_then(Value::as_str) {
11552                            if !t.is_empty() {
11553                                blocks.push(serde_json::json!({"type": text_type, "text": t}));
11554                            }
11555                        }
11556                    }
11557                    Some("image_url") => {
11558                        if let Some(url) = p
11559                            .get("image_url")
11560                            .and_then(|u| u.get("url"))
11561                            .and_then(Value::as_str)
11562                        {
11563                            blocks.push(serde_json::json!({
11564                                "type": "input_image",
11565                                "image_url": url,
11566                            }));
11567                        }
11568                    }
11569                    _ => {}
11570                }
11571            }
11572            Value::Array(blocks)
11573        }
11574        None => {
11575            let text = msg.content.clone().unwrap_or_default();
11576            Value::Array(vec![serde_json::json!({"type": text_type, "text": text})])
11577        }
11578    }
11579}
11580
11581/// PARITY-11 (nested images, honest-residue side): a Codex
11582/// `function_call_output` response_item's `output` field is a BARE STRING
11583/// (real `codex-rs` protocol shape — unlike a `message` response_item, it has
11584/// no structured content array, so [`codex_message_content_blocks`]'s
11585/// `input_image` slot genuinely does not apply here). A nested image captured
11586/// off a Claude `tool_result` (`extract_tool_result_content`,
11587/// `content_parts`) therefore CANNOT be carried through this hop — but rather
11588/// than silently re-emitting the old bare `[image]` marker (indistinguishable
11589/// from a real, intentional annotation and impossible to tell apart from
11590/// "the data survived") or dropping it with zero trace, fold in an honest,
11591/// countable disclosure of exactly how many images were dropped and why —
11592/// same bracketed-note convention as [`UNCONVERTIBLE_IMAGE_MARKER`], applied
11593/// on the WRITE side instead of the read side. `content_parts` being `None`
11594/// (every pre-existing call site, and any tool result with no nested image)
11595/// reproduces the historical `msg.content` text byte-for-byte.
11596fn codex_tool_output_text(msg: &ChatMessage) -> String {
11597    let mut text = msg.content.clone().unwrap_or_default();
11598    if let Some(parts) = &msg.content_parts {
11599        let n = parts
11600            .iter()
11601            .filter(|p| p.get("type").and_then(Value::as_str) == Some("image_url"))
11602            .count();
11603        if n > 0 {
11604            if !text.is_empty() {
11605                text.push('\n');
11606            }
11607            text.push_str(&format!(
11608                "[image: {n} nested image(s) dropped — codex tool output has no \
11609                 structured content slot to carry them]"
11610            ));
11611        }
11612    }
11613    text
11614}
11615
11616// ---- Pi writer helpers -----------------------------------------------------
11617
11618/// Emit the pi v3 `session` header line. Frozen (§1.1/§1.3): `version` is
11619/// ALWAYS 3, never a lower value, so pi never rewrites a supercode-emitted
11620/// file in place on first resume (`pi-fields.md` sm:848-850).
11621fn push_pi_header(
11622    out: &mut String,
11623    id: &str,
11624    cwd: &str,
11625    parent_session: Option<&str>,
11626    created_at: Option<&str>,
11627    claude_fork_context_ref: Option<&str>,
11628) {
11629    let mut header = serde_json::json!({
11630        "type": "session",
11631        "version": 3,
11632        "id": id,
11633        "timestamp": created_at.unwrap_or(SYNTH_TS),
11634        "cwd": cwd,
11635    });
11636    if let Some(ps) = parent_session {
11637        header["parentSession"] = Value::String(ps.to_string());
11638    }
11639    // D7: namespaced passthrough field, exactly like the Codex writer's
11640    // `session_meta.payload.claude_fork_context_ref` — pi tolerates unknown
11641    // header keys, and `capture_pi_header` reads this same key back on
11642    // import, so a Claude -> Pi -> Claude round trip still reconstructs the
11643    // fork-context-ref record instead of silently losing it on this hop.
11644    if let Some(raw) = claude_fork_context_ref {
11645        header["claude_fork_context_ref"] =
11646            serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()));
11647    }
11648    push_jsonl(out, &header);
11649}
11650
11651/// A fresh 8-hex entry id, collision-checked against `used` (mirrors pi's own
11652/// `randomUUID().slice(0,8)` + collision check, `pi-fields.md` sm:216-224 —
11653/// deterministic here rather than random, which still satisfies "fresh,
11654/// collision-free" without an extra RNG dependency).
11655fn pi_fresh_id(used: &mut HashSet<String>, counter: &mut u64) -> String {
11656    loop {
11657        *counter += 1;
11658        let h = counter.wrapping_mul(0x9E3779B97F4A7C15);
11659        let id = format!("{:08x}", (h >> 32) as u32);
11660        if used.insert(id.clone()) {
11661            return id;
11662        }
11663    }
11664}
11665
11666/// Parse a `data:<mime>[;base64],<data>` URI back into `(mime, data)` — the
11667/// inverse of the loader's `data:{mime};base64,{data}` construction.
11668fn parse_data_uri(url: &str) -> Option<(String, String)> {
11669    let rest = url.strip_prefix("data:")?;
11670    let (meta, data) = rest.split_once(',')?;
11671    let mime = meta.strip_suffix(";base64").unwrap_or(meta);
11672    Some((mime.to_string(), data.to_string()))
11673}
11674
11675/// Rebuild a pi `(TextContent|ImageContent)[]` (or bare string) content value
11676/// from a `ChatMessage`'s `content`/`content_parts` — shared by `user` and
11677/// `toolResult` entries (both use the identical union on the wire).
11678fn pi_content_value(msg: &ChatMessage) -> Value {
11679    if let Some(parts) = &msg.content_parts {
11680        let mut arr = Vec::new();
11681        for p in parts {
11682            match p.get("type").and_then(Value::as_str) {
11683                Some("text") => {
11684                    if let Some(t) = p.get("text").and_then(Value::as_str) {
11685                        arr.push(serde_json::json!({"type": "text", "text": t}));
11686                    }
11687                }
11688                Some("image_url") => {
11689                    if let Some(url) = p
11690                        .get("image_url")
11691                        .and_then(|u| u.get("url"))
11692                        .and_then(Value::as_str)
11693                    {
11694                        if let Some((mime, data)) = parse_data_uri(url) {
11695                            arr.push(
11696                                serde_json::json!({"type": "image", "mimeType": mime, "data": data}),
11697                            );
11698                        }
11699                    }
11700                }
11701                _ => {}
11702            }
11703        }
11704        Value::Array(arr)
11705    } else {
11706        Value::String(msg.content.clone().unwrap_or_default())
11707    }
11708}
11709
11710fn pi_assistant_content_value(msg: &ChatMessage) -> Value {
11711    let mut arr = Vec::new();
11712    if let Some(thinking) = msg.metadata.get("thinking") {
11713        let mut block = serde_json::json!({"type": "thinking", "thinking": thinking});
11714        if let Some(sig) = msg.metadata.get("thinking_signature") {
11715            block["thinkingSignature"] = Value::String(sig.clone());
11716        }
11717        if msg.metadata.get("pi_thinking_redacted").map(String::as_str) == Some("true") {
11718            block["redacted"] = Value::Bool(true);
11719        }
11720        arr.push(block);
11721    }
11722    if let Some(text) = &msg.content {
11723        if !text.is_empty() {
11724            let mut block = serde_json::json!({"type": "text", "text": text});
11725            if let Some(sig) = msg.metadata.get("pi_text_signature") {
11726                block["textSignature"] = Value::String(sig.clone());
11727            }
11728            arr.push(block);
11729        }
11730    }
11731    for tc in msg.tool_calls() {
11732        let args = tc
11733            .function
11734            .parsed_arguments()
11735            .unwrap_or_else(|_| Value::Object(Default::default()));
11736        let mut block = serde_json::json!({
11737            "type": "toolCall",
11738            "id": tc.id,
11739            "name": tc.function.name,
11740            "arguments": args,
11741        });
11742        if let Some(sig) = msg.metadata.get("pi_thought_signature") {
11743            block["thoughtSignature"] = Value::String(sig.clone());
11744        }
11745        arr.push(block);
11746    }
11747    Value::Array(arr)
11748}
11749
11750fn default_pi_usage() -> Value {
11751    serde_json::json!({
11752        "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0,
11753        "cost": {"input": 0.0, "output": 0.0, "cacheRead": 0.0, "cacheWrite": 0.0, "total": 0.0},
11754    })
11755}
11756
11757fn is_tool_error_flag(msg: &ChatMessage) -> bool {
11758    msg.metadata.get("pi_is_error").map(String::as_str) == Some("true") || crate::is_tool_error(msg)
11759}
11760
11761#[cfg(test)]
11762mod tests {
11763    use super::{
11764        opencode_message_timestamp, parent_tool_use_index, truncate_messages_with_anchor, Session,
11765        SessionFormat,
11766    };
11767    use crate::message::ChatMessage;
11768    use crate::{Fidelity, Role};
11769
11770    #[test]
11771    fn a_new_user_message_slides_the_display_tail_without_becoming_its_only_anchor() {
11772        let mut messages = vec![
11773            ChatMessage::user("original prompt"),
11774            ChatMessage::assistant("one"),
11775            ChatMessage::assistant("two"),
11776            ChatMessage::assistant("three"),
11777            ChatMessage::assistant("four"),
11778            ChatMessage::assistant("five"),
11779            ChatMessage::user("new prompt"),
11780        ];
11781
11782        truncate_messages_with_anchor(&mut messages, 4, Vec::new());
11783
11784        assert_eq!(messages.len(), 4);
11785        assert_eq!(messages[0].content.as_deref(), Some("original prompt"));
11786        assert_eq!(messages[3].content.as_deref(), Some("new prompt"));
11787    }
11788
11789    #[test]
11790    fn a_tool_heavy_current_turn_keeps_the_previous_and_current_user_anchors() {
11791        let mut messages = vec![
11792            ChatMessage::user("previous prompt"),
11793            ChatMessage::assistant("previous answer"),
11794            ChatMessage::user("current prompt"),
11795            ChatMessage::assistant("tool one"),
11796            ChatMessage::assistant("tool two"),
11797            ChatMessage::assistant("tool three"),
11798            ChatMessage::assistant("tool four"),
11799            ChatMessage::assistant("tool five"),
11800        ];
11801
11802        truncate_messages_with_anchor(&mut messages, 4, Vec::new());
11803
11804        assert_eq!(messages.len(), 4);
11805        assert_eq!(messages[0].content.as_deref(), Some("previous prompt"));
11806        assert_eq!(messages[1].content.as_deref(), Some("current prompt"));
11807        assert_eq!(messages[3].content.as_deref(), Some("tool five"));
11808    }
11809
11810    #[test]
11811    fn a_preceding_user_anchor_is_restored_when_dedup_shrinks_below_the_limit() {
11812        let mut messages = vec![
11813            ChatMessage::user("current prompt"),
11814            ChatMessage::assistant("tool one"),
11815            ChatMessage::assistant("tool two"),
11816        ];
11817
11818        truncate_messages_with_anchor(&mut messages, 4, vec![ChatMessage::user("previous prompt")]);
11819
11820        assert_eq!(messages.len(), 4);
11821        assert_eq!(messages[0].content.as_deref(), Some("previous prompt"));
11822        assert_eq!(messages[1].content.as_deref(), Some("current prompt"));
11823    }
11824
11825    #[test]
11826    fn a_tool_heavy_turn_restores_two_users_that_both_left_the_retention_buffer() {
11827        let mut messages = vec![
11828            ChatMessage::assistant("tool one"),
11829            ChatMessage::assistant("tool two"),
11830            ChatMessage::assistant("tool three"),
11831            ChatMessage::assistant("tool four"),
11832        ];
11833
11834        truncate_messages_with_anchor(
11835            &mut messages,
11836            4,
11837            vec![
11838                ChatMessage::user("previous prompt"),
11839                ChatMessage::user("current prompt"),
11840            ],
11841        );
11842
11843        assert_eq!(messages.len(), 4);
11844        assert_eq!(messages[0].content.as_deref(), Some("previous prompt"));
11845        assert_eq!(messages[1].content.as_deref(), Some("current prompt"));
11846        assert_eq!(messages[3].content.as_deref(), Some("tool four"));
11847    }
11848
11849    #[test]
11850    fn bounded_codex_file_expands_past_tool_noise_and_loads_real_earlier_history() {
11851        let nonce = std::time::SystemTime::now()
11852            .duration_since(std::time::UNIX_EPOCH)
11853            .unwrap()
11854            .as_nanos();
11855        let path = std::env::temp_dir().join(format!(
11856            "supercode-display-history-{}-{nonce}.jsonl",
11857            std::process::id()
11858        ));
11859        let user = |text: &str| {
11860            format!(
11861                r#"{{"type":"response_item","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"{text}"}}]}}}}"#,
11862            )
11863        };
11864        let assistant = |index: usize| {
11865            format!(
11866                r#"{{"type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"answer-{index}"}}]}}}}"#,
11867            )
11868        };
11869        let mut lines = vec![
11870            r#"{"type":"session_meta","payload":{"id":"session-1"}}"#.to_string(),
11871            user("earlier prompt"),
11872            format!(
11873                r#"{{"type":"event_msg","payload":{{"type":"token_count","noise":"{}"}}}}"#,
11874                "x".repeat(5 * 1024 * 1024)
11875            ),
11876            user("latest prompt"),
11877        ];
11878        lines.extend((0..130).map(assistant));
11879        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
11880
11881        let initial = Session::load_display_view(&path, Fidelity::Semantic, 120).unwrap();
11882        let expanded = Session::load_display_view(&path, Fidelity::Semantic, 240).unwrap();
11883        std::fs::remove_file(&path).unwrap();
11884
11885        let initial_users = initial
11886            .messages
11887            .iter()
11888            .filter(|message| message.role == Role::User)
11889            .filter_map(|message| message.content.as_deref())
11890            .collect::<Vec<_>>();
11891        assert_eq!(initial.messages.len(), 120);
11892        assert_eq!(initial_users, ["earlier prompt", "latest prompt"]);
11893        assert!(
11894            initial.imported_message_count.unwrap() > initial.messages.len(),
11895            "a bounded initial page must truthfully report earlier history"
11896        );
11897        assert_eq!(expanded.messages.len(), 132);
11898        assert_eq!(expanded.imported_message_count, Some(132));
11899    }
11900
11901    #[test]
11902    fn bounded_codex_display_history_reports_the_unbounded_message_total() {
11903        let jsonl = (0..6)
11904            .map(|index| {
11905                let role = if index % 2 == 0 { "user" } else { "assistant" };
11906                format!(
11907                    r#"{{"type":"response_item","payload":{{"type":"message","role":"{role}","content":[{{"type":"input_text","text":"message-{index}"}}]}}}}"#,
11908                )
11909            })
11910            .collect::<Vec<_>>()
11911            .join("\n");
11912
11913        let session = Session::from_codex_display_str(&jsonl, 2).unwrap();
11914
11915        assert_eq!(session.messages.len(), 2);
11916        assert_eq!(session.imported_message_count, Some(6));
11917    }
11918
11919    #[test]
11920    fn malformed_discriminated_native_turn_is_counted_as_parse_loss() {
11921        let base = Session::from_native_messages(Vec::new());
11922        let mut native = base.to_native_jsonl_v2(&[]);
11923        native.push_str("{\"supercode_turn\":1}\n");
11924
11925        let parsed = Session::from_native_str(&native).unwrap();
11926        assert_eq!(parsed.parse_error_lines, 1);
11927        assert!(parsed.messages.is_empty());
11928        assert_eq!(
11929            parsed.raw.last().map(String::as_str),
11930            Some("{\"supercode_turn\":1}")
11931        );
11932    }
11933
11934    #[test]
11935    fn spliced_export_refuses_a_native_wrapper_with_parse_loss() {
11936        let imported = Session::from_claude_code_str(
11937            r#"{"type":"user","sessionId":"s","cwd":"/tmp","message":{"role":"user","content":"hi"}}"#,
11938        )
11939        .unwrap();
11940        let mut native = imported.to_native_jsonl_v2(&[ChatMessage::assistant("continued")]);
11941        native.push_str("{\"supercode_turn\":1}\n");
11942
11943        let parsed = Session::from_native_str(&native).unwrap();
11944        let error = parsed
11945            .to_jsonl_spliced(SessionFormat::ClaudeCode, None)
11946            .unwrap_err();
11947        assert!(error.to_string().contains("parse loss"), "{error}");
11948    }
11949
11950    #[test]
11951    fn sidecar_loader_requires_a_supported_native_header() {
11952        for malformed in [
11953            "",
11954            "not-json\n",
11955            "{}\n",
11956            "{\"supercode_native\":2}\n",
11957            "{\"supercode_native\":99,\"source\":\"native\"}\n",
11958        ] {
11959            let error = Session::from_sidecar_str(malformed).unwrap_err();
11960            assert!(error.to_string().contains("sidecar header"), "{error}");
11961        }
11962    }
11963
11964    #[test]
11965    fn gemini_user_parts_preserve_text_media_and_response_order() {
11966        let session = Session::from_gemini_str(
11967            r#"{"sessionId":"11111111-1111-4111-8111-111111111111","projectHash":"p"}
11968{"type":"gemini","content":"calling","toolCalls":[{"id":"a","name":"read","args":{}},{"id":"b","name":"read","args":{}}]}
11969{"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"}]}
11970"#,
11971        )
11972        .unwrap();
11973
11974        assert_eq!(session.messages.len(), 6);
11975        assert_eq!(
11976            session.messages[1].content_parts.as_ref().unwrap()[0]["text"],
11977            "before"
11978        );
11979        assert_eq!(session.messages[2].tool_call_id.as_deref(), Some("b"));
11980        assert!(
11981            session.messages[3].content_parts.as_ref().unwrap()[0]["image_url"]["url"]
11982                .as_str()
11983                .unwrap()
11984                .starts_with("data:image/png;base64,")
11985        );
11986        assert_eq!(session.messages[4].tool_call_id.as_deref(), Some("a"));
11987        assert_eq!(
11988            session.messages[5].content_parts.as_ref().unwrap()[0]["text"],
11989            "after"
11990        );
11991    }
11992
11993    #[test]
11994    fn opencode_synthetic_timestamp_fails_closed_at_i64_max() {
11995        let msg = ChatMessage::user("continuation");
11996        let mut cursor = i64::MAX - 1;
11997        assert_eq!(
11998            opencode_message_timestamp(&msg, &mut cursor).unwrap(),
11999            i64::MAX
12000        );
12001        let err = opencode_message_timestamp(&msg, &mut cursor).unwrap_err();
12002        assert!(err.to_string().contains("after i64::MAX"));
12003        assert_eq!(cursor, i64::MAX, "overflow must not wrap or mutate state");
12004    }
12005
12006    /// Pin of the single-pass indexer against the relevant Claude tool-result
12007    /// shape (SUP-21). An id absent from the transcript must map to nothing.
12008    #[test]
12009    fn parent_tool_use_index_matches_known_fixture_linkage() {
12010        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"}}"#;
12011
12012        let ids = vec![
12013            "ad8dc6cf98b49eea6".to_string(),
12014            "no-such-agent-id".to_string(),
12015        ];
12016        let index = parent_tool_use_index(main_text, &ids);
12017
12018        assert_eq!(
12019            index.get("ad8dc6cf98b49eea6").map(String::as_str),
12020            Some("toolu_01SYjhg9qRCzUWY2GTa3iazQ"),
12021            "known agent id must resolve to the pinned parent tool_use_id"
12022        );
12023        assert_eq!(
12024            index.get("no-such-agent-id"),
12025            None,
12026            "unknown agent id must yield no entry (best-effort None)"
12027        );
12028    }
12029
12030    #[test]
12031    fn parent_tool_use_index_empty_ids_returns_empty_map() {
12032        let index = parent_tool_use_index("irrelevant text", &[]);
12033        assert!(index.is_empty());
12034    }
12035}