supercode_interchange/session.rs
1//! Natively load — and continue — real Claude Code and Codex sessions.
2//!
3//! Both tools persist their conversations as JSONL on disk:
4//!
5//! - **Claude Code**: `~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`,
6//! one line per event in the Anthropic message format, linked by
7//! `uuid`/`parentUuid`.
8//! - **Codex**: `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`, where each line
9//! is a `{timestamp, type, payload}` envelope and the `response_item` lines
10//! form the canonical conversation.
11//!
12//! [`Session::load`] auto-detects the format and normalizes either one into a
13//! provider-neutral [`Vec<ChatMessage>`] that can be handed straight back to a
14//! model (via OpenRouter or any OpenAI-compatible endpoint) to continue.
15//!
16//! Provider-internal artifacts that don't replay across vendors — Anthropic
17//! `thinking` blocks, Codex `reasoning` items — are dropped during
18//! normalization.
19//!
20//! # Where this sits in supercode's priorities
21//!
22//! This module is the home of **feature 1 (translate between session formats)**
23//! and half of **feature 2 (emulate-to-continue)** — the load/emit surface for
24//! each harness ([`SessionFormat`], `from_*_str` loaders, `to_*_jsonl`
25//! emitters). See [`AGENTS.md`](../../../AGENTS.md) for the three ranked
26//! feature-priorities and the glue-tool positioning; the top priority is
27//! **feature 3 (continue losslessly *with massive token reduction*)**, which
28//! this fidelity work exists to make trustworthy. `opencode` + `pi` loaders
29//! are built against the frozen `docs/interop/opencode-pi-spec.md` contract
30//! — OpenCode additionally reads its native SQLite store (`opencode*.db`,
31//! PARITY-3/PARITY-16) via `rusqlite`, reconstructing the same envelope form
32//! [`Session::from_opencode_str`] already parses for the JSON-tree surfaces.
33
34use std::collections::{BTreeMap, HashMap, HashSet};
35use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
36use std::path::{Path, PathBuf};
37
38use rusqlite::Connection;
39use serde_json::Value;
40
41use crate::{
42 ChatMessage, Fidelity, FunctionCall, InterchangeError as Error, Result, Role, ToolCall,
43};
44
45/// Which tool produced a session log.
46///
47/// This is **read-provenance**: a fact recovered when a log is loaded (stored
48/// in [`SessionMeta::source`], filled in by auto-detection in
49/// `detect_source`), describing which tool originally wrote the file on
50/// disk. It answers "where did this session come from?" — e.g. for
51/// `inspect`/`convert` display in the CLI.
52///
53/// It is deliberately distinct from [`SessionFormat`], even though the two
54/// enums' variant lists currently coincide: [`SessionFormat`] selects a
55/// serialization codec (what to parse/export *as*), while `SessionSource`
56/// records history (what wrote the file). The pair is intentionally kept
57/// separate rather than merged — a session loaded from one tool's log can
58/// still be exported in the other tool's format, and the two concepts could
59/// diverge further (e.g. a format that is readable but not attributable, or
60/// multiple versioned formats sharing one source).
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum SessionSource {
63 /// A `~/.claude/projects/.../<id>.jsonl` transcript.
64 ClaudeCode,
65 /// A `~/.codex/sessions/.../rollout-*.jsonl` file.
66 Codex,
67 /// An OpenCode session — multi-file JSON tree(s) or SQLite `opencode*.db`
68 /// (`docs/interop/opencode-pi-spec.md` §1.2). Detection and loading are
69 /// wave B; this variant exists now so `SessionSource`/`SessionFormat` stay
70 /// 1:1 per the frozen interop spec (§0).
71 OpenCode,
72 /// A `~/.pi/agent/sessions/--<enc-cwd>--/<iso>_<sessionId>.jsonl`
73 /// transcript (`docs/interop/opencode-pi-spec.md` §1.1) — line-oriented
74 /// JSONL like Claude Code/Codex, so it shares their byte-lossless native
75 /// round-trip property.
76 Pi,
77 /// A Grok session transcript stored as
78 /// `~/.grok/sessions/<percent-encoded-cwd>/<session-id>/chat_history.jsonl`.
79 Grok,
80 /// A Gemini CLI transcript stored under
81 /// `~/.gemini/tmp/<project>/chats/session-*.jsonl`.
82 Gemini,
83 /// A Goose session exported through `_goose/unstable/session/export`, or
84 /// reconstructed from Goose's `sessions/sessions.db` native store.
85 Goose,
86 /// P5-3 safety-hardening fix (Fable-5 review, LOW "translation-fidelity
87 /// cosmetic"): a session that was never imported from ANY foreign
88 /// tool's log at all — authored directly by supercode's own agent loop,
89 /// with no foreign-tool prefix (`Session.raw` starts empty). Currently
90 /// only `crate::agent::Agent`'s `persist_subagent_transcript` (P5-3,
91 /// natively-spawned `spawn_subagent` children) uses this — before this
92 /// variant existed, that call site built its blank `Session` via
93 /// `Session::from_claude_code_str("")` purely as an "empty parser to
94 /// get a blank skeleton" trick, which left `meta.source ==
95 /// SessionSource::ClaudeCode` even though nothing Claude-Code-shaped
96 /// was ever involved, mislabeling a native supercode spawn as an
97 /// imported CC session on disk (and in any `inspect`/`convert` reading
98 /// it back). Never produced by auto-detection (`detect_source`) or any
99 /// `from_<tool>_str` loader — only by code that explicitly constructs
100 /// a `SessionMeta` with this source, so no existing imported-session
101 /// path can ever observe this variant appearing where it didn't before.
102 Native,
103}
104
105/// An on-disk session format supercode can both read and write.
106///
107/// Like an image editor that opens and exports several file formats, supercode
108/// keeps one canonical in-memory model ([`Session`]) and converts to/from each
109/// supported format on the edges.
110///
111/// This is a **write-target** / codec selector: a caller's request, passed to
112/// [`Session::load_str`], [`Session::to_jsonl`], and [`Session::save`],
113/// choosing which on-disk dialect to parse or emit. It answers "what format
114/// should I read/write?" — as opposed to [`SessionSource`], which records the
115/// provenance fact of what actually produced a loaded file. The two enums are
116/// intentionally kept separate (provenance fact vs. serialization choice) and
117/// should not be unified, even though their variants currently match
118/// one-to-one.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum SessionFormat {
121 /// Claude Code transcript JSONL.
122 ClaudeCode,
123 /// Codex rollout JSONL.
124 Codex,
125 /// OpenCode export-document / envelope JSONL (wave B; see
126 /// [`SessionSource::OpenCode`]).
127 OpenCode,
128 /// Pi session JSONL (see [`SessionSource::Pi`]).
129 Pi,
130 /// Grok `chat_history.jsonl` transcript.
131 Grok,
132 /// Gemini CLI session JSONL.
133 Gemini,
134 /// Goose native session-export JSON.
135 Goose,
136}
137
138impl SessionFormat {
139 /// The [`SessionSource`] a file of this format reports.
140 ///
141 /// This is the deliberate one-way bridge between the two concepts: a file
142 /// saved in this format will, when reloaded, report this provenance (see
143 /// `crates/harness/tests/session_saving.rs`), making the relationship
144 /// discoverable from the method itself.
145 pub fn source(self) -> SessionSource {
146 match self {
147 SessionFormat::ClaudeCode => SessionSource::ClaudeCode,
148 SessionFormat::Codex => SessionSource::Codex,
149 SessionFormat::OpenCode => SessionSource::OpenCode,
150 SessionFormat::Pi => SessionSource::Pi,
151 SessionFormat::Grok => SessionSource::Grok,
152 SessionFormat::Gemini => SessionSource::Gemini,
153 SessionFormat::Goose => SessionSource::Goose,
154 }
155 }
156}
157
158/// Metadata recovered from a session log.
159#[derive(Debug, Clone)]
160#[non_exhaustive]
161pub struct SessionMeta {
162 /// The tool that wrote the log.
163 pub source: SessionSource,
164 /// The session/rollout id.
165 pub session_id: Option<String>,
166 /// The model the session was running.
167 pub model: Option<String>,
168 /// The working directory the session ran in.
169 pub cwd: Option<PathBuf>,
170 /// The system / base-instructions prompt, when the log records it.
171 pub system_prompt: Option<String>,
172 /// Verbatim source-format header records (the Codex `session_meta` /
173 /// `turn_context` lines), preserved so re-export can replay the exact header
174 /// the original tool expects rather than guessing its required fields.
175 pub codex_headers: Vec<Value>,
176 /// Exact source lines for Codex execution/provenance records that affect
177 /// continuation semantics but must not be replayed as active events after
178 /// a foreign-format hop. Each entry records its original physical-line
179 /// index, discriminant, and verbatim JSONL text. Foreign writers carry the
180 /// list in a namespaced extension; a later Codex export restores headers
181 /// from it while keeping compaction/rollback/review records non-operative,
182 /// avoiding a second rollback or compaction of the already-normalized view.
183 pub codex_provenance: Vec<Value>,
184 /// The OpenCode analogue of [`Self::codex_headers`]
185 /// (`docs/interop/opencode-pi-spec.md` §1.2/§2.1): the verbatim
186 /// `SessionInfo` record (always element 0, or `Value::Null` if somehow
187 /// absent), plus any captured `session_diff`/`todo` side-records — each
188 /// wrapped as `{"key": [...], "value": ...}`, mirroring the envelope
189 /// shape `raw` uses, so a consumer can tell which storage key a header
190 /// record belongs to. These replay only via the direct-write fallback
191 /// (`Session::to_opencode_direct_write`); `opencode import` has no
192 /// ingestion path for `session_diff`/`todo` (S5).
193 pub opencode_headers: Vec<Value>,
194 /// Goose's native session-export object with `conversation` removed.
195 /// Goose stores sessions in SQLite but defines this JSON object as its
196 /// official import/export boundary. Keeping the shell lets an unchanged
197 /// direct round-trip remain byte exact while appended turns are spliced
198 /// into a stock-importable artifact without guessing native metadata.
199 pub goose_header: Option<Value>,
200 /// For a Claude Code subagent session: its `agentId` (the `agent-<id>` file
201 /// stem). `None` for top-level sessions.
202 pub agent_id: Option<String>,
203 /// For a subagent session: the `tool_use_id` of the parent `Task` call that
204 /// spawned it, recovered from the parent transcript's tool result. Best
205 /// effort — `None` if the link could not be established.
206 pub parent_tool_use_id: Option<String>,
207 /// Cross-file lineage keys for multi-file/multi-agent sessions (Codex
208 /// `parent_thread_id`, `forked_from_id`, `thread_source`, and the
209 /// `source.subagent.thread_spawn` fields `agent_role` / `agent_nickname` /
210 /// `depth`). Empty for a plain top-level session. Used by
211 /// [`Session::reconstruct_tree`] to nest children under their parents.
212 pub lineage: std::collections::BTreeMap<String, String>,
213}
214
215impl SessionMeta {
216 fn new(source: SessionSource) -> Self {
217 SessionMeta {
218 source,
219 session_id: None,
220 model: None,
221 cwd: None,
222 system_prompt: None,
223 codex_headers: Vec::new(),
224 codex_provenance: Vec::new(),
225 opencode_headers: Vec::new(),
226 goose_header: None,
227 agent_id: None,
228 parent_tool_use_id: None,
229 lineage: std::collections::BTreeMap::new(),
230 }
231 }
232}
233
234/// A normalized, replayable conversation loaded from a tool's session log.
235#[derive(Debug, Clone)]
236pub struct Session {
237 /// Recovered metadata.
238 pub meta: SessionMeta,
239 /// The conversation, normalized to the OpenAI chat-completions shape.
240 pub messages: Vec<ChatMessage>,
241 /// Subagent (Task) sub-conversations. Claude Code stores these as separate
242 /// `<session>/subagents/agent-*.jsonl` files; loading a session by path now
243 /// discovers and attaches them here (each is a full [`Session`] whose
244 /// `meta.agent_id` / `meta.parent_tool_use_id` link it back to its spawn).
245 pub subagents: Vec<Session>,
246 /// Every original JSONL line of the source log, STRICT-VERBATIM (IX-1):
247 /// captured via `split_lines_verbatim`, not the blank-skipping/trimming
248 /// `non_empty_lines` parse view, so a blank line, a CRLF (`\r\n`)
249 /// terminator, or trailing whitespace on a line all survive bit-for-bit
250 /// rather than being dropped/normalized away. Normalization into
251 /// `messages` is still lossy by design (it targets the OpenAI replay
252 /// shape), but these raw lines retain *everything* — including records
253 /// with no canonical representation (e.g. Claude `file-history-snapshot`)
254 /// — so a round-trip through the supercode-native format
255 /// ([`Session::to_native_jsonl`]) is byte-lossless for the line-oriented
256 /// formats (Claude Code/Codex/Pi), for ANY input (see
257 /// [`Self::raw_trailing_newline`] for the one piece of information a line
258 /// list alone can't carry).
259 pub raw: Vec<String>,
260 /// Whether the source text `raw` was captured from ended with a trailing
261 /// `\n`. `raw`'s line list alone can't distinguish a source ending with a
262 /// trailing newline from one that doesn't (both split into the same
263 /// lines) — this flag carries that fact out-of-band so
264 /// [`Self::to_native_jsonl`]/[`Self::from_native_str`] can reproduce the
265 /// original source bytes exactly, including the presence/absence of a
266 /// final newline. `true` for a `Session` whose `raw` isn't captured
267 /// verbatim from real source text (e.g. OpenCode's re-synthesized
268 /// export-document `raw`, or a `Session` assembled programmatically) —
269 /// matching the historical always-terminated-by-newline behavior for
270 /// those cases.
271 pub raw_trailing_newline: bool,
272 /// How many of `messages` (and, symmetrically, of `raw` — see below) came
273 /// from parsing the imported log, as opposed to being appended after
274 /// import. Set once, at the end of [`Self::from_claude_code_str`] /
275 /// [`Self::from_codex_str`], to `messages.len()` at that moment — i.e.
276 /// before [`Self::from_native_str`]'s subsequent loop reattaches any
277 /// appended [`crate::sidecar::NativeTurn`] records onto `messages`/`raw`.
278 /// That loop pushes exactly one `raw` line and one message per appended
279 /// turn, so the two lists grow in lockstep from here on: the raw-prefix
280 /// boundary A12's [`Self::to_jsonl_spliced`] needs is always recoverable
281 /// as `raw.len() - (messages.len() - imported_message_count)`, without a
282 /// second counter. `None` only when a `Session` is constructed some other
283 /// way than through those two loaders — splicing then has no boundary to
284 /// honor and treats every message as imported (equivalent to
285 /// `Some(messages.len())`).
286 pub imported_message_count: Option<usize>,
287 /// Whether `raw` was captured strict-verbatim from real source text
288 /// (`true`) or re-synthesized by this crate (`false`) — the fact
289 /// [`Self::raw_verbatim`]'s callers need to know before claiming a
290 /// same-format `convert` is byte-identical (PARITY-AUDIT.md P006/P007).
291 /// `true` for every line-oriented loader (`from_claude_code_str`,
292 /// `from_codex_str`, `from_pi_str`) and OpenCode's own ENVELOPE read
293 /// surface (`from_opencode_str`'s per-line loop) — each of those splits
294 /// `raw` directly out of the source text via `split_lines_verbatim`, so
295 /// replaying it reproduces the original bytes exactly. `false` for
296 /// OpenCode's EXPORT-DOCUMENT read surface
297 /// (`Session::from_opencode_export_doc`): a pretty-printed
298 /// `{info, messages:[...]}` document has no per-line envelope structure
299 /// of its own, so `raw` there is one envelope line RE-SYNTHESIZED per
300 /// record — faithful in value, but not the original document's bytes.
301 /// A `Session` assembled programmatically (not through a `from_*_str`
302 /// loader) also defaults to `false` — no real source text was captured
303 /// at all.
304 pub raw_is_verbatim: bool,
305 /// PARITY-15: how many non-empty lines of the source text FAILED to
306 /// deserialize at all (a genuinely malformed/truncated JSON line — not
307 /// a well-formed-but-unmodeled record type, which is a normal,
308 /// intentional "skip", tracked separately by `crate::audit`). Every
309 /// line-oriented loader tolerates a stray corrupt line rather than
310 /// hard-failing the whole load (a single bad line must not make an
311 /// otherwise-healthy multi-thousand-line session unloadable) — but that
312 /// tolerance used to be completely invisible: `Session::load` returned
313 /// `Ok` either way, with no signal that anything was skipped. This
314 /// count is what lets a caller (the CLI, `inspect`/`convert`) surface
315 /// that loss loudly instead of silently. `0` for a cleanly-parsed file,
316 /// and for a `Session` assembled programmatically.
317 pub parse_error_lines: usize,
318 /// Named degradations a [`Fidelity::Semantic`] load accepted instead of
319 /// failing — the same "say exactly what was given up" residue list
320 /// `harness.v1.sessions.export` already reports for artifacts.
321 ///
322 /// ALWAYS empty for a lossless load: every stricter fidelity refuses a
323 /// transcript it cannot reconstruct exactly, which is what keeps
324 /// continuation/transfer/export guarantees intact. A non-empty list means
325 /// this session is a read-only VIEW ([`Session::load_with_fidelity`] with
326 /// [`Fidelity::Semantic`]) and must not be used as a continuation source.
327 pub load_residue: Vec<String>,
328}
329
330impl Session {
331 /// The fidelity this reconstruction actually achieved.
332 ///
333 /// Same rule the export path applies to an artifact: named residue means
334 /// [`Fidelity::Semantic`]; otherwise a verbatim source capture is
335 /// [`Fidelity::ByteLossless`] and a re-synthesized one is
336 /// [`Fidelity::ValueLossless`]. A subagent's residue counts as this
337 /// session's: the whole reconstruction is only as faithful as its least
338 /// faithful part, and each child still reports its own residue where it
339 /// was measured.
340 pub fn load_fidelity(&self) -> Fidelity {
341 let own = if !self.load_residue.is_empty() {
342 Fidelity::Semantic
343 } else if self.raw_is_verbatim {
344 Fidelity::ByteLossless
345 } else {
346 Fidelity::ValueLossless
347 };
348 if own != Fidelity::Semantic
349 && self
350 .subagents
351 .iter()
352 .any(|subagent| subagent.load_fidelity() == Fidelity::Semantic)
353 {
354 return Fidelity::Semantic;
355 }
356 own
357 }
358
359 /// Assemble a session from supercode's own flat store transcript (one
360 /// [`ChatMessage`] per JSONL line). These files are the native working
361 /// format written by Supercode's native session store, not a foreign
362 /// harness log, so routing them through format auto-detection would
363 /// misclassify them as an empty Claude Code session.
364 pub fn from_native_messages(messages: Vec<ChatMessage>) -> Session {
365 Session {
366 meta: SessionMeta::new(SessionSource::Native),
367 messages,
368 subagents: Vec::new(),
369 raw: Vec::new(),
370 raw_trailing_newline: true,
371 imported_message_count: None,
372 raw_is_verbatim: false,
373 parse_error_lines: 0,
374 load_residue: Vec::new(),
375 }
376 }
377
378 /// Load a session, auto-detecting whether it's a Claude Code or Codex log
379 /// — or, when `path` looks like a SQLite database, a real OpenCode
380 /// `opencode*.db` store (PARITY-3/PARITY-16): that check runs BEFORE any
381 /// UTF-8 text read, so a binary `.db` file is routed to
382 /// [`Self::from_opencode_sqlite`] instead of failing on a raw "stream
383 /// did not contain valid UTF-8" error (the confirmed footgun these items
384 /// close — see [`looks_like_sqlite`] and the UTF-8 diagnostic reader).
385 ///
386 /// A DIRECTORY is also accepted directly: `path` is probed with
387 /// [`detect_opencode_storage_surface`] BEFORE the SQLite/UTF-8 file
388 /// checks below (both of which assume a file and would otherwise surface
389 /// a cryptic "Is a directory" `io::Error` — the confirmed footgun this
390 /// closes). This lets `inspect`/`convert`/`resume` accept an OpenCode
391 /// DATA-ROOT directly (e.g. `~/.local/share/opencode`), matching what
392 /// `audit --format opencode` already does. A resolved `Sqlite` surface
393 /// loads exactly like pointing `load` at that `opencode*.db` file
394 /// directly (most-recently-updated top-level session). The legacy
395 /// `JsonTreeA`/`JsonTreeB` surfaces are classifier-only (see
396 /// [`OpenCodeStorageSurface`]) — there's no direct-JSON-tree loader, so
397 /// that case returns a clear error naming the `.db` file / `audit` as the
398 /// way in, rather than silently doing nothing or crashing.
399 pub fn load(path: impl AsRef<Path>) -> Result<Session> {
400 Self::load_with_fidelity(path, Fidelity::ByteLossless)
401 }
402
403 /// Load a session at a declared [`Fidelity`].
404 ///
405 /// [`Fidelity::Semantic`] is the READ-ONLY VIEW mode: a transcript whose
406 /// record graph cannot be reconstructed exactly (the everyday case for a
407 /// Claude Code session that has been compacted or resumed across files,
408 /// where a live record's `parentUuid` names a record that was pruned)
409 /// still loads, stitched best-effort in transcript order, and names what
410 /// it gave up in [`Session::load_residue`]. Every stricter level keeps
411 /// the historical behavior — refuse loudly — because a continuation,
412 /// transfer or export built on a guessed graph is exactly the loss
413 /// supercode exists to prevent. Callers that go on to RESUME a session
414 /// must therefore use [`Session::load`].
415 pub fn load_with_fidelity(path: impl AsRef<Path>, fidelity: Fidelity) -> Result<Session> {
416 Self::load_with_fidelity_and_subagents(path, fidelity, true)
417 }
418
419 /// Load only the selected session's own transcript at a declared fidelity.
420 ///
421 /// This is the read-only frontend path: Claude Code can place hundreds of
422 /// child transcripts beside a parent, but a chat viewport displaying the
423 /// parent must not eagerly parse and transport that entire child tree.
424 /// Translation, continuation, export, and the ordinary [`Self::load`]
425 /// path keep attaching every subagent unchanged.
426 #[doc(hidden)]
427 pub fn load_parent_with_fidelity(
428 path: impl AsRef<Path>,
429 fidelity: Fidelity,
430 ) -> Result<Session> {
431 Self::load_with_fidelity_and_subagents(path, fidelity, false)
432 }
433
434 /// Load a bounded, parent-only transcript for human display.
435 ///
436 /// Unlike the continuation loader, Codex compaction records do not erase
437 /// earlier visible assistant turns here: the native rollout still holds
438 /// those records, and a scrollback view should show what the human saw,
439 /// not only the compacted context the next model call will receive.
440 #[doc(hidden)]
441 pub fn load_display_view(
442 path: impl AsRef<Path>,
443 fidelity: Fidelity,
444 message_limit: usize,
445 ) -> Result<Session> {
446 let path = path.as_ref();
447 if path.is_dir() || looks_like_sqlite(path) {
448 let mut session = Self::load_parent_with_fidelity(path, fidelity)?;
449 truncate_session_messages(&mut session, message_limit);
450 return Ok(session);
451 }
452 let (source, text) = read_display_jsonl(path, message_limit)?;
453 let mut session = match source {
454 Some(SessionSource::Codex) => Self::from_codex_display_str(&text, message_limit)?,
455 Some(SessionSource::Pi) => Self::from_pi_str(&text)?,
456 Some(SessionSource::Grok) => {
457 let mut session = Self::from_grok_str(&text)?;
458 session.capture_grok_path_metadata(path);
459 session
460 }
461 Some(SessionSource::OpenCode) => Self::from_opencode_str(&text)?,
462 _ => Self::from_claude_code_str_with_fidelity(&text, fidelity)?,
463 };
464 truncate_session_messages(&mut session, message_limit);
465 Ok(session)
466 }
467
468 fn load_with_fidelity_and_subagents(
469 path: impl AsRef<Path>,
470 fidelity: Fidelity,
471 include_subagents: bool,
472 ) -> Result<Session> {
473 let path = path.as_ref();
474 if path.is_dir() {
475 return match detect_opencode_storage_surface(path) {
476 Some((OpenCodeStorageSurface::Sqlite, db_path)) => {
477 Self::from_opencode_sqlite(&db_path, None)
478 }
479 Some((
480 OpenCodeStorageSurface::JsonTreeA | OpenCodeStorageSurface::JsonTreeB,
481 _,
482 )) => Err(crate::Error::Other(format!(
483 "{} is an OpenCode data root using a legacy JSON storage tree, which \
484 supercode does not load directly — point `inspect`/`convert`/`resume` \
485 at the store's `opencode*.db` SQLite file if this install has one, or \
486 use `audit --format opencode {}` instead",
487 path.display(),
488 path.display()
489 ))),
490 None => Err(crate::Error::Other(format!(
491 "{} is a directory, but no session file or OpenCode store was found in it \
492 (expected an `opencode*.db` SQLite file, or an OpenCode legacy JSON storage \
493 tree)",
494 path.display()
495 ))),
496 };
497 }
498 if looks_like_sqlite(path) {
499 return Self::from_opencode_sqlite(path, None);
500 }
501 let text = read_utf8_or_diagnose(path)?;
502 match detect_source(&text) {
503 Some(SessionSource::Codex) => Self::from_codex_str(&text),
504 Some(SessionSource::Pi) => Self::from_pi_str(&text),
505 Some(SessionSource::Grok) => {
506 let mut session = Self::from_grok_str(&text)?;
507 session.capture_grok_path_metadata(path);
508 Ok(session)
509 }
510 Some(SessionSource::Gemini) => Self::from_gemini_str(&text),
511 Some(SessionSource::Goose) => Self::from_goose_str(&text),
512 // IX-3: a detected OpenCode session must route to its own
513 // loader, not the Claude Code fallback below
514 // (`docs/interop/build-followups.md`).
515 Some(SessionSource::OpenCode) => Self::from_opencode_str(&text),
516 _ => {
517 let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
518 if include_subagents {
519 session.attach_claude_subagents(path, &text, fidelity)?;
520 }
521 Ok(session)
522 }
523 }
524 }
525
526 /// Load a Claude Code transcript from a file, attaching any subagent
527 /// (`Task`) sub-conversations stored alongside it.
528 pub fn from_claude_code(path: impl AsRef<Path>) -> Result<Session> {
529 Self::from_claude_code_with_fidelity(path, Fidelity::ByteLossless)
530 }
531
532 /// [`Session::from_claude_code`] at a declared [`Fidelity`] — see
533 /// [`Session::load_with_fidelity`] for what [`Fidelity::Semantic`] buys.
534 pub fn from_claude_code_with_fidelity(
535 path: impl AsRef<Path>,
536 fidelity: Fidelity,
537 ) -> Result<Session> {
538 let text = std::fs::read_to_string(path.as_ref())?;
539 let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
540 session.attach_claude_subagents(path.as_ref(), &text, fidelity)?;
541 Ok(session)
542 }
543
544 /// Discover and load `<session>/subagents/agent-*.jsonl` files for a
545 /// Claude Code transcript at `main_path`, linking each back to the parent
546 /// `Task` tool call via the agent id embedded in the parent's tool result.
547 fn attach_claude_subagents(
548 &mut self,
549 main_path: &Path,
550 main_text: &str,
551 fidelity: Fidelity,
552 ) -> Result<()> {
553 let Some(dir) = subagents_dir_for(main_path) else {
554 return Ok(());
555 };
556 let entries = std::fs::read_dir(&dir).map_err(|error| {
557 crate::Error::Other(format!(
558 "failed to enumerate Claude subagents at {}: {error}",
559 dir.display()
560 ))
561 })?;
562 let mut files = Vec::new();
563 for entry in entries {
564 let entry = entry.map_err(|error| {
565 crate::Error::Other(format!(
566 "failed to enumerate Claude subagents at {}: {error}",
567 dir.display()
568 ))
569 })?;
570 let path = entry.path();
571 if path.extension().and_then(|extension| extension.to_str()) == Some("jsonl") {
572 files.push(path);
573 }
574 }
575 files.sort();
576
577 // Phase 1 — collect each subagent + its recovered agent id, without
578 // touching the main transcript yet.
579 let mut collected: Vec<(Session, Option<String>)> = Vec::with_capacity(files.len());
580 for file in files {
581 let text = read_utf8_or_diagnose(&file).map_err(|error| {
582 crate::Error::Other(format!(
583 "failed to read Claude subagent {}: {error}",
584 file.display()
585 ))
586 })?;
587 let sub = match Self::from_claude_code_str_with_fidelity(&text, fidelity) {
588 Ok(sub) => sub,
589 // A read-only VIEW keeps the main conversation rather than
590 // losing the whole session to one unreconstructable child;
591 // the skip is named, not silent. Every stricter fidelity
592 // still propagates the child's failure.
593 Err(error) if fidelity.tolerates_residue() => {
594 self.load_residue.push(format!(
595 "Claude subagent {} could not be reconstructed ({error}); it was omitted from this view",
596 file.display()
597 ));
598 continue;
599 }
600 Err(error) => {
601 return Err(crate::Error::Other(format!(
602 "failed to reconstruct Claude subagent {}: {error}",
603 file.display()
604 )))
605 }
606 };
607 // agentId: prefer the file's own record, fall back to the filename stem.
608 let agent_id = first_agent_id(&text).or_else(|| {
609 file.file_stem()
610 .and_then(|s| s.to_str())
611 .map(|s| s.trim_start_matches("agent-").to_string())
612 });
613 collected.push((sub, agent_id));
614 }
615
616 // Phase 2 — single pass over the main transcript to index every
617 // requested agent id at once, then assign each subagent's parent by
618 // an O(1) lookup.
619 let agent_ids: Vec<String> = collected.iter().filter_map(|(_, id)| id.clone()).collect();
620 let index = parent_tool_use_index(main_text, &agent_ids);
621
622 for (mut sub, agent_id) in collected {
623 sub.meta.parent_tool_use_id = agent_id.as_ref().and_then(|id| index.get(id).cloned());
624 sub.meta.agent_id = agent_id;
625 self.subagents.push(sub);
626 }
627 Ok(())
628 }
629
630 /// Load a Codex rollout from a file.
631 pub fn from_codex(path: impl AsRef<Path>) -> Result<Session> {
632 Self::from_codex_str(&std::fs::read_to_string(path.as_ref())?)
633 }
634
635 /// Parse a Claude Code transcript from an in-memory JSONL string.
636 pub fn from_claude_code_str(jsonl: &str) -> Result<Session> {
637 Self::from_claude_code_str_with_fidelity(jsonl, Fidelity::ByteLossless)
638 }
639
640 /// [`Session::from_claude_code_str`] at a declared [`Fidelity`] — see
641 /// [`Session::load_with_fidelity`] for what [`Fidelity::Semantic`] buys.
642 pub fn from_claude_code_str_with_fidelity(jsonl: &str, fidelity: Fidelity) -> Result<Session> {
643 let mut meta = SessionMeta::new(SessionSource::ClaudeCode);
644 let mut messages = Vec::new();
645 // IX-1: `raw` is captured STRICT-VERBATIM (blank lines, CRLF, trailing
646 // whitespace all preserved) — separate from the blank-skipping
647 // `non_empty_lines` walk just below, which still parses records only
648 // (a blank line is not a JSON record and must not become one).
649 let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
650 let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
651 // PARITY-15: a malformed/truncated line is still tolerated (a
652 // single bad line must not make an otherwise-healthy multi-
653 // thousand-line session unloadable) — but it's no longer INVISIBLE.
654 let mut parse_error_lines = 0usize;
655 let mut index = ClaudeReplayIndex::default();
656
657 // Claude transcripts are append-only trees, not linear chat logs.
658 // Build a lightweight graph index first so normalization sees the
659 // same single active, post-compaction branch Claude Code would
660 // resume. `raw` above deliberately remains the complete source.
661 for (line_index, line) in raw_lines.iter().enumerate() {
662 if line.trim().is_empty() {
663 continue;
664 }
665 let v: Value = match serde_json::from_str(line) {
666 Ok(v) => v,
667 Err(_) => {
668 parse_error_lines += 1; // tolerate stray/corrupt lines
669 continue;
670 }
671 };
672 capture_claude_meta(&v, &mut meta, line)?;
673 index.observe(line_index, &v)?;
674 }
675
676 let ClaudeReplaySelection {
677 lines: replay_lines,
678 residue: load_residue,
679 } = index.select_lines(fidelity)?;
680 let mut pending_assistant: Option<Value> = None;
681
682 for line_index in replay_lines {
683 let line = raw_lines[line_index];
684 let v: Value = serde_json::from_str(line).map_err(crate::Error::Decode)?;
685
686 if v.get("type").and_then(Value::as_str) == Some("assistant") {
687 if v.get("isApiErrorMessage").and_then(Value::as_bool) == Some(true) {
688 flush_claude_assistant(&mut pending_assistant, &mut messages);
689 continue;
690 }
691 if let Some(pending) = pending_assistant.as_mut() {
692 if claude_assistant_message_id(pending).is_some_and(|message_id| {
693 claude_assistant_message_id(&v) == Some(message_id)
694 }) {
695 merge_claude_assistant_chunk(pending, &v);
696 continue;
697 }
698 flush_claude_assistant(&mut pending_assistant, &mut messages);
699 }
700 pending_assistant = Some(v);
701 continue;
702 }
703
704 flush_claude_assistant(&mut pending_assistant, &mut messages);
705
706 // WAVE-2 item 1: every Claude Code record carries a real
707 // top-level `timestamp` (ISO-8601) — provenance stamping below
708 // attaches it to every canonical `ChatMessage` this line
709 // produces, together with the record UUID and assistant model.
710 // `entry(...).or_insert_with` preserves any more-precise value a
711 // role-specific loader already supplied.
712 let before = messages.len();
713 match v.get("type").and_then(Value::as_str) {
714 Some("user") => push_claude_user(&v, &mut messages),
715 Some("assistant") => push_claude_assistant(&v, &mut messages),
716 Some("attachment") => push_claude_attachment(&v, &mut messages),
717 Some("system") => push_claude_system(&v, &mut messages),
718 _ => {} // mode, queue-operation, ... — skip
719 }
720 // UUID/model provenance remains meaningful even for legacy
721 // records that predate Claude Code's timestamp field.
722 capture_claude_record_provenance(&v, &mut messages[before..]);
723 restore_single_grok_message(&v, &mut messages[before..]);
724 }
725 flush_claude_assistant(&mut pending_assistant, &mut messages);
726
727 reorder_tool_results_after_calls(&mut messages);
728 ensure_tool_results_paired(&mut messages);
729 let imported_message_count = Some(messages.len());
730 Ok(Session {
731 meta,
732 messages,
733 subagents: Vec::new(),
734 raw,
735 raw_trailing_newline,
736 imported_message_count,
737 // Claude Code is line-oriented: `raw` is split directly out of
738 // the source text (strict-verbatim, IX-1).
739 raw_is_verbatim: true,
740 parse_error_lines,
741 load_residue,
742 })
743 }
744
745 /// Reconstruct multi-file subagent trees from a flat set of loaded sessions.
746 ///
747 /// Codex stores subagents as separate rollout files linked to their parent
748 /// by `lineage["parent_thread_id"]` (→ the parent's `session_id`). Given a
749 /// collection of sessions, this nests each child into its parent's
750 /// [`Session::subagents`] and returns only the roots. Children whose parent
751 /// isn't in the set are returned as roots themselves (best effort).
752 pub fn reconstruct_tree(sessions: Vec<Session>) -> Vec<Session> {
753 use std::collections::HashMap;
754 // Index each session's position by its session_id.
755 let mut idx: HashMap<String, usize> = HashMap::new();
756 for (i, s) in sessions.iter().enumerate() {
757 if let Some(id) = &s.meta.session_id {
758 idx.insert(id.clone(), i);
759 }
760 }
761 // Determine each session's parent (by index), if present in the set.
762 let parent_of: Vec<Option<usize>> = sessions
763 .iter()
764 .map(|s| {
765 s.meta
766 .lineage
767 .get("parent_thread_id")
768 .and_then(|p| idx.get(p).copied())
769 })
770 .collect();
771
772 // Move children into parents, deepest-first so chains nest correctly.
773 let mut slots: Vec<Option<Session>> = sessions.into_iter().map(Some).collect();
774 let mut order: Vec<usize> = (0..slots.len()).collect();
775 order.sort_by_key(|&i| std::cmp::Reverse(depth_of(i, &parent_of)));
776 for i in order {
777 if let Some(p) = parent_of[i] {
778 if p != i {
779 if let Some(child) = slots[i].take() {
780 if let Some(parent) = slots[p].as_mut() {
781 parent.subagents.push(child);
782 } else {
783 slots[i] = Some(child); // parent already moved; keep as root
784 }
785 }
786 }
787 }
788 }
789 slots.into_iter().flatten().collect()
790 }
791
792 /// Parse a session of a known format from an in-memory JSONL string.
793 pub fn load_str(jsonl: &str, format: SessionFormat) -> Result<Session> {
794 match format {
795 SessionFormat::ClaudeCode => Self::from_claude_code_str(jsonl),
796 SessionFormat::Codex => Self::from_codex_str(jsonl),
797 SessionFormat::Pi => Self::from_pi_str(jsonl),
798 SessionFormat::OpenCode => Self::from_opencode_str(jsonl),
799 SessionFormat::Grok => Self::from_grok_str(jsonl),
800 SessionFormat::Gemini => Self::from_gemini_str(jsonl),
801 SessionFormat::Goose => Self::from_goose_str(jsonl),
802 }
803 }
804
805 /// Serialize this session to JSONL in the given format.
806 ///
807 /// The conversation is synthesized from the canonical messages, so this
808 /// works for sessions loaded from *either* tool as well as ones supercode
809 /// built itself. Converting between formats (e.g. Codex → Claude Code) is an
810 /// "export": format-specific framing that has no slot in the target may be
811 /// dropped, but the user/assistant/tool conversation is preserved.
812 pub fn to_jsonl(&self, format: SessionFormat) -> Result<String> {
813 match format {
814 SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl()),
815 SessionFormat::Codex => Ok(self.to_codex_jsonl()),
816 SessionFormat::Pi => Ok(self.to_pi_jsonl()),
817 SessionFormat::OpenCode => self.to_opencode_jsonl(),
818 SessionFormat::Grok => Ok(self.to_grok_jsonl()),
819 SessionFormat::Gemini => Ok(self.to_gemini_jsonl()),
820 SessionFormat::Goose => Ok(self.to_goose_json()),
821 }
822 }
823
824 /// Export back to `format`, replaying the imported `raw` prefix
825 /// **verbatim** — original uuids/ids, real timestamps, and
826 /// loader-skipped records (e.g. Claude Code `file-history-snapshot`) that
827 /// [`Self::to_jsonl`]'s full synthesis discards or fakes — when `format`
828 /// is the session's own origin (`format.source() == self.meta.source`,
829 /// see [`SessionFormat::source`]) and there is a `raw` prefix to replay.
830 /// Only messages appended *after* import (tracked by
831 /// [`Self::imported_message_count`]) are synthesized, chained onto the
832 /// last original record found in the raw prefix.
833 ///
834 /// `session_id` of `Some(new)` rewrites the session id on every emitted
835 /// line, raw and synthesized alike (`sessionId` for Claude Code,
836 /// `session_meta.payload.id` for Codex); `None` leaves ids as recorded.
837 ///
838 /// Cross-format export (no verbatim prefix exists in the target dialect,
839 /// by definition) and a session with no `raw` lines both fall back
840 /// unchanged to [`Self::to_jsonl`] — full synthesis, same output as
841 /// today. A12 (SPEC.md §6): this turns "export back to origin" from
842 /// *semantic* to *near-byte* fidelity for the dominant hop-back case;
843 /// cross-format stays at the documented semantic tier.
844 pub fn to_jsonl_spliced(
845 &self,
846 format: SessionFormat,
847 session_id: Option<&str>,
848 ) -> Result<String> {
849 if self.parse_error_lines > 0
850 || self
851 .subagents
852 .iter()
853 .any(|subagent| subagent.parse_error_lines > 0)
854 {
855 return Err(Error::InvalidSession(
856 "refusing spliced export because the loaded session contains parse loss"
857 .to_string(),
858 ));
859 }
860 if self.raw.is_empty() || format.source() != self.meta.source {
861 if let Some(session_id) = session_id {
862 let mut rewritten = self.clone();
863 rewritten.meta.session_id = Some(session_id.to_string());
864 return rewritten.to_jsonl(format);
865 }
866 return self.to_jsonl(format);
867 }
868 match format {
869 SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl_spliced(session_id)),
870 SessionFormat::Codex => Ok(self.to_codex_jsonl_spliced(session_id)),
871 SessionFormat::Pi => self.to_pi_jsonl_spliced(session_id),
872 SessionFormat::OpenCode => self.to_opencode_jsonl_spliced(session_id),
873 SessionFormat::Grok => Ok(self.to_grok_jsonl_spliced()),
874 SessionFormat::Gemini => Ok(self.to_gemini_jsonl_spliced(session_id)),
875 SessionFormat::Goose => Ok(self.to_goose_json_spliced(session_id)),
876 }
877 }
878
879 /// Write this session to `path` in the given format.
880 pub fn save(&self, path: impl AsRef<Path>, format: SessionFormat) -> Result<()> {
881 std::fs::write(path.as_ref(), self.to_jsonl(format)?)?;
882 Ok(())
883 }
884
885 /// Reconstruct the exact source bytes this `Session` was loaded from,
886 /// out of [`Self::raw`] + [`Self::raw_trailing_newline`] (the exact
887 /// inverse of the strict-verbatim capture those two fields record — see
888 /// `join_lines_verbatim`).
889 ///
890 /// For a genuinely line-oriented source (Claude Code, Codex, Pi, and an
891 /// OpenCode *envelope*-form JSONL), `raw` is captured verbatim from the
892 /// original text, so this reproduces the original file byte-for-byte —
893 /// the P008/P009 diagonal-convert fix (`convert <file> --to
894 /// <same-format>` is byte-identical to `<file>`) is built on exactly
895 /// this. The one documented exception is an OpenCode **export-document**
896 /// source (a single pretty-printed JSON value, not JSONL): `raw` there
897 /// is RE-SYNTHESIZED as one envelope line per record (see
898 /// `from_opencode_export_doc`'s contract), so this returns a
899 /// verbatim reproduction of THAT captured representation rather than the
900 /// original pretty-printed document — a known, narrow residue, not a
901 /// silent loss (the same records are all still present).
902 pub fn raw_verbatim(&self) -> String {
903 join_lines_verbatim(&self.raw, self.raw_trailing_newline)
904 }
905
906 /// Serialize to the **supercode-native** lossless format: a header line
907 /// recording the original source, followed by every original JSONL line
908 /// verbatim. Unlike [`Self::to_jsonl`] (which targets a foreign tool's
909 /// schema and is necessarily lossy), this preserves *everything* — including
910 /// records with no canonical representation — so [`Self::from_native_str`]
911 /// reconstructs the session with full fidelity.
912 pub fn to_native_jsonl(&self) -> String {
913 let source = match self.meta.source {
914 SessionSource::ClaudeCode => "claude_code",
915 SessionSource::Codex => "codex",
916 SessionSource::Pi => "pi",
917 SessionSource::OpenCode => "opencode",
918 SessionSource::Grok => "grok",
919 SessionSource::Gemini => "gemini",
920 SessionSource::Goose => "goose",
921 // P5-3 safety-hardening fix: a natively-spawned session must
922 // never be written to disk labeled as an imported CC session.
923 SessionSource::Native => "native",
924 };
925 let header = serde_json::json!({
926 "supercode_native": 1,
927 "source": source,
928 // IX-1: carries whether the ORIGINAL imported source text ended
929 // with a trailing newline — `from_native_str` needs this to
930 // reconstruct the exact source bytes (not just the `raw` line
931 // list) when re-parsing the body with the per-source loader.
932 "raw_trailing_newline": self.raw_trailing_newline,
933 })
934 .to_string();
935 let mut out =
936 String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
937 out.push_str(&header);
938 out.push('\n');
939 for line in &self.raw {
940 out.push_str(line);
941 out.push('\n');
942 }
943 out
944 }
945
946 /// Serialize to the **supercode-native v2** format: the same imported-body
947 /// mechanism as [`Self::to_native_jsonl`] (a versioned header line
948 /// followed by every `Session.raw` line verbatim), plus one
949 /// [`crate::sidecar::NativeTurn`] record per message in `appended` — turns
950 /// produced after import, which have no backing `raw` line of their own.
951 /// `NativeTurn` carries `metadata` in full (unlike `ChatMessage`'s wire
952 /// serde), so nothing the live agent loop records is lost to disk.
953 ///
954 /// `appended` is caller-supplied rather than inferred from
955 /// `self.messages`: A1 doesn't track which of `self.messages` came from
956 /// import vs. the live loop — that bookkeeping belongs to the live writer
957 /// built on top of this (A2/A3).
958 pub fn to_native_jsonl_v2(&self, appended: &[ChatMessage]) -> String {
959 self.to_native_jsonl_v2_with_timestamp(appended, None)
960 }
961
962 pub(crate) fn to_native_jsonl_v2_with_timestamp(
963 &self,
964 appended: &[ChatMessage],
965 fixed_timestamp: Option<&str>,
966 ) -> String {
967 let source = match self.meta.source {
968 SessionSource::ClaudeCode => "claude_code",
969 SessionSource::Codex => "codex",
970 SessionSource::Pi => "pi",
971 SessionSource::OpenCode => "opencode",
972 SessionSource::Grok => "grok",
973 SessionSource::Gemini => "gemini",
974 SessionSource::Goose => "goose",
975 // P5-3 safety-hardening fix: a natively-spawned session must
976 // never be written to disk labeled as an imported CC session.
977 SessionSource::Native => "native",
978 };
979 // P5-3 (§2 module 9, §5.2 P5 row 3 "native write side — store
980 // already parses CC sidechains + CX lineage on import"): a
981 // natively-spawned subagent's own `Session` carries its lineage on
982 // `SessionMeta` (`agent_id`/`parent_tool_use_id`/`lineage`) — before
983 // this, `to_native_jsonl_v2` never wrote any of the three to disk at
984 // all, so a native-spawned child's lineage was lost the instant it
985 // round-tripped through a sidecar. Emitted only when non-empty/`Some`
986 // (`skip_serializing_if`-equivalent via manual omission below) so a
987 // plain top-level session's header is byte-identical to before this
988 // change.
989 let mut header_obj = serde_json::json!({
990 "supercode_native": 2,
991 "source": source,
992 "session_id": self.meta.session_id,
993 "created": fixed_timestamp
994 .map(ToOwned::to_owned)
995 .unwrap_or_else(crate::sidecar::now_rfc3339),
996 // IX-1: see `to_native_jsonl`'s header field of the same name.
997 "raw_trailing_newline": self.raw_trailing_newline,
998 });
999 if let Some(obj) = header_obj.as_object_mut() {
1000 if let Some(agent_id) = &self.meta.agent_id {
1001 obj.insert("agent_id".to_string(), Value::String(agent_id.clone()));
1002 }
1003 if let Some(parent_tool_use_id) = &self.meta.parent_tool_use_id {
1004 obj.insert(
1005 "parent_tool_use_id".to_string(),
1006 Value::String(parent_tool_use_id.clone()),
1007 );
1008 }
1009 if !self.meta.lineage.is_empty() {
1010 obj.insert(
1011 "lineage".to_string(),
1012 serde_json::to_value(&self.meta.lineage).unwrap_or(Value::Null),
1013 );
1014 }
1015 }
1016 let header = header_obj.to_string();
1017 let mut out =
1018 String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
1019 out.push_str(&header);
1020 out.push('\n');
1021 for line in &self.raw {
1022 out.push_str(line);
1023 out.push('\n');
1024 }
1025 for (turn_index, msg) in appended.iter().enumerate() {
1026 let turn = match fixed_timestamp {
1027 Some(timestamp) => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
1028 msg,
1029 timestamp.to_string(),
1030 turn_index as u64,
1031 ),
1032 None => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
1033 msg,
1034 crate::sidecar::now_rfc3339(),
1035 turn_index as u64,
1036 ),
1037 };
1038 out.push_str(&serde_json::to_string(&turn).unwrap_or_default());
1039 out.push('\n');
1040 }
1041 out
1042 }
1043
1044 /// Parse the supercode-native format produced by [`Self::to_native_jsonl`]
1045 /// / [`Self::to_native_jsonl_v2`]. A v1 file (no appended turns) parses
1046 /// exactly as before. A v2 file's appended `NativeTurn` records —
1047 /// discriminated by the `supercode_turn` key, which never appears in a v1
1048 /// body — are split out before the imported body is handed to the
1049 /// per-source loader, then reattached in file order: to `messages` (via
1050 /// [`crate::sidecar::NativeTurn::into_message`]) and to `raw` (verbatim),
1051 /// so a v2 file round-trips byte-for-byte through
1052 /// [`Self::to_native_jsonl_v2`] again.
1053 pub fn from_native_str(jsonl: &str) -> Result<Session> {
1054 // IX-1: the native WRAPPER's own lines are split verbatim (not via
1055 // the blank-skipping `non_empty_lines`) so that any `raw` line it
1056 // carries — which can itself be blank, CRLF-terminated, or
1057 // whitespace-padded, now that raw-capture is strict-verbatim —
1058 // survives being embedded in (and re-extracted from) this wrapper
1059 // bit-for-bit. The wrapper we ourselves emit never has a blank line
1060 // of its own (`to_native_jsonl(_v2)` always writes one well-formed
1061 // record per line), so this is a behavior-preserving switch for any
1062 // native text this crate produced; it also makes a hand-fed/legacy
1063 // native string tolerated exactly as `non_empty_lines` used to.
1064 let (all_lines, _wrapper_trailing_newline) = split_lines_verbatim(jsonl);
1065 let mut lines = all_lines.into_iter();
1066 let header = lines.next().unwrap_or("");
1067 let hv: Value = serde_json::from_str(header).unwrap_or(Value::Null);
1068 let source = hv.get("source").and_then(Value::as_str);
1069 // IX-1: whether the ORIGINAL imported source (before it was wrapped
1070 // in this native format) ended with a trailing newline — a property
1071 // of the pre-wrap source, not of this wrapper (which always
1072 // LF-terminates every line it writes, regardless). Missing on a
1073 // native file written before IX-1 (or a hand-built header in an
1074 // older test/sidecar) — default `true`, the historical
1075 // always-newline-terminated assumption.
1076 let raw_trailing_newline = hv
1077 .get("raw_trailing_newline")
1078 .and_then(Value::as_bool)
1079 .unwrap_or(true);
1080
1081 // Split appended NativeTurn records (v2) out of the imported body. A
1082 // v1 body never carries a `supercode_turn` key, so this is a no-op
1083 // there — one code path serves both versions.
1084 let mut body_lines: Vec<String> = Vec::new();
1085 let mut turn_lines: Vec<&str> = Vec::new();
1086 for line in lines {
1087 let is_turn = serde_json::from_str::<Value>(line)
1088 .ok()
1089 .is_some_and(|v| v.get("supercode_turn").is_some());
1090 if is_turn {
1091 turn_lines.push(line);
1092 } else {
1093 body_lines.push(line.to_string());
1094 }
1095 }
1096 // IX-1: reconstruct the ORIGINAL imported source bytes exactly —
1097 // `body_lines.join("\n")` alone would silently gain a trailing
1098 // newline the original source never had (or lose one it did have).
1099 let body = join_lines_verbatim(&body_lines, raw_trailing_newline);
1100
1101 // The remaining lines are the original log; re-parse with the right loader.
1102 let mut session = match source {
1103 Some("codex") => Self::from_codex_str(&body)?,
1104 Some("claude_code") => Self::from_claude_code_str(&body)?,
1105 Some("pi") => Self::from_pi_str(&body)?,
1106 Some("opencode") => Self::from_opencode_str(&body)?,
1107 Some("grok") => Self::from_grok_str(&body)?,
1108 Some("gemini") => Self::from_gemini_str(&body)?,
1109 Some("goose") => Self::from_goose_str(&body)?,
1110 // P5-3 safety-hardening fix: a natively-spawned session's body
1111 // is always empty (it never had any foreign-tool prefix to
1112 // begin with — see `SessionSource::Native`'s doc comment), so
1113 // any loader would parse it identically; `from_claude_code_str`
1114 // is reused purely as a blank-skeleton builder (empty
1115 // `raw`/`messages`), then its `meta.source` is corrected to
1116 // `Native` — never left mislabeled as `ClaudeCode`.
1117 Some("native") => {
1118 let mut s = Self::from_claude_code_str(&body)?;
1119 s.meta.source = SessionSource::Native;
1120 s
1121 }
1122 // No/unknown header — auto-detect the body.
1123 _ => match detect_source(&body) {
1124 Some(SessionSource::Codex) => Self::from_codex_str(&body)?,
1125 Some(SessionSource::Pi) => Self::from_pi_str(&body)?,
1126 Some(SessionSource::OpenCode) => Self::from_opencode_str(&body)?,
1127 Some(SessionSource::Grok) => Self::from_grok_str(&body)?,
1128 Some(SessionSource::Gemini) => Self::from_gemini_str(&body)?,
1129 Some(SessionSource::Goose) => Self::from_goose_str(&body)?,
1130 _ => Self::from_claude_code_str(&body)?,
1131 },
1132 };
1133
1134 for line in turn_lines {
1135 match serde_json::from_str::<crate::sidecar::NativeTurn>(line) {
1136 Ok(turn) => {
1137 session.raw.push(line.to_string());
1138 session.messages.push(turn.into_message());
1139 }
1140 Err(_) => {
1141 // A valid JSON object carrying the native-turn
1142 // discriminator belongs to this wrapper, not to the
1143 // imported body. If its required fields are malformed,
1144 // count it as parse loss so every fail-loud caller can
1145 // refuse continuation instead of silently dropping a
1146 // native history record. Keep the rejected source line
1147 // in `raw` as well: diagnostics must count it in their
1148 // denominator, and even corrupt input must not disappear
1149 // merely because it reached the parser.
1150 session.raw.push(line.to_string());
1151 session.parse_error_lines += 1;
1152 }
1153 }
1154 }
1155
1156 // P5-3 (native write side, see `Self::to_native_jsonl_v2`'s matching
1157 // header block): recover a natively-spawned subagent's own lineage
1158 // from the v2 header, when present. Overlays (rather than merges
1159 // into) whatever the per-source body loader may have already set on
1160 // `session.meta` — these three keys are ONLY ever written by
1161 // `to_native_jsonl_v2` itself (never by a CC/CX/OC/Pi body), so a
1162 // header that carries them is authoritative for a file this crate
1163 // produced.
1164 if let Some(agent_id) = hv.get("agent_id").and_then(Value::as_str) {
1165 session.meta.agent_id = Some(agent_id.to_string());
1166 }
1167 if let Some(parent_tool_use_id) = hv.get("parent_tool_use_id").and_then(Value::as_str) {
1168 session.meta.parent_tool_use_id = Some(parent_tool_use_id.to_string());
1169 }
1170 if let Some(lineage) = hv.get("lineage").and_then(Value::as_object) {
1171 for (k, v) in lineage {
1172 if let Some(s) = v.as_str() {
1173 session.meta.lineage.insert(k.clone(), s.to_string());
1174 }
1175 }
1176 }
1177
1178 Ok(session)
1179 }
1180
1181 /// The full-fidelity [`Session`] a sidecar denotes.
1182 ///
1183 /// The sidecar (native-v2 format, D1) is the imported body plus every
1184 /// appended [`crate::sidecar::NativeTurn`]. Unlike the deliberately
1185 /// tolerant lower-level native parser, this persisted-store entry point
1186 /// validates its framing header before loading anything: a missing,
1187 /// malformed, or unsupported header must never become a zero-message
1188 /// session that callers could continue as if it were complete.
1189 pub fn from_sidecar_str(s: &str) -> Result<Session> {
1190 let header = s.lines().next().ok_or_else(|| {
1191 Error::InvalidSession("sidecar header is missing from an empty artifact".to_string())
1192 })?;
1193 let value: Value = serde_json::from_str(header).map_err(|error| {
1194 Error::InvalidSession(format!("sidecar header is not valid JSON: {error}"))
1195 })?;
1196 let version = value.get("supercode_native").and_then(Value::as_u64);
1197 if !matches!(version, Some(1 | 2)) {
1198 return Err(Error::InvalidSession(
1199 "sidecar header must declare supported `supercode_native` version 1 or 2"
1200 .to_string(),
1201 ));
1202 }
1203 let source = value.get("source").and_then(Value::as_str);
1204 if !matches!(
1205 source,
1206 Some(
1207 "native"
1208 | "claude_code"
1209 | "codex"
1210 | "gemini"
1211 | "goose"
1212 | "opencode"
1213 | "pi"
1214 | "grok"
1215 )
1216 ) {
1217 return Err(Error::InvalidSession(
1218 "sidecar header must declare a supported `source`".to_string(),
1219 ));
1220 }
1221 Self::from_native_str(s)
1222 }
1223
1224 /// Parse a Codex rollout from an in-memory JSONL string.
1225 pub fn from_codex_str(jsonl: &str) -> Result<Session> {
1226 let mut meta = SessionMeta::new(SessionSource::Codex);
1227 let mut messages = Vec::new();
1228
1229 // First pass: collect the text of every assistant message that exists as
1230 // a canonical `response_item`. In normal sessions the streamed
1231 // `event_msg/agent_message` events duplicate these and are safely
1232 // skipped; in collab/multi-agent sessions the assistant narration lives
1233 // ONLY as `agent_message` events, so we recover the ones with no
1234 // response_item counterpart (deduping by exact text).
1235 let assistant_texts = collect_codex_assistant_texts(jsonl);
1236 // IX-1: strict-verbatim raw capture — see `from_claude_code_str`.
1237 let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
1238 let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
1239 let mut pending_reasoning = String::new();
1240 let mut pending_reasoning_content = String::new();
1241 let mut pending_reasoning_encrypted = false;
1242 // PARITY-15: see `from_claude_code_str`'s identical counter.
1243 let mut parse_error_lines = 0usize;
1244 let mut restored_embedded_codex_provenance = false;
1245
1246 for (record_index, raw_line) in raw_lines.iter().enumerate() {
1247 let line = raw_line.trim();
1248 if line.is_empty() {
1249 continue;
1250 }
1251 let v: Value = match serde_json::from_str(line) {
1252 Ok(v) => v,
1253 Err(_) => {
1254 parse_error_lines += 1;
1255 continue;
1256 }
1257 };
1258 let payload = v.get("payload").unwrap_or(&Value::Null);
1259 if !restored_embedded_codex_provenance
1260 && v.get("type").and_then(Value::as_str) == Some("session_meta")
1261 && payload
1262 .get(SUPERCODE_CODEX_PROVENANCE_KEY)
1263 .map(|extension| restore_codex_provenance(extension, &mut meta))
1264 .transpose()?
1265 .unwrap_or(false)
1266 {
1267 restored_embedded_codex_provenance = true;
1268 }
1269 if !restored_embedded_codex_provenance {
1270 capture_codex_provenance_record(&mut meta, record_index, raw_line, &v);
1271 }
1272 // WAVE-2 item 1: every Codex record carries a real top-level
1273 // `timestamp` (ISO-8601) — threaded onto each `ChatMessage` a
1274 // line produces via `stamp_new_codex_messages` below, at each
1275 // arm that pushes messages.
1276 let line_ts = v.get("timestamp").and_then(Value::as_str);
1277
1278 match v.get("type").and_then(Value::as_str) {
1279 Some("session_meta") => {
1280 capture_codex_session_meta(payload, &mut meta);
1281 if !restored_embedded_codex_provenance {
1282 meta.codex_headers.push(v.clone());
1283 }
1284 }
1285 Some("turn_context") => {
1286 if meta.model.is_none() {
1287 meta.model = payload
1288 .get("model")
1289 .and_then(Value::as_str)
1290 .map(str::to_string);
1291 }
1292 if !restored_embedded_codex_provenance {
1293 meta.codex_headers.push(v.clone());
1294 }
1295 }
1296 Some("response_item")
1297 if payload.get("type").and_then(Value::as_str) == Some("reasoning") =>
1298 {
1299 // Retain reasoning (P3): summary text if any, the raw
1300 // `content` chain-of-thought text if any (N2 — this used
1301 // to be dropped despite `Coverage::Retained` claiming the
1302 // whole item survived; see `crate::audit`'s doc comment),
1303 // plus a flag for the opaque encrypted_content a
1304 // same-model continuation can replay. Stashed onto the
1305 // next assistant message below.
1306 let summary = extract_text_content(payload.get("summary"));
1307 if !summary.trim().is_empty() {
1308 push_str_field(&mut pending_reasoning, &summary);
1309 }
1310 // N2: `content` is `null` on the vast majority of real
1311 // turns (raw reasoning text is only ever populated for
1312 // certain reasoning-transcript configurations) — guard
1313 // on non-null BEFORE calling `extract_text_content`,
1314 // since `Some(&Value::Null)` would otherwise fall into
1315 // its `Some(other) => other.to_string()` arm and
1316 // stringify to the literal text `"null"`.
1317 if let Some(raw_content) = payload.get("content").filter(|v| !v.is_null()) {
1318 let text = extract_text_content(Some(raw_content));
1319 if !text.trim().is_empty() {
1320 push_str_field(&mut pending_reasoning_content, &text);
1321 }
1322 }
1323 // N1: `serde_json` returns `Some(&Value::Null)` for a
1324 // present-but-null `encrypted_content` key — which is
1325 // what EVERY real rollout's reasoning item carries
1326 // (upstream always serializes the field, never
1327 // `skip_serializing_if`, `codex-rs/protocol/src/
1328 // models.rs:970-983`). The old `.is_some()` check
1329 // false-flagged every single reasoning item as
1330 // "encrypted" on real data; only a genuinely non-null
1331 // value means the model actually returned an opaque
1332 // blob that a same-model continuation could replay.
1333 if payload
1334 .get("encrypted_content")
1335 .is_some_and(|v| !v.is_null())
1336 {
1337 pending_reasoning_encrypted = true;
1338 }
1339 }
1340 Some("response_item") => {
1341 let before = messages.len();
1342 push_codex_item(payload, &mut messages);
1343 // Attach any pending reasoning to a newly produced assistant turn.
1344 if messages.len() > before
1345 && (!pending_reasoning.is_empty()
1346 || !pending_reasoning_content.is_empty()
1347 || pending_reasoning_encrypted)
1348 {
1349 let is_assistant = messages
1350 .last()
1351 .map(|m| m.role == Role::Assistant)
1352 .unwrap_or(false);
1353 if is_assistant {
1354 let last = messages.last_mut().expect("checked above");
1355 if !pending_reasoning.is_empty() {
1356 last.metadata.insert(
1357 "reasoning".to_string(),
1358 std::mem::take(&mut pending_reasoning),
1359 );
1360 }
1361 if !pending_reasoning_content.is_empty() {
1362 last.metadata.insert(
1363 "reasoning_content".to_string(),
1364 std::mem::take(&mut pending_reasoning_content),
1365 );
1366 }
1367 if pending_reasoning_encrypted {
1368 last.metadata
1369 .insert("reasoning_encrypted".to_string(), "true".to_string());
1370 pending_reasoning_encrypted = false;
1371 }
1372 } else {
1373 // N3: the item that just landed is NOT the
1374 // assistant turn the pending reasoning was for
1375 // (e.g. an aborted turn's reasoning directly
1376 // followed by a user message) — the old code
1377 // unconditionally cleared the pending state
1378 // here, silently discarding it. Flush it as its
1379 // own message instead, inserted just before the
1380 // interrupting item so replay order stays
1381 // chronological, keeping `Coverage::Retained`
1382 // honest for this shape too.
1383 let orphan = orphaned_reasoning_message(
1384 &mut pending_reasoning,
1385 &mut pending_reasoning_content,
1386 &mut pending_reasoning_encrypted,
1387 );
1388 messages.insert(before, orphan);
1389 }
1390 }
1391 stamp_new_codex_messages(&mut messages, before, line_ts);
1392 restore_single_grok_message(payload, &mut messages[before..]);
1393 }
1394 // A compaction record replaces all prior turns with its
1395 // summarized `replacement_history` — exactly how Codex itself
1396 // resumes a compacted session.
1397 Some("compacted") => {
1398 messages.clear();
1399 if let Some(Value::Array(history)) = payload.get("replacement_history") {
1400 for item in history {
1401 push_codex_item(item, &mut messages);
1402 }
1403 }
1404 // `replacement_history` items carry no per-item
1405 // timestamp of their own (observed corpora) — the
1406 // `compacted` record's own timestamp (when it happened)
1407 // is the best-effort real source for every message it
1408 // synthesizes, so it stamps the whole rebuilt vec (index
1409 // 0, since `clear()` reset it above).
1410 stamp_new_codex_messages(&mut messages, 0, line_ts);
1411 // IX-6 fix: replaying `replacement_history` through
1412 // `push_codex_item` can leave the LAST replayed message
1413 // marked `__codex_open_turn` (if it's an assistant
1414 // `message`, per the combined-turn merge below). That
1415 // marker must not survive past the compaction boundary —
1416 // a live `function_call` arriving after this record is a
1417 // NEW turn, not a continuation of the compaction
1418 // summary's synthetic turn, so it must not merge into it.
1419 if let Some(last) = messages.last_mut() {
1420 last.metadata.remove("__codex_open_turn");
1421 }
1422 }
1423 Some("event_msg")
1424 if payload.get("type").and_then(Value::as_str) == Some("agent_message") =>
1425 {
1426 let before = messages.len();
1427 let text = agent_message_text(payload);
1428 if !text.trim().is_empty() && !assistant_texts.contains(text.trim()) {
1429 push_assistant(&mut messages, text, Vec::new());
1430 if let Some(last) = messages.last_mut() {
1431 if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
1432 last.metadata.insert("phase".to_string(), phase.to_string());
1433 }
1434 }
1435 }
1436 stamp_new_codex_messages(&mut messages, before, line_ts);
1437 }
1438 // The user rolled back (undid) the last N turns — replay must
1439 // drop them so the reloaded conversation matches what the user
1440 // actually kept.
1441 Some("event_msg")
1442 if payload.get("type").and_then(Value::as_str)
1443 == Some("thread_rolled_back") =>
1444 {
1445 let n = payload
1446 .get("num_turns")
1447 .and_then(Value::as_u64)
1448 .unwrap_or(1);
1449 for _ in 0..n {
1450 remove_last_turn(&mut messages);
1451 }
1452 }
1453 // The natural-language goal assigned to this thread (sometimes
1454 // the only place the objective text is recorded).
1455 Some("event_msg")
1456 if payload.get("type").and_then(Value::as_str)
1457 == Some("thread_goal_updated") =>
1458 {
1459 let before = messages.len();
1460 let goal = payload.get("goal");
1461 if let Some(obj) = goal
1462 .and_then(|g| g.get("objective"))
1463 .and_then(Value::as_str)
1464 {
1465 if !obj.trim().is_empty() {
1466 messages.push(ChatMessage::system(format!("[thread goal] {obj}")));
1467 // D4: `goal.objective` alone used to be the ONLY
1468 // captured field, but the audit labeled this
1469 // `Retained` as if the whole record survived.
1470 // `goal.status`/`goal.tokenBudget` (real
1471 // `ThreadGoal` wire fields, camelCase) are
1472 // captured too so that label is honest — see
1473 // `crate::audit::event_msg_coverage`'s doc
1474 // comment.
1475 if let Some(last) = messages.last_mut() {
1476 if let Some(status) =
1477 goal.and_then(|g| g.get("status")).and_then(Value::as_str)
1478 {
1479 last.metadata
1480 .insert("goal_status".to_string(), status.to_string());
1481 }
1482 if let Some(budget) = goal
1483 .and_then(|g| g.get("tokenBudget"))
1484 .and_then(Value::as_i64)
1485 {
1486 last.metadata.insert(
1487 "goal_token_budget".to_string(),
1488 budget.to_string(),
1489 );
1490 }
1491 }
1492 }
1493 }
1494 stamp_new_codex_messages(&mut messages, before, line_ts);
1495 }
1496 // Code-review output — unique assistant-generated content with no
1497 // `message` counterpart.
1498 Some("event_msg")
1499 if payload.get("type").and_then(Value::as_str)
1500 == Some("exited_review_mode") =>
1501 {
1502 let before = messages.len();
1503 if let Some(review) = payload.get("review_output") {
1504 let text = review
1505 .get("overall_explanation")
1506 .and_then(Value::as_str)
1507 .map(str::to_string)
1508 .unwrap_or_else(|| review.to_string());
1509 push_assistant(&mut messages, format!("[code review] {text}"), Vec::new());
1510 // D4: `overall_explanation` alone used to be the ONLY
1511 // captured field, but the audit labeled this
1512 // `Retained` as if `review_output.findings` survived
1513 // too. Capture `findings` verbatim (as JSON, onto
1514 // metadata) so that label is honest — this is the
1515 // only place review-mode findings (title/body/
1516 // confidence_score/priority/code_location) live.
1517 if let Some(findings) = review.get("findings") {
1518 if findings.as_array().is_some_and(|a| !a.is_empty()) {
1519 if let Some(last) = messages.last_mut() {
1520 if let Ok(s) = serde_json::to_string(findings) {
1521 last.metadata.insert("review_findings".to_string(), s);
1522 }
1523 }
1524 }
1525 }
1526 // N4: `overall_correctness`/`overall_confidence_score`
1527 // are the review's actual verdict — distinct from the
1528 // findings list and the explanation prose already
1529 // captured above — and were neither captured nor
1530 // disclosed as residue while the audit doc stayed
1531 // silent about them. Capture both onto the same
1532 // message's metadata, same pattern as `findings`.
1533 if let Some(last) = messages.last_mut() {
1534 if let Some(correctness) =
1535 review.get("overall_correctness").and_then(Value::as_str)
1536 {
1537 last.metadata.insert(
1538 "review_overall_correctness".to_string(),
1539 correctness.to_string(),
1540 );
1541 }
1542 if let Some(score) = review
1543 .get("overall_confidence_score")
1544 .and_then(Value::as_f64)
1545 {
1546 last.metadata.insert(
1547 "review_overall_confidence_score".to_string(),
1548 score.to_string(),
1549 );
1550 }
1551 }
1552 }
1553 stamp_new_codex_messages(&mut messages, before, line_ts);
1554 }
1555 _ => {} // other event_msg, token_count, ... — UI events, skip
1556 }
1557 }
1558
1559 // N3/PARITY-11: reasoning still pending at EOF is an aborted-turn
1560 // shape a real rollout can leave behind (the process was
1561 // interrupted mid-turn, after the model reasoned but before it
1562 // replied — end of file, or a rollback/compaction boundary that
1563 // clears the pending state some other way) — the old code silently
1564 // dropped it here (nothing ever consumed the pending buffers once
1565 // the loop ended). Flush it as its own trailing message instead, so
1566 // `Coverage::Retained` holds for this shape too. Superset of the
1567 // independently-discovered PARITY-11 fix: also folds in
1568 // `pending_reasoning_content` (the raw chain-of-thought, distinct
1569 // from `summary`/`reasoning`) via the shared `orphaned_reasoning_
1570 // message` helper, which the interrupted-by-a-user-message shape
1571 // (`orphaned_reasoning_is_flushed_not_discarded`, case A) also
1572 // relies on — a trailing-EOF-only flush here would miss that case.
1573 if !pending_reasoning.is_empty()
1574 || !pending_reasoning_content.is_empty()
1575 || pending_reasoning_encrypted
1576 {
1577 let orphan = orphaned_reasoning_message(
1578 &mut pending_reasoning,
1579 &mut pending_reasoning_content,
1580 &mut pending_reasoning_encrypted,
1581 );
1582 messages.push(orphan);
1583 }
1584
1585 ensure_tool_results_paired(&mut messages);
1586 // IX-6: `__codex_open_turn` is an internal bookkeeping marker for the
1587 // combined-turn merge above — strip it so it never leaks out as
1588 // visible `ChatMessage` metadata.
1589 for m in &mut messages {
1590 m.metadata.remove("__codex_open_turn");
1591 if m.metadata
1592 .remove("__grok_remove_synthetic_turn_id")
1593 .is_some()
1594 {
1595 m.metadata.remove("turn_id");
1596 }
1597 }
1598 let imported_message_count = Some(messages.len());
1599 Ok(Session {
1600 meta,
1601 messages,
1602 subagents: Vec::new(),
1603 raw,
1604 raw_trailing_newline,
1605 imported_message_count,
1606 // Codex is line-oriented: `raw` is split directly out of the
1607 // source text (strict-verbatim, IX-1).
1608 raw_is_verbatim: true,
1609 parse_error_lines,
1610 load_residue: Vec::new(),
1611 })
1612 }
1613
1614 /// Parse a Codex rollout as bounded human-visible history rather than as
1615 /// resumable model context. This deliberately ignores outer `compacted`
1616 /// replacement semantics: the original `response_item` records remain in
1617 /// the rollout and are the authoritative UI history.
1618 fn from_codex_display_str(jsonl: &str, message_limit: usize) -> Result<Session> {
1619 let mut meta = SessionMeta::new(SessionSource::Codex);
1620 let mut messages: Vec<ChatMessage> = Vec::new();
1621 let mut preceding_user = None;
1622 let mut parse_error_lines = 0usize;
1623 let mut record_count = 0usize;
1624 let retain = message_limit.max(1).saturating_add(64);
1625 let mut canonical_assistant_texts = HashSet::new();
1626
1627 for raw_line in non_empty_lines(jsonl) {
1628 record_count += 1;
1629 let value: Value = match serde_json::from_str(raw_line) {
1630 Ok(value) => value,
1631 Err(_) => {
1632 parse_error_lines += 1;
1633 continue;
1634 }
1635 };
1636 let payload = value.get("payload").unwrap_or(&Value::Null);
1637 let line_ts = value.get("timestamp").and_then(Value::as_str);
1638 match value.get("type").and_then(Value::as_str) {
1639 Some("session_meta") => capture_codex_session_meta(payload, &mut meta),
1640 Some("turn_context") if meta.model.is_none() => {
1641 meta.model = payload
1642 .get("model")
1643 .and_then(Value::as_str)
1644 .map(str::to_string);
1645 }
1646 Some("response_item")
1647 if payload.get("type").and_then(Value::as_str) != Some("reasoning") =>
1648 {
1649 let assistant_text = (payload.get("type").and_then(Value::as_str)
1650 == Some("message")
1651 && payload.get("role").and_then(Value::as_str) == Some("assistant"))
1652 .then(|| extract_text_content(payload.get("content")))
1653 .filter(|text| !text.trim().is_empty());
1654 if let Some(text) = assistant_text.as_deref() {
1655 if let Some(index) = messages.iter().rposition(|message| {
1656 message.metadata.contains_key("codex_event_message")
1657 && message.content.as_deref() == Some(text)
1658 }) {
1659 messages.remove(index);
1660 }
1661 canonical_assistant_texts.insert(text.trim().to_string());
1662 }
1663 let before = messages.len();
1664 push_codex_item(payload, &mut messages);
1665 stamp_new_codex_messages(&mut messages, before, line_ts);
1666 restore_single_grok_message(payload, &mut messages[before..]);
1667 }
1668 Some("event_msg")
1669 if payload.get("type").and_then(Value::as_str) == Some("agent_message") =>
1670 {
1671 let text = agent_message_text(payload);
1672 if !text.trim().is_empty() && !canonical_assistant_texts.contains(text.trim()) {
1673 let before = messages.len();
1674 push_assistant(&mut messages, text, Vec::new());
1675 if let Some(last) = messages.last_mut() {
1676 last.metadata
1677 .insert("codex_event_message".to_string(), "true".to_string());
1678 if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
1679 last.metadata.insert("phase".to_string(), phase.to_string());
1680 }
1681 }
1682 stamp_new_codex_messages(&mut messages, before, line_ts);
1683 }
1684 }
1685 // `compacted` changes continuation context, not what was
1686 // already visible in scrollback. Other event records are UI
1687 // lifecycle noise or duplicate canonical response items.
1688 _ => {}
1689 }
1690 if messages.len() > retain {
1691 let remove = messages.len() - retain;
1692 for message in messages.drain(..remove) {
1693 if message.role == Role::User {
1694 preceding_user = Some(message);
1695 }
1696 }
1697 }
1698 }
1699
1700 for message in &mut messages {
1701 message.metadata.remove("__codex_open_turn");
1702 message.metadata.remove("codex_event_message");
1703 if message
1704 .metadata
1705 .remove("__grok_remove_synthetic_turn_id")
1706 .is_some()
1707 {
1708 message.metadata.remove("turn_id");
1709 }
1710 }
1711 truncate_messages_with_anchor(&mut messages, message_limit, preceding_user);
1712 let imported_message_count = Some(messages.len());
1713 Ok(Session {
1714 meta,
1715 messages,
1716 subagents: Vec::new(),
1717 // Preserve the cheap count without retaining hundreds of
1718 // megabytes of source lines in a display-only value.
1719 raw: vec![String::new(); record_count],
1720 raw_trailing_newline: jsonl.ends_with('\n'),
1721 imported_message_count,
1722 raw_is_verbatim: false,
1723 parse_error_lines,
1724 load_residue: vec![
1725 "display history is a bounded native-record projection, not resumable model context"
1726 .to_string(),
1727 ],
1728 })
1729 }
1730
1731 /// Load a Pi session from a file.
1732 pub fn from_pi(path: impl AsRef<Path>) -> Result<Session> {
1733 Self::from_pi_str(&std::fs::read_to_string(path.as_ref())?)
1734 }
1735
1736 /// Parse a Pi session (`docs/interop/opencode-pi-spec.md` §1.1,
1737 /// `docs/interop/research/pi-fields.md`) from an in-memory JSONL string.
1738 ///
1739 /// Line 1 is the `session` header; every other line is one `SessionEntry`
1740 /// in a tree keyed by `id`/`parentId` — file order is append order, not
1741 /// tree order. `raw` captures every line verbatim (byte-lossless T1,
1742 /// exactly like Claude Code/Codex). `messages` is the **active path
1743 /// only**: pi's own leaf rule is "the last entry in file order"
1744 /// (`pi-fields.md` `sm:897`), so this walks `parentId` from there back to
1745 /// the root and linearizes root→leaf. Non-active branches, `label`s, and
1746 /// state records (`thinking_level_change`/`model_change`/`custom`/
1747 /// `session_info`) are never visited by that walk — they survive in
1748 /// `raw` only, pi's defining residue (§1.1).
1749 ///
1750 /// `message.role` is an OPEN union upstream (§1.1 S6): a role outside the
1751 /// five modeled here (`user`/`assistant`/`toolResult`/`bashExecution`/
1752 /// `custom`) produces no canonical message — raw-only survival, never a
1753 /// panic — and the Pi corpus audit turns that into a
1754 /// visible coverage failure rather than a silent drop.
1755 ///
1756 /// Same fail-loud discipline applies to `ImageContent` blocks
1757 /// (`user`/`toolResult`/`custom*` content, see `pi_image_shape`): the
1758 /// assumed `{mimeType, data}` shape is UNVERIFIED against real pi output
1759 /// (`pi-fields.md` doesn't enumerate `ImageContent`'s own fields, only
1760 /// cites the containing union) — a follow-up TR tracks confirming it
1761 /// against a real corpus. Until then, an image block that doesn't match
1762 /// that shape never gets silently synthesized as an empty/corrupt
1763 /// `image_url` part; the containing message survives in `raw` only and
1764 /// trips `crate::audit::Corpus::Pi`'s `message/UnknownImageShape` bucket.
1765 pub fn from_pi_str(jsonl: &str) -> Result<Session> {
1766 let mut meta = SessionMeta::new(SessionSource::Pi);
1767 // IX-1: `raw` is captured STRICT-VERBATIM — separate from the
1768 // blank-skipping PARSE walk (`lines_v`) below, which must keep
1769 // skipping blank/whitespace-only lines when it looks for `SessionEntry`
1770 // records (a blank line is never a record, on either view).
1771 let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
1772 let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
1773 let non_empty_line_count = non_empty_lines(jsonl).count();
1774 let lines_v: Vec<Value> = non_empty_lines(jsonl)
1775 .filter_map(|l| serde_json::from_str(l).ok())
1776 .collect();
1777 // PARITY-15: every line that failed to even deserialize as JSON at
1778 // all (never mind whether it then parsed as a recognized
1779 // `SessionEntry` shape) — see `from_claude_code_str`'s identical
1780 // counter.
1781 let parse_error_lines = non_empty_line_count.saturating_sub(lines_v.len());
1782
1783 if let Some(header) = lines_v.first() {
1784 capture_pi_header(header, &mut meta)?;
1785 }
1786
1787 // Every non-header entry that parses as an object carrying an `id`.
1788 // (A line that fails to parse, or a header re-parsed as an entry,
1789 // simply never enters `by_id` — it survives in `raw` only, exactly
1790 // like a malformed/non-conversational line in the other loaders.)
1791 struct PiEntry {
1792 id: String,
1793 parent_id: Option<String>,
1794 value: Value,
1795 }
1796 let mut entries: Vec<PiEntry> = Vec::new();
1797 let mut by_id: HashMap<String, usize> = HashMap::new();
1798 for v in lines_v.iter().skip(1) {
1799 let Some(id) = v.get("id").and_then(Value::as_str) else {
1800 continue;
1801 };
1802 let parent_id = v
1803 .get("parentId")
1804 .and_then(Value::as_str)
1805 .map(str::to_string);
1806 by_id.insert(id.to_string(), entries.len());
1807 entries.push(PiEntry {
1808 id: id.to_string(),
1809 parent_id,
1810 value: v.clone(),
1811 });
1812 }
1813
1814 if entries.is_empty() {
1815 return Ok(Session {
1816 meta,
1817 messages: Vec::new(),
1818 subagents: Vec::new(),
1819 raw,
1820 raw_trailing_newline,
1821 imported_message_count: Some(0),
1822 // Pi is line-oriented: `raw` is split directly out of the
1823 // source text (strict-verbatim, IX-1), even for this
1824 // no-entries early return.
1825 raw_is_verbatim: true,
1826 parse_error_lines,
1827 load_residue: Vec::new(),
1828 });
1829 }
1830
1831 // Leaf = the last entry in file order (pi's own rule, `sm:897`), NOT
1832 // necessarily a `message` entry — a trailing `label`/`session_info`
1833 // still anchors the walk correctly since the walk just follows
1834 // `parentId` regardless of the leaf's own type.
1835 let leaf_idx = entries.len() - 1;
1836 let mut chain_rev: Vec<usize> = Vec::new();
1837 let mut cur: Option<String> = Some(entries[leaf_idx].id.clone());
1838 let mut guard = 0usize;
1839 while let Some(id) = cur {
1840 let Some(&idx) = by_id.get(&id) else { break };
1841 chain_rev.push(idx);
1842 cur = entries[idx].parent_id.clone();
1843 guard += 1;
1844 if guard > entries.len() + 1 {
1845 break; // cycle guard — malformed parentId chain
1846 }
1847 }
1848 chain_rev.reverse();
1849 let active = chain_rev; // indices into `entries`, root..leaf order
1850
1851 let pos_in_active: HashMap<&str, usize> = active
1852 .iter()
1853 .enumerate()
1854 .map(|(pos, &idx)| (entries[idx].id.as_str(), pos))
1855 .collect();
1856
1857 // First pass: compaction discipline (§2.1 S3) — every message from an
1858 // entry before the LATEST `firstKeptEntryId` on the active path is
1859 // excluded from replay (`compacted_out`), mirroring pi's own
1860 // `buildContextEntries` slice (`sm:414-450`).
1861 let mut kept_from_pos = 0usize;
1862 for &idx in &active {
1863 let e = &entries[idx];
1864 if e.value.get("type").and_then(Value::as_str) == Some("compaction") {
1865 if let Some(fk) = e.value.get("firstKeptEntryId").and_then(Value::as_str) {
1866 if let Some(&p) = pos_in_active.get(fk) {
1867 kept_from_pos = kept_from_pos.max(p);
1868 }
1869 }
1870 }
1871 }
1872
1873 let mut messages = Vec::new();
1874 let mut current_model: Option<String> = None;
1875 for (pos, &idx) in active.iter().enumerate() {
1876 let e = &entries[idx];
1877 let v = &e.value;
1878 let entry_ts = v
1879 .get("timestamp")
1880 .and_then(Value::as_str)
1881 .map(str::to_string);
1882 let before = messages.len();
1883 match v.get("type").and_then(Value::as_str) {
1884 Some("message") => {
1885 let msg_v = v.get("message").cloned().unwrap_or(Value::Null);
1886 match msg_v.get("role").and_then(Value::as_str) {
1887 Some("user") => push_pi_user(&msg_v, &mut messages),
1888 Some("assistant") => {
1889 push_pi_assistant(&msg_v, &mut messages);
1890 if let Some(m) = msg_v.get("model").and_then(Value::as_str) {
1891 current_model = Some(m.to_string());
1892 }
1893 }
1894 Some("toolResult") => push_pi_tool_result(&msg_v, &mut messages),
1895 Some("bashExecution") => push_pi_bash(&msg_v, &mut messages),
1896 Some("custom") => push_pi_custom_common(&msg_v, &mut messages),
1897 // OPEN UNION (S6): any other role — raw-only survival.
1898 _ => {}
1899 }
1900 }
1901 Some("custom_message") => push_pi_custom_common(v, &mut messages),
1902 Some("compaction") => push_pi_compaction(v, &mut messages),
1903 Some("branch_summary") => push_pi_branch_summary(v, &mut messages),
1904 Some("model_change") => {
1905 if let Some(m) = v.get("modelId").and_then(Value::as_str) {
1906 current_model = Some(m.to_string());
1907 }
1908 }
1909 Some("session_info") => {
1910 if let Some(name) = v.get("name").and_then(Value::as_str) {
1911 if !name.is_empty() {
1912 meta.lineage
1913 .insert("session_name".to_string(), name.to_string());
1914 }
1915 }
1916 }
1917 // thinking_level_change, custom (entry-level state), label —
1918 // no clean home, raw-only (§2.3).
1919 _ => {}
1920 }
1921 let is_summary = matches!(
1922 v.get("type").and_then(Value::as_str),
1923 Some("compaction") | Some("branch_summary")
1924 );
1925 for m in &mut messages[before..] {
1926 m.metadata.insert("pi_entry_id".to_string(), e.id.clone());
1927 if let Some(p) = &e.parent_id {
1928 m.metadata.insert("pi_parent_id".to_string(), p.clone());
1929 }
1930 if let Some(ts) = &entry_ts {
1931 m.metadata
1932 .entry("timestamp".to_string())
1933 .or_insert_with(|| ts.clone());
1934 }
1935 // WAVE-2 item 1 fallback: the entry-level `timestamp` above
1936 // is pi's authoritative, always-monotonic-in-file-order
1937 // wall-clock (mandatory on every entry) and wins whenever
1938 // present. The nested `message.timestamp` (unix-ms) is only
1939 // reached here — via `entry(...).or_insert_with`, so it
1940 // never overwrites the entry-level value — in the rare case
1941 // an entry lacks its own `timestamp`. This intentionally
1942 // does NOT prefer the msg-level field even though it LOOKS
1943 // more precise: unlike the entry-level timestamp, it is not
1944 // guaranteed monotonic with this loader's root->leaf
1945 // linearization (e.g. a rewound-branch entry can carry an
1946 // earlier msg-level clock reading than its file-order
1947 // neighbors), and OpenCode's own loader re-sorts messages by
1948 // this canonical timestamp — a non-monotonic source would
1949 // silently scramble replay order on a pi->opencode hop.
1950 if let Some(ms) = v
1951 .get("message")
1952 .and_then(|mm| mm.get("timestamp"))
1953 .and_then(Value::as_u64)
1954 {
1955 m.metadata
1956 .entry("timestamp".to_string())
1957 .or_insert_with(|| crate::sidecar::ms_to_rfc3339(ms as i64));
1958 }
1959 // A compaction/branch-summary message IS the retained marker
1960 // — never mark it excluded, regardless of its own position.
1961 if !is_summary && pos < kept_from_pos {
1962 m.metadata
1963 .insert("compacted_out".to_string(), "true".to_string());
1964 }
1965 }
1966 restore_single_grok_message(v, &mut messages[before..]);
1967 for message in &mut messages[before..] {
1968 restore_tool_outcome_extension(v, message);
1969 }
1970 }
1971
1972 meta.model = current_model;
1973 ensure_tool_results_paired(&mut messages);
1974 let imported_message_count = Some(messages.len());
1975 Ok(Session {
1976 meta,
1977 messages,
1978 subagents: Vec::new(),
1979 raw,
1980 raw_trailing_newline,
1981 imported_message_count,
1982 // Pi is line-oriented: `raw` is split directly out of the
1983 // source text (strict-verbatim, IX-1).
1984 raw_is_verbatim: true,
1985 parse_error_lines,
1986 load_residue: Vec::new(),
1987 })
1988 }
1989
1990 /// Load Grok's resumable `chat_history.jsonl` transcript.
1991 ///
1992 /// The surrounding session directory carries the session id, workspace,
1993 /// and `summary.json`; [`Self::from_grok_str`] handles the transcript
1994 /// itself while this path-aware entry point overlays that directory
1995 /// metadata.
1996 pub fn from_grok(path: impl AsRef<Path>) -> Result<Session> {
1997 let path = path.as_ref();
1998 let mut session = Self::from_grok_str(&std::fs::read_to_string(path)?)?;
1999 session.capture_grok_path_metadata(path);
2000 Ok(session)
2001 }
2002
2003 /// Parse Grok's line-oriented `chat_history.jsonl` format.
2004 ///
2005 /// Conversational records are `user`, `assistant`, and `tool_result`.
2006 /// `system` is the regenerated base prompt and is retained in
2007 /// [`SessionMeta::system_prompt`]; encrypted reasoning and backend-only
2008 /// state remain byte-exact in [`Session::raw`] but are intentionally not
2009 /// replayed as chat turns.
2010 pub fn from_grok_str(jsonl: &str) -> Result<Session> {
2011 let mut meta = SessionMeta::new(SessionSource::Grok);
2012 let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
2013 let raw: Vec<String> = raw_lines.iter().map(|line| line.to_string()).collect();
2014 let mut messages = Vec::new();
2015 let mut parse_error_lines = 0usize;
2016 let mut tool_names: HashMap<String, String> = HashMap::new();
2017
2018 for line in non_empty_lines(jsonl) {
2019 let value: Value = match serde_json::from_str(line) {
2020 Ok(value) => value,
2021 Err(_) => {
2022 parse_error_lines += 1;
2023 continue;
2024 }
2025 };
2026 restore_codex_provenance_from_top_level(&value, &mut meta)?;
2027 match value.get("type").and_then(Value::as_str) {
2028 Some("system") => {
2029 if meta.system_prompt.is_none() {
2030 meta.system_prompt = value
2031 .get("content")
2032 .and_then(Value::as_str)
2033 .map(str::to_string);
2034 }
2035 }
2036 Some("user") => {
2037 let content = extract_text_content(value.get("content"));
2038 let role = if value.get("synthetic_reason").and_then(Value::as_str)
2039 == Some("supercode_system_event")
2040 {
2041 Role::System
2042 } else {
2043 Role::User
2044 };
2045 let content = if role == Role::User {
2046 match grok_human_user_text(&content) {
2047 Some(content) => content,
2048 None if value.get(SUPERCODE_GROK_MESSAGE_KEY).is_some() => {
2049 String::new()
2050 }
2051 None => continue,
2052 }
2053 } else {
2054 content
2055 };
2056 let mut message = ChatMessage {
2057 role,
2058 content: Some(content),
2059 content_parts: None,
2060 tool_calls: None,
2061 tool_call_id: None,
2062 name: None,
2063 metadata: Default::default(),
2064 };
2065 capture_grok_scalar_metadata(
2066 &value,
2067 &mut message,
2068 &["prompt_index", "prior_turn_interrupt", "synthetic_reason"],
2069 );
2070 restore_grok_message_extension(&value, &mut message);
2071 messages.push(message);
2072 }
2073 Some("assistant") => {
2074 let calls: Vec<ToolCall> = value
2075 .get("tool_calls")
2076 .and_then(Value::as_array)
2077 .into_iter()
2078 .flatten()
2079 .filter_map(|call| {
2080 let id = call.get("id")?.as_str()?.to_string();
2081 let name = call.get("name")?.as_str()?.to_string();
2082 let arguments = call
2083 .get("arguments")
2084 .map(value_to_arg_string)
2085 .unwrap_or_else(|| "{}".to_string());
2086 tool_names.insert(id.clone(), name.clone());
2087 Some(function_call(&id, &name, arguments))
2088 })
2089 .collect();
2090 let content = value
2091 .get("content")
2092 .and_then(Value::as_str)
2093 .filter(|content| !content.is_empty())
2094 .map(str::to_string);
2095 let mut message = ChatMessage {
2096 role: Role::Assistant,
2097 content,
2098 content_parts: None,
2099 tool_calls: (!calls.is_empty()).then_some(calls),
2100 tool_call_id: None,
2101 name: None,
2102 metadata: Default::default(),
2103 };
2104 capture_grok_scalar_metadata(
2105 &value,
2106 &mut message,
2107 &["model_id", "model_fingerprint", "reasoning_effort"],
2108 );
2109 if let Some(model) = value.get("model_id").and_then(Value::as_str) {
2110 meta.model = Some(model.to_string());
2111 }
2112 restore_grok_message_extension(&value, &mut message);
2113 messages.push(message);
2114 }
2115 Some("tool_result") => {
2116 let id = value
2117 .get("tool_call_id")
2118 .and_then(Value::as_str)
2119 .unwrap_or_default();
2120 let content = value
2121 .get("content")
2122 .map(|value| match value {
2123 Value::String(text) => text.clone(),
2124 other => extract_text_content(Some(other)),
2125 })
2126 .unwrap_or_default();
2127 let mut message = tool_message(id, content);
2128 message.name = tool_names.get(id).cloned();
2129 restore_grok_message_extension(&value, &mut message);
2130 messages.push(message);
2131 }
2132 // `reasoning` contains encrypted chain-of-thought and
2133 // `backend_tool_call` is execution bookkeeping. Both survive
2134 // verbatim in raw without being replayed to another model.
2135 _ => {}
2136 }
2137 }
2138
2139 ensure_tool_results_paired(&mut messages);
2140 let imported_message_count = Some(messages.len());
2141 Ok(Session {
2142 meta,
2143 messages,
2144 subagents: Vec::new(),
2145 raw,
2146 raw_trailing_newline,
2147 imported_message_count,
2148 raw_is_verbatim: true,
2149 parse_error_lines,
2150 load_residue: Vec::new(),
2151 })
2152 }
2153
2154 fn capture_grok_path_metadata(&mut self, transcript: &Path) {
2155 let Some(session_dir) = transcript.parent() else {
2156 return;
2157 };
2158 self.meta.session_id = session_dir
2159 .file_name()
2160 .and_then(|name| name.to_str())
2161 .map(str::to_string);
2162 self.meta.cwd = session_dir
2163 .parent()
2164 .and_then(Path::file_name)
2165 .and_then(|name| name.to_str())
2166 .and_then(percent_decode_path)
2167 .map(PathBuf::from);
2168
2169 let Ok(summary_text) = std::fs::read_to_string(session_dir.join("summary.json")) else {
2170 return;
2171 };
2172 let Ok(summary) = serde_json::from_str::<Value>(&summary_text) else {
2173 return;
2174 };
2175 if let Some(model) = summary.get("current_model_id").and_then(Value::as_str) {
2176 self.meta.model = Some(model.to_string());
2177 }
2178 for (source, target) in [
2179 ("generated_title", "session_name"),
2180 ("created_at", "created_at"),
2181 ("updated_at", "updated_at"),
2182 ("chat_format_version", "grok_chat_format_version"),
2183 ] {
2184 if let Some(value) = summary.get(source) {
2185 self.meta.lineage.insert(
2186 target.to_string(),
2187 value
2188 .as_str()
2189 .map(str::to_string)
2190 .unwrap_or_else(|| value.to_string()),
2191 );
2192 }
2193 }
2194 }
2195
2196 /// Load a Gemini CLI transcript from disk.
2197 pub fn from_gemini(path: impl AsRef<Path>) -> Result<Session> {
2198 Self::from_gemini_str(&std::fs::read_to_string(path.as_ref())?)
2199 }
2200
2201 /// Parse Gemini CLI's line-oriented session format.
2202 ///
2203 /// Gemini stores a header without a `type`, followed by `user` and
2204 /// `gemini` records. Function calls are embedded in assistant content
2205 /// parts and function responses in user content parts. Unknown records
2206 /// remain byte-exact in [`Session::raw`] instead of silently entering the
2207 /// replay conversation.
2208 pub fn from_gemini_str(jsonl: &str) -> Result<Session> {
2209 let mut meta = SessionMeta::new(SessionSource::Gemini);
2210 let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
2211 let raw = raw_lines.iter().map(|line| line.to_string()).collect();
2212 let mut messages = Vec::new();
2213 let mut parse_error_lines = 0usize;
2214 let mut pending_by_name: HashMap<String, Vec<String>> = HashMap::new();
2215
2216 for (line_index, line) in non_empty_lines(jsonl).enumerate() {
2217 let value: Value = match serde_json::from_str(line) {
2218 Ok(value) => value,
2219 Err(_) => {
2220 parse_error_lines += 1;
2221 continue;
2222 }
2223 };
2224 let kind = value.get("type").and_then(Value::as_str);
2225 if kind.is_none() {
2226 if meta.session_id.is_none() {
2227 meta.session_id = value
2228 .get("sessionId")
2229 .and_then(Value::as_str)
2230 .map(str::to_string);
2231 }
2232 for (source, target) in [
2233 ("projectHash", "gemini_project_hash"),
2234 ("startTime", "created_at"),
2235 ("lastUpdated", "updated_at"),
2236 ("kind", "gemini_session_kind"),
2237 ] {
2238 if let Some(raw) = value.get(source) {
2239 meta.lineage.insert(
2240 target.to_string(),
2241 raw.as_str()
2242 .map(str::to_string)
2243 .unwrap_or_else(|| raw.to_string()),
2244 );
2245 }
2246 }
2247 continue;
2248 }
2249 if kind != Some("user") && kind != Some("gemini") {
2250 continue;
2251 }
2252
2253 let timestamp = value.get("timestamp").and_then(Value::as_str);
2254 let model = value.get("model").and_then(Value::as_str);
2255 if let Some(model) = model {
2256 meta.model = Some(model.to_string());
2257 }
2258 let content = value.get("content").unwrap_or(&Value::Null);
2259 let parts = content.as_array();
2260 let text = match content {
2261 Value::String(text) => text.clone(),
2262 Value::Array(parts) => parts
2263 .iter()
2264 .filter_map(|part| part.get("text").and_then(Value::as_str))
2265 .collect::<Vec<_>>()
2266 .join(" ")
2267 .trim()
2268 .to_string(),
2269 _ => String::new(),
2270 };
2271
2272 if kind == Some("gemini") {
2273 let legacy_calls = parts
2274 .into_iter()
2275 .flatten()
2276 .filter_map(|part| part.get("functionCall"));
2277 let native_calls = value
2278 .get("toolCalls")
2279 .and_then(Value::as_array)
2280 .into_iter()
2281 .flatten();
2282 let calls = native_calls
2283 .chain(legacy_calls)
2284 .enumerate()
2285 .filter_map(|(call_index, call)| {
2286 let name = call.get("name")?.as_str()?.to_string();
2287 let id = call
2288 .get("id")
2289 .and_then(Value::as_str)
2290 .map(str::to_string)
2291 .unwrap_or_else(|| format!("gemini-{line_index}-{call_index}"));
2292 pending_by_name
2293 .entry(name.clone())
2294 .or_default()
2295 .push(id.clone());
2296 let arguments = call
2297 .get("args")
2298 .map(value_to_arg_string)
2299 .unwrap_or_else(|| "{}".to_string());
2300 Some(function_call(&id, &name, arguments))
2301 })
2302 .collect::<Vec<_>>();
2303 let mut message = ChatMessage {
2304 role: Role::Assistant,
2305 content: (!text.is_empty()).then_some(text),
2306 content_parts: None,
2307 tool_calls: (!calls.is_empty()).then_some(calls),
2308 tool_call_id: None,
2309 name: None,
2310 metadata: Default::default(),
2311 };
2312 if let Some(timestamp) = timestamp {
2313 message
2314 .metadata
2315 .insert("timestamp".into(), timestamp.into());
2316 }
2317 if let Some(model) = model {
2318 message.metadata.insert("gemini_model".into(), model.into());
2319 }
2320 if let Some(thoughts) = value.get("thoughts").filter(|value| !value.is_null()) {
2321 message
2322 .metadata
2323 .insert("gemini_thoughts".into(), thoughts.to_string());
2324 }
2325 restore_gemini_message_extension(&value, &mut message);
2326 if message.content.is_some() || message.tool_calls.is_some() {
2327 messages.push(message);
2328 }
2329 continue;
2330 }
2331
2332 let mut user_parts = Vec::new();
2333 if let Some(parts) = parts {
2334 for part in parts {
2335 if let Some(response) = part.get("functionResponse") {
2336 push_gemini_user_parts(
2337 &mut messages,
2338 std::mem::take(&mut user_parts),
2339 timestamp,
2340 &value,
2341 );
2342 let name = response
2343 .get("name")
2344 .and_then(Value::as_str)
2345 .unwrap_or("tool")
2346 .to_string();
2347 let explicit_id = response
2348 .get("id")
2349 .and_then(Value::as_str)
2350 .map(str::to_string);
2351 if let Some(id) = explicit_id.as_deref() {
2352 if let Some(ids) = pending_by_name.get_mut(&name) {
2353 if let Some(position) = ids.iter().position(|pending| pending == id)
2354 {
2355 ids.remove(position);
2356 }
2357 }
2358 }
2359 let id = explicit_id
2360 .or_else(|| {
2361 pending_by_name
2362 .get_mut(&name)
2363 .and_then(|ids| (!ids.is_empty()).then(|| ids.remove(0)))
2364 })
2365 .unwrap_or_else(|| format!("gemini-{line_index}-response"));
2366 let output = response
2367 .get("response")
2368 .and_then(|response| response.get("output"))
2369 .map(|output| {
2370 output
2371 .as_str()
2372 .map(str::to_string)
2373 .unwrap_or_else(|| output.to_string())
2374 })
2375 .or_else(|| response.get("response").map(Value::to_string))
2376 .unwrap_or_default();
2377 let mut message = tool_message(&id, output);
2378 message.name = Some(name);
2379 if let Some(timestamp) = timestamp {
2380 message
2381 .metadata
2382 .insert("timestamp".into(), timestamp.into());
2383 }
2384 restore_gemini_message_extension(&value, &mut message);
2385 messages.push(message);
2386 continue;
2387 }
2388 if let Some(text) = part.get("text").and_then(Value::as_str) {
2389 user_parts.push(serde_json::json!({"type": "text", "text": text}));
2390 continue;
2391 }
2392 if let Some(inline) = part.get("inlineData") {
2393 let Some(data) = inline.get("data").and_then(Value::as_str) else {
2394 continue;
2395 };
2396 let media_type = inline
2397 .get("mimeType")
2398 .and_then(Value::as_str)
2399 .unwrap_or("application/octet-stream");
2400 user_parts.push(serde_json::json!({
2401 "type": "image_url",
2402 "image_url": {"url": format!("data:{media_type};base64,{data}")},
2403 }));
2404 }
2405 }
2406 } else if !text.is_empty() {
2407 user_parts.push(serde_json::json!({"type": "text", "text": text}));
2408 }
2409 push_gemini_user_parts(&mut messages, user_parts, timestamp, &value);
2410 }
2411
2412 ensure_tool_results_paired(&mut messages);
2413 let imported_message_count = Some(messages.len());
2414 Ok(Session {
2415 meta,
2416 messages,
2417 subagents: Vec::new(),
2418 raw,
2419 raw_trailing_newline,
2420 imported_message_count,
2421 raw_is_verbatim: true,
2422 parse_error_lines,
2423 load_residue: Vec::new(),
2424 })
2425 }
2426
2427 /// Load a Goose session-export JSON document from disk.
2428 pub fn from_goose(path: impl AsRef<Path>) -> Result<Session> {
2429 Self::from_goose_str(&std::fs::read_to_string(path.as_ref())?)
2430 }
2431
2432 /// Parse Goose's official native import/export document.
2433 ///
2434 /// Goose's durable store is SQLite, but its own
2435 /// `_goose/unstable/session/export` and `/session/import` boundary is one
2436 /// JSON object containing a `conversation` array. Unknown native content
2437 /// blocks are retained on the first canonical message in a namespaced
2438 /// portability envelope; unchanged same-format exports replay the exact
2439 /// source bytes.
2440 pub fn from_goose_str(json: &str) -> Result<Session> {
2441 let document: Value = serde_json::from_str(json).map_err(crate::Error::Decode)?;
2442 let object = document.as_object().ok_or_else(|| {
2443 Error::InvalidSession("Goose session export must be a JSON object".to_string())
2444 })?;
2445 let conversation = object
2446 .get("conversation")
2447 .and_then(Value::as_array)
2448 .ok_or_else(|| {
2449 Error::InvalidSession(
2450 "Goose session export must contain a conversation array".to_string(),
2451 )
2452 })?;
2453
2454 let mut meta = SessionMeta::new(SessionSource::Goose);
2455 meta.session_id = object.get("id").and_then(Value::as_str).map(str::to_string);
2456 meta.cwd = object
2457 .get("working_dir")
2458 .or_else(|| object.get("workingDir"))
2459 .and_then(Value::as_str)
2460 .map(PathBuf::from);
2461 meta.model = object
2462 .get("model_config")
2463 .or_else(|| object.get("modelConfig"))
2464 .and_then(|model| model.get("model_name").or_else(|| model.get("modelName")))
2465 .and_then(Value::as_str)
2466 .map(str::to_string);
2467 for (source, target) in [
2468 ("name", "session_name"),
2469 ("created_at", "created_at"),
2470 ("updated_at", "updated_at"),
2471 ("session_type", "goose_session_type"),
2472 ("goose_mode", "goose_mode"),
2473 ("provider_name", "goose_provider_name"),
2474 ("parent_session_id", "parent_session_id"),
2475 ] {
2476 if let Some(value) = object.get(source) {
2477 meta.lineage.insert(
2478 target.to_string(),
2479 value
2480 .as_str()
2481 .map(str::to_string)
2482 .unwrap_or_else(|| value.to_string()),
2483 );
2484 }
2485 }
2486 let mut header = document.clone();
2487 if let Some(header) = header.as_object_mut() {
2488 header.remove("conversation");
2489 }
2490 meta.goose_header = Some(header.clone());
2491
2492 let mut messages = Vec::new();
2493 for (native_index, native) in conversation.iter().enumerate() {
2494 let before = messages.len();
2495 normalize_goose_message(native, native_index, &mut messages);
2496 if let Some(first) = messages.get_mut(before) {
2497 first
2498 .metadata
2499 .insert("goose_native_message".to_string(), native.to_string());
2500 first
2501 .metadata
2502 .insert("goose_native_index".to_string(), native_index.to_string());
2503 if native_index == 0 {
2504 first
2505 .metadata
2506 .insert("goose_session_header".to_string(), header.to_string());
2507 }
2508 restore_grok_message_extension(native, first);
2509 }
2510 for message in messages.iter_mut().skip(before + 1) {
2511 message
2512 .metadata
2513 .insert("goose_native_index".to_string(), native_index.to_string());
2514 }
2515 }
2516 ensure_tool_results_paired(&mut messages);
2517
2518 let (raw_lines, raw_trailing_newline) = split_lines_verbatim(json);
2519 let raw = raw_lines.iter().map(|line| line.to_string()).collect();
2520 let imported_message_count = Some(messages.len());
2521 Ok(Session {
2522 meta,
2523 messages,
2524 subagents: Vec::new(),
2525 raw,
2526 raw_trailing_newline,
2527 imported_message_count,
2528 raw_is_verbatim: true,
2529 parse_error_lines: 0,
2530 load_residue: Vec::new(),
2531 })
2532 }
2533
2534 /// Load one Goose session directly from its native SQLite store.
2535 ///
2536 /// The selector is Goose's stable `sessions.id`. The reconstructed JSON
2537 /// uses Goose's own public export shape, so the ordinary Goose codec is
2538 /// the single normalization boundary for both files and the live store.
2539 pub fn from_goose_sqlite(db_path: &Path, session_id: &str) -> Result<Session> {
2540 Self::from_goose_sqlite_with_limit(db_path, session_id, None)
2541 }
2542
2543 /// Bounded Goose store read for transcript UI surfaces. The inner query
2544 /// selects only the newest native rows; the outer query restores their
2545 /// chronological order. Export/continue callers deliberately use the
2546 /// unbounded public loader above.
2547 #[doc(hidden)]
2548 pub fn from_goose_sqlite_display(
2549 db_path: &Path,
2550 session_id: &str,
2551 message_limit: usize,
2552 ) -> Result<Session> {
2553 Self::from_goose_sqlite_with_limit(db_path, session_id, Some(message_limit.max(1)))
2554 }
2555
2556 fn from_goose_sqlite_with_limit(
2557 db_path: &Path,
2558 session_id: &str,
2559 message_limit: Option<usize>,
2560 ) -> Result<Session> {
2561 let connection = Connection::open_with_flags(
2562 db_path,
2563 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
2564 )
2565 .map_err(|error| Error::Other(format!("failed to open Goose SQLite store: {error}")))?;
2566 let mut statement = connection
2567 .prepare(
2568 "SELECT id, name, working_dir, created_at, updated_at, session_type, \
2569 extension_data, goose_mode, provider_name, model_config_json \
2570 FROM sessions WHERE id = ?1",
2571 )
2572 .map_err(|error| Error::Other(format!("failed to query Goose sessions: {error}")))?;
2573 let mut document = statement
2574 .query_row([session_id], |row| {
2575 let extension_data: Option<String> = row.get(6)?;
2576 let model_config: Option<String> = row.get(9)?;
2577 Ok(serde_json::json!({
2578 "id": row.get::<_, String>(0)?,
2579 "working_dir": row.get::<_, String>(2)?,
2580 "name": row.get::<_, String>(1)?,
2581 "user_set_name": false,
2582 "session_type": row.get::<_, String>(5)?,
2583 "created_at": row.get::<_, String>(3)?,
2584 "updated_at": row.get::<_, String>(4)?,
2585 "extension_data": extension_data
2586 .as_deref()
2587 .and_then(|value| serde_json::from_str::<Value>(value).ok())
2588 .unwrap_or_else(|| serde_json::json!({})),
2589 "usage": {},
2590 "accumulated_usage": {},
2591 "accumulated_cost": Value::Null,
2592 "schedule_id": Value::Null,
2593 "recipe": Value::Null,
2594 "user_recipe_values": Value::Null,
2595 "conversation": [],
2596 "message_count": 0,
2597 "last_message_at": Value::Null,
2598 "provider_name": row.get::<_, Option<String>>(8)?,
2599 "model_config": model_config
2600 .as_deref()
2601 .and_then(|value| serde_json::from_str::<Value>(value).ok()),
2602 "goose_mode": row.get::<_, String>(7)?,
2603 "archived_at": Value::Null,
2604 "project_id": Value::Null,
2605 "parent_session_id": Value::Null,
2606 "last_message_snippet": Value::Null,
2607 }))
2608 })
2609 .map_err(|error| Error::Other(format!("failed to load Goose session: {error}")))?;
2610
2611 let message_query = message_limit.map_or_else(
2612 || {
2613 "SELECT message_id, role, content_json, created_timestamp, metadata_json \
2614 FROM messages WHERE session_id = ?1 ORDER BY created_timestamp, id"
2615 .to_string()
2616 },
2617 |limit| {
2618 format!(
2619 "SELECT message_id, role, content_json, created_timestamp, metadata_json \
2620 FROM (SELECT id AS native_row_id, message_id, role, content_json, \
2621 created_timestamp, metadata_json \
2622 FROM messages WHERE session_id = ?1 \
2623 ORDER BY created_timestamp DESC, id DESC LIMIT {limit}) \
2624 ORDER BY created_timestamp, native_row_id"
2625 )
2626 },
2627 );
2628 let mut message_statement = connection
2629 .prepare(&message_query)
2630 .map_err(|error| Error::Other(format!("failed to query Goose messages: {error}")))?;
2631 let rows = message_statement
2632 .query_map([session_id], |row| {
2633 let content: String = row.get(2)?;
2634 let metadata: Option<String> = row.get(4)?;
2635 Ok(serde_json::json!({
2636 "id": row.get::<_, Option<String>>(0)?,
2637 "role": row.get::<_, String>(1)?,
2638 "created": row.get::<_, i64>(3)?,
2639 "content": serde_json::from_str::<Value>(&content)
2640 .unwrap_or_else(|_| Value::Array(Vec::new())),
2641 "metadata": metadata
2642 .as_deref()
2643 .and_then(|value| serde_json::from_str::<Value>(value).ok())
2644 .unwrap_or_else(|| serde_json::json!({
2645 "userVisible": true,
2646 "agentVisible": true
2647 })),
2648 }))
2649 })
2650 .map_err(|error| Error::Other(format!("failed to load Goose messages: {error}")))?;
2651 let conversation = rows
2652 .collect::<std::result::Result<Vec<_>, _>>()
2653 .map_err(|error| Error::Other(format!("failed to decode Goose messages: {error}")))?;
2654 document["message_count"] = Value::from(conversation.len());
2655 document["conversation"] = Value::Array(conversation);
2656 let json = serde_json::to_string_pretty(&document).map_err(crate::Error::Decode)?;
2657 let mut session = Self::from_goose_str(&json)?;
2658 // SQLite was reconstructed through values, not captured byte-for-byte.
2659 session.raw_is_verbatim = false;
2660 Ok(session)
2661 }
2662
2663 /// Load an OpenCode session from a file — either read surface, see
2664 /// [`Self::from_opencode_str`].
2665 pub fn from_opencode(path: impl AsRef<Path>) -> Result<Session> {
2666 Self::from_opencode_str(&std::fs::read_to_string(path.as_ref())?)
2667 }
2668
2669 /// Load an OpenCode session from a real SQLite store (PARITY-3): opens
2670 /// `db_path`, resolves `session_id` (explicit, or — when `None` — the
2671 /// most-recently-updated top-level session, see
2672 /// `opencode_sqlite_primary_session_id`) and reconstructs the SAME
2673 /// envelope form [`Self::from_opencode_str`] already parses for the
2674 /// JSON-tree surfaces — so canonicalization (§2.1 mapping, compaction
2675 /// discipline, S1 tool-output masking, …) is shared code, not
2676 /// reimplemented here. See `docs/interop/opencode-pi-spec.md` §1.2/S9c
2677 /// for the envelope-construction rules this follows (all-columns rule,
2678 /// raw `revert` column carried verbatim).
2679 pub fn from_opencode_sqlite(db_path: &Path, session_id: Option<&str>) -> Result<Session> {
2680 let conn = opencode_sqlite_open(db_path)?;
2681 let id = match session_id {
2682 Some(id) => id.to_string(),
2683 None => opencode_sqlite_primary_session_id(&conn)?,
2684 };
2685 let lines = opencode_sqlite_session_envelope_lines(&conn, db_path, &id)?;
2686 let mut text = lines.join("\n");
2687 text.push('\n');
2688 let mut session = Self::from_opencode_str(&text)?;
2689 // D4: `text` above is a SYNTHESIZED reconstruction from SQL rows —
2690 // not the original source bytes (a binary `.db` file has no
2691 // "verbatim" line-oriented form to begin with). `from_opencode_str`
2692 // defaults `raw_is_verbatim` to `true` because for its OTHER two
2693 // callers (an actual envelope-form file's own text, an actual
2694 // export-document's text) that really is the source. It is NEVER
2695 // true for this diagonal — mirrors the export-document fix just
2696 // above for the same reason (`from_opencode_export_doc`, `false`).
2697 // `convert opencode.db --to opencode` must not claim byte-identical.
2698 session.raw_is_verbatim = false;
2699 Ok(session)
2700 }
2701
2702 /// Parse an OpenCode session from either of its two frozen **read
2703 /// surfaces** (`docs/interop/opencode-pi-spec.md` §1.2, `S9a`,
2704 /// `opencode-fields.md`):
2705 ///
2706 /// - the **envelope form**: each line is
2707 /// `{"key":[<storage key path>],"value":<record>}`, minified — the
2708 /// synthesized raw-capture unit for the JSON-tree/SQLite storage
2709 /// generations;
2710 /// - the **export-document form**: a single pretty-printed JSON document
2711 /// `{info: SessionInfo, messages:[{info: Message, parts:[Part]}, …]}`
2712 /// — the `opencode export`/`import` interchange shape, and EXACTLY
2713 /// what the OpenCode writer emits.
2714 ///
2715 /// Both forms are parsed into the same `(session_info, side_records,
2716 /// Vec<OcMsg>)` shape and funnel through the SAME shared canonicalizer,
2717 /// `opencode_session_from_records` — so the same underlying records
2718 /// produce identical `messages` regardless of which surface carried
2719 /// them in. This is what makes `load(to_opencode_jsonl(S))` round-trip
2720 /// (§4.1's OpenCode diagonal, the exact circuit the fidelity matrix
2721 /// exercises): previously this function parsed the envelope form only
2722 /// and silently returned an empty-but-`Ok` `Session` for an export
2723 /// document — the confirmed footgun this now closes.
2724 ///
2725 /// Record classification (envelope form) is driven by the envelope
2726 /// `key`'s first component (`"session"` / `"message"` / `"part"` /
2727 /// `"session_diff"` / `"todo"`) — the frozen key scheme for all three
2728 /// storage generations plus SQLite rows (§1.2 S9c: a SQLite row's
2729 /// envelope synthesizes `key:["<table>","<ses>",...ids]` and MUST carry
2730 /// every column, `data` and non-`data` alike — e.g. the `session` row's
2731 /// `revert` column under the V2 `Revert.State` schema, whose extra
2732 /// `files` field the CLI's own row→V1 reconstruction drops; the
2733 /// envelope's `raw` capture keeps that raw column value regardless of
2734 /// what this loader's canonicalization understands).
2735 ///
2736 /// Mapping to canonical `messages` (§2.1, shared by both forms via
2737 /// `push_opencode_user`/`push_opencode_assistant`): `User`/`Assistant`
2738 /// text parts → `content`; a `User` `file` part whose `mime` is an image
2739 /// and whose `url` is a `data:` URI → an `image_url` `content_parts`
2740 /// entry; an `Assistant` `tool` part's `callID` + `state.*.input` → a
2741 /// `ToolCall`, and the SAME part's `state.completed.output` /
2742 /// `state.error.error` → a paired `Tool` message split by `callID`
2743 /// (opencode keeps call+result on one record; this loader splits it
2744 /// into the two OpenAI-shape messages the other loaders already
2745 /// produce).
2746 ///
2747 /// **S1 (`time.compacted`):** when a `tool` part's
2748 /// `state.completed.time.compacted` is set, the emitted `Tool`
2749 /// message's `content` is the placeholder
2750 /// [`OPENCODE_COMPACTED_TOOL_PLACEHOLDER`] — mirroring what opencode's
2751 /// own `toModelMessage` replays — while the REAL output survives in
2752 /// `raw` (always) and in `metadata["oc_tool_output_compacted"]` (full
2753 /// text) + `metadata["oc_tool_time_compacted"]` (the mask timestamp), so
2754 /// it is reversible, never actually lost.
2755 ///
2756 /// **Compaction boundary:** a `compaction` part's `tail_start_id` marks
2757 /// every message strictly before that message id
2758 /// `metadata["compacted_out"]="true"` (honored uniformly by
2759 /// `is_replay_excluded`) — except a `summary:true` `Assistant`
2760 /// message, which opencode itself hoists in FRONT of the retained tail
2761 /// on replay (`message-v2.ts:521-572`) and so must never be excluded
2762 /// regardless of its position, mirroring pi's identical exemption for
2763 /// its own compaction/branch-summary entries.
2764 ///
2765 /// **Unknown part `type` or unknown `tool.state.status`:** never
2766 /// canonicalized — raw-only survival, exactly like an unmodeled Pi
2767 /// `message.role` (S6-style fail-loud). The OpenCode corpus audit
2768 /// is what turns that into a visible coverage failure rather than a
2769 /// silent drop.
2770 ///
2771 /// **Export-document `raw`:** an export document is a single
2772 /// pretty-printed JSON value with no per-line envelope structure of its
2773 /// own to capture verbatim, so `raw` here is RE-SYNTHESIZED — one
2774 /// envelope line per `session`/`message`/`part` record found in the
2775 /// document, in the exact `{"key":[...],"value":...}` shape the native
2776 /// envelope form uses — so every native/T1-value-tier path
2777 /// (`to_native_jsonl`, `opencode_records_from_raw`, the
2778 /// splice/direct-write writers) stays consistent regardless of which
2779 /// read surface produced this `Session`.
2780 ///
2781 /// **Malformed input:** input that reaches this function non-empty but
2782 /// yields zero session/message/part records under EITHER form returns a
2783 /// clear `Err` rather than a silently-empty `Ok(Session)` — the
2784 /// confirmed footgun (`supercode resume`/`convert`/`inspect` on such
2785 /// input must not silently succeed with an empty session). A
2786 /// legitimately-empty session — a real `session` record with zero
2787 /// messages, or a valid export document with an empty `messages` array
2788 /// — is not an error.
2789 pub fn from_opencode_str(text: &str) -> Result<Session> {
2790 let trimmed = text.trim();
2791
2792 // Export-document form first (§1.2/S9a) — mirrors `detect_source`'s
2793 // own precedence: try the whole-text parse before the per-line
2794 // envelope loop below, since a pretty-printed multi-line document
2795 // has no individually-valid-JSON lines for that loop to match.
2796 if let Ok(doc) = serde_json::from_str::<Value>(trimmed) {
2797 if doc.get("info").is_some() && doc.get("messages").and_then(Value::as_array).is_some()
2798 {
2799 return Self::from_opencode_export_doc(&doc);
2800 }
2801 }
2802
2803 // Envelope form: each line `{"key":[<storage key path>],"value":<record>}`.
2804 // IX-1: `raw` is captured STRICT-VERBATIM — separate from the
2805 // blank-skipping PARSE walk just below, which keeps skipping
2806 // blank/whitespace-only lines when it looks for envelope records.
2807 let (raw_lines, raw_trailing_newline) = split_lines_verbatim(text);
2808 let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
2809 let mut session_info: Option<Value> = None;
2810 let mut side_records: Vec<Value> = Vec::new();
2811 let mut msgs: Vec<OcMsg> = Vec::new();
2812 let mut msg_index: HashMap<String, usize> = HashMap::new();
2813 // PARITY-15: see `from_claude_code_str`'s identical counter — only
2814 // a genuinely malformed line (fails to deserialize as JSON at all),
2815 // not a well-formed envelope this loader simply doesn't recognize.
2816 let mut parse_error_lines = 0usize;
2817
2818 for line in non_empty_lines(text) {
2819 let Ok(env) = serde_json::from_str::<Value>(line) else {
2820 parse_error_lines += 1;
2821 continue; // malformed line — raw-only, exactly like the other loaders
2822 };
2823 let Some(key) = env.get("key").and_then(Value::as_array) else {
2824 continue; // not an envelope record — raw-only
2825 };
2826 let value = env.get("value").cloned().unwrap_or(Value::Null);
2827 match key.first().and_then(Value::as_str) {
2828 Some("session") => session_info = Some(value),
2829 Some("message") => {
2830 let Some(id) = value.get("id").and_then(Value::as_str) else {
2831 continue;
2832 };
2833 let time_created = value
2834 .get("time")
2835 .and_then(|t| t.get("created"))
2836 .and_then(Value::as_i64)
2837 .unwrap_or(0);
2838 msg_index.insert(id.to_string(), msgs.len());
2839 msgs.push(OcMsg {
2840 id: id.to_string(),
2841 time_created,
2842 value,
2843 parts: Vec::new(),
2844 });
2845 }
2846 Some("part") => {
2847 if let Some(msg_id) = value.get("messageID").and_then(Value::as_str) {
2848 if let Some(&idx) = msg_index.get(msg_id) {
2849 msgs[idx].parts.push(value);
2850 }
2851 // A part whose message wasn't captured (out-of-order
2852 // envelope) — still fully present in `raw`, just not
2853 // attached to a canonical message.
2854 }
2855 }
2856 Some("session_diff") | Some("todo") => {
2857 side_records.push(serde_json::json!({"key": key, "value": value}));
2858 }
2859 _ => {} // unrecognized top-level key — raw-only
2860 }
2861 }
2862
2863 opencode_guard_against_silent_empty(
2864 !trimmed.is_empty(),
2865 &session_info,
2866 &msgs,
2867 &side_records,
2868 )?;
2869 opencode_session_from_records(
2870 session_info,
2871 side_records,
2872 msgs,
2873 raw,
2874 raw_trailing_newline,
2875 // Envelope form: `raw` is split directly out of the source text
2876 // (strict-verbatim, IX-1) — genuinely reproduces the original
2877 // bytes on replay.
2878 true,
2879 parse_error_lines,
2880 )
2881 }
2882
2883 /// The **export-document** read surface of [`Self::from_opencode_str`]
2884 /// — see that function's doc comment for the shared canonicalization
2885 /// and the `raw` re-synthesis this performs. `doc` is already known to
2886 /// have the `{info, messages:[...]}` shape (the caller checks this,
2887 /// matching `detect_source`'s own S9a check) before calling this.
2888 fn from_opencode_export_doc(doc: &Value) -> Result<Session> {
2889 let session_info = doc.get("info").cloned().filter(|v| !v.is_null());
2890 let messages_arr = doc
2891 .get("messages")
2892 .and_then(Value::as_array)
2893 .cloned()
2894 .unwrap_or_default();
2895
2896 let session_id = session_info
2897 .as_ref()
2898 .and_then(|si| si.get("id"))
2899 .and_then(Value::as_str)
2900 .unwrap_or("ses_unknown")
2901 .to_string();
2902 let project_id = session_info
2903 .as_ref()
2904 .and_then(|si| si.get("projectID"))
2905 .and_then(Value::as_str)
2906 .unwrap_or("global")
2907 .to_string();
2908
2909 // Re-synthesize one envelope line per record — see the doc comment
2910 // on `from_opencode_str` ("Export-document `raw`").
2911 let mut raw: Vec<String> = Vec::new();
2912 if let Some(si) = &session_info {
2913 raw.push(
2914 serde_json::json!({"key": ["session", project_id, session_id], "value": si})
2915 .to_string(),
2916 );
2917 }
2918
2919 let mut msgs: Vec<OcMsg> = Vec::new();
2920 for entry in &messages_arr {
2921 let Some(info) = entry.get("info") else {
2922 continue; // malformed message entry — no clean home, raw-only
2923 };
2924 let Some(id) = info.get("id").and_then(Value::as_str) else {
2925 continue;
2926 };
2927 let time_created = info
2928 .get("time")
2929 .and_then(|t| t.get("created"))
2930 .and_then(Value::as_i64)
2931 .unwrap_or(0);
2932 let parts: Vec<Value> = entry
2933 .get("parts")
2934 .and_then(Value::as_array)
2935 .cloned()
2936 .unwrap_or_default();
2937
2938 raw.push(
2939 serde_json::json!({"key": ["message", session_id, id], "value": info}).to_string(),
2940 );
2941 for p in &parts {
2942 let part_id = p.get("id").and_then(Value::as_str).unwrap_or("");
2943 raw.push(serde_json::json!({"key": ["part", id, part_id], "value": p}).to_string());
2944 }
2945
2946 msgs.push(OcMsg {
2947 id: id.to_string(),
2948 time_created,
2949 value: info.clone(),
2950 parts,
2951 });
2952 }
2953
2954 opencode_guard_against_silent_empty(true, &session_info, &msgs, &[])?;
2955 opencode_session_from_records(
2956 session_info,
2957 Vec::new(),
2958 msgs,
2959 raw,
2960 // Synthesized `raw` (§1.2's "Export-document raw" — one envelope
2961 // line re-derived per record, no real per-line source bytes to
2962 // measure) — matches the historical always-newline-terminated
2963 // behavior; see `Session::raw_trailing_newline`'s doc comment.
2964 true,
2965 // Export-document form: `raw` above is RE-SYNTHESIZED, one
2966 // envelope line derived per record — not the original document's
2967 // bytes (see this function's doc comment). `convert`'s
2968 // byte-identical claim must not fire on this diagonal.
2969 false,
2970 // PARITY-15: a pretty-printed export document is parsed WHOLE
2971 // (the caller's `if let Ok(doc) = serde_json::from_str(...)`) —
2972 // there's no per-line parse-loss concept here; a malformed
2973 // document fails that top-level parse and never reaches this
2974 // function at all.
2975 0,
2976 )
2977 }
2978
2979 /// P5-5 (design §2 module 21 `session.tree`, §2.1 D-6 "session.tree →
2980 /// core.session(tree-addressable transcript)"): materialize this
2981 /// session's linear [`Self::messages`] into a native in-place
2982 /// [`crate::session_tree::SessionTree`] — the bridge a caller uses the
2983 /// FIRST time it wants to run a tree operation (rewind/branch/label)
2984 /// against an otherwise-linear [`Session`]. `created_at_ms` stamps every
2985 /// synthesized node (see
2986 /// [`crate::session_tree::SessionTree::from_linear`]'s doc comment for
2987 /// why a single timestamp is used: the source linear messages carry no
2988 /// per-turn timestamp of their own here).
2989 ///
2990 /// This does not mutate `self` or persist anything — see
2991 /// the composition layer's session-store tree writer for persistence, and
2992 /// [`Self::apply_session_tree`] for the inverse bridge.
2993 pub fn to_session_tree(&self, created_at_ms: i64) -> crate::session_tree::SessionTree {
2994 crate::session_tree::SessionTree::from_linear(&self.messages, created_at_ms)
2995 }
2996
2997 /// P5-5: the inverse of [`Self::to_session_tree`] — overwrite
2998 /// [`Self::messages`] with `tree`'s ACTIVE branch's linear projection
2999 /// (C7's "tree-with-linear-projection": this is exactly what keeps every
3000 /// existing linear consumer — the agent loop, exporters — working
3001 /// unchanged after a tree operation runs). Nothing else on `self`
3002 /// (`meta`, `raw`, ...) is touched.
3003 ///
3004 /// **Fail-closed.** Propagates [`crate::session_tree::SessionTree::linear_projection`]'s
3005 /// `Err` rather than applying anything — a structurally-corrupt tree
3006 /// (a cycle, a dangling leaf, an active branch pointing at nothing) must
3007 /// error, not silently overwrite [`Self::messages`] with an empty `Vec`.
3008 /// `self` is left untouched on `Err` (the assignment only happens after
3009 /// the projection has already succeeded).
3010 pub fn apply_session_tree(&mut self, tree: &crate::session_tree::SessionTree) -> Result<()> {
3011 self.messages = tree.linear_projection()?;
3012 Ok(())
3013 }
3014}
3015
3016/// One opencode `message` record plus its `part` children, gathered from
3017/// EITHER read surface (envelope-form records or export-document
3018/// `{info, parts}` entries) before the shared per-record canonicalization
3019/// in [`opencode_session_from_records`].
3020struct OcMsg {
3021 id: String,
3022 time_created: i64,
3023 value: Value,
3024 parts: Vec<Value>,
3025}
3026
3027const OPENCODE_SUPERCODE_MESSAGE_POSITION: &str = "_supercode_message_position";
3028const OPENCODE_SUPERCODE_RESULT_POSITION: &str = "_supercode_result_position";
3029const OPENCODE_INTERNAL_ORIGINAL_POSITION: &str = "__supercode_original_position";
3030
3031/// Guard against the confirmed footgun: input that reached
3032/// [`Session::from_opencode_str`] non-empty but produced no
3033/// session/message/part record under either read surface returns `Err`
3034/// instead of a silently-empty `Ok(Session)`. A legitimately-empty session
3035/// (a real session record with zero messages, or a valid empty `messages`
3036/// array) is not an error — only genuinely unparseable content is.
3037fn opencode_guard_against_silent_empty(
3038 non_empty_input: bool,
3039 session_info: &Option<Value>,
3040 msgs: &[OcMsg],
3041 side_records: &[Value],
3042) -> Result<()> {
3043 let has_any_record = session_info.as_ref().is_some_and(|v| !v.is_null())
3044 || !msgs.is_empty()
3045 || !side_records.is_empty();
3046 if non_empty_input && !has_any_record {
3047 return Err(crate::Error::Other(
3048 "opencode input was recognized as an OpenCode source (envelope or \
3049 export-document form) but no session/message/part record could be parsed from \
3050 it — refusing to silently return an empty session"
3051 .to_string(),
3052 ));
3053 }
3054 Ok(())
3055}
3056
3057/// The shared per-record canonicalization for BOTH of
3058/// [`Session::from_opencode_str`]'s read surfaces (envelope form and
3059/// export-document form): frozen ordering, `SessionMeta` capture, the
3060/// compaction boundary pass, and the `User`/`Assistant` → `messages`
3061/// mapping via [`push_opencode_user`]/[`push_opencode_assistant`]. Fed the
3062/// same underlying `(session_info, side_records, msgs)` regardless of which
3063/// surface produced them, this produces byte-for-byte identical `messages`
3064/// — the invariant that makes `load(to_opencode_jsonl(S))` round-trip.
3065fn opencode_session_from_records(
3066 session_info: Option<Value>,
3067 side_records: Vec<Value>,
3068 mut msgs: Vec<OcMsg>,
3069 raw: Vec<String>,
3070 raw_trailing_newline: bool,
3071 raw_is_verbatim: bool,
3072 parse_error_lines: usize,
3073) -> Result<Session> {
3074 let mut meta = SessionMeta::new(SessionSource::OpenCode);
3075
3076 // `msg_index` is captured BEFORE the frozen-order sort below, mapping
3077 // each message id to its PRE-sort position — used only to resolve a
3078 // `tail_start_id` reference in the compaction-boundary pass further
3079 // down. In every real opencode session (either surface) records
3080 // already arrive/are listed in creation order, so pre- and post-sort
3081 // positions coincide; this mirrors the original envelope-only
3082 // implementation's behavior exactly (not a new invariant introduced by
3083 // sharing this code across both surfaces).
3084 let msg_index: HashMap<String, usize> = msgs
3085 .iter()
3086 .enumerate()
3087 .map(|(i, m)| (m.id.clone(), i))
3088 .collect();
3089
3090 // Frozen order (§1.2): messages by (time.created, id); each
3091 // message's parts by id.
3092 msgs.sort_by(|a, b| {
3093 a.time_created
3094 .cmp(&b.time_created)
3095 .then_with(|| a.id.cmp(&b.id))
3096 });
3097 for m in &mut msgs {
3098 m.parts.sort_by(|a, b| {
3099 let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
3100 let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
3101 ai.cmp(bi)
3102 });
3103 }
3104
3105 meta.opencode_headers
3106 .push(session_info.clone().unwrap_or(Value::Null));
3107 meta.opencode_headers.extend(side_records);
3108 if let Some(si) = &session_info {
3109 capture_opencode_session_info(si, &mut meta)?;
3110 }
3111
3112 // Compaction boundary (§2.1/§2.2 S3): the LATEST `tail_start_id`
3113 // seen — mirrors pi's `kept_from_pos` discipline (there is only one
3114 // active path in opencode's own linear message list, so no branch
3115 // walk is needed the way pi's tree requires).
3116 let mut tail_start_pos: Option<usize> = None;
3117 for m in &msgs {
3118 for p in &m.parts {
3119 if p.get("type").and_then(Value::as_str) == Some("compaction") {
3120 if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
3121 if let Some(&tp) = msg_index.get(t) {
3122 tail_start_pos = Some(tail_start_pos.map_or(tp, |cur| cur.max(tp)));
3123 }
3124 }
3125 }
3126 }
3127 }
3128
3129 let mut messages = Vec::new();
3130 let mut first_system_seen = false;
3131 for (pos, m) in msgs.iter().enumerate() {
3132 let before = messages.len();
3133 match m.value.get("role").and_then(Value::as_str) {
3134 // B4: a `User` message that's actually
3135 // `append_synthesized_opencode_messages`'s own re-materialized
3136 // Claude `system` record (one `synthetic: true` text part
3137 // carrying the supercode marker key — see
3138 // `opencode_claude_system_subtype`'s doc comment) restores
3139 // `Role::System`, not a genuine user turn.
3140 Some("user") => match opencode_claude_system_subtype(&m.parts) {
3141 Some(subtype) => {
3142 push_opencode_claude_system(&m.value, &m.parts, subtype, &mut messages)
3143 }
3144 None => push_opencode_user(
3145 &m.value,
3146 &m.parts,
3147 &mut messages,
3148 &mut meta,
3149 &mut first_system_seen,
3150 ),
3151 },
3152 Some("assistant") => {
3153 push_opencode_assistant(&m.value, &m.parts, &mut messages, &mut meta)
3154 }
3155 // Unrecognized/missing role — raw-only survival;
3156 // `audit::Corpus::OpenCode` scores this as Unmodeled.
3157 _ => {}
3158 }
3159 if let Some(original_position) = m
3160 .value
3161 .get(OPENCODE_SUPERCODE_MESSAGE_POSITION)
3162 .and_then(Value::as_u64)
3163 {
3164 if let Some(message) = messages[before..]
3165 .iter_mut()
3166 .find(|message| message.role != Role::Tool)
3167 {
3168 message.metadata.insert(
3169 OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
3170 original_position.to_string(),
3171 );
3172 }
3173 }
3174 for msg in &mut messages[before..] {
3175 let is_summary = msg.metadata.get("is_summary").map(String::as_str) == Some("true");
3176 if !is_summary {
3177 if let Some(tsp) = tail_start_pos {
3178 if pos < tsp {
3179 msg.metadata
3180 .insert("compacted_out".to_string(), "true".to_string());
3181 }
3182 }
3183 }
3184 }
3185 }
3186
3187 let marked_slots = messages
3188 .iter()
3189 .enumerate()
3190 .filter_map(|(index, message)| {
3191 message
3192 .metadata
3193 .contains_key(OPENCODE_INTERNAL_ORIGINAL_POSITION)
3194 .then_some(index)
3195 })
3196 .collect::<Vec<_>>();
3197 if !marked_slots.is_empty() {
3198 // A spliced OpenCode export can contain an unmarked native prefix
3199 // followed by a marked synthesized tail. Reorder only among the
3200 // marked slots so the tail never jumps in front of its raw prefix.
3201 let mut marked_messages = marked_slots
3202 .iter()
3203 .map(|index| messages[*index].clone())
3204 .collect::<Vec<_>>();
3205 marked_messages.sort_by_key(|message| {
3206 message
3207 .metadata
3208 .get(OPENCODE_INTERNAL_ORIGINAL_POSITION)
3209 .and_then(|position| position.parse::<usize>().ok())
3210 .unwrap_or(usize::MAX)
3211 });
3212 for (slot, message) in marked_slots.into_iter().zip(marked_messages) {
3213 messages[slot] = message;
3214 }
3215 for message in &mut messages {
3216 message.metadata.remove(OPENCODE_INTERNAL_ORIGINAL_POSITION);
3217 }
3218 }
3219 ensure_tool_results_paired(&mut messages);
3220 let imported_message_count = Some(messages.len());
3221 Ok(Session {
3222 meta,
3223 messages,
3224 subagents: Vec::new(),
3225 raw,
3226 raw_trailing_newline,
3227 imported_message_count,
3228 raw_is_verbatim,
3229 parse_error_lines,
3230 load_residue: Vec::new(),
3231 })
3232}
3233
3234/// Resolve each opencode subagent (`task`) child session's
3235/// `meta.parent_tool_use_id` from its parent's own `task` tool part
3236/// `callID` (`docs/interop/opencode-pi-spec.md` §2.1 — the analogue of
3237/// Claude Code's `agentId`/`parent_tool_use_id` linkage,
3238/// `opencode-fields.md` `task.ts:145,171-176`).
3239///
3240/// Nesting itself needs no opencode-specific pass:
3241/// `capture_opencode_session_info` already mirrors `SessionInfo.parentID`
3242/// into `lineage["parent_thread_id"]` (the same key Codex subagents use),
3243/// so the existing generic [`Session::reconstruct_tree`] nests these
3244/// sessions correctly on its own. Call this FIRST — it only reads
3245/// `session_id`/`messages`/metadata, never reorders `sessions` — then feed
3246/// the same `Vec` to `reconstruct_tree`.
3247pub fn resolve_opencode_parent_tool_use_ids(sessions: &mut [Session]) {
3248 let ids: Vec<Option<String>> = sessions.iter().map(|s| s.meta.session_id.clone()).collect();
3249 for i in 0..sessions.len() {
3250 let child_id = sessions[i].meta.session_id.clone();
3251 let parent_id = sessions[i].meta.lineage.get("parent_session_id").cloned();
3252 let (Some(child_id), Some(parent_id)) = (child_id, parent_id) else {
3253 continue;
3254 };
3255 let Some(parent_idx) = ids
3256 .iter()
3257 .position(|id| id.as_deref() == Some(parent_id.as_str()))
3258 else {
3259 continue;
3260 };
3261 for m in &sessions[parent_idx].messages {
3262 for (k, v) in &m.metadata {
3263 if let Some(call_id) = k.strip_prefix("oc_task_child_session_id__") {
3264 if v == &child_id {
3265 sessions[i].meta.parent_tool_use_id = Some(call_id.to_string());
3266 }
3267 }
3268 }
3269 }
3270 }
3271}
3272
3273/// D7: repair tool-result/tool-call ORDERING for the Claude Code loader.
3274///
3275/// Real Claude Code transcripts are well-formed by `uuid`/`parentUuid` tree
3276/// structure but are NOT guaranteed to be well-formed in raw file order: async
3277/// tool-result delivery (or an edited turn) can write a `tool_result` `user`
3278/// line BEFORE the assistant `tool_use` line that owns it, even though the
3279/// parent/child tree itself is fine. The active-branch projection restores
3280/// parent-before-child order, but a result can still trail a later assistant
3281/// turn instead of sitting next to the assistant that owns it. Both OpenAI-
3282/// and Anthropic-shaped wire APIs reject either shape on `resume`. Measured
3283/// on a real corpus: 21/450 sessions (4.7%), 89 inverted pairs.
3284///
3285/// This reorders `messages` so every OWNED `Role::Tool` result (its
3286/// `tool_call_id` matches a `tool_calls` entry on some `Role::Assistant`
3287/// message anywhere in the list) sits immediately after the `Role::Assistant`
3288/// message that owns it, while leaving every other message's relative order
3289/// untouched. Orphan tool results — no matching call anywhere in the list —
3290/// are left in their ORIGINAL position, untouched; they are never moved. It
3291/// is a pure reorder: same message count, same multiset of messages, in/out.
3292///
3293/// Tool-call ids (`toolu_*`) are globally unique within a Claude Code session
3294/// — each appears exactly once as a call and once as its result — so a
3295/// simple id -> owning-assistant map is sufficient; no special-casing is
3296/// needed for sidechain (`isSidechain`) messages, since `push_claude_*`
3297/// already pushes those inline with their own distinct ids.
3298///
3299/// Results whose matching call is missing entirely (no owner found) are left
3300/// in place untouched — `ensure_tool_results_paired` (which runs right after
3301/// this) is responsible for synthesizing a placeholder result for any call
3302/// that ends up unanswered; this pass never drops or fabricates anything.
3303fn reorder_tool_results_after_calls(messages: &mut Vec<ChatMessage>) {
3304 // 0. First pass: which tool_call_ids are actually "owned" — emitted by
3305 // some assistant message anywhere in the list — and the position of
3306 // that owning assistant. Owned as `String` (not borrowed) so this map
3307 // can outlive the later `messages.drain(..)`.
3308 let mut owner_positions: HashMap<String, usize> = HashMap::new();
3309 for (index, m) in messages.iter().enumerate() {
3310 if m.role == Role::Assistant {
3311 for c in m.tool_calls() {
3312 if !c.id.is_empty() {
3313 owner_positions.entry(c.id.clone()).or_insert(index);
3314 }
3315 }
3316 }
3317 }
3318
3319 // Fast, cheap detection of "nothing to do": every owned result must be
3320 // in the contiguous tool-result block immediately following its owning
3321 // assistant. Checking only result-before-owner inversions is insufficient
3322 // after Claude's active-branch projection: that projection can put the
3323 // owner first while leaving its result behind a later assistant turn.
3324 // Mere orphans never set this flag. A canonical session returns with
3325 // `messages` byte-for-byte unchanged, mirroring
3326 // `ensure_tool_results_paired`'s own no-op guard.
3327 let mut contiguous_owner = None;
3328 let needs_reorder =
3329 messages
3330 .iter()
3331 .enumerate()
3332 .any(|(message_index, message)| match message.role {
3333 Role::Assistant => {
3334 contiguous_owner = Some(message_index);
3335 false
3336 }
3337 Role::Tool => match message
3338 .tool_call_id
3339 .as_deref()
3340 .and_then(|id| owner_positions.get(id))
3341 .copied()
3342 {
3343 Some(owner) => Some(owner) != contiguous_owner,
3344 None => {
3345 // An orphan or unlinked tool message interrupts the
3346 // owner's contiguous result block but never moves by
3347 // itself.
3348 contiguous_owner = None;
3349 false
3350 }
3351 },
3352 _ => {
3353 contiguous_owner = None;
3354 false
3355 }
3356 });
3357 if !needs_reorder {
3358 return;
3359 }
3360
3361 // 1. Second pass: route messages into the "spine" (everything that stays
3362 // at its own position — non-tool messages AND orphan tool results)
3363 // versus owned tool results (pulled out, to be reattached right after
3364 // their owner). Record, for each spine index that's an assistant, the
3365 // set of tool_call_ids it owns.
3366 let mut spine: Vec<ChatMessage> = Vec::with_capacity(messages.len());
3367 let mut call_owner: HashMap<String, usize> = HashMap::new();
3368 // Buffer of (original_position, message) for every OWNED tool result,
3369 // built alongside the spine; a result can reference a call emitted later
3370 // in file order, so owner spine-index is resolved in a later step once
3371 // `call_owner` is complete.
3372 let mut owned_results: Vec<(usize, ChatMessage)> = Vec::new();
3373
3374 let drained: Vec<ChatMessage> = std::mem::take(messages);
3375 for (orig_pos, msg) in drained.into_iter().enumerate() {
3376 if msg.role == Role::Tool {
3377 let is_owned = msg
3378 .tool_call_id
3379 .as_deref()
3380 .map(|id| !id.is_empty() && owner_positions.contains_key(id))
3381 .unwrap_or(false);
3382 if is_owned {
3383 owned_results.push((orig_pos, msg));
3384 continue;
3385 }
3386 // Orphan: no matching call anywhere. Treat exactly like a
3387 // non-tool message for placement — it joins the spine at its
3388 // current position and is never moved.
3389 spine.push(msg);
3390 continue;
3391 }
3392 if msg.role == Role::Assistant {
3393 let spine_idx = spine.len();
3394 for c in msg.tool_calls() {
3395 if !c.id.is_empty() {
3396 call_owner.entry(c.id.clone()).or_insert(spine_idx);
3397 }
3398 }
3399 }
3400 spine.push(msg);
3401 }
3402
3403 // 2. Resolve each owned result's owner spine-index now that `call_owner`
3404 // is complete, then bucket results by owner spine-index. Every result
3405 // here was routed as "owned" because its id was found in `owned_ids`,
3406 // which was built from the exact same `tool_calls()` scan that
3407 // populates `call_owner` below, so the lookup is guaranteed to hit.
3408 let mut buckets: HashMap<usize, Vec<(usize, ChatMessage)>> = HashMap::new();
3409 for (orig_pos, msg) in owned_results.into_iter() {
3410 let id = msg
3411 .tool_call_id
3412 .as_deref()
3413 .filter(|id| !id.is_empty())
3414 .expect("routed as owned, so tool_call_id must be a non-empty owned id");
3415 let idx = *call_owner
3416 .get(id)
3417 .expect("owned id must have an owning assistant in call_owner");
3418 buckets.entry(idx).or_default().push((orig_pos, msg));
3419 }
3420 // Keep each bucket's results in their original relative file order.
3421 for v in buckets.values_mut() {
3422 v.sort_by_key(|(pos, _)| *pos);
3423 }
3424
3425 // 3. Rebuild: emit each spine message (which now includes orphans at
3426 // their original position, untouched) in order; immediately after
3427 // emitting an assistant message that owns one or more tool results,
3428 // emit its owned results, in original relative order.
3429 let mut out: Vec<ChatMessage> = Vec::with_capacity(spine.len() + buckets.len());
3430 for (idx, msg) in spine.into_iter().enumerate() {
3431 out.push(msg);
3432 if let Some(results) = buckets.remove(&idx) {
3433 for (_, r) in results {
3434 out.push(r);
3435 }
3436 }
3437 }
3438 *messages = out;
3439}
3440
3441/// Guarantee every assistant `tool_calls` entry is answered by a following tool
3442/// result. Interrupted/aborted turns leave a tool call with no result, which
3443/// many chat-completions endpoints reject when the conversation is replayed.
3444/// We insert a synthetic placeholder result immediately after the assistant
3445/// turn so the transcript stays valid for continuation. (Orphan results — a
3446/// tool message with no preceding call — do not occur in practice and are left
3447/// untouched.)
3448fn ensure_tool_results_paired(messages: &mut Vec<ChatMessage>) {
3449 let answered: HashSet<String> = messages
3450 .iter()
3451 .filter(|m| m.role == Role::Tool)
3452 .filter_map(|m| m.tool_call_id.clone())
3453 .collect();
3454
3455 // Nothing missing? Leave the vector byte-for-byte unchanged.
3456 let any_missing = messages.iter().any(|m| {
3457 m.tool_calls()
3458 .iter()
3459 .any(|c| !c.id.is_empty() && !answered.contains(&c.id))
3460 });
3461 if !any_missing {
3462 return;
3463 }
3464
3465 let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len() + 4);
3466 for msg in messages.drain(..) {
3467 let synth: Vec<ChatMessage> = msg
3468 .tool_calls()
3469 .iter()
3470 .filter(|c| !c.id.is_empty() && !answered.contains(&c.id))
3471 .map(|c| {
3472 let mut m = ChatMessage::tool_result(
3473 c.id.clone(),
3474 c.function.name.clone(),
3475 "[no tool result recorded — turn interrupted]".to_string(),
3476 );
3477 // TR-10: an interrupted call never executed to completion —
3478 // never a candidate for `ReductionKind::ToolInputElided`
3479 // (the "still-pending calls are never input-elided"
3480 // boundary).
3481 crate::mark_tool_error(&mut m);
3482 m
3483 })
3484 .collect();
3485 out.push(msg);
3486 out.extend(synth);
3487 }
3488 *messages = out;
3489}
3490
3491/// Whether `msg` is excluded from every replay/export path — the frozen
3492/// cross-format discipline (`docs/interop/opencode-pi-spec.md` §2.1/§2.2/§6):
3493/// a message marked `compacted_out` (pre-compaction history a source harness
3494/// itself no longer replays) or `pi_exclude_from_context` (a pi `!!` bash
3495/// escape) must never reach `to_jsonl`/`to_jsonl_spliced` for ANY target
3496/// format, not just the one that produced the marker — so a translated
3497/// compacted session replays the same sliced context the source harness
3498/// would, instead of double-including history plus its own summary.
3499fn is_replay_excluded(msg: &ChatMessage) -> bool {
3500 msg.metadata.get("compacted_out").map(String::as_str) == Some("true")
3501 || msg
3502 .metadata
3503 .get("pi_exclude_from_context")
3504 .map(String::as_str)
3505 == Some("true")
3506}
3507
3508// ---- detection ------------------------------------------------------------
3509
3510fn detect_source(text: &str) -> Option<SessionSource> {
3511 // OpenCode export-document form (`docs/interop/opencode-pi-spec.md`
3512 // §1.2/S9a): `{info: SessionInfo, messages: [...]}` — a single
3513 // pretty-printed, MULTI-LINE JSON document, unlike every other format
3514 // this crate reads. It cannot be recognized by the per-line loop below
3515 // (no individual line of a pretty-printed document is itself valid
3516 // JSON), so it gets its own whole-text parse attempt up front. Cheap to
3517 // attempt: a real JSONL file (many newline-separated objects) fails this
3518 // parse immediately (trailing-data error) and falls through unaffected.
3519 if let Ok(v) = serde_json::from_str::<Value>(text.trim()) {
3520 if v.get("conversation").and_then(Value::as_array).is_some()
3521 && (v.get("working_dir").is_some() || v.get("workingDir").is_some())
3522 {
3523 return Some(SessionSource::Goose);
3524 }
3525 if v.get("info").is_some() && v.get("messages").and_then(Value::as_array).is_some() {
3526 return Some(SessionSource::OpenCode);
3527 }
3528 }
3529 for line in non_empty_lines(text) {
3530 // Tolerate a corrupt/truncated line (e.g. a partial first line) rather
3531 // than abandoning detection — the loaders themselves skip bad lines, so
3532 // bailing here would silently misroute an otherwise-valid Codex file.
3533 let Ok(v) = serde_json::from_str::<Value>(line) else {
3534 continue;
3535 };
3536 // OpenCode's frozen envelope form (§1.2): `{"key":[...],"value":...}`,
3537 // one record per line — the synthesized raw-capture unit for the
3538 // JSON-tree/SQLite generations alike. No other format's lines carry
3539 // both a top-level `key` ARRAY and a `value` field, so this is
3540 // unambiguous against Codex/Pi/Claude Code.
3541 if v.get("key").and_then(Value::as_array).is_some() && v.get("value").is_some() {
3542 return Some(SessionSource::OpenCode);
3543 }
3544 // Codex envelopes always carry a `payload`; Claude Code lines never do.
3545 if v.get("payload").is_some() {
3546 return Some(SessionSource::Codex);
3547 }
3548 // Gemini CLI starts with an untyped session header. Its project hash
3549 // and timestamps distinguish it from Claude Code records that also
3550 // carry `sessionId`.
3551 if v.get("sessionId").and_then(Value::as_str).is_some()
3552 && (v.get("projectHash").is_some()
3553 || v.get("startTime").is_some()
3554 || v.get("lastUpdated").is_some())
3555 && v.get("type").is_none()
3556 {
3557 return Some(SessionSource::Gemini);
3558 }
3559 // Grok's resumable `chat_history.jsonl` stores the role/type and
3560 // content directly on each record. Claude Code uses a nested
3561 // `message` envelope for the overlapping `user`/`assistant` tags.
3562 let tag = v.get("type").and_then(Value::as_str);
3563 if tag == Some("gemini") && v.get("content").is_some() {
3564 return Some(SessionSource::Gemini);
3565 }
3566 if v.get("message").is_none()
3567 && v.get("uuid").is_none()
3568 && v.get("sessionId").is_none()
3569 && matches!(
3570 tag,
3571 Some(
3572 "system"
3573 | "user"
3574 | "assistant"
3575 | "tool_result"
3576 | "reasoning"
3577 | "backend_tool_call"
3578 )
3579 )
3580 && (v.get("content").is_some()
3581 || v.get("tool_calls").is_some()
3582 || v.get("tool_call_id").is_some()
3583 || v.get("encrypted_content").is_some()
3584 || v.get("kind").is_some())
3585 {
3586 return Some(SessionSource::Grok);
3587 }
3588 // Pi's `SessionHeader` (line 1, always `type:"session"`): a bare `id`
3589 // (the session id) with no `message`/`uuid` — Claude Code's own
3590 // `type`-bearing lines always carry one or the other, never a
3591 // `type:"session"` header shape (`docs/interop/research/pi-fields.md`
3592 // §1).
3593 if v.get("type").and_then(Value::as_str) == Some("session")
3594 && v.get("id").and_then(Value::as_str).is_some()
3595 && v.get("message").is_none()
3596 && v.get("uuid").is_none()
3597 {
3598 return Some(SessionSource::Pi);
3599 }
3600 if v.get("type").is_some() || v.get("message").is_some() {
3601 return Some(SessionSource::ClaudeCode);
3602 }
3603 }
3604 None
3605}
3606
3607/// Which on-disk OpenCode storage surface is present under a data root
3608/// (`docs/interop/opencode-pi-spec.md` §1.2/S9a): SQLite `opencode*.db` (or
3609/// `$OPENCODE_DB`), legacy JSON tree generation B, or legacy JSON tree
3610/// generation A. This is a **filesystem classifier only** — it answers
3611/// "which generation is this?" for a corpus-discovery tool; it does not
3612/// itself read/parse the surface. See [`Session::from_opencode_str`]'s docs
3613/// for the envelope form any of these three surfaces synthesizes into, and
3614/// [`Session::from_opencode_sqlite`] (PARITY-3/PARITY-16) for the `rusqlite`
3615/// reader that reconstructs that same envelope form from `Sqlite`'s rows —
3616/// `JsonTreeA`/`JsonTreeB` remain classifier-only (their `session_diff`
3617/// round-trips via the JSON store per upstream's own behavior even on a
3618/// SQLite install, so nothing is silently lost by not reading the legacy
3619/// trees directly).
3620#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3621pub enum OpenCodeStorageSurface {
3622 /// SQLite `<data>/opencode*.db` (or `$OPENCODE_DB` override). Read by
3623 /// [`Session::from_opencode_sqlite`] / [`opencode_sqlite_store_stats`] /
3624 /// [`opencode_sqlite_corpus_envelope_text`].
3625 Sqlite,
3626 /// Legacy JSON tree, generation B: `<data>/storage/{session,message,part,session_diff}/…`,
3627 /// marker file `storage/migration`.
3628 JsonTreeB,
3629 /// Legacy JSON tree, generation A: `<data>/project/<slug>/storage/session/{info,message,part}/…`.
3630 JsonTreeA,
3631}
3632
3633/// Probe `data_root` (e.g. `~/.local/share/opencode`) for the OpenCode
3634/// storage surface present, per the discovery rules frozen in
3635/// `docs/interop/opencode-pi-spec.md` §1.2/S9a: `$OPENCODE_DB` wins outright;
3636/// otherwise glob `opencode*.db` (not just `opencode.db` — dev/beta channels
3637/// suffix the filename, `database.ts:43-55`); otherwise look for the JSON
3638/// tree generation-B marker (`storage/migration`); otherwise generation-A's
3639/// `project/` subtree. Returns `None` if nothing is found.
3640pub fn detect_opencode_storage_surface(
3641 data_root: &Path,
3642) -> Option<(OpenCodeStorageSurface, PathBuf)> {
3643 if let Ok(p) = std::env::var("OPENCODE_DB") {
3644 let pb = PathBuf::from(p);
3645 if pb.is_file() {
3646 return Some((OpenCodeStorageSurface::Sqlite, pb));
3647 }
3648 }
3649 if let Ok(entries) = std::fs::read_dir(data_root) {
3650 // D8: `std::fs::read_dir`'s iteration order is filesystem-dependent,
3651 // NOT deterministic — a store with both a default-channel
3652 // `opencode.db` and a channel-suffixed `opencode-dev.db` (S9a: both
3653 // are legal, e.g. after switching install channels) previously
3654 // returned "whichever the OS happened to list first", which could
3655 // differ between two `inspect`/`audit`/`convert` runs against the
3656 // exact same directory. Collect every `opencode*.db` candidate and
3657 // pick deterministically: the exact `opencode.db` name wins if
3658 // present (the default/most-common channel); otherwise the
3659 // lexicographically-smallest match, so repeated runs always agree.
3660 let mut candidates: Vec<PathBuf> = entries
3661 .flatten()
3662 .map(|entry| entry.path())
3663 .filter(|p| {
3664 p.file_name()
3665 .and_then(|n| n.to_str())
3666 .is_some_and(|name| name.starts_with("opencode") && name.ends_with(".db"))
3667 })
3668 .collect();
3669 candidates.sort();
3670 if let Some(exact) = candidates
3671 .iter()
3672 .find(|p| p.file_name().and_then(|n| n.to_str()) == Some("opencode.db"))
3673 {
3674 return Some((OpenCodeStorageSurface::Sqlite, exact.clone()));
3675 }
3676 if let Some(first) = candidates.into_iter().next() {
3677 return Some((OpenCodeStorageSurface::Sqlite, first));
3678 }
3679 }
3680 let storage = data_root.join("storage");
3681 if storage.join("migration").is_file() {
3682 return Some((OpenCodeStorageSurface::JsonTreeB, storage));
3683 }
3684 let project_dir = data_root.join("project");
3685 if project_dir.is_dir() {
3686 return Some((OpenCodeStorageSurface::JsonTreeA, project_dir));
3687 }
3688 None
3689}
3690
3691/// STRICT-VERBATIM line split for `raw` capture (IX-1) — the counterpart to
3692/// [`non_empty_lines`]'s blank-skipping/trimming PARSE view.
3693///
3694/// Splits only on `\n` (never treats `\r\n` specially), so a CRLF source's
3695/// `\r` survives as part of the returned line's own content; blank lines and
3696/// trailing-whitespace-only lines are kept verbatim rather than dropped or
3697/// trimmed. This is what makes `Session.raw` — populated from this at every
3698/// raw-capture site — byte-for-byte faithful to the source for ANY input, not
3699/// just well-formed LF JSONL with no blank lines.
3700///
3701/// Returns `(lines, ends_with_newline)`. `lines.join("\n")` alone cannot
3702/// distinguish a source that ended with a trailing newline from one that
3703/// didn't (both split into the same line list), so `ends_with_newline`
3704/// records that fact out-of-band — [`join_lines_verbatim`] is the exact
3705/// inverse, given both. `text == ""` yields `(vec![], false)`: an empty
3706/// source has zero lines, not one blank line.
3707fn split_lines_verbatim(text: &str) -> (Vec<&str>, bool) {
3708 if text.is_empty() {
3709 return (Vec::new(), false);
3710 }
3711 let ends_with_newline = text.ends_with('\n');
3712 let body = if ends_with_newline {
3713 &text[..text.len() - 1]
3714 } else {
3715 text
3716 };
3717 (body.split('\n').collect(), ends_with_newline)
3718}
3719
3720/// The exact inverse of [`split_lines_verbatim`]: reconstruct the original
3721/// source bytes from its verbatim lines plus the trailing-newline flag.
3722fn join_lines_verbatim(lines: &[String], ends_with_newline: bool) -> String {
3723 let mut out = lines.join("\n");
3724 if ends_with_newline {
3725 out.push('\n');
3726 }
3727 out
3728}
3729
3730// ---- OpenCode SQLite (PARITY-3/PARITY-4/PARITY-16) -------------------------
3731//
3732// Reads a real `opencode*.db` store directly with `rusqlite` (bundled
3733// SQLite — no system library dependency) and reconstructs the SAME envelope
3734// form `{"key":[...],"value":...}` that `Session::from_opencode_str` already
3735// parses for the JSON-tree surfaces (`docs/interop/opencode-pi-spec.md`
3736// §1.2/S9c). Row -> envelope reconstruction mirrors the real app's own
3737// `session.ts` `fromRow` (session table: columnar fields recombined into the
3738// camelCase `SessionInfo` shape) with ONE deliberate divergence: `revert` is
3739// carried as the RAW column value, not upstream's own `fromRow`
3740// reconstruction — which silently drops the V2 `Revert.State` schema's extra
3741// `files` field (`session.ts:70-76`, `revert.ts:18-24`, spec finding S9c).
3742// `message`/`part` rows are simpler: their `data` column is already the V1
3743// `Info`/`Part` JSON minus the id columns hoisted out by the schema
3744// (`session/sql.ts:68-98`), so reconstruction is just re-injecting
3745// `id`/`sessionID`(/`messageID`).
3746
3747/// First 16 bytes of every SQLite database file — the format's own magic,
3748/// independent of file extension.
3749const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0";
3750
3751/// Whether `path` should be routed to the OpenCode SQLite loader instead of
3752/// the UTF-8 text path (PARITY-16): true when the file's first 16 bytes are
3753/// the SQLite magic, OR its extension is `.db` — the latter so a
3754/// corrupted/truncated `opencode*.db` still gets `rusqlite`'s own "not a
3755/// database" diagnostic (PARITY-3 AC03) instead of a confusing UTF-8 error.
3756/// A non-existent path is NOT considered SQLite here — the missing-file
3757/// diagnostic in that case comes from the normal load path (`with_context`
3758/// at the CLI call sites), which already names the path clearly.
3759pub fn looks_like_sqlite(path: &Path) -> bool {
3760 if !path.is_file() {
3761 return false;
3762 }
3763 if path.extension().and_then(|e| e.to_str()) == Some("db") {
3764 return true;
3765 }
3766 use std::io::Read;
3767 let Ok(mut f) = std::fs::File::open(path) else {
3768 return false;
3769 };
3770 let mut buf = [0u8; 16];
3771 f.read_exact(&mut buf).is_ok() && &buf == SQLITE_MAGIC
3772}
3773
3774/// Read `path` as UTF-8 text, translating a non-UTF-8 failure into a clear,
3775/// format-aware diagnostic (PARITY-16) instead of the raw "stream did not
3776/// contain valid UTF-8" `io::Error` — named path, and what supercode DOES
3777/// accept there. Binary SQLite input never reaches this function: callers
3778/// check [`looks_like_sqlite`] first and route to
3779/// [`Session::from_opencode_sqlite`] instead.
3780fn read_utf8_or_diagnose(path: &Path) -> Result<String> {
3781 let bytes = std::fs::read(path)?;
3782 String::from_utf8(bytes).map_err(|_| {
3783 crate::Error::Other(format!(
3784 "{} is not valid UTF-8 text, and is not a recognized OpenCode SQLite store \
3785 (no `SQLite format 3` header) — supercode reads Claude Code / Codex / Pi / \
3786 OpenCode session logs as UTF-8 JSONL, or an OpenCode `opencode*.db` SQLite file",
3787 path.display()
3788 ))
3789 })
3790}
3791
3792/// Read only the portion of a JSONL transcript a bounded scrollback can use.
3793///
3794/// The first record carries durable session metadata (especially for Codex),
3795/// while the trailing window carries the messages the viewport will render.
3796/// Full lossless loaders intentionally continue to read every byte.
3797fn read_display_jsonl(
3798 path: &Path,
3799 message_limit: usize,
3800) -> Result<(Option<SessionSource>, String)> {
3801 const MIN_TAIL_BYTES: u64 = 4 * 1024 * 1024;
3802 const MAX_TAIL_BYTES: u64 = 64 * 1024 * 1024;
3803 const BYTES_PER_MESSAGE: u64 = 16 * 1024;
3804
3805 let mut first = String::new();
3806 BufReader::new(std::fs::File::open(path)?).read_line(&mut first)?;
3807 let source = detect_source(&first);
3808 if !matches!(
3809 source,
3810 Some(SessionSource::ClaudeCode | SessionSource::Codex)
3811 ) {
3812 let text = read_utf8_or_diagnose(path)?;
3813 return Ok((detect_source(&text), text));
3814 }
3815
3816 let mut file = std::fs::File::open(path)?;
3817 let file_len = file.metadata()?.len();
3818 let requested = (message_limit.max(1) as u64)
3819 .saturating_mul(BYTES_PER_MESSAGE)
3820 .clamp(MIN_TAIL_BYTES, MAX_TAIL_BYTES);
3821 if file_len <= requested {
3822 let text = read_utf8_or_diagnose(path)?;
3823 return Ok((source, text));
3824 }
3825
3826 let start = file_len - requested;
3827 file.seek(SeekFrom::Start(start))?;
3828 let mut bytes = Vec::with_capacity(requested as usize);
3829 file.read_to_end(&mut bytes)?;
3830 // The window normally starts in the middle of a JSON record. Discard that
3831 // partial prefix so every line passed to the existing parsers is valid.
3832 if let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') {
3833 bytes.drain(..=newline);
3834 }
3835 let mut tail = String::from_utf8(bytes).map_err(|_| {
3836 crate::Error::Other(format!(
3837 "{} contains non-UTF-8 data in its display window",
3838 path.display()
3839 ))
3840 })?;
3841 if !tail
3842 .lines()
3843 .any(|line| native_display_human_line(line, source))
3844 {
3845 // A single tool-heavy turn can exceed the ordinary byte window. Search backward through a
3846 // separately bounded native slice for only its nearest human record, then prepend that one
3847 // line to the cheap tail. The skipped megabytes are never normalized or sent over RPC.
3848 let search_bytes = file_len.min(MAX_TAIL_BYTES);
3849 let search_start = file_len - search_bytes;
3850 file.seek(SeekFrom::Start(search_start))?;
3851 let mut search = Vec::with_capacity(search_bytes as usize);
3852 file.read_to_end(&mut search)?;
3853 if search_start > 0 {
3854 if let Some(newline) = search.iter().position(|byte| *byte == b'\n') {
3855 search.drain(..=newline);
3856 }
3857 }
3858 if let Ok(search) = std::str::from_utf8(&search) {
3859 if let Some(anchor) = search
3860 .lines()
3861 .rev()
3862 .find(|line| native_display_human_line(line, source))
3863 {
3864 tail = format!("{anchor}\n{tail}");
3865 }
3866 }
3867 }
3868 let text = if source == Some(SessionSource::Codex) {
3869 format!("{first}{tail}")
3870 } else {
3871 tail
3872 };
3873 Ok((source, text))
3874}
3875
3876fn native_display_human_line(line: &str, source: Option<SessionSource>) -> bool {
3877 if !line
3878 .as_bytes()
3879 .windows(6)
3880 .any(|window| window == b"\"user\"")
3881 {
3882 return false;
3883 }
3884 let Ok(value) = serde_json::from_str::<Value>(line) else {
3885 return false;
3886 };
3887 match source {
3888 Some(SessionSource::Codex) => {
3889 value.get("type").and_then(Value::as_str) == Some("response_item")
3890 && value
3891 .get("payload")
3892 .and_then(|payload| payload.get("type"))
3893 .and_then(Value::as_str)
3894 == Some("message")
3895 && value
3896 .get("payload")
3897 .and_then(|payload| payload.get("role"))
3898 .and_then(Value::as_str)
3899 == Some("user")
3900 }
3901 Some(SessionSource::ClaudeCode) => {
3902 value.get("type").and_then(Value::as_str) == Some("user")
3903 && value
3904 .get("message")
3905 .and_then(|message| message.get("content"))
3906 .is_some_and(|content| match content {
3907 Value::String(text) => !text.trim().is_empty(),
3908 Value::Array(parts) => parts.iter().any(|part| {
3909 part.get("type").and_then(Value::as_str) == Some("text")
3910 && part
3911 .get("text")
3912 .and_then(Value::as_str)
3913 .is_some_and(|text| !text.trim().is_empty())
3914 }),
3915 _ => false,
3916 })
3917 }
3918 _ => false,
3919 }
3920}
3921
3922fn opencode_sql_err(e: rusqlite::Error, context: &str) -> crate::Error {
3923 crate::Error::Other(format!("OpenCode SQLite error while {context}: {e}"))
3924}
3925
3926/// Open `db_path` read-only and confirm it carries the expected V1 schema
3927/// (a `session` table) — the shared entry point for every SQLite read below,
3928/// so every caller gets the SAME clear diagnostics (PARITY-3 AC03): missing
3929/// path, not-a-database, and wrong/unsupported schema are each named
3930/// distinctly rather than surfacing later as "zero sessions" or a generic
3931/// parse failure.
3932fn opencode_sqlite_open(db_path: &Path) -> Result<Connection> {
3933 if !db_path.is_file() {
3934 return Err(crate::Error::Other(format!(
3935 "OpenCode SQLite store not found at {} — expected an `opencode*.db` file \
3936 (see `docs/interop/opencode-pi-spec.md` §1.2)",
3937 db_path.display()
3938 )));
3939 }
3940 let conn = Connection::open_with_flags(
3941 db_path,
3942 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
3943 )
3944 .map_err(|e| {
3945 crate::Error::Other(format!(
3946 "{} does not look like a valid OpenCode SQLite database: {e}",
3947 db_path.display()
3948 ))
3949 })?;
3950 let has_session_table: i64 = conn
3951 .query_row(
3952 "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='session'",
3953 [],
3954 |r| r.get(0),
3955 )
3956 .map_err(|e| {
3957 crate::Error::Other(format!(
3958 "failed to read the OpenCode SQLite schema at {}: {e}",
3959 db_path.display()
3960 ))
3961 })?;
3962 if has_session_table == 0 {
3963 return Err(crate::Error::Other(format!(
3964 "{} is a SQLite database but has no `session` table — not a recognized \
3965 OpenCode store (wrong file, or an unsupported/pre-SQLite OpenCode version)",
3966 db_path.display()
3967 )));
3968 }
3969 Ok(conn)
3970}
3971
3972/// Parse a nullable JSON-typed SQLite column (`metadata`, `revert`, `model`,
3973/// …). D7: an unparseable non-empty column previously degraded to
3974/// `Value::Null` with NO diagnostic — indistinguishable from a genuinely
3975/// absent/NULL column, so a corrupt `data`/`metadata` value silently
3976/// vanished (e.g. a message whose `data` fails to parse loses its entire
3977/// canonical content with no trace). A `tracing::warn!` now surfaces the
3978/// column name and context (session/record id) whenever this happens, so
3979/// the failure is diagnosable — `Value::Null` is kept as the parsed VALUE
3980/// (still the least-wrong placeholder for a broken column; changing it to a
3981/// sentinel would risk misleading every legitimate `.is_null()` check
3982/// elsewhere) but the frontend/log now knows it happened.
3983fn opencode_json_col(s: Option<String>, col: &str, context: &str) -> Value {
3984 match s.as_deref() {
3985 None => Value::Null,
3986 Some(t) => match serde_json::from_str::<Value>(t) {
3987 Ok(v) => v,
3988 Err(e) => {
3989 tracing::warn!(
3990 column = col,
3991 context,
3992 error = %e,
3993 "opencode SQLite column failed to parse as JSON — treating as absent (D7)"
3994 );
3995 Value::Null
3996 }
3997 },
3998 }
3999}
4000
4001/// Columns the `session` table has in a GIVEN store, read once per session
4002/// load via `PRAGMA table_info` (D3: real-world stores vary — a lean/older
4003/// `opencode` generation may lack columns the newest schema added, e.g.
4004/// `workspace_id`; rusqlite's `Row::get(name)` hard-errors
4005/// "Invalid column name" on an absent column, so callers must check
4006/// membership before reading a not-guaranteed column instead of reading it
4007/// unconditionally).
4008fn opencode_session_columns(
4009 conn: &Connection,
4010) -> rusqlite::Result<std::collections::HashSet<String>> {
4011 let mut stmt = conn.prepare("PRAGMA table_info(session)")?;
4012 let names = stmt.query_map([], |r| r.get::<_, String>(1))?; // column 1 = name
4013 names.collect()
4014}
4015
4016/// Reconstruct one `session` row into the camelCase `SessionInfo` envelope
4017/// value [`Session::from_opencode_str`]'s `capture_opencode_session_info`
4018/// already parses — mirrors upstream's own `session.ts` `fromRow`, EXCEPT
4019/// `revert` carries the raw column value verbatim rather than upstream's
4020/// field-selecting reconstruction (spec S9c: that reconstruction silently
4021/// drops the V2 `Revert.State` schema's extra `files` field).
4022///
4023/// D3: not every column this loader would like to read is guaranteed to
4024/// exist — a real, older/leaner `opencode` install's `session` table (e.g.
4025/// v1.2.15) lacks `workspace_id`/`path`/`metadata`/`cost`/`tokens_*`/
4026/// `agent`/`model` entirely. Those are read defensively (guarded by
4027/// [`opencode_session_columns`]); columns present in EVERY `opencode`
4028/// generation this loader has ever targeted are still read unconditionally.
4029fn opencode_row_session_info(conn: &Connection, session_id: &str) -> Result<Value> {
4030 let cols = opencode_session_columns(conn)
4031 .map_err(|e| opencode_sql_err(e, &format!("reading session `{session_id}` schema")))?;
4032 let has = |name: &str| cols.contains(name);
4033
4034 conn.query_row("SELECT * FROM session WHERE id = ?1", [session_id], |r| {
4035 let id: String = r.get("id")?;
4036 let project_id: String = r.get("project_id")?;
4037 let workspace_id: Option<String> = if has("workspace_id") {
4038 r.get("workspace_id")?
4039 } else {
4040 None
4041 };
4042 let parent_id: Option<String> = r.get("parent_id")?;
4043 let slug: String = r.get("slug")?;
4044 let directory: String = r.get("directory")?;
4045 let path: Option<String> = if has("path") { r.get("path")? } else { None };
4046 let title: String = r.get("title")?;
4047 let version: String = r.get("version")?;
4048 let share_url: Option<String> = r.get("share_url")?;
4049 let summary_additions: Option<i64> = r.get("summary_additions")?;
4050 let summary_deletions: Option<i64> = r.get("summary_deletions")?;
4051 let summary_files: Option<i64> = r.get("summary_files")?;
4052 let summary_diffs: Option<String> = r.get("summary_diffs")?;
4053 let metadata: Option<String> = if has("metadata") {
4054 r.get("metadata")?
4055 } else {
4056 None
4057 };
4058 let cost: f64 = if has("cost") { r.get("cost")? } else { 0.0 };
4059 let tokens_input: i64 = if has("tokens_input") {
4060 r.get("tokens_input")?
4061 } else {
4062 0
4063 };
4064 let tokens_output: i64 = if has("tokens_output") {
4065 r.get("tokens_output")?
4066 } else {
4067 0
4068 };
4069 let tokens_reasoning: i64 = if has("tokens_reasoning") {
4070 r.get("tokens_reasoning")?
4071 } else {
4072 0
4073 };
4074 let tokens_cache_read: i64 = if has("tokens_cache_read") {
4075 r.get("tokens_cache_read")?
4076 } else {
4077 0
4078 };
4079 let tokens_cache_write: i64 = if has("tokens_cache_write") {
4080 r.get("tokens_cache_write")?
4081 } else {
4082 0
4083 };
4084 let revert: Option<String> = r.get("revert")?;
4085 let permission: Option<String> = if has("permission") {
4086 r.get("permission")?
4087 } else {
4088 None
4089 };
4090 let agent: Option<String> = if has("agent") { r.get("agent")? } else { None };
4091 let model: Option<String> = if has("model") { r.get("model")? } else { None };
4092 let time_created: i64 = r.get("time_created")?;
4093 let time_updated: i64 = r.get("time_updated")?;
4094 let time_compacting: Option<i64> = if has("time_compacting") {
4095 r.get("time_compacting")?
4096 } else {
4097 None
4098 };
4099 let time_archived: Option<i64> = if has("time_archived") {
4100 r.get("time_archived")?
4101 } else {
4102 None
4103 };
4104
4105 let summary =
4106 (summary_additions.is_some() || summary_deletions.is_some() || summary_files.is_some())
4107 .then(|| {
4108 serde_json::json!({
4109 "additions": summary_additions.unwrap_or(0),
4110 "deletions": summary_deletions.unwrap_or(0),
4111 "files": summary_files.unwrap_or(0),
4112 "diffs": opencode_json_col(summary_diffs, "summary_diffs", session_id),
4113 })
4114 });
4115 let share = share_url.map(|u| serde_json::json!({"url": u}));
4116
4117 Ok(serde_json::json!({
4118 "id": id,
4119 "slug": slug,
4120 "projectID": project_id,
4121 "workspaceID": workspace_id,
4122 "directory": directory,
4123 "path": path,
4124 "parentID": parent_id,
4125 "summary": summary,
4126 "cost": cost,
4127 "tokens": {
4128 "input": tokens_input,
4129 "output": tokens_output,
4130 "reasoning": tokens_reasoning,
4131 "cache": {"read": tokens_cache_read, "write": tokens_cache_write},
4132 },
4133 "share": share,
4134 "title": title,
4135 "agent": agent,
4136 "model": opencode_json_col(model, "model", session_id),
4137 "version": version,
4138 "metadata": opencode_json_col(metadata, "metadata", session_id),
4139 "time": {
4140 "created": time_created,
4141 "updated": time_updated,
4142 "compacting": time_compacting,
4143 "archived": time_archived,
4144 },
4145 "permission": opencode_json_col(permission, "permission", session_id),
4146 // S9c: raw column value, not a field-selecting reconstruction —
4147 // see this function's doc comment.
4148 "revert": opencode_json_col(revert, "revert", session_id),
4149 }))
4150 })
4151 .map_err(|e| match e {
4152 rusqlite::Error::QueryReturnedNoRows => crate::Error::Other(format!(
4153 "OpenCode session `{session_id}` not found in this SQLite store"
4154 )),
4155 e => opencode_sql_err(e, &format!("reading session `{session_id}`")),
4156 })
4157}
4158
4159/// `message.data` / `part.data` already ARE the V1 `Info`/`Part` JSON minus
4160/// the id columns the schema hoists out (`session/sql.ts:68-98`) — just
4161/// re-inject them, matching what a JSON-tree file (or the export document)
4162/// carries at this same key. Also re-injects the row's own `time_created`/
4163/// `time_updated` columns (D2/S9c: these live OUTSIDE `data` in the real
4164/// schema — `Timestamps` in `database/schema.sql.ts` — and MUST be carried
4165/// in the envelope so `raw` is value-complete and re-writable without
4166/// re-minting timestamps; distinct keys from `data`'s own `"time"` object,
4167/// which is a different, in-schema field with different semantics).
4168fn opencode_row_message_value(
4169 id: &str,
4170 session_id: &str,
4171 data_json: &str,
4172 time_created: i64,
4173 time_updated: i64,
4174) -> Value {
4175 let mut v = opencode_json_col(Some(data_json.to_string()), "message.data", id);
4176 if let Value::Object(map) = &mut v {
4177 map.insert("id".to_string(), Value::String(id.to_string()));
4178 map.insert(
4179 "sessionID".to_string(),
4180 Value::String(session_id.to_string()),
4181 );
4182 map.insert("time_created".to_string(), Value::from(time_created));
4183 map.insert("time_updated".to_string(), Value::from(time_updated));
4184 }
4185 v
4186}
4187
4188fn opencode_row_part_value(
4189 id: &str,
4190 session_id: &str,
4191 message_id: &str,
4192 data_json: &str,
4193 time_created: i64,
4194 time_updated: i64,
4195) -> Value {
4196 let mut v = opencode_json_col(Some(data_json.to_string()), "part.data", id);
4197 if let Value::Object(map) = &mut v {
4198 map.insert("id".to_string(), Value::String(id.to_string()));
4199 map.insert(
4200 "sessionID".to_string(),
4201 Value::String(session_id.to_string()),
4202 );
4203 map.insert(
4204 "messageID".to_string(),
4205 Value::String(message_id.to_string()),
4206 );
4207 map.insert("time_created".to_string(), Value::from(time_created));
4208 map.insert("time_updated".to_string(), Value::from(time_updated));
4209 }
4210 v
4211}
4212
4213/// Ordered envelope lines for one session's `session`/`message`/`part`/`todo`
4214/// records (`docs/interop/opencode-pi-spec.md` §1.2's frozen order): session
4215/// info first, then each message (by `time_created, id`) immediately
4216/// followed by its own parts (by `id`) — parts MUST directly follow their
4217/// owning message line, since `Session::from_opencode_str`'s envelope parser
4218/// attaches a `part` line to whichever message id is already in its index
4219/// and silently leaves an out-of-order part `raw`-only otherwise — then
4220/// `todo` side-records, then a `session_diff` side-record if the JSON
4221/// sidecar file for this session exists (order-independent).
4222///
4223/// `db_path` is needed only for the `session_diff` sidecar (D3): §1.3 says
4224/// it "is still JSON-written even on SQLite installs" — verified against
4225/// `packages/opencode/src/session/revert.ts:76` /
4226/// `packages/opencode/src/storage/storage.ts:192` at the pinned schema
4227/// commit, which write it to `<data>/storage/session_diff/<session>.json`
4228/// (`<data>` being `db_path`'s parent directory) on every revert, entirely
4229/// separate from the `session.revert` DB column this loader already
4230/// captures. Without this, revert diffs vanish from `raw` and audit
4231/// under-counts `session_diff` records for real reverted sessions.
4232fn opencode_sqlite_session_envelope_lines(
4233 conn: &Connection,
4234 db_path: &Path,
4235 session_id: &str,
4236) -> Result<Vec<String>> {
4237 let mut lines = Vec::new();
4238
4239 let session_info = opencode_row_session_info(conn, session_id)?;
4240 let project_id = session_info
4241 .get("projectID")
4242 .and_then(Value::as_str)
4243 .unwrap_or("global")
4244 .to_string();
4245 lines.push(
4246 serde_json::json!({"key": ["session", project_id, session_id], "value": session_info})
4247 .to_string(),
4248 );
4249
4250 let mut msg_stmt = conn
4251 .prepare(
4252 "SELECT id, data, time_created, time_updated FROM message \
4253 WHERE session_id = ?1 ORDER BY time_created, id",
4254 )
4255 .map_err(|e| opencode_sql_err(e, "preparing the message query"))?;
4256 let msg_rows = msg_stmt
4257 .query_map([session_id], |r| {
4258 let id: String = r.get("id")?;
4259 let data: String = r.get("data")?;
4260 let time_created: i64 = r.get("time_created")?;
4261 let time_updated: i64 = r.get("time_updated")?;
4262 Ok((id, data, time_created, time_updated))
4263 })
4264 .map_err(|e| opencode_sql_err(e, "querying messages"))?;
4265
4266 let mut part_stmt = conn
4267 .prepare(
4268 "SELECT id, data, time_created, time_updated FROM part \
4269 WHERE message_id = ?1 ORDER BY id",
4270 )
4271 .map_err(|e| opencode_sql_err(e, "preparing the part query"))?;
4272
4273 for row in msg_rows {
4274 let (msg_id, data, msg_time_created, msg_time_updated) =
4275 row.map_err(|e| opencode_sql_err(e, "reading a message row"))?;
4276 let msg_value = opencode_row_message_value(
4277 &msg_id,
4278 session_id,
4279 &data,
4280 msg_time_created,
4281 msg_time_updated,
4282 );
4283 lines.push(
4284 serde_json::json!({"key": ["message", session_id, msg_id], "value": msg_value})
4285 .to_string(),
4286 );
4287
4288 let part_rows = part_stmt
4289 .query_map([&msg_id], |r| {
4290 let id: String = r.get("id")?;
4291 let data: String = r.get("data")?;
4292 let time_created: i64 = r.get("time_created")?;
4293 let time_updated: i64 = r.get("time_updated")?;
4294 Ok((id, data, time_created, time_updated))
4295 })
4296 .map_err(|e| opencode_sql_err(e, "querying parts"))?;
4297 for prow in part_rows {
4298 let (part_id, pdata, part_time_created, part_time_updated) =
4299 prow.map_err(|e| opencode_sql_err(e, "reading a part row"))?;
4300 let part_value = opencode_row_part_value(
4301 &part_id,
4302 session_id,
4303 &msg_id,
4304 &pdata,
4305 part_time_created,
4306 part_time_updated,
4307 );
4308 lines.push(
4309 serde_json::json!({"key": ["part", msg_id, part_id], "value": part_value})
4310 .to_string(),
4311 );
4312 }
4313 }
4314
4315 let mut todo_stmt = conn
4316 .prepare(
4317 "SELECT content, status, priority, position, time_created, time_updated \
4318 FROM todo WHERE session_id = ?1 ORDER BY position",
4319 )
4320 .map_err(|e| opencode_sql_err(e, "preparing the todo query"))?;
4321 let todo_rows = todo_stmt
4322 .query_map([session_id], |r| {
4323 let content: String = r.get("content")?;
4324 let status: String = r.get("status")?;
4325 let priority: String = r.get("priority")?;
4326 let position: i64 = r.get("position")?;
4327 let time_created: i64 = r.get("time_created")?;
4328 let time_updated: i64 = r.get("time_updated")?;
4329 Ok(serde_json::json!({
4330 "sessionID": session_id,
4331 "content": content,
4332 "status": status,
4333 "priority": priority,
4334 "position": position,
4335 "time": {"created": time_created, "updated": time_updated},
4336 }))
4337 })
4338 .map_err(|e| opencode_sql_err(e, "querying todos"))?;
4339 for trow in todo_rows {
4340 let tv = trow.map_err(|e| opencode_sql_err(e, "reading a todo row"))?;
4341 let position = tv.get("position").cloned().unwrap_or(Value::Null);
4342 lines.push(
4343 serde_json::json!({"key": ["todo", session_id, position], "value": tv}).to_string(),
4344 );
4345 }
4346
4347 if let Some(diff_value) = opencode_read_session_diff_sidecar(db_path, session_id) {
4348 lines.push(
4349 serde_json::json!({"key": ["session_diff", session_id], "value": diff_value})
4350 .to_string(),
4351 );
4352 }
4353
4354 Ok(lines)
4355}
4356
4357/// D3: read the `session_diff` JSON sidecar file for `session_id`, if
4358/// present, from `<db_path's parent>/storage/session_diff/<session_id>.json`
4359/// — the real on-disk location (`storage.ts`'s `Global.Path.data` +
4360/// `"storage"`, with `db_path` itself living at `Global.Path.data/opencode.db`
4361/// or `opencode-<channel>.db`). Best-effort: a missing file is the common
4362/// case (most sessions never revert) and is not an error; an existing-but-
4363/// unparseable file surfaces a diagnostic (D7-style) rather than silently
4364/// vanishing.
4365fn opencode_read_session_diff_sidecar(db_path: &Path, session_id: &str) -> Option<Value> {
4366 let dir = db_path.parent()?;
4367 let sidecar = dir
4368 .join("storage")
4369 .join("session_diff")
4370 .join(format!("{session_id}.json"));
4371 let text = std::fs::read_to_string(&sidecar).ok()?;
4372 match serde_json::from_str::<Value>(&text) {
4373 Ok(v) => Some(v),
4374 Err(e) => {
4375 tracing::warn!(
4376 path = %sidecar.display(),
4377 error = %e,
4378 "opencode session_diff sidecar failed to parse as JSON — skipping (D7)"
4379 );
4380 None
4381 }
4382 }
4383}
4384
4385/// Pick the "primary" session for a bare `.db` path with no explicit session
4386/// id (`Session::load`'s auto-detect entry point): the most-recently-updated
4387/// TOP-LEVEL session (`parent_id IS NULL` sorts first, then `time_updated`
4388/// descending) — a subagent/task child session is never picked over an
4389/// available root session, mirroring `most_recent_session`'s "latest wins"
4390/// convention used elsewhere in this crate for supercode's own store.
4391fn opencode_sqlite_primary_session_id(conn: &Connection) -> Result<String> {
4392 conn.query_row(
4393 "SELECT id FROM session ORDER BY (parent_id IS NULL) DESC, time_updated DESC LIMIT 1",
4394 [],
4395 |r| r.get::<_, String>(0),
4396 )
4397 .map_err(|e| match e {
4398 rusqlite::Error::QueryReturnedNoRows => {
4399 crate::Error::Other("OpenCode SQLite store contains no sessions".to_string())
4400 }
4401 e => opencode_sql_err(e, "selecting the primary session"),
4402 })
4403}
4404
4405fn opencode_sqlite_all_session_ids(conn: &Connection, limit: Option<usize>) -> Result<Vec<String>> {
4406 let mut stmt = conn
4407 .prepare("SELECT id FROM session ORDER BY time_created, id")
4408 .map_err(|e| opencode_sql_err(e, "listing sessions"))?;
4409 let rows = stmt
4410 .query_map([], |r| r.get::<_, String>(0))
4411 .map_err(|e| opencode_sql_err(e, "listing sessions"))?;
4412 let mut ids = Vec::new();
4413 for row in rows {
4414 ids.push(row.map_err(|e| opencode_sql_err(e, "reading a session id"))?);
4415 if limit.is_some_and(|n| ids.len() >= n) {
4416 break;
4417 }
4418 }
4419 Ok(ids)
4420}
4421
4422/// D6: list every session id in an OpenCode SQLite store (oldest first) —
4423/// exposed so CLI callers (`convert`, `inspect`) can detect a multi-session
4424/// store and warn before [`Session::load`]/[`Session::from_opencode_sqlite`]
4425/// silently picks just the primary one. Previously nothing surfaced this:
4426/// `convert opencode.db --to X` converted 1-of-N sessions with no warning
4427/// and no way to name a different one.
4428pub fn opencode_sqlite_session_ids(db_path: &Path) -> Result<Vec<String>> {
4429 let conn = opencode_sqlite_open(db_path)?;
4430 opencode_sqlite_all_session_ids(&conn, None)
4431}
4432
4433/// D6: the same "most-recently-updated top-level session" selection
4434/// [`Session::load`]/[`Session::from_opencode_sqlite`] make by default when
4435/// no explicit session id is given — exposed so a CLI-level warning can name
4436/// which one was chosen.
4437pub fn opencode_sqlite_primary_id(db_path: &Path) -> Result<String> {
4438 let conn = opencode_sqlite_open(db_path)?;
4439 opencode_sqlite_primary_session_id(&conn)
4440}
4441
4442/// Cheap store-level counts — `COUNT(*)` only, no row hydration — for
4443/// `inspect`'s "reports the audited real store's sessions, messages, and
4444/// parts" summary (PARITY-3 AC01).
4445#[derive(Debug, Clone, Copy, Default)]
4446#[non_exhaustive]
4447pub struct OpenCodeSqliteStoreStats {
4448 /// Row count of the `session` table.
4449 pub sessions: u64,
4450 /// Row count of the `message` table.
4451 pub messages: u64,
4452 /// Row count of the `part` table.
4453 pub parts: u64,
4454 /// Row count of the `todo` table.
4455 pub todos: u64,
4456}
4457
4458/// Count sessions/messages/parts/todos in a real OpenCode SQLite store
4459/// without loading any of them (PARITY-3 AC01).
4460pub fn opencode_sqlite_store_stats(db_path: &Path) -> Result<OpenCodeSqliteStoreStats> {
4461 let conn = opencode_sqlite_open(db_path)?;
4462 let count = |table: &str| -> Result<u64> {
4463 let sql = format!("SELECT count(*) FROM {table}");
4464 conn.query_row(&sql, [], |r| r.get::<_, i64>(0))
4465 .map(|n| n.max(0) as u64)
4466 .map_err(|e| opencode_sql_err(e, &format!("counting `{table}` rows")))
4467 };
4468 Ok(OpenCodeSqliteStoreStats {
4469 sessions: count("session")?,
4470 messages: count("message")?,
4471 parts: count("part")?,
4472 todos: count("todo")?,
4473 })
4474}
4475
4476/// Combined envelope text spanning every session in `db_path` (or up to
4477/// `limit_sessions`) — for corpus-style scanning
4478/// (the OpenCode SQLite corpus-audit path, PARITY-4).
4479/// Safe to concatenate multiple sessions' records into one text even though
4480/// [`Session::from_opencode_str`] itself only keeps the LAST `session` line
4481/// (single-session semantics) — the audit line-classifier
4482/// (`audit_opencode_line`) scores each line independently and doesn't care
4483/// about session boundaries. Use [`Session::from_opencode_sqlite`] to load
4484/// one session as a real [`Session`].
4485pub fn opencode_sqlite_corpus_envelope_text(
4486 db_path: &Path,
4487 limit_sessions: Option<usize>,
4488) -> Result<String> {
4489 let conn = opencode_sqlite_open(db_path)?;
4490 let ids = opencode_sqlite_all_session_ids(&conn, limit_sessions)?;
4491 let mut out = String::new();
4492 for id in ids {
4493 for line in opencode_sqlite_session_envelope_lines(&conn, db_path, &id)? {
4494 out.push_str(&line);
4495 out.push('\n');
4496 }
4497 }
4498 Ok(out)
4499}
4500
4501/// PARSE-iteration view (blank/whitespace-only lines skipped, each line
4502/// trimmed, `\r` line endings normalized away by [`str::lines`]) — used
4503/// everywhere a loader walks lines looking for JSON *records*, where a blank
4504/// line is simply not a record and must not become a spurious parse
4505/// failure/empty entry. Deliberately NOT used for `raw` capture any more
4506/// (IX-1) — see [`split_lines_verbatim`] for that.
4507fn non_empty_lines(text: &str) -> impl Iterator<Item = &str> {
4508 text.lines().map(str::trim).filter(|l| !l.is_empty())
4509}
4510
4511// ---- Claude Code ----------------------------------------------------------
4512
4513/// The `subagents/` directory for a Claude Code transcript at `<dir>/<stem>.jsonl`
4514/// is `<dir>/<stem>/subagents/`. Returns it only if it exists.
4515fn subagents_dir_for(main_path: &Path) -> Option<PathBuf> {
4516 let dir = main_path.parent()?;
4517 let stem = main_path.file_stem()?.to_str()?;
4518 let candidate = dir.join(stem).join("subagents");
4519 candidate.is_dir().then_some(candidate)
4520}
4521
4522/// The first `agentId` recorded in a subagent transcript.
4523fn first_agent_id(jsonl: &str) -> Option<String> {
4524 for line in non_empty_lines(jsonl) {
4525 if let Ok(v) = serde_json::from_str::<Value>(line) {
4526 if let Some(id) = v.get("agentId").and_then(Value::as_str) {
4527 return Some(id.to_string());
4528 }
4529 }
4530 }
4531 None
4532}
4533
4534/// Find the `tool_use_id` of each parent `Task` call that spawned one of
4535/// `agent_ids`, by locating the parent transcript's `tool_result` whose
4536/// serialized content mentions the agent id. Best effort: an id with no
4537/// qualifying match is simply absent from the returned map.
4538///
4539/// Single pass over `main_text` — each line is parsed at most once,
4540/// regardless of how many agent ids are being sought — with each id's result
4541/// reproducing exactly what a per-id scan-and-parse-per-hit-line search would
4542/// return: the first line (in file order) whose raw text contains the id and
4543/// which — the first qualifying `tool_result` block in that line, in block
4544/// order — has a string `tool_use_id` and a serialized form that also
4545/// contains the id. A `tool_result` block matching on raw-line/serialized
4546/// containment but lacking a `tool_use_id` yields nothing for that id and
4547/// does not shadow a later match.
4548fn parent_tool_use_index(main_text: &str, agent_ids: &[String]) -> HashMap<String, String> {
4549 let mut index: HashMap<String, String> = HashMap::new();
4550 if agent_ids.is_empty() {
4551 return index;
4552 }
4553
4554 for line in non_empty_lines(main_text) {
4555 if index.len() == agent_ids.len() {
4556 break;
4557 }
4558 // Cheap prefilter: every match this function can ever return comes
4559 // from a block whose raw line carries the literal JSON string value
4560 // `tool_result` (no JSON-escape variants of that ASCII literal).
4561 if !line.contains("tool_result") {
4562 continue;
4563 }
4564 let still_unmapped: Vec<&String> = agent_ids
4565 .iter()
4566 .filter(|id| !index.contains_key(id.as_str()))
4567 .collect();
4568 if still_unmapped.is_empty() {
4569 break;
4570 }
4571 let Ok(v) = serde_json::from_str::<Value>(line) else {
4572 continue;
4573 };
4574 let content = v.get("message").and_then(|m| m.get("content"));
4575 let Some(Value::Array(blocks)) = content else {
4576 continue;
4577 };
4578 for b in blocks {
4579 if b.get("type").and_then(Value::as_str) != Some("tool_result") {
4580 continue;
4581 }
4582 let Some(tool_use_id) = b.get("tool_use_id").and_then(Value::as_str) else {
4583 continue;
4584 };
4585 let block_str = b.to_string();
4586 for id in &still_unmapped {
4587 if index.contains_key(id.as_str()) {
4588 continue;
4589 }
4590 if line.contains(id.as_str()) && block_str.contains(id.as_str()) {
4591 index.insert((*id).clone(), tool_use_id.to_string());
4592 }
4593 }
4594 }
4595 }
4596
4597 index
4598}
4599
4600#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4601enum ClaudeReplayKind {
4602 User,
4603 Assistant,
4604 Attachment,
4605 System,
4606}
4607
4608impl ClaudeReplayKind {
4609 fn is_conversation(self) -> bool {
4610 matches!(self, Self::User | Self::Assistant)
4611 }
4612}
4613
4614#[derive(Debug, Clone)]
4615struct ClaudeReplayNode {
4616 line_index: usize,
4617 uuid: String,
4618 parent_uuid: Option<String>,
4619 kind: ClaudeReplayKind,
4620 is_sidechain: bool,
4621 assistant_message_id: Option<String>,
4622 is_tool_result: bool,
4623 compact: Option<ClaudeCompactBoundary>,
4624}
4625
4626#[derive(Debug, Clone)]
4627struct ClaudeCompactBoundary {
4628 anchor_uuid: Option<String>,
4629 preserved_uuids: Vec<String>,
4630 preserved_segment: Option<(String, String)>,
4631}
4632
4633/// One projection of a Claude transcript graph: the source lines to replay,
4634/// plus whatever the projection had to give up to produce them (always empty
4635/// below [`Fidelity::Semantic`], which is the only level that degrades
4636/// instead of failing).
4637#[derive(Debug, Default)]
4638struct ClaudeReplaySelection {
4639 lines: Vec<usize>,
4640 residue: Vec<String>,
4641}
4642
4643#[derive(Debug, Default)]
4644struct ClaudeReplayIndex {
4645 nodes: Vec<ClaudeReplayNode>,
4646 by_uuid: HashMap<String, usize>,
4647 segment_anchors: HashSet<String>,
4648 last_prompt: Option<(String, bool)>,
4649 linear_lines: Vec<usize>,
4650}
4651
4652impl ClaudeReplayIndex {
4653 fn observe(&mut self, line_index: usize, v: &Value) -> Result<()> {
4654 if v.get("type").and_then(Value::as_str) == Some("last-prompt") {
4655 if let Some(leaf) = v.get("leafUuid").and_then(Value::as_str) {
4656 self.last_prompt = Some((
4657 leaf.to_string(),
4658 v.get("explicit").and_then(Value::as_bool) == Some(true),
4659 ));
4660 }
4661 return Ok(());
4662 }
4663
4664 // A fork-context-ref is a real Claude graph anchor, but not a replay
4665 // message. Its child is the first conversational record in the
4666 // exported fork, so reaching this UUID terminates the locally
4667 // replayable segment rather than indicating a broken parent edge.
4668 if v.get("type").and_then(Value::as_str) == Some("fork-context-ref") {
4669 if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
4670 self.segment_anchors.insert(uuid.to_string());
4671 }
4672 return Ok(());
4673 }
4674
4675 let kind = match v.get("type").and_then(Value::as_str) {
4676 Some("user") => ClaudeReplayKind::User,
4677 Some("assistant") => ClaudeReplayKind::Assistant,
4678 Some("attachment") => ClaudeReplayKind::Attachment,
4679 Some("system") => ClaudeReplayKind::System,
4680 _ => return Ok(()),
4681 };
4682 self.linear_lines.push(line_index);
4683 let Some(uuid) = v.get("uuid").and_then(Value::as_str) else {
4684 return Ok(());
4685 };
4686 if self.by_uuid.contains_key(uuid) {
4687 return Err(claude_replay_error(format!(
4688 "duplicate uuid `{uuid}` in Claude transcript"
4689 )));
4690 }
4691
4692 let compact = (kind == ClaudeReplayKind::System
4693 && v.get("subtype").and_then(Value::as_str) == Some("compact_boundary"))
4694 .then(|| ClaudeCompactBoundary::from_value(v));
4695 let assistant_message_id = (kind == ClaudeReplayKind::Assistant)
4696 .then(|| claude_assistant_message_id(v).map(str::to_string))
4697 .flatten();
4698 let is_tool_result = kind == ClaudeReplayKind::User
4699 && v.get("message")
4700 .and_then(|m| m.get("content"))
4701 .and_then(Value::as_array)
4702 .is_some_and(|blocks| {
4703 blocks
4704 .iter()
4705 .any(|b| b.get("type").and_then(Value::as_str) == Some("tool_result"))
4706 });
4707 let node = ClaudeReplayNode {
4708 line_index,
4709 uuid: uuid.to_string(),
4710 parent_uuid: v
4711 .get("parentUuid")
4712 .and_then(Value::as_str)
4713 .map(str::to_string),
4714 kind,
4715 is_sidechain: v.get("isSidechain").and_then(Value::as_bool) == Some(true),
4716 assistant_message_id,
4717 is_tool_result,
4718 compact,
4719 };
4720 self.by_uuid.insert(uuid.to_string(), self.nodes.len());
4721 self.nodes.push(node);
4722 Ok(())
4723 }
4724
4725 /// Project the transcript at `fidelity`.
4726 ///
4727 /// Below [`Fidelity::Semantic`] this is the STRICT projection every
4728 /// continuation, transfer and export path depends on: reconstruct
4729 /// Claude's own single active post-compaction branch, or fail naming what
4730 /// could not be reconstructed.
4731 ///
4732 /// [`Fidelity::Semantic`] is the read-only VIEW mode. A Claude transcript
4733 /// that has been compacted, summarized, or resumed across files routinely
4734 /// contains a live record whose `parentUuid` names a record that is no
4735 /// longer on disk. Strict projection rightly refuses — a continuation
4736 /// built on a guessed graph is silent loss — but a VIEW does not need a
4737 /// continuation, so this mode anchors each dangling edge as a segment
4738 /// root, projects every severed segment exactly as the active branch is
4739 /// projected, splices them back together in transcript order, and names
4740 /// every degradation in the returned residue instead of erroring.
4741 fn select_lines(mut self, fidelity: Fidelity) -> Result<ClaudeReplaySelection> {
4742 let lenient = fidelity.tolerates_residue();
4743 let mut residue = Vec::new();
4744 if self.nodes.is_empty() {
4745 // Older exports and many hand-authored compatibility fixtures do
4746 // not carry Claude's `uuid`/`parentUuid` graph fields. There is no
4747 // branch information to project in that shape, so preserve the
4748 // historical linear normalization behavior. Native graph-bearing
4749 // transcripts always take the projection below.
4750 return Ok(ClaudeReplaySelection {
4751 lines: self.linear_lines,
4752 residue,
4753 });
4754 }
4755 if lenient {
4756 self.anchor_dangling_parents(&mut residue);
4757 }
4758 // Last resort for a VIEW: a transcript whose graph is unprojectable
4759 // for some OTHER reason (a cycle, an unresolvable compact boundary)
4760 // still renders as the file's own record order. A read-only mirror
4761 // that cannot open a session at all is the defect this mode exists
4762 // to remove, so `Semantic` never returns an error.
4763 let fallback = lenient.then(|| self.linear_lines.clone());
4764 match self.project(lenient, &mut residue) {
4765 Ok(lines) => Ok(ClaudeReplaySelection { lines, residue }),
4766 Err(error) => match fallback {
4767 Some(lines) => {
4768 residue.push(format!(
4769 "the Claude record graph could not be projected ({error}); \
4770 every record was stitched in transcript order instead"
4771 ));
4772 Ok(ClaudeReplaySelection { lines, residue })
4773 }
4774 None => Err(error),
4775 },
4776 }
4777 }
4778
4779 /// Turn every edge that points outside the transcript into a segment
4780 /// root, naming the dangling uuids as residue.
4781 ///
4782 /// A `fork-context-ref` anchor is already a declared segment boundary,
4783 /// not a break, so it is left alone.
4784 fn anchor_dangling_parents(&mut self, residue: &mut Vec<String>) {
4785 let mut dangling = Vec::new();
4786 for idx in 0..self.nodes.len() {
4787 let Some(parent) = self.nodes[idx].parent_uuid.as_deref() else {
4788 continue;
4789 };
4790 if self.by_uuid.contains_key(parent) || self.segment_anchors.contains(parent) {
4791 continue;
4792 }
4793 dangling.push(format!("`{}` → `{parent}`", self.nodes[idx].uuid));
4794 self.nodes[idx].parent_uuid = None;
4795 }
4796 if dangling.is_empty() {
4797 return;
4798 }
4799 const NAMED: usize = 8;
4800 let total = dangling.len();
4801 let overflow = total.saturating_sub(NAMED);
4802 dangling.truncate(NAMED);
4803 let mut listed = dangling.join(", ");
4804 if overflow > 0 {
4805 listed.push_str(&format!(", and {overflow} more"));
4806 }
4807 residue.push(format!(
4808 "{total} Claude record(s) reference a parentUuid absent from the transcript and were \
4809 anchored as segment roots: {listed}"
4810 ));
4811 }
4812
4813 fn project(&mut self, lenient: bool, residue: &mut Vec<String>) -> Result<Vec<usize>> {
4814 let mut retained = vec![true; self.nodes.len()];
4815 if let Some(boundary_index) = self.nodes.iter().rposition(|n| n.compact.is_some()) {
4816 let parents: Option<Vec<Option<String>>> = lenient.then(|| {
4817 self.nodes
4818 .iter()
4819 .map(|node| node.parent_uuid.clone())
4820 .collect()
4821 });
4822 if let Err(error) = self.apply_latest_compaction(boundary_index, &mut retained) {
4823 let Some(parents) = parents else {
4824 return Err(error);
4825 };
4826 // The boundary rewrites parents as it goes, so restore the
4827 // graph it half-edited before continuing without it.
4828 for (node, parent) in self.nodes.iter_mut().zip(parents) {
4829 node.parent_uuid = parent;
4830 }
4831 retained.iter_mut().for_each(|keep| *keep = true);
4832 residue.push(format!(
4833 "the latest Claude compact boundary could not be projected ({error}); \
4834 no pre-compaction record was pruned from this view"
4835 ));
4836 }
4837 }
4838 let sidechain_only = self
4839 .nodes
4840 .iter()
4841 .enumerate()
4842 .filter(|(idx, node)| retained[*idx] && node.kind.is_conversation())
4843 .all(|(_, node)| node.is_sidechain);
4844
4845 let explicit_leaf = self
4846 .last_prompt
4847 .as_ref()
4848 .filter(|(_, explicit)| *explicit)
4849 .and_then(|(uuid, _)| self.by_uuid.get(uuid).copied())
4850 .filter(|idx| retained[*idx]);
4851 let newest_non_sidechain = self
4852 .nodes
4853 .iter()
4854 .enumerate()
4855 .rev()
4856 .find(|(idx, node)| retained[*idx] && !node.is_sidechain)
4857 .map(|(idx, _)| idx);
4858 // Dedicated Claude subagent transcripts are sidechains by design:
4859 // every record, including their root user prompt, has
4860 // `isSidechain:true`. When there is no main-chain candidate, resume
4861 // the newest retained sidechain leaf instead of rejecting the child.
4862 let newest_sidechain = self
4863 .nodes
4864 .iter()
4865 .enumerate()
4866 .rev()
4867 .find(|(idx, node)| retained[*idx] && node.is_sidechain && node.kind.is_conversation())
4868 .map(|(idx, _)| idx);
4869 let mut active = explicit_leaf
4870 .or(newest_non_sidechain)
4871 .or(newest_sidechain)
4872 .ok_or_else(|| claude_replay_error("no Claude record remains after compaction"))?;
4873
4874 // Metadata descendants such as turn_duration are leaves in the raw
4875 // graph. Claude resumes from their nearest user/assistant ancestor,
4876 // then appends those descendants to the reconstructed chain.
4877 let mut seeking = HashSet::new();
4878 while !self.nodes[active].kind.is_conversation() {
4879 if !seeking.insert(active) {
4880 return Err(claude_replay_error(
4881 "cycle while resolving active Claude leaf",
4882 ));
4883 }
4884 active = self.parent_index(active, &retained)?;
4885 }
4886
4887 let mut segments =
4888 vec![self.project_segment(active, &retained, sidechain_only, lenient)?];
4889 if lenient {
4890 for leaf in self.severed_segment_leaves(active, &retained) {
4891 segments.push(self.project_segment(leaf, &retained, sidechain_only, lenient)?);
4892 }
4893 if segments.len() > 1 {
4894 residue.push(format!(
4895 "{} conversation segments were stitched in transcript order because the \
4896 Claude record graph is severed",
4897 segments.len()
4898 ));
4899 }
4900 }
4901 // Each segment keeps its own reconstructed order; the segments
4902 // themselves are spliced by where they start in the file.
4903 segments.retain(|segment| !segment.is_empty());
4904 segments.sort_by_key(|segment| {
4905 segment
4906 .iter()
4907 .map(|idx| self.nodes[*idx].line_index)
4908 .min()
4909 .unwrap_or(usize::MAX)
4910 });
4911 let mut ordered = Vec::new();
4912 let mut placed = HashSet::new();
4913 for idx in segments.into_iter().flatten() {
4914 if placed.insert(idx) {
4915 ordered.push(idx);
4916 }
4917 }
4918
4919 self.recover_parallel_assistant_chunks(ordered, &retained)
4920 .map(|indices| {
4921 indices
4922 .into_iter()
4923 .map(|idx| self.nodes[idx].line_index)
4924 .collect()
4925 })
4926 }
4927
4928 /// Reconstruct one segment: `leaf`'s parent chain, oldest-first, plus the
4929 /// non-conversation descendants rooted at it.
4930 fn project_segment(
4931 &self,
4932 leaf: usize,
4933 retained: &[bool],
4934 sidechain_only: bool,
4935 lenient: bool,
4936 ) -> Result<Vec<usize>> {
4937 let mut reversed = Vec::new();
4938 let mut seen = HashSet::new();
4939 let mut cursor = Some(leaf);
4940 while let Some(idx) = cursor {
4941 if !seen.insert(idx) {
4942 return Err(claude_replay_error(format!(
4943 "cycle in active Claude parentUuid chain at `{}`",
4944 self.nodes[idx].uuid
4945 )));
4946 }
4947 reversed.push(idx);
4948 cursor = match self.nodes[idx].parent_uuid.as_deref() {
4949 Some(parent) => match self.by_uuid.get(parent).copied() {
4950 Some(parent) => Some(parent),
4951 None if self.segment_anchors.contains(parent) => None,
4952 // Claude can resume a background child in-place while
4953 // retaining only the new segment in that child's JSONL.
4954 // Its first record then points to a UUID not present in
4955 // the sidechain file. That external edge is a segment
4956 // boundary, not corruption; the complete source remains
4957 // available byte-for-byte in `raw`.
4958 None if sidechain_only => None,
4959 None => {
4960 return Err(claude_replay_error(format!(
4961 "active Claude record `{}` has missing parentUuid `{parent}`",
4962 self.nodes[idx].uuid
4963 )));
4964 }
4965 },
4966 None => None,
4967 };
4968 if cursor.is_some_and(|parent| !retained[parent]) {
4969 if lenient {
4970 // A compaction boundary is where this segment ends; the
4971 // records it pruned stay pruned.
4972 break;
4973 }
4974 return Err(claude_replay_error(format!(
4975 "active Claude chain crosses an excluded compaction record from `{}`",
4976 self.nodes[idx].uuid
4977 )));
4978 }
4979 }
4980 reversed.reverse();
4981
4982 // Include non-conversation descendants rooted at the segment's leaf
4983 // (turn_duration, attachments, etc.), matching Claude's QYH/n1T.
4984 let mut descendants = Vec::new();
4985 let mut frontier = vec![leaf];
4986 let mut head = 0;
4987 while head < frontier.len() {
4988 let parent = frontier[head];
4989 head += 1;
4990 for (idx, node) in self.nodes.iter().enumerate() {
4991 if !retained[idx]
4992 || node.kind.is_conversation()
4993 || seen.contains(&idx)
4994 || node.parent_uuid.as_deref() != Some(self.nodes[parent].uuid.as_str())
4995 {
4996 continue;
4997 }
4998 seen.insert(idx);
4999 descendants.push(idx);
5000 frontier.push(idx);
5001 }
5002 }
5003 descendants.sort_by_key(|idx| self.nodes[*idx].line_index);
5004 reversed.extend(descendants);
5005 Ok(reversed)
5006 }
5007
5008 /// The newest retained conversation record of every component the active
5009 /// leaf's own component cannot reach.
5010 ///
5011 /// Only a severed graph produces any: a healthy transcript is one
5012 /// component, so the abandoned branches a rewind left behind stay
5013 /// abandoned here exactly as they do under strict projection.
5014 fn severed_segment_leaves(&self, active: usize, retained: &[bool]) -> Vec<usize> {
5015 let active_root = self.component_root(active, retained);
5016 let mut newest_by_root: BTreeMap<usize, usize> = BTreeMap::new();
5017 for idx in 0..self.nodes.len() {
5018 if !retained[idx] || !self.nodes[idx].kind.is_conversation() {
5019 continue;
5020 }
5021 let Some(root) = self.component_root(idx, retained) else {
5022 continue;
5023 };
5024 if Some(root) == active_root {
5025 continue;
5026 }
5027 let newest = newest_by_root.entry(root).or_insert(idx);
5028 if self.nodes[idx].line_index > self.nodes[*newest].line_index {
5029 *newest = idx;
5030 }
5031 }
5032 newest_by_root.into_values().collect()
5033 }
5034
5035 /// Walk `idx` up to the record that anchors its component, stopping at a
5036 /// root, an edge that leaves the transcript, or a pruned parent. `None`
5037 /// when the walk cycles.
5038 fn component_root(&self, idx: usize, retained: &[bool]) -> Option<usize> {
5039 let mut cursor = idx;
5040 let mut seen = HashSet::new();
5041 loop {
5042 if !seen.insert(cursor) {
5043 return None;
5044 }
5045 let next = self.nodes[cursor]
5046 .parent_uuid
5047 .as_deref()
5048 .and_then(|parent| self.by_uuid.get(parent).copied())
5049 .filter(|parent| retained[*parent]);
5050 match next {
5051 Some(parent) => cursor = parent,
5052 None => return Some(cursor),
5053 }
5054 }
5055 }
5056
5057 fn apply_latest_compaction(
5058 &mut self,
5059 boundary_index: usize,
5060 retained: &mut [bool],
5061 ) -> Result<()> {
5062 let compact = self.nodes[boundary_index]
5063 .compact
5064 .clone()
5065 .expect("called with compact boundary");
5066 let mut preserved = compact.preserved_uuids;
5067 if preserved.is_empty() {
5068 if let Some((head, tail)) = compact.preserved_segment {
5069 preserved = self.walk_preserved_segment(&head, &tail)?;
5070 }
5071 }
5072
5073 let preserved_set: HashSet<String> = preserved.iter().cloned().collect();
5074 for uuid in &preserved {
5075 if !self.by_uuid.contains_key(uuid) {
5076 return Err(claude_replay_error(format!(
5077 "latest compact boundary references missing preserved uuid `{uuid}`"
5078 )));
5079 }
5080 }
5081
5082 let removed_uuids: HashSet<String> = self
5083 .nodes
5084 .iter()
5085 .enumerate()
5086 .filter(|(idx, node)| *idx < boundary_index && !preserved_set.contains(&node.uuid))
5087 .map(|(_, node)| node.uuid.clone())
5088 .collect();
5089 for (idx, node) in self.nodes.iter().enumerate() {
5090 if idx < boundary_index && !preserved_set.contains(&node.uuid) {
5091 retained[idx] = false;
5092 }
5093 }
5094
5095 if preserved.is_empty() {
5096 return Ok(());
5097 }
5098 let anchor = compact.anchor_uuid.ok_or_else(|| {
5099 claude_replay_error("preserved compact boundary is missing anchorUuid")
5100 })?;
5101 if !self.by_uuid.contains_key(&anchor) {
5102 return Err(claude_replay_error(format!(
5103 "latest compact boundary references missing anchor uuid `{anchor}`"
5104 )));
5105 }
5106 let tail = preserved.last().cloned().expect("non-empty preserved list");
5107 let mut parent = anchor.clone();
5108 for uuid in &preserved {
5109 let idx = self.by_uuid[uuid];
5110 self.nodes[idx].parent_uuid = Some(parent);
5111 parent = uuid.clone();
5112 }
5113 let first = &preserved[0];
5114 for node in &mut self.nodes {
5115 if node.parent_uuid.as_deref() == Some(anchor.as_str()) && node.uuid != *first {
5116 node.parent_uuid = Some(tail.clone());
5117 }
5118 }
5119 for node in &mut self.nodes {
5120 if node.kind.is_conversation()
5121 && node
5122 .parent_uuid
5123 .as_ref()
5124 .is_some_and(|parent| removed_uuids.contains(parent))
5125 {
5126 node.parent_uuid = Some(tail.clone());
5127 }
5128 }
5129 Ok(())
5130 }
5131
5132 fn walk_preserved_segment(&self, head: &str, tail: &str) -> Result<Vec<String>> {
5133 let mut reversed = Vec::new();
5134 let mut seen = HashSet::new();
5135 let mut cursor = tail;
5136 loop {
5137 if !seen.insert(cursor.to_string()) {
5138 return Err(claude_replay_error("cycle in compact preservedSegment"));
5139 }
5140 let idx = *self.by_uuid.get(cursor).ok_or_else(|| {
5141 claude_replay_error(format!(
5142 "compact preservedSegment references missing uuid `{cursor}`"
5143 ))
5144 })?;
5145 reversed.push(cursor.to_string());
5146 if cursor == head {
5147 reversed.reverse();
5148 return Ok(reversed);
5149 }
5150 cursor = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
5151 claude_replay_error(format!(
5152 "compact preservedSegment tail `{tail}` does not reach head `{head}`"
5153 ))
5154 })?;
5155 }
5156 }
5157
5158 fn parent_index(&self, idx: usize, retained: &[bool]) -> Result<usize> {
5159 let parent = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
5160 claude_replay_error(format!(
5161 "Claude record `{}` has no conversational ancestor",
5162 self.nodes[idx].uuid
5163 ))
5164 })?;
5165 let parent_idx = *self.by_uuid.get(parent).ok_or_else(|| {
5166 claude_replay_error(format!(
5167 "Claude record `{}` has missing parentUuid `{parent}`",
5168 self.nodes[idx].uuid
5169 ))
5170 })?;
5171 if !retained[parent_idx] {
5172 return Err(claude_replay_error(format!(
5173 "Claude record `{}` points into compacted-out history",
5174 self.nodes[idx].uuid
5175 )));
5176 }
5177 Ok(parent_idx)
5178 }
5179
5180 fn recover_parallel_assistant_chunks(
5181 &self,
5182 base: Vec<usize>,
5183 retained: &[bool],
5184 ) -> Result<Vec<usize>> {
5185 let selected: HashSet<usize> = base.iter().copied().collect();
5186 let mut replacements: HashMap<usize, Vec<usize>> = HashMap::new();
5187 let mut skipped_positions = HashSet::new();
5188 let mut handled_ids = HashSet::new();
5189
5190 for (base_pos, idx) in base.iter().copied().enumerate() {
5191 let Some(message_id) = self.nodes[idx].assistant_message_id.as_deref() else {
5192 continue;
5193 };
5194 if !handled_ids.insert(message_id.to_string()) {
5195 continue;
5196 }
5197 let base_positions: Vec<usize> = base
5198 .iter()
5199 .enumerate()
5200 .filter(|(_, candidate)| {
5201 self.nodes[**candidate].assistant_message_id.as_deref() == Some(message_id)
5202 })
5203 .map(|(pos, _)| pos)
5204 .collect();
5205 let anchor_pos = base_positions.first().copied().unwrap_or(base_pos);
5206 skipped_positions.extend(base_positions.iter().copied().skip(1));
5207
5208 // A streamed Anthropic response can be stored as sibling records
5209 // rather than a literal parent chain. Reassemble every chunk at
5210 // the first active occurrence and restore raw chunk order before
5211 // the normalizer coalesces their content blocks.
5212 let mut chunks: Vec<usize> = self
5213 .nodes
5214 .iter()
5215 .enumerate()
5216 .filter(|(candidate, node)| {
5217 retained[*candidate] && node.assistant_message_id.as_deref() == Some(message_id)
5218 })
5219 .map(|(candidate, _)| candidate)
5220 .collect();
5221 chunks.sort_by_key(|candidate| self.nodes[*candidate].line_index);
5222
5223 let assistant_uuids: HashSet<&str> = self
5224 .nodes
5225 .iter()
5226 .filter(|node| node.assistant_message_id.as_deref() == Some(message_id))
5227 .map(|node| node.uuid.as_str())
5228 .collect();
5229 let mut results: Vec<usize> = self
5230 .nodes
5231 .iter()
5232 .enumerate()
5233 .filter(|(candidate, node)| {
5234 retained[*candidate]
5235 && !selected.contains(candidate)
5236 && node.is_tool_result
5237 && node
5238 .parent_uuid
5239 .as_deref()
5240 .is_some_and(|parent| assistant_uuids.contains(parent))
5241 })
5242 .map(|(candidate, _)| candidate)
5243 .collect();
5244 results.sort_by_key(|candidate| self.nodes[*candidate].line_index);
5245 chunks.extend(results);
5246 replacements.insert(anchor_pos, chunks);
5247 }
5248
5249 let mut out = Vec::with_capacity(selected.len());
5250 for (pos, idx) in base.into_iter().enumerate() {
5251 if let Some(replacement) = replacements.remove(&pos) {
5252 out.extend(replacement);
5253 } else if !skipped_positions.contains(&pos) {
5254 out.push(idx);
5255 }
5256 }
5257 Ok(out)
5258 }
5259}
5260
5261impl ClaudeCompactBoundary {
5262 fn from_value(v: &Value) -> Self {
5263 let metadata = v.get("compactMetadata");
5264 let preserved_messages = metadata.and_then(|m| m.get("preservedMessages"));
5265 let anchor_uuid = preserved_messages
5266 .and_then(|p| p.get("anchorUuid"))
5267 .and_then(Value::as_str)
5268 .or_else(|| {
5269 metadata
5270 .and_then(|m| m.get("preservedSegment"))
5271 .and_then(|p| p.get("anchorUuid"))
5272 .and_then(Value::as_str)
5273 })
5274 .map(str::to_string);
5275 let preserved_uuids = preserved_messages
5276 .and_then(|p| p.get("uuids"))
5277 .and_then(Value::as_array)
5278 .map(|uuids| {
5279 uuids
5280 .iter()
5281 .filter_map(Value::as_str)
5282 .map(str::to_string)
5283 .collect()
5284 })
5285 .unwrap_or_default();
5286 let preserved_segment =
5287 metadata
5288 .and_then(|m| m.get("preservedSegment"))
5289 .and_then(|segment| {
5290 Some((
5291 segment.get("headUuid")?.as_str()?.to_string(),
5292 segment.get("tailUuid")?.as_str()?.to_string(),
5293 ))
5294 });
5295 Self {
5296 anchor_uuid,
5297 preserved_uuids,
5298 preserved_segment,
5299 }
5300 }
5301}
5302
5303fn claude_replay_error(message: impl Into<String>) -> crate::Error {
5304 crate::Error::Other(format!(
5305 "cannot reconstruct lossless Claude continuation: {}",
5306 message.into()
5307 ))
5308}
5309
5310fn claude_assistant_message_id(v: &Value) -> Option<&str> {
5311 v.get("message")
5312 .and_then(|message| message.get("id"))
5313 .and_then(Value::as_str)
5314}
5315
5316fn merge_claude_assistant_chunk(target: &mut Value, chunk: &Value) {
5317 let Some(target_message) = target.get_mut("message") else {
5318 return;
5319 };
5320 let Some(chunk_message) = chunk.get("message") else {
5321 return;
5322 };
5323 let mut content = target_message
5324 .get("content")
5325 .and_then(Value::as_array)
5326 .cloned()
5327 .unwrap_or_default();
5328 if let Some(blocks) = chunk_message.get("content").and_then(Value::as_array) {
5329 content.extend(blocks.iter().cloned());
5330 }
5331 let mut merged_message = chunk_message.clone();
5332 merged_message["content"] = Value::Array(content);
5333 *target_message = merged_message;
5334}
5335
5336fn flush_claude_assistant(pending: &mut Option<Value>, out: &mut Vec<ChatMessage>) {
5337 let Some(v) = pending.take() else {
5338 return;
5339 };
5340 let reasoning_only = claude_assistant_message_id(&v).is_some()
5341 && v.get("message")
5342 .and_then(|message| message.get("content"))
5343 .and_then(Value::as_array)
5344 .is_some_and(|blocks| {
5345 !blocks.is_empty()
5346 && blocks.iter().all(|block| {
5347 matches!(
5348 block.get("type").and_then(Value::as_str),
5349 Some("thinking" | "redacted_thinking")
5350 )
5351 })
5352 });
5353 if reasoning_only {
5354 return;
5355 }
5356 let before = out.len();
5357 push_claude_assistant(&v, out);
5358 capture_claude_record_provenance(&v, &mut out[before..]);
5359 restore_single_grok_message(&v, &mut out[before..]);
5360}
5361
5362/// Attach the record identity, clock, and actual assistant model to every
5363/// canonical message produced from one Claude JSONL record. These fields are
5364/// deliberately per-message: a continued transcript can cross a provider
5365/// boundary, so the session-level source model is not authoritative for its
5366/// appended tail.
5367fn capture_claude_record_provenance(v: &Value, messages: &mut [ChatMessage]) {
5368 let timestamp = v.get("timestamp").and_then(Value::as_str);
5369 let uuid = v.get("uuid").and_then(Value::as_str);
5370 let model = v
5371 .get("message")
5372 .and_then(|message| message.get("model"))
5373 .and_then(Value::as_str);
5374 for message in messages {
5375 if let Some(timestamp) = timestamp {
5376 message
5377 .metadata
5378 .entry("timestamp".to_string())
5379 .or_insert_with(|| timestamp.to_string());
5380 }
5381 if let Some(uuid) = uuid {
5382 message
5383 .metadata
5384 .entry("claude_uuid".to_string())
5385 .or_insert_with(|| uuid.to_string());
5386 }
5387 if let Some(model) = model {
5388 message
5389 .metadata
5390 .entry("model".to_string())
5391 .or_insert_with(|| model.to_string());
5392 }
5393 }
5394}
5395
5396fn capture_claude_meta(v: &Value, meta: &mut SessionMeta, raw_line: &str) -> Result<()> {
5397 restore_codex_provenance_from_top_level(v, meta)?;
5398 if meta.session_id.is_none() {
5399 if let Some(id) = v.get("sessionId").and_then(Value::as_str) {
5400 meta.session_id = Some(id.to_string());
5401 }
5402 }
5403 if meta.cwd.is_none() {
5404 if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
5405 meta.cwd = Some(PathBuf::from(cwd));
5406 }
5407 }
5408 if meta.model.is_none() {
5409 if let Some(model) = v
5410 .get("message")
5411 .and_then(|m| m.get("model"))
5412 .and_then(Value::as_str)
5413 {
5414 meta.model = Some(model.to_string());
5415 }
5416 }
5417 // PARITY-10 (provenance P010): `fork-context-ref` is an extremely rare
5418 // real Claude Code record with no confirmed field shape (see
5419 // `ClaudeRecord::ForkContextRef`'s doc comment) — rather than guess at
5420 // named fields and risk silently mis-modeling it, stash the WHOLE raw
5421 // line verbatim under a lineage key. `write_claude_code_records` (below)
5422 // re-emits it byte-for-byte, so the record survives the Claude Code
5423 // semantic writer (not just the CLI's raw-passthrough diagonal path) —
5424 // and `to_codex_jsonl`'s synthesized header carries a namespaced copy
5425 // so a Claude -> Codex -> Claude round trip can still reconstruct it
5426 // (dev/03). A session can only fork from one context, so the first one
5427 // seen wins, matching every other "first wins" field above.
5428 //
5429 // D4 (Fable-5 review, confirmed): this used to store `v.to_string()` —
5430 // a RE-SERIALIZATION of the parsed `Value`, not the original source
5431 // text. `serde_json::Value` here has no `preserve_order` feature (see
5432 // `Cargo.toml`), so its object keys are a `BTreeMap` and get silently
5433 // REORDERED (alphabetized) on re-emit — the "byte-for-byte" claim in
5434 // this very comment was false. Fixed the cheap+honest way: store the
5435 // caller's own already-verbatim source `raw_line` text instead of
5436 // re-serializing `v` at all, so this is now ACTUALLY byte-for-byte
5437 // (key order, spacing, everything) rather than merely
5438 // structurally-equivalent JSON.
5439 if v.get("type").and_then(Value::as_str) == Some("fork-context-ref")
5440 && !meta.lineage.contains_key("claude_fork_context_ref_raw")
5441 {
5442 meta.lineage.insert(
5443 "claude_fork_context_ref_raw".to_string(),
5444 raw_line.to_string(),
5445 );
5446 }
5447 Ok(())
5448}
5449
5450fn push_claude_user(v: &Value, out: &mut Vec<ChatMessage>) {
5451 let content = v.get("message").and_then(|m| m.get("content"));
5452 let provenance = claude_user_provenance(v);
5453 match content {
5454 Some(Value::String(s)) => {
5455 if !s.trim().is_empty() {
5456 out.push(ChatMessage::user(s.clone()).with_metas(&provenance));
5457 }
5458 }
5459 Some(Value::Array(blocks)) => {
5460 let mut text = String::new();
5461 // IX-5: image blocks alongside/instead of text — collected
5462 // separately (never synthesized on a malformed shape, see
5463 // `claude_image_block_to_part`) so a multimodal user turn
5464 // survives as `content_parts` instead of the image silently
5465 // vanishing.
5466 let mut images: Vec<Value> = Vec::new();
5467 // D5: an `image` block whose `source` isn't base64/url (e.g. a
5468 // Files-API `{"source":{"type":"file","file_id":..}}`
5469 // reference) makes `claude_image_block_to_part` return `None` —
5470 // track that it was SEEN even though it couldn't be converted,
5471 // so an image-ONLY record (no text, no convertible image) isn't
5472 // silently dropped below (the same vanishing-record bug-class
5473 // PARITY-11 fixed for reasoning-only turns).
5474 let mut saw_unconvertible_image = false;
5475 for b in blocks {
5476 match b.get("type").and_then(Value::as_str) {
5477 Some("text") => push_text(&mut text, b.get("text")),
5478 Some("tool_result") => {
5479 let id = b
5480 .get("tool_use_id")
5481 .and_then(Value::as_str)
5482 .unwrap_or_default();
5483 // PARITY-11 (nested images): `extract_tool_result_content`
5484 // captures any `image` blocks nested inside this
5485 // `tool_result` into `content_parts` (via
5486 // `claude_image_block_to_part`, the same conversion the
5487 // top-level `image` block path already uses) instead of
5488 // flattening them to the bare `[image]` marker text the
5489 // old `extract_tool_result` emitted — the everyday
5490 // "Read a PNG / screenshot tool output" shape.
5491 let (result, images) =
5492 extract_tool_result_content(b.get("content"), v.get("toolUseResult"));
5493 let mut msg = tool_message(id, result);
5494 if !images.is_empty() {
5495 // D-mix (Fable review, must-fix): `content_parts`
5496 // is a self-contained contract — the pi writer
5497 // (`pi_content_value`) reads ONLY `content_parts`
5498 // for a `Role::Tool` message and never falls back
5499 // to `msg.content`, so on a MIXED text+image
5500 // tool_result a bare `content_parts: [image]`
5501 // silently drops the sibling text on `convert
5502 // --to pi` (a regression vs. the pre-PARITY-11
5503 // baseline, which at least preserved the text).
5504 // Prepend the text as part 0, exactly mirroring
5505 // `pi_content_to_text_and_parts` and
5506 // `push_opencode_user`'s identical
5507 // self-contained-parts construction. `msg.content`
5508 // keeps the text too (unchanged) for the writers
5509 // that read text from `msg.content` and only scan
5510 // `content_parts` for `image_url` entries
5511 // (`claude_tool_result_content_value`,
5512 // `codex_tool_output_text`, the opencode
5513 // assistant writer) — those already filter
5514 // strictly on `image_url`/text-typed lookups, so
5515 // this text part is never double-counted.
5516 let mut parts = Vec::new();
5517 if let Some(t) = &msg.content {
5518 if !t.is_empty() {
5519 parts.push(serde_json::json!({"type": "text", "text": t}));
5520 }
5521 }
5522 parts.extend(images);
5523 msg.content_parts = Some(parts);
5524 }
5525 // The assistant turn that issued this tool call — the
5526 // tool-pairing graph edge (parallel to parentUuid).
5527 if let Some(src) = v.get("sourceToolAssistantUUID").and_then(Value::as_str)
5528 {
5529 msg.metadata
5530 .insert("sourceToolAssistantUUID".to_string(), src.to_string());
5531 }
5532 // TR-10: preserve the Claude wire `is_error` flag so
5533 // the reduction layer's success/failure boundary
5534 // (`ReductionKind::ToolInputElided` must never target
5535 // an errored call) survives import — `ChatMessage`
5536 // otherwise has no structural slot for it.
5537 if b.get("is_error").and_then(Value::as_bool) == Some(true) {
5538 crate::mark_tool_error(&mut msg);
5539 } else {
5540 restore_tool_outcome_extension(v, &mut msg);
5541 }
5542 out.push(msg);
5543 }
5544 Some("image") => match claude_image_block_to_part(b) {
5545 Some(part) => images.push(part),
5546 None => saw_unconvertible_image = true,
5547 },
5548 _ => {} // document / unknown — skip
5549 }
5550 }
5551 // D5: nothing convertible landed in `text`/`images` but an
5552 // image block WAS present — fold in the same short bracketed
5553 // marker convention already used for `[web_search]`/`[model
5554 // fallback: ...]` rather than letting the record vanish.
5555 if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
5556 push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
5557 }
5558 let before = out.len();
5559 if !images.is_empty() {
5560 let mut parts = Vec::new();
5561 if !text.trim().is_empty() {
5562 parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
5563 }
5564 parts.extend(images);
5565 out.push(
5566 ChatMessage {
5567 role: Role::User,
5568 content: None,
5569 content_parts: Some(parts),
5570 tool_calls: None,
5571 tool_call_id: None,
5572 name: None,
5573 metadata: Default::default(),
5574 }
5575 .with_metas(&provenance),
5576 );
5577 } else if !text.trim().is_empty() {
5578 out.push(ChatMessage::user(text).with_metas(&provenance));
5579 }
5580 if saw_unconvertible_image && out.len() > before {
5581 if let Some(msg) = out.last_mut() {
5582 msg.metadata
5583 .insert("image_source_unconvertible".to_string(), "true".to_string());
5584 }
5585 }
5586 }
5587 _ => {}
5588 }
5589}
5590
5591/// D5 (Fable-5 review): the bracketed marker folded into `text` when an
5592/// `image` block is SEEN but [`claude_image_block_to_part`] can't convert it
5593/// (e.g. a Files-API `{"source":{"type":"file",...}}` reference) and nothing
5594/// else in the record survives either — matches the existing
5595/// `[web_search]`/`[model fallback: ...]` convention rather than letting the
5596/// whole record vanish (`push_claude_user`/`push_claude_assistant`).
5597const UNCONVERTIBLE_IMAGE_MARKER: &str =
5598 "[image: source not captured — unsupported/unconvertible image reference]";
5599
5600/// Parse a Claude Code user-turn `image` content block
5601/// (`{"type":"image","source":{"type":"base64","media_type":..,"data":..}}`
5602/// or `{"type":"image","source":{"type":"url","url":..}}`) into a
5603/// `content_parts` `image_url` entry (a `data:` URI for the base64 form, the
5604/// bare URL for the url form) — the inverse of
5605/// [`claude_user_content_value`]'s emission. Only a well-formed source
5606/// (non-empty `media_type`/`data`, or non-empty `url`) is recognized;
5607/// anything else — including a well-formed but unconvertible source like a
5608/// Files-API `{"type":"file",...}` reference (D5) — is left as raw-only
5609/// residue rather than synthesizing a corrupt/empty part (mirrors the
5610/// pi/opencode loaders' `pi_image_shape`/`opencode_file_image_part`
5611/// discipline). Callers must not let that turn the record invisible though:
5612/// see [`UNCONVERTIBLE_IMAGE_MARKER`].
5613fn claude_image_block_to_part(b: &Value) -> Option<Value> {
5614 let source = b.get("source")?;
5615 match source.get("type").and_then(Value::as_str) {
5616 Some("base64") => {
5617 let mime = source.get("media_type").and_then(Value::as_str)?;
5618 let data = source.get("data").and_then(Value::as_str)?;
5619 if mime.is_empty() || data.is_empty() {
5620 return None;
5621 }
5622 Some(serde_json::json!({
5623 "type": "image_url",
5624 "image_url": {"url": format!("data:{mime};base64,{data}")},
5625 }))
5626 }
5627 Some("url") => {
5628 let url = source.get("url").and_then(Value::as_str)?;
5629 if url.is_empty() {
5630 return None;
5631 }
5632 Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
5633 }
5634 _ => None,
5635 }
5636}
5637
5638/// Rebuild a Claude Code user-turn `message.content` value from a
5639/// `ChatMessage` — the inverse of `push_claude_user`'s parse (text blocks +
5640/// [`claude_image_block_to_part`]). When `content_parts` is absent this
5641/// MUST reproduce the historical plain-string `content` exactly (IX-5's
5642/// overriding constraint: a text-only message's export stays byte-identical)
5643/// — only a multimodal message (`content_parts` present, e.g. imported from
5644/// Pi/OpenCode or a real Claude Code image turn) gets the Anthropic
5645/// content-array shape, one `text` block (if any non-empty text part) plus
5646/// one `image` block per `image_url` part (`data:` URI → `source.base64`;
5647/// any other URL → `source.url`).
5648fn claude_user_content_value(msg: &ChatMessage) -> Value {
5649 match &msg.content_parts {
5650 Some(parts) => {
5651 let mut blocks = Vec::new();
5652 for p in parts {
5653 match p.get("type").and_then(Value::as_str) {
5654 Some("text") => {
5655 if let Some(t) = p.get("text").and_then(Value::as_str) {
5656 if !t.is_empty() {
5657 blocks.push(serde_json::json!({"type": "text", "text": t}));
5658 }
5659 }
5660 }
5661 Some("image_url") => {
5662 if let Some(url) = p
5663 .get("image_url")
5664 .and_then(|u| u.get("url"))
5665 .and_then(Value::as_str)
5666 {
5667 blocks.push(match parse_data_uri(url) {
5668 Some((mime, data)) => serde_json::json!({
5669 "type": "image",
5670 "source": {"type": "base64", "media_type": mime, "data": data},
5671 }),
5672 None => serde_json::json!({
5673 "type": "image",
5674 "source": {"type": "url", "url": url},
5675 }),
5676 });
5677 }
5678 }
5679 _ => {}
5680 }
5681 }
5682 Value::Array(blocks)
5683 }
5684 None => Value::String(msg.content.clone().unwrap_or_default()),
5685 }
5686}
5687
5688/// Rebuild a Claude Code `tool_result` block's `content` value from a `Tool`
5689/// `ChatMessage` — the inverse of [`extract_tool_result_content`]'s parse
5690/// (PARITY-11). When `content_parts` is absent (or empty) this MUST reproduce
5691/// the historical plain-string `content` exactly (same IX-5-style constraint
5692/// `claude_user_content_value` follows) — only a `tool_result` that actually
5693/// carries a captured nested image gets the Anthropic content-array shape,
5694/// one `text` block (the existing `msg.content`, if any) plus one `image`
5695/// block per `image_url` part (mirrors `claude_user_content_value`'s
5696/// `data:` URI -> `source.base64` / other URL -> `source.url` mapping).
5697fn claude_tool_result_content_value(msg: &ChatMessage) -> Value {
5698 match &msg.content_parts {
5699 Some(parts) if !parts.is_empty() => {
5700 let mut blocks = Vec::new();
5701 if let Some(t) = &msg.content {
5702 if !t.is_empty() {
5703 blocks.push(serde_json::json!({"type": "text", "text": t}));
5704 }
5705 }
5706 for p in parts {
5707 if p.get("type").and_then(Value::as_str) == Some("image_url") {
5708 if let Some(url) = p
5709 .get("image_url")
5710 .and_then(|u| u.get("url"))
5711 .and_then(Value::as_str)
5712 {
5713 blocks.push(match parse_data_uri(url) {
5714 Some((mime, data)) => serde_json::json!({
5715 "type": "image",
5716 "source": {"type": "base64", "media_type": mime, "data": data},
5717 }),
5718 None => serde_json::json!({
5719 "type": "image",
5720 "source": {"type": "url", "url": url},
5721 }),
5722 });
5723 }
5724 }
5725 }
5726 Value::Array(blocks)
5727 }
5728 _ => Value::String(msg.content.clone().unwrap_or_default()),
5729 }
5730}
5731
5732/// Collect the Claude Code user-turn provenance fields that distinguish real
5733/// human input from system-injected turns and record replay-relevant state.
5734fn claude_user_provenance(v: &Value) -> Vec<(String, String)> {
5735 let mut out = Vec::new();
5736 let mut take_str = |key: &str| {
5737 if let Some(s) = v.get(key).and_then(Value::as_str) {
5738 out.push((key.to_string(), s.to_string()));
5739 }
5740 };
5741 take_str("promptSource"); // typed | queued | system | sdk
5742 take_str("interruptedMessageId");
5743 take_str("sourceToolUseID");
5744 for flag in ["isMeta", "isCompactSummary", "isVisibleInTranscriptOnly"] {
5745 if v.get(flag).and_then(Value::as_bool) == Some(true) {
5746 out.push((flag.to_string(), "true".to_string()));
5747 }
5748 }
5749 if let Some(n) = v.get("queuePriority").and_then(Value::as_i64) {
5750 out.push(("queuePriority".to_string(), n.to_string()));
5751 }
5752 // `origin` is an object like {"kind":"task-notification"} — keep its kind.
5753 if let Some(kind) = v
5754 .get("origin")
5755 .and_then(|o| o.get("kind"))
5756 .and_then(Value::as_str)
5757 {
5758 out.push(("origin".to_string(), kind.to_string()));
5759 }
5760 out
5761}
5762
5763/// Content-bearing Claude `system` events (`scheduled_task_fire`,
5764/// `local_command`, `away_summary`) carry real text that's part of the
5765/// interaction; fold them in as system context. Marker/metric subtypes
5766/// (`turn_duration`, `compact_boundary`, `api_error`, `stop_hook_summary`) carry
5767/// no conversational content and are skipped.
5768fn push_claude_system(v: &Value, out: &mut Vec<ChatMessage>) {
5769 let keep = matches!(
5770 v.get("subtype").and_then(Value::as_str),
5771 Some("scheduled_task_fire") | Some("local_command") | Some("away_summary")
5772 );
5773 if !keep {
5774 return;
5775 }
5776 if let Some(content) = v.get("content").and_then(Value::as_str) {
5777 if !content.trim().is_empty() {
5778 let subtype = v.get("subtype").and_then(Value::as_str).unwrap_or("system");
5779 out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
5780 }
5781 }
5782}
5783
5784/// Fold content-bearing Claude Code `attachment` records into the conversation
5785/// as user-role messages. Most attachment subtypes (`task_reminder`,
5786/// `deferred_tools_delta`, `skill_listing`, `hook_*`, `command_permissions`, …)
5787/// are regenerable system injections and are skipped; only the four that carry
5788/// non-regenerable user/external content are kept.
5789fn push_claude_attachment(v: &Value, out: &mut Vec<ChatMessage>) {
5790 let att = match v.get("attachment") {
5791 Some(a) => a,
5792 None => return,
5793 };
5794 let kind = match att.get("type").and_then(Value::as_str) {
5795 Some(kind) => kind,
5796 None => return,
5797 };
5798 let text = match kind {
5799 // A queued prompt. `commandMode` says whose: `prompt` is the person's
5800 // own text, `task-notification` is the runtime reporting a finished
5801 // background task. Kept verbatim below.
5802 "queued_command" => att
5803 .get("prompt")
5804 .and_then(Value::as_str)
5805 .map(str::to_string),
5806 // A file the user attached: header + contents.
5807 "file" => attachment_with_path(att, "attached file", "filename", "content"),
5808 // A user-edited file snippet.
5809 "edited_text_file" => attachment_with_path(att, "edited file", "filename", "snippet"),
5810 // Injected project memory (CLAUDE.md), point-in-time.
5811 "nested_memory" => attachment_with_path(att, "project memory", "path", "content"),
5812 _ => None, // regenerable system injection — skip
5813 };
5814 let Some(text) = text else { return };
5815 if text.trim().is_empty() {
5816 return;
5817 }
5818 // An attachment record wears the user's ROLE, but the record itself says
5819 // who actually spoke — and that fact is lost the moment the attachment is
5820 // flattened to `[label: path]` text, so carry it as metadata the way
5821 // `push_claude_system` carries `systemSubtype`. Two fields, both verbatim:
5822 //
5823 // `attachmentType` the subtype. `file` / `edited_text_file` /
5824 // `nested_memory` are envelopes the runtime built
5825 // around a file body; a frontend that trusts the role
5826 // shows the reader a numbered source listing in a
5827 // chat bubble apparently sent by themselves.
5828 // `commandMode` present on `queued_command` only, and the whole
5829 // story for it. Measured over the local Claude Code
5830 // corpus (2,512 `queued_command` attachments): 926
5831 // `prompt`, every one of them plain human text, and
5832 // 1,586 `task-notification`, every one of them a
5833 // `<task-notification>` frame — the same text Claude
5834 // Code also writes as a `type:"user"` record stamped
5835 // `origin.kind = "task-notification"`.
5836 //
5837 // Presentation policy (which of these a frontend hides) belongs to the
5838 // frontend — `isContextMessage` in `sdk/client/client.mjs`. The loader's
5839 // job is to stop discarding the producer's own answer.
5840 let mut message = ChatMessage::user(text).with_meta("attachmentType", kind);
5841 if let Some(mode) = att.get("commandMode").and_then(Value::as_str) {
5842 message = message.with_meta("commandMode", mode);
5843 }
5844 out.push(message);
5845}
5846
5847/// Format an attachment as `[<label>: <path>]\n<body>`.
5848fn attachment_with_path(
5849 att: &Value,
5850 label: &str,
5851 path_key: &str,
5852 body_key: &str,
5853) -> Option<String> {
5854 let body = att.get(body_key).and_then(Value::as_str)?;
5855 let path = att
5856 .get(path_key)
5857 .or_else(|| att.get("displayPath"))
5858 .and_then(Value::as_str)
5859 .unwrap_or("");
5860 Some(format!("[{label}: {path}]\n{body}"))
5861}
5862
5863fn push_str_field(buf: &mut String, s: &str) {
5864 if !buf.is_empty() {
5865 buf.push('\n');
5866 }
5867 buf.push_str(s);
5868}
5869
5870/// N3: build a synthesized message for reasoning that could not attach to a
5871/// following assistant turn — either interrupted mid-stream by a
5872/// non-assistant item, or dangling at EOF (an aborted-turn shape). Drains
5873/// the three pending buffers (all empty/`false` afterward) so callers don't
5874/// separately have to remember to clear them.
5875fn orphaned_reasoning_message(
5876 reasoning: &mut String,
5877 reasoning_content: &mut String,
5878 encrypted: &mut bool,
5879) -> ChatMessage {
5880 let mut msg = ChatMessage::system("[reasoning] (turn ended without a reply)".to_string());
5881 if !reasoning.is_empty() {
5882 msg = msg.with_meta("reasoning", std::mem::take(reasoning));
5883 }
5884 if !reasoning_content.is_empty() {
5885 msg = msg.with_meta("reasoning_content", std::mem::take(reasoning_content));
5886 }
5887 if *encrypted {
5888 msg = msg.with_meta("reasoning_encrypted", "true");
5889 *encrypted = false;
5890 }
5891 msg
5892}
5893
5894fn push_claude_assistant(v: &Value, out: &mut Vec<ChatMessage>) {
5895 let content = v.get("message").and_then(|m| m.get("content"));
5896 let mut text = String::new();
5897 let mut calls: Vec<ToolCall> = Vec::new();
5898 // Legacy singular fields — kept for backward compatibility with every
5899 // existing consumer of `metadata["thinking"]`/`["thinking_signature"]`/
5900 // `["redacted_thinking"]` (a concatenation of all thinking text, and the
5901 // LAST block's signature/data). D8 (Fable-5 review, confirmed): when a
5902 // message carries MULTIPLE `thinking` blocks, collapsing them down to
5903 // these singular fields silently drops every signature but the last
5904 // one's — a real Anthropic `thinking` block's `signature` cryptographically
5905 // covers ONLY that block's own text, so re-emitting block 1's text under
5906 // block 2's signature (or vice versa) produces a signature that will
5907 // never verify. `thinking_blocks` below is the fix: every block
5908 // preserved SEPARATELY, in order, each with its own (optional)
5909 // signature/data — the writer prefers it over the legacy fields
5910 // whenever present.
5911 let mut thinking = String::new();
5912 let mut signature: Option<String> = None;
5913 // PARITY-11: real Claude corpora also carry `redacted_thinking` and
5914 // `image` assistant blocks, and (rarely) a `fallback` model-routing
5915 // marker — none handled before, all silently vanishing (audit's own
5916 // census: 132,927 `thinking` / 67 `redacted_thinking` / 9 `image` / 4
5917 // `fallback` blocks in the reference corpus).
5918 //
5919 // D8: `redacted_thinking` is real data ONLY — never a fabricated
5920 // placeholder. The pre-fix code defaulted a missing `data` field to the
5921 // literal string `"<redacted>"`, which is indistinguishable from an
5922 // actual (if oddly-named) opaque payload on re-emit — a caller reading
5923 // it back has no way to tell "no data was ever captured" from "the
5924 // provider's own opaque blob happens to be the string `<redacted>`".
5925 // `redacted_thinking_seen` tracks block PRESENCE independently of
5926 // whether it had real data, so the reasoning-only-turn rescue below
5927 // still fires even when no block had a `data` field at all.
5928 let mut redacted_thinking: Option<String> = None;
5929 let mut redacted_thinking_seen = false;
5930 let mut images: Vec<Value> = Vec::new();
5931 // Real Anthropic `thinking` blocks very commonly carry an EMPTY
5932 // `thinking` string alongside a real `signature` (the summarized/
5933 // redacted-in-the-clear-but-replayable case) — `thinking.trim()` alone
5934 // would miss those, so track "a thinking block existed at all"
5935 // separately from whether it had visible text.
5936 let mut thinking_block_seen = false;
5937 // D8: every `thinking`/`redacted_thinking` block, preserved SEPARATELY
5938 // and IN ORDER — see the comment on `thinking`/`redacted_thinking`
5939 // above. Serialized as a single JSON-array metadata string
5940 // (`ChatMessage::metadata` is a flat string map) under
5941 // `"thinking_blocks"`.
5942 let mut thinking_blocks: Vec<Value> = Vec::new();
5943 // D5: mirrors `push_claude_user`'s tracking — an `image` block whose
5944 // source isn't base64/url (e.g. Files-API `{"type":"file",...}`) must
5945 // not silently vanish the whole record when nothing else survives.
5946 let mut saw_unconvertible_image = false;
5947
5948 match content {
5949 Some(Value::String(s)) => push_text(&mut text, Some(&Value::String(s.clone()))),
5950 Some(Value::Array(blocks)) => {
5951 for b in blocks {
5952 match b.get("type").and_then(Value::as_str) {
5953 Some("text") => push_text(&mut text, b.get("text")),
5954 Some("tool_use") => {
5955 let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
5956 let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
5957 let args = b
5958 .get("input")
5959 .map(|i| i.to_string())
5960 .unwrap_or_else(|| "{}".to_string());
5961 calls.push(function_call(id, name, args));
5962 }
5963 // Thinking is not replayed across providers, but retain it in
5964 // (skip-serialized) metadata so a same-model continuation can
5965 // re-inject it. See P3.
5966 Some("thinking") => {
5967 thinking_block_seen = true;
5968 let t = b.get("thinking").and_then(Value::as_str).unwrap_or("");
5969 if !t.is_empty() {
5970 push_str_field(&mut thinking, t); // legacy concatenated field
5971 }
5972 let sig = b.get("signature").and_then(Value::as_str);
5973 if let Some(s) = sig {
5974 signature = Some(s.to_string()); // legacy last-wins field
5975 }
5976 // D8: this block's OWN text + signature, not folded
5977 // into the running concatenation above.
5978 let mut block = serde_json::json!({"type": "thinking", "thinking": t});
5979 if let Some(s) = sig {
5980 block["signature"] = Value::String(s.to_string());
5981 }
5982 thinking_blocks.push(block);
5983 }
5984 // Anthropic's redacted reasoning: an opaque, provider-private
5985 // payload (flagged content the API declines to show in the
5986 // clear). Like `thinking`, it's not replayable, but the raw
5987 // `data` is retained in metadata rather than silently
5988 // vanishing — a same-model continuation can still replay it
5989 // verbatim even though supercode never renders it.
5990 Some("redacted_thinking") => {
5991 redacted_thinking_seen = true;
5992 let data = b.get("data").and_then(Value::as_str);
5993 // D8: no fabricated fallback — `data` is only ever
5994 // the real captured payload, or genuinely absent.
5995 if let Some(d) = data {
5996 redacted_thinking = Some(d.to_string()); // legacy last-wins field
5997 }
5998 let mut block = serde_json::json!({"type": "redacted_thinking"});
5999 if let Some(d) = data {
6000 block["data"] = Value::String(d.to_string());
6001 }
6002 thinking_blocks.push(block);
6003 }
6004 // An assistant-emitted image block (e.g. a generated
6005 // image) — collected exactly like `push_claude_user`'s
6006 // user-turn image handling (`claude_image_block_to_part`
6007 // is role-general), so it survives as `content_parts`
6008 // instead of vanishing.
6009 Some("image") => match claude_image_block_to_part(b) {
6010 Some(part) => images.push(part),
6011 None => saw_unconvertible_image = true,
6012 },
6013 // A provider-routing note (real shape:
6014 // `{"type":"fallback","from":{"model":..},"to":{"model":..}}`
6015 // — a mid-generation model swap, e.g. an overloaded model
6016 // falling back to another). Carries no replayable
6017 // conversational content, but folding it into `text` as a
6018 // short bracketed marker — the same convention the Codex
6019 // loader already uses for `[web_search]`/
6020 // `[image_generation] ...` — keeps it visible instead of
6021 // silently vanishing, including the case where it's the
6022 // ONLY block in the turn (see the reasoning-only-turn fix
6023 // below: before this, that shape dropped the entire
6024 // message).
6025 Some("fallback") => {
6026 let from = b
6027 .get("from")
6028 .and_then(|f| f.get("model"))
6029 .and_then(Value::as_str)
6030 .unwrap_or("?");
6031 let to = b
6032 .get("to")
6033 .and_then(|t| t.get("model"))
6034 .and_then(Value::as_str)
6035 .unwrap_or("?");
6036 push_str_field(&mut text, &format!("[model fallback: {from} -> {to}]"));
6037 }
6038 _ => {}
6039 }
6040 }
6041 }
6042 _ => {}
6043 }
6044
6045 // D5: nothing convertible landed in `text`/`images` but an image block
6046 // WAS present — fold in the same bracketed-marker convention `fallback`
6047 // uses above, so a genuinely image-only (unconvertible source) turn
6048 // doesn't vanish (mirrors `push_claude_user`'s identical fix).
6049 if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
6050 push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
6051 }
6052
6053 let before = out.len();
6054 if !images.is_empty() {
6055 let mut parts = Vec::new();
6056 if !text.trim().is_empty() {
6057 parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
6058 }
6059 parts.extend(images);
6060 out.push(ChatMessage {
6061 role: Role::Assistant,
6062 content: None,
6063 content_parts: Some(parts),
6064 tool_calls: (!calls.is_empty()).then_some(calls),
6065 tool_call_id: None,
6066 name: None,
6067 metadata: Default::default(),
6068 });
6069 } else {
6070 push_assistant(out, text, calls);
6071 // A recognized native assistant record remains transcript state even
6072 // when its content array is empty (for example, an interrupted model
6073 // turn). Force a bare message whenever `push_assistant` had nothing
6074 // to emit. This includes the reasoning-only case and also preserves
6075 // genuinely part-less records instead of silently changing turn
6076 // count/order during translation.
6077 if out.len() == before {
6078 let mut empty = ChatMessage {
6079 role: Role::Assistant,
6080 content: None,
6081 content_parts: None,
6082 tool_calls: None,
6083 tool_call_id: None,
6084 name: None,
6085 metadata: Default::default(),
6086 };
6087 if !thinking_block_seen && !redacted_thinking_seen {
6088 empty
6089 .metadata
6090 .insert("empty_assistant_record".to_string(), "true".to_string());
6091 }
6092 out.push(empty);
6093 }
6094 }
6095 // Attach retained reasoning + attribution to the message we just produced.
6096 if out.len() > before {
6097 if let Some(msg) = out.last_mut() {
6098 // Insert "thinking" (even as an empty string) whenever a
6099 // `thinking` block was actually seen, not just when it had
6100 // visible text — a real `thinking` block commonly carries an
6101 // empty `thinking` string alongside a real `signature` (the
6102 // summarized-away-but-still-replayable case), and the writer
6103 // below keys its re-emission decision off this metadata key's
6104 // PRESENCE, not its content.
6105 if thinking_block_seen {
6106 msg.metadata.insert("thinking".to_string(), thinking);
6107 }
6108 if let Some(sig) = signature {
6109 msg.metadata.insert("thinking_signature".to_string(), sig);
6110 }
6111 if let Some(rt) = redacted_thinking {
6112 msg.metadata.insert("redacted_thinking".to_string(), rt);
6113 }
6114 // D8: exact per-block re-emission list — every `thinking`/
6115 // `redacted_thinking` block preserved separately, in order, each
6116 // with its own (optional) signature/data. The writer prefers
6117 // this over the legacy singular fields above whenever present,
6118 // so a multi-block message round-trips losslessly instead of
6119 // collapsing to one block under one (now-unverifiable)
6120 // signature.
6121 if !thinking_blocks.is_empty() {
6122 msg.metadata.insert(
6123 "thinking_blocks".to_string(),
6124 Value::Array(thinking_blocks).to_string(),
6125 );
6126 }
6127 // D5: honest signal that this message contained an image block
6128 // whose source this loader couldn't convert — the actual image
6129 // content is NOT captured, only a marker/partial record.
6130 if saw_unconvertible_image {
6131 msg.metadata
6132 .insert("image_source_unconvertible".to_string(), "true".to_string());
6133 }
6134 // Attribution: which skill / subagent / MCP server+tool produced
6135 // this turn, plus the model `slug`.
6136 for key in [
6137 "attributionSkill",
6138 "attributionAgent",
6139 "attributionMcpServer",
6140 "attributionMcpTool",
6141 "slug",
6142 ] {
6143 if let Some(s) = v.get(key).and_then(Value::as_str) {
6144 msg.metadata.insert(key.to_string(), s.to_string());
6145 }
6146 }
6147 }
6148 }
6149}
6150
6151// ---- Codex ----------------------------------------------------------------
6152
6153const SUPERCODE_CODEX_PROVENANCE_KEY: &str = "_supercode_codex_provenance";
6154
6155fn codex_provenance_kind(record: &Value) -> Option<&str> {
6156 match record.get("type").and_then(Value::as_str) {
6157 Some("session_meta") => Some("session_meta"),
6158 Some("turn_context") => Some("turn_context"),
6159 Some("compacted") => Some("compacted"),
6160 Some("event_msg") => match record
6161 .get("payload")
6162 .and_then(|payload| payload.get("type"))
6163 .and_then(Value::as_str)
6164 {
6165 Some("thread_rolled_back") => Some("event_msg/thread_rolled_back"),
6166 Some("thread_goal_updated") => Some("event_msg/thread_goal_updated"),
6167 Some("entered_review_mode") => Some("event_msg/entered_review_mode"),
6168 Some("exited_review_mode") => Some("event_msg/exited_review_mode"),
6169 _ => None,
6170 },
6171 _ => None,
6172 }
6173}
6174
6175fn capture_codex_provenance_record(
6176 meta: &mut SessionMeta,
6177 record_index: usize,
6178 raw_line: &str,
6179 record: &Value,
6180) {
6181 let Some(kind) = codex_provenance_kind(record) else {
6182 return;
6183 };
6184 meta.codex_provenance.push(serde_json::json!({
6185 "record_index": record_index,
6186 "kind": kind,
6187 "raw": raw_line,
6188 }));
6189}
6190
6191fn codex_provenance_envelope(meta: &SessionMeta) -> Option<Value> {
6192 (!meta.codex_provenance.is_empty()).then(|| {
6193 serde_json::json!({
6194 "version": 1,
6195 "records": &meta.codex_provenance,
6196 })
6197 })
6198}
6199
6200fn restore_codex_provenance(extension: &Value, meta: &mut SessionMeta) -> Result<bool> {
6201 if extension.get("version").and_then(Value::as_u64) != Some(1) {
6202 return Err(Error::InvalidSession(
6203 "invalid portable Codex provenance: expected version 1".to_string(),
6204 ));
6205 }
6206 let Some(records) = extension.get("records").and_then(Value::as_array) else {
6207 return Err(Error::InvalidSession(
6208 "invalid portable Codex provenance: `records` must be an array".to_string(),
6209 ));
6210 };
6211 if records.is_empty() {
6212 return Err(Error::InvalidSession(
6213 "invalid portable Codex provenance: `records` must not be empty".to_string(),
6214 ));
6215 }
6216 let mut restored = Vec::with_capacity(records.len());
6217 for entry in records {
6218 let Some(_record_index) = entry.get("record_index").and_then(Value::as_u64) else {
6219 return Err(Error::InvalidSession(
6220 "invalid portable Codex provenance: record_index must be an integer".to_string(),
6221 ));
6222 };
6223 let Some(kind) = entry.get("kind").and_then(Value::as_str) else {
6224 return Err(Error::InvalidSession(
6225 "invalid portable Codex provenance: kind must be a string".to_string(),
6226 ));
6227 };
6228 let Some(raw) = entry.get("raw").and_then(Value::as_str) else {
6229 return Err(Error::InvalidSession(
6230 "invalid portable Codex provenance: raw must be a string".to_string(),
6231 ));
6232 };
6233 let Ok(record) = serde_json::from_str::<Value>(raw) else {
6234 return Err(Error::InvalidSession(
6235 "invalid portable Codex provenance: raw is not valid JSON".to_string(),
6236 ));
6237 };
6238 if codex_provenance_kind(&record) != Some(kind) {
6239 return Err(Error::InvalidSession(format!(
6240 "invalid portable Codex provenance: kind `{kind}` does not match raw record"
6241 )));
6242 }
6243 restored.push(entry.clone());
6244 }
6245 meta.codex_provenance = restored;
6246 meta.codex_headers.clear();
6247 for entry in &meta.codex_provenance {
6248 let Some(raw) = entry.get("raw").and_then(Value::as_str) else {
6249 continue;
6250 };
6251 let Ok(record) = serde_json::from_str::<Value>(raw) else {
6252 continue;
6253 };
6254 if matches!(
6255 record.get("type").and_then(Value::as_str),
6256 Some("session_meta") | Some("turn_context")
6257 ) {
6258 meta.codex_headers.push(record);
6259 }
6260 }
6261 Ok(true)
6262}
6263
6264fn restore_codex_provenance_from_top_level(record: &Value, meta: &mut SessionMeta) -> Result<bool> {
6265 match record.get(SUPERCODE_CODEX_PROVENANCE_KEY) {
6266 Some(extension) => restore_codex_provenance(extension, meta),
6267 None => Ok(false),
6268 }
6269}
6270
6271fn inject_first_jsonl_top_level(out: &mut String, key: &str, extension: Value) {
6272 let Some(line_end) = out.find('\n') else {
6273 return;
6274 };
6275 let Ok(mut record) = serde_json::from_str::<Value>(&out[..line_end]) else {
6276 return;
6277 };
6278 let Some(object) = record.as_object_mut() else {
6279 return;
6280 };
6281 object.insert(key.to_string(), extension);
6282 out.replace_range(..line_end, &record.to_string());
6283}
6284
6285fn inject_codex_provenance(out: &mut String, extension: Value) {
6286 let Some(line_end) = out.find('\n') else {
6287 return;
6288 };
6289 let Ok(mut record) = serde_json::from_str::<Value>(&out[..line_end]) else {
6290 return;
6291 };
6292 if record.get("type").and_then(Value::as_str) != Some("session_meta") {
6293 return;
6294 }
6295 let Some(payload) = record.get_mut("payload").and_then(Value::as_object_mut) else {
6296 return;
6297 };
6298 payload.insert(SUPERCODE_CODEX_PROVENANCE_KEY.to_string(), extension);
6299 out.replace_range(..line_end, &record.to_string());
6300}
6301
6302/// Remove the last conversational turn from `messages`: everything from the
6303/// last `user` message to the end (the user prompt plus the assistant's
6304/// response and any tool calls/results it triggered).
6305fn remove_last_turn(messages: &mut Vec<ChatMessage>) {
6306 if let Some(idx) = messages.iter().rposition(|m| m.role == Role::User) {
6307 messages.truncate(idx);
6308 } else {
6309 messages.clear();
6310 }
6311 // IX-6 fix: the new tail exposed by `truncate` may still carry
6312 // `__codex_open_turn` from when it was marked (it was NOT the last
6313 // message at that time — items after it, now removed by the rollback,
6314 // intervened). A bare `function_call` arriving after the rollback is a
6315 // genuinely NEW turn and must get its own message, not merge into this
6316 // stale marked tail — close it out here so `push_codex_item`'s
6317 // adjacency check (`out.last()` + marker) can't be fooled by the
6318 // truncation re-exposing it.
6319 if let Some(last) = messages.last_mut() {
6320 last.metadata.remove("__codex_open_turn");
6321 }
6322}
6323
6324fn truncate_messages(messages: &mut Vec<ChatMessage>, message_limit: usize) {
6325 truncate_messages_with_anchor(messages, message_limit, None);
6326}
6327
6328fn truncate_messages_with_anchor(
6329 messages: &mut Vec<ChatMessage>,
6330 message_limit: usize,
6331 preceding_user: Option<ChatMessage>,
6332) {
6333 let limit = message_limit.max(1);
6334 if messages.len() <= limit {
6335 return;
6336 }
6337 let tail_start = messages.len() - limit;
6338 if let Some(relative_user) = messages[tail_start..]
6339 .iter()
6340 .position(|message| message.role == Role::User)
6341 {
6342 messages.drain(..tail_start + relative_user);
6343 return;
6344 }
6345 let anchor = messages[..tail_start]
6346 .iter()
6347 .rfind(|message| message.role == Role::User)
6348 .cloned()
6349 .or(preceding_user);
6350 if let Some(anchor) = anchor {
6351 let recent_start = messages.len() - limit.saturating_sub(1);
6352 messages.drain(..recent_start);
6353 messages.insert(0, anchor);
6354 } else {
6355 messages.drain(..tail_start);
6356 }
6357}
6358
6359fn truncate_session_messages(session: &mut Session, message_limit: usize) {
6360 truncate_messages(&mut session.messages, message_limit);
6361}
6362
6363/// The text of a Codex `agent_message` event. `message` is usually a string but
6364/// can be a structured object (e.g. review output) — fall back to its JSON.
6365fn agent_message_text(payload: &Value) -> String {
6366 match payload.get("message") {
6367 Some(Value::String(s)) => s.clone(),
6368 Some(other) => extract_text_content(Some(other)),
6369 None => String::new(),
6370 }
6371}
6372
6373/// Trimmed texts of all assistant messages present as `response_item` — the
6374/// dedup set for recovering collab-only `agent_message` narration.
6375fn collect_codex_assistant_texts(jsonl: &str) -> std::collections::HashSet<String> {
6376 let mut set = std::collections::HashSet::new();
6377 for line in non_empty_lines(jsonl) {
6378 let Ok(v) = serde_json::from_str::<Value>(line) else {
6379 continue;
6380 };
6381 if v.get("type").and_then(Value::as_str) != Some("response_item") {
6382 continue;
6383 }
6384 let payload = v.get("payload").unwrap_or(&Value::Null);
6385 if payload.get("type").and_then(Value::as_str) == Some("message")
6386 && payload.get("role").and_then(Value::as_str) == Some("assistant")
6387 {
6388 let text = extract_text_content(payload.get("content"));
6389 if !text.trim().is_empty() {
6390 set.insert(text.trim().to_string());
6391 }
6392 }
6393 }
6394 set
6395}
6396
6397fn capture_codex_session_meta(payload: &Value, meta: &mut SessionMeta) {
6398 if meta.session_id.is_none() {
6399 if let Some(id) = payload.get("id").and_then(Value::as_str) {
6400 meta.session_id = Some(id.to_string());
6401 }
6402 }
6403 if meta.cwd.is_none() {
6404 if let Some(cwd) = payload.get("cwd").and_then(Value::as_str) {
6405 meta.cwd = Some(PathBuf::from(cwd));
6406 }
6407 }
6408 if meta.system_prompt.is_none() {
6409 // `base_instructions` may be a string or `{ "text": "..." }`.
6410 let bi = payload.get("base_instructions");
6411 let text = match bi {
6412 Some(Value::String(s)) => Some(s.clone()),
6413 Some(Value::Object(_)) => bi
6414 .and_then(|b| b.get("text"))
6415 .and_then(Value::as_str)
6416 .map(str::to_string),
6417 _ => None,
6418 };
6419 meta.system_prompt = text;
6420 }
6421 if meta.model.is_none() {
6422 if let Some(m) = payload.get("model").and_then(Value::as_str) {
6423 meta.model = Some(m.to_string());
6424 }
6425 }
6426 // Cross-file lineage keys for multi-agent / forked sessions.
6427 let mut put = |key: &str, v: Option<&Value>| {
6428 if let Some(s) = v.and_then(Value::as_str) {
6429 meta.lineage.insert(key.to_string(), s.to_string());
6430 }
6431 };
6432 put("parent_thread_id", payload.get("parent_thread_id"));
6433 put("forked_from_id", payload.get("forked_from_id"));
6434 put("thread_source", payload.get("thread_source"));
6435 // PARITY-10 dev/03: the other half of `write_synthesized_codex_header`'s
6436 // passthrough — restores a captured Claude `fork-context-ref` so a
6437 // Claude -> Codex -> Claude round trip reconstructs the original record
6438 // (`to_claude_code_jsonl` re-emits whatever lands in this lineage key).
6439 if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
6440 if let Some(v) = payload.get("claude_fork_context_ref") {
6441 meta.lineage
6442 .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
6443 }
6444 }
6445 if let Some(spawn) = payload
6446 .get("source")
6447 .and_then(|s| s.get("subagent"))
6448 .and_then(|s| s.get("thread_spawn"))
6449 {
6450 // parent_thread_id can also live here (preferred when both present).
6451 if let Some(p) = spawn.get("parent_thread_id").and_then(Value::as_str) {
6452 meta.lineage
6453 .insert("parent_thread_id".to_string(), p.to_string());
6454 }
6455 for k in ["agent_role", "agent_nickname"] {
6456 if let Some(s) = spawn.get(k).and_then(Value::as_str) {
6457 meta.lineage.insert(k.to_string(), s.to_string());
6458 }
6459 }
6460 if let Some(d) = spawn.get("depth").and_then(Value::as_i64) {
6461 meta.lineage.insert("depth".to_string(), d.to_string());
6462 }
6463 }
6464}
6465
6466/// Depth of a node in the parent forest (root = 0), bounded against cycles.
6467fn depth_of(mut i: usize, parent_of: &[Option<usize>]) -> usize {
6468 let mut d = 0;
6469 let mut guard = 0;
6470 while let Some(p) = parent_of[i] {
6471 if p == i || guard > parent_of.len() {
6472 break;
6473 }
6474 i = p;
6475 d += 1;
6476 guard += 1;
6477 }
6478 d
6479}
6480
6481/// Codex per-turn grouping id, stored under `payload.metadata.turn_id`.
6482fn codex_turn_id(payload: &Value) -> Option<&str> {
6483 payload
6484 .get("metadata")
6485 .and_then(|m| m.get("turn_id"))
6486 .and_then(Value::as_str)
6487}
6488
6489/// N2 (spliced-export hardening): every Codex group id already present in
6490/// `raw_prefix` — the verbatim RAW lines [`Session::to_codex_jsonl_spliced`]
6491/// replays ahead of the appended tail it synthesizes via
6492/// `Session::write_codex_records`. This is the GROUND TRUTH of what
6493/// physically lands in the exported `out` string for the prefix: each line
6494/// is parsed as a Codex envelope and its own `payload.metadata.turn_id` (the
6495/// exact field `codex_turn_id` reads, whether it's a real native `turn_id`
6496/// or one of OUR OWN fabricated `sc-grp-N`/`<real>~dupN` ids from a prior
6497/// export) is extracted directly — no re-derivation from `self.messages`
6498/// needed (that would have to reconstruct which ids the ORIGINAL export
6499/// happened to assign, which this sidesteps entirely by reading them back
6500/// out of the bytes themselves). A line that fails to parse, isn't a
6501/// `response_item`, or carries no `turn_id` contributes nothing — headers
6502/// and non-message/call records (e.g. `session_meta`, `function_call_output`)
6503/// never carry this field to begin with.
6504fn collect_codex_group_ids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
6505 let mut ids = HashSet::new();
6506 for line in raw_prefix {
6507 if let Ok(v) = serde_json::from_str::<Value>(line) {
6508 if let Some(payload) = v.get("payload") {
6509 if let Some(tid) = codex_turn_id(payload) {
6510 ids.insert(tid.to_string());
6511 }
6512 }
6513 }
6514 }
6515 ids
6516}
6517
6518/// Stamp every `ChatMessage` appended to `messages` since index `from` with
6519/// `ts` (a Codex record's own top-level `timestamp`, ISO-8601) as the
6520/// canonical `metadata["timestamp"]` (WAVE-2 item 1) — the same
6521/// `entry(...).or_insert_with` discipline the pi/Claude loaders use, so a
6522/// message that already carries a more specific timestamp of its own is
6523/// never overwritten (none currently do on the Codex side, but this keeps
6524/// every loader consistent). A no-op when `ts` is `None` (a line with no
6525/// `timestamp` field) or `from >= messages.len()` (nothing new was pushed).
6526fn stamp_new_codex_messages(messages: &mut [ChatMessage], from: usize, ts: Option<&str>) {
6527 let Some(ts) = ts else { return };
6528 let Some(slice) = messages.get_mut(from..) else {
6529 return;
6530 };
6531 for m in slice {
6532 m.metadata
6533 .entry("timestamp".to_string())
6534 .or_insert_with(|| ts.to_string());
6535 }
6536}
6537
6538fn push_codex_item(payload: &Value, out: &mut Vec<ChatMessage>) {
6539 match payload.get("type").and_then(Value::as_str) {
6540 Some("message") => {
6541 let role = match payload.get("role").and_then(Value::as_str) {
6542 Some("user") => Role::User,
6543 Some("assistant") => Role::Assistant,
6544 // "developer" and "system" both carry operator instructions.
6545 _ => Role::System,
6546 };
6547 let content = payload.get("content");
6548 let text = extract_text_content(content);
6549 // IX-5: `input_image` blocks alongside/instead of text — see
6550 // `codex_extract_images`. A text-only message (no image blocks)
6551 // takes the historical `content: Some(text)` shape unchanged.
6552 let images = codex_extract_images(content);
6553 let is_empty_assistant =
6554 role == Role::Assistant && text.trim().is_empty() && images.is_empty();
6555 if !text.trim().is_empty() || !images.is_empty() || is_empty_assistant {
6556 let content_parts = if images.is_empty() {
6557 None
6558 } else {
6559 let mut parts = Vec::new();
6560 if !text.trim().is_empty() {
6561 parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
6562 }
6563 parts.extend(images);
6564 Some(parts)
6565 };
6566 let mut msg = ChatMessage {
6567 role,
6568 content: if content_parts.is_some() || text.is_empty() {
6569 None
6570 } else {
6571 Some(text)
6572 },
6573 content_parts,
6574 tool_calls: None,
6575 tool_call_id: None,
6576 name: None,
6577 metadata: Default::default(),
6578 };
6579 // Preserve the assistant `phase` (commentary vs final_answer) so
6580 // a reloaded transcript can distinguish narration from the answer.
6581 if role == Role::Assistant {
6582 if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
6583 msg.metadata.insert("phase".to_string(), phase.to_string());
6584 }
6585 // IX-6: mark this as an open, mergeable combined-turn
6586 // candidate — a `function_call` response_item found
6587 // immediately after (still `out.last()` when reached,
6588 // i.e. no other item intervened) merges into this SAME
6589 // `ChatMessage` instead of splitting into a second one,
6590 // matching how Claude's parser keeps a text+tool_use
6591 // turn together. Stripped again before the loaded
6592 // `Session` is returned (`from_codex_str`), so it never
6593 // leaks as visible metadata.
6594 msg.metadata
6595 .insert("__codex_open_turn".to_string(), "true".to_string());
6596 }
6597 // The per-turn grouping key (Codex batches items by turn_id).
6598 if let Some(tid) = codex_turn_id(payload) {
6599 msg.metadata.insert("turn_id".to_string(), tid.to_string());
6600 }
6601 // PARITY-6 dev/02: restore the original Claude
6602 // `systemSubtype` for a `developer`/`system` message that
6603 // was itself synthesized FROM a real Claude system record
6604 // (`write_codex_records`'s `Role::System` arm stamps
6605 // `claude_system_subtype`) — the exact inverse, so
6606 // `write_claude_code_records`'s `Role::System` arm can
6607 // re-materialize the real Claude `type: "system"` record
6608 // faithfully on a Codex -> Claude Code hop instead of
6609 // guessing a fallback subtype.
6610 if role == Role::System {
6611 if let Some(subtype) = payload
6612 .get("metadata")
6613 .and_then(|m| m.get("claude_system_subtype"))
6614 .and_then(Value::as_str)
6615 {
6616 msg.metadata
6617 .insert("systemSubtype".to_string(), subtype.to_string());
6618 }
6619 }
6620 if is_empty_assistant {
6621 msg.metadata
6622 .insert("empty_assistant_record".to_string(), "true".to_string());
6623 }
6624 out.push(msg);
6625 }
6626 }
6627 Some("function_call") => {
6628 let id = payload
6629 .get("call_id")
6630 .and_then(Value::as_str)
6631 .unwrap_or_default();
6632 let raw_name = payload
6633 .get("name")
6634 .and_then(Value::as_str)
6635 .unwrap_or_default();
6636 // Preserve the MCP `namespace` by qualifying the tool name
6637 // (`<namespace>__<name>`, matching the mcp__server__tool convention),
6638 // so the tool identity isn't ambiguous on round-trip.
6639 let qualified;
6640 let name = match payload.get("namespace").and_then(Value::as_str) {
6641 Some(ns) if !ns.is_empty() && !raw_name.starts_with(ns) => {
6642 qualified = format!("{ns}__{raw_name}");
6643 qualified.as_str()
6644 }
6645 _ => raw_name,
6646 };
6647 let args = payload
6648 .get("arguments")
6649 .map(value_to_arg_string)
6650 .unwrap_or_else(|| "{}".to_string());
6651 let call = function_call(id, name, args);
6652 // IX-6: a `function_call` immediately after an assistant `message`
6653 // in the SAME turn (still `out.last()`, marked `__codex_open_turn`
6654 // by the "message" arm above, and not yet closed by anything else)
6655 // merges into that ONE `ChatMessage` — text→`content`,
6656 // call→`tool_calls` — instead of splitting into a second message.
6657 // A bare `function_call` with no such preceding turn (the marker
6658 // absent, or `out.last()` not an assistant message) is unaffected:
6659 // it still gets its own synthesized message, exactly as before.
6660 //
6661 // Belt-and-suspenders (PARITY-6/7 tightened): if this
6662 // `function_call` response_item itself carries a `turn_id` (rare
6663 // in observed real-native-Codex corpora — Codex usually only
6664 // stamps it on `message` payloads — but ALWAYS present on OUR
6665 // OWN synthesized export whenever a `ChatMessage`'s own tool
6666 // calls need merge disambiguation, see `write_codex_records`),
6667 // it must match the marked assistant message's recorded
6668 // `turn_id` EXACTLY — including "the marked message has none at
6669 // all" counting as a mismatch. That's exactly the shape of two
6670 // genuinely separate, adjacent `ChatMessage`s (an unrelated
6671 // text-only turn immediately followed by a different,
6672 // tool-call-only turn): the tool-only turn's own `function_call`s
6673 // carry a synthetic id while the unrelated preceding text
6674 // message carries none, so this correctly refuses the merge
6675 // instead of falling through to a permissive default. Only when
6676 // this `function_call` carries NO `turn_id` at all (the ordinary
6677 // real-native-Codex shape) does this fall back to the original
6678 // permissive "adjacency + open marker is enough" rule —
6679 // unchanged from before for the vast majority of real Codex
6680 // data. The truncation/clear strip above is what actually closes
6681 // the marker across rollback/compaction boundaries; this is only
6682 // an extra guard for the case where a stale-but-unstripped
6683 // marker and a turn_id mismatch coincide.
6684 let can_merge = out.last().is_some_and(|last| {
6685 last.role == Role::Assistant
6686 && last.metadata.contains_key("__codex_open_turn")
6687 && match codex_turn_id(payload) {
6688 Some(fc_tid) => {
6689 last.metadata.get("turn_id").map(String::as_str) == Some(fc_tid)
6690 }
6691 None => true,
6692 }
6693 });
6694 if can_merge {
6695 out.last_mut()
6696 .expect("can_merge implies out.last() is Some")
6697 .tool_calls
6698 .get_or_insert_with(Vec::new)
6699 .push(call);
6700 } else {
6701 push_assistant(out, String::new(), vec![call]);
6702 // PARITY-6/7: a BARE tool-call turn (no preceding `message`
6703 // in this turn, so nothing set `__codex_open_turn` above) can
6704 // still be the FIRST of several tool calls that all belong to
6705 // the SAME original `ChatMessage` (`write_codex_records`
6706 // stamps every one of a message's own tool calls with the
6707 // identical synthetic `turn_id`). Re-open THIS freshly
6708 // created message — but ONLY when a real `turn_id` is
6709 // present — so the NEXT `function_call` in the same group
6710 // merges into it instead of becoming its own message too.
6711 // Gated on `codex_turn_id(payload).is_some()` (not the bare
6712 // default `true` the belt-and-suspenders check above uses)
6713 // so real native Codex data — which almost never carries
6714 // this field on `function_call` payloads (see the comment
6715 // above) — keeps its existing "every bare tool call is its
6716 // own turn" behavior exactly as before.
6717 if let Some(tid) = codex_turn_id(payload) {
6718 if let Some(last) = out.last_mut() {
6719 last.metadata
6720 .insert("__codex_open_turn".to_string(), "true".to_string());
6721 last.metadata.insert("turn_id".to_string(), tid.to_string());
6722 }
6723 }
6724 }
6725 }
6726 Some("function_call_output") => {
6727 let id = payload
6728 .get("call_id")
6729 .and_then(Value::as_str)
6730 .unwrap_or_default();
6731 let result = match payload.get("output") {
6732 Some(Value::String(s)) => s.clone(),
6733 Some(v) => extract_text_content(Some(v)),
6734 None => String::new(),
6735 };
6736 let mut message = tool_message(id, result);
6737 // TR-13: Codex v1 exposes no structured success/error field on
6738 // this record. Free-text output is not a safe classifier, so the
6739 // reduction engine must treat the outcome as explicitly unknown
6740 // and fail closed on both success-only and error-only pruning.
6741 crate::mark_tool_outcome_unknown(&mut message);
6742 out.push(message);
6743 }
6744 // Custom / MCP tool calls are shaped like function calls but carry their
6745 // arguments under `input` (a JSON-encoded string). Normalize them the
6746 // same way so MCP-using sessions don't lose those turns.
6747 Some("custom_tool_call") => {
6748 let id = payload
6749 .get("call_id")
6750 .and_then(Value::as_str)
6751 .unwrap_or_default();
6752 let name = payload
6753 .get("name")
6754 .and_then(Value::as_str)
6755 .unwrap_or_default();
6756 // Unlike `function_call.arguments`, Codex custom tools accept a
6757 // free-form `input` string (apply_patch is the common case).
6758 // Canonical `FunctionCall::arguments` must remain valid JSON, so
6759 // retain the input's JSON type instead of treating a free-form
6760 // string as if it were already a JSON document. This lets every
6761 // target harness carry the value rather than silently replacing
6762 // it with `{}` when `parsed_arguments()` fails.
6763 let args = payload
6764 .get("input")
6765 .map(Value::to_string)
6766 .unwrap_or_else(|| "{}".to_string());
6767 push_assistant(out, String::new(), vec![function_call(id, name, args)]);
6768 if let Some(message) = out.last_mut() {
6769 message.metadata.insert(
6770 "codex_custom_tool_call_ids".to_string(),
6771 serde_json::json!([id]).to_string(),
6772 );
6773 }
6774 }
6775 Some("custom_tool_call_output") => {
6776 let id = payload
6777 .get("call_id")
6778 .and_then(Value::as_str)
6779 .unwrap_or_default();
6780 let result = match payload.get("output") {
6781 Some(Value::String(s)) => s.clone(),
6782 Some(v) => extract_text_content(Some(v)),
6783 None => String::new(),
6784 };
6785 let mut message = tool_message(id, result);
6786 crate::mark_tool_outcome_unknown(&mut message);
6787 out.push(message);
6788 }
6789 // Tool-search is a clean call/output pair keyed by call_id.
6790 //
6791 // D1: this arm used to ALWAYS start a brand-new `ChatMessage`,
6792 // ignoring the `turn_id` merge stamps `write_codex_records` puts on
6793 // its own synthesized `tool_search_call` records (see the PARITY-6/7
6794 // comment there and on `codex_turn_id`/the `function_call` arm
6795 // above). That left the same bug-class the turn_id work fixed for
6796 // `function_call` half-done here: a single Claude assistant message
6797 // containing text + a `tool_search` block reloaded as 2 messages
6798 // (1 -> 2 inflation), and a message with 2 `tool_search` blocks
6799 // reloaded as 3. Mirror the `function_call` arm's merge check
6800 // exactly so a `tool_search_call` immediately following an open
6801 // assistant turn (or another tool call sharing the same `turn_id`)
6802 // merges into that SAME `ChatMessage` instead of splitting.
6803 Some("tool_search_call") => {
6804 let id = payload
6805 .get("call_id")
6806 .and_then(Value::as_str)
6807 .unwrap_or_default();
6808 let args = payload
6809 .get("arguments")
6810 .map(value_to_arg_string)
6811 .unwrap_or_else(|| "{}".to_string());
6812 let call = function_call(id, "tool_search", args);
6813 let can_merge = out.last().is_some_and(|last| {
6814 last.role == Role::Assistant
6815 && last.metadata.contains_key("__codex_open_turn")
6816 && match codex_turn_id(payload) {
6817 Some(fc_tid) => {
6818 last.metadata.get("turn_id").map(String::as_str) == Some(fc_tid)
6819 }
6820 None => true,
6821 }
6822 });
6823 if can_merge {
6824 out.last_mut()
6825 .expect("can_merge implies out.last() is Some")
6826 .tool_calls
6827 .get_or_insert_with(Vec::new)
6828 .push(call);
6829 } else {
6830 push_assistant(out, String::new(), vec![call]);
6831 // Re-open the freshly created message so a FOLLOWING
6832 // `function_call`/`tool_search_call` sharing this same
6833 // `turn_id` merges into it too — matching the bare
6834 // `function_call` case's own re-open logic above.
6835 if let Some(tid) = codex_turn_id(payload) {
6836 if let Some(last) = out.last_mut() {
6837 last.metadata
6838 .insert("__codex_open_turn".to_string(), "true".to_string());
6839 last.metadata.insert("turn_id".to_string(), tid.to_string());
6840 }
6841 }
6842 }
6843 }
6844 Some("tool_search_output") => {
6845 let id = payload
6846 .get("call_id")
6847 .and_then(Value::as_str)
6848 .unwrap_or_default();
6849 let result = payload
6850 .get("tools")
6851 .map(value_to_arg_string)
6852 .unwrap_or_default();
6853 out.push(tool_message(id, result));
6854 }
6855 // Web-search / image-generation response_items carry no paired output
6856 // here (results live in event_msg), so emit an assistant marker rather
6857 // than a dangling unanswered tool call.
6858 Some("web_search_call") => {
6859 push_assistant(out, "[web_search]".to_string(), Vec::new());
6860 }
6861 Some("image_generation_call") => {
6862 let prompt = payload
6863 .get("revised_prompt")
6864 .and_then(Value::as_str)
6865 .unwrap_or("");
6866 push_assistant(
6867 out,
6868 format!("[image_generation] {prompt}").trim().to_string(),
6869 Vec::new(),
6870 );
6871 }
6872 // "reasoning" and anything else — dropped.
6873 _ => {}
6874 }
6875}
6876
6877// ---- Grok -------------------------------------------------------------
6878
6879const SUPERCODE_GEMINI_MESSAGE_KEY: &str = "_supercode_gemini_message";
6880
6881fn set_gemini_message_extension(value: &mut Value, message: &ChatMessage) {
6882 value[SUPERCODE_GEMINI_MESSAGE_KEY] = serde_json::json!({
6883 "schema": 1,
6884 "role": message.role,
6885 "content": message.content,
6886 "content_parts": message.content_parts,
6887 "tool_calls": message.tool_calls,
6888 "tool_call_id": message.tool_call_id,
6889 "name": message.name,
6890 "metadata": message.metadata,
6891 });
6892}
6893
6894fn restore_gemini_message_extension(value: &Value, message: &mut ChatMessage) {
6895 let Some(extension) = value.get(SUPERCODE_GEMINI_MESSAGE_KEY) else {
6896 return;
6897 };
6898 if extension.get("schema").and_then(Value::as_u64) != Some(1) {
6899 return;
6900 }
6901 if let Some(role) = extension
6902 .get("role")
6903 .and_then(|value| serde_json::from_value(value.clone()).ok())
6904 {
6905 message.role = role;
6906 }
6907 message.content = extension
6908 .get("content")
6909 .and_then(Value::as_str)
6910 .map(str::to_string);
6911 message.content_parts = extension
6912 .get("content_parts")
6913 .and_then(|value| serde_json::from_value(value.clone()).ok());
6914 message.tool_calls = extension
6915 .get("tool_calls")
6916 .and_then(|value| serde_json::from_value(value.clone()).ok());
6917 message.tool_call_id = extension
6918 .get("tool_call_id")
6919 .and_then(Value::as_str)
6920 .map(str::to_string);
6921 message.name = extension
6922 .get("name")
6923 .and_then(Value::as_str)
6924 .map(str::to_string);
6925 message.metadata.clear();
6926 if let Some(metadata) = extension.get("metadata").and_then(Value::as_object) {
6927 for (key, value) in metadata {
6928 if let Some(value) = value.as_str() {
6929 message.metadata.insert(key.clone(), value.to_string());
6930 }
6931 }
6932 }
6933}
6934
6935fn capture_grok_scalar_metadata(value: &Value, message: &mut ChatMessage, keys: &[&str]) {
6936 for key in keys {
6937 if let Some(value) = value.get(*key) {
6938 message.metadata.insert(
6939 format!("grok_{key}"),
6940 value
6941 .as_str()
6942 .map(str::to_string)
6943 .unwrap_or_else(|| value.to_string()),
6944 );
6945 }
6946 }
6947}
6948
6949fn grok_human_user_text(raw: &str) -> Option<String> {
6950 let text = raw.trim();
6951 if text.is_empty() || text.starts_with("<user_info>") || text.starts_with("<system-reminder>") {
6952 return None;
6953 }
6954 let unwrapped = text
6955 .strip_prefix("<user_query>")
6956 .and_then(|value| value.strip_suffix("</user_query>"))
6957 .map(str::trim)
6958 .unwrap_or(text);
6959 (!unwrapped.is_empty()).then(|| unwrapped.to_string())
6960}
6961
6962/// Portable extension for messages whose canonical fields cannot be expressed
6963/// by the target's stock schema. It was introduced for Grok and retains that
6964/// on-disk key for compatibility. Gemini has the same need: Claude Code and
6965/// Codex have no native slot for a tool-result name or Gemini-only metadata.
6966/// Their readers tolerate unknown namespaced fields, so forwarding this
6967/// adapter-owned envelope keeps those cross-format hops reversible without
6968/// pretending the stock schemas represent the fields directly.
6969const SUPERCODE_GROK_MESSAGE_KEY: &str = "_supercode_grok_message";
6970
6971/// Namespaced line-level extension carrying the one tool-result outcome state
6972/// Claude cannot represent natively. Keeping this narrower than the full Grok
6973/// portability envelope avoids changing unrelated target-message projection.
6974const SUPERCODE_TOOL_OUTCOME_KEY: &str = "_supercode_tool_outcome";
6975
6976fn restore_tool_outcome_extension(value: &Value, message: &mut ChatMessage) {
6977 if !crate::is_tool_error(message)
6978 && value
6979 .get(SUPERCODE_TOOL_OUTCOME_KEY)
6980 .and_then(Value::as_str)
6981 == Some("unknown")
6982 {
6983 crate::mark_tool_outcome_unknown(message);
6984 }
6985}
6986
6987fn grok_message_extension(source: SessionSource, message: &ChatMessage) -> Option<Value> {
6988 let metadata = message
6989 .metadata
6990 .iter()
6991 .filter(|(key, _)| {
6992 key.starts_with("gemini_") || key.starts_with("grok_") || key.starts_with("goose_")
6993 })
6994 .map(|(key, value)| (key.clone(), Value::String(value.clone())))
6995 .collect::<serde_json::Map<_, _>>();
6996
6997 // `meta.source` changes after every reload. Keying portability only on
6998 // the immediate source therefore made Grok metadata survive one hop but
6999 // disappear on A -> B -> C translations. Once Grok-owned fields are
7000 // present, keep forwarding them regardless of the current container.
7001 let has_portable_fields =
7002 !metadata.is_empty() || message.metadata.contains_key("codex_custom_tool_call_ids");
7003 (matches!(
7004 source,
7005 SessionSource::Gemini | SessionSource::Grok | SessionSource::Goose
7006 ) || has_portable_fields
7007 || message.content_parts.is_some())
7008 .then(|| {
7009 serde_json::json!({
7010 "schema": 2,
7011 "role": message.role,
7012 "content": message.content,
7013 "content_parts": message.content_parts,
7014 "tool_calls": message.tool_calls,
7015 "tool_call_id": message.tool_call_id,
7016 "name": message.name,
7017 "metadata": message.metadata,
7018 })
7019 })
7020}
7021
7022fn set_grok_target_message_extension(value: &mut Value, message: &ChatMessage) {
7023 value[SUPERCODE_GROK_MESSAGE_KEY] = serde_json::json!({
7024 "schema": 2,
7025 "role": message.role,
7026 "content": message.content,
7027 "content_parts": message.content_parts,
7028 "tool_calls": message.tool_calls,
7029 "tool_call_id": message.tool_call_id,
7030 "name": message.name,
7031 "metadata": message.metadata,
7032 });
7033}
7034
7035fn set_grok_message_extension(value: &mut Value, source: SessionSource, message: &ChatMessage) {
7036 if let Some(extension) = grok_message_extension(source, message) {
7037 value[SUPERCODE_GROK_MESSAGE_KEY] = extension;
7038 }
7039}
7040
7041fn restore_grok_message_extension(value: &Value, message: &mut ChatMessage) {
7042 let Some(extension) = value.get(SUPERCODE_GROK_MESSAGE_KEY) else {
7043 return;
7044 };
7045 // Codex temporarily marks a text assistant item so immediately-following
7046 // function-call items can merge back into the same canonical turn. The
7047 // portable envelope must not erase that loader-private marker before the
7048 // merge happens; `from_codex_str` removes it before returning.
7049 let codex_open_turn = message.metadata.get("__codex_open_turn").cloned();
7050 let codex_turn_id = message.metadata.get("turn_id").cloned();
7051 let extension_has_turn_id = extension
7052 .get("metadata")
7053 .and_then(Value::as_object)
7054 .is_some_and(|metadata| metadata.contains_key("turn_id"));
7055 if extension.get("schema").and_then(Value::as_u64) == Some(2) {
7056 if let Some(role) = extension
7057 .get("role")
7058 .and_then(|value| serde_json::from_value(value.clone()).ok())
7059 {
7060 message.role = role;
7061 }
7062 message.content = extension
7063 .get("content")
7064 .and_then(Value::as_str)
7065 .map(str::to_string);
7066 message.content_parts = extension
7067 .get("content_parts")
7068 .and_then(|value| serde_json::from_value(value.clone()).ok());
7069 // Tool calls are shared native structure in every supported format.
7070 // Keep the loader's reconstruction instead of restoring this copy:
7071 // Codex stores a combined text+tool turn across multiple records, so
7072 // eagerly restoring calls on its text record would duplicate them
7073 // when the following function-call records merge.
7074 message.tool_call_id = extension
7075 .get("tool_call_id")
7076 .and_then(Value::as_str)
7077 .map(str::to_string);
7078 message.name = extension
7079 .get("name")
7080 .and_then(Value::as_str)
7081 .map(str::to_string);
7082 message.metadata.clear();
7083 }
7084 if let Some(metadata) = extension.get("metadata").and_then(Value::as_object) {
7085 for (key, value) in metadata {
7086 if let Some(value) = value.as_str() {
7087 message.metadata.insert(key.clone(), value.to_string());
7088 }
7089 }
7090 }
7091 if let Some(name) = extension.get("name").and_then(Value::as_str) {
7092 message.name = Some(name.to_string());
7093 }
7094 if let Some(marker) = codex_open_turn {
7095 message
7096 .metadata
7097 .insert("__codex_open_turn".to_string(), marker);
7098 }
7099 if let Some(turn_id) = codex_turn_id {
7100 message.metadata.insert("turn_id".to_string(), turn_id);
7101 if !extension_has_turn_id {
7102 message.metadata.insert(
7103 "__grok_remove_synthetic_turn_id".to_string(),
7104 "true".to_string(),
7105 );
7106 }
7107 }
7108}
7109
7110fn restore_single_grok_message(value: &Value, messages: &mut [ChatMessage]) {
7111 if let [message] = messages {
7112 restore_grok_message_extension(value, message);
7113 }
7114}
7115
7116fn normalize_goose_message(native: &Value, native_index: usize, out: &mut Vec<ChatMessage>) {
7117 let role = match native.get("role").and_then(Value::as_str) {
7118 Some("assistant") => Role::Assistant,
7119 _ => Role::User,
7120 };
7121 let created = native.get("created").and_then(Value::as_i64);
7122 let native_id = native.get("id").and_then(Value::as_str);
7123 let mut text = Vec::new();
7124 let mut content_parts = Vec::new();
7125 let mut tool_calls = Vec::new();
7126 let mut tool_results = Vec::new();
7127
7128 for (block_index, block) in native
7129 .get("content")
7130 .and_then(Value::as_array)
7131 .into_iter()
7132 .flatten()
7133 .enumerate()
7134 {
7135 match block.get("type").and_then(Value::as_str) {
7136 Some("text") => {
7137 if let Some(value) = block.get("text").and_then(Value::as_str) {
7138 text.push(value.to_string());
7139 content_parts.push(serde_json::json!({"type": "text", "text": value}));
7140 }
7141 }
7142 Some("image") => {
7143 let data = block
7144 .get("data")
7145 .and_then(Value::as_str)
7146 .unwrap_or_default();
7147 let media_type = block
7148 .get("mimeType")
7149 .or_else(|| block.get("mime_type"))
7150 .and_then(Value::as_str)
7151 .unwrap_or("application/octet-stream");
7152 content_parts.push(serde_json::json!({
7153 "type": "image_url",
7154 "image_url": {"url": format!("data:{media_type};base64,{data}")},
7155 }));
7156 }
7157 Some("toolRequest" | "frontendToolRequest") => {
7158 let id = block
7159 .get("id")
7160 .and_then(Value::as_str)
7161 .map(str::to_string)
7162 .unwrap_or_else(|| format!("goose-{native_index}-{block_index}"));
7163 let call = block
7164 .get("toolCall")
7165 .and_then(|call| {
7166 (call.get("status").and_then(Value::as_str) == Some("success"))
7167 .then(|| call.get("value"))
7168 .flatten()
7169 })
7170 .or_else(|| block.get("toolCall"));
7171 let Some(call) = call else { continue };
7172 let name = call.get("name").and_then(Value::as_str).unwrap_or("tool");
7173 let arguments = call
7174 .get("arguments")
7175 .map(value_to_arg_string)
7176 .unwrap_or_else(|| "{}".to_string());
7177 tool_calls.push(function_call(&id, name, arguments));
7178 }
7179 Some("toolResponse") => tool_results.push(block.clone()),
7180 _ => {}
7181 }
7182 }
7183
7184 if !text.is_empty() || !content_parts.is_empty() || !tool_calls.is_empty() {
7185 let has_non_text = content_parts
7186 .iter()
7187 .any(|part| part.get("type").and_then(Value::as_str) != Some("text"));
7188 let mut message = ChatMessage {
7189 role,
7190 content: (!text.is_empty()).then(|| text.join("\n")),
7191 content_parts: has_non_text.then_some(content_parts),
7192 tool_calls: (!tool_calls.is_empty()).then_some(tool_calls),
7193 tool_call_id: None,
7194 name: None,
7195 metadata: Default::default(),
7196 };
7197 capture_goose_message_metadata(native, created, native_id, &mut message);
7198 out.push(message);
7199 }
7200
7201 for (result_index, block) in tool_results.into_iter().enumerate() {
7202 let id = block
7203 .get("id")
7204 .and_then(Value::as_str)
7205 .map(str::to_string)
7206 .unwrap_or_else(|| format!("goose-{native_index}-result-{result_index}"));
7207 let result = block.get("toolResult").unwrap_or(&Value::Null);
7208 let status_error = result.get("status").and_then(Value::as_str) == Some("error");
7209 let value = result.get("value").unwrap_or(result);
7210 let is_error = status_error || value.get("isError").and_then(Value::as_bool) == Some(true);
7211 let output = if status_error {
7212 result
7213 .get("error")
7214 .and_then(Value::as_str)
7215 .unwrap_or("Goose tool call failed")
7216 .to_string()
7217 } else {
7218 value
7219 .get("content")
7220 .and_then(Value::as_array)
7221 .map(|content| {
7222 content
7223 .iter()
7224 .filter_map(|part| {
7225 part.get("text")
7226 .and_then(Value::as_str)
7227 .map(str::to_string)
7228 .or_else(|| Some(part.to_string()))
7229 })
7230 .collect::<Vec<_>>()
7231 .join("\n")
7232 })
7233 .unwrap_or_else(|| value.to_string())
7234 };
7235 let mut message = tool_message(&id, output);
7236 if is_error {
7237 crate::mark_tool_error(&mut message);
7238 }
7239 capture_goose_message_metadata(native, created, native_id, &mut message);
7240 out.push(message);
7241 }
7242}
7243
7244fn capture_goose_message_metadata(
7245 native: &Value,
7246 created: Option<i64>,
7247 native_id: Option<&str>,
7248 message: &mut ChatMessage,
7249) {
7250 if let Some(created) = created {
7251 message
7252 .metadata
7253 .insert("goose_created".to_string(), created.to_string());
7254 }
7255 if let Some(native_id) = native_id {
7256 message
7257 .metadata
7258 .insert("goose_message_id".to_string(), native_id.to_string());
7259 }
7260 if let Some(metadata) = native.get("metadata") {
7261 message
7262 .metadata
7263 .insert("goose_metadata".to_string(), metadata.to_string());
7264 }
7265}
7266
7267#[doc(hidden)]
7268pub fn percent_decode_path(encoded: &str) -> Option<String> {
7269 fn hex(byte: u8) -> Option<u8> {
7270 match byte {
7271 b'0'..=b'9' => Some(byte - b'0'),
7272 b'a'..=b'f' => Some(byte - b'a' + 10),
7273 b'A'..=b'F' => Some(byte - b'A' + 10),
7274 _ => None,
7275 }
7276 }
7277
7278 let bytes = encoded.as_bytes();
7279 let mut decoded = Vec::with_capacity(bytes.len());
7280 let mut index = 0usize;
7281 while index < bytes.len() {
7282 if bytes[index] == b'%' {
7283 let high = *bytes.get(index + 1)?;
7284 let low = *bytes.get(index + 2)?;
7285 decoded.push(hex(high)? * 16 + hex(low)?);
7286 index += 3;
7287 } else {
7288 decoded.push(bytes[index]);
7289 index += 1;
7290 }
7291 }
7292 String::from_utf8(decoded).ok()
7293}
7294
7295// ---- Pi ---------------------------------------------------------------
7296
7297fn capture_pi_header(v: &Value, meta: &mut SessionMeta) -> Result<()> {
7298 restore_codex_provenance_from_top_level(v, meta)?;
7299 if let Some(id) = v.get("id").and_then(Value::as_str) {
7300 meta.session_id = Some(id.to_string());
7301 }
7302 if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
7303 meta.cwd = Some(PathBuf::from(cwd));
7304 }
7305 // Absent `version` means a pre-v3 file (`pi-fields.md` §1: "absent = v1").
7306 let version = v
7307 .get("version")
7308 .and_then(Value::as_u64)
7309 .map(|n| n.to_string())
7310 .unwrap_or_else(|| "1".to_string());
7311 meta.lineage.insert("pi_version".to_string(), version);
7312 if let Some(ts) = v.get("timestamp").and_then(Value::as_str) {
7313 meta.lineage
7314 .insert("created_at".to_string(), ts.to_string());
7315 }
7316 if let Some(ps) = v.get("parentSession").and_then(Value::as_str) {
7317 meta.lineage
7318 .insert("parent_session_path".to_string(), ps.to_string());
7319 }
7320 // D7: the other half of `push_pi_header`'s passthrough — restores a
7321 // captured Claude `fork-context-ref` so a Claude -> Pi -> Claude round
7322 // trip reconstructs the original record (mirrors
7323 // `capture_codex_session_meta`'s identical `claude_fork_context_ref`
7324 // restore for the Codex hop).
7325 if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
7326 if let Some(v) = v.get("claude_fork_context_ref") {
7327 meta.lineage
7328 .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
7329 }
7330 }
7331 Ok(())
7332}
7333
7334/// Validate one `ImageContent` block's `{mimeType, data}` shape and return
7335/// `(mime, data)` when it looks like a real image payload.
7336///
7337/// **This shape is UNVERIFIED against real pi output**: `pi-fields.md` cites
7338/// `ai:316-350` for the `ImageContent` content-block union but does not
7339/// enumerate `ImageContent`'s own fields (only `TextContent`/`ThinkingContent`/
7340/// `ToolCall` are itemized there) — `{mimeType, data}` (mirroring the OpenAI/
7341/// Anthropic multimodal wire shape) is this loader's best guess, not a
7342/// frozen-spec fact. A follow-up TR tracks confirming/correcting this shape
7343/// against a real pi corpus. Until then this function VALIDATES rather than
7344/// assumes: both fields must be present, non-empty strings, and `data` must
7345/// look like base64 (only the base64 alphabet, incl. `=` padding) — anything
7346/// else is an unknown/unexpected image shape, and the caller must route the
7347/// whole message to raw-only survival (S6-style fail loud) instead of
7348/// silently synthesizing a corrupt/empty `image_url` part.
7349fn pi_image_shape(item: &Value) -> Option<(String, String)> {
7350 let mime = item.get("mimeType").and_then(Value::as_str)?;
7351 let data = item.get("data").and_then(Value::as_str)?;
7352 if mime.is_empty() || data.is_empty() {
7353 return None;
7354 }
7355 if !data
7356 .bytes()
7357 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
7358 {
7359 return None;
7360 }
7361 Some((mime.to_string(), data.to_string()))
7362}
7363
7364/// True if `content` (a pi content value: bare string or
7365/// `(TextContent|ImageContent)[]`) contains at least one `type:"image"` block
7366/// that does not match [`pi_image_shape`] — shared by the loader (which
7367/// routes such a message to raw-only survival, never a synthesized-empty
7368/// part) and the coverage audit (`audit::Corpus::Pi`), which scores it as
7369/// `message/UnknownImageShape` instead of silently `Normalized`, so the shape
7370/// mismatch surfaces as a coverage FAILURE rather than vanishing.
7371#[doc(hidden)]
7372pub fn pi_content_has_unknown_image_shape(content: Option<&Value>) -> bool {
7373 let Some(Value::Array(items)) = content else {
7374 return false;
7375 };
7376 items.iter().any(|item| {
7377 item.get("type").and_then(Value::as_str) == Some("image") && pi_image_shape(item).is_none()
7378 })
7379}
7380
7381/// Split a pi `(TextContent|ImageContent)[]` (or bare string) content value
7382/// into concatenated text plus, when a WELL-FORMED image block is present,
7383/// the full `content_parts` array (leading text block + one `image_url` part
7384/// per image, its `data:` URI carrying the exact `mimeType`/`data` bytes pi
7385/// stored) — shared by `user`/`toolResult`/`custom*` content, which all use
7386/// the identical union (`pi-fields.md` §3a/§3c/§3e).
7387///
7388/// Returns `(text, parts, unknown_image_shape)`. When an image block does NOT
7389/// match [`pi_image_shape`] (missing/empty `data`/`mimeType`, or a `data`
7390/// value that isn't recognizable base64), this NEVER synthesizes an empty/
7391/// corrupt `image_url` part — it reports `unknown_image_shape = true` and
7392/// every caller must treat that as raw-only survival for the whole message
7393/// (mirroring the unknown-`message.role` rule, S6), so a shape this loader
7394/// guessed wrong fails loud instead of silently dropping/corrupting the
7395/// image.
7396fn pi_content_to_text_and_parts(content: Option<&Value>) -> (String, Option<Vec<Value>>, bool) {
7397 match content {
7398 Some(Value::String(s)) => (s.clone(), None, false),
7399 Some(Value::Array(items)) => {
7400 let mut text = String::new();
7401 let mut parts: Vec<Value> = Vec::new();
7402 let mut has_image = false;
7403 let mut unknown_image_shape = false;
7404 for item in items {
7405 match item.get("type").and_then(Value::as_str) {
7406 Some("text") => {
7407 if let Some(t) = item.get("text").and_then(Value::as_str) {
7408 push_str_field(&mut text, t);
7409 }
7410 }
7411 Some("image") => {
7412 has_image = true;
7413 match pi_image_shape(item) {
7414 Some((mime, data)) => {
7415 parts.push(serde_json::json!({
7416 "type": "image_url",
7417 "image_url": {"url": format!("data:{mime};base64,{data}")},
7418 }));
7419 }
7420 None => unknown_image_shape = true,
7421 }
7422 }
7423 _ => {}
7424 }
7425 }
7426 if unknown_image_shape {
7427 // Never synthesize an empty/corrupt part for a shape we
7428 // don't recognize — raw-only survival for the whole message;
7429 // the coverage guard is what turns this into a visible
7430 // failure (S6-style).
7431 return (String::new(), None, true);
7432 }
7433 if has_image {
7434 if !text.trim().is_empty() {
7435 parts.insert(0, serde_json::json!({"type": "text", "text": text.clone()}));
7436 }
7437 (text, Some(parts), false)
7438 } else {
7439 (text, None, false)
7440 }
7441 }
7442 _ => (String::new(), None, false),
7443 }
7444}
7445
7446fn push_pi_user(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7447 let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
7448 // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7449 // survival, never a synthesized-empty part. `audit::Corpus::Pi`'s
7450 // `message/UnknownImageShape` bucket is what turns this into a visible
7451 // coverage failure.
7452 if unknown_image_shape {
7453 return;
7454 }
7455 if text.trim().is_empty() && parts.is_none() {
7456 return;
7457 }
7458 let mut msg = match parts {
7459 Some(parts) => ChatMessage {
7460 role: Role::User,
7461 content: None,
7462 content_parts: Some(parts),
7463 tool_calls: None,
7464 tool_call_id: None,
7465 name: None,
7466 metadata: Default::default(),
7467 },
7468 None => ChatMessage::user(text),
7469 };
7470 // WAVE-2 fidelity fix: pi's message-level unix-ms clock
7471 // (`message.timestamp`) is a DISTINCT field from the canonical
7472 // entry-level ISO `metadata["timestamp"]` WAVE-2 item 1 wires — the two
7473 // carry genuinely different values in real corpora (the fixture's are
7474 // ~6 months apart). Preserve it separately so it isn't silently lost for
7475 // every pi session; see `msg_pi_native_timestamp_ms` (the pi writer's
7476 // native round-trip consumer) and the INHERENT residue note on
7477 // `pi_dropped_keys_cross` in `interop_fidelity_matrix.rs`.
7478 if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7479 msg.metadata
7480 .insert("pi_msg_timestamp".to_string(), ts.to_string());
7481 }
7482 out.push(msg);
7483}
7484
7485fn push_pi_assistant(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7486 let mut text = String::new();
7487 let mut calls: Vec<ToolCall> = Vec::new();
7488 let mut thinking = String::new();
7489 let mut thinking_seen = false;
7490 let mut thinking_sig: Option<String> = None;
7491 let mut thinking_redacted = false;
7492 let mut text_sig: Option<String> = None;
7493 let mut thought_sig: Option<String> = None;
7494
7495 if let Some(Value::Array(blocks)) = msg_v.get("content") {
7496 for b in blocks {
7497 match b.get("type").and_then(Value::as_str) {
7498 Some("text") => {
7499 if let Some(t) = b.get("text").and_then(Value::as_str) {
7500 push_str_field(&mut text, t);
7501 }
7502 if let Some(sig) = b.get("textSignature") {
7503 text_sig = Some(match sig {
7504 Value::String(s) => s.clone(),
7505 other => other.to_string(),
7506 });
7507 }
7508 }
7509 Some("thinking") => {
7510 thinking_seen = true;
7511 if let Some(t) = b.get("thinking").and_then(Value::as_str) {
7512 push_str_field(&mut thinking, t);
7513 }
7514 if let Some(sig) = b.get("thinkingSignature").and_then(Value::as_str) {
7515 thinking_sig = Some(sig.to_string());
7516 }
7517 if b.get("redacted").and_then(Value::as_bool) == Some(true) {
7518 thinking_redacted = true;
7519 }
7520 }
7521 Some("toolCall") => {
7522 let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
7523 let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
7524 // `arguments` is a JSON OBJECT on pi's wire, not a string
7525 // (`pi-fields.md` §3b open question 4) — serialize to the
7526 // string `FunctionCall::arguments` expects.
7527 let args = b
7528 .get("arguments")
7529 .cloned()
7530 .unwrap_or_else(|| Value::Object(Default::default()));
7531 calls.push(function_call(id, name, args.to_string()));
7532 if let Some(sig) = b.get("thoughtSignature").and_then(Value::as_str) {
7533 thought_sig = Some(sig.to_string());
7534 }
7535 }
7536 _ => {}
7537 }
7538 }
7539 }
7540
7541 let before = out.len();
7542 push_assistant(out, text, calls);
7543 // A recognized native assistant entry remains transcript state even
7544 // when its content array is empty, except Pi's explicit empty error
7545 // response: that record has no replayable content and is established
7546 // raw-only residue (`pi_real_corpus_error_retry`). Preserve empty
7547 // non-error turns and Pi's standalone thinking-block shape.
7548 let is_empty_error =
7549 !thinking_seen && msg_v.get("stopReason").and_then(Value::as_str) == Some("error");
7550 if out.len() == before && !is_empty_error {
7551 let mut empty = ChatMessage {
7552 role: Role::Assistant,
7553 content: None,
7554 content_parts: None,
7555 tool_calls: None,
7556 tool_call_id: None,
7557 name: None,
7558 metadata: Default::default(),
7559 };
7560 if !thinking_seen {
7561 empty
7562 .metadata
7563 .insert("empty_assistant_record".to_string(), "true".to_string());
7564 }
7565 out.push(empty);
7566 }
7567 if out.len() > before {
7568 let msg = out.last_mut().expect("just pushed");
7569 if thinking_seen {
7570 msg.metadata.insert("thinking".to_string(), thinking);
7571 }
7572 if let Some(s) = thinking_sig {
7573 msg.metadata.insert("thinking_signature".to_string(), s);
7574 }
7575 if thinking_redacted {
7576 msg.metadata
7577 .insert("pi_thinking_redacted".to_string(), "true".to_string());
7578 }
7579 if let Some(s) = text_sig {
7580 msg.metadata.insert("pi_text_signature".to_string(), s);
7581 }
7582 if let Some(s) = thought_sig {
7583 msg.metadata.insert("pi_thought_signature".to_string(), s);
7584 }
7585 for (key, field) in [
7586 ("pi_api", "api"),
7587 ("pi_provider", "provider"),
7588 ("pi_response_model", "responseModel"),
7589 ("pi_response_id", "responseId"),
7590 ("pi_stop_reason", "stopReason"),
7591 ("pi_error_message", "errorMessage"),
7592 ] {
7593 if let Some(s) = msg_v.get(field).and_then(Value::as_str) {
7594 msg.metadata.insert(key.to_string(), s.to_string());
7595 }
7596 }
7597 if let Some(diag) = msg_v.get("diagnostics") {
7598 if !diag.is_null() {
7599 msg.metadata
7600 .insert("pi_diagnostics".to_string(), diag.to_string());
7601 }
7602 }
7603 if let Some(usage) = msg_v.get("usage") {
7604 if !usage.is_null() {
7605 msg.metadata
7606 .insert("pi_usage".to_string(), usage.to_string());
7607 }
7608 }
7609 // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7610 // separately from the canonical entry-level ISO `timestamp` — see
7611 // `push_pi_user`.
7612 if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7613 msg.metadata
7614 .insert("pi_msg_timestamp".to_string(), ts.to_string());
7615 }
7616 }
7617}
7618
7619fn push_pi_tool_result(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7620 let id = msg_v
7621 .get("toolCallId")
7622 .and_then(Value::as_str)
7623 .unwrap_or_default();
7624 let name = msg_v
7625 .get("toolName")
7626 .and_then(Value::as_str)
7627 .unwrap_or_default();
7628 let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
7629 // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7630 // survival, never a synthesized-empty part. Dropping the toolResult
7631 // message here leaves its `toolCallId` unanswered, which
7632 // `ensure_tool_results_paired` already turns into a visible
7633 // "[no tool result recorded — turn interrupted]" placeholder — a loud
7634 // failure mode, not a silent one.
7635 if unknown_image_shape {
7636 return;
7637 }
7638 let mut msg = ChatMessage {
7639 role: Role::Tool,
7640 content: Some(text),
7641 content_parts: parts,
7642 tool_calls: None,
7643 tool_call_id: Some(id.to_string()),
7644 name: Some(name.to_string()),
7645 metadata: Default::default(),
7646 };
7647 if let Some(details) = msg_v.get("details") {
7648 if !details.is_null() {
7649 msg.metadata
7650 .insert("pi_tool_details".to_string(), details.to_string());
7651 }
7652 }
7653 let is_error = msg_v
7654 .get("isError")
7655 .and_then(Value::as_bool)
7656 .unwrap_or(false);
7657 msg.metadata
7658 .insert("pi_is_error".to_string(), is_error.to_string());
7659 if is_error {
7660 crate::mark_tool_error(&mut msg);
7661 }
7662 // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7663 // separately from the canonical entry-level ISO `timestamp` — see
7664 // `push_pi_user`.
7665 if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7666 msg.metadata
7667 .insert("pi_msg_timestamp".to_string(), ts.to_string());
7668 }
7669 out.push(msg);
7670}
7671
7672/// Render a pi `bashExecution` message (`!`/`!!` shell escape) to the text
7673/// pi itself sends the model, mirroring `bashExecutionToText`
7674/// (`pi-fields.md` §3d, `msg:82-98`). The exact upstream string constants
7675/// aren't reproduced in the frozen research doc (only cited by file:line),
7676/// so this is a faithful, clearly-labeled reconstruction — every structured
7677/// field is additionally preserved verbatim in `metadata`/`raw` regardless.
7678fn push_pi_bash(msg_v: &Value, out: &mut Vec<ChatMessage>) {
7679 let command = msg_v.get("command").and_then(Value::as_str).unwrap_or("");
7680 let output = msg_v.get("output").and_then(Value::as_str).unwrap_or("");
7681 let exit_code = msg_v.get("exitCode").and_then(Value::as_i64);
7682 let cancelled = msg_v
7683 .get("cancelled")
7684 .and_then(Value::as_bool)
7685 .unwrap_or(false);
7686 let truncated = msg_v
7687 .get("truncated")
7688 .and_then(Value::as_bool)
7689 .unwrap_or(false);
7690
7691 let mut text = format!("$ {command}\n{output}");
7692 if let Some(code) = exit_code {
7693 if code != 0 {
7694 text.push_str(&format!("\n[exit code: {code}]"));
7695 }
7696 }
7697 if cancelled {
7698 text.push_str("\n[cancelled]");
7699 }
7700 if truncated {
7701 text.push_str("\n[truncated]");
7702 }
7703
7704 let mut msg = ChatMessage::user(text);
7705 msg.metadata
7706 .insert("pi_bash_command".to_string(), command.to_string());
7707 msg.metadata
7708 .insert("pi_bash_output".to_string(), output.to_string());
7709 if let Some(code) = exit_code {
7710 msg.metadata
7711 .insert("pi_bash_exit_code".to_string(), code.to_string());
7712 }
7713 msg.metadata
7714 .insert("pi_bash_cancelled".to_string(), cancelled.to_string());
7715 msg.metadata
7716 .insert("pi_bash_truncated".to_string(), truncated.to_string());
7717 if let Some(p) = msg_v.get("fullOutputPath").and_then(Value::as_str) {
7718 msg.metadata
7719 .insert("pi_bash_full_output_path".to_string(), p.to_string());
7720 }
7721 // `!!` — hidden from the model context; honored by `is_replay_excluded`
7722 // on every writer, not just pi's own (§2.2).
7723 if msg_v.get("excludeFromContext").and_then(Value::as_bool) == Some(true) {
7724 msg.metadata
7725 .insert("pi_exclude_from_context".to_string(), "true".to_string());
7726 }
7727 // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7728 // separately from the canonical entry-level ISO `timestamp` — see
7729 // `push_pi_user`.
7730 if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
7731 msg.metadata
7732 .insert("pi_msg_timestamp".to_string(), ts.to_string());
7733 }
7734 out.push(msg);
7735}
7736
7737/// B4: the `customType` marker `write_pi_entries`'s `Role::System` arm
7738/// stamps on a re-materialized content-bearing Claude `system` record (see
7739/// that arm's doc comment). Namespaced (`supercode_`-prefixed) so it can
7740/// never collide with a real pi `CustomMessage.customType` — pi's own
7741/// hook-injected custom types are hook/extension names (e.g. `hookMessage`
7742/// migration targets), never this literal string.
7743const PI_CLAUDE_SYSTEM_CUSTOM_TYPE: &str = "supercode_claude_system";
7744
7745/// Shared mapping for pi's `role:"custom"` message (§3e) and top-level
7746/// `custom_message` entries (§9) — both enter context as a `User` message
7747/// with the same `customType`/`display`/`details` residue.
7748///
7749/// B4 exception: when `customType` is [`PI_CLAUDE_SYSTEM_CUSTOM_TYPE`] (our
7750/// own marker — see `write_pi_entries`'s `Role::System` arm), this is
7751/// actually a re-materialized content-bearing Claude `system` record round-
7752/// tripping through pi, not a genuine pi extension message — restore
7753/// `Role::System` + `metadata["systemSubtype"]` (from `details.
7754/// claude_system_subtype`, falling back to `local_command` — still one of
7755/// `push_claude_system`'s own keep subtypes — exactly like
7756/// `write_codex_records`'s Codex-leg fallback) instead of the generic
7757/// `Role::User` path below, so a Claude -> Pi -> Claude round trip restores
7758/// the exact original role, not just the text.
7759fn push_pi_custom_common(v: &Value, out: &mut Vec<ChatMessage>) {
7760 if v.get("customType").and_then(Value::as_str) == Some(PI_CLAUDE_SYSTEM_CUSTOM_TYPE) {
7761 let content = v.get("content").and_then(Value::as_str).unwrap_or("");
7762 if content.trim().is_empty() {
7763 return;
7764 }
7765 let subtype = v
7766 .get("details")
7767 .and_then(|d| d.get("claude_system_subtype"))
7768 .and_then(Value::as_str)
7769 .unwrap_or("local_command");
7770 out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
7771 return;
7772 }
7773 let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(v.get("content"));
7774 // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
7775 // survival, never a synthesized-empty part.
7776 if unknown_image_shape {
7777 return;
7778 }
7779 if text.trim().is_empty() && parts.is_none() {
7780 return;
7781 }
7782 let mut msg = match parts {
7783 Some(parts) => ChatMessage {
7784 role: Role::User,
7785 content: None,
7786 content_parts: Some(parts),
7787 tool_calls: None,
7788 tool_call_id: None,
7789 name: None,
7790 metadata: Default::default(),
7791 },
7792 None => ChatMessage::user(text),
7793 };
7794 if let Some(ct) = v.get("customType").and_then(Value::as_str) {
7795 msg.metadata
7796 .insert("pi_custom_type".to_string(), ct.to_string());
7797 }
7798 if let Some(d) = v.get("display").and_then(Value::as_bool) {
7799 msg.metadata.insert("pi_display".to_string(), d.to_string());
7800 }
7801 if let Some(details) = v.get("details") {
7802 if !details.is_null() {
7803 msg.metadata
7804 .insert("pi_details".to_string(), details.to_string());
7805 }
7806 }
7807 // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
7808 // separately from the canonical entry-level ISO `timestamp` — see
7809 // `push_pi_user`. `v` here is the `message` object for the `role:
7810 // "custom"` case; for the top-level `custom_message` case `v` is the
7811 // entry itself, whose `timestamp` is the entry-level ISO string (not a
7812 // u64), so this is a no-op there — matching pre-WAVE-2 behavior.
7813 if let Some(ts) = v.get("timestamp").and_then(Value::as_u64) {
7814 msg.metadata
7815 .insert("pi_msg_timestamp".to_string(), ts.to_string());
7816 }
7817 out.push(msg);
7818}
7819
7820/// pi's own prefix-wrapped user text for a `compaction` entry summary
7821/// (§2.1: "compaction/branch summaries (pi's own prefix-wrapped user text)").
7822/// The exact upstream wrapper string is cited (`msg:11-17`) but not
7823/// reproduced in the frozen research doc; this is a clearly-labeled
7824/// reconstruction, not pi's literal bytes — see build-report ambiguity note.
7825fn push_pi_compaction(entry_v: &Value, out: &mut Vec<ChatMessage>) {
7826 let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
7827 if summary.trim().is_empty() {
7828 return;
7829 }
7830 let mut msg = ChatMessage::user(format!("[compaction summary]\n{summary}"));
7831 msg.metadata
7832 .insert("pi_type".to_string(), "compaction".to_string());
7833 if let Some(fk) = entry_v.get("firstKeptEntryId").and_then(Value::as_str) {
7834 msg.metadata
7835 .insert("pi_first_kept_entry_id".to_string(), fk.to_string());
7836 }
7837 if let Some(tb) = entry_v.get("tokensBefore").and_then(Value::as_u64) {
7838 msg.metadata
7839 .insert("pi_tokens_before".to_string(), tb.to_string());
7840 }
7841 if let Some(d) = entry_v.get("details") {
7842 if !d.is_null() {
7843 msg.metadata.insert("pi_details".to_string(), d.to_string());
7844 }
7845 }
7846 if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
7847 msg.metadata
7848 .insert("pi_from_hook".to_string(), "true".to_string());
7849 }
7850 out.push(msg);
7851}
7852
7853/// pi's own prefix-wrapped user text for a `branch_summary` entry (a
7854/// rewind-with-summary) — same reconstruction caveat as
7855/// [`push_pi_compaction`].
7856fn push_pi_branch_summary(entry_v: &Value, out: &mut Vec<ChatMessage>) {
7857 let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
7858 if summary.trim().is_empty() {
7859 return;
7860 }
7861 let mut msg = ChatMessage::user(format!("[branch summary]\n{summary}"));
7862 msg.metadata
7863 .insert("pi_type".to_string(), "branch_summary".to_string());
7864 if let Some(f) = entry_v.get("fromId").and_then(Value::as_str) {
7865 msg.metadata.insert("pi_from_id".to_string(), f.to_string());
7866 }
7867 if let Some(d) = entry_v.get("details") {
7868 if !d.is_null() {
7869 msg.metadata.insert("pi_details".to_string(), d.to_string());
7870 }
7871 }
7872 if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
7873 msg.metadata
7874 .insert("pi_from_hook".to_string(), "true".to_string());
7875 }
7876 out.push(msg);
7877}
7878
7879// ---- OpenCode ---------------------------------------------------------
7880
7881/// The placeholder opencode's own replay substitutes for a `tool` part's
7882/// output once `state.completed.time.compacted` is set
7883/// (`message-v2.ts:293-296 @fd9ee43`) — the REAL output is never actually
7884/// erased from the record (S1); it survives in `raw` and in this loader's
7885/// `metadata["oc_tool_output_compacted"]`.
7886pub const OPENCODE_COMPACTED_TOOL_PLACEHOLDER: &str = "[Old tool result content cleared]";
7887
7888fn capture_opencode_session_info(si: &Value, meta: &mut SessionMeta) -> Result<()> {
7889 restore_codex_provenance_from_top_level(si, meta)?;
7890 if let Some(id) = si.get("id").and_then(Value::as_str) {
7891 meta.session_id = Some(id.to_string());
7892 }
7893 if let Some(dir) = si.get("directory").and_then(Value::as_str) {
7894 meta.cwd = Some(PathBuf::from(dir));
7895 }
7896 if let Some(agent) = si.get("agent").and_then(Value::as_str) {
7897 meta.agent_id = Some(agent.to_string());
7898 }
7899 if let Some(model) = si.get("model") {
7900 let provider = model.get("providerID").and_then(Value::as_str);
7901 let id = model.get("id").and_then(Value::as_str);
7902 if let (Some(p), Some(i)) = (provider, id) {
7903 meta.model = Some(format!("{p}/{i}"));
7904 }
7905 }
7906 if let Some(project_id) = si.get("projectID").and_then(Value::as_str) {
7907 meta.lineage
7908 .insert("projectID".to_string(), project_id.to_string());
7909 }
7910 if let Some(slug) = si.get("slug").and_then(Value::as_str) {
7911 meta.lineage.insert("slug".to_string(), slug.to_string());
7912 }
7913 if let Some(ws) = si.get("workspaceID").and_then(Value::as_str) {
7914 meta.lineage
7915 .insert("workspaceID".to_string(), ws.to_string());
7916 }
7917 if let Some(parent) = si.get("parentID").and_then(Value::as_str) {
7918 meta.lineage
7919 .insert("parent_session_id".to_string(), parent.to_string());
7920 // Mirrored under the Codex-originated lineage key so the existing
7921 // generic `Session::reconstruct_tree` nests opencode subagent
7922 // sessions too, with no format-specific nesting pass (§2.1: "child
7923 // session's parentID ... → drives reconstruct_tree").
7924 meta.lineage
7925 .insert("parent_thread_id".to_string(), parent.to_string());
7926 }
7927 // D7: the other half of `synthesized_opencode_info`'s passthrough —
7928 // restores a captured Claude `fork-context-ref` so a Claude -> OpenCode
7929 // -> Claude round trip reconstructs the original record (mirrors
7930 // `capture_codex_session_meta`/`capture_pi_header`'s identical
7931 // `claude_fork_context_ref` restore for the Codex/Pi hops).
7932 if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
7933 if let Some(v) = si.get("claude_fork_context_ref") {
7934 meta.lineage
7935 .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
7936 }
7937 }
7938 Ok(())
7939}
7940
7941/// An opencode `User`/`Assistant` `file` part's image data-URI →
7942/// `content_parts` `image_url` entry (§2.1). Only `data:` URIs with an
7943/// `image/*` mime are mapped ("T3 clean for images; non-media residue") —
7944/// a bare filesystem path, an `https:` link, or a non-image mime is left as
7945/// raw-only residue (§2.3), never a corrupt/guessed `image_url`. `pub(crate)`
7946/// so [`crate::audit::audit_opencode_line`] can classify a `file` part's
7947/// coverage with the SAME test this loader uses to canonicalize it (D5) —
7948/// one definition of "is this file part actually replayed", not two.
7949#[doc(hidden)]
7950pub fn opencode_file_image_part(part: &Value) -> Option<Value> {
7951 let mime = part.get("mime").and_then(Value::as_str)?;
7952 let url = part.get("url").and_then(Value::as_str)?;
7953 if !mime.starts_with("image/") || !url.starts_with("data:") {
7954 return None;
7955 }
7956 Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
7957}
7958
7959/// B4: the part-`metadata` key `append_synthesized_opencode_messages`'s
7960/// `Role::System` arm stamps on the one `synthetic: true` text part of a
7961/// re-materialized content-bearing Claude `system` record (see that arm's
7962/// doc comment). Namespaced (`supercode_`-prefixed) so it can never collide
7963/// with real opencode/provider part metadata (e.g. `anthropic.signature`).
7964const OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: &str = "supercode_claude_system_subtype";
7965
7966/// Detect `append_synthesized_opencode_messages`'s own marker shape: a
7967/// `User` message with EXACTLY one `synthetic: true` text part carrying
7968/// [`OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY`] in its part-level `metadata`. Real
7969/// opencode data is never misclassified — a genuine opencode `synthetic`
7970/// text part never carries this supercode-namespaced key, and a real
7971/// multi-part user message (text + an attached file, say) never matches
7972/// (`parts.len() != 1` bails). Returns the original Claude `systemSubtype`
7973/// (e.g. `local_command`) on a match.
7974fn opencode_claude_system_subtype(parts: &[Value]) -> Option<String> {
7975 let [part] = parts else { return None };
7976 if part.get("type").and_then(Value::as_str) != Some("text") {
7977 return None;
7978 }
7979 if part.get("synthetic").and_then(Value::as_bool) != Some(true) {
7980 return None;
7981 }
7982 part.get("metadata")
7983 .and_then(|m| m.get(OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY))
7984 .and_then(Value::as_str)
7985 .map(str::to_string)
7986}
7987
7988/// The [`opencode_claude_system_subtype`] match arm: restores `Role::System`
7989/// and `metadata["systemSubtype"]` from the marked text part instead of
7990/// `push_opencode_user`'s generic `Role::User` path, so a Claude ->
7991/// OpenCode -> Claude round trip restores the exact original role, not just
7992/// the text. Content is never fabricated — only emitted when non-empty.
7993fn push_opencode_claude_system(
7994 msg_value: &Value,
7995 parts: &[Value],
7996 subtype: String,
7997 out: &mut Vec<ChatMessage>,
7998) {
7999 let Some(text) = parts
8000 .first()
8001 .and_then(|p| p.get("text"))
8002 .and_then(Value::as_str)
8003 else {
8004 return;
8005 };
8006 if text.trim().is_empty() {
8007 return;
8008 }
8009 let mut msg = ChatMessage::system(text.to_string()).with_meta("systemSubtype", subtype);
8010 set_opencode_msg_timestamp(&mut msg, msg_value);
8011 out.push(msg);
8012}
8013
8014/// Map an opencode `User` message (`msg_value`) + its parts to zero or one
8015/// canonical `Role::User` `ChatMessage` (§2.1). `text` parts concatenate
8016/// (an `ignored` one is never included — §2.2, "must not be re-emitted to
8017/// the model"); `file` parts with a recognized image shape become
8018/// `content_parts`. `System` (opencode's per-turn `User.system`) fills
8019/// `SessionMeta.system_prompt` on the first turn that carries it, and
8020/// `metadata["system"]` on every turn that does (§2.1: "System prompt is
8021/// per-user-message, not per-session").
8022/// Fold an opencode message envelope's `time.created` (unix-ms) into the
8023/// canonical `metadata["timestamp"]` (ISO-8601, WAVE-2 item 1) — the same
8024/// field claude/codex/pi loaders populate. Lossless to millisecond precision
8025/// (opencode's own wire granularity); a `None`/malformed `time.created`
8026/// leaves `metadata["timestamp"]` unset, so the writer falls back to
8027/// `SYNTH_TS`/`SYNTH_TS_MS`.
8028fn set_opencode_msg_timestamp(msg: &mut ChatMessage, msg_value: &Value) {
8029 if let Some(ms) = msg_value
8030 .get("time")
8031 .and_then(|t| t.get("created"))
8032 .and_then(Value::as_i64)
8033 {
8034 msg.metadata
8035 .insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
8036 }
8037}
8038
8039fn push_opencode_user(
8040 msg_value: &Value,
8041 parts: &[Value],
8042 out: &mut Vec<ChatMessage>,
8043 meta: &mut SessionMeta,
8044 first_system_seen: &mut bool,
8045) {
8046 let mut text = String::new();
8047 let mut image_parts: Vec<Value> = Vec::new();
8048 let mut has_ignored = false;
8049 for p in parts {
8050 match p.get("type").and_then(Value::as_str) {
8051 Some("text") => {
8052 if p.get("ignored").and_then(Value::as_bool) == Some(true) {
8053 has_ignored = true;
8054 continue; // must never be replayed (§2.2)
8055 }
8056 if let Some(t) = p.get("text").and_then(Value::as_str) {
8057 push_str_field(&mut text, t);
8058 }
8059 }
8060 Some("file") => {
8061 if let Some(img) = opencode_file_image_part(p) {
8062 image_parts.push(img);
8063 }
8064 }
8065 // reasoning/tool never appear on a User message; step-start,
8066 // step-finish, snapshot, patch, agent, subtask, retry have no
8067 // clean home (§2.3); compaction is read separately by the
8068 // caller (tail_start_id) and tagged onto the message below.
8069 _ => {}
8070 }
8071 }
8072
8073 let has_images = !image_parts.is_empty();
8074 if text.trim().is_empty() && !has_images {
8075 return;
8076 }
8077 let mut msg = if has_images {
8078 let mut all = Vec::new();
8079 if !text.trim().is_empty() {
8080 all.push(serde_json::json!({"type": "text", "text": text.clone()}));
8081 }
8082 all.extend(image_parts);
8083 ChatMessage {
8084 role: Role::User,
8085 content: None,
8086 content_parts: Some(all),
8087 tool_calls: None,
8088 tool_call_id: None,
8089 name: None,
8090 metadata: Default::default(),
8091 }
8092 } else {
8093 ChatMessage::user(text)
8094 };
8095
8096 if has_ignored {
8097 msg.metadata
8098 .insert("oc_has_ignored_part".to_string(), "true".to_string());
8099 }
8100 if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
8101 msg.metadata
8102 .insert("oc_message_id".to_string(), id.to_string());
8103 }
8104 if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
8105 msg.metadata.insert("agent".to_string(), agent.to_string());
8106 }
8107 if let Some(model) = msg_value.get("model") {
8108 if !model.is_null() {
8109 msg.metadata.insert("model".to_string(), model.to_string());
8110 }
8111 }
8112 if let Some(system) = msg_value.get("system").and_then(Value::as_str) {
8113 if !*first_system_seen {
8114 meta.system_prompt = Some(system.to_string());
8115 *first_system_seen = true;
8116 }
8117 msg.metadata
8118 .insert("system".to_string(), system.to_string());
8119 }
8120 for p in parts {
8121 if p.get("type").and_then(Value::as_str) == Some("compaction") {
8122 msg.metadata
8123 .insert("phase".to_string(), "compaction".to_string());
8124 if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
8125 msg.metadata
8126 .insert("tail_start_id".to_string(), t.to_string());
8127 }
8128 }
8129 }
8130 set_opencode_msg_timestamp(&mut msg, msg_value);
8131 restore_grok_message_extension(msg_value, &mut msg);
8132 out.push(msg);
8133}
8134
8135/// Map an opencode `Assistant` message + its parts to a canonical
8136/// `Role::Assistant` `ChatMessage` (text + `tool_calls`), followed
8137/// immediately by one `Role::Tool` `ChatMessage` per `tool` part that
8138/// reached `completed`/`error` — the split-by-`callID` opencode's single
8139/// part→two-messages mapping (§2.1). `pending`/`running` calls (an
8140/// interrupted turn) synthesize no tool call/result of their own here; the
8141/// shared [`ensure_tool_results_paired`] fills that gap uniformly, exactly
8142/// like the other three loaders. A `tool` part whose `state.status` is none
8143/// of the four known values is skipped entirely — raw-only survival, never
8144/// guessed — so [`crate::audit::Corpus::OpenCode`] can flag it.
8145fn push_opencode_assistant(
8146 msg_value: &Value,
8147 parts: &[Value],
8148 out: &mut Vec<ChatMessage>,
8149 meta: &mut SessionMeta,
8150) {
8151 let mut text = String::new();
8152 let mut calls: Vec<ToolCall> = Vec::new();
8153 let mut thinking = String::new();
8154 let mut reasoning_seen = false;
8155 let mut thinking_sig: Option<String> = None;
8156 // (call_id, tool_name, the tool part itself) — deferred so the
8157 // assistant message (carrying `tool_calls`) is pushed FIRST, matching
8158 // every other loader's message ordering (call, then result).
8159 let mut tool_results: Vec<(String, String, Value)> = Vec::new();
8160
8161 for p in parts {
8162 match p.get("type").and_then(Value::as_str) {
8163 Some("text") => {
8164 if p.get("ignored").and_then(Value::as_bool) == Some(true) {
8165 continue;
8166 }
8167 if let Some(t) = p.get("text").and_then(Value::as_str) {
8168 push_str_field(&mut text, t);
8169 }
8170 }
8171 Some("reasoning") => {
8172 reasoning_seen = true;
8173 if let Some(t) = p.get("text").and_then(Value::as_str) {
8174 push_str_field(&mut thinking, t);
8175 }
8176 if let Some(sig) = p
8177 .get("metadata")
8178 .and_then(|m| m.get("anthropic"))
8179 .and_then(|a| a.get("signature"))
8180 .and_then(Value::as_str)
8181 {
8182 thinking_sig = Some(sig.to_string());
8183 }
8184 }
8185 Some("tool") => {
8186 let call_id = p.get("callID").and_then(Value::as_str).unwrap_or_default();
8187 let tool_name = p.get("tool").and_then(Value::as_str).unwrap_or_default();
8188 let status = p
8189 .get("state")
8190 .and_then(|s| s.get("status"))
8191 .and_then(Value::as_str);
8192 let known_status = matches!(
8193 status,
8194 Some("pending") | Some("running") | Some("completed") | Some("error")
8195 );
8196 if call_id.is_empty() || !known_status {
8197 // Unknown/unrecognized status, or a malformed part with
8198 // no callID — raw-only survival, never synthesized.
8199 continue;
8200 }
8201 let input = p
8202 .get("state")
8203 .and_then(|s| s.get("input"))
8204 .cloned()
8205 .unwrap_or_else(|| Value::Object(Default::default()));
8206 calls.push(function_call(call_id, tool_name, input.to_string()));
8207 if matches!(status, Some("completed") | Some("error")) {
8208 tool_results.push((call_id.to_string(), tool_name.to_string(), p.clone()));
8209 }
8210 }
8211 // file/step-start/step-finish/snapshot/patch/agent/subtask/retry
8212 // — no clean home on an Assistant turn (§2.3).
8213 _ => {}
8214 }
8215 }
8216
8217 let before = out.len();
8218 push_assistant(out, text, calls);
8219 // A native OpenCode assistant record is transcript state even when it
8220 // has no parts. Real stores contain these after an interrupted/empty
8221 // model turn; dropping the record here loses its id, timestamp, model,
8222 // token/cost metadata, and shifts the conversation on every export.
8223 // Keep one empty canonical assistant message so all target writers can
8224 // preserve the turn. This also covers reasoning-only records (whose
8225 // reasoning payload is attached as metadata just below).
8226 if out.len() == before {
8227 let mut empty = ChatMessage {
8228 role: Role::Assistant,
8229 content: None,
8230 content_parts: None,
8231 tool_calls: None,
8232 tool_call_id: None,
8233 name: None,
8234 metadata: Default::default(),
8235 };
8236 if !reasoning_seen {
8237 empty
8238 .metadata
8239 .insert("empty_assistant_record".to_string(), "true".to_string());
8240 }
8241 out.push(empty);
8242 }
8243 if out.len() > before {
8244 let msg = out.last_mut().expect("just pushed");
8245 if reasoning_seen {
8246 msg.metadata.insert("thinking".to_string(), thinking);
8247 }
8248 if let Some(sig) = thinking_sig {
8249 msg.metadata.insert("thinking_signature".to_string(), sig);
8250 }
8251 if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
8252 msg.metadata
8253 .insert("oc_message_id".to_string(), id.to_string());
8254 }
8255 if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
8256 msg.metadata.insert("agent".to_string(), agent.to_string());
8257 if meta.agent_id.is_none() {
8258 meta.agent_id = Some(agent.to_string());
8259 }
8260 }
8261 let provider = msg_value.get("providerID").and_then(Value::as_str);
8262 let model_id = msg_value.get("modelID").and_then(Value::as_str);
8263 if let (Some(p), Some(i)) = (provider, model_id) {
8264 let full = format!("{p}/{i}");
8265 msg.metadata.insert("model".to_string(), full.clone());
8266 if meta.model.is_none() {
8267 meta.model = Some(full);
8268 }
8269 }
8270 if let Some(cwd) = msg_value
8271 .get("path")
8272 .and_then(|p| p.get("cwd"))
8273 .and_then(Value::as_str)
8274 {
8275 if meta.cwd.is_none() {
8276 meta.cwd = Some(PathBuf::from(cwd));
8277 }
8278 }
8279 if msg_value.get("summary").and_then(Value::as_bool) == Some(true) {
8280 msg.metadata
8281 .insert("is_summary".to_string(), "true".to_string());
8282 }
8283 for (key, field) in [
8284 ("finish", "finish"),
8285 ("variant", "variant"),
8286 ("mode", "mode"),
8287 ] {
8288 if let Some(s) = msg_value.get(field).and_then(Value::as_str) {
8289 msg.metadata.insert(key.to_string(), s.to_string());
8290 }
8291 }
8292 for (key, field) in [
8293 ("cost", "cost"),
8294 ("tokens", "tokens"),
8295 ("error", "error"),
8296 ("structured", "structured"),
8297 ] {
8298 if let Some(v) = msg_value.get(field) {
8299 if !v.is_null() {
8300 msg.metadata.insert(key.to_string(), v.to_string());
8301 }
8302 }
8303 }
8304 // Subagent linkage (§2.1): a `task` tool's own `metadata` carries
8305 // the spawned child session id — keyed by callID so multiple `task`
8306 // calls in one message never collide.
8307 // `resolve_opencode_parent_tool_use_ids` reads these back once a
8308 // whole session set is loaded.
8309 for p in parts {
8310 if p.get("type").and_then(Value::as_str) == Some("tool")
8311 && p.get("tool").and_then(Value::as_str) == Some("task")
8312 {
8313 if let (Some(call_id), Some(child)) = (
8314 p.get("callID").and_then(Value::as_str),
8315 p.get("metadata")
8316 .and_then(|m| m.get("sessionId"))
8317 .and_then(Value::as_str),
8318 ) {
8319 msg.metadata.insert(
8320 format!("oc_task_child_session_id__{call_id}"),
8321 child.to_string(),
8322 );
8323 }
8324 }
8325 }
8326 set_opencode_msg_timestamp(msg, msg_value);
8327 restore_grok_message_extension(msg_value, msg);
8328 }
8329
8330 // Second pass: the paired Tool-role message for each completed/error
8331 // tool part, split by callID (§2.1 — "the SAME part carries call and
8332 // result").
8333 for (call_id, tool_name, part) in tool_results {
8334 let status = part
8335 .get("state")
8336 .and_then(|s| s.get("status"))
8337 .and_then(Value::as_str);
8338 let compacted_at = part
8339 .get("state")
8340 .and_then(|s| s.get("time"))
8341 .and_then(|t| t.get("compacted"))
8342 .and_then(Value::as_i64);
8343 let real_output = part
8344 .get("state")
8345 .and_then(|s| s.get("output"))
8346 .and_then(Value::as_str)
8347 .unwrap_or("")
8348 .to_string();
8349 let (content, is_error) = match status {
8350 Some("completed") => {
8351 if compacted_at.is_some() {
8352 (OPENCODE_COMPACTED_TOOL_PLACEHOLDER.to_string(), false)
8353 } else {
8354 (real_output.clone(), false)
8355 }
8356 }
8357 Some("error") => {
8358 let err = part
8359 .get("state")
8360 .and_then(|s| s.get("error"))
8361 .and_then(Value::as_str)
8362 .unwrap_or("")
8363 .to_string();
8364 (err, true)
8365 }
8366 _ => (String::new(), false),
8367 };
8368 let mut tmsg = ChatMessage {
8369 role: Role::Tool,
8370 content: Some(content),
8371 content_parts: None,
8372 tool_calls: None,
8373 tool_call_id: Some(call_id),
8374 name: Some(tool_name),
8375 metadata: Default::default(),
8376 };
8377 if let Some(original_position) = part
8378 .get(OPENCODE_SUPERCODE_RESULT_POSITION)
8379 .and_then(Value::as_u64)
8380 {
8381 tmsg.metadata.insert(
8382 OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
8383 original_position.to_string(),
8384 );
8385 }
8386 if is_error {
8387 crate::mark_tool_error(&mut tmsg);
8388 }
8389 restore_tool_outcome_extension(&part, &mut tmsg);
8390 if let Some(ts) = compacted_at {
8391 // S1: the real output is preserved — reversible, never erased.
8392 tmsg.metadata
8393 .insert("oc_tool_output_compacted".to_string(), real_output);
8394 tmsg.metadata
8395 .insert("oc_tool_time_compacted".to_string(), ts.to_string());
8396 }
8397 if status == Some("completed") {
8398 if let Some(atts) = part
8399 .get("state")
8400 .and_then(|s| s.get("attachments"))
8401 .and_then(Value::as_array)
8402 {
8403 let images: Vec<Value> = atts.iter().filter_map(opencode_file_image_part).collect();
8404 if !images.is_empty() {
8405 // D-mix consistency fix (Fable-recommended, same
8406 // pattern as `push_claude_user`'s tool_result arm above):
8407 // a completed opencode tool part with BOTH `state.output`
8408 // text and `state.attachments` images is the same
8409 // non-self-contained hybrid shape — `content_parts` here
8410 // used to hold images only, so opencode -> pi silently
8411 // dropped the output text (`pi_content_value` reads
8412 // `content_parts` exclusively for `Role::Tool`). Prepend
8413 // the text as part 0 so `content_parts` is
8414 // self-contained; `tmsg.content` keeps the text too,
8415 // unchanged, for writers that read it from there and
8416 // only scan `content_parts` for `image_url` entries.
8417 let mut parts = Vec::new();
8418 if let Some(t) = &tmsg.content {
8419 if !t.is_empty() {
8420 parts.push(serde_json::json!({"type": "text", "text": t}));
8421 }
8422 }
8423 parts.extend(images);
8424 tmsg.content_parts = Some(parts);
8425 }
8426 }
8427 }
8428 if let Some(id) = part.get("id").and_then(Value::as_str) {
8429 tmsg.metadata
8430 .insert("oc_part_id".to_string(), id.to_string());
8431 }
8432 // WAVE-2 item 1: a tool part's own `state.time.{end,start}` (unix-ms,
8433 // real opencode wire shape — `pi-fields.md`/`opencode-pi-spec.md`
8434 // cite `state.time.compacted`, but the SAME object also carries
8435 // `start`/`end` on every completed/error call) is this Tool
8436 // message's real source timestamp; prefer `end` (completion, closer
8437 // to when the RESULT — this message's content — was produced) and
8438 // fall back to `start` when only that is present.
8439 let tool_ts = part
8440 .get("state")
8441 .and_then(|s| s.get("time"))
8442 .and_then(|t| t.get("end").or_else(|| t.get("start")))
8443 .and_then(Value::as_i64);
8444 if let Some(ms) = tool_ts {
8445 tmsg.metadata
8446 .insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
8447 }
8448 // OpenCode folds a canonical tool result into the assistant's tool
8449 // part. Restore the portable envelope from that part after native
8450 // fields have been captured so A -> OpenCode -> A retains fields
8451 // OpenCode does not model independently (for example Goose's
8452 // message-level metadata and an intentionally absent tool name).
8453 restore_grok_message_extension(&part, &mut tmsg);
8454 out.push(tmsg);
8455 }
8456}
8457
8458// ---- shared helpers -------------------------------------------------------
8459
8460fn push_text(buf: &mut String, v: Option<&Value>) {
8461 if let Some(Value::String(s)) = v {
8462 if !buf.is_empty() {
8463 buf.push('\n');
8464 }
8465 buf.push_str(s);
8466 }
8467}
8468
8469/// Extract a Claude `tool_result` block's content, preserving non-text items
8470/// instead of silently dropping them:
8471///
8472/// - text blocks are concatenated;
8473/// - PARITY-11 (nested images, real-session-confirmed — the everyday "Read a
8474/// PNG / screenshot tool output" shape): `image` blocks are captured into
8475/// the returned `content_parts`-shaped `Vec<Value>` via
8476/// [`claude_image_block_to_part`] — the SAME base64/url conversion the
8477/// top-level `image` content-block path (`push_claude_user`) already uses
8478/// — instead of being flattened to the bare `[image]` marker text that used
8479/// to make the data unrecoverable from every writer. An unconvertible
8480/// source (D5 discipline — a Files-API `{"type":"file",...}` reference,
8481/// etc.) folds [`UNCONVERTIBLE_IMAGE_MARKER`] into the text instead of
8482/// vanishing, exactly like the top-level path;
8483/// - `tool_reference` blocks become `[tool_reference: <name>]`;
8484///
8485/// and if the block yields no text/images at all, fall back to the record's
8486/// `toolUseResult` field (string used directly, structured value serialized),
8487/// which is where Claude Code stores the actual result in many cases.
8488///
8489/// Returns `(text, images)`; callers that only need the old text-only
8490/// behavior can ignore the second element — every caller MUST fold non-empty
8491/// `images` into the resulting `ChatMessage.content_parts` themselves (this
8492/// function has no `ChatMessage` to attach to).
8493fn extract_tool_result_content(
8494 content: Option<&Value>,
8495 tool_use_result: Option<&Value>,
8496) -> (String, Vec<Value>) {
8497 let mut parts: Vec<String> = Vec::new();
8498 let mut images: Vec<Value> = Vec::new();
8499 match content {
8500 Some(Value::String(s)) => {
8501 if !s.is_empty() {
8502 parts.push(s.clone());
8503 }
8504 }
8505 Some(Value::Array(items)) => {
8506 for item in items {
8507 match item.get("type").and_then(Value::as_str) {
8508 Some("text") => {
8509 if let Some(t) = item.get("text").and_then(Value::as_str) {
8510 parts.push(t.to_string());
8511 }
8512 }
8513 Some("image") => match claude_image_block_to_part(item) {
8514 Some(part) => images.push(part),
8515 None => parts.push(UNCONVERTIBLE_IMAGE_MARKER.to_string()),
8516 },
8517 Some("tool_reference") => {
8518 let name = item.get("tool_name").and_then(Value::as_str).unwrap_or("?");
8519 parts.push(format!("[tool_reference: {name}]"));
8520 }
8521 _ => {
8522 if let Some(s) = item.as_str() {
8523 parts.push(s.to_string());
8524 }
8525 }
8526 }
8527 }
8528 }
8529 Some(other) => parts.push(other.to_string()),
8530 None => {}
8531 }
8532
8533 let joined = parts.join("\n");
8534 if !joined.trim().is_empty() || !images.is_empty() {
8535 return (joined, images);
8536 }
8537 // Empty tool_result content — recover from toolUseResult.
8538 match tool_use_result {
8539 Some(Value::String(s)) => (s.clone(), images),
8540 Some(v) => (v.to_string(), images),
8541 None => (joined, images),
8542 }
8543}
8544
8545/// Pull readable text out of a content value that may be a plain string or an
8546/// array of `{ "text": "..." }`-bearing blocks (any block type).
8547fn extract_text_content(v: Option<&Value>) -> String {
8548 match v {
8549 Some(Value::String(s)) => s.clone(),
8550 Some(Value::Array(items)) => {
8551 let mut parts = Vec::new();
8552 for item in items {
8553 if let Some(t) = item.get("text").and_then(Value::as_str) {
8554 parts.push(t.to_string());
8555 } else if let Some(s) = item.as_str() {
8556 parts.push(s.to_string());
8557 }
8558 }
8559 parts.join("\n")
8560 }
8561 Some(other) => other.to_string(),
8562 None => String::new(),
8563 }
8564}
8565
8566/// Extract Codex `input_image` content blocks from a `message` response_item's
8567/// `content` value into `content_parts` `image_url` entries — the inverse of
8568/// [`codex_message_content_blocks`]'s `input_image` emission (IX-5). Only a
8569/// block whose `image_url` is a non-empty string is recognized; anything else
8570/// (a missing/malformed `image_url`, a `file_id`-only reference) is left as
8571/// raw-only residue rather than synthesizing a corrupt/empty part — mirroring
8572/// the pi/opencode/Claude loaders' image-shape discipline.
8573fn codex_extract_images(content: Option<&Value>) -> Vec<Value> {
8574 let Some(Value::Array(items)) = content else {
8575 return Vec::new();
8576 };
8577 items
8578 .iter()
8579 .filter(|item| item.get("type").and_then(Value::as_str) == Some("input_image"))
8580 .filter_map(|item| {
8581 let url = item.get("image_url").and_then(Value::as_str)?;
8582 if url.is_empty() {
8583 return None;
8584 }
8585 Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
8586 })
8587 .collect()
8588}
8589
8590fn value_to_arg_string(v: &Value) -> String {
8591 match v {
8592 Value::String(s) => s.clone(),
8593 other => other.to_string(),
8594 }
8595}
8596
8597fn push_gemini_user_parts(
8598 messages: &mut Vec<ChatMessage>,
8599 content_parts: Vec<Value>,
8600 timestamp: Option<&str>,
8601 source: &Value,
8602) {
8603 if content_parts.is_empty() {
8604 return;
8605 }
8606 let mut message = ChatMessage {
8607 role: Role::User,
8608 content: None,
8609 content_parts: Some(content_parts),
8610 tool_calls: None,
8611 tool_call_id: None,
8612 name: None,
8613 metadata: Default::default(),
8614 };
8615 if let Some(timestamp) = timestamp {
8616 message
8617 .metadata
8618 .insert("timestamp".into(), timestamp.into());
8619 }
8620 restore_gemini_message_extension(source, &mut message);
8621 messages.push(message);
8622}
8623
8624fn function_call(id: &str, name: &str, arguments: String) -> ToolCall {
8625 ToolCall {
8626 id: id.to_string(),
8627 kind: "function".to_string(),
8628 function: FunctionCall {
8629 name: name.to_string(),
8630 arguments,
8631 },
8632 }
8633}
8634
8635fn tool_message(tool_call_id: &str, content: String) -> ChatMessage {
8636 ChatMessage {
8637 role: Role::Tool,
8638 content: Some(content),
8639 content_parts: None,
8640 tool_calls: None,
8641 tool_call_id: Some(tool_call_id.to_string()),
8642 name: None,
8643 metadata: Default::default(),
8644 }
8645}
8646
8647/// Emit a single assistant message combining accumulated text and tool calls.
8648/// A turn with neither (e.g. thinking-only) produces nothing.
8649fn push_assistant(out: &mut Vec<ChatMessage>, text: String, calls: Vec<ToolCall>) {
8650 let has_text = !text.trim().is_empty();
8651 if !has_text && calls.is_empty() {
8652 return;
8653 }
8654 out.push(ChatMessage {
8655 role: Role::Assistant,
8656 content: has_text.then_some(text),
8657 content_parts: None,
8658 tool_calls: (!calls.is_empty()).then_some(calls),
8659 tool_call_id: None,
8660 name: None,
8661 metadata: Default::default(),
8662 });
8663}
8664
8665// ---- writers --------------------------------------------------------------
8666
8667/// A placeholder timestamp for SYNTHESIZED records only — WAVE-2 item 1's
8668/// fallback (`docs/interop` build brief): every writer now emits a message's
8669/// REAL source timestamp (`metadata["timestamp"]`, the canonical ISO-8601
8670/// field every loader populates) when one is present. `SYNTH_TS` fires only
8671/// for a message with no source timestamp at all — a turn synthesized/
8672/// appended after import (the live agent loop, a splice's appended tail,
8673/// ...), which was never loaded from a real per-message timestamp to begin
8674/// with. Both tools tolerate identical timestamps; callers that need real
8675/// ones for a synthesized turn can post-process.
8676const SYNTH_TS: &str = "2026-01-01T00:00:00.000Z";
8677
8678/// The unix-millisecond twin of [`SYNTH_TS`], for OpenCode's numeric
8679/// `time.created`/`time.updated` fields.
8680const SYNTH_TS_MS: i64 = 1_767_225_600_000;
8681
8682/// `msg`'s real source timestamp (`metadata["timestamp"]`, canonical
8683/// ISO-8601 — WAVE-2 item 1) when present AND well-formed, else the
8684/// [`SYNTH_TS`] fallback. Validated via [`crate::sidecar::rfc3339_to_ms`] (a
8685/// parse, not just a presence check) so an absent, empty, or malformed
8686/// source value all degrade to the same documented fallback rather than
8687/// propagating garbage verbatim. Used by every writer that emits an
8688/// ISO-8601 timestamp field
8689/// (Claude Code, Codex, pi's entry-level `timestamp`).
8690fn msg_timestamp_or_synth(msg: &ChatMessage) -> &str {
8691 match msg.metadata.get("timestamp") {
8692 Some(ts) if crate::sidecar::rfc3339_to_ms(ts).is_some() => ts.as_str(),
8693 _ => SYNTH_TS,
8694 }
8695}
8696
8697/// OpenCode reloads an export document by sorting messages on
8698/// `time.created`, so a timestamp-less appended continuation cannot reuse
8699/// the fixed historical [`SYNTH_TS_MS`] fallback when its imported prefix is
8700/// newer. Advance a deterministic cursor for synthesized clocks while still
8701/// preserving every real source timestamp verbatim.
8702fn opencode_message_timestamp(msg: &ChatMessage, cursor: &mut i64) -> Result<i64> {
8703 if let Some(real) = msg
8704 .metadata
8705 .get("timestamp")
8706 .and_then(|s| crate::sidecar::rfc3339_to_ms(s))
8707 {
8708 // A NativeTurn timestamp is durable provenance minted by supercode,
8709 // not an OpenCode source clock that must be replayed verbatim.
8710 // Multiple turns may be recorded in the same millisecond, while
8711 // OpenCode sorts solely by `time.created`; allocate such turns after
8712 // the existing cursor so their persisted order cannot collapse. This
8713 // also preserves the fail-closed i64::MAX exhaustion behavior.
8714 if msg.metadata.contains_key("supercode_native_uuid") && real <= *cursor {
8715 *cursor = cursor.checked_add(1).ok_or_else(|| {
8716 crate::Error::Other(
8717 "cannot synthesize an OpenCode continuation timestamp after i64::MAX"
8718 .to_string(),
8719 )
8720 })?;
8721 return Ok(*cursor);
8722 }
8723 *cursor = (*cursor).max(real);
8724 return Ok(real);
8725 }
8726 let next = cursor.checked_add(1).ok_or_else(|| {
8727 crate::Error::Other(
8728 "cannot synthesize an OpenCode continuation timestamp after i64::MAX".to_string(),
8729 )
8730 })?;
8731 *cursor = next.max(SYNTH_TS_MS);
8732 Ok(*cursor)
8733}
8734
8735/// Largest integer nested under any OpenCode `time` object. Imported
8736/// prefixes carry more clocks than `message.time.created` (assistant
8737/// completion, tool start/end, session updated); a synthesized continuation
8738/// must follow all of them, not merely sort after message creation times.
8739fn opencode_max_timestamp(value: &Value) -> Option<i64> {
8740 fn max_number(value: &Value) -> Option<i64> {
8741 match value {
8742 Value::Number(n) => n.as_i64(),
8743 Value::Array(values) => values.iter().filter_map(max_number).max(),
8744 Value::Object(fields) => fields.values().filter_map(max_number).max(),
8745 _ => None,
8746 }
8747 }
8748
8749 match value {
8750 Value::Array(values) => values.iter().filter_map(opencode_max_timestamp).max(),
8751 Value::Object(fields) => fields
8752 .iter()
8753 .filter_map(|(key, value)| {
8754 if key == "time" {
8755 max_number(value)
8756 } else {
8757 opencode_max_timestamp(value)
8758 }
8759 })
8760 .max(),
8761 _ => None,
8762 }
8763}
8764
8765/// pi's own message-level unix-ms clock (`metadata["pi_msg_timestamp"]`,
8766/// restored by the pi loader's `push_pi_*` helpers) — DISTINCT from the
8767/// canonical entry-level ISO `metadata["timestamp"]` [`msg_timestamp_or_synth`]
8768/// reads. The two carry genuinely different values in real pi corpora (a
8769/// message-level clock reading vs. the entry's own wall-clock stamp), so this
8770/// is intentionally its own accessor. Used ONLY by [`Session::write_pi_entries`]'s
8771/// nested `message.timestamp` field, so a pi -> pi native round-trip
8772/// preserves the source message-level clock value-exact instead of deriving
8773/// it from the (distinct) entry-level timestamp. Falls back to
8774/// [`SYNTH_TS_MS`] for a message that never carried a pi message-level clock
8775/// reading (non-pi-sourced, or a synthesized/appended turn).
8776fn msg_pi_native_timestamp_ms(msg: &ChatMessage) -> i64 {
8777 msg.metadata
8778 .get("pi_msg_timestamp")
8779 .and_then(|s| s.parse::<i64>().ok())
8780 .unwrap_or(SYNTH_TS_MS)
8781}
8782
8783/// A format-valid (v4-shaped) but deterministic uuid, derived from a counter.
8784fn synth_uuid(n: usize) -> String {
8785 format!("00000000-0000-4000-8000-{n:012x}")
8786}
8787
8788/// R1 (Skeptic B, spliced-export uuid-collision hardening — the same bug
8789/// class N2 closed for the Codex spliced path's group ids, see
8790/// `collect_codex_group_ids_from_raw`/`write_codex_records`): mint the next
8791/// `synth_uuid`, skipping any value already present in `seed_used_ids` — the
8792/// uuids [`collect_claude_uuids_from_raw`] found already sitting in the
8793/// verbatim raw prefix [`Session::to_claude_code_jsonl_spliced`] replays
8794/// ahead of the tail this counter mints. Without this, re-splicing a
8795/// previously-exported-then-reimported session (export -> reimport -> append
8796/// -> export again) restarts `counter` at 1 with no memory of the prior
8797/// export's tail uuids now sitting in the prefix, so the second tail
8798/// fabricates the SAME `00000000-0000-4000-8000-…` values the first one did
8799/// — a uuid collision across prefix and tail that can mis-link any
8800/// uuid-keyed consumer (fork/tree lineage, `parentUuid`). `counter` keeps
8801/// climbing monotonically even across skips. `used_ids` is also updated for
8802/// each minted or metadata-backed identity, so collisions are prevented both
8803/// against the replayed prefix and within the appended tail.
8804fn next_claude_uuid(counter: &mut usize, used_ids: &mut HashSet<String>) -> String {
8805 loop {
8806 let candidate = synth_uuid(*counter);
8807 *counter += 1;
8808 if used_ids.insert(candidate.clone()) {
8809 return candidate;
8810 }
8811 }
8812}
8813
8814/// Reuse a message's durable native/source UUID when available, falling back
8815/// to the deterministic synthesized sequence only for hand-built or legacy
8816/// messages that never carried identity metadata.
8817fn claude_message_uuid(
8818 msg: &ChatMessage,
8819 counter: &mut usize,
8820 used_ids: &mut HashSet<String>,
8821) -> String {
8822 for key in ["claude_uuid", "supercode_native_uuid"] {
8823 if let Some(candidate) = msg.metadata.get(key) {
8824 if !candidate.is_empty() && used_ids.insert(candidate.clone()) {
8825 return candidate.clone();
8826 }
8827 }
8828 }
8829 next_claude_uuid(counter, used_ids)
8830}
8831
8832/// Companion to [`next_claude_uuid`]: every `uuid` already present in
8833/// `raw_prefix` — the verbatim RAW lines
8834/// [`Session::to_claude_code_jsonl_spliced`] replays ahead of the appended
8835/// tail it synthesizes via [`Session::write_claude_code_records`]. This is
8836/// the GROUND TRUTH of what physically lands in the exported `out` string
8837/// for the prefix (mirrors `collect_codex_group_ids_from_raw`'s approach on
8838/// the Codex side): each line is parsed as a Claude Code JSONL record and
8839/// its own top-level `uuid` field is read back out of the bytes directly, no
8840/// re-derivation from `self.messages` needed. A line that fails to parse, or
8841/// parses but carries no `uuid` (e.g. a trailing `file-history-snapshot`
8842/// record), contributes nothing.
8843fn collect_claude_uuids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
8844 let mut ids = HashSet::new();
8845 for line in raw_prefix {
8846 if let Ok(v) = serde_json::from_str::<Value>(line) {
8847 if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
8848 ids.insert(uuid.to_string());
8849 }
8850 }
8851 }
8852 ids
8853}
8854
8855fn push_jsonl(out: &mut String, value: &Value) {
8856 out.push_str(&value.to_string());
8857 out.push('\n');
8858}
8859
8860/// Replay a raw (verbatim) JSONL `line` into `out`, rewriting `key` to
8861/// `new_id` when the line parses as a JSON object carrying that key — used
8862/// by A12's Claude Code splice, where the session id lives at the top level
8863/// of (almost) every record under `key = "sessionId"`. A line that fails to
8864/// parse, or parses but lacks `key`, is copied through byte-for-byte
8865/// (nothing to patch, so nothing is reserialized).
8866fn push_spliced_line(out: &mut String, line: &str, new_id: Option<&str>, key: &str) {
8867 if let Some(new_id) = new_id {
8868 if let Ok(mut v) = serde_json::from_str::<Value>(line) {
8869 if v.get(key).is_some() {
8870 v[key] = Value::String(new_id.to_string());
8871 out.push_str(&v.to_string());
8872 out.push('\n');
8873 return;
8874 }
8875 }
8876 }
8877 out.push_str(line);
8878 out.push('\n');
8879}
8880
8881impl Session {
8882 fn cwd_string(&self) -> String {
8883 self.meta
8884 .cwd
8885 .as_ref()
8886 .map(|p| p.to_string_lossy().into_owned())
8887 .unwrap_or_else(|| ".".to_string())
8888 }
8889
8890 /// `(raw_prefix_len, message_prefix_len)` for A12 splicing: how many
8891 /// leading `raw` lines / `messages` came from the imported log, as
8892 /// opposed to being appended after import.
8893 ///
8894 /// `imported_message_count` (see its doc comment) pins the message-side
8895 /// boundary directly. The raw-side boundary isn't separately tracked —
8896 /// [`Self::from_native_str`]'s turn-appending loop pushes exactly one
8897 /// `raw` line per appended message, so the two lists grow by the same
8898 /// `appended_count` from the same starting point, and
8899 /// `raw.len() - appended_count` recovers it without a second counter.
8900 fn spliced_prefix_lens(&self) -> (usize, usize) {
8901 let message_prefix_len = self
8902 .imported_message_count
8903 .unwrap_or(self.messages.len())
8904 .min(self.messages.len());
8905 let appended_count = self.messages.len() - message_prefix_len;
8906 let raw_prefix_len = self.raw.len().saturating_sub(appended_count);
8907 (raw_prefix_len, message_prefix_len)
8908 }
8909
8910 /// Synthesize a Claude Code transcript.
8911 ///
8912 /// Claude Code transcripts have no slot for the *session-level system
8913 /// prompt* (that lives in the CLI, not the log) — but PARITY-6 dev/02
8914 /// (Fable-5 corpus audit) surfaced that content-bearing `System`
8915 /// `ChatMessage`s (Claude's own `type: "system"` records with a
8916 /// content-bearing `subtype` like `local_command`/`scheduled_task_fire`/
8917 /// `away_summary` — see `push_claude_system`, the exact inverse of what
8918 /// this writer now does) DO have a first-class slot: the real `type:
8919 /// "system"` record itself. This function used to unconditionally drop
8920 /// every `System` message, silently losing e.g. a real
8921 /// `<local-command-stdout>` record on any format -> Claude Code hop
8922 /// (confirmed on a real 2,982-message corpus session: Codex -> Claude
8923 /// Code dropped its one surviving `system` message, 2215 -> 2214, with
8924 /// no loss manifest). `write_claude_code_records`'s `Role::System` arm
8925 /// now re-materializes it instead.
8926 fn to_claude_code_jsonl(&self) -> String {
8927 let session_id = self
8928 .meta
8929 .session_id
8930 .clone()
8931 .unwrap_or_else(|| synth_uuid(0));
8932 let cwd = self.cwd_string();
8933 let mut out = String::new();
8934 // PARITY-10: a captured `fork-context-ref` (see `capture_claude_meta`)
8935 // re-emitted byte-for-byte, ahead of the conversation it applies to —
8936 // this is what makes the record survive the SEMANTIC Claude Code
8937 // writer (the raw-passthrough diagonal in `crates/cli` already
8938 // preserves it by construction; this covers the library `to_jsonl`
8939 // path too, e.g. a `--session-id` override that forces the semantic
8940 // writer).
8941 if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
8942 out.push_str(raw);
8943 out.push('\n');
8944 }
8945 // Full synthesis: `out` at this point has no raw prefix ahead of it
8946 // (unlike the A12 splice below), so there are no uuids yet in play
8947 // to seed against — see `next_claude_uuid`'s doc comment.
8948 self.write_claude_code_records(
8949 &mut out,
8950 &self.messages,
8951 &session_id,
8952 &cwd,
8953 None,
8954 1,
8955 &HashSet::new(),
8956 );
8957 if let Some(extension) = codex_provenance_envelope(&self.meta) {
8958 if out.is_empty() {
8959 push_jsonl(
8960 &mut out,
8961 &serde_json::json!({
8962 "type": "file-history-snapshot",
8963 "messageId": synth_uuid(1),
8964 "snapshot": {},
8965 "sessionId": session_id,
8966 "cwd": cwd,
8967 "timestamp": SYNTH_TS,
8968 }),
8969 );
8970 }
8971 inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
8972 }
8973 out
8974 }
8975
8976 /// Synthesize Claude Code records for `messages` (a full session or an
8977 /// appended tail — A12's [`Self::to_jsonl_spliced`] reuses this for just
8978 /// the latter), starting the `parentUuid` chain at `parent` and the
8979 /// `synth_uuid` counter at `counter`. Factored out of
8980 /// [`Self::to_claude_code_jsonl`] so the record shape is defined once.
8981 ///
8982 /// `seed_used_ids` primes [`next_claude_uuid`]'s collision guard with
8983 /// every uuid that will ALREADY be present in `out` before this call
8984 /// ever runs — see that function's doc comment for why the A12 splice
8985 /// path needs this and full synthesis doesn't.
8986 // R1: this was already at clippy's `too_many_arguments` threshold (7,
8987 // including `&self`) before the fix; the added `seed_used_ids` param
8988 // pushes it to 8. Every argument here is independently meaningful (two
8989 // record-shape inputs, two id/parent-chain threading values, and now
8990 // the collision seed) — bundling them into a params struct is a larger
8991 // refactor of this already-widely-called private helper than the R1 fix
8992 // warrants, so this is allowed rather than restructured.
8993 #[allow(clippy::too_many_arguments)]
8994 fn write_claude_code_records(
8995 &self,
8996 out: &mut String,
8997 messages: &[ChatMessage],
8998 session_id: &str,
8999 cwd: &str,
9000 mut parent: Option<String>,
9001 mut counter: usize,
9002 seed_used_ids: &HashSet<String>,
9003 ) {
9004 let mut used_ids = seed_used_ids.clone();
9005 for msg in messages {
9006 if is_replay_excluded(msg) {
9007 continue;
9008 }
9009 let blocks: Vec<Value> = match msg.role {
9010 // PARITY-6 dev/02: re-materialize a content-bearing System
9011 // `ChatMessage` as a real Claude Code `type: "system"`
9012 // record — the exact inverse of `push_claude_system`, which
9013 // is what produced it in the first place for a message
9014 // loaded FROM a real Claude Code transcript. `subtype`
9015 // prefers the original `systemSubtype` metadata
9016 // (`push_claude_system`'s `.with_meta`, round-tripped
9017 // through the Codex hop via `write_codex_records`'s
9018 // `claude_system_subtype` metadata channel and restored by
9019 // `push_codex_item`); when that channel didn't carry it
9020 // (e.g. a genuinely native, non-Claude-origin developer
9021 // message), fall back to `local_command` — the observed
9022 // common case, and still one of `push_claude_system`'s own
9023 // `keep` subtypes, so the record survives a *subsequent*
9024 // reload rather than being silently re-dropped. This never
9025 // fabricates content: the real text is always carried
9026 // verbatim, only the subtype label is a best-effort guess
9027 // when the true one wasn't recoverable.
9028 Role::System => {
9029 let content = msg.content.clone().unwrap_or_default();
9030 if content.trim().is_empty() {
9031 continue;
9032 }
9033 let subtype = msg
9034 .metadata
9035 .get("systemSubtype")
9036 .cloned()
9037 .unwrap_or_else(|| "local_command".to_string());
9038 // R1/B3 union: this mint must ALSO route through
9039 // `next_claude_uuid` + `seed_used_ids` like the other
9040 // three arms below — otherwise this System arm (added by
9041 // B3 after R1 landed) mints a raw `synth_uuid` that can
9042 // collide with a uuid already sitting in the A12 splice's
9043 // raw prefix (see `next_claude_uuid`'s doc comment).
9044 let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9045 let mut line = serde_json::json!({
9046 "parentUuid": parent,
9047 "type": "system",
9048 "subtype": subtype,
9049 "content": content,
9050 "uuid": uuid,
9051 "sessionId": session_id,
9052 "cwd": cwd,
9053 "timestamp": msg_timestamp_or_synth(msg),
9054 });
9055 set_grok_message_extension(&mut line, self.meta.source, msg);
9056 push_jsonl(out, &line);
9057 parent = Some(uuid);
9058 continue;
9059 }
9060 Role::User => {
9061 let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9062 let mut line = serde_json::json!({
9063 "parentUuid": parent,
9064 "type": "user",
9065 "message": {
9066 "role": "user",
9067 "content": claude_user_content_value(msg),
9068 },
9069 "uuid": uuid,
9070 "sessionId": session_id,
9071 "cwd": cwd,
9072 "timestamp": msg_timestamp_or_synth(msg),
9073 });
9074 set_grok_message_extension(&mut line, self.meta.source, msg);
9075 push_jsonl(out, &line);
9076 parent = Some(uuid);
9077 continue;
9078 }
9079 Role::Tool => {
9080 let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9081 let mut line = serde_json::json!({
9082 "parentUuid": parent,
9083 "type": "user",
9084 "message": {
9085 "role": "user",
9086 "content": [{
9087 "type": "tool_result",
9088 "tool_use_id": msg.tool_call_id.clone().unwrap_or_default(),
9089 "content": claude_tool_result_content_value(msg),
9090 }],
9091 },
9092 "uuid": uuid,
9093 "sessionId": session_id,
9094 "cwd": cwd,
9095 "timestamp": msg_timestamp_or_synth(msg),
9096 });
9097 if crate::tool_outcome(msg) == crate::ToolOutcome::Unknown {
9098 line[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
9099 }
9100 set_grok_message_extension(&mut line, self.meta.source, msg);
9101 push_jsonl(out, &line);
9102 parent = Some(uuid);
9103 continue;
9104 }
9105 Role::Assistant => {
9106 let mut blocks = Vec::new();
9107 // PARITY-16 (found via the REAL pi corpus, PARITY-5
9108 // dev/01): thinking/redacted_thinking must be re-emitted
9109 // BEFORE text/tool_use, unconditionally whenever
9110 // retained metadata is present — not only when `blocks`
9111 // is otherwise empty. The previous `if blocks.is_empty()`
9112 // gate (now below, applied unconditionally instead)
9113 // meant a turn that thinks AND THEN answers/calls a tool
9114 // in the SAME turn — pi's own default emission shape,
9115 // and the overwhelmingly common real-world case for any
9116 // reasoning model, not the rare reasoning-only edge case
9117 // this gate's comment described — silently dropped its
9118 // entire `thinking` block on Pi -> Claude Code export. A
9119 // genuine multi-turn pi session driven through pi's own
9120 // real Agent loop (faux provider, see
9121 // `pi_interop.rs`'s live-corpus tests) exposed this: its
9122 // thinking+text turns lost the thinking block entirely.
9123 // D8: prefer the exact per-block list when present —
9124 // every `thinking`/`redacted_thinking` block re-emitted
9125 // SEPARATELY with its own signature/data, exactly as
9126 // captured (`push_claude_assistant`), instead of the
9127 // legacy singular fields' lossy collapse (which drops
9128 // every signature but the last one's on a multi-block
9129 // message). Falls back to the legacy fields only for a
9130 // `Session` that never populated `thinking_blocks` (e.g.
9131 // hand-constructed in another loader/test, or loaded
9132 // from a non-Claude-Code source like Pi).
9133 match msg
9134 .metadata
9135 .get("thinking_blocks")
9136 .and_then(|s| serde_json::from_str::<Value>(s).ok())
9137 .and_then(|v| v.as_array().cloned())
9138 {
9139 Some(saved_blocks) => blocks.extend(saved_blocks),
9140 None => {
9141 if let Some(t) = msg.metadata.get("thinking") {
9142 let mut block =
9143 serde_json::json!({"type": "thinking", "thinking": t});
9144 if let Some(sig) = msg.metadata.get("thinking_signature") {
9145 block["signature"] = Value::String(sig.clone());
9146 }
9147 blocks.push(block);
9148 }
9149 if let Some(rt) = msg.metadata.get("redacted_thinking") {
9150 blocks.push(
9151 serde_json::json!({"type": "redacted_thinking", "data": rt}),
9152 );
9153 }
9154 }
9155 }
9156 if let Some(t) = &msg.content {
9157 if !t.is_empty() {
9158 blocks.push(serde_json::json!({"type": "text", "text": t}));
9159 }
9160 }
9161 // PARITY-11: an assistant-emitted image (`content_parts`,
9162 // e.g. a generated image — `push_claude_assistant`'s
9163 // load-side counterpart) has no slot in `msg.content`;
9164 // without this, `blocks` stayed empty for an image-only
9165 // turn and the whole message vanished on Claude Code
9166 // semantic export, same failure mode the IX-6 Codex
9167 // writer fix already closed on that side.
9168 if let Some(parts) = &msg.content_parts {
9169 for p in parts {
9170 if p.get("type").and_then(Value::as_str) == Some("image_url") {
9171 if let Some(url) = p
9172 .get("image_url")
9173 .and_then(|u| u.get("url"))
9174 .and_then(Value::as_str)
9175 {
9176 blocks.push(match parse_data_uri(url) {
9177 Some((mime, data)) => serde_json::json!({
9178 "type": "image",
9179 "source": {"type": "base64", "media_type": mime, "data": data},
9180 }),
9181 None => serde_json::json!({
9182 "type": "image",
9183 "source": {"type": "url", "url": url},
9184 }),
9185 });
9186 }
9187 }
9188 }
9189 }
9190 for tc in msg.tool_calls() {
9191 let input = tc
9192 .function
9193 .parsed_arguments()
9194 .unwrap_or_else(|_| Value::Object(Default::default()));
9195 blocks.push(serde_json::json!({
9196 "type": "tool_use",
9197 "id": tc.id,
9198 "name": tc.function.name,
9199 "input": input,
9200 }));
9201 }
9202 // PARITY-11 / PARITY-16: a genuinely reasoning-only turn
9203 // (no text, no tool_use, no image) still doesn't vanish
9204 // — the thinking/redacted_thinking prepend above already
9205 // ran unconditionally, so `blocks` is non-empty here
9206 // whenever any of those were present.
9207 blocks
9208 }
9209 };
9210
9211 // An empty assistant content array is a valid native interrupted
9212 // turn and must remain a record. Every non-assistant arm above
9213 // already `continue`s after writing its own shape, so an empty
9214 // `blocks` value here belongs specifically to that assistant.
9215 let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
9216 let mut message = serde_json::json!({"role": "assistant", "content": blocks});
9217 if let Some(model) = msg.metadata.get("model").or(self.meta.model.as_ref()) {
9218 message["model"] = Value::String(model.clone());
9219 }
9220 let mut line = serde_json::json!({
9221 "parentUuid": parent,
9222 "type": "assistant",
9223 "message": message,
9224 "uuid": uuid,
9225 "sessionId": session_id,
9226 "cwd": cwd,
9227 "timestamp": msg_timestamp_or_synth(msg),
9228 });
9229 set_grok_message_extension(&mut line, self.meta.source, msg);
9230 push_jsonl(out, &line);
9231 parent = Some(uuid);
9232 }
9233 }
9234
9235 /// A12 splice: replay the imported Claude Code `raw` prefix verbatim
9236 /// (patching `sessionId` on each line when `session_id` is `Some`), then
9237 /// synthesize records only for the appended tail, via
9238 /// [`Self::write_claude_code_records`] — chaining `parentUuid` from the
9239 /// last original `uuid` found anywhere in the raw prefix (not just its
9240 /// final line: a trailing loader-skipped record, e.g.
9241 /// `file-history-snapshot`, may carry no `uuid` of its own).
9242 fn to_claude_code_jsonl_spliced(&self, session_id: Option<&str>) -> String {
9243 let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
9244 let sid = session_id
9245 .map(str::to_string)
9246 .or_else(|| self.meta.session_id.clone())
9247 .unwrap_or_else(|| synth_uuid(0));
9248 let cwd = self.cwd_string();
9249
9250 let mut out = String::new();
9251 let mut parent: Option<String> = None;
9252 for line in &self.raw[..raw_prefix_len] {
9253 push_spliced_line(&mut out, line, session_id, "sessionId");
9254 if let Ok(v) = serde_json::from_str::<Value>(line) {
9255 if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
9256 parent = Some(uuid.to_string());
9257 }
9258 }
9259 }
9260
9261 // R1 (spliced-path hardening, mirrors the Codex N2 fix above): seed
9262 // the tail's collision guard with every uuid the just-replayed RAW
9263 // prefix already carries, so `write_claude_code_records` never
9264 // fabricates a `synth_uuid` for the appended tail that collides with
9265 // one already sitting in the prefix (see `next_claude_uuid`'s and
9266 // `collect_claude_uuids_from_raw`'s doc comments).
9267 let seed_used_ids = collect_claude_uuids_from_raw(&self.raw[..raw_prefix_len]);
9268 self.write_claude_code_records(
9269 &mut out,
9270 &self.messages[message_prefix_len..],
9271 &sid,
9272 &cwd,
9273 parent,
9274 1,
9275 &seed_used_ids,
9276 );
9277 out
9278 }
9279
9280 /// Synthesize a Codex rollout.
9281 fn to_codex_jsonl(&self) -> String {
9282 let mut out = String::new();
9283
9284 if self.meta.codex_headers.is_empty() {
9285 self.write_synthesized_codex_header(&mut out);
9286 } else {
9287 // Replay the exact header records the original tool wrote — Codex's
9288 // reader validates the header shape strictly — overriding only the
9289 // session id when the caller changed it.
9290 for header in &self.meta.codex_headers {
9291 let mut header = header.clone();
9292 if header.get("type").and_then(Value::as_str) == Some("session_meta") {
9293 if let Some(id) = &self.meta.session_id {
9294 if let Some(payload) = header.get_mut("payload") {
9295 payload["id"] = Value::String(id.clone());
9296 }
9297 }
9298 }
9299 push_jsonl(&mut out, &header);
9300 }
9301 }
9302
9303 // Full synthesis: `out` at this point is only the header, so there
9304 // are no group ids yet in play to seed against (see
9305 // `write_codex_records`'s doc comment).
9306 self.write_codex_records(&mut out, &self.messages, &std::collections::HashSet::new());
9307 if let Some(extension) = codex_provenance_envelope(&self.meta) {
9308 inject_codex_provenance(&mut out, extension);
9309 }
9310 out
9311 }
9312
9313 /// Synthesize Codex `response_item` records for `messages` (a full
9314 /// session or an appended tail — A12's [`Self::to_jsonl_spliced`] reuses
9315 /// this for just the latter). Factored out of [`Self::to_codex_jsonl`] so
9316 /// the record shape is defined once; `tool_search_call_ids` pairing is
9317 /// scoped to this call's `messages`, matching the header-replay
9318 /// contract that only appended records need synthesizing.
9319 ///
9320 /// `seed_used_ids` primes the N2 collision guard below with every group
9321 /// id that will ALREADY be present in `out` before this call ever runs —
9322 /// [`Self::to_codex_jsonl`] (full synthesis, `out` starts as just the
9323 /// header) passes an empty set, since every group id in that case is
9324 /// assigned by this very loop. [`Self::to_codex_jsonl_spliced`] (the A12
9325 /// splice) passes the ids already used by the verbatim RAW prefix it
9326 /// replayed into `out` just before calling this for the appended tail —
9327 /// without that seed, the tail's own `used_group_ids`/`next_group_id`
9328 /// start blind to the prefix and can fabricate/reuse a group id that
9329 /// COLLIDES with one still "open" at the end of the prefix, letting
9330 /// reimport's merge check (`can_merge`, `push_codex_item`) wrongly splice
9331 /// an unrelated appended message into a historical one — the same
9332 /// bug-class N2 closed for full synthesis, reopened here because the
9333 /// spliced tail's tracking set used to always start empty regardless of
9334 /// what the replayed prefix already contained.
9335 fn write_codex_records(
9336 &self,
9337 out: &mut String,
9338 messages: &[ChatMessage],
9339 seed_used_ids: &std::collections::HashSet<String>,
9340 ) {
9341 // Call ids of assistant `tool_search` calls (B6 agent intrinsic), so
9342 // the matching tool result below can be emitted as the paired
9343 // `tool_search_output` record rather than a generic
9344 // `function_call_output` — the exact inverse of the importer's
9345 // `tool_search_call`/`tool_search_output` normalization
9346 // (`push_codex_item`, above).
9347 let mut tool_search_call_ids: std::collections::HashSet<String> = Default::default();
9348 // PARITY-6/7: two ADJACENT but genuinely SEPARATE Claude assistant
9349 // records (e.g. a text-only narration turn immediately followed by a
9350 // bare tool-call turn, no user turn between — a real, common Claude
9351 // Code shape) each become their own Codex `message`/`function_call`
9352 // response_item(s) here. Codex's own reader (`push_codex_item`, IX-6)
9353 // opportunistically RE-MERGES an assistant `message` immediately
9354 // followed by a `function_call` back into ONE `ChatMessage`, to match
9355 // how a genuinely single Claude turn (text+tool_use in the SAME
9356 // record) round-trips — but with no distinguishing signal, it can't
9357 // tell that case apart from two originally-separate records that
9358 // just happen to be adjacent, so it wrongly recombines them too,
9359 // silently shrinking the message count on every Claude -> Codex ->
9360 // (inspect) hop. Fix: stamp a synthetic `metadata.turn_id` — unique
9361 // per ORIGINAL `ChatMessage` — onto the `message` record AND every
9362 // `function_call`/`tool_search_call` record THAT SAME `ChatMessage`
9363 // itself emits. `push_codex_item`'s merge already treats a turn_id
9364 // mismatch as "different turn, do not merge" (the pre-existing
9365 // belt-and-suspenders check); real native Codex data almost never
9366 // carries this field (per that check's own comment), so this is a
9367 // no-op there and only sharpens fidelity for OUR OWN synthesized
9368 // export.
9369 let mut next_group_id: u64 = 0;
9370 // N2 (Fable-5 review, turn_id-collision hardening): every group id
9371 // this export has already assigned — whether REUSED from a real
9372 // `turn_id` or FABRICATED as `sc-grp-N` — so a later assistant
9373 // `ChatMessage` never emits one that's already in use. Two concrete
9374 // mis-merge scenarios motivate this:
9375 //
9376 // (a) Claude->Codex export fabricates `sc-grp-0` for msg A (msg A's
9377 // own text+tool_use); reload makes A carry REAL turn_id
9378 // `sc-grp-0`. A bare tool-call msg B (metadata has no turn_id of
9379 // its own) is then appended. Re-export: A reuses its real
9380 // `sc-grp-0`, but B independently fabricates a FRESH id starting
9381 // from `next_group_id == 0` again (nothing bumped it when A's id
9382 // was reused rather than fabricated) — also `sc-grp-0`.
9383 // Collision. If A's call has no output (interrupted session),
9384 // reimport sees message(sc-grp-0)+call(sc-grp-0)+call(sc-grp-0)
9385 // adjacent with nothing to break the run and merges all three
9386 // into ONE message (2 -> 1).
9387 // (b) Native Codex: `message(turn-7)` opens the merge marker, a
9388 // truncation/clear event strips `__codex_open_turn` (closing the
9389 // turn without changing the id), then `function_call(turn-7)`
9390 // loads as a SECOND, separate `ChatMessage` that still carries
9391 // the SAME real `turn_id` (the reopen step in `push_codex_item`
9392 // restamps it). Full-synthesis export naively reuses `turn-7`
9393 // verbatim for BOTH messages (they're two different loop
9394 // iterations, each independently reusing its own `real_turn_id`)
9395 // and emits them adjacent — reimport's merge check can't tell
9396 // this apart from a single message's own multi-call turn and
9397 // recombines them (2 -> 1).
9398 //
9399 // Fix: the fabricated-id counter is advanced (skipped) past any id
9400 // already in `used_group_ids`, AND a real id that's already been
9401 // used gets disambiguated (`<real>~dupN`) instead of reused verbatim
9402 // — never letting two DIFFERENT `ChatMessage`s in this export share
9403 // one group id, since `push_codex_item`'s merge check treats a
9404 // shared id as "same turn, merge". A single `ChatMessage`'s own
9405 // message record + its own tool call records still share ONE group
9406 // id (computed once per loop iteration below, before insertion), so
9407 // the D1 tool_search merge and ordinary same-turn multi-call
9408 // grouping are unaffected — this only stops REUSE across iterations.
9409 //
9410 // Seeded from `seed_used_ids` (see this fn's doc comment) so the
9411 // spliced-export tail is likewise blind-proof against the prefix it
9412 // doesn't itself write.
9413 let mut used_group_ids: std::collections::HashSet<String> = seed_used_ids.clone();
9414
9415 for msg in messages {
9416 if is_replay_excluded(msg) {
9417 continue;
9418 }
9419 // D3 (Fable-5 review): a message loaded FROM real native Codex
9420 // carries its OWN real `turn_id` in `msg.metadata["turn_id"]`
9421 // (`push_codex_item`'s "message" arm stamps it whenever the
9422 // source record itself has one). The group-id logic below used
9423 // to ALWAYS fabricate a fresh `"sc-grp-N"` value regardless,
9424 // silently overwriting/discarding that real id on any
9425 // native-Codex -> load -> export-Codex hop. Reuse it verbatim
9426 // when present; only fabricate a synthetic id as a fallback for
9427 // our own merge-disambiguation need (PARITY-6/7) when the
9428 // message has no real one of its own.
9429 let real_turn_id = msg.metadata.get("turn_id").map(String::as_str);
9430 match msg.role {
9431 Role::System => {
9432 // PARITY-6 dev/02: carry the original Claude
9433 // `systemSubtype` (`push_claude_system`'s `.with_meta`)
9434 // through as `metadata.claude_system_subtype`, so
9435 // `push_codex_item`'s reverse load can restore it and
9436 // `write_claude_code_records`'s `Role::System` arm can
9437 // re-materialize the EXACT original subtype rather than
9438 // guessing on a Codex -> Claude hop.
9439 let subtype_meta = msg
9440 .metadata
9441 .get("systemSubtype")
9442 .map(|s| ("claude_system_subtype", s.as_str()));
9443 self.push_codex_message(
9444 out,
9445 "developer",
9446 "input_text",
9447 msg,
9448 real_turn_id,
9449 subtype_meta,
9450 )
9451 }
9452 Role::User => {
9453 self.push_codex_message(out, "user", "input_text", msg, real_turn_id, None)
9454 }
9455 Role::Assistant => {
9456 // Emit the message record whenever there is text OR
9457 // content_parts (IX-6 follow-up): an image-only assistant
9458 // message has `content: None, content_parts:
9459 // Some([image])` (the loader's `codex_extract_images` is
9460 // role-general, so this shape can occur on the assistant
9461 // side too) — gating on `msg.content` alone silently
9462 // dropped the whole message, image included. A
9463 // text-only message (content_parts: None) keeps taking
9464 // the historical byte-identical path via
9465 // `codex_message_content_blocks`'s `None` arm. A real
9466 // empty native assistant record carries the
9467 // loader's explicit marker and must also be emitted.
9468 // Reasoning-only cross-provider turns deliberately lack
9469 // that marker and keep the documented Codex residue.
9470 let has_text = msg.content.as_deref().is_some_and(|t| !t.is_empty());
9471 let has_message_record = has_text
9472 || msg.content_parts.is_some()
9473 || msg.metadata.contains_key("empty_assistant_record");
9474 // Only assign a synthetic group id when there's actual
9475 // merge ambiguity to resolve (a message AND its own tool
9476 // calls, or 2+ of this message's own tool calls) — a
9477 // pure-text message with no tool calls, or a lone tool
9478 // call with nothing else from the same `ChatMessage`,
9479 // has nothing to disambiguate, so it keeps the exact
9480 // historical byte shape (no `metadata` key at all).
9481 let group_id: Option<String> = if let Some(real) = real_turn_id {
9482 if used_group_ids.contains(real) {
9483 // N2: this real turn_id was already used by an
9484 // earlier (now-closed) `ChatMessage` in this same
9485 // export — reusing it verbatim would let the
9486 // reimport merge check recombine two originally
9487 // separate messages (see the doc comment above).
9488 let mut n = 1u64;
9489 let mut candidate = format!("{real}~dup{n}");
9490 while used_group_ids.contains(&candidate) {
9491 n += 1;
9492 candidate = format!("{real}~dup{n}");
9493 }
9494 Some(candidate)
9495 } else {
9496 Some(real.to_string())
9497 }
9498 } else if !msg.tool_calls().is_empty() {
9499 // N2: skip past any id already used (e.g. a REAL
9500 // turn_id that happens to look like `sc-grp-N`, or an
9501 // id an earlier reused-real case landed on).
9502 let mut candidate = format!("sc-grp-{next_group_id}");
9503 next_group_id += 1;
9504 while used_group_ids.contains(&candidate) {
9505 candidate = format!("sc-grp-{next_group_id}");
9506 next_group_id += 1;
9507 }
9508 Some(candidate)
9509 } else {
9510 None
9511 };
9512 if let Some(g) = &group_id {
9513 used_group_ids.insert(g.clone());
9514 }
9515 if has_message_record {
9516 self.push_codex_message(
9517 out,
9518 "assistant",
9519 "output_text",
9520 msg,
9521 group_id.as_deref(),
9522 None,
9523 );
9524 }
9525 for tc in msg.tool_calls() {
9526 let custom_tool_call = msg
9527 .metadata
9528 .get("codex_custom_tool_call_ids")
9529 .and_then(|raw| serde_json::from_str::<Vec<String>>(raw).ok())
9530 .is_some_and(|ids| ids.iter().any(|id| id == &tc.id));
9531 if custom_tool_call {
9532 let input = tc
9533 .function
9534 .parsed_arguments()
9535 .unwrap_or_else(|_| Value::String(tc.function.arguments.clone()));
9536 let mut payload = with_turn_id(
9537 serde_json::json!({
9538 "type": "custom_tool_call",
9539 "name": tc.function.name,
9540 "input": input,
9541 "call_id": tc.id,
9542 }),
9543 group_id.as_deref(),
9544 );
9545 set_grok_message_extension(&mut payload, self.meta.source, msg);
9546 push_jsonl(
9547 out,
9548 &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9549 );
9550 } else if tc.function.name == "tool_search" {
9551 tool_search_call_ids.insert(tc.id.clone());
9552 let mut payload = with_turn_id(
9553 serde_json::json!({
9554 "type": "tool_search_call",
9555 "arguments": tc.function.arguments,
9556 "call_id": tc.id,
9557 }),
9558 group_id.as_deref(),
9559 );
9560 set_grok_message_extension(&mut payload, self.meta.source, msg);
9561 push_jsonl(
9562 out,
9563 &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9564 );
9565 } else {
9566 let mut payload = with_turn_id(
9567 serde_json::json!({
9568 "type": "function_call",
9569 "name": tc.function.name,
9570 "arguments": tc.function.arguments,
9571 "call_id": tc.id,
9572 }),
9573 group_id.as_deref(),
9574 );
9575 set_grok_message_extension(&mut payload, self.meta.source, msg);
9576 push_jsonl(
9577 out,
9578 &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9579 );
9580 }
9581 }
9582 // PARITY-11: a genuinely reasoning-only turn (Claude
9583 // `thinking`/`redacted_thinking` with no text, tool_use,
9584 // or image — `push_claude_assistant`'s load-side fix for
9585 // the ~21% of real assistant records that are exactly
9586 // this shape) has no message record and no tool calls,
9587 // so nothing above writes anything for it. This is
9588 // DELIBERATE, not a residual gap: Codex's `reasoning`
9589 // response_item is understood on import (see the
9590 // `response_item`/`"reasoning"` arm above), but its
9591 // real-native semantics is "the reasoning immediately
9592 // BEFORE the next turn" — the reader attaches it to
9593 // whatever response_item comes next, unconditionally.
9594 // For a genuinely standalone Claude reasoning-only turn
9595 // (no related turn follows in Codex's export at all),
9596 // emitting one here would get silently misattributed as
9597 // belonging to some later, unrelated turn instead —
9598 // strictly worse than the current honest, accounted-for
9599 // absence (thinking/redacted_thinking is provider-
9600 // private and "not replayed across providers" by
9601 // original design; the audit correctly classifies it
9602 // `Coverage::Dropped`, not `Unmodeled`). See the
9603 // PARITY-6/7 corpus test's `is_replayable` filter for
9604 // why this doesn't count as a message-count regression.
9605 }
9606 Role::Tool
9607 if msg
9608 .tool_call_id
9609 .as_deref()
9610 .is_some_and(|id| tool_search_call_ids.contains(id)) =>
9611 {
9612 let content = msg.content.clone().unwrap_or_default();
9613 let tools =
9614 serde_json::from_str::<Value>(&content).unwrap_or(Value::String(content));
9615 let mut payload = serde_json::json!({
9616 "type": "tool_search_output",
9617 "call_id": msg.tool_call_id.clone().unwrap_or_default(),
9618 "tools": tools,
9619 });
9620 set_grok_message_extension(&mut payload, self.meta.source, msg);
9621 push_jsonl(
9622 out,
9623 &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9624 );
9625 }
9626 Role::Tool => {
9627 let mut payload = serde_json::json!({
9628 "type": "function_call_output",
9629 "call_id": msg.tool_call_id.clone().unwrap_or_default(),
9630 "output": codex_tool_output_text(msg),
9631 });
9632 set_grok_message_extension(&mut payload, self.meta.source, msg);
9633 push_jsonl(
9634 out,
9635 &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9636 );
9637 }
9638 }
9639 }
9640 }
9641
9642 /// A12 splice: replay the imported Codex `raw` prefix verbatim — every
9643 /// line, not just the `session_meta`/`turn_context` headers
9644 /// [`Self::to_codex_jsonl`] replays — overriding only
9645 /// `session_meta.payload.id` when `session_id` is `Some` (every other
9646 /// line, including `response_item`s the stock synthesis would otherwise
9647 /// rebuild from scratch, is untouched byte-for-byte). Then synthesizes
9648 /// `response_item` records only for the appended tail, via
9649 /// [`Self::write_codex_records`].
9650 fn to_codex_jsonl_spliced(&self, session_id: Option<&str>) -> String {
9651 let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
9652
9653 let mut out = String::new();
9654 for line in &self.raw[..raw_prefix_len] {
9655 match session_id {
9656 Some(id) => {
9657 let patched = serde_json::from_str::<Value>(line)
9658 .ok()
9659 .filter(|v| v.get("type").and_then(Value::as_str) == Some("session_meta"))
9660 .map(|mut v| {
9661 if let Some(payload) = v.get_mut("payload") {
9662 payload["id"] = Value::String(id.to_string());
9663 }
9664 v.to_string()
9665 });
9666 out.push_str(patched.as_deref().unwrap_or(line));
9667 }
9668 None => out.push_str(line),
9669 }
9670 out.push('\n');
9671 }
9672
9673 // N2 (spliced-path hardening): seed the tail's collision guard with
9674 // every group id the just-replayed RAW prefix already carries, so
9675 // `write_codex_records` never fabricates/reuses an id for the
9676 // appended tail that collides with one still open at the end of the
9677 // prefix (see that fn's doc comment, and
9678 // `collect_codex_group_ids_from_raw`'s).
9679 let mut seed_used_ids = collect_codex_group_ids_from_raw(&self.raw[..raw_prefix_len]);
9680 // Belt-and-suspenders: also union in the prefix `messages`' own
9681 // recorded `turn_id` metadata. In the ordinary case this is already
9682 // a subset of what the raw-line scan above found (the loader stamps
9683 // `metadata["turn_id"]` from the very same `payload.metadata.turn_id`
9684 // field the scan reads) — but scanning `messages` too costs nothing
9685 // and means this stays correct even if some future loader path ever
9686 // derives a message's `turn_id` by some means other than a literal
9687 // `payload.metadata.turn_id` copy.
9688 for msg in &self.messages[..message_prefix_len] {
9689 if let Some(tid) = msg.metadata.get("turn_id") {
9690 seed_used_ids.insert(tid.clone());
9691 }
9692 }
9693 self.write_codex_records(
9694 &mut out,
9695 &self.messages[message_prefix_len..],
9696 &seed_used_ids,
9697 );
9698 out
9699 }
9700
9701 /// Build a Codex header from scratch (used when converting from another
9702 /// format, where no original Codex header exists to replay). Emits the
9703 /// fields Codex requires on `session_meta`.
9704 fn write_synthesized_codex_header(&self, out: &mut String) {
9705 let mut meta_payload = serde_json::json!({
9706 "id": self.meta.session_id.clone().unwrap_or_else(|| synth_uuid(0)),
9707 "timestamp": SYNTH_TS,
9708 "cwd": self.cwd_string(),
9709 "originator": "supercode",
9710 "cli_version": env!("CARGO_PKG_VERSION"),
9711 "source": "exec",
9712 "thread_source": "user",
9713 "model_provider": "openai",
9714 });
9715 if let Some(sp) = &self.meta.system_prompt {
9716 meta_payload["base_instructions"] = serde_json::json!({"text": sp});
9717 }
9718 // PARITY-10 dev/03: carry a captured Claude `fork-context-ref` (see
9719 // `capture_claude_meta`) through the Codex hop under a clearly
9720 // namespaced custom field — real Codex tooling ignores unknown
9721 // `session_meta.payload` keys, and `capture_codex_session_meta`
9722 // reads this same key back on import, so a Claude -> Codex -> Claude
9723 // round trip still reconstructs the original record instead of
9724 // silently losing the lineage note on the cross-format hop.
9725 if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
9726 meta_payload["claude_fork_context_ref"] =
9727 serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
9728 }
9729 push_jsonl(
9730 out,
9731 &serde_json::json!({"timestamp": SYNTH_TS, "type": "session_meta", "payload": meta_payload}),
9732 );
9733 if let Some(model) = &self.meta.model {
9734 push_jsonl(
9735 out,
9736 &serde_json::json!({
9737 "timestamp": SYNTH_TS,
9738 "type": "turn_context",
9739 "payload": {"model": model, "cwd": self.cwd_string()},
9740 }),
9741 );
9742 }
9743 }
9744
9745 /// `turn_id`: see the PARITY-6/7 (and D3) comment on
9746 /// [`Self::write_codex_records`] — `Some` when the source message
9747 /// carries its own REAL `turn_id` (a native-Codex round-trip), or
9748 /// (assistant only) a synthetic disambiguation id when it owns tool
9749 /// calls needing merge disambiguation and has no real id of its own;
9750 /// `None` reproduces the exact historical shape (no `metadata` key at
9751 /// all).
9752 /// `extra_metadata`: PARITY-6 dev/02 — an additional `(key, value)`
9753 /// pair folded into `payload.metadata` alongside `turn_id` (used by the
9754 /// `Role::System` case in [`Self::write_codex_records`] to carry
9755 /// `claude_system_subtype`, so a Claude `<local-command-stdout>`-style
9756 /// system record's subtype survives the Claude -> Codex -> Claude round
9757 /// trip instead of only its text; `None` for every other caller,
9758 /// preserving the exact historical shape).
9759 fn push_codex_message(
9760 &self,
9761 out: &mut String,
9762 role: &str,
9763 text_type: &str,
9764 msg: &ChatMessage,
9765 turn_id: Option<&str>,
9766 extra_metadata: Option<(&str, &str)>,
9767 ) {
9768 let mut payload = with_turn_id(
9769 serde_json::json!({
9770 "type": "message",
9771 "role": role,
9772 "content": codex_message_content_blocks(text_type, msg),
9773 }),
9774 turn_id,
9775 );
9776 if let Some((k, v)) = extra_metadata {
9777 if payload.get("metadata").is_none() {
9778 payload["metadata"] = serde_json::json!({});
9779 }
9780 payload["metadata"][k] = serde_json::json!(v);
9781 }
9782 set_grok_message_extension(&mut payload, self.meta.source, msg);
9783 push_jsonl(
9784 out,
9785 &codex_response_item(payload, msg_timestamp_or_synth(msg)),
9786 );
9787 }
9788
9789 /// Synthesize a fresh pi v3 session from the canonical `messages`
9790 /// (T3 cross-format/full synthesis — `to_jsonl(other)`'s "view round-trip"
9791 /// tier, §3/§4.1: this is NOT the byte-lossless native path, which goes
9792 /// through `raw` + `to_native_jsonl(_v2)` instead).
9793 fn to_pi_jsonl(&self) -> String {
9794 let session_id = self
9795 .meta
9796 .session_id
9797 .clone()
9798 .unwrap_or_else(|| synth_uuid(0));
9799 let cwd = self.cwd_string();
9800 let mut out = String::new();
9801 push_pi_header(
9802 &mut out,
9803 &session_id,
9804 &cwd,
9805 self.meta
9806 .lineage
9807 .get("parent_session_path")
9808 .map(String::as_str),
9809 self.meta.lineage.get("created_at").map(String::as_str),
9810 // D7: carry a captured Claude `fork-context-ref` (see
9811 // `capture_claude_meta`) through the Pi hop too — mirrors the
9812 // Codex hop's `claude_fork_context_ref` passthrough
9813 // (`write_synthesized_codex_header`) so a Claude -> Pi -> Claude
9814 // round trip doesn't silently lose fork lineage just because Pi
9815 // has no native slot for it.
9816 self.meta
9817 .lineage
9818 .get("claude_fork_context_ref_raw")
9819 .map(String::as_str),
9820 );
9821 let mut used_ids: HashSet<String> = HashSet::new();
9822 let mut counter: u64 = 0;
9823 self.write_pi_entries(&mut out, &self.messages, None, &mut used_ids, &mut counter);
9824 if let Some(extension) = codex_provenance_envelope(&self.meta) {
9825 inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
9826 }
9827 out
9828 }
9829
9830 /// Synthesize pi `message` entries for `messages` (a full session, or —
9831 /// for [`Self::to_pi_jsonl_spliced`] — just the appended tail), chaining
9832 /// `parentId` from `parent` and drawing fresh ids from `used_ids`/
9833 /// `counter`. Skips [`is_replay_excluded`] messages (`compacted_out`,
9834 /// `pi_exclude_from_context`) exactly like the Claude/Codex writers.
9835 fn write_pi_entries(
9836 &self,
9837 out: &mut String,
9838 messages: &[ChatMessage],
9839 mut parent: Option<String>,
9840 used_ids: &mut HashSet<String>,
9841 counter: &mut u64,
9842 ) {
9843 // Claude Code and Codex do not repeat the tool name on their native
9844 // tool-result records. Recover that redundant Pi field from the
9845 // paired assistant call when a cross-format round trip therefore
9846 // returns a canonical Tool message with `name == None`.
9847 let mut paired_tool_names = HashMap::<String, String>::new();
9848 for msg in messages {
9849 if is_replay_excluded(msg) {
9850 continue;
9851 }
9852 for call in msg.tool_calls() {
9853 paired_tool_names.insert(call.id.clone(), call.function.name.clone());
9854 }
9855 let id = pi_fresh_id(used_ids, counter);
9856 let mut entry = match msg.role {
9857 // B4: pi has no session-level system/developer PROMPT slot
9858 // (§1.1: "no system-prompt... rebuilt at runtime"), but a
9859 // content-bearing `Role::System` message loaded from a real
9860 // Claude Code `type: "system"` record (`push_claude_system`'s
9861 // keep-listed subtypes: `local_command`, `scheduled_task_fire`,
9862 // `away_summary`) is NOT a system prompt — it's a real,
9863 // non-regenerable transcript event. Pi's own `role:"custom"`
9864 // `CustomMessage` (§3e: "extension-injected... sent to the LLM
9865 // as a user message") is the closest existing, non-fabricated
9866 // slot pi's own parser already understands, so this
9867 // re-materializes the record there instead of silently
9868 // dropping it — the exact allowance push_claude_system's own
9869 // doc comment describes in reverse. `customType` is a
9870 // supercode-namespaced marker (`push_pi_custom_common`
9871 // recognizes it on reload and restores `Role::System` +
9872 // `metadata["systemSubtype"]`, exactly like `push_claude_system`
9873 // produced in the first place); a real pi customType never
9874 // collides with this name. `details.claude_system_subtype`
9875 // carries the original subtype losslessly through the pi leg
9876 // (mirrors `write_codex_records`'s `claude_system_subtype`
9877 // metadata channel on the Codex leg, PARITY-6 dev/02). Content
9878 // is never fabricated — only emitted when non-empty.
9879 Role::System => {
9880 let content = msg.content.clone().unwrap_or_default();
9881 if content.trim().is_empty() {
9882 continue;
9883 }
9884 let subtype = msg
9885 .metadata
9886 .get("systemSubtype")
9887 .cloned()
9888 .unwrap_or_else(|| "local_command".to_string());
9889 serde_json::json!({
9890 "type": "message",
9891 "id": id,
9892 "parentId": parent,
9893 "timestamp": msg_timestamp_or_synth(msg),
9894 "message": {
9895 "role": "custom",
9896 "customType": PI_CLAUDE_SYSTEM_CUSTOM_TYPE,
9897 "content": content,
9898 "display": true,
9899 "details": {"claude_system_subtype": subtype},
9900 "timestamp": msg_pi_native_timestamp_ms(msg),
9901 },
9902 })
9903 }
9904 Role::User => serde_json::json!({
9905 "type": "message",
9906 "id": id,
9907 "parentId": parent,
9908 "timestamp": msg_timestamp_or_synth(msg),
9909 "message": {
9910 "role": "user",
9911 "content": pi_content_value(msg),
9912 "timestamp": msg_pi_native_timestamp_ms(msg),
9913 },
9914 }),
9915 Role::Assistant => {
9916 let api = msg
9917 .metadata
9918 .get("pi_api")
9919 .cloned()
9920 .unwrap_or_else(|| "anthropic-messages".to_string());
9921 let provider = msg
9922 .metadata
9923 .get("pi_provider")
9924 .cloned()
9925 .unwrap_or_else(|| "anthropic".to_string());
9926 let model = self
9927 .meta
9928 .model
9929 .clone()
9930 .unwrap_or_else(|| "unknown".to_string());
9931 let usage = msg
9932 .metadata
9933 .get("pi_usage")
9934 .and_then(|s| serde_json::from_str::<Value>(s).ok())
9935 .unwrap_or_else(default_pi_usage);
9936 let stop_reason = msg
9937 .metadata
9938 .get("pi_stop_reason")
9939 .cloned()
9940 .unwrap_or_else(|| "stop".to_string());
9941 serde_json::json!({
9942 "type": "message",
9943 "id": id,
9944 "parentId": parent,
9945 "timestamp": msg_timestamp_or_synth(msg),
9946 "message": {
9947 "role": "assistant",
9948 "content": pi_assistant_content_value(msg),
9949 "api": api,
9950 "provider": provider,
9951 "model": model,
9952 "usage": usage,
9953 "stopReason": stop_reason,
9954 "timestamp": msg_pi_native_timestamp_ms(msg),
9955 },
9956 })
9957 }
9958 Role::Tool => serde_json::json!({
9959 "type": "message",
9960 "id": id,
9961 "parentId": parent,
9962 "timestamp": msg_timestamp_or_synth(msg),
9963 "message": {
9964 "role": "toolResult",
9965 "toolCallId": msg.tool_call_id.clone().unwrap_or_default(),
9966 "toolName": msg.name.as_deref().or_else(|| {
9967 msg.tool_call_id
9968 .as_deref()
9969 .and_then(|id| paired_tool_names.get(id).map(String::as_str))
9970 }).unwrap_or_default(),
9971 "content": pi_content_value(msg),
9972 "isError": is_tool_error_flag(msg),
9973 "timestamp": msg_pi_native_timestamp_ms(msg),
9974 },
9975 }),
9976 };
9977 if msg.role == Role::Tool && crate::tool_outcome(msg) == crate::ToolOutcome::Unknown {
9978 entry[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
9979 }
9980 set_grok_message_extension(&mut entry, self.meta.source, msg);
9981 push_jsonl(out, &entry);
9982 parent = Some(id);
9983 if msg.role == Role::Tool {
9984 if let Some(call_id) = msg.tool_call_id.as_deref() {
9985 paired_tool_names.remove(call_id);
9986 }
9987 }
9988 }
9989 }
9990
9991 /// A12-style splice for pi (§1.1/§4.2): replay the imported `raw` prefix
9992 /// **verbatim** — the header line always has its `version` normalized to
9993 /// 3 (§1.3: pi rewrites any pre-v3 file in place on load, destroying
9994 /// byte-identity, so the writer never re-emits one; this intentionally
9995 /// breaks byte-identity for pre-v3 originals only, the accepted
9996 /// trade-off) and its `id` overridden when `session_id` is `Some`. Every
9997 /// other raw line — every entry — is untouched (pi repeats the session
9998 /// id on no other line, `pi-fields.md` §1). Then synthesizes `message`
9999 /// entries only for the appended tail via [`Self::write_pi_entries`],
10000 /// chaining from the last entry `id` found in the raw prefix.
10001 fn to_pi_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
10002 let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10003 if raw_prefix_len == 0 {
10004 return Ok(self.to_pi_jsonl());
10005 }
10006
10007 let mut out = String::new();
10008 let mut used_ids: HashSet<String> = HashSet::new();
10009 let mut leaf: Option<String> = None;
10010 for (i, line) in self.raw[..raw_prefix_len].iter().enumerate() {
10011 if i == 0 {
10012 if let Ok(v) = serde_json::from_str::<Value>(line) {
10013 if v.get("type").and_then(Value::as_str) == Some("session") {
10014 let needs_v3 = v.get("version").and_then(Value::as_u64) != Some(3);
10015 // Only reparse+reserialize the header when something
10016 // actually needs to change — this crate doesn't
10017 // enable serde_json's `preserve_order`, so a no-op
10018 // round-trip through `Value` would reorder keys
10019 // alphabetically and silently break the "prefix
10020 // bytes unchanged" splice guarantee for the (common)
10021 // already-v3, no-override case.
10022 if needs_v3 || session_id.is_some() {
10023 let mut v = v;
10024 v["version"] = serde_json::json!(3);
10025 if let Some(new_id) = session_id {
10026 v["id"] = Value::String(new_id.to_string());
10027 }
10028 out.push_str(&v.to_string());
10029 out.push('\n');
10030 continue;
10031 }
10032 }
10033 }
10034 }
10035 out.push_str(line);
10036 out.push('\n');
10037 if let Ok(v) = serde_json::from_str::<Value>(line) {
10038 if let Some(id) = v.get("id").and_then(Value::as_str) {
10039 used_ids.insert(id.to_string());
10040 leaf = Some(id.to_string());
10041 }
10042 }
10043 }
10044
10045 let mut counter: u64 = 0;
10046 self.write_pi_entries(
10047 &mut out,
10048 &self.messages[message_prefix_len..],
10049 leaf,
10050 &mut used_ids,
10051 &mut counter,
10052 );
10053 Ok(out)
10054 }
10055
10056 // ---- Grok writers -----------------------------------------------
10057
10058 /// Synthesize Grok's resumable `chat_history.jsonl` transcript.
10059 fn to_grok_jsonl(&self) -> String {
10060 let mut out = String::new();
10061 if let Some(prompt) = self
10062 .meta
10063 .system_prompt
10064 .as_deref()
10065 .filter(|prompt| !prompt.is_empty())
10066 {
10067 push_jsonl(
10068 &mut out,
10069 &serde_json::json!({
10070 "type": "system",
10071 "content": prompt,
10072 }),
10073 );
10074 }
10075 self.write_grok_records(&mut out, &self.messages);
10076 if let Some(extension) = codex_provenance_envelope(&self.meta) {
10077 if out.is_empty() {
10078 push_jsonl(
10079 &mut out,
10080 &serde_json::json!({"type": "system", "content": ""}),
10081 );
10082 }
10083 inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
10084 }
10085 out
10086 }
10087
10088 fn write_grok_records(&self, out: &mut String, messages: &[ChatMessage]) {
10089 for message in messages {
10090 if is_replay_excluded(message) {
10091 continue;
10092 }
10093 let mut value = match message.role {
10094 Role::System => serde_json::json!({
10095 "type": "user",
10096 "content": [{"type": "text", "text": message.content.clone().unwrap_or_default()}],
10097 "synthetic_reason": "supercode_system_event",
10098 }),
10099 Role::User => {
10100 let mut value = serde_json::json!({
10101 "type": "user",
10102 "content": [{"type": "text", "text": message.content.clone().unwrap_or_default()}],
10103 });
10104 if let Some(object) = value.as_object_mut() {
10105 for (metadata, field) in [
10106 ("grok_prompt_index", "prompt_index"),
10107 ("grok_prior_turn_interrupt", "prior_turn_interrupt"),
10108 ("grok_synthetic_reason", "synthetic_reason"),
10109 ] {
10110 if let Some(raw) = message.metadata.get(metadata) {
10111 object.insert(
10112 field.to_string(),
10113 serde_json::from_str(raw)
10114 .unwrap_or_else(|_| Value::String(raw.clone())),
10115 );
10116 }
10117 }
10118 }
10119 value
10120 }
10121 Role::Assistant => {
10122 let calls = message
10123 .tool_calls()
10124 .iter()
10125 .map(|call| {
10126 serde_json::json!({
10127 "id": call.id,
10128 "name": call.function.name,
10129 "arguments": call.function.arguments,
10130 })
10131 })
10132 .collect::<Vec<_>>();
10133 let mut value = serde_json::json!({
10134 "type": "assistant",
10135 "content": message.content.clone().unwrap_or_default(),
10136 "tool_calls": calls,
10137 "model_id": message.metadata.get("grok_model_id")
10138 .or(self.meta.model.as_ref())
10139 .cloned()
10140 .unwrap_or_else(|| "unknown".to_string()),
10141 });
10142 if let Some(object) = value.as_object_mut() {
10143 for (metadata, field) in [
10144 ("grok_model_fingerprint", "model_fingerprint"),
10145 ("grok_reasoning_effort", "reasoning_effort"),
10146 ] {
10147 if let Some(raw) = message.metadata.get(metadata) {
10148 object.insert(field.to_string(), Value::String(raw.clone()));
10149 }
10150 }
10151 }
10152 value
10153 }
10154 Role::Tool => serde_json::json!({
10155 "type": "tool_result",
10156 "tool_call_id": message.tool_call_id.clone().unwrap_or_default(),
10157 "content": message.content.clone().unwrap_or_default(),
10158 }),
10159 };
10160 set_grok_target_message_extension(&mut value, message);
10161 push_jsonl(out, &value);
10162 }
10163 }
10164
10165 /// Replay a Grok imported prefix verbatim, then append newly-created
10166 /// canonical turns. Grok stores the session id in the directory name,
10167 /// not in transcript records, so there is no in-file id to rewrite.
10168 fn to_grok_jsonl_spliced(&self) -> String {
10169 let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10170 if raw_prefix_len == 0 {
10171 return self.to_grok_jsonl();
10172 }
10173 let mut out = String::new();
10174 for line in &self.raw[..raw_prefix_len] {
10175 out.push_str(line);
10176 out.push('\n');
10177 }
10178 self.write_grok_records(&mut out, &self.messages[message_prefix_len..]);
10179 out
10180 }
10181
10182 // ---- Gemini writers ---------------------------------------------
10183
10184 fn to_gemini_jsonl(&self) -> String {
10185 let mut out = String::new();
10186 self.write_gemini_header(&mut out, self.meta.session_id.as_deref());
10187 self.write_gemini_records(&mut out, &self.messages);
10188 push_jsonl(
10189 &mut out,
10190 &serde_json::json!({
10191 "$set": {"lastUpdated": SYNTH_TS}
10192 }),
10193 );
10194 out
10195 }
10196
10197 fn write_gemini_header(&self, out: &mut String, session_id: Option<&str>) {
10198 push_jsonl(
10199 out,
10200 &serde_json::json!({
10201 "sessionId": session_id.unwrap_or("supercode-gemini-session"),
10202 "projectHash": self.meta.lineage.get("gemini_project_hash")
10203 .cloned().unwrap_or_else(|| "supercode".to_string()),
10204 "startTime": self.meta.lineage.get("created_at")
10205 .cloned().unwrap_or_else(|| SYNTH_TS.to_string()),
10206 "lastUpdated": self.meta.lineage.get("updated_at")
10207 .cloned().unwrap_or_else(|| SYNTH_TS.to_string()),
10208 "kind": self.meta.lineage.get("gemini_session_kind")
10209 .cloned().unwrap_or_else(|| "main".to_string()),
10210 }),
10211 );
10212 }
10213
10214 fn write_gemini_records(&self, out: &mut String, messages: &[ChatMessage]) {
10215 let mut call_names = HashMap::new();
10216 for (index, message) in messages.iter().enumerate() {
10217 if is_replay_excluded(message) {
10218 continue;
10219 }
10220 let timestamp = message
10221 .metadata
10222 .get("timestamp")
10223 .cloned()
10224 .unwrap_or_else(|| SYNTH_TS.to_string());
10225 match message.role {
10226 Role::System | Role::User => {
10227 let mut parts = Vec::new();
10228 let text = message.content.clone().or_else(|| {
10229 message.content_parts.as_ref().and_then(|parts| {
10230 let text = parts
10231 .iter()
10232 .filter_map(|part| part.get("text").and_then(Value::as_str))
10233 .collect::<Vec<_>>()
10234 .join(" ");
10235 (!text.is_empty()).then_some(text)
10236 })
10237 });
10238 if let Some(text) = text {
10239 let text = if message.role == Role::System {
10240 format!("[System] {text}")
10241 } else {
10242 text
10243 };
10244 parts.push(serde_json::json!({"text": text}));
10245 }
10246 if let Some(content_parts) = &message.content_parts {
10247 for part in content_parts {
10248 let Some(url) = part
10249 .get("image_url")
10250 .and_then(|value| value.get("url"))
10251 .and_then(Value::as_str)
10252 else {
10253 continue;
10254 };
10255 let Some(rest) = url.strip_prefix("data:") else {
10256 continue;
10257 };
10258 let Some((media_type, data)) = rest.split_once(";base64,") else {
10259 continue;
10260 };
10261 parts.push(serde_json::json!({
10262 "inlineData": {"mimeType": media_type, "data": data}
10263 }));
10264 }
10265 }
10266 if !parts.is_empty() {
10267 let mut value = serde_json::json!({
10268 "id": format!("supercode-user-{index}"),
10269 "timestamp": timestamp,
10270 "type": "user",
10271 "content": parts,
10272 });
10273 set_gemini_message_extension(&mut value, message);
10274 push_jsonl(out, &value);
10275 }
10276 }
10277 Role::Assistant => {
10278 let mut tool_calls = Vec::new();
10279 for call in message.tool_calls() {
10280 call_names.insert(call.id.clone(), call.function.name.clone());
10281 let args = serde_json::from_str::<Value>(&call.function.arguments)
10282 .unwrap_or_else(|_| Value::String(call.function.arguments.clone()));
10283 tool_calls.push(serde_json::json!({
10284 "id": call.id,
10285 "name": call.function.name,
10286 "args": args,
10287 }));
10288 }
10289 let mut value = serde_json::json!({
10290 "id": format!("supercode-gemini-{index}"),
10291 "timestamp": timestamp,
10292 "type": "gemini",
10293 "content": message.content.clone().unwrap_or_default(),
10294 "model": message.metadata.get("gemini_model")
10295 .or(self.meta.model.as_ref())
10296 .cloned().unwrap_or_else(|| "unknown".to_string()),
10297 });
10298 if !tool_calls.is_empty() {
10299 value["toolCalls"] = Value::Array(tool_calls);
10300 }
10301 if let Some(thoughts) = message.metadata.get("gemini_thoughts") {
10302 value["thoughts"] = serde_json::from_str(thoughts)
10303 .unwrap_or_else(|_| Value::String(thoughts.clone()));
10304 }
10305 set_gemini_message_extension(&mut value, message);
10306 push_jsonl(out, &value);
10307 }
10308 Role::Tool => {
10309 let id = message.tool_call_id.clone().unwrap_or_default();
10310 let name = message
10311 .name
10312 .clone()
10313 .or_else(|| call_names.get(&id).cloned())
10314 .unwrap_or_else(|| "tool".to_string());
10315 let output = message.content.clone().unwrap_or_else(|| {
10316 message
10317 .content_parts
10318 .as_ref()
10319 .map(|parts| Value::Array(parts.clone()))
10320 .map(|value| value.to_string())
10321 .unwrap_or_default()
10322 });
10323 let mut value = serde_json::json!({
10324 "id": format!("supercode-tool-{index}"),
10325 "timestamp": timestamp,
10326 "type": "user",
10327 "content": [{
10328 "functionResponse": {
10329 "id": id,
10330 "name": name,
10331 "response": {"output": output}
10332 }
10333 }],
10334 });
10335 set_gemini_message_extension(&mut value, message);
10336 push_jsonl(out, &value);
10337 }
10338 }
10339 }
10340 }
10341
10342 fn to_gemini_jsonl_spliced(&self, session_id: Option<&str>) -> String {
10343 let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
10344 if raw_prefix_len == 0 {
10345 let mut out = String::new();
10346 self.write_gemini_header(&mut out, session_id.or(self.meta.session_id.as_deref()));
10347 self.write_gemini_records(&mut out, &self.messages);
10348 return out;
10349 }
10350 let mut out = String::new();
10351 for (index, line) in self.raw[..raw_prefix_len].iter().enumerate() {
10352 if index == 0 && session_id.is_some() {
10353 if let Ok(mut value) = serde_json::from_str::<Value>(line) {
10354 if value.get("type").is_none() && value.get("sessionId").is_some() {
10355 value["sessionId"] =
10356 Value::String(session_id.unwrap_or_default().to_string());
10357 push_jsonl(&mut out, &value);
10358 continue;
10359 }
10360 }
10361 }
10362 out.push_str(line);
10363 out.push('\n');
10364 }
10365 self.write_gemini_records(&mut out, &self.messages[message_prefix_len..]);
10366 out
10367 }
10368
10369 // ---- Goose writers ----------------------------------------------
10370
10371 fn to_goose_json(&self) -> String {
10372 if self.meta.source == SessionSource::Goose
10373 && !self.raw.is_empty()
10374 && self.imported_message_count == Some(self.messages.len())
10375 {
10376 return join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10377 }
10378 self.synthesized_goose_document(None, &self.messages)
10379 }
10380
10381 fn to_goose_json_spliced(&self, session_id: Option<&str>) -> String {
10382 let message_prefix_len = self
10383 .imported_message_count
10384 .unwrap_or(self.messages.len())
10385 .min(self.messages.len());
10386 if self.meta.source == SessionSource::Goose && !self.raw.is_empty() {
10387 if session_id.is_none() && message_prefix_len == self.messages.len() {
10388 return join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10389 }
10390 let source = join_lines_verbatim(&self.raw, self.raw_trailing_newline);
10391 if let Ok(mut document) = serde_json::from_str::<Value>(&source) {
10392 if let Some(session_id) = session_id {
10393 document["id"] = Value::String(session_id.to_string());
10394 }
10395 let appended = self.goose_conversation(&self.messages[message_prefix_len..]);
10396 if let Some(conversation) = document
10397 .get_mut("conversation")
10398 .and_then(Value::as_array_mut)
10399 {
10400 conversation.extend(appended);
10401 document["message_count"] = Value::from(conversation.len());
10402 }
10403 return serde_json::to_string_pretty(&document).unwrap_or_else(|_| {
10404 self.synthesized_goose_document(session_id, &self.messages)
10405 });
10406 }
10407 }
10408 self.synthesized_goose_document(session_id, &self.messages)
10409 }
10410
10411 fn synthesized_goose_document(
10412 &self,
10413 session_id: Option<&str>,
10414 messages: &[ChatMessage],
10415 ) -> String {
10416 let mut document = self
10417 .meta
10418 .goose_header
10419 .clone()
10420 .or_else(|| {
10421 self.messages.iter().find_map(|message| {
10422 message
10423 .metadata
10424 .get("goose_session_header")
10425 .and_then(|value| serde_json::from_str(value).ok())
10426 })
10427 })
10428 .unwrap_or_else(|| {
10429 serde_json::json!({
10430 "id": "supercode-goose-session",
10431 "working_dir": self.cwd_string(),
10432 "name": "supercode export",
10433 "user_set_name": false,
10434 "session_type": "user",
10435 "created_at": SYNTH_TS,
10436 "updated_at": SYNTH_TS,
10437 "extension_data": {},
10438 "usage": {},
10439 "accumulated_usage": {},
10440 "accumulated_cost": Value::Null,
10441 "schedule_id": Value::Null,
10442 "recipe": Value::Null,
10443 "user_recipe_values": Value::Null,
10444 "message_count": 0,
10445 "last_message_at": Value::Null,
10446 "provider_name": Value::Null,
10447 "model_config": Value::Null,
10448 "goose_mode": "auto",
10449 "archived_at": Value::Null,
10450 "project_id": Value::Null,
10451 "parent_session_id": Value::Null,
10452 "last_message_snippet": Value::Null,
10453 })
10454 });
10455 document["id"] = Value::String(
10456 session_id
10457 .map(str::to_string)
10458 .or_else(|| self.meta.session_id.clone())
10459 .unwrap_or_else(|| "supercode-goose-session".to_string()),
10460 );
10461 document["working_dir"] = Value::String(self.cwd_string());
10462 let conversation = self.goose_conversation(messages);
10463 document["message_count"] = Value::from(conversation.len());
10464 document["conversation"] = Value::Array(conversation);
10465 serde_json::to_string_pretty(&document).unwrap_or_else(|_| "{}".to_string())
10466 }
10467
10468 fn goose_conversation(&self, messages: &[ChatMessage]) -> Vec<Value> {
10469 let mut out = Vec::new();
10470 let mut last_native_index: Option<String> = None;
10471 let mut tool_names = HashMap::<String, String>::new();
10472 for (index, message) in messages.iter().enumerate() {
10473 if is_replay_excluded(message) {
10474 continue;
10475 }
10476 if let Some(native_index) = message.metadata.get("goose_native_index") {
10477 if last_native_index.as_ref() == Some(native_index) {
10478 continue;
10479 }
10480 last_native_index = Some(native_index.clone());
10481 if let Some(native) = message
10482 .metadata
10483 .get("goose_native_message")
10484 .and_then(|value| serde_json::from_str::<Value>(value).ok())
10485 {
10486 out.push(native);
10487 continue;
10488 }
10489 } else {
10490 last_native_index = None;
10491 }
10492
10493 for call in message.tool_calls() {
10494 tool_names.insert(call.id.clone(), call.function.name.clone());
10495 }
10496 let created = message
10497 .metadata
10498 .get("goose_created")
10499 .and_then(|value| value.parse::<i64>().ok())
10500 .unwrap_or(SYNTH_TS_MS / 1000 + index as i64);
10501 let role = match message.role {
10502 Role::Assistant => "assistant",
10503 _ => "user",
10504 };
10505 let mut content = Vec::new();
10506 // A Goose tool response carries its output inside
10507 // `toolResult.value.content`; duplicating it as a sibling text
10508 // block makes the loader normalize one Tool message twice.
10509 if message.role != Role::Tool {
10510 if let Some(text) = &message.content {
10511 let text = if message.role == Role::System {
10512 format!("[System] {text}")
10513 } else {
10514 text.clone()
10515 };
10516 content.push(serde_json::json!({"type": "text", "text": text}));
10517 }
10518 if let Some(parts) = &message.content_parts {
10519 for part in parts {
10520 if let Some(text) = part.get("text").and_then(Value::as_str) {
10521 if message.content.is_none() {
10522 content.push(serde_json::json!({"type": "text", "text": text}));
10523 }
10524 }
10525 let Some(url) = part
10526 .get("image_url")
10527 .and_then(|image| image.get("url"))
10528 .and_then(Value::as_str)
10529 else {
10530 continue;
10531 };
10532 let Some(data) = url.strip_prefix("data:") else {
10533 continue;
10534 };
10535 let Some((media_type, data)) = data.split_once(";base64,") else {
10536 continue;
10537 };
10538 content.push(serde_json::json!({
10539 "type": "image",
10540 "data": data,
10541 "mimeType": media_type,
10542 }));
10543 }
10544 }
10545 }
10546 for call in message.tool_calls() {
10547 let arguments = serde_json::from_str::<Value>(&call.function.arguments)
10548 .unwrap_or_else(|_| Value::String(call.function.arguments.clone()));
10549 content.push(serde_json::json!({
10550 "type": "toolRequest",
10551 "id": call.id,
10552 "toolCall": {
10553 "status": "success",
10554 "value": {"name": call.function.name, "arguments": arguments}
10555 }
10556 }));
10557 }
10558 if message.role == Role::Tool {
10559 let id = message.tool_call_id.clone().unwrap_or_default();
10560 let output = message.content.clone().unwrap_or_else(|| {
10561 message
10562 .content_parts
10563 .as_ref()
10564 .map(|parts| Value::Array(parts.clone()).to_string())
10565 .unwrap_or_default()
10566 });
10567 let tool_result = if crate::is_tool_error(message) {
10568 serde_json::json!({"status": "error", "error": output})
10569 } else {
10570 serde_json::json!({
10571 "status": "success",
10572 "value": {
10573 "content": [{"type": "text", "text": output}],
10574 "isError": false
10575 }
10576 })
10577 };
10578 content.push(serde_json::json!({
10579 "type": "toolResponse",
10580 "id": id,
10581 "toolResult": tool_result,
10582 "metadata": {
10583 "toolName": message.name.as_ref()
10584 .or_else(|| tool_names.get(&id))
10585 }
10586 }));
10587 }
10588 if content.is_empty() {
10589 continue;
10590 }
10591 let metadata = message
10592 .metadata
10593 .get("goose_metadata")
10594 .and_then(|value| serde_json::from_str::<Value>(value).ok())
10595 .unwrap_or_else(|| {
10596 serde_json::json!({
10597 "userVisible": true,
10598 "agentVisible": true
10599 })
10600 });
10601 let mut native = serde_json::json!({
10602 "id": message.metadata.get("goose_message_id")
10603 .cloned().unwrap_or_else(|| format!("supercode-goose-{index}")),
10604 "role": role,
10605 "created": created,
10606 "content": content,
10607 "metadata": metadata,
10608 });
10609 // Goose tolerates unknown top-level fields on a conversation
10610 // message. Always carry the canonical envelope when Goose is
10611 // the TARGET so metadata absent from Goose's stock schema can
10612 // make a later Goose -> source round trip without residue.
10613 set_grok_target_message_extension(&mut native, message);
10614 out.push(native);
10615 }
10616 out
10617 }
10618
10619 // ---- OpenCode writers ---------------------------------------------
10620
10621 /// Re-derive the structured OpenCode records (`SessionInfo`, an ordered
10622 /// `(message value, part values)` list) directly from `self.raw`'s
10623 /// envelope lines — the same classification
10624 /// [`Self::from_opencode_str`] performs, but returning the raw VALUES
10625 /// rather than canonical `ChatMessage`s. Used by
10626 /// [`Self::to_opencode_jsonl_spliced`] (§4.2 S5: the imported prefix
10627 /// must be VALUE-EQUAL at its position, not re-synthesized from lossy
10628 /// `messages`) and [`Self::to_opencode_direct_write`] (S5: the fidelity
10629 /// path for excess keys/timestamps/side-records `opencode import`
10630 /// cannot restore).
10631 fn opencode_records_from_raw(&self) -> (Option<Value>, Vec<(Value, Vec<Value>)>) {
10632 let mut session_info: Option<Value> = None;
10633 let mut msg_order: Vec<String> = Vec::new();
10634 let mut msg_values: HashMap<String, Value> = HashMap::new();
10635 let mut msg_parts: HashMap<String, Vec<Value>> = HashMap::new();
10636 for line in &self.raw {
10637 let Ok(env) = serde_json::from_str::<Value>(line) else {
10638 continue;
10639 };
10640 let Some(key) = env.get("key").and_then(Value::as_array) else {
10641 continue;
10642 };
10643 let value = env.get("value").cloned().unwrap_or(Value::Null);
10644 match key.first().and_then(Value::as_str) {
10645 Some("session") => session_info = Some(value),
10646 Some("message") => {
10647 if let Some(id) = value.get("id").and_then(Value::as_str) {
10648 if !msg_values.contains_key(id) {
10649 msg_order.push(id.to_string());
10650 }
10651 msg_values.insert(id.to_string(), value);
10652 }
10653 }
10654 Some("part") => {
10655 if let Some(mid) = value.get("messageID").and_then(Value::as_str) {
10656 msg_parts.entry(mid.to_string()).or_default().push(value);
10657 }
10658 }
10659 _ => {}
10660 }
10661 }
10662 let mut ordered: Vec<(String, i64)> = msg_order
10663 .iter()
10664 .map(|id| {
10665 let tc = msg_values
10666 .get(id)
10667 .and_then(|v| v.get("time"))
10668 .and_then(|t| t.get("created"))
10669 .and_then(Value::as_i64)
10670 .unwrap_or(0);
10671 (id.clone(), tc)
10672 })
10673 .collect();
10674 ordered.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
10675 let mut out = Vec::new();
10676 for (id, _) in ordered {
10677 let mut parts = msg_parts.remove(&id).unwrap_or_default();
10678 parts.sort_by(|a, b| {
10679 let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
10680 let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
10681 ai.cmp(bi)
10682 });
10683 if let Some(v) = msg_values.remove(&id) {
10684 out.push((v, parts));
10685 }
10686 }
10687 (session_info, out)
10688 }
10689
10690 /// Best-effort `SessionInfo` synthesized from `self.meta` — used when no
10691 /// `raw` prefix exists to replay (a fresh/cross-format-converted
10692 /// session). T3 tier: only what `SessionMeta` carries survives.
10693 fn synthesized_opencode_info(&self) -> Value {
10694 let id = self
10695 .meta
10696 .session_id
10697 .clone()
10698 .unwrap_or_else(|| "ses_supercode00000000000001".to_string());
10699 let mut info = serde_json::json!({
10700 "id": id,
10701 "projectID": self.meta.lineage.get("projectID").cloned().unwrap_or_else(|| "global".to_string()),
10702 // OpenCode 1.2.15's import path writes this into a NOT NULL
10703 // SQLite column. Preserve a real source slug when available and
10704 // mint a stable, human-readable fallback for foreign sessions.
10705 "slug": self.meta.lineage.get("slug").cloned().unwrap_or_else(|| "supercode-export".to_string()),
10706 "directory": self.cwd_string(),
10707 "title": "supercode export",
10708 "version": env!("CARGO_PKG_VERSION"),
10709 "time": {"created": SYNTH_TS_MS, "updated": SYNTH_TS_MS},
10710 });
10711 if let Some(agent) = &self.meta.agent_id {
10712 info["agent"] = Value::String(agent.clone());
10713 }
10714 if let Some(model) = &self.meta.model {
10715 if let Some((provider, mid)) = model.split_once('/') {
10716 info["model"] = serde_json::json!({"providerID": provider, "id": mid});
10717 }
10718 }
10719 if let Some(parent) = self.meta.lineage.get("parent_session_id") {
10720 info["parentID"] = Value::String(parent.clone());
10721 }
10722 // D7: carry a captured Claude `fork-context-ref` through the
10723 // OpenCode hop too, exactly like the Codex (`claude_fork_context_ref`
10724 // on `session_meta.payload`) and Pi (`claude_fork_context_ref` on
10725 // the `session` header) hops already do — namespaced so real
10726 // OpenCode tooling ignores it, and `capture_opencode_session_info`
10727 // reads this same key back on import so a Claude -> OpenCode ->
10728 // Claude round trip doesn't silently lose fork lineage either.
10729 //
10730 // DOCUMENTED LIMITATION (D7 caveat, per the frozen interop spec —
10731 // `interop-research-spec@f168465`, `opencode-pi-spec.md` ~line 123):
10732 // this `claude_fork_context_ref` key on `SessionInfo` survives
10733 // supercode's OWN round-trip (write here, read back by
10734 // `capture_opencode_session_info` above) but NOT a real upstream
10735 // `opencode import` ingestion — that path decodes with
10736 // `Schema.decodeUnknownSync`, which strips any key its schema
10737 // doesn't declare. The direct-file/DB fallback (bypassing
10738 // `opencode import` entirely) is the per-spec fidelity path for
10739 // this lineage to actually reach real OpenCode.
10740 if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
10741 info["claude_fork_context_ref"] =
10742 serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
10743 }
10744 if let Some(extension) = codex_provenance_envelope(&self.meta) {
10745 info[SUPERCODE_CODEX_PROVENANCE_KEY] = extension;
10746 }
10747 info
10748 }
10749
10750 /// OpenCode uses `SessionInfo.time.updated` for recency selection. Any
10751 /// synthesized continuation message therefore has to advance the
10752 /// session clock along with its own `time.created` value.
10753 fn touch_opencode_session_updated(info: &mut Value, timestamp: i64) {
10754 if !info.get("time").is_some_and(Value::is_object) {
10755 info["time"] = serde_json::json!({});
10756 }
10757 info["time"]["updated"] = serde_json::json!(timestamp);
10758 }
10759
10760 /// Synthesize opencode `{info, parts}` message objects for `messages`
10761 /// (T3 tier — full synthesis from canonical `ChatMessage`s, the inverse
10762 /// of [`push_opencode_user`]/[`push_opencode_assistant`]), appending
10763 /// them to `out`. Every `Tool` message anywhere in `messages` is folded
10764 /// back into its call's assistant `tool` part (match by
10765 /// `tool_call_id`/`callID`, pairing repeated ids by occurrence over the
10766 /// whole slice)
10767 /// — the exact inverse of the loader's call/result split. This is a
10768 /// GLOBAL match, not a scan of the contiguous run of `Tool` messages
10769 /// immediately following each assistant: two-or-more consecutive
10770 /// assistant-with-tool-call messages before their results (streamed /
10771 /// parallel tool calls) otherwise strand the earlier call's real result
10772 /// behind a later assistant message, silently downgrading it to
10773 /// "pending" and losing the recorded output. Skips [`is_replay_excluded`]
10774 /// messages exactly like every other writer.
10775 fn append_synthesized_opencode_messages(
10776 &self,
10777 out: &mut Vec<Value>,
10778 messages: &[ChatMessage],
10779 session_id: &str,
10780 counter: &mut u64,
10781 timestamp_cursor: &mut i64,
10782 ) -> Result<()> {
10783 // Pair each Tool message to its call GLOBALLY by `tool_call_id`,
10784 // over the ENTIRE slice being processed, rather than by scanning
10785 // only the contiguous run of `Role::Tool` messages immediately
10786 // following a given assistant message. Two-or-more consecutive
10787 // assistant-with-tool-call messages before their results (streamed
10788 // / parallel tool calls — extremely common in real Claude Code and
10789 // Codex sessions) break the contiguous-run assumption: the first
10790 // assistant's own result(s) land AFTER a second assistant message,
10791 // not immediately after the first, so a contiguous scan starting
10792 // right after the first assistant finds nothing and silently drops
10793 // its real tool output into the `None => "pending"` branch below.
10794 // A single `id -> result` map is still insufficient: long real
10795 // sessions can reuse provider call ids. Last-write-wins then attaches
10796 // the final output to every earlier occurrence. Collect calls and
10797 // results independently and zip their occurrences in transcript
10798 // order, giving every concrete call position its own result.
10799 let mut calls_by_id: HashMap<&str, Vec<(usize, usize)>> = HashMap::new();
10800 let mut results_by_id: HashMap<&str, Vec<(usize, &ChatMessage)>> = HashMap::new();
10801 for (message_index, message) in messages.iter().enumerate() {
10802 if message.role == Role::Assistant {
10803 for (tool_index, call) in message.tool_calls().iter().enumerate() {
10804 calls_by_id
10805 .entry(call.id.as_str())
10806 .or_default()
10807 .push((message_index, tool_index));
10808 }
10809 } else if message.role == Role::Tool {
10810 if let Some(id) = &message.tool_call_id {
10811 results_by_id
10812 .entry(id.as_str())
10813 .or_default()
10814 .push((message_index, message));
10815 }
10816 }
10817 }
10818 let mut paired_results: HashMap<(usize, usize), (usize, &ChatMessage)> = HashMap::new();
10819 for (id, calls) in calls_by_id {
10820 let Some(results) = results_by_id.get(id) else {
10821 continue;
10822 };
10823 for (call_position, result) in calls.into_iter().zip(results.iter().copied()) {
10824 paired_results.insert(call_position, result);
10825 }
10826 }
10827 let mut i = 0;
10828 while i < messages.len() {
10829 let msg = &messages[i];
10830 if is_replay_excluded(msg) {
10831 i += 1;
10832 continue;
10833 }
10834 match msg.role {
10835 // B4: opencode V1 has no session-level system-PROMPT slot
10836 // either — `User.system` is a per-turn system-PROMPT
10837 // OVERRIDE (§2.1), a different thing from a content-bearing
10838 // `Role::System` message loaded from a real Claude `type:
10839 // "system"` record (`push_claude_system`'s keep-listed
10840 // subtypes). Stuffing real transcript content into
10841 // `User.system` would be a genuine misuse — it overrides the
10842 // replayed system prompt, not just annotates a turn — so
10843 // this instead reuses opencode's own `text` part `synthetic`
10844 // flag (§3.1: "injected by opencode, not typed by user"),
10845 // which is EXACTLY the right existing, non-fabricated
10846 // semantic for "system-originated content presented as a
10847 // user turn": a dedicated `User` message with one
10848 // `synthetic: true` text part, tagged with a
10849 // supercode-namespaced part-`metadata` key so
10850 // `opencode_claude_system_subtype`/`push_opencode_claude_system`
10851 // recognize it on reload and restore `Role::System` +
10852 // `metadata["systemSubtype"]` rather than treating it as a
10853 // real user turn. Content is never fabricated — only
10854 // emitted when non-empty.
10855 Role::System => {
10856 let content = msg.content.clone().unwrap_or_default();
10857 if content.trim().is_empty() {
10858 i += 1;
10859 continue;
10860 }
10861 let subtype = msg
10862 .metadata
10863 .get("systemSubtype")
10864 .cloned()
10865 .unwrap_or_else(|| "local_command".to_string());
10866 let msg_id = opencode_fresh_id("msg", counter);
10867 let part_id = opencode_fresh_id("prt", counter);
10868 let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
10869 let mut info = serde_json::json!({
10870 "id": msg_id,
10871 "sessionID": session_id,
10872 "role": "user",
10873 "time": {"created": timestamp},
10874 });
10875 info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
10876 let parts = vec![serde_json::json!({
10877 "id": part_id,
10878 "sessionID": session_id,
10879 "messageID": msg_id,
10880 "type": "text",
10881 "text": content,
10882 "synthetic": true,
10883 "metadata": {OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: subtype},
10884 })];
10885 out.push(serde_json::json!({"info": info, "parts": parts}));
10886 i += 1;
10887 }
10888 Role::User => {
10889 let msg_id = opencode_fresh_id("msg", counter);
10890 let parts = opencode_user_parts_from_message(msg, &msg_id, session_id, counter);
10891 let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
10892 let mut info = serde_json::json!({
10893 "id": msg_id,
10894 "sessionID": session_id,
10895 "role": "user",
10896 "time": {"created": timestamp},
10897 });
10898 info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
10899 opencode_restore_agent_model_fields(
10900 &mut info, msg, /* is_assistant */ false,
10901 );
10902 set_grok_message_extension(&mut info, self.meta.source, msg);
10903 out.push(serde_json::json!({
10904 "info": info,
10905 "parts": parts,
10906 }));
10907 i += 1;
10908 }
10909 Role::Assistant => {
10910 let msg_id = opencode_fresh_id("msg", counter);
10911 let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
10912 let mut parts = Vec::new();
10913 if let Some(thinking) = msg.metadata.get("thinking") {
10914 let mut part = serde_json::json!({
10915 "id": opencode_fresh_id("prt", counter),
10916 "sessionID": session_id,
10917 "messageID": msg_id,
10918 "type": "reasoning",
10919 "text": thinking,
10920 // Required by OpenCode V1's native reasoning
10921 // schema. A synthesized part has no distinct
10922 // stream start/end, so the source message clock
10923 // is the honest zero-duration span.
10924 "time": {"start": timestamp, "end": timestamp},
10925 });
10926 if let Some(signature) = msg.metadata.get("thinking_signature") {
10927 part["metadata"] = serde_json::json!({
10928 "anthropic": {"signature": signature},
10929 });
10930 }
10931 parts.push(part);
10932 }
10933 if let Some(t) = &msg.content {
10934 if !t.is_empty() {
10935 parts.push(serde_json::json!({
10936 "id": opencode_fresh_id("prt", counter),
10937 "sessionID": session_id,
10938 "messageID": msg_id,
10939 "type": "text",
10940 "text": t,
10941 }));
10942 }
10943 }
10944 // Fold each tool call's result back into ONE `tool`
10945 // part, matched by tool_call_id via the GLOBAL
10946 // `all_results` map built above (not a contiguous scan)
10947 // — a result may be many messages away when other
10948 // assistant turns with their own pending calls
10949 // intervene before it appears.
10950 for (tool_index, tc) in msg.tool_calls().iter().enumerate() {
10951 let input = tc
10952 .function
10953 .parsed_arguments()
10954 .unwrap_or_else(|_| Value::Object(Default::default()));
10955 let paired_result = paired_results.get(&(i, tool_index)).copied();
10956 let state = match paired_result {
10957 Some((_, result)) if crate::is_tool_error(result) => {
10958 let result_timestamp =
10959 opencode_message_timestamp(result, timestamp_cursor)?;
10960 serde_json::json!({
10961 "status": "error",
10962 "input": input,
10963 "error": result.content.clone().unwrap_or_default(),
10964 "time": {"end": result_timestamp},
10965 })
10966 }
10967 Some((_, result)) => {
10968 let result_timestamp =
10969 opencode_message_timestamp(result, timestamp_cursor)?;
10970 let mut s = serde_json::json!({
10971 "status": "completed",
10972 "input": input,
10973 "output": result.content.clone().unwrap_or_default(),
10974 "title": tc.function.name,
10975 "time": {"end": result_timestamp},
10976 });
10977 // PARITY-11 (nested images): the LOADER already
10978 // reads a completed tool part's
10979 // `state.attachments` back into `content_parts`
10980 // (`opencode_file_image_part`, above) — this is
10981 // the missing WRITE-side inverse. Without it, a
10982 // Claude `tool_result`'s nested image (now
10983 // captured into `content_parts` by
10984 // `extract_tool_result_content`) reached
10985 // `content_parts` on the canonical `ChatMessage`
10986 // but was silently dropped again on re-export to
10987 // OpenCode, because nothing ever read it back
10988 // out. `mime`/`url` shape matches exactly what
10989 // `opencode_file_image_part` expects on reload.
10990 if let Some(cps) = &result.content_parts {
10991 let atts: Vec<Value> = cps
10992 .iter()
10993 .filter(|p| {
10994 p.get("type").and_then(Value::as_str)
10995 == Some("image_url")
10996 })
10997 .filter_map(|p| {
10998 let url = p
10999 .get("image_url")
11000 .and_then(|u| u.get("url"))
11001 .and_then(Value::as_str)?;
11002 let mime = url
11003 .strip_prefix("data:")
11004 .and_then(|r| r.split_once(','))
11005 .map(|(m, _)| m.trim_end_matches(";base64"))
11006 .unwrap_or("application/octet-stream");
11007 Some(serde_json::json!({
11008 "mime": mime,
11009 "url": url,
11010 }))
11011 })
11012 .collect();
11013 if !atts.is_empty() {
11014 s["attachments"] = Value::Array(atts);
11015 }
11016 }
11017 s
11018 }
11019 None => serde_json::json!({"status": "pending", "input": input}),
11020 };
11021 let mut part = serde_json::json!({
11022 "id": opencode_fresh_id("prt", counter),
11023 "sessionID": session_id,
11024 "messageID": msg_id,
11025 "type": "tool",
11026 "callID": tc.id,
11027 "tool": tc.function.name,
11028 "state": state,
11029 });
11030 if let Some((result_position, _)) = paired_result {
11031 part[OPENCODE_SUPERCODE_RESULT_POSITION] =
11032 serde_json::json!(result_position);
11033 }
11034 if paired_result.is_some_and(|(_, result)| {
11035 crate::tool_outcome(result) == crate::ToolOutcome::Unknown
11036 }) {
11037 part[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
11038 }
11039 if let Some((_, result)) = paired_result {
11040 set_grok_message_extension(&mut part, self.meta.source, result);
11041 }
11042 parts.push(part);
11043 }
11044 let mut info = serde_json::json!({
11045 "id": msg_id,
11046 "sessionID": session_id,
11047 "role": "assistant",
11048 "time": {"created": timestamp},
11049 });
11050 info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
11051 opencode_restore_agent_model_fields(
11052 &mut info, msg, /* is_assistant */ true,
11053 );
11054 set_grok_message_extension(&mut info, self.meta.source, msg);
11055 out.push(serde_json::json!({
11056 "info": info,
11057 "parts": parts,
11058 }));
11059 i += 1;
11060 }
11061 // A Tool message is always folded into its call's assistant
11062 // `tool` part above (via occurrence-aware global pairing, not
11063 // positional adjacency), so it never needs its own entry
11064 // here — just advance past it.
11065 Role::Tool => i += 1,
11066 }
11067 }
11068 Ok(())
11069 }
11070
11071 /// Synthesize a fresh OpenCode export DOCUMENT from the canonical
11072 /// `messages` (T3 cross-format/full synthesis tier — mirrors
11073 /// [`Self::to_pi_jsonl`]'s doc comment: this is NOT the value-lossless
11074 /// native path, which goes through `raw` + `to_native_jsonl(_v2)`
11075 /// instead). Shape: `{info: SessionInfo, messages: [{info, parts}, …]}`
11076 /// (§1.2 — the `opencode export`/`import` interchange shape).
11077 fn to_opencode_jsonl(&self) -> Result<String> {
11078 let mut info = self.synthesized_opencode_info();
11079 let ses_id = info
11080 .get("id")
11081 .and_then(Value::as_str)
11082 .unwrap_or("ses_new")
11083 .to_string();
11084 let mut messages_json: Vec<Value> = Vec::new();
11085 let mut counter: u64 = 0;
11086 let mut timestamp_cursor =
11087 opencode_max_timestamp(&info).unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11088 self.append_synthesized_opencode_messages(
11089 &mut messages_json,
11090 &self.messages,
11091 &ses_id,
11092 &mut counter,
11093 &mut timestamp_cursor,
11094 )?;
11095 if !messages_json.is_empty() {
11096 Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11097 }
11098 let doc = serde_json::json!({"info": info, "messages": messages_json});
11099 Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
11100 }
11101
11102 /// A12-style splice for OpenCode (§1.2/§4.2 point 1, S5): replay the
11103 /// imported records **value-equal at their position** in the export
11104 /// doc's `messages[]`/`parts[]` — reconstructed directly from `self.raw`
11105 /// via [`Self::opencode_records_from_raw`], never re-derived from the
11106 /// lossy canonical `messages` — then append freshly synthesized
11107 /// `{info, parts}` objects for the tail via
11108 /// [`Self::append_synthesized_opencode_messages`]. Unlike the
11109 /// line-oriented formats' splice, `out` here is a single export
11110 /// DOCUMENT, not a line stream (§4.2 restates the "prefix verbatim"
11111 /// assertion accordingly: value-equality at position, not byte
11112 /// equality of a line range).
11113 fn to_opencode_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
11114 if self.raw.is_empty() {
11115 return self.to_opencode_jsonl();
11116 }
11117 let (session_info, records) = self.opencode_records_from_raw();
11118 let (_, message_prefix_len) = self.spliced_prefix_lens();
11119
11120 let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
11121 if let Some(id) = session_id {
11122 info["id"] = Value::String(id.to_string());
11123 }
11124 let ses_id_for_new = info
11125 .get("id")
11126 .and_then(Value::as_str)
11127 .unwrap_or("ses_new")
11128 .to_string();
11129
11130 let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
11131 .chain(records.iter().flat_map(|(msg, parts)| {
11132 std::iter::once(opencode_max_timestamp(msg))
11133 .chain(parts.iter().map(opencode_max_timestamp))
11134 }))
11135 .flatten()
11136 .max()
11137 .unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11138
11139 let mut messages_json: Vec<Value> = records
11140 .into_iter()
11141 .map(|(msg, parts)| serde_json::json!({"info": msg, "parts": parts}))
11142 .collect();
11143 let imported_len = messages_json.len();
11144
11145 let mut counter: u64 = 0;
11146 self.append_synthesized_opencode_messages(
11147 &mut messages_json,
11148 &self.messages[message_prefix_len..],
11149 &ses_id_for_new,
11150 &mut counter,
11151 &mut timestamp_cursor,
11152 )?;
11153 if messages_json.len() > imported_len {
11154 Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11155 }
11156
11157 let doc = serde_json::json!({"info": info, "messages": messages_json});
11158 Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
11159 }
11160
11161 /// The **required** direct-write fallback (S5): write the imported
11162 /// OpenCode records **verbatim** — excess/unknown keys, part-row
11163 /// timestamps, and `session_diff`/`todo` side-records intact — to a
11164 /// generation-B JSON-file storage tree
11165 /// (`docs/interop/opencode-pi-spec.md` §1.2), the fidelity path
11166 /// `opencode import` cannot provide (S5: import re-decodes through a
11167 /// strict schema and STRIPS excess keys; inserts part rows without
11168 /// `time_created`/`time_updated`, so those reset to `Date.now()`; and
11169 /// has no ingestion path for `session_diff`/`todo` at all).
11170 ///
11171 /// Writes the JSON-FILE layout rather than a live SQLite write
11172 /// specifically to avoid a new `rusqlite`-class dependency on this
11173 /// build's memory-constrained box (see the build report); `session_diff`
11174 /// itself is still JSON-written by upstream even on SQLite installs
11175 /// (§1.3), so this is a real fidelity path, not a fictional one.
11176 ///
11177 /// Returns the `storage/session/<projectID>/` directory written to.
11178 pub fn to_opencode_direct_write(&self, data_root: &Path) -> Result<PathBuf> {
11179 let (session_info, mut records) = self.opencode_records_from_raw();
11180 let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
11181 let ses_id = info
11182 .get("id")
11183 .and_then(Value::as_str)
11184 .unwrap_or("ses_new")
11185 .to_string();
11186 if info.get("id").is_none() {
11187 info["id"] = Value::String(ses_id.clone());
11188 }
11189 let project_id = info
11190 .get("projectID")
11191 .and_then(Value::as_str)
11192 .unwrap_or("global")
11193 .to_string();
11194
11195 // Appended tail (messages produced after import): synthesize fresh
11196 // message/part VALUES via the same T3 synthesis the splice writer
11197 // uses, so continuation turns get files too. Do this BEFORE creating
11198 // any directories: timestamp exhaustion must fail atomically rather
11199 // than leave a partial direct-write tree behind.
11200 let (_, message_prefix_len) = self.spliced_prefix_lens();
11201 let mut counter: u64 = 0;
11202 let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
11203 .chain(records.iter().flat_map(|(msg, parts)| {
11204 std::iter::once(opencode_max_timestamp(msg))
11205 .chain(parts.iter().map(opencode_max_timestamp))
11206 }))
11207 .flatten()
11208 .max()
11209 .unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
11210 let mut appended_json: Vec<Value> = Vec::new();
11211 self.append_synthesized_opencode_messages(
11212 &mut appended_json,
11213 &self.messages[message_prefix_len..],
11214 &ses_id,
11215 &mut counter,
11216 &mut timestamp_cursor,
11217 )?;
11218 if !appended_json.is_empty() {
11219 Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
11220 }
11221 for entry in appended_json {
11222 let msg = entry.get("info").cloned().unwrap_or(Value::Null);
11223 let parts = entry
11224 .get("parts")
11225 .and_then(Value::as_array)
11226 .cloned()
11227 .unwrap_or_default();
11228 records.push((msg, parts));
11229 }
11230
11231 let storage = data_root.join("storage");
11232 let session_dir = storage.join("session").join(&project_id);
11233 std::fs::create_dir_all(&session_dir)?;
11234 std::fs::write(
11235 session_dir.join(format!("{ses_id}.json")),
11236 serde_json::to_string_pretty(&info).unwrap_or_default(),
11237 )?;
11238
11239 let message_dir = storage.join("message").join(&ses_id);
11240 let part_dir = storage.join("part");
11241 std::fs::create_dir_all(&message_dir)?;
11242
11243 for (msg, parts) in &records {
11244 let Some(msg_id) = msg.get("id").and_then(Value::as_str) else {
11245 continue;
11246 };
11247 std::fs::write(
11248 message_dir.join(format!("{msg_id}.json")),
11249 serde_json::to_string_pretty(msg).unwrap_or_default(),
11250 )?;
11251 let this_part_dir = part_dir.join(msg_id);
11252 std::fs::create_dir_all(&this_part_dir)?;
11253 for part in parts {
11254 let Some(part_id) = part.get("id").and_then(Value::as_str) else {
11255 continue;
11256 };
11257 std::fs::write(
11258 this_part_dir.join(format!("{part_id}.json")),
11259 serde_json::to_string_pretty(part).unwrap_or_default(),
11260 )?;
11261 }
11262 }
11263
11264 // Side-records (S5c): session_diff / todo have NO ingestion path via
11265 // `opencode import` at all — the direct write is their only
11266 // fidelity path.
11267 for header in &self.meta.opencode_headers {
11268 let Some(key) = header.get("key").and_then(Value::as_array) else {
11269 continue;
11270 };
11271 let Some(kind) = key.first().and_then(Value::as_str) else {
11272 continue;
11273 };
11274 let value = header.get("value").cloned().unwrap_or(Value::Null);
11275 if !matches!(kind, "session_diff" | "todo") {
11276 continue;
11277 }
11278 let dir = storage.join(kind);
11279 std::fs::create_dir_all(&dir)?;
11280 std::fs::write(
11281 dir.join(format!("{ses_id}.json")),
11282 serde_json::to_string_pretty(&value).unwrap_or_default(),
11283 )?;
11284 }
11285
11286 Ok(session_dir)
11287 }
11288}
11289
11290fn opencode_fresh_id(prefix: &str, counter: &mut u64) -> String {
11291 *counter += 1;
11292 format!("{prefix}_synth{counter:06}")
11293}
11294
11295/// WAVE-2 fidelity item 2: restore the `agent`/`cost`/`finish`/`is_summary`/
11296/// `model`/`tokens` fields onto a synthesized OpenCode `info` record, in the
11297/// EXACT native shape opencode's own loaders (`push_opencode_user` /
11298/// `push_opencode_assistant`, above) parse back out — so an opencode->opencode
11299/// export-doc round-trip (write here, reload via [`Session::from_opencode_str`])
11300/// reconstructs value-identical `ChatMessage.metadata`. Each field is emitted
11301/// ONLY when its metadata key is present (a synthesized continuation turn, or
11302/// a User message that never carried `agent`, stays clean — no spurious
11303/// null/empty fields).
11304///
11305/// - `agent`: plain string on BOTH User (`v1/session.ts:346`) and Assistant
11306/// (`:465`) — `metadata["agent"]` is inserted verbatim by both loaders from
11307/// `msg_value.get("agent")`, so it's re-emitted verbatim here too.
11308/// - `model`: the loaders capture it in TWO DIFFERENT shapes depending on
11309/// role (`docs/interop/research/opencode-fields.md` §3 2a/2b), so the
11310/// inverse must match per-role:
11311/// - User: `push_opencode_user` stores `metadata["model"]` as the
11312/// STRINGIFIED `{providerID, modelID, variant?}` object
11313/// (`msg_value.get("model")...to_string()`) — re-parsed and re-emitted
11314/// as that same object under `"model"`.
11315/// - Assistant: `push_opencode_assistant` stores `metadata["model"]` as
11316/// `"{providerID}/{modelID}"` (from the top-level `providerID`/`modelID`
11317/// fields, joined) — split back on the FIRST `/` (matching `format!`'s
11318/// join; a `modelID` containing further `/`s round-trips correctly since
11319/// `split_once` only consumes the first) and re-emitted as the two
11320/// top-level `providerID`/`modelID` fields the loader actually reads.
11321/// - `is_summary`/`cost`/`finish`/`tokens`: Assistant-only concepts (no such
11322/// fields exist on opencode's `User` schema) — `is_summary` re-expands
11323/// `"true"` back to the native `summary: true` bool (the loader only ever
11324/// sets the metadata key on `Some(true)`, never on absent/false, so the
11325/// inverse never needs to emit `false`); `finish` is a plain string;
11326/// `cost`/`tokens` were captured via `v.to_string()` on the raw JSON
11327/// `Value` (a number and an object respectively), so they're re-parsed
11328/// from that stringified form and re-emitted as the native JSON value —
11329/// NOT as strings — matching `msg_value.get(field)` shape exactly.
11330fn opencode_restore_agent_model_fields(info: &mut Value, msg: &ChatMessage, is_assistant: bool) {
11331 if let Some(agent) = msg.metadata.get("agent") {
11332 info["agent"] = Value::String(agent.clone());
11333 }
11334 if let Some(model) = msg.metadata.get("model") {
11335 if is_assistant {
11336 if let Some((provider, model_id)) = model.split_once('/') {
11337 info["providerID"] = Value::String(provider.to_string());
11338 info["modelID"] = Value::String(model_id.to_string());
11339 }
11340 } else if let Ok(v) = serde_json::from_str::<Value>(model) {
11341 info["model"] = v;
11342 }
11343 }
11344 if !is_assistant {
11345 return;
11346 }
11347 if msg.metadata.get("is_summary").map(String::as_str) == Some("true") {
11348 info["summary"] = Value::Bool(true);
11349 }
11350 if let Some(finish) = msg.metadata.get("finish") {
11351 info["finish"] = Value::String(finish.clone());
11352 }
11353 if let Some(cost) = msg.metadata.get("cost") {
11354 if let Ok(v) = serde_json::from_str::<Value>(cost) {
11355 info["cost"] = v;
11356 }
11357 }
11358 if let Some(tokens) = msg.metadata.get("tokens") {
11359 if let Ok(v) = serde_json::from_str::<Value>(tokens) {
11360 info["tokens"] = v;
11361 }
11362 }
11363}
11364
11365fn opencode_user_parts_from_message(
11366 msg: &ChatMessage,
11367 msg_id: &str,
11368 session_id: &str,
11369 counter: &mut u64,
11370) -> Vec<Value> {
11371 let mut parts = Vec::new();
11372 if let Some(cps) = &msg.content_parts {
11373 for p in cps {
11374 match p.get("type").and_then(Value::as_str) {
11375 Some("text") => {
11376 if let Some(t) = p.get("text").and_then(Value::as_str) {
11377 parts.push(serde_json::json!({
11378 "id": opencode_fresh_id("prt", counter),
11379 "sessionID": session_id,
11380 "messageID": msg_id,
11381 "type": "text",
11382 "text": t,
11383 }));
11384 }
11385 }
11386 Some("image_url") => {
11387 if let Some(url) = p
11388 .get("image_url")
11389 .and_then(|u| u.get("url"))
11390 .and_then(Value::as_str)
11391 {
11392 let mime = url
11393 .strip_prefix("data:")
11394 .and_then(|r| r.split_once(','))
11395 .map(|(m, _)| m.trim_end_matches(";base64"))
11396 .unwrap_or("application/octet-stream");
11397 parts.push(serde_json::json!({
11398 "id": opencode_fresh_id("prt", counter),
11399 "sessionID": session_id,
11400 "messageID": msg_id,
11401 "type": "file",
11402 "mime": mime,
11403 "url": url,
11404 }));
11405 }
11406 }
11407 _ => {}
11408 }
11409 }
11410 } else if let Some(t) = &msg.content {
11411 if !t.is_empty() {
11412 parts.push(serde_json::json!({
11413 "id": opencode_fresh_id("prt", counter),
11414 "sessionID": session_id,
11415 "messageID": msg_id,
11416 "type": "text",
11417 "text": t,
11418 }));
11419 }
11420 }
11421 parts
11422}
11423
11424fn codex_response_item(payload: Value, ts: &str) -> Value {
11425 serde_json::json!({"timestamp": ts, "type": "response_item", "payload": payload})
11426}
11427
11428/// Stamp `payload.metadata.turn_id` when `turn_id` is `Some` (PARITY-6/7,
11429/// see [`Session::write_codex_records`]); a no-op returning `payload`
11430/// untouched when `None`, so the historical byte shape is preserved for
11431/// every record that has no merge ambiguity to disambiguate.
11432fn with_turn_id(mut payload: Value, turn_id: Option<&str>) -> Value {
11433 if let Some(tid) = turn_id {
11434 payload["metadata"] = serde_json::json!({"turn_id": tid});
11435 }
11436 payload
11437}
11438
11439/// Build a Codex `message` response_item's `content` block array from a
11440/// `ChatMessage` — the inverse of [`codex_extract_images`]/`extract_text_content`'s
11441/// parse. When `content_parts` is `None` this MUST reproduce the historical
11442/// single-block shape exactly (IX-5's overriding constraint: a text-only
11443/// message's export stays byte-identical) — only a multimodal message gets
11444/// one `{text_type}` block per non-empty text part plus one native Codex
11445/// `input_image` block (`{"type":"input_image","image_url":<data:URI or
11446/// URL>}` — the Responses-API-shaped image content Codex's own `input_text`/
11447/// `output_text` blocks already follow the family of) per `image_url` part.
11448fn codex_message_content_blocks(text_type: &str, msg: &ChatMessage) -> Value {
11449 match &msg.content_parts {
11450 Some(parts) => {
11451 let mut blocks = Vec::new();
11452 for p in parts {
11453 match p.get("type").and_then(Value::as_str) {
11454 Some("text") => {
11455 if let Some(t) = p.get("text").and_then(Value::as_str) {
11456 if !t.is_empty() {
11457 blocks.push(serde_json::json!({"type": text_type, "text": t}));
11458 }
11459 }
11460 }
11461 Some("image_url") => {
11462 if let Some(url) = p
11463 .get("image_url")
11464 .and_then(|u| u.get("url"))
11465 .and_then(Value::as_str)
11466 {
11467 blocks.push(serde_json::json!({
11468 "type": "input_image",
11469 "image_url": url,
11470 }));
11471 }
11472 }
11473 _ => {}
11474 }
11475 }
11476 Value::Array(blocks)
11477 }
11478 None => {
11479 let text = msg.content.clone().unwrap_or_default();
11480 Value::Array(vec![serde_json::json!({"type": text_type, "text": text})])
11481 }
11482 }
11483}
11484
11485/// PARITY-11 (nested images, honest-residue side): a Codex
11486/// `function_call_output` response_item's `output` field is a BARE STRING
11487/// (real `codex-rs` protocol shape — unlike a `message` response_item, it has
11488/// no structured content array, so [`codex_message_content_blocks`]'s
11489/// `input_image` slot genuinely does not apply here). A nested image captured
11490/// off a Claude `tool_result` (`extract_tool_result_content`,
11491/// `content_parts`) therefore CANNOT be carried through this hop — but rather
11492/// than silently re-emitting the old bare `[image]` marker (indistinguishable
11493/// from a real, intentional annotation and impossible to tell apart from
11494/// "the data survived") or dropping it with zero trace, fold in an honest,
11495/// countable disclosure of exactly how many images were dropped and why —
11496/// same bracketed-note convention as [`UNCONVERTIBLE_IMAGE_MARKER`], applied
11497/// on the WRITE side instead of the read side. `content_parts` being `None`
11498/// (every pre-existing call site, and any tool result with no nested image)
11499/// reproduces the historical `msg.content` text byte-for-byte.
11500fn codex_tool_output_text(msg: &ChatMessage) -> String {
11501 let mut text = msg.content.clone().unwrap_or_default();
11502 if let Some(parts) = &msg.content_parts {
11503 let n = parts
11504 .iter()
11505 .filter(|p| p.get("type").and_then(Value::as_str) == Some("image_url"))
11506 .count();
11507 if n > 0 {
11508 if !text.is_empty() {
11509 text.push('\n');
11510 }
11511 text.push_str(&format!(
11512 "[image: {n} nested image(s) dropped — codex tool output has no \
11513 structured content slot to carry them]"
11514 ));
11515 }
11516 }
11517 text
11518}
11519
11520// ---- Pi writer helpers -----------------------------------------------------
11521
11522/// Emit the pi v3 `session` header line. Frozen (§1.1/§1.3): `version` is
11523/// ALWAYS 3, never a lower value, so pi never rewrites a supercode-emitted
11524/// file in place on first resume (`pi-fields.md` sm:848-850).
11525fn push_pi_header(
11526 out: &mut String,
11527 id: &str,
11528 cwd: &str,
11529 parent_session: Option<&str>,
11530 created_at: Option<&str>,
11531 claude_fork_context_ref: Option<&str>,
11532) {
11533 let mut header = serde_json::json!({
11534 "type": "session",
11535 "version": 3,
11536 "id": id,
11537 "timestamp": created_at.unwrap_or(SYNTH_TS),
11538 "cwd": cwd,
11539 });
11540 if let Some(ps) = parent_session {
11541 header["parentSession"] = Value::String(ps.to_string());
11542 }
11543 // D7: namespaced passthrough field, exactly like the Codex writer's
11544 // `session_meta.payload.claude_fork_context_ref` — pi tolerates unknown
11545 // header keys, and `capture_pi_header` reads this same key back on
11546 // import, so a Claude -> Pi -> Claude round trip still reconstructs the
11547 // fork-context-ref record instead of silently losing it on this hop.
11548 if let Some(raw) = claude_fork_context_ref {
11549 header["claude_fork_context_ref"] =
11550 serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()));
11551 }
11552 push_jsonl(out, &header);
11553}
11554
11555/// A fresh 8-hex entry id, collision-checked against `used` (mirrors pi's own
11556/// `randomUUID().slice(0,8)` + collision check, `pi-fields.md` sm:216-224 —
11557/// deterministic here rather than random, which still satisfies "fresh,
11558/// collision-free" without an extra RNG dependency).
11559fn pi_fresh_id(used: &mut HashSet<String>, counter: &mut u64) -> String {
11560 loop {
11561 *counter += 1;
11562 let h = counter.wrapping_mul(0x9E3779B97F4A7C15);
11563 let id = format!("{:08x}", (h >> 32) as u32);
11564 if used.insert(id.clone()) {
11565 return id;
11566 }
11567 }
11568}
11569
11570/// Parse a `data:<mime>[;base64],<data>` URI back into `(mime, data)` — the
11571/// inverse of the loader's `data:{mime};base64,{data}` construction.
11572fn parse_data_uri(url: &str) -> Option<(String, String)> {
11573 let rest = url.strip_prefix("data:")?;
11574 let (meta, data) = rest.split_once(',')?;
11575 let mime = meta.strip_suffix(";base64").unwrap_or(meta);
11576 Some((mime.to_string(), data.to_string()))
11577}
11578
11579/// Rebuild a pi `(TextContent|ImageContent)[]` (or bare string) content value
11580/// from a `ChatMessage`'s `content`/`content_parts` — shared by `user` and
11581/// `toolResult` entries (both use the identical union on the wire).
11582fn pi_content_value(msg: &ChatMessage) -> Value {
11583 if let Some(parts) = &msg.content_parts {
11584 let mut arr = Vec::new();
11585 for p in parts {
11586 match p.get("type").and_then(Value::as_str) {
11587 Some("text") => {
11588 if let Some(t) = p.get("text").and_then(Value::as_str) {
11589 arr.push(serde_json::json!({"type": "text", "text": t}));
11590 }
11591 }
11592 Some("image_url") => {
11593 if let Some(url) = p
11594 .get("image_url")
11595 .and_then(|u| u.get("url"))
11596 .and_then(Value::as_str)
11597 {
11598 if let Some((mime, data)) = parse_data_uri(url) {
11599 arr.push(
11600 serde_json::json!({"type": "image", "mimeType": mime, "data": data}),
11601 );
11602 }
11603 }
11604 }
11605 _ => {}
11606 }
11607 }
11608 Value::Array(arr)
11609 } else {
11610 Value::String(msg.content.clone().unwrap_or_default())
11611 }
11612}
11613
11614fn pi_assistant_content_value(msg: &ChatMessage) -> Value {
11615 let mut arr = Vec::new();
11616 if let Some(thinking) = msg.metadata.get("thinking") {
11617 let mut block = serde_json::json!({"type": "thinking", "thinking": thinking});
11618 if let Some(sig) = msg.metadata.get("thinking_signature") {
11619 block["thinkingSignature"] = Value::String(sig.clone());
11620 }
11621 if msg.metadata.get("pi_thinking_redacted").map(String::as_str) == Some("true") {
11622 block["redacted"] = Value::Bool(true);
11623 }
11624 arr.push(block);
11625 }
11626 if let Some(text) = &msg.content {
11627 if !text.is_empty() {
11628 let mut block = serde_json::json!({"type": "text", "text": text});
11629 if let Some(sig) = msg.metadata.get("pi_text_signature") {
11630 block["textSignature"] = Value::String(sig.clone());
11631 }
11632 arr.push(block);
11633 }
11634 }
11635 for tc in msg.tool_calls() {
11636 let args = tc
11637 .function
11638 .parsed_arguments()
11639 .unwrap_or_else(|_| Value::Object(Default::default()));
11640 let mut block = serde_json::json!({
11641 "type": "toolCall",
11642 "id": tc.id,
11643 "name": tc.function.name,
11644 "arguments": args,
11645 });
11646 if let Some(sig) = msg.metadata.get("pi_thought_signature") {
11647 block["thoughtSignature"] = Value::String(sig.clone());
11648 }
11649 arr.push(block);
11650 }
11651 Value::Array(arr)
11652}
11653
11654fn default_pi_usage() -> Value {
11655 serde_json::json!({
11656 "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0,
11657 "cost": {"input": 0.0, "output": 0.0, "cacheRead": 0.0, "cacheWrite": 0.0, "total": 0.0},
11658 })
11659}
11660
11661fn is_tool_error_flag(msg: &ChatMessage) -> bool {
11662 msg.metadata.get("pi_is_error").map(String::as_str) == Some("true") || crate::is_tool_error(msg)
11663}
11664
11665#[cfg(test)]
11666mod tests {
11667 use super::{opencode_message_timestamp, parent_tool_use_index, Session, SessionFormat};
11668 use crate::message::ChatMessage;
11669
11670 #[test]
11671 fn malformed_discriminated_native_turn_is_counted_as_parse_loss() {
11672 let base = Session::from_native_messages(Vec::new());
11673 let mut native = base.to_native_jsonl_v2(&[]);
11674 native.push_str("{\"supercode_turn\":1}\n");
11675
11676 let parsed = Session::from_native_str(&native).unwrap();
11677 assert_eq!(parsed.parse_error_lines, 1);
11678 assert!(parsed.messages.is_empty());
11679 assert_eq!(
11680 parsed.raw.last().map(String::as_str),
11681 Some("{\"supercode_turn\":1}")
11682 );
11683 }
11684
11685 #[test]
11686 fn spliced_export_refuses_a_native_wrapper_with_parse_loss() {
11687 let imported = Session::from_claude_code_str(
11688 r#"{"type":"user","sessionId":"s","cwd":"/tmp","message":{"role":"user","content":"hi"}}"#,
11689 )
11690 .unwrap();
11691 let mut native = imported.to_native_jsonl_v2(&[ChatMessage::assistant("continued")]);
11692 native.push_str("{\"supercode_turn\":1}\n");
11693
11694 let parsed = Session::from_native_str(&native).unwrap();
11695 let error = parsed
11696 .to_jsonl_spliced(SessionFormat::ClaudeCode, None)
11697 .unwrap_err();
11698 assert!(error.to_string().contains("parse loss"), "{error}");
11699 }
11700
11701 #[test]
11702 fn sidecar_loader_requires_a_supported_native_header() {
11703 for malformed in [
11704 "",
11705 "not-json\n",
11706 "{}\n",
11707 "{\"supercode_native\":2}\n",
11708 "{\"supercode_native\":99,\"source\":\"native\"}\n",
11709 ] {
11710 let error = Session::from_sidecar_str(malformed).unwrap_err();
11711 assert!(error.to_string().contains("sidecar header"), "{error}");
11712 }
11713 }
11714
11715 #[test]
11716 fn gemini_user_parts_preserve_text_media_and_response_order() {
11717 let session = Session::from_gemini_str(
11718 r#"{"sessionId":"11111111-1111-4111-8111-111111111111","projectHash":"p"}
11719{"type":"gemini","content":"calling","toolCalls":[{"id":"a","name":"read","args":{}},{"id":"b","name":"read","args":{}}]}
11720{"type":"user","content":[{"text":"before"},{"functionResponse":{"id":"b","name":"read","response":{"output":"B"}}},{"inlineData":{"mimeType":"image/png","data":"YQ=="}},{"functionResponse":{"name":"read","response":{"output":"A"}}},{"text":"after"}]}
11721"#,
11722 )
11723 .unwrap();
11724
11725 assert_eq!(session.messages.len(), 6);
11726 assert_eq!(
11727 session.messages[1].content_parts.as_ref().unwrap()[0]["text"],
11728 "before"
11729 );
11730 assert_eq!(session.messages[2].tool_call_id.as_deref(), Some("b"));
11731 assert!(
11732 session.messages[3].content_parts.as_ref().unwrap()[0]["image_url"]["url"]
11733 .as_str()
11734 .unwrap()
11735 .starts_with("data:image/png;base64,")
11736 );
11737 assert_eq!(session.messages[4].tool_call_id.as_deref(), Some("a"));
11738 assert_eq!(
11739 session.messages[5].content_parts.as_ref().unwrap()[0]["text"],
11740 "after"
11741 );
11742 }
11743
11744 #[test]
11745 fn opencode_synthetic_timestamp_fails_closed_at_i64_max() {
11746 let msg = ChatMessage::user("continuation");
11747 let mut cursor = i64::MAX - 1;
11748 assert_eq!(
11749 opencode_message_timestamp(&msg, &mut cursor).unwrap(),
11750 i64::MAX
11751 );
11752 let err = opencode_message_timestamp(&msg, &mut cursor).unwrap_err();
11753 assert!(err.to_string().contains("after i64::MAX"));
11754 assert_eq!(cursor, i64::MAX, "overflow must not wrap or mutate state");
11755 }
11756
11757 /// Pin of the single-pass indexer against the relevant Claude tool-result
11758 /// shape (SUP-21). An id absent from the transcript must map to nothing.
11759 #[test]
11760 fn parent_tool_use_index_matches_known_fixture_linkage() {
11761 let main_text = r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SYjhg9qRCzUWY2GTa3iazQ","type":"tool_result","content":[{"type":"text","text":"agentId: ad8dc6cf98b49eea6"}]}]},"toolUseResult":{"agentId":"ad8dc6cf98b49eea6"}}"#;
11762
11763 let ids = vec![
11764 "ad8dc6cf98b49eea6".to_string(),
11765 "no-such-agent-id".to_string(),
11766 ];
11767 let index = parent_tool_use_index(main_text, &ids);
11768
11769 assert_eq!(
11770 index.get("ad8dc6cf98b49eea6").map(String::as_str),
11771 Some("toolu_01SYjhg9qRCzUWY2GTa3iazQ"),
11772 "known agent id must resolve to the pinned parent tool_use_id"
11773 );
11774 assert_eq!(
11775 index.get("no-such-agent-id"),
11776 None,
11777 "unknown agent id must yield no entry (best-effort None)"
11778 );
11779 }
11780
11781 #[test]
11782 fn parent_tool_use_index_empty_ids_returns_empty_map() {
11783 let index = parent_tool_use_index("irrelevant text", &[]);
11784 assert!(index.is_empty());
11785 }
11786}