supercode/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::path::{Path, PathBuf};
36
37use rusqlite::Connection;
38use serde_json::Value;
39
40use crate::error::{Error, Result};
41use crate::fidelity::Fidelity;
42use crate::message::{ChatMessage, FunctionCall, Role, ToolCall};
43
44/// Which tool produced a session log.
45///
46/// This is **read-provenance**: a fact recovered when a log is loaded (stored
47/// in [`SessionMeta::source`], filled in by auto-detection in
48/// `detect_source`), describing which tool originally wrote the file on
49/// disk. It answers "where did this session come from?" — e.g. for
50/// `inspect`/`convert` display in the CLI.
51///
52/// It is deliberately distinct from [`SessionFormat`], even though the two
53/// enums' variant lists currently coincide: [`SessionFormat`] selects a
54/// serialization codec (what to parse/export *as*), while `SessionSource`
55/// records history (what wrote the file). The pair is intentionally kept
56/// separate rather than merged — a session loaded from one tool's log can
57/// still be exported in the other tool's format, and the two concepts could
58/// diverge further (e.g. a format that is readable but not attributable, or
59/// multiple versioned formats sharing one source).
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum SessionSource {
62 /// A `~/.claude/projects/.../<id>.jsonl` transcript.
63 ClaudeCode,
64 /// A `~/.codex/sessions/.../rollout-*.jsonl` file.
65 Codex,
66 /// An OpenCode session — multi-file JSON tree(s) or SQLite `opencode*.db`
67 /// (`docs/interop/opencode-pi-spec.md` §1.2). Detection and loading are
68 /// wave B; this variant exists now so `SessionSource`/`SessionFormat` stay
69 /// 1:1 per the frozen interop spec (§0).
70 OpenCode,
71 /// A `~/.pi/agent/sessions/--<enc-cwd>--/<iso>_<sessionId>.jsonl`
72 /// transcript (`docs/interop/opencode-pi-spec.md` §1.1) — line-oriented
73 /// JSONL like Claude Code/Codex, so it shares their byte-lossless native
74 /// round-trip property.
75 Pi,
76 /// A Grok session transcript stored as
77 /// `~/.grok/sessions/<percent-encoded-cwd>/<session-id>/chat_history.jsonl`.
78 Grok,
79 /// P5-3 safety-hardening fix (Fable-5 review, LOW "translation-fidelity
80 /// cosmetic"): a session that was never imported from ANY foreign
81 /// tool's log at all — authored directly by supercode's own agent loop,
82 /// with no foreign-tool prefix (`Session.raw` starts empty). Currently
83 /// only `crate::agent::Agent`'s `persist_subagent_transcript` (P5-3,
84 /// natively-spawned `spawn_subagent` children) uses this — before this
85 /// variant existed, that call site built its blank `Session` via
86 /// `Session::from_claude_code_str("")` purely as an "empty parser to
87 /// get a blank skeleton" trick, which left `meta.source ==
88 /// SessionSource::ClaudeCode` even though nothing Claude-Code-shaped
89 /// was ever involved, mislabeling a native supercode spawn as an
90 /// imported CC session on disk (and in any `inspect`/`convert` reading
91 /// it back). Never produced by auto-detection (`detect_source`) or any
92 /// `from_<tool>_str` loader — only by code that explicitly constructs
93 /// a `SessionMeta` with this source, so no existing imported-session
94 /// path can ever observe this variant appearing where it didn't before.
95 Native,
96}
97
98/// An on-disk session format supercode can both read and write.
99///
100/// Like an image editor that opens and exports several file formats, supercode
101/// keeps one canonical in-memory model ([`Session`]) and converts to/from each
102/// supported format on the edges.
103///
104/// This is a **write-target** / codec selector: a caller's request, passed to
105/// [`Session::load_str`], [`Session::to_jsonl`], and [`Session::save`],
106/// choosing which on-disk dialect to parse or emit. It answers "what format
107/// should I read/write?" — as opposed to [`SessionSource`], which records the
108/// provenance fact of what actually produced a loaded file. The two enums are
109/// intentionally kept separate (provenance fact vs. serialization choice) and
110/// should not be unified, even though their variants currently match
111/// one-to-one.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum SessionFormat {
114 /// Claude Code transcript JSONL.
115 ClaudeCode,
116 /// Codex rollout JSONL.
117 Codex,
118 /// OpenCode export-document / envelope JSONL (wave B; see
119 /// [`SessionSource::OpenCode`]).
120 OpenCode,
121 /// Pi session JSONL (see [`SessionSource::Pi`]).
122 Pi,
123 /// Grok `chat_history.jsonl` transcript.
124 Grok,
125}
126
127impl SessionFormat {
128 /// The [`SessionSource`] a file of this format reports.
129 ///
130 /// This is the deliberate one-way bridge between the two concepts: a file
131 /// saved in this format will, when reloaded, report this provenance (see
132 /// `crates/core/tests/session_saving.rs`), making the relationship
133 /// discoverable from the method itself.
134 pub fn source(self) -> SessionSource {
135 match self {
136 SessionFormat::ClaudeCode => SessionSource::ClaudeCode,
137 SessionFormat::Codex => SessionSource::Codex,
138 SessionFormat::OpenCode => SessionSource::OpenCode,
139 SessionFormat::Pi => SessionSource::Pi,
140 SessionFormat::Grok => SessionSource::Grok,
141 }
142 }
143}
144
145/// Metadata recovered from a session log.
146#[derive(Debug, Clone)]
147#[non_exhaustive]
148pub struct SessionMeta {
149 /// The tool that wrote the log.
150 pub source: SessionSource,
151 /// The session/rollout id.
152 pub session_id: Option<String>,
153 /// The model the session was running.
154 pub model: Option<String>,
155 /// The working directory the session ran in.
156 pub cwd: Option<PathBuf>,
157 /// The system / base-instructions prompt, when the log records it.
158 pub system_prompt: Option<String>,
159 /// Verbatim source-format header records (the Codex `session_meta` /
160 /// `turn_context` lines), preserved so re-export can replay the exact header
161 /// the original tool expects rather than guessing its required fields.
162 pub codex_headers: Vec<Value>,
163 /// Exact source lines for Codex execution/provenance records that affect
164 /// continuation semantics but must not be replayed as active events after
165 /// a foreign-format hop. Each entry records its original physical-line
166 /// index, discriminant, and verbatim JSONL text. Foreign writers carry the
167 /// list in a namespaced extension; a later Codex export restores headers
168 /// from it while keeping compaction/rollback/review records non-operative,
169 /// avoiding a second rollback or compaction of the already-normalized view.
170 pub codex_provenance: Vec<Value>,
171 /// The OpenCode analogue of [`Self::codex_headers`]
172 /// (`docs/interop/opencode-pi-spec.md` §1.2/§2.1): the verbatim
173 /// `SessionInfo` record (always element 0, or `Value::Null` if somehow
174 /// absent), plus any captured `session_diff`/`todo` side-records — each
175 /// wrapped as `{"key": [...], "value": ...}`, mirroring the envelope
176 /// shape `raw` uses, so a consumer can tell which storage key a header
177 /// record belongs to. These replay only via the direct-write fallback
178 /// (`Session::to_opencode_direct_write`); `opencode import` has no
179 /// ingestion path for `session_diff`/`todo` (S5).
180 pub opencode_headers: Vec<Value>,
181 /// For a Claude Code subagent session: its `agentId` (the `agent-<id>` file
182 /// stem). `None` for top-level sessions.
183 pub agent_id: Option<String>,
184 /// For a subagent session: the `tool_use_id` of the parent `Task` call that
185 /// spawned it, recovered from the parent transcript's tool result. Best
186 /// effort — `None` if the link could not be established.
187 pub parent_tool_use_id: Option<String>,
188 /// Cross-file lineage keys for multi-file/multi-agent sessions (Codex
189 /// `parent_thread_id`, `forked_from_id`, `thread_source`, and the
190 /// `source.subagent.thread_spawn` fields `agent_role` / `agent_nickname` /
191 /// `depth`). Empty for a plain top-level session. Used by
192 /// [`Session::reconstruct_tree`] to nest children under their parents.
193 pub lineage: std::collections::BTreeMap<String, String>,
194}
195
196impl SessionMeta {
197 fn new(source: SessionSource) -> Self {
198 SessionMeta {
199 source,
200 session_id: None,
201 model: None,
202 cwd: None,
203 system_prompt: None,
204 codex_headers: Vec::new(),
205 codex_provenance: Vec::new(),
206 opencode_headers: Vec::new(),
207 agent_id: None,
208 parent_tool_use_id: None,
209 lineage: std::collections::BTreeMap::new(),
210 }
211 }
212}
213
214/// A normalized, replayable conversation loaded from a tool's session log.
215#[derive(Debug, Clone)]
216pub struct Session {
217 /// Recovered metadata.
218 pub meta: SessionMeta,
219 /// The conversation, normalized to the OpenAI chat-completions shape.
220 pub messages: Vec<ChatMessage>,
221 /// Subagent (Task) sub-conversations. Claude Code stores these as separate
222 /// `<session>/subagents/agent-*.jsonl` files; loading a session by path now
223 /// discovers and attaches them here (each is a full [`Session`] whose
224 /// `meta.agent_id` / `meta.parent_tool_use_id` link it back to its spawn).
225 pub subagents: Vec<Session>,
226 /// Every original JSONL line of the source log, STRICT-VERBATIM (IX-1):
227 /// captured via [`split_lines_verbatim`], not the blank-skipping/trimming
228 /// [`non_empty_lines`] parse view, so a blank line, a CRLF (`\r\n`)
229 /// terminator, or trailing whitespace on a line all survive bit-for-bit
230 /// rather than being dropped/normalized away. Normalization into
231 /// `messages` is still lossy by design (it targets the OpenAI replay
232 /// shape), but these raw lines retain *everything* — including records
233 /// with no canonical representation (e.g. Claude `file-history-snapshot`)
234 /// — so a round-trip through the supercode-native format
235 /// ([`Session::to_native_jsonl`]) is byte-lossless for the line-oriented
236 /// formats (Claude Code/Codex/Pi), for ANY input (see
237 /// [`Self::raw_trailing_newline`] for the one piece of information a line
238 /// list alone can't carry).
239 pub raw: Vec<String>,
240 /// Whether the source text `raw` was captured from ended with a trailing
241 /// `\n`. `raw`'s line list alone can't distinguish a source ending with a
242 /// trailing newline from one that doesn't (both split into the same
243 /// lines) — this flag carries that fact out-of-band so
244 /// [`Self::to_native_jsonl`]/[`Self::from_native_str`] can reproduce the
245 /// original source bytes exactly, including the presence/absence of a
246 /// final newline. `true` for a `Session` whose `raw` isn't captured
247 /// verbatim from real source text (e.g. OpenCode's re-synthesized
248 /// export-document `raw`, or a `Session` assembled programmatically) —
249 /// matching the historical always-terminated-by-newline behavior for
250 /// those cases.
251 pub raw_trailing_newline: bool,
252 /// How many of `messages` (and, symmetrically, of `raw` — see below) came
253 /// from parsing the imported log, as opposed to being appended after
254 /// import. Set once, at the end of [`Self::from_claude_code_str`] /
255 /// [`Self::from_codex_str`], to `messages.len()` at that moment — i.e.
256 /// before [`Self::from_native_str`]'s subsequent loop reattaches any
257 /// appended [`crate::sidecar::NativeTurn`] records onto `messages`/`raw`.
258 /// That loop pushes exactly one `raw` line and one message per appended
259 /// turn, so the two lists grow in lockstep from here on: the raw-prefix
260 /// boundary A12's [`Self::to_jsonl_spliced`] needs is always recoverable
261 /// as `raw.len() - (messages.len() - imported_message_count)`, without a
262 /// second counter. `None` only when a `Session` is constructed some other
263 /// way than through those two loaders — splicing then has no boundary to
264 /// honor and treats every message as imported (equivalent to
265 /// `Some(messages.len())`).
266 pub imported_message_count: Option<usize>,
267 /// Whether `raw` was captured strict-verbatim from real source text
268 /// (`true`) or re-synthesized by this crate (`false`) — the fact
269 /// [`Self::raw_verbatim`]'s callers need to know before claiming a
270 /// same-format `convert` is byte-identical (PARITY-AUDIT.md P006/P007).
271 /// `true` for every line-oriented loader (`from_claude_code_str`,
272 /// `from_codex_str`, `from_pi_str`) and OpenCode's own ENVELOPE read
273 /// surface (`from_opencode_str`'s per-line loop) — each of those splits
274 /// `raw` directly out of the source text via `split_lines_verbatim`, so
275 /// replaying it reproduces the original bytes exactly. `false` for
276 /// OpenCode's EXPORT-DOCUMENT read surface
277 /// (`Session::from_opencode_export_doc`): a pretty-printed
278 /// `{info, messages:[...]}` document has no per-line envelope structure
279 /// of its own, so `raw` there is one envelope line RE-SYNTHESIZED per
280 /// record — faithful in value, but not the original document's bytes.
281 /// A `Session` assembled programmatically (not through a `from_*_str`
282 /// loader) also defaults to `false` — no real source text was captured
283 /// at all.
284 pub raw_is_verbatim: bool,
285 /// PARITY-15: how many non-empty lines of the source text FAILED to
286 /// deserialize at all (a genuinely malformed/truncated JSON line — not
287 /// a well-formed-but-unmodeled record type, which is a normal,
288 /// intentional "skip", tracked separately by `crate::audit`). Every
289 /// line-oriented loader tolerates a stray corrupt line rather than
290 /// hard-failing the whole load (a single bad line must not make an
291 /// otherwise-healthy multi-thousand-line session unloadable) — but that
292 /// tolerance used to be completely invisible: `Session::load` returned
293 /// `Ok` either way, with no signal that anything was skipped. This
294 /// count is what lets a caller (the CLI, `inspect`/`convert`) surface
295 /// that loss loudly instead of silently. `0` for a cleanly-parsed file,
296 /// and for a `Session` assembled programmatically.
297 pub parse_error_lines: usize,
298 /// Named degradations a [`Fidelity::Semantic`] load accepted instead of
299 /// failing — the same "say exactly what was given up" residue list
300 /// `harness.v1.sessions.export` already reports for artifacts.
301 ///
302 /// ALWAYS empty for a lossless load: every stricter fidelity refuses a
303 /// transcript it cannot reconstruct exactly, which is what keeps
304 /// continuation/transfer/export guarantees intact. A non-empty list means
305 /// this session is a read-only VIEW ([`Session::load_with_fidelity`] with
306 /// [`Fidelity::Semantic`]) and must not be used as a continuation source.
307 pub load_residue: Vec<String>,
308}
309
310impl Session {
311 /// The fidelity this reconstruction actually achieved.
312 ///
313 /// Same rule the export path applies to an artifact: named residue means
314 /// [`Fidelity::Semantic`]; otherwise a verbatim source capture is
315 /// [`Fidelity::ByteLossless`] and a re-synthesized one is
316 /// [`Fidelity::ValueLossless`]. A subagent's residue counts as this
317 /// session's: the whole reconstruction is only as faithful as its least
318 /// faithful part, and each child still reports its own residue where it
319 /// was measured.
320 pub fn load_fidelity(&self) -> Fidelity {
321 let own = if !self.load_residue.is_empty() {
322 Fidelity::Semantic
323 } else if self.raw_is_verbatim {
324 Fidelity::ByteLossless
325 } else {
326 Fidelity::ValueLossless
327 };
328 if own != Fidelity::Semantic
329 && self
330 .subagents
331 .iter()
332 .any(|subagent| subagent.load_fidelity() == Fidelity::Semantic)
333 {
334 return Fidelity::Semantic;
335 }
336 own
337 }
338
339 /// Assemble a session from supercode's own flat store transcript (one
340 /// [`ChatMessage`] per JSONL line). These files are the native working
341 /// format written by [`crate::store::SessionStore`], not a foreign
342 /// harness log, so routing them through format auto-detection would
343 /// misclassify them as an empty Claude Code session.
344 pub fn from_native_messages(messages: Vec<ChatMessage>) -> Session {
345 Session {
346 meta: SessionMeta::new(SessionSource::Native),
347 messages,
348 subagents: Vec::new(),
349 raw: Vec::new(),
350 raw_trailing_newline: true,
351 imported_message_count: None,
352 raw_is_verbatim: false,
353 parse_error_lines: 0,
354 load_residue: Vec::new(),
355 }
356 }
357
358 /// Load a session, auto-detecting whether it's a Claude Code or Codex log
359 /// — or, when `path` looks like a SQLite database, a real OpenCode
360 /// `opencode*.db` store (PARITY-3/PARITY-16): that check runs BEFORE any
361 /// UTF-8 text read, so a binary `.db` file is routed to
362 /// [`Self::from_opencode_sqlite`] instead of failing on a raw "stream
363 /// did not contain valid UTF-8" error (the confirmed footgun these items
364 /// close — see [`looks_like_sqlite`] / [`read_utf8_or_diagnose`]).
365 ///
366 /// A DIRECTORY is also accepted directly: `path` is probed with
367 /// [`detect_opencode_storage_surface`] BEFORE the SQLite/UTF-8 file
368 /// checks below (both of which assume a file and would otherwise surface
369 /// a cryptic "Is a directory" `io::Error` — the confirmed footgun this
370 /// closes). This lets `inspect`/`convert`/`resume` accept an OpenCode
371 /// DATA-ROOT directly (e.g. `~/.local/share/opencode`), matching what
372 /// `audit --format opencode` already does. A resolved `Sqlite` surface
373 /// loads exactly like pointing `load` at that `opencode*.db` file
374 /// directly (most-recently-updated top-level session). The legacy
375 /// `JsonTreeA`/`JsonTreeB` surfaces are classifier-only (see
376 /// [`OpenCodeStorageSurface`]) — there's no direct-JSON-tree loader, so
377 /// that case returns a clear error naming the `.db` file / `audit` as the
378 /// way in, rather than silently doing nothing or crashing.
379 pub fn load(path: impl AsRef<Path>) -> Result<Session> {
380 Self::load_with_fidelity(path, Fidelity::ByteLossless)
381 }
382
383 /// Load a session at a declared [`Fidelity`].
384 ///
385 /// [`Fidelity::Semantic`] is the READ-ONLY VIEW mode: a transcript whose
386 /// record graph cannot be reconstructed exactly (the everyday case for a
387 /// Claude Code session that has been compacted or resumed across files,
388 /// where a live record's `parentUuid` names a record that was pruned)
389 /// still loads, stitched best-effort in transcript order, and names what
390 /// it gave up in [`Session::load_residue`]. Every stricter level keeps
391 /// the historical behavior — refuse loudly — because a continuation,
392 /// transfer or export built on a guessed graph is exactly the loss
393 /// supercode exists to prevent. Callers that go on to RESUME a session
394 /// must therefore use [`Session::load`].
395 pub fn load_with_fidelity(path: impl AsRef<Path>, fidelity: Fidelity) -> Result<Session> {
396 let path = path.as_ref();
397 if path.is_dir() {
398 return match detect_opencode_storage_surface(path) {
399 Some((OpenCodeStorageSurface::Sqlite, db_path)) => {
400 Self::from_opencode_sqlite(&db_path, None)
401 }
402 Some((
403 OpenCodeStorageSurface::JsonTreeA | OpenCodeStorageSurface::JsonTreeB,
404 _,
405 )) => Err(crate::Error::Other(format!(
406 "{} is an OpenCode data root using a legacy JSON storage tree, which \
407 supercode does not load directly — point `inspect`/`convert`/`resume` \
408 at the store's `opencode*.db` SQLite file if this install has one, or \
409 use `audit --format opencode {}` instead",
410 path.display(),
411 path.display()
412 ))),
413 None => Err(crate::Error::Other(format!(
414 "{} is a directory, but no session file or OpenCode store was found in it \
415 (expected an `opencode*.db` SQLite file, or an OpenCode legacy JSON storage \
416 tree)",
417 path.display()
418 ))),
419 };
420 }
421 if looks_like_sqlite(path) {
422 return Self::from_opencode_sqlite(path, None);
423 }
424 let text = read_utf8_or_diagnose(path)?;
425 match detect_source(&text) {
426 Some(SessionSource::Codex) => Self::from_codex_str(&text),
427 Some(SessionSource::Pi) => Self::from_pi_str(&text),
428 Some(SessionSource::Grok) => {
429 let mut session = Self::from_grok_str(&text)?;
430 session.capture_grok_path_metadata(path);
431 Ok(session)
432 }
433 // IX-3: a detected OpenCode session must route to its own
434 // loader, not the Claude Code fallback below
435 // (`docs/interop/build-followups.md`).
436 Some(SessionSource::OpenCode) => Self::from_opencode_str(&text),
437 _ => {
438 let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
439 session.attach_claude_subagents(path, &text, fidelity)?;
440 Ok(session)
441 }
442 }
443 }
444
445 /// Load a Claude Code transcript from a file, attaching any subagent
446 /// (`Task`) sub-conversations stored alongside it.
447 pub fn from_claude_code(path: impl AsRef<Path>) -> Result<Session> {
448 Self::from_claude_code_with_fidelity(path, Fidelity::ByteLossless)
449 }
450
451 /// [`Session::from_claude_code`] at a declared [`Fidelity`] — see
452 /// [`Session::load_with_fidelity`] for what [`Fidelity::Semantic`] buys.
453 pub fn from_claude_code_with_fidelity(
454 path: impl AsRef<Path>,
455 fidelity: Fidelity,
456 ) -> Result<Session> {
457 let text = std::fs::read_to_string(path.as_ref())?;
458 let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
459 session.attach_claude_subagents(path.as_ref(), &text, fidelity)?;
460 Ok(session)
461 }
462
463 /// Discover and load `<session>/subagents/agent-*.jsonl` files for a
464 /// Claude Code transcript at `main_path`, linking each back to the parent
465 /// `Task` tool call via the agent id embedded in the parent's tool result.
466 fn attach_claude_subagents(
467 &mut self,
468 main_path: &Path,
469 main_text: &str,
470 fidelity: Fidelity,
471 ) -> Result<()> {
472 let Some(dir) = subagents_dir_for(main_path) else {
473 return Ok(());
474 };
475 let entries = std::fs::read_dir(&dir).map_err(|error| {
476 crate::Error::Other(format!(
477 "failed to enumerate Claude subagents at {}: {error}",
478 dir.display()
479 ))
480 })?;
481 let mut files = Vec::new();
482 for entry in entries {
483 let entry = entry.map_err(|error| {
484 crate::Error::Other(format!(
485 "failed to enumerate Claude subagents at {}: {error}",
486 dir.display()
487 ))
488 })?;
489 let path = entry.path();
490 if path.extension().and_then(|extension| extension.to_str()) == Some("jsonl") {
491 files.push(path);
492 }
493 }
494 files.sort();
495
496 // Phase 1 — collect each subagent + its recovered agent id, without
497 // touching the main transcript yet.
498 let mut collected: Vec<(Session, Option<String>)> = Vec::with_capacity(files.len());
499 for file in files {
500 let text = read_utf8_or_diagnose(&file).map_err(|error| {
501 crate::Error::Other(format!(
502 "failed to read Claude subagent {}: {error}",
503 file.display()
504 ))
505 })?;
506 let sub = match Self::from_claude_code_str_with_fidelity(&text, fidelity) {
507 Ok(sub) => sub,
508 // A read-only VIEW keeps the main conversation rather than
509 // losing the whole session to one unreconstructable child;
510 // the skip is named, not silent. Every stricter fidelity
511 // still propagates the child's failure.
512 Err(error) if fidelity.tolerates_residue() => {
513 self.load_residue.push(format!(
514 "Claude subagent {} could not be reconstructed ({error}); it was omitted from this view",
515 file.display()
516 ));
517 continue;
518 }
519 Err(error) => {
520 return Err(crate::Error::Other(format!(
521 "failed to reconstruct Claude subagent {}: {error}",
522 file.display()
523 )))
524 }
525 };
526 // agentId: prefer the file's own record, fall back to the filename stem.
527 let agent_id = first_agent_id(&text).or_else(|| {
528 file.file_stem()
529 .and_then(|s| s.to_str())
530 .map(|s| s.trim_start_matches("agent-").to_string())
531 });
532 collected.push((sub, agent_id));
533 }
534
535 // Phase 2 — single pass over the main transcript to index every
536 // requested agent id at once, then assign each subagent's parent by
537 // an O(1) lookup.
538 let agent_ids: Vec<String> = collected.iter().filter_map(|(_, id)| id.clone()).collect();
539 let index = parent_tool_use_index(main_text, &agent_ids);
540
541 for (mut sub, agent_id) in collected {
542 sub.meta.parent_tool_use_id = agent_id.as_ref().and_then(|id| index.get(id).cloned());
543 sub.meta.agent_id = agent_id;
544 self.subagents.push(sub);
545 }
546 Ok(())
547 }
548
549 /// Load a Codex rollout from a file.
550 pub fn from_codex(path: impl AsRef<Path>) -> Result<Session> {
551 Self::from_codex_str(&std::fs::read_to_string(path.as_ref())?)
552 }
553
554 /// Parse a Claude Code transcript from an in-memory JSONL string.
555 pub fn from_claude_code_str(jsonl: &str) -> Result<Session> {
556 Self::from_claude_code_str_with_fidelity(jsonl, Fidelity::ByteLossless)
557 }
558
559 /// [`Session::from_claude_code_str`] at a declared [`Fidelity`] — see
560 /// [`Session::load_with_fidelity`] for what [`Fidelity::Semantic`] buys.
561 pub fn from_claude_code_str_with_fidelity(jsonl: &str, fidelity: Fidelity) -> Result<Session> {
562 let mut meta = SessionMeta::new(SessionSource::ClaudeCode);
563 let mut messages = Vec::new();
564 // IX-1: `raw` is captured STRICT-VERBATIM (blank lines, CRLF, trailing
565 // whitespace all preserved) — separate from the blank-skipping
566 // `non_empty_lines` walk just below, which still parses records only
567 // (a blank line is not a JSON record and must not become one).
568 let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
569 let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
570 // PARITY-15: a malformed/truncated line is still tolerated (a
571 // single bad line must not make an otherwise-healthy multi-
572 // thousand-line session unloadable) — but it's no longer INVISIBLE.
573 let mut parse_error_lines = 0usize;
574 let mut index = ClaudeReplayIndex::default();
575
576 // Claude transcripts are append-only trees, not linear chat logs.
577 // Build a lightweight graph index first so normalization sees the
578 // same single active, post-compaction branch Claude Code would
579 // resume. `raw` above deliberately remains the complete source.
580 for (line_index, line) in raw_lines.iter().enumerate() {
581 if line.trim().is_empty() {
582 continue;
583 }
584 let v: Value = match serde_json::from_str(line) {
585 Ok(v) => v,
586 Err(_) => {
587 parse_error_lines += 1; // tolerate stray/corrupt lines
588 continue;
589 }
590 };
591 capture_claude_meta(&v, &mut meta, line)?;
592 index.observe(line_index, &v)?;
593 }
594
595 let ClaudeReplaySelection {
596 lines: replay_lines,
597 residue: load_residue,
598 } = index.select_lines(fidelity)?;
599 let mut pending_assistant: Option<Value> = None;
600
601 for line_index in replay_lines {
602 let line = raw_lines[line_index];
603 let v: Value = serde_json::from_str(line).map_err(crate::Error::Decode)?;
604
605 if v.get("type").and_then(Value::as_str) == Some("assistant") {
606 if v.get("isApiErrorMessage").and_then(Value::as_bool) == Some(true) {
607 flush_claude_assistant(&mut pending_assistant, &mut messages);
608 continue;
609 }
610 if let Some(pending) = pending_assistant.as_mut() {
611 if claude_assistant_message_id(pending).is_some_and(|message_id| {
612 claude_assistant_message_id(&v) == Some(message_id)
613 }) {
614 merge_claude_assistant_chunk(pending, &v);
615 continue;
616 }
617 flush_claude_assistant(&mut pending_assistant, &mut messages);
618 }
619 pending_assistant = Some(v);
620 continue;
621 }
622
623 flush_claude_assistant(&mut pending_assistant, &mut messages);
624
625 // WAVE-2 item 1: every Claude Code record carries a real
626 // top-level `timestamp` (ISO-8601) — provenance stamping below
627 // attaches it to every canonical `ChatMessage` this line
628 // produces, together with the record UUID and assistant model.
629 // `entry(...).or_insert_with` preserves any more-precise value a
630 // role-specific loader already supplied.
631 let before = messages.len();
632 match v.get("type").and_then(Value::as_str) {
633 Some("user") => push_claude_user(&v, &mut messages),
634 Some("assistant") => push_claude_assistant(&v, &mut messages),
635 Some("attachment") => push_claude_attachment(&v, &mut messages),
636 Some("system") => push_claude_system(&v, &mut messages),
637 _ => {} // mode, queue-operation, ... — skip
638 }
639 // UUID/model provenance remains meaningful even for legacy
640 // records that predate Claude Code's timestamp field.
641 capture_claude_record_provenance(&v, &mut messages[before..]);
642 restore_single_grok_message(&v, &mut messages[before..]);
643 }
644 flush_claude_assistant(&mut pending_assistant, &mut messages);
645
646 reorder_tool_results_after_calls(&mut messages);
647 ensure_tool_results_paired(&mut messages);
648 let imported_message_count = Some(messages.len());
649 Ok(Session {
650 meta,
651 messages,
652 subagents: Vec::new(),
653 raw,
654 raw_trailing_newline,
655 imported_message_count,
656 // Claude Code is line-oriented: `raw` is split directly out of
657 // the source text (strict-verbatim, IX-1).
658 raw_is_verbatim: true,
659 parse_error_lines,
660 load_residue,
661 })
662 }
663
664 /// Reconstruct multi-file subagent trees from a flat set of loaded sessions.
665 ///
666 /// Codex stores subagents as separate rollout files linked to their parent
667 /// by `lineage["parent_thread_id"]` (→ the parent's `session_id`). Given a
668 /// collection of sessions, this nests each child into its parent's
669 /// [`Session::subagents`] and returns only the roots. Children whose parent
670 /// isn't in the set are returned as roots themselves (best effort).
671 pub fn reconstruct_tree(sessions: Vec<Session>) -> Vec<Session> {
672 use std::collections::HashMap;
673 // Index each session's position by its session_id.
674 let mut idx: HashMap<String, usize> = HashMap::new();
675 for (i, s) in sessions.iter().enumerate() {
676 if let Some(id) = &s.meta.session_id {
677 idx.insert(id.clone(), i);
678 }
679 }
680 // Determine each session's parent (by index), if present in the set.
681 let parent_of: Vec<Option<usize>> = sessions
682 .iter()
683 .map(|s| {
684 s.meta
685 .lineage
686 .get("parent_thread_id")
687 .and_then(|p| idx.get(p).copied())
688 })
689 .collect();
690
691 // Move children into parents, deepest-first so chains nest correctly.
692 let mut slots: Vec<Option<Session>> = sessions.into_iter().map(Some).collect();
693 let mut order: Vec<usize> = (0..slots.len()).collect();
694 order.sort_by_key(|&i| std::cmp::Reverse(depth_of(i, &parent_of)));
695 for i in order {
696 if let Some(p) = parent_of[i] {
697 if p != i {
698 if let Some(child) = slots[i].take() {
699 if let Some(parent) = slots[p].as_mut() {
700 parent.subagents.push(child);
701 } else {
702 slots[i] = Some(child); // parent already moved; keep as root
703 }
704 }
705 }
706 }
707 }
708 slots.into_iter().flatten().collect()
709 }
710
711 /// Parse a session of a known format from an in-memory JSONL string.
712 pub fn load_str(jsonl: &str, format: SessionFormat) -> Result<Session> {
713 match format {
714 SessionFormat::ClaudeCode => Self::from_claude_code_str(jsonl),
715 SessionFormat::Codex => Self::from_codex_str(jsonl),
716 SessionFormat::Pi => Self::from_pi_str(jsonl),
717 SessionFormat::OpenCode => Self::from_opencode_str(jsonl),
718 SessionFormat::Grok => Self::from_grok_str(jsonl),
719 }
720 }
721
722 /// Serialize this session to JSONL in the given format.
723 ///
724 /// The conversation is synthesized from the canonical messages, so this
725 /// works for sessions loaded from *either* tool as well as ones supercode
726 /// built itself. Converting between formats (e.g. Codex → Claude Code) is an
727 /// "export": format-specific framing that has no slot in the target may be
728 /// dropped, but the user/assistant/tool conversation is preserved.
729 pub fn to_jsonl(&self, format: SessionFormat) -> Result<String> {
730 match format {
731 SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl()),
732 SessionFormat::Codex => Ok(self.to_codex_jsonl()),
733 SessionFormat::Pi => Ok(self.to_pi_jsonl()),
734 SessionFormat::OpenCode => self.to_opencode_jsonl(),
735 SessionFormat::Grok => Ok(self.to_grok_jsonl()),
736 }
737 }
738
739 /// Export back to `format`, replaying the imported `raw` prefix
740 /// **verbatim** — original uuids/ids, real timestamps, and
741 /// loader-skipped records (e.g. Claude Code `file-history-snapshot`) that
742 /// [`Self::to_jsonl`]'s full synthesis discards or fakes — when `format`
743 /// is the session's own origin (`format.source() == self.meta.source`,
744 /// see [`SessionFormat::source`]) and there is a `raw` prefix to replay.
745 /// Only messages appended *after* import (tracked by
746 /// [`Self::imported_message_count`]) are synthesized, chained onto the
747 /// last original record found in the raw prefix.
748 ///
749 /// `session_id` of `Some(new)` rewrites the session id on every emitted
750 /// line, raw and synthesized alike (`sessionId` for Claude Code,
751 /// `session_meta.payload.id` for Codex); `None` leaves ids as recorded.
752 ///
753 /// Cross-format export (no verbatim prefix exists in the target dialect,
754 /// by definition) and a session with no `raw` lines both fall back
755 /// unchanged to [`Self::to_jsonl`] — full synthesis, same output as
756 /// today. A12 (SPEC.md §6): this turns "export back to origin" from
757 /// *semantic* to *near-byte* fidelity for the dominant hop-back case;
758 /// cross-format stays at the documented semantic tier.
759 pub fn to_jsonl_spliced(
760 &self,
761 format: SessionFormat,
762 session_id: Option<&str>,
763 ) -> Result<String> {
764 if self.parse_error_lines > 0
765 || self
766 .subagents
767 .iter()
768 .any(|subagent| subagent.parse_error_lines > 0)
769 {
770 return Err(Error::InvalidSession(
771 "refusing spliced export because the loaded session contains parse loss"
772 .to_string(),
773 ));
774 }
775 if self.raw.is_empty() || format.source() != self.meta.source {
776 if let Some(session_id) = session_id {
777 let mut rewritten = self.clone();
778 rewritten.meta.session_id = Some(session_id.to_string());
779 return rewritten.to_jsonl(format);
780 }
781 return self.to_jsonl(format);
782 }
783 match format {
784 SessionFormat::ClaudeCode => Ok(self.to_claude_code_jsonl_spliced(session_id)),
785 SessionFormat::Codex => Ok(self.to_codex_jsonl_spliced(session_id)),
786 SessionFormat::Pi => self.to_pi_jsonl_spliced(session_id),
787 SessionFormat::OpenCode => self.to_opencode_jsonl_spliced(session_id),
788 SessionFormat::Grok => Ok(self.to_grok_jsonl_spliced()),
789 }
790 }
791
792 /// Write this session to `path` in the given format.
793 pub fn save(&self, path: impl AsRef<Path>, format: SessionFormat) -> Result<()> {
794 std::fs::write(path.as_ref(), self.to_jsonl(format)?)?;
795 Ok(())
796 }
797
798 /// Reconstruct the exact source bytes this `Session` was loaded from,
799 /// out of [`Self::raw`] + [`Self::raw_trailing_newline`] (the exact
800 /// inverse of the strict-verbatim capture those two fields record — see
801 /// [`join_lines_verbatim`]).
802 ///
803 /// For a genuinely line-oriented source (Claude Code, Codex, Pi, and an
804 /// OpenCode *envelope*-form JSONL), `raw` is captured verbatim from the
805 /// original text, so this reproduces the original file byte-for-byte —
806 /// the P008/P009 diagonal-convert fix (`convert <file> --to
807 /// <same-format>` is byte-identical to `<file>`) is built on exactly
808 /// this. The one documented exception is an OpenCode **export-document**
809 /// source (a single pretty-printed JSON value, not JSONL): `raw` there
810 /// is RE-SYNTHESIZED as one envelope line per record (see
811 /// [`Self::from_opencode_export_doc`]'s doc comment), so this returns a
812 /// verbatim reproduction of THAT captured representation rather than the
813 /// original pretty-printed document — a known, narrow residue, not a
814 /// silent loss (the same records are all still present).
815 pub fn raw_verbatim(&self) -> String {
816 join_lines_verbatim(&self.raw, self.raw_trailing_newline)
817 }
818
819 /// Serialize to the **supercode-native** lossless format: a header line
820 /// recording the original source, followed by every original JSONL line
821 /// verbatim. Unlike [`Self::to_jsonl`] (which targets a foreign tool's
822 /// schema and is necessarily lossy), this preserves *everything* — including
823 /// records with no canonical representation — so [`Self::from_native_str`]
824 /// reconstructs the session with full fidelity.
825 pub fn to_native_jsonl(&self) -> String {
826 let source = match self.meta.source {
827 SessionSource::ClaudeCode => "claude_code",
828 SessionSource::Codex => "codex",
829 SessionSource::Pi => "pi",
830 SessionSource::OpenCode => "opencode",
831 SessionSource::Grok => "grok",
832 // P5-3 safety-hardening fix: a natively-spawned session must
833 // never be written to disk labeled as an imported CC session.
834 SessionSource::Native => "native",
835 };
836 let header = serde_json::json!({
837 "supercode_native": 1,
838 "source": source,
839 // IX-1: carries whether the ORIGINAL imported source text ended
840 // with a trailing newline — `from_native_str` needs this to
841 // reconstruct the exact source bytes (not just the `raw` line
842 // list) when re-parsing the body with the per-source loader.
843 "raw_trailing_newline": self.raw_trailing_newline,
844 })
845 .to_string();
846 let mut out =
847 String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
848 out.push_str(&header);
849 out.push('\n');
850 for line in &self.raw {
851 out.push_str(line);
852 out.push('\n');
853 }
854 out
855 }
856
857 /// Serialize to the **supercode-native v2** format: the same imported-body
858 /// mechanism as [`Self::to_native_jsonl`] (a versioned header line
859 /// followed by every `Session.raw` line verbatim), plus one
860 /// [`crate::sidecar::NativeTurn`] record per message in `appended` — turns
861 /// produced after import, which have no backing `raw` line of their own.
862 /// `NativeTurn` carries `metadata` in full (unlike `ChatMessage`'s wire
863 /// serde), so nothing the live agent loop records is lost to disk.
864 ///
865 /// `appended` is caller-supplied rather than inferred from
866 /// `self.messages`: A1 doesn't track which of `self.messages` came from
867 /// import vs. the live loop — that bookkeeping belongs to the live writer
868 /// built on top of this (A2/A3).
869 pub fn to_native_jsonl_v2(&self, appended: &[ChatMessage]) -> String {
870 self.to_native_jsonl_v2_with_timestamp(appended, None)
871 }
872
873 pub(crate) fn to_native_jsonl_v2_with_timestamp(
874 &self,
875 appended: &[ChatMessage],
876 fixed_timestamp: Option<&str>,
877 ) -> String {
878 let source = match self.meta.source {
879 SessionSource::ClaudeCode => "claude_code",
880 SessionSource::Codex => "codex",
881 SessionSource::Pi => "pi",
882 SessionSource::OpenCode => "opencode",
883 SessionSource::Grok => "grok",
884 // P5-3 safety-hardening fix: a natively-spawned session must
885 // never be written to disk labeled as an imported CC session.
886 SessionSource::Native => "native",
887 };
888 // P5-3 (§2 module 9, §5.2 P5 row 3 "native write side — store
889 // already parses CC sidechains + CX lineage on import"): a
890 // natively-spawned subagent's own `Session` carries its lineage on
891 // `SessionMeta` (`agent_id`/`parent_tool_use_id`/`lineage`) — before
892 // this, `to_native_jsonl_v2` never wrote any of the three to disk at
893 // all, so a native-spawned child's lineage was lost the instant it
894 // round-tripped through a sidecar. Emitted only when non-empty/`Some`
895 // (`skip_serializing_if`-equivalent via manual omission below) so a
896 // plain top-level session's header is byte-identical to before this
897 // change.
898 let mut header_obj = serde_json::json!({
899 "supercode_native": 2,
900 "source": source,
901 "session_id": self.meta.session_id,
902 "created": fixed_timestamp
903 .map(ToOwned::to_owned)
904 .unwrap_or_else(crate::sidecar::now_rfc3339),
905 // IX-1: see `to_native_jsonl`'s header field of the same name.
906 "raw_trailing_newline": self.raw_trailing_newline,
907 });
908 if let Some(obj) = header_obj.as_object_mut() {
909 if let Some(agent_id) = &self.meta.agent_id {
910 obj.insert("agent_id".to_string(), Value::String(agent_id.clone()));
911 }
912 if let Some(parent_tool_use_id) = &self.meta.parent_tool_use_id {
913 obj.insert(
914 "parent_tool_use_id".to_string(),
915 Value::String(parent_tool_use_id.clone()),
916 );
917 }
918 if !self.meta.lineage.is_empty() {
919 obj.insert(
920 "lineage".to_string(),
921 serde_json::to_value(&self.meta.lineage).unwrap_or(Value::Null),
922 );
923 }
924 }
925 let header = header_obj.to_string();
926 let mut out =
927 String::with_capacity(self.raw.iter().map(|l| l.len() + 1).sum::<usize>() + 64);
928 out.push_str(&header);
929 out.push('\n');
930 for line in &self.raw {
931 out.push_str(line);
932 out.push('\n');
933 }
934 for (turn_index, msg) in appended.iter().enumerate() {
935 let turn = match fixed_timestamp {
936 Some(timestamp) => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
937 msg,
938 timestamp.to_string(),
939 turn_index as u64,
940 ),
941 None => crate::sidecar::NativeTurn::from_with_timestamp_and_index(
942 msg,
943 crate::sidecar::now_rfc3339(),
944 turn_index as u64,
945 ),
946 };
947 out.push_str(&serde_json::to_string(&turn).unwrap_or_default());
948 out.push('\n');
949 }
950 out
951 }
952
953 /// Parse the supercode-native format produced by [`Self::to_native_jsonl`]
954 /// / [`Self::to_native_jsonl_v2`]. A v1 file (no appended turns) parses
955 /// exactly as before. A v2 file's appended `NativeTurn` records —
956 /// discriminated by the `supercode_turn` key, which never appears in a v1
957 /// body — are split out before the imported body is handed to the
958 /// per-source loader, then reattached in file order: to `messages` (via
959 /// [`crate::sidecar::NativeTurn::into_message`]) and to `raw` (verbatim),
960 /// so a v2 file round-trips byte-for-byte through
961 /// [`Self::to_native_jsonl_v2`] again.
962 pub fn from_native_str(jsonl: &str) -> Result<Session> {
963 // IX-1: the native WRAPPER's own lines are split verbatim (not via
964 // the blank-skipping `non_empty_lines`) so that any `raw` line it
965 // carries — which can itself be blank, CRLF-terminated, or
966 // whitespace-padded, now that raw-capture is strict-verbatim —
967 // survives being embedded in (and re-extracted from) this wrapper
968 // bit-for-bit. The wrapper we ourselves emit never has a blank line
969 // of its own (`to_native_jsonl(_v2)` always writes one well-formed
970 // record per line), so this is a behavior-preserving switch for any
971 // native text this crate produced; it also makes a hand-fed/legacy
972 // native string tolerated exactly as `non_empty_lines` used to.
973 let (all_lines, _wrapper_trailing_newline) = split_lines_verbatim(jsonl);
974 let mut lines = all_lines.into_iter();
975 let header = lines.next().unwrap_or("");
976 let hv: Value = serde_json::from_str(header).unwrap_or(Value::Null);
977 let source = hv.get("source").and_then(Value::as_str);
978 // IX-1: whether the ORIGINAL imported source (before it was wrapped
979 // in this native format) ended with a trailing newline — a property
980 // of the pre-wrap source, not of this wrapper (which always
981 // LF-terminates every line it writes, regardless). Missing on a
982 // native file written before IX-1 (or a hand-built header in an
983 // older test/sidecar) — default `true`, the historical
984 // always-newline-terminated assumption.
985 let raw_trailing_newline = hv
986 .get("raw_trailing_newline")
987 .and_then(Value::as_bool)
988 .unwrap_or(true);
989
990 // Split appended NativeTurn records (v2) out of the imported body. A
991 // v1 body never carries a `supercode_turn` key, so this is a no-op
992 // there — one code path serves both versions.
993 let mut body_lines: Vec<String> = Vec::new();
994 let mut turn_lines: Vec<&str> = Vec::new();
995 for line in lines {
996 let is_turn = serde_json::from_str::<Value>(line)
997 .ok()
998 .is_some_and(|v| v.get("supercode_turn").is_some());
999 if is_turn {
1000 turn_lines.push(line);
1001 } else {
1002 body_lines.push(line.to_string());
1003 }
1004 }
1005 // IX-1: reconstruct the ORIGINAL imported source bytes exactly —
1006 // `body_lines.join("\n")` alone would silently gain a trailing
1007 // newline the original source never had (or lose one it did have).
1008 let body = join_lines_verbatim(&body_lines, raw_trailing_newline);
1009
1010 // The remaining lines are the original log; re-parse with the right loader.
1011 let mut session = match source {
1012 Some("codex") => Self::from_codex_str(&body)?,
1013 Some("claude_code") => Self::from_claude_code_str(&body)?,
1014 Some("pi") => Self::from_pi_str(&body)?,
1015 Some("opencode") => Self::from_opencode_str(&body)?,
1016 Some("grok") => Self::from_grok_str(&body)?,
1017 // P5-3 safety-hardening fix: a natively-spawned session's body
1018 // is always empty (it never had any foreign-tool prefix to
1019 // begin with — see `SessionSource::Native`'s doc comment), so
1020 // any loader would parse it identically; `from_claude_code_str`
1021 // is reused purely as a blank-skeleton builder (empty
1022 // `raw`/`messages`), then its `meta.source` is corrected to
1023 // `Native` — never left mislabeled as `ClaudeCode`.
1024 Some("native") => {
1025 let mut s = Self::from_claude_code_str(&body)?;
1026 s.meta.source = SessionSource::Native;
1027 s
1028 }
1029 // No/unknown header — auto-detect the body.
1030 _ => match detect_source(&body) {
1031 Some(SessionSource::Codex) => Self::from_codex_str(&body)?,
1032 Some(SessionSource::Pi) => Self::from_pi_str(&body)?,
1033 Some(SessionSource::OpenCode) => Self::from_opencode_str(&body)?,
1034 Some(SessionSource::Grok) => Self::from_grok_str(&body)?,
1035 _ => Self::from_claude_code_str(&body)?,
1036 },
1037 };
1038
1039 for line in turn_lines {
1040 match serde_json::from_str::<crate::sidecar::NativeTurn>(line) {
1041 Ok(turn) => {
1042 session.raw.push(line.to_string());
1043 session.messages.push(turn.into_message());
1044 }
1045 Err(_) => {
1046 // A valid JSON object carrying the native-turn
1047 // discriminator belongs to this wrapper, not to the
1048 // imported body. If its required fields are malformed,
1049 // count it as parse loss so every fail-loud caller can
1050 // refuse continuation instead of silently dropping a
1051 // native history record. Keep the rejected source line
1052 // in `raw` as well: diagnostics must count it in their
1053 // denominator, and even corrupt input must not disappear
1054 // merely because it reached the parser.
1055 session.raw.push(line.to_string());
1056 session.parse_error_lines += 1;
1057 }
1058 }
1059 }
1060
1061 // P5-3 (native write side, see `Self::to_native_jsonl_v2`'s matching
1062 // header block): recover a natively-spawned subagent's own lineage
1063 // from the v2 header, when present. Overlays (rather than merges
1064 // into) whatever the per-source body loader may have already set on
1065 // `session.meta` — these three keys are ONLY ever written by
1066 // `to_native_jsonl_v2` itself (never by a CC/CX/OC/Pi body), so a
1067 // header that carries them is authoritative for a file this crate
1068 // produced.
1069 if let Some(agent_id) = hv.get("agent_id").and_then(Value::as_str) {
1070 session.meta.agent_id = Some(agent_id.to_string());
1071 }
1072 if let Some(parent_tool_use_id) = hv.get("parent_tool_use_id").and_then(Value::as_str) {
1073 session.meta.parent_tool_use_id = Some(parent_tool_use_id.to_string());
1074 }
1075 if let Some(lineage) = hv.get("lineage").and_then(Value::as_object) {
1076 for (k, v) in lineage {
1077 if let Some(s) = v.as_str() {
1078 session.meta.lineage.insert(k.clone(), s.to_string());
1079 }
1080 }
1081 }
1082
1083 Ok(session)
1084 }
1085
1086 /// The full-fidelity [`Session`] a sidecar denotes.
1087 ///
1088 /// The sidecar (native-v2 format, D1) is the imported body plus every
1089 /// appended [`crate::sidecar::NativeTurn`]. Unlike the deliberately
1090 /// tolerant lower-level native parser, this persisted-store entry point
1091 /// validates its framing header before loading anything: a missing,
1092 /// malformed, or unsupported header must never become a zero-message
1093 /// session that callers could continue as if it were complete.
1094 pub fn from_sidecar_str(s: &str) -> Result<Session> {
1095 let header = s.lines().next().ok_or_else(|| {
1096 Error::InvalidSession("sidecar header is missing from an empty artifact".to_string())
1097 })?;
1098 let value: Value = serde_json::from_str(header).map_err(|error| {
1099 Error::InvalidSession(format!("sidecar header is not valid JSON: {error}"))
1100 })?;
1101 let version = value.get("supercode_native").and_then(Value::as_u64);
1102 if !matches!(version, Some(1 | 2)) {
1103 return Err(Error::InvalidSession(
1104 "sidecar header must declare supported `supercode_native` version 1 or 2"
1105 .to_string(),
1106 ));
1107 }
1108 let source = value.get("source").and_then(Value::as_str);
1109 if !matches!(
1110 source,
1111 Some("native" | "claude_code" | "codex" | "opencode" | "pi" | "grok")
1112 ) {
1113 return Err(Error::InvalidSession(
1114 "sidecar header must declare a supported `source`".to_string(),
1115 ));
1116 }
1117 Self::from_native_str(s)
1118 }
1119
1120 /// Parse a Codex rollout from an in-memory JSONL string.
1121 pub fn from_codex_str(jsonl: &str) -> Result<Session> {
1122 let mut meta = SessionMeta::new(SessionSource::Codex);
1123 let mut messages = Vec::new();
1124
1125 // First pass: collect the text of every assistant message that exists as
1126 // a canonical `response_item`. In normal sessions the streamed
1127 // `event_msg/agent_message` events duplicate these and are safely
1128 // skipped; in collab/multi-agent sessions the assistant narration lives
1129 // ONLY as `agent_message` events, so we recover the ones with no
1130 // response_item counterpart (deduping by exact text).
1131 let assistant_texts = collect_codex_assistant_texts(jsonl);
1132 // IX-1: strict-verbatim raw capture — see `from_claude_code_str`.
1133 let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
1134 let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
1135 let mut pending_reasoning = String::new();
1136 let mut pending_reasoning_content = String::new();
1137 let mut pending_reasoning_encrypted = false;
1138 // PARITY-15: see `from_claude_code_str`'s identical counter.
1139 let mut parse_error_lines = 0usize;
1140 let mut restored_embedded_codex_provenance = false;
1141
1142 for (record_index, raw_line) in raw_lines.iter().enumerate() {
1143 let line = raw_line.trim();
1144 if line.is_empty() {
1145 continue;
1146 }
1147 let v: Value = match serde_json::from_str(line) {
1148 Ok(v) => v,
1149 Err(_) => {
1150 parse_error_lines += 1;
1151 continue;
1152 }
1153 };
1154 let payload = v.get("payload").unwrap_or(&Value::Null);
1155 if !restored_embedded_codex_provenance
1156 && v.get("type").and_then(Value::as_str) == Some("session_meta")
1157 && payload
1158 .get(SUPERCODE_CODEX_PROVENANCE_KEY)
1159 .map(|extension| restore_codex_provenance(extension, &mut meta))
1160 .transpose()?
1161 .unwrap_or(false)
1162 {
1163 restored_embedded_codex_provenance = true;
1164 }
1165 if !restored_embedded_codex_provenance {
1166 capture_codex_provenance_record(&mut meta, record_index, raw_line, &v);
1167 }
1168 // WAVE-2 item 1: every Codex record carries a real top-level
1169 // `timestamp` (ISO-8601) — threaded onto each `ChatMessage` a
1170 // line produces via `stamp_new_codex_messages` below, at each
1171 // arm that pushes messages.
1172 let line_ts = v.get("timestamp").and_then(Value::as_str);
1173
1174 match v.get("type").and_then(Value::as_str) {
1175 Some("session_meta") => {
1176 capture_codex_session_meta(payload, &mut meta);
1177 if !restored_embedded_codex_provenance {
1178 meta.codex_headers.push(v.clone());
1179 }
1180 }
1181 Some("turn_context") => {
1182 if meta.model.is_none() {
1183 meta.model = payload
1184 .get("model")
1185 .and_then(Value::as_str)
1186 .map(str::to_string);
1187 }
1188 if !restored_embedded_codex_provenance {
1189 meta.codex_headers.push(v.clone());
1190 }
1191 }
1192 Some("response_item")
1193 if payload.get("type").and_then(Value::as_str) == Some("reasoning") =>
1194 {
1195 // Retain reasoning (P3): summary text if any, the raw
1196 // `content` chain-of-thought text if any (N2 — this used
1197 // to be dropped despite `Coverage::Retained` claiming the
1198 // whole item survived; see `crate::audit`'s doc comment),
1199 // plus a flag for the opaque encrypted_content a
1200 // same-model continuation can replay. Stashed onto the
1201 // next assistant message below.
1202 let summary = extract_text_content(payload.get("summary"));
1203 if !summary.trim().is_empty() {
1204 push_str_field(&mut pending_reasoning, &summary);
1205 }
1206 // N2: `content` is `null` on the vast majority of real
1207 // turns (raw reasoning text is only ever populated for
1208 // certain reasoning-transcript configurations) — guard
1209 // on non-null BEFORE calling `extract_text_content`,
1210 // since `Some(&Value::Null)` would otherwise fall into
1211 // its `Some(other) => other.to_string()` arm and
1212 // stringify to the literal text `"null"`.
1213 if let Some(raw_content) = payload.get("content").filter(|v| !v.is_null()) {
1214 let text = extract_text_content(Some(raw_content));
1215 if !text.trim().is_empty() {
1216 push_str_field(&mut pending_reasoning_content, &text);
1217 }
1218 }
1219 // N1: `serde_json` returns `Some(&Value::Null)` for a
1220 // present-but-null `encrypted_content` key — which is
1221 // what EVERY real rollout's reasoning item carries
1222 // (upstream always serializes the field, never
1223 // `skip_serializing_if`, `codex-rs/protocol/src/
1224 // models.rs:970-983`). The old `.is_some()` check
1225 // false-flagged every single reasoning item as
1226 // "encrypted" on real data; only a genuinely non-null
1227 // value means the model actually returned an opaque
1228 // blob that a same-model continuation could replay.
1229 if payload
1230 .get("encrypted_content")
1231 .is_some_and(|v| !v.is_null())
1232 {
1233 pending_reasoning_encrypted = true;
1234 }
1235 }
1236 Some("response_item") => {
1237 let before = messages.len();
1238 push_codex_item(payload, &mut messages);
1239 // Attach any pending reasoning to a newly produced assistant turn.
1240 if messages.len() > before
1241 && (!pending_reasoning.is_empty()
1242 || !pending_reasoning_content.is_empty()
1243 || pending_reasoning_encrypted)
1244 {
1245 let is_assistant = messages
1246 .last()
1247 .map(|m| m.role == Role::Assistant)
1248 .unwrap_or(false);
1249 if is_assistant {
1250 let last = messages.last_mut().expect("checked above");
1251 if !pending_reasoning.is_empty() {
1252 last.metadata.insert(
1253 "reasoning".to_string(),
1254 std::mem::take(&mut pending_reasoning),
1255 );
1256 }
1257 if !pending_reasoning_content.is_empty() {
1258 last.metadata.insert(
1259 "reasoning_content".to_string(),
1260 std::mem::take(&mut pending_reasoning_content),
1261 );
1262 }
1263 if pending_reasoning_encrypted {
1264 last.metadata
1265 .insert("reasoning_encrypted".to_string(), "true".to_string());
1266 pending_reasoning_encrypted = false;
1267 }
1268 } else {
1269 // N3: the item that just landed is NOT the
1270 // assistant turn the pending reasoning was for
1271 // (e.g. an aborted turn's reasoning directly
1272 // followed by a user message) — the old code
1273 // unconditionally cleared the pending state
1274 // here, silently discarding it. Flush it as its
1275 // own message instead, inserted just before the
1276 // interrupting item so replay order stays
1277 // chronological, keeping `Coverage::Retained`
1278 // honest for this shape too.
1279 let orphan = orphaned_reasoning_message(
1280 &mut pending_reasoning,
1281 &mut pending_reasoning_content,
1282 &mut pending_reasoning_encrypted,
1283 );
1284 messages.insert(before, orphan);
1285 }
1286 }
1287 stamp_new_codex_messages(&mut messages, before, line_ts);
1288 restore_single_grok_message(payload, &mut messages[before..]);
1289 }
1290 // A compaction record replaces all prior turns with its
1291 // summarized `replacement_history` — exactly how Codex itself
1292 // resumes a compacted session.
1293 Some("compacted") => {
1294 messages.clear();
1295 if let Some(Value::Array(history)) = payload.get("replacement_history") {
1296 for item in history {
1297 push_codex_item(item, &mut messages);
1298 }
1299 }
1300 // `replacement_history` items carry no per-item
1301 // timestamp of their own (observed corpora) — the
1302 // `compacted` record's own timestamp (when it happened)
1303 // is the best-effort real source for every message it
1304 // synthesizes, so it stamps the whole rebuilt vec (index
1305 // 0, since `clear()` reset it above).
1306 stamp_new_codex_messages(&mut messages, 0, line_ts);
1307 // IX-6 fix: replaying `replacement_history` through
1308 // `push_codex_item` can leave the LAST replayed message
1309 // marked `__codex_open_turn` (if it's an assistant
1310 // `message`, per the combined-turn merge below). That
1311 // marker must not survive past the compaction boundary —
1312 // a live `function_call` arriving after this record is a
1313 // NEW turn, not a continuation of the compaction
1314 // summary's synthetic turn, so it must not merge into it.
1315 if let Some(last) = messages.last_mut() {
1316 last.metadata.remove("__codex_open_turn");
1317 }
1318 }
1319 Some("event_msg")
1320 if payload.get("type").and_then(Value::as_str) == Some("agent_message") =>
1321 {
1322 let before = messages.len();
1323 let text = agent_message_text(payload);
1324 if !text.trim().is_empty() && !assistant_texts.contains(text.trim()) {
1325 push_assistant(&mut messages, text, Vec::new());
1326 if let Some(last) = messages.last_mut() {
1327 if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
1328 last.metadata.insert("phase".to_string(), phase.to_string());
1329 }
1330 }
1331 }
1332 stamp_new_codex_messages(&mut messages, before, line_ts);
1333 }
1334 // The user rolled back (undid) the last N turns — replay must
1335 // drop them so the reloaded conversation matches what the user
1336 // actually kept.
1337 Some("event_msg")
1338 if payload.get("type").and_then(Value::as_str)
1339 == Some("thread_rolled_back") =>
1340 {
1341 let n = payload
1342 .get("num_turns")
1343 .and_then(Value::as_u64)
1344 .unwrap_or(1);
1345 for _ in 0..n {
1346 remove_last_turn(&mut messages);
1347 }
1348 }
1349 // The natural-language goal assigned to this thread (sometimes
1350 // the only place the objective text is recorded).
1351 Some("event_msg")
1352 if payload.get("type").and_then(Value::as_str)
1353 == Some("thread_goal_updated") =>
1354 {
1355 let before = messages.len();
1356 let goal = payload.get("goal");
1357 if let Some(obj) = goal
1358 .and_then(|g| g.get("objective"))
1359 .and_then(Value::as_str)
1360 {
1361 if !obj.trim().is_empty() {
1362 messages.push(ChatMessage::system(format!("[thread goal] {obj}")));
1363 // D4: `goal.objective` alone used to be the ONLY
1364 // captured field, but the audit labeled this
1365 // `Retained` as if the whole record survived.
1366 // `goal.status`/`goal.tokenBudget` (real
1367 // `ThreadGoal` wire fields, camelCase) are
1368 // captured too so that label is honest — see
1369 // `crate::audit::event_msg_coverage`'s doc
1370 // comment.
1371 if let Some(last) = messages.last_mut() {
1372 if let Some(status) =
1373 goal.and_then(|g| g.get("status")).and_then(Value::as_str)
1374 {
1375 last.metadata
1376 .insert("goal_status".to_string(), status.to_string());
1377 }
1378 if let Some(budget) = goal
1379 .and_then(|g| g.get("tokenBudget"))
1380 .and_then(Value::as_i64)
1381 {
1382 last.metadata.insert(
1383 "goal_token_budget".to_string(),
1384 budget.to_string(),
1385 );
1386 }
1387 }
1388 }
1389 }
1390 stamp_new_codex_messages(&mut messages, before, line_ts);
1391 }
1392 // Code-review output — unique assistant-generated content with no
1393 // `message` counterpart.
1394 Some("event_msg")
1395 if payload.get("type").and_then(Value::as_str)
1396 == Some("exited_review_mode") =>
1397 {
1398 let before = messages.len();
1399 if let Some(review) = payload.get("review_output") {
1400 let text = review
1401 .get("overall_explanation")
1402 .and_then(Value::as_str)
1403 .map(str::to_string)
1404 .unwrap_or_else(|| review.to_string());
1405 push_assistant(&mut messages, format!("[code review] {text}"), Vec::new());
1406 // D4: `overall_explanation` alone used to be the ONLY
1407 // captured field, but the audit labeled this
1408 // `Retained` as if `review_output.findings` survived
1409 // too. Capture `findings` verbatim (as JSON, onto
1410 // metadata) so that label is honest — this is the
1411 // only place review-mode findings (title/body/
1412 // confidence_score/priority/code_location) live.
1413 if let Some(findings) = review.get("findings") {
1414 if findings.as_array().is_some_and(|a| !a.is_empty()) {
1415 if let Some(last) = messages.last_mut() {
1416 if let Ok(s) = serde_json::to_string(findings) {
1417 last.metadata.insert("review_findings".to_string(), s);
1418 }
1419 }
1420 }
1421 }
1422 // N4: `overall_correctness`/`overall_confidence_score`
1423 // are the review's actual verdict — distinct from the
1424 // findings list and the explanation prose already
1425 // captured above — and were neither captured nor
1426 // disclosed as residue while the audit doc stayed
1427 // silent about them. Capture both onto the same
1428 // message's metadata, same pattern as `findings`.
1429 if let Some(last) = messages.last_mut() {
1430 if let Some(correctness) =
1431 review.get("overall_correctness").and_then(Value::as_str)
1432 {
1433 last.metadata.insert(
1434 "review_overall_correctness".to_string(),
1435 correctness.to_string(),
1436 );
1437 }
1438 if let Some(score) = review
1439 .get("overall_confidence_score")
1440 .and_then(Value::as_f64)
1441 {
1442 last.metadata.insert(
1443 "review_overall_confidence_score".to_string(),
1444 score.to_string(),
1445 );
1446 }
1447 }
1448 }
1449 stamp_new_codex_messages(&mut messages, before, line_ts);
1450 }
1451 _ => {} // other event_msg, token_count, ... — UI events, skip
1452 }
1453 }
1454
1455 // N3/PARITY-11: reasoning still pending at EOF is an aborted-turn
1456 // shape a real rollout can leave behind (the process was
1457 // interrupted mid-turn, after the model reasoned but before it
1458 // replied — end of file, or a rollback/compaction boundary that
1459 // clears the pending state some other way) — the old code silently
1460 // dropped it here (nothing ever consumed the pending buffers once
1461 // the loop ended). Flush it as its own trailing message instead, so
1462 // `Coverage::Retained` holds for this shape too. Superset of the
1463 // independently-discovered PARITY-11 fix: also folds in
1464 // `pending_reasoning_content` (the raw chain-of-thought, distinct
1465 // from `summary`/`reasoning`) via the shared `orphaned_reasoning_
1466 // message` helper, which the interrupted-by-a-user-message shape
1467 // (`orphaned_reasoning_is_flushed_not_discarded`, case A) also
1468 // relies on — a trailing-EOF-only flush here would miss that case.
1469 if !pending_reasoning.is_empty()
1470 || !pending_reasoning_content.is_empty()
1471 || pending_reasoning_encrypted
1472 {
1473 let orphan = orphaned_reasoning_message(
1474 &mut pending_reasoning,
1475 &mut pending_reasoning_content,
1476 &mut pending_reasoning_encrypted,
1477 );
1478 messages.push(orphan);
1479 }
1480
1481 ensure_tool_results_paired(&mut messages);
1482 // IX-6: `__codex_open_turn` is an internal bookkeeping marker for the
1483 // combined-turn merge above — strip it so it never leaks out as
1484 // visible `ChatMessage` metadata.
1485 for m in &mut messages {
1486 m.metadata.remove("__codex_open_turn");
1487 if m.metadata
1488 .remove("__grok_remove_synthetic_turn_id")
1489 .is_some()
1490 {
1491 m.metadata.remove("turn_id");
1492 }
1493 }
1494 let imported_message_count = Some(messages.len());
1495 Ok(Session {
1496 meta,
1497 messages,
1498 subagents: Vec::new(),
1499 raw,
1500 raw_trailing_newline,
1501 imported_message_count,
1502 // Codex is line-oriented: `raw` is split directly out of the
1503 // source text (strict-verbatim, IX-1).
1504 raw_is_verbatim: true,
1505 parse_error_lines,
1506 load_residue: Vec::new(),
1507 })
1508 }
1509
1510 /// Load a Pi session from a file.
1511 pub fn from_pi(path: impl AsRef<Path>) -> Result<Session> {
1512 Self::from_pi_str(&std::fs::read_to_string(path.as_ref())?)
1513 }
1514
1515 /// Parse a Pi session (`docs/interop/opencode-pi-spec.md` §1.1,
1516 /// `docs/interop/research/pi-fields.md`) from an in-memory JSONL string.
1517 ///
1518 /// Line 1 is the `session` header; every other line is one `SessionEntry`
1519 /// in a tree keyed by `id`/`parentId` — file order is append order, not
1520 /// tree order. `raw` captures every line verbatim (byte-lossless T1,
1521 /// exactly like Claude Code/Codex). `messages` is the **active path
1522 /// only**: pi's own leaf rule is "the last entry in file order"
1523 /// (`pi-fields.md` `sm:897`), so this walks `parentId` from there back to
1524 /// the root and linearizes root→leaf. Non-active branches, `label`s, and
1525 /// state records (`thinking_level_change`/`model_change`/`custom`/
1526 /// `session_info`) are never visited by that walk — they survive in
1527 /// `raw` only, pi's defining residue (§1.1).
1528 ///
1529 /// `message.role` is an OPEN union upstream (§1.1 S6): a role outside the
1530 /// five modeled here (`user`/`assistant`/`toolResult`/`bashExecution`/
1531 /// `custom`) produces no canonical message — raw-only survival, never a
1532 /// panic — and [`crate::audit::Corpus::Pi`] is what turns that into a
1533 /// visible coverage failure rather than a silent drop.
1534 ///
1535 /// Same fail-loud discipline applies to `ImageContent` blocks
1536 /// (`user`/`toolResult`/`custom*` content, see [`pi_image_shape`]): the
1537 /// assumed `{mimeType, data}` shape is UNVERIFIED against real pi output
1538 /// (`pi-fields.md` doesn't enumerate `ImageContent`'s own fields, only
1539 /// cites the containing union) — a follow-up TR tracks confirming it
1540 /// against a real corpus. Until then, an image block that doesn't match
1541 /// that shape never gets silently synthesized as an empty/corrupt
1542 /// `image_url` part; the containing message survives in `raw` only and
1543 /// trips `crate::audit::Corpus::Pi`'s `message/UnknownImageShape` bucket.
1544 pub fn from_pi_str(jsonl: &str) -> Result<Session> {
1545 let mut meta = SessionMeta::new(SessionSource::Pi);
1546 // IX-1: `raw` is captured STRICT-VERBATIM — separate from the
1547 // blank-skipping PARSE walk (`lines_v`) below, which must keep
1548 // skipping blank/whitespace-only lines when it looks for `SessionEntry`
1549 // records (a blank line is never a record, on either view).
1550 let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
1551 let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
1552 let non_empty_line_count = non_empty_lines(jsonl).count();
1553 let lines_v: Vec<Value> = non_empty_lines(jsonl)
1554 .filter_map(|l| serde_json::from_str(l).ok())
1555 .collect();
1556 // PARITY-15: every line that failed to even deserialize as JSON at
1557 // all (never mind whether it then parsed as a recognized
1558 // `SessionEntry` shape) — see `from_claude_code_str`'s identical
1559 // counter.
1560 let parse_error_lines = non_empty_line_count.saturating_sub(lines_v.len());
1561
1562 if let Some(header) = lines_v.first() {
1563 capture_pi_header(header, &mut meta)?;
1564 }
1565
1566 // Every non-header entry that parses as an object carrying an `id`.
1567 // (A line that fails to parse, or a header re-parsed as an entry,
1568 // simply never enters `by_id` — it survives in `raw` only, exactly
1569 // like a malformed/non-conversational line in the other loaders.)
1570 struct PiEntry {
1571 id: String,
1572 parent_id: Option<String>,
1573 value: Value,
1574 }
1575 let mut entries: Vec<PiEntry> = Vec::new();
1576 let mut by_id: HashMap<String, usize> = HashMap::new();
1577 for v in lines_v.iter().skip(1) {
1578 let Some(id) = v.get("id").and_then(Value::as_str) else {
1579 continue;
1580 };
1581 let parent_id = v
1582 .get("parentId")
1583 .and_then(Value::as_str)
1584 .map(str::to_string);
1585 by_id.insert(id.to_string(), entries.len());
1586 entries.push(PiEntry {
1587 id: id.to_string(),
1588 parent_id,
1589 value: v.clone(),
1590 });
1591 }
1592
1593 if entries.is_empty() {
1594 return Ok(Session {
1595 meta,
1596 messages: Vec::new(),
1597 subagents: Vec::new(),
1598 raw,
1599 raw_trailing_newline,
1600 imported_message_count: Some(0),
1601 // Pi is line-oriented: `raw` is split directly out of the
1602 // source text (strict-verbatim, IX-1), even for this
1603 // no-entries early return.
1604 raw_is_verbatim: true,
1605 parse_error_lines,
1606 load_residue: Vec::new(),
1607 });
1608 }
1609
1610 // Leaf = the last entry in file order (pi's own rule, `sm:897`), NOT
1611 // necessarily a `message` entry — a trailing `label`/`session_info`
1612 // still anchors the walk correctly since the walk just follows
1613 // `parentId` regardless of the leaf's own type.
1614 let leaf_idx = entries.len() - 1;
1615 let mut chain_rev: Vec<usize> = Vec::new();
1616 let mut cur: Option<String> = Some(entries[leaf_idx].id.clone());
1617 let mut guard = 0usize;
1618 while let Some(id) = cur {
1619 let Some(&idx) = by_id.get(&id) else { break };
1620 chain_rev.push(idx);
1621 cur = entries[idx].parent_id.clone();
1622 guard += 1;
1623 if guard > entries.len() + 1 {
1624 break; // cycle guard — malformed parentId chain
1625 }
1626 }
1627 chain_rev.reverse();
1628 let active = chain_rev; // indices into `entries`, root..leaf order
1629
1630 let pos_in_active: HashMap<&str, usize> = active
1631 .iter()
1632 .enumerate()
1633 .map(|(pos, &idx)| (entries[idx].id.as_str(), pos))
1634 .collect();
1635
1636 // First pass: compaction discipline (§2.1 S3) — every message from an
1637 // entry before the LATEST `firstKeptEntryId` on the active path is
1638 // excluded from replay (`compacted_out`), mirroring pi's own
1639 // `buildContextEntries` slice (`sm:414-450`).
1640 let mut kept_from_pos = 0usize;
1641 for &idx in &active {
1642 let e = &entries[idx];
1643 if e.value.get("type").and_then(Value::as_str) == Some("compaction") {
1644 if let Some(fk) = e.value.get("firstKeptEntryId").and_then(Value::as_str) {
1645 if let Some(&p) = pos_in_active.get(fk) {
1646 kept_from_pos = kept_from_pos.max(p);
1647 }
1648 }
1649 }
1650 }
1651
1652 let mut messages = Vec::new();
1653 let mut current_model: Option<String> = None;
1654 for (pos, &idx) in active.iter().enumerate() {
1655 let e = &entries[idx];
1656 let v = &e.value;
1657 let entry_ts = v
1658 .get("timestamp")
1659 .and_then(Value::as_str)
1660 .map(str::to_string);
1661 let before = messages.len();
1662 match v.get("type").and_then(Value::as_str) {
1663 Some("message") => {
1664 let msg_v = v.get("message").cloned().unwrap_or(Value::Null);
1665 match msg_v.get("role").and_then(Value::as_str) {
1666 Some("user") => push_pi_user(&msg_v, &mut messages),
1667 Some("assistant") => {
1668 push_pi_assistant(&msg_v, &mut messages);
1669 if let Some(m) = msg_v.get("model").and_then(Value::as_str) {
1670 current_model = Some(m.to_string());
1671 }
1672 }
1673 Some("toolResult") => push_pi_tool_result(&msg_v, &mut messages),
1674 Some("bashExecution") => push_pi_bash(&msg_v, &mut messages),
1675 Some("custom") => push_pi_custom_common(&msg_v, &mut messages),
1676 // OPEN UNION (S6): any other role — raw-only survival.
1677 _ => {}
1678 }
1679 }
1680 Some("custom_message") => push_pi_custom_common(v, &mut messages),
1681 Some("compaction") => push_pi_compaction(v, &mut messages),
1682 Some("branch_summary") => push_pi_branch_summary(v, &mut messages),
1683 Some("model_change") => {
1684 if let Some(m) = v.get("modelId").and_then(Value::as_str) {
1685 current_model = Some(m.to_string());
1686 }
1687 }
1688 Some("session_info") => {
1689 if let Some(name) = v.get("name").and_then(Value::as_str) {
1690 if !name.is_empty() {
1691 meta.lineage
1692 .insert("session_name".to_string(), name.to_string());
1693 }
1694 }
1695 }
1696 // thinking_level_change, custom (entry-level state), label —
1697 // no clean home, raw-only (§2.3).
1698 _ => {}
1699 }
1700 let is_summary = matches!(
1701 v.get("type").and_then(Value::as_str),
1702 Some("compaction") | Some("branch_summary")
1703 );
1704 for m in &mut messages[before..] {
1705 m.metadata.insert("pi_entry_id".to_string(), e.id.clone());
1706 if let Some(p) = &e.parent_id {
1707 m.metadata.insert("pi_parent_id".to_string(), p.clone());
1708 }
1709 if let Some(ts) = &entry_ts {
1710 m.metadata
1711 .entry("timestamp".to_string())
1712 .or_insert_with(|| ts.clone());
1713 }
1714 // WAVE-2 item 1 fallback: the entry-level `timestamp` above
1715 // is pi's authoritative, always-monotonic-in-file-order
1716 // wall-clock (mandatory on every entry) and wins whenever
1717 // present. The nested `message.timestamp` (unix-ms) is only
1718 // reached here — via `entry(...).or_insert_with`, so it
1719 // never overwrites the entry-level value — in the rare case
1720 // an entry lacks its own `timestamp`. This intentionally
1721 // does NOT prefer the msg-level field even though it LOOKS
1722 // more precise: unlike the entry-level timestamp, it is not
1723 // guaranteed monotonic with this loader's root->leaf
1724 // linearization (e.g. a rewound-branch entry can carry an
1725 // earlier msg-level clock reading than its file-order
1726 // neighbors), and OpenCode's own loader re-sorts messages by
1727 // this canonical timestamp — a non-monotonic source would
1728 // silently scramble replay order on a pi->opencode hop.
1729 if let Some(ms) = v
1730 .get("message")
1731 .and_then(|mm| mm.get("timestamp"))
1732 .and_then(Value::as_u64)
1733 {
1734 m.metadata
1735 .entry("timestamp".to_string())
1736 .or_insert_with(|| crate::sidecar::ms_to_rfc3339(ms as i64));
1737 }
1738 // A compaction/branch-summary message IS the retained marker
1739 // — never mark it excluded, regardless of its own position.
1740 if !is_summary && pos < kept_from_pos {
1741 m.metadata
1742 .insert("compacted_out".to_string(), "true".to_string());
1743 }
1744 }
1745 restore_single_grok_message(v, &mut messages[before..]);
1746 for message in &mut messages[before..] {
1747 restore_tool_outcome_extension(v, message);
1748 }
1749 }
1750
1751 meta.model = current_model;
1752 ensure_tool_results_paired(&mut messages);
1753 let imported_message_count = Some(messages.len());
1754 Ok(Session {
1755 meta,
1756 messages,
1757 subagents: Vec::new(),
1758 raw,
1759 raw_trailing_newline,
1760 imported_message_count,
1761 // Pi is line-oriented: `raw` is split directly out of the
1762 // source text (strict-verbatim, IX-1).
1763 raw_is_verbatim: true,
1764 parse_error_lines,
1765 load_residue: Vec::new(),
1766 })
1767 }
1768
1769 /// Load Grok's resumable `chat_history.jsonl` transcript.
1770 ///
1771 /// The surrounding session directory carries the session id, workspace,
1772 /// and `summary.json`; [`Self::from_grok_str`] handles the transcript
1773 /// itself while this path-aware entry point overlays that directory
1774 /// metadata.
1775 pub fn from_grok(path: impl AsRef<Path>) -> Result<Session> {
1776 let path = path.as_ref();
1777 let mut session = Self::from_grok_str(&std::fs::read_to_string(path)?)?;
1778 session.capture_grok_path_metadata(path);
1779 Ok(session)
1780 }
1781
1782 /// Parse Grok's line-oriented `chat_history.jsonl` format.
1783 ///
1784 /// Conversational records are `user`, `assistant`, and `tool_result`.
1785 /// `system` is the regenerated base prompt and is retained in
1786 /// [`SessionMeta::system_prompt`]; encrypted reasoning and backend-only
1787 /// state remain byte-exact in [`Session::raw`] but are intentionally not
1788 /// replayed as chat turns.
1789 pub fn from_grok_str(jsonl: &str) -> Result<Session> {
1790 let mut meta = SessionMeta::new(SessionSource::Grok);
1791 let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
1792 let raw: Vec<String> = raw_lines.iter().map(|line| line.to_string()).collect();
1793 let mut messages = Vec::new();
1794 let mut parse_error_lines = 0usize;
1795 let mut tool_names: HashMap<String, String> = HashMap::new();
1796
1797 for line in non_empty_lines(jsonl) {
1798 let value: Value = match serde_json::from_str(line) {
1799 Ok(value) => value,
1800 Err(_) => {
1801 parse_error_lines += 1;
1802 continue;
1803 }
1804 };
1805 restore_codex_provenance_from_top_level(&value, &mut meta)?;
1806 match value.get("type").and_then(Value::as_str) {
1807 Some("system") => {
1808 if meta.system_prompt.is_none() {
1809 meta.system_prompt = value
1810 .get("content")
1811 .and_then(Value::as_str)
1812 .map(str::to_string);
1813 }
1814 }
1815 Some("user") => {
1816 let content = extract_text_content(value.get("content"));
1817 let role = if value.get("synthetic_reason").and_then(Value::as_str)
1818 == Some("supercode_system_event")
1819 {
1820 Role::System
1821 } else {
1822 Role::User
1823 };
1824 let content = if role == Role::User {
1825 match grok_human_user_text(&content) {
1826 Some(content) => content,
1827 None if value.get(SUPERCODE_GROK_MESSAGE_KEY).is_some() => {
1828 String::new()
1829 }
1830 None => continue,
1831 }
1832 } else {
1833 content
1834 };
1835 let mut message = ChatMessage {
1836 role,
1837 content: Some(content),
1838 content_parts: None,
1839 tool_calls: None,
1840 tool_call_id: None,
1841 name: None,
1842 metadata: Default::default(),
1843 };
1844 capture_grok_scalar_metadata(
1845 &value,
1846 &mut message,
1847 &["prompt_index", "prior_turn_interrupt", "synthetic_reason"],
1848 );
1849 restore_grok_message_extension(&value, &mut message);
1850 messages.push(message);
1851 }
1852 Some("assistant") => {
1853 let calls: Vec<ToolCall> = value
1854 .get("tool_calls")
1855 .and_then(Value::as_array)
1856 .into_iter()
1857 .flatten()
1858 .filter_map(|call| {
1859 let id = call.get("id")?.as_str()?.to_string();
1860 let name = call.get("name")?.as_str()?.to_string();
1861 let arguments = call
1862 .get("arguments")
1863 .map(value_to_arg_string)
1864 .unwrap_or_else(|| "{}".to_string());
1865 tool_names.insert(id.clone(), name.clone());
1866 Some(function_call(&id, &name, arguments))
1867 })
1868 .collect();
1869 let content = value
1870 .get("content")
1871 .and_then(Value::as_str)
1872 .filter(|content| !content.is_empty())
1873 .map(str::to_string);
1874 let mut message = ChatMessage {
1875 role: Role::Assistant,
1876 content,
1877 content_parts: None,
1878 tool_calls: (!calls.is_empty()).then_some(calls),
1879 tool_call_id: None,
1880 name: None,
1881 metadata: Default::default(),
1882 };
1883 capture_grok_scalar_metadata(
1884 &value,
1885 &mut message,
1886 &["model_id", "model_fingerprint", "reasoning_effort"],
1887 );
1888 if let Some(model) = value.get("model_id").and_then(Value::as_str) {
1889 meta.model = Some(model.to_string());
1890 }
1891 restore_grok_message_extension(&value, &mut message);
1892 messages.push(message);
1893 }
1894 Some("tool_result") => {
1895 let id = value
1896 .get("tool_call_id")
1897 .and_then(Value::as_str)
1898 .unwrap_or_default();
1899 let content = value
1900 .get("content")
1901 .map(|value| match value {
1902 Value::String(text) => text.clone(),
1903 other => extract_text_content(Some(other)),
1904 })
1905 .unwrap_or_default();
1906 let mut message = tool_message(id, content);
1907 message.name = tool_names.get(id).cloned();
1908 restore_grok_message_extension(&value, &mut message);
1909 messages.push(message);
1910 }
1911 // `reasoning` contains encrypted chain-of-thought and
1912 // `backend_tool_call` is execution bookkeeping. Both survive
1913 // verbatim in raw without being replayed to another model.
1914 _ => {}
1915 }
1916 }
1917
1918 ensure_tool_results_paired(&mut messages);
1919 let imported_message_count = Some(messages.len());
1920 Ok(Session {
1921 meta,
1922 messages,
1923 subagents: Vec::new(),
1924 raw,
1925 raw_trailing_newline,
1926 imported_message_count,
1927 raw_is_verbatim: true,
1928 parse_error_lines,
1929 load_residue: Vec::new(),
1930 })
1931 }
1932
1933 fn capture_grok_path_metadata(&mut self, transcript: &Path) {
1934 let Some(session_dir) = transcript.parent() else {
1935 return;
1936 };
1937 self.meta.session_id = session_dir
1938 .file_name()
1939 .and_then(|name| name.to_str())
1940 .map(str::to_string);
1941 self.meta.cwd = session_dir
1942 .parent()
1943 .and_then(Path::file_name)
1944 .and_then(|name| name.to_str())
1945 .and_then(percent_decode_path)
1946 .map(PathBuf::from);
1947
1948 let Ok(summary_text) = std::fs::read_to_string(session_dir.join("summary.json")) else {
1949 return;
1950 };
1951 let Ok(summary) = serde_json::from_str::<Value>(&summary_text) else {
1952 return;
1953 };
1954 if let Some(model) = summary.get("current_model_id").and_then(Value::as_str) {
1955 self.meta.model = Some(model.to_string());
1956 }
1957 for (source, target) in [
1958 ("generated_title", "session_name"),
1959 ("created_at", "created_at"),
1960 ("updated_at", "updated_at"),
1961 ("chat_format_version", "grok_chat_format_version"),
1962 ] {
1963 if let Some(value) = summary.get(source) {
1964 self.meta.lineage.insert(
1965 target.to_string(),
1966 value
1967 .as_str()
1968 .map(str::to_string)
1969 .unwrap_or_else(|| value.to_string()),
1970 );
1971 }
1972 }
1973 }
1974
1975 /// Load an OpenCode session from a file — either read surface, see
1976 /// [`Self::from_opencode_str`].
1977 pub fn from_opencode(path: impl AsRef<Path>) -> Result<Session> {
1978 Self::from_opencode_str(&std::fs::read_to_string(path.as_ref())?)
1979 }
1980
1981 /// Load an OpenCode session from a real SQLite store (PARITY-3): opens
1982 /// `db_path`, resolves `session_id` (explicit, or — when `None` — the
1983 /// most-recently-updated top-level session, see
1984 /// [`opencode_sqlite_primary_session_id`]) and reconstructs the SAME
1985 /// envelope form [`Self::from_opencode_str`] already parses for the
1986 /// JSON-tree surfaces — so canonicalization (§2.1 mapping, compaction
1987 /// discipline, S1 tool-output masking, …) is shared code, not
1988 /// reimplemented here. See `docs/interop/opencode-pi-spec.md` §1.2/S9c
1989 /// for the envelope-construction rules this follows (all-columns rule,
1990 /// raw `revert` column carried verbatim).
1991 pub fn from_opencode_sqlite(db_path: &Path, session_id: Option<&str>) -> Result<Session> {
1992 let conn = opencode_sqlite_open(db_path)?;
1993 let id = match session_id {
1994 Some(id) => id.to_string(),
1995 None => opencode_sqlite_primary_session_id(&conn)?,
1996 };
1997 let lines = opencode_sqlite_session_envelope_lines(&conn, db_path, &id)?;
1998 let mut text = lines.join("\n");
1999 text.push('\n');
2000 let mut session = Self::from_opencode_str(&text)?;
2001 // D4: `text` above is a SYNTHESIZED reconstruction from SQL rows —
2002 // not the original source bytes (a binary `.db` file has no
2003 // "verbatim" line-oriented form to begin with). `from_opencode_str`
2004 // defaults `raw_is_verbatim` to `true` because for its OTHER two
2005 // callers (an actual envelope-form file's own text, an actual
2006 // export-document's text) that really is the source. It is NEVER
2007 // true for this diagonal — mirrors the export-document fix just
2008 // above for the same reason (`from_opencode_export_doc`, `false`).
2009 // `convert opencode.db --to opencode` must not claim byte-identical.
2010 session.raw_is_verbatim = false;
2011 Ok(session)
2012 }
2013
2014 /// Parse an OpenCode session from either of its two frozen **read
2015 /// surfaces** (`docs/interop/opencode-pi-spec.md` §1.2, `S9a`,
2016 /// `opencode-fields.md`):
2017 ///
2018 /// - the **envelope form**: each line is
2019 /// `{"key":[<storage key path>],"value":<record>}`, minified — the
2020 /// synthesized raw-capture unit for the JSON-tree/SQLite storage
2021 /// generations;
2022 /// - the **export-document form**: a single pretty-printed JSON document
2023 /// `{info: SessionInfo, messages:[{info: Message, parts:[Part]}, …]}`
2024 /// — the `opencode export`/`import` interchange shape, and EXACTLY
2025 /// what [`Self::to_opencode_jsonl`] emits.
2026 ///
2027 /// Both forms are parsed into the same `(session_info, side_records,
2028 /// Vec<OcMsg>)` shape and funnel through the SAME shared canonicalizer,
2029 /// [`opencode_session_from_records`] — so the same underlying records
2030 /// produce identical `messages` regardless of which surface carried
2031 /// them in. This is what makes `load(to_opencode_jsonl(S))` round-trip
2032 /// (§4.1's OpenCode diagonal, the exact circuit the fidelity matrix
2033 /// exercises): previously this function parsed the envelope form only
2034 /// and silently returned an empty-but-`Ok` `Session` for an export
2035 /// document — the confirmed footgun this now closes.
2036 ///
2037 /// Record classification (envelope form) is driven by the envelope
2038 /// `key`'s first component (`"session"` / `"message"` / `"part"` /
2039 /// `"session_diff"` / `"todo"`) — the frozen key scheme for all three
2040 /// storage generations plus SQLite rows (§1.2 S9c: a SQLite row's
2041 /// envelope synthesizes `key:["<table>","<ses>",...ids]` and MUST carry
2042 /// every column, `data` and non-`data` alike — e.g. the `session` row's
2043 /// `revert` column under the V2 `Revert.State` schema, whose extra
2044 /// `files` field the CLI's own row→V1 reconstruction drops; the
2045 /// envelope's `raw` capture keeps that raw column value regardless of
2046 /// what this loader's canonicalization understands).
2047 ///
2048 /// Mapping to canonical `messages` (§2.1, shared by both forms via
2049 /// [`push_opencode_user`]/[`push_opencode_assistant`]): `User`/`Assistant`
2050 /// text parts → `content`; a `User` `file` part whose `mime` is an image
2051 /// and whose `url` is a `data:` URI → an `image_url` `content_parts`
2052 /// entry; an `Assistant` `tool` part's `callID` + `state.*.input` → a
2053 /// `ToolCall`, and the SAME part's `state.completed.output` /
2054 /// `state.error.error` → a paired `Tool` message split by `callID`
2055 /// (opencode keeps call+result on one record; this loader splits it
2056 /// into the two OpenAI-shape messages the other loaders already
2057 /// produce).
2058 ///
2059 /// **S1 (`time.compacted`):** when a `tool` part's
2060 /// `state.completed.time.compacted` is set, the emitted `Tool`
2061 /// message's `content` is the placeholder
2062 /// [`OPENCODE_COMPACTED_TOOL_PLACEHOLDER`] — mirroring what opencode's
2063 /// own `toModelMessage` replays — while the REAL output survives in
2064 /// `raw` (always) and in `metadata["oc_tool_output_compacted"]` (full
2065 /// text) + `metadata["oc_tool_time_compacted"]` (the mask timestamp), so
2066 /// it is reversible, never actually lost.
2067 ///
2068 /// **Compaction boundary:** a `compaction` part's `tail_start_id` marks
2069 /// every message strictly before that message id
2070 /// `metadata["compacted_out"]="true"` (honored uniformly by
2071 /// [`is_replay_excluded`]) — except a `summary:true` `Assistant`
2072 /// message, which opencode itself hoists in FRONT of the retained tail
2073 /// on replay (`message-v2.ts:521-572`) and so must never be excluded
2074 /// regardless of its position, mirroring pi's identical exemption for
2075 /// its own compaction/branch-summary entries.
2076 ///
2077 /// **Unknown part `type` or unknown `tool.state.status`:** never
2078 /// canonicalized — raw-only survival, exactly like an unmodeled Pi
2079 /// `message.role` (S6-style fail-loud). [`crate::audit::Corpus::OpenCode`]
2080 /// is what turns that into a visible coverage failure rather than a
2081 /// silent drop.
2082 ///
2083 /// **Export-document `raw`:** an export document is a single
2084 /// pretty-printed JSON value with no per-line envelope structure of its
2085 /// own to capture verbatim, so `raw` here is RE-SYNTHESIZED — one
2086 /// envelope line per `session`/`message`/`part` record found in the
2087 /// document, in the exact `{"key":[...],"value":...}` shape the native
2088 /// envelope form uses — so every native/T1-value-tier path
2089 /// (`to_native_jsonl`, [`Self::opencode_records_from_raw`], the
2090 /// splice/direct-write writers) stays consistent regardless of which
2091 /// read surface produced this `Session`.
2092 ///
2093 /// **Malformed input:** input that reaches this function non-empty but
2094 /// yields zero session/message/part records under EITHER form returns a
2095 /// clear `Err` rather than a silently-empty `Ok(Session)` — the
2096 /// confirmed footgun (`supercode resume`/`convert`/`inspect` on such
2097 /// input must not silently succeed with an empty session). A
2098 /// legitimately-empty session — a real `session` record with zero
2099 /// messages, or a valid export document with an empty `messages` array
2100 /// — is not an error.
2101 pub fn from_opencode_str(text: &str) -> Result<Session> {
2102 let trimmed = text.trim();
2103
2104 // Export-document form first (§1.2/S9a) — mirrors `detect_source`'s
2105 // own precedence: try the whole-text parse before the per-line
2106 // envelope loop below, since a pretty-printed multi-line document
2107 // has no individually-valid-JSON lines for that loop to match.
2108 if let Ok(doc) = serde_json::from_str::<Value>(trimmed) {
2109 if doc.get("info").is_some() && doc.get("messages").and_then(Value::as_array).is_some()
2110 {
2111 return Self::from_opencode_export_doc(&doc);
2112 }
2113 }
2114
2115 // Envelope form: each line `{"key":[<storage key path>],"value":<record>}`.
2116 // IX-1: `raw` is captured STRICT-VERBATIM — separate from the
2117 // blank-skipping PARSE walk just below, which keeps skipping
2118 // blank/whitespace-only lines when it looks for envelope records.
2119 let (raw_lines, raw_trailing_newline) = split_lines_verbatim(text);
2120 let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
2121 let mut session_info: Option<Value> = None;
2122 let mut side_records: Vec<Value> = Vec::new();
2123 let mut msgs: Vec<OcMsg> = Vec::new();
2124 let mut msg_index: HashMap<String, usize> = HashMap::new();
2125 // PARITY-15: see `from_claude_code_str`'s identical counter — only
2126 // a genuinely malformed line (fails to deserialize as JSON at all),
2127 // not a well-formed envelope this loader simply doesn't recognize.
2128 let mut parse_error_lines = 0usize;
2129
2130 for line in non_empty_lines(text) {
2131 let Ok(env) = serde_json::from_str::<Value>(line) else {
2132 parse_error_lines += 1;
2133 continue; // malformed line — raw-only, exactly like the other loaders
2134 };
2135 let Some(key) = env.get("key").and_then(Value::as_array) else {
2136 continue; // not an envelope record — raw-only
2137 };
2138 let value = env.get("value").cloned().unwrap_or(Value::Null);
2139 match key.first().and_then(Value::as_str) {
2140 Some("session") => session_info = Some(value),
2141 Some("message") => {
2142 let Some(id) = value.get("id").and_then(Value::as_str) else {
2143 continue;
2144 };
2145 let time_created = value
2146 .get("time")
2147 .and_then(|t| t.get("created"))
2148 .and_then(Value::as_i64)
2149 .unwrap_or(0);
2150 msg_index.insert(id.to_string(), msgs.len());
2151 msgs.push(OcMsg {
2152 id: id.to_string(),
2153 time_created,
2154 value,
2155 parts: Vec::new(),
2156 });
2157 }
2158 Some("part") => {
2159 if let Some(msg_id) = value.get("messageID").and_then(Value::as_str) {
2160 if let Some(&idx) = msg_index.get(msg_id) {
2161 msgs[idx].parts.push(value);
2162 }
2163 // A part whose message wasn't captured (out-of-order
2164 // envelope) — still fully present in `raw`, just not
2165 // attached to a canonical message.
2166 }
2167 }
2168 Some("session_diff") | Some("todo") => {
2169 side_records.push(serde_json::json!({"key": key, "value": value}));
2170 }
2171 _ => {} // unrecognized top-level key — raw-only
2172 }
2173 }
2174
2175 opencode_guard_against_silent_empty(
2176 !trimmed.is_empty(),
2177 &session_info,
2178 &msgs,
2179 &side_records,
2180 )?;
2181 opencode_session_from_records(
2182 session_info,
2183 side_records,
2184 msgs,
2185 raw,
2186 raw_trailing_newline,
2187 // Envelope form: `raw` is split directly out of the source text
2188 // (strict-verbatim, IX-1) — genuinely reproduces the original
2189 // bytes on replay.
2190 true,
2191 parse_error_lines,
2192 )
2193 }
2194
2195 /// The **export-document** read surface of [`Self::from_opencode_str`]
2196 /// — see that function's doc comment for the shared canonicalization
2197 /// and the `raw` re-synthesis this performs. `doc` is already known to
2198 /// have the `{info, messages:[...]}` shape (the caller checks this,
2199 /// matching `detect_source`'s own S9a check) before calling this.
2200 fn from_opencode_export_doc(doc: &Value) -> Result<Session> {
2201 let session_info = doc.get("info").cloned().filter(|v| !v.is_null());
2202 let messages_arr = doc
2203 .get("messages")
2204 .and_then(Value::as_array)
2205 .cloned()
2206 .unwrap_or_default();
2207
2208 let session_id = session_info
2209 .as_ref()
2210 .and_then(|si| si.get("id"))
2211 .and_then(Value::as_str)
2212 .unwrap_or("ses_unknown")
2213 .to_string();
2214 let project_id = session_info
2215 .as_ref()
2216 .and_then(|si| si.get("projectID"))
2217 .and_then(Value::as_str)
2218 .unwrap_or("global")
2219 .to_string();
2220
2221 // Re-synthesize one envelope line per record — see the doc comment
2222 // on `from_opencode_str` ("Export-document `raw`").
2223 let mut raw: Vec<String> = Vec::new();
2224 if let Some(si) = &session_info {
2225 raw.push(
2226 serde_json::json!({"key": ["session", project_id, session_id], "value": si})
2227 .to_string(),
2228 );
2229 }
2230
2231 let mut msgs: Vec<OcMsg> = Vec::new();
2232 for entry in &messages_arr {
2233 let Some(info) = entry.get("info") else {
2234 continue; // malformed message entry — no clean home, raw-only
2235 };
2236 let Some(id) = info.get("id").and_then(Value::as_str) else {
2237 continue;
2238 };
2239 let time_created = info
2240 .get("time")
2241 .and_then(|t| t.get("created"))
2242 .and_then(Value::as_i64)
2243 .unwrap_or(0);
2244 let parts: Vec<Value> = entry
2245 .get("parts")
2246 .and_then(Value::as_array)
2247 .cloned()
2248 .unwrap_or_default();
2249
2250 raw.push(
2251 serde_json::json!({"key": ["message", session_id, id], "value": info}).to_string(),
2252 );
2253 for p in &parts {
2254 let part_id = p.get("id").and_then(Value::as_str).unwrap_or("");
2255 raw.push(serde_json::json!({"key": ["part", id, part_id], "value": p}).to_string());
2256 }
2257
2258 msgs.push(OcMsg {
2259 id: id.to_string(),
2260 time_created,
2261 value: info.clone(),
2262 parts,
2263 });
2264 }
2265
2266 opencode_guard_against_silent_empty(true, &session_info, &msgs, &[])?;
2267 opencode_session_from_records(
2268 session_info,
2269 Vec::new(),
2270 msgs,
2271 raw,
2272 // Synthesized `raw` (§1.2's "Export-document raw" — one envelope
2273 // line re-derived per record, no real per-line source bytes to
2274 // measure) — matches the historical always-newline-terminated
2275 // behavior; see `Session::raw_trailing_newline`'s doc comment.
2276 true,
2277 // Export-document form: `raw` above is RE-SYNTHESIZED, one
2278 // envelope line derived per record — not the original document's
2279 // bytes (see this function's doc comment). `convert`'s
2280 // byte-identical claim must not fire on this diagonal.
2281 false,
2282 // PARITY-15: a pretty-printed export document is parsed WHOLE
2283 // (the caller's `if let Ok(doc) = serde_json::from_str(...)`) —
2284 // there's no per-line parse-loss concept here; a malformed
2285 // document fails that top-level parse and never reaches this
2286 // function at all.
2287 0,
2288 )
2289 }
2290
2291 /// P5-5 (design §2 module 21 `session.tree`, §2.1 D-6 "session.tree →
2292 /// core.session(tree-addressable transcript)"): materialize this
2293 /// session's linear [`Self::messages`] into a native in-place
2294 /// [`crate::session_tree::SessionTree`] — the bridge a caller uses the
2295 /// FIRST time it wants to run a tree operation (rewind/branch/label)
2296 /// against an otherwise-linear [`Session`]. `created_at_ms` stamps every
2297 /// synthesized node (see
2298 /// [`crate::session_tree::SessionTree::from_linear`]'s doc comment for
2299 /// why a single timestamp is used: the source linear messages carry no
2300 /// per-turn timestamp of their own here).
2301 ///
2302 /// This does not mutate `self` or persist anything — see
2303 /// [`crate::store::SessionStore::save_tree`] for the sidecar write, and
2304 /// [`Self::apply_session_tree`] for the inverse bridge.
2305 pub fn to_session_tree(&self, created_at_ms: i64) -> crate::session_tree::SessionTree {
2306 crate::session_tree::SessionTree::from_linear(&self.messages, created_at_ms)
2307 }
2308
2309 /// P5-5: the inverse of [`Self::to_session_tree`] — overwrite
2310 /// [`Self::messages`] with `tree`'s ACTIVE branch's linear projection
2311 /// (C7's "tree-with-linear-projection": this is exactly what keeps every
2312 /// existing linear consumer — the agent loop, exporters — working
2313 /// unchanged after a tree operation runs). Nothing else on `self`
2314 /// (`meta`, `raw`, ...) is touched.
2315 ///
2316 /// **Fail-closed.** Propagates [`crate::session_tree::SessionTree::linear_projection`]'s
2317 /// `Err` rather than applying anything — a structurally-corrupt tree
2318 /// (a cycle, a dangling leaf, an active branch pointing at nothing) must
2319 /// error, not silently overwrite [`Self::messages`] with an empty `Vec`.
2320 /// `self` is left untouched on `Err` (the assignment only happens after
2321 /// the projection has already succeeded).
2322 pub fn apply_session_tree(&mut self, tree: &crate::session_tree::SessionTree) -> Result<()> {
2323 self.messages = tree.linear_projection()?;
2324 Ok(())
2325 }
2326}
2327
2328/// One opencode `message` record plus its `part` children, gathered from
2329/// EITHER read surface (envelope-form records or export-document
2330/// `{info, parts}` entries) before the shared per-record canonicalization
2331/// in [`opencode_session_from_records`].
2332struct OcMsg {
2333 id: String,
2334 time_created: i64,
2335 value: Value,
2336 parts: Vec<Value>,
2337}
2338
2339const OPENCODE_SUPERCODE_MESSAGE_POSITION: &str = "_supercode_message_position";
2340const OPENCODE_SUPERCODE_RESULT_POSITION: &str = "_supercode_result_position";
2341const OPENCODE_INTERNAL_ORIGINAL_POSITION: &str = "__supercode_original_position";
2342
2343/// Guard against the confirmed footgun: input that reached
2344/// [`Session::from_opencode_str`] non-empty but produced no
2345/// session/message/part record under either read surface returns `Err`
2346/// instead of a silently-empty `Ok(Session)`. A legitimately-empty session
2347/// (a real session record with zero messages, or a valid empty `messages`
2348/// array) is not an error — only genuinely unparseable content is.
2349fn opencode_guard_against_silent_empty(
2350 non_empty_input: bool,
2351 session_info: &Option<Value>,
2352 msgs: &[OcMsg],
2353 side_records: &[Value],
2354) -> Result<()> {
2355 let has_any_record = session_info.as_ref().is_some_and(|v| !v.is_null())
2356 || !msgs.is_empty()
2357 || !side_records.is_empty();
2358 if non_empty_input && !has_any_record {
2359 return Err(crate::Error::Other(
2360 "opencode input was recognized as an OpenCode source (envelope or \
2361 export-document form) but no session/message/part record could be parsed from \
2362 it — refusing to silently return an empty session"
2363 .to_string(),
2364 ));
2365 }
2366 Ok(())
2367}
2368
2369/// The shared per-record canonicalization for BOTH of
2370/// [`Session::from_opencode_str`]'s read surfaces (envelope form and
2371/// export-document form): frozen ordering, `SessionMeta` capture, the
2372/// compaction boundary pass, and the `User`/`Assistant` → `messages`
2373/// mapping via [`push_opencode_user`]/[`push_opencode_assistant`]. Fed the
2374/// same underlying `(session_info, side_records, msgs)` regardless of which
2375/// surface produced them, this produces byte-for-byte identical `messages`
2376/// — the invariant that makes `load(to_opencode_jsonl(S))` round-trip.
2377fn opencode_session_from_records(
2378 session_info: Option<Value>,
2379 side_records: Vec<Value>,
2380 mut msgs: Vec<OcMsg>,
2381 raw: Vec<String>,
2382 raw_trailing_newline: bool,
2383 raw_is_verbatim: bool,
2384 parse_error_lines: usize,
2385) -> Result<Session> {
2386 let mut meta = SessionMeta::new(SessionSource::OpenCode);
2387
2388 // `msg_index` is captured BEFORE the frozen-order sort below, mapping
2389 // each message id to its PRE-sort position — used only to resolve a
2390 // `tail_start_id` reference in the compaction-boundary pass further
2391 // down. In every real opencode session (either surface) records
2392 // already arrive/are listed in creation order, so pre- and post-sort
2393 // positions coincide; this mirrors the original envelope-only
2394 // implementation's behavior exactly (not a new invariant introduced by
2395 // sharing this code across both surfaces).
2396 let msg_index: HashMap<String, usize> = msgs
2397 .iter()
2398 .enumerate()
2399 .map(|(i, m)| (m.id.clone(), i))
2400 .collect();
2401
2402 // Frozen order (§1.2): messages by (time.created, id); each
2403 // message's parts by id.
2404 msgs.sort_by(|a, b| {
2405 a.time_created
2406 .cmp(&b.time_created)
2407 .then_with(|| a.id.cmp(&b.id))
2408 });
2409 for m in &mut msgs {
2410 m.parts.sort_by(|a, b| {
2411 let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
2412 let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
2413 ai.cmp(bi)
2414 });
2415 }
2416
2417 meta.opencode_headers
2418 .push(session_info.clone().unwrap_or(Value::Null));
2419 meta.opencode_headers.extend(side_records);
2420 if let Some(si) = &session_info {
2421 capture_opencode_session_info(si, &mut meta)?;
2422 }
2423
2424 // Compaction boundary (§2.1/§2.2 S3): the LATEST `tail_start_id`
2425 // seen — mirrors pi's `kept_from_pos` discipline (there is only one
2426 // active path in opencode's own linear message list, so no branch
2427 // walk is needed the way pi's tree requires).
2428 let mut tail_start_pos: Option<usize> = None;
2429 for m in &msgs {
2430 for p in &m.parts {
2431 if p.get("type").and_then(Value::as_str) == Some("compaction") {
2432 if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
2433 if let Some(&tp) = msg_index.get(t) {
2434 tail_start_pos = Some(tail_start_pos.map_or(tp, |cur| cur.max(tp)));
2435 }
2436 }
2437 }
2438 }
2439 }
2440
2441 let mut messages = Vec::new();
2442 let mut first_system_seen = false;
2443 for (pos, m) in msgs.iter().enumerate() {
2444 let before = messages.len();
2445 match m.value.get("role").and_then(Value::as_str) {
2446 // B4: a `User` message that's actually
2447 // `append_synthesized_opencode_messages`'s own re-materialized
2448 // Claude `system` record (one `synthetic: true` text part
2449 // carrying the supercode marker key — see
2450 // `opencode_claude_system_subtype`'s doc comment) restores
2451 // `Role::System`, not a genuine user turn.
2452 Some("user") => match opencode_claude_system_subtype(&m.parts) {
2453 Some(subtype) => {
2454 push_opencode_claude_system(&m.value, &m.parts, subtype, &mut messages)
2455 }
2456 None => push_opencode_user(
2457 &m.value,
2458 &m.parts,
2459 &mut messages,
2460 &mut meta,
2461 &mut first_system_seen,
2462 ),
2463 },
2464 Some("assistant") => {
2465 push_opencode_assistant(&m.value, &m.parts, &mut messages, &mut meta)
2466 }
2467 // Unrecognized/missing role — raw-only survival;
2468 // `audit::Corpus::OpenCode` scores this as Unmodeled.
2469 _ => {}
2470 }
2471 if let Some(original_position) = m
2472 .value
2473 .get(OPENCODE_SUPERCODE_MESSAGE_POSITION)
2474 .and_then(Value::as_u64)
2475 {
2476 if let Some(message) = messages[before..]
2477 .iter_mut()
2478 .find(|message| message.role != Role::Tool)
2479 {
2480 message.metadata.insert(
2481 OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
2482 original_position.to_string(),
2483 );
2484 }
2485 }
2486 for msg in &mut messages[before..] {
2487 let is_summary = msg.metadata.get("is_summary").map(String::as_str) == Some("true");
2488 if !is_summary {
2489 if let Some(tsp) = tail_start_pos {
2490 if pos < tsp {
2491 msg.metadata
2492 .insert("compacted_out".to_string(), "true".to_string());
2493 }
2494 }
2495 }
2496 }
2497 }
2498
2499 let marked_slots = messages
2500 .iter()
2501 .enumerate()
2502 .filter_map(|(index, message)| {
2503 message
2504 .metadata
2505 .contains_key(OPENCODE_INTERNAL_ORIGINAL_POSITION)
2506 .then_some(index)
2507 })
2508 .collect::<Vec<_>>();
2509 if !marked_slots.is_empty() {
2510 // A spliced OpenCode export can contain an unmarked native prefix
2511 // followed by a marked synthesized tail. Reorder only among the
2512 // marked slots so the tail never jumps in front of its raw prefix.
2513 let mut marked_messages = marked_slots
2514 .iter()
2515 .map(|index| messages[*index].clone())
2516 .collect::<Vec<_>>();
2517 marked_messages.sort_by_key(|message| {
2518 message
2519 .metadata
2520 .get(OPENCODE_INTERNAL_ORIGINAL_POSITION)
2521 .and_then(|position| position.parse::<usize>().ok())
2522 .unwrap_or(usize::MAX)
2523 });
2524 for (slot, message) in marked_slots.into_iter().zip(marked_messages) {
2525 messages[slot] = message;
2526 }
2527 for message in &mut messages {
2528 message.metadata.remove(OPENCODE_INTERNAL_ORIGINAL_POSITION);
2529 }
2530 }
2531 ensure_tool_results_paired(&mut messages);
2532 let imported_message_count = Some(messages.len());
2533 Ok(Session {
2534 meta,
2535 messages,
2536 subagents: Vec::new(),
2537 raw,
2538 raw_trailing_newline,
2539 imported_message_count,
2540 raw_is_verbatim,
2541 parse_error_lines,
2542 load_residue: Vec::new(),
2543 })
2544}
2545
2546/// Resolve each opencode subagent (`task`) child session's
2547/// `meta.parent_tool_use_id` from its parent's own `task` tool part
2548/// `callID` (`docs/interop/opencode-pi-spec.md` §2.1 — the analogue of
2549/// Claude Code's `agentId`/`parent_tool_use_id` linkage,
2550/// `opencode-fields.md` `task.ts:145,171-176`).
2551///
2552/// Nesting itself needs no opencode-specific pass:
2553/// [`capture_opencode_session_info`] already mirrors `SessionInfo.parentID`
2554/// into `lineage["parent_thread_id"]` (the same key Codex subagents use),
2555/// so the existing generic [`Session::reconstruct_tree`] nests these
2556/// sessions correctly on its own. Call this FIRST — it only reads
2557/// `session_id`/`messages`/metadata, never reorders `sessions` — then feed
2558/// the same `Vec` to `reconstruct_tree`.
2559pub fn resolve_opencode_parent_tool_use_ids(sessions: &mut [Session]) {
2560 let ids: Vec<Option<String>> = sessions.iter().map(|s| s.meta.session_id.clone()).collect();
2561 for i in 0..sessions.len() {
2562 let child_id = sessions[i].meta.session_id.clone();
2563 let parent_id = sessions[i].meta.lineage.get("parent_session_id").cloned();
2564 let (Some(child_id), Some(parent_id)) = (child_id, parent_id) else {
2565 continue;
2566 };
2567 let Some(parent_idx) = ids
2568 .iter()
2569 .position(|id| id.as_deref() == Some(parent_id.as_str()))
2570 else {
2571 continue;
2572 };
2573 for m in &sessions[parent_idx].messages {
2574 for (k, v) in &m.metadata {
2575 if let Some(call_id) = k.strip_prefix("oc_task_child_session_id__") {
2576 if v == &child_id {
2577 sessions[i].meta.parent_tool_use_id = Some(call_id.to_string());
2578 }
2579 }
2580 }
2581 }
2582 }
2583}
2584
2585/// D7: repair tool-result/tool-call ORDERING for the Claude Code loader.
2586///
2587/// Real Claude Code transcripts are well-formed by `uuid`/`parentUuid` tree
2588/// structure but are NOT guaranteed to be well-formed in raw file order: async
2589/// tool-result delivery (or an edited turn) can write a `tool_result` `user`
2590/// line BEFORE the assistant `tool_use` line that owns it, even though the
2591/// parent/child tree itself is fine. The active-branch projection restores
2592/// parent-before-child order, but a result can still trail a later assistant
2593/// turn instead of sitting next to the assistant that owns it. Both OpenAI-
2594/// and Anthropic-shaped wire APIs reject either shape on `resume`. Measured
2595/// on a real corpus: 21/450 sessions (4.7%), 89 inverted pairs.
2596///
2597/// This reorders `messages` so every OWNED `Role::Tool` result (its
2598/// `tool_call_id` matches a `tool_calls` entry on some `Role::Assistant`
2599/// message anywhere in the list) sits immediately after the `Role::Assistant`
2600/// message that owns it, while leaving every other message's relative order
2601/// untouched. Orphan tool results — no matching call anywhere in the list —
2602/// are left in their ORIGINAL position, untouched; they are never moved. It
2603/// is a pure reorder: same message count, same multiset of messages, in/out.
2604///
2605/// Tool-call ids (`toolu_*`) are globally unique within a Claude Code session
2606/// — each appears exactly once as a call and once as its result — so a
2607/// simple id -> owning-assistant map is sufficient; no special-casing is
2608/// needed for sidechain (`isSidechain`) messages, since `push_claude_*`
2609/// already pushes those inline with their own distinct ids.
2610///
2611/// Results whose matching call is missing entirely (no owner found) are left
2612/// in place untouched — `ensure_tool_results_paired` (which runs right after
2613/// this) is responsible for synthesizing a placeholder result for any call
2614/// that ends up unanswered; this pass never drops or fabricates anything.
2615fn reorder_tool_results_after_calls(messages: &mut Vec<ChatMessage>) {
2616 // 0. First pass: which tool_call_ids are actually "owned" — emitted by
2617 // some assistant message anywhere in the list — and the position of
2618 // that owning assistant. Owned as `String` (not borrowed) so this map
2619 // can outlive the later `messages.drain(..)`.
2620 let mut owner_positions: HashMap<String, usize> = HashMap::new();
2621 for (index, m) in messages.iter().enumerate() {
2622 if m.role == Role::Assistant {
2623 for c in m.tool_calls() {
2624 if !c.id.is_empty() {
2625 owner_positions.entry(c.id.clone()).or_insert(index);
2626 }
2627 }
2628 }
2629 }
2630
2631 // Fast, cheap detection of "nothing to do": every owned result must be
2632 // in the contiguous tool-result block immediately following its owning
2633 // assistant. Checking only result-before-owner inversions is insufficient
2634 // after Claude's active-branch projection: that projection can put the
2635 // owner first while leaving its result behind a later assistant turn.
2636 // Mere orphans never set this flag. A canonical session returns with
2637 // `messages` byte-for-byte unchanged, mirroring
2638 // `ensure_tool_results_paired`'s own no-op guard.
2639 let mut contiguous_owner = None;
2640 let needs_reorder =
2641 messages
2642 .iter()
2643 .enumerate()
2644 .any(|(message_index, message)| match message.role {
2645 Role::Assistant => {
2646 contiguous_owner = Some(message_index);
2647 false
2648 }
2649 Role::Tool => match message
2650 .tool_call_id
2651 .as_deref()
2652 .and_then(|id| owner_positions.get(id))
2653 .copied()
2654 {
2655 Some(owner) => Some(owner) != contiguous_owner,
2656 None => {
2657 // An orphan or unlinked tool message interrupts the
2658 // owner's contiguous result block but never moves by
2659 // itself.
2660 contiguous_owner = None;
2661 false
2662 }
2663 },
2664 _ => {
2665 contiguous_owner = None;
2666 false
2667 }
2668 });
2669 if !needs_reorder {
2670 return;
2671 }
2672
2673 // 1. Second pass: route messages into the "spine" (everything that stays
2674 // at its own position — non-tool messages AND orphan tool results)
2675 // versus owned tool results (pulled out, to be reattached right after
2676 // their owner). Record, for each spine index that's an assistant, the
2677 // set of tool_call_ids it owns.
2678 let mut spine: Vec<ChatMessage> = Vec::with_capacity(messages.len());
2679 let mut call_owner: HashMap<String, usize> = HashMap::new();
2680 // Buffer of (original_position, message) for every OWNED tool result,
2681 // built alongside the spine; a result can reference a call emitted later
2682 // in file order, so owner spine-index is resolved in a later step once
2683 // `call_owner` is complete.
2684 let mut owned_results: Vec<(usize, ChatMessage)> = Vec::new();
2685
2686 let drained: Vec<ChatMessage> = std::mem::take(messages);
2687 for (orig_pos, msg) in drained.into_iter().enumerate() {
2688 if msg.role == Role::Tool {
2689 let is_owned = msg
2690 .tool_call_id
2691 .as_deref()
2692 .map(|id| !id.is_empty() && owner_positions.contains_key(id))
2693 .unwrap_or(false);
2694 if is_owned {
2695 owned_results.push((orig_pos, msg));
2696 continue;
2697 }
2698 // Orphan: no matching call anywhere. Treat exactly like a
2699 // non-tool message for placement — it joins the spine at its
2700 // current position and is never moved.
2701 spine.push(msg);
2702 continue;
2703 }
2704 if msg.role == Role::Assistant {
2705 let spine_idx = spine.len();
2706 for c in msg.tool_calls() {
2707 if !c.id.is_empty() {
2708 call_owner.entry(c.id.clone()).or_insert(spine_idx);
2709 }
2710 }
2711 }
2712 spine.push(msg);
2713 }
2714
2715 // 2. Resolve each owned result's owner spine-index now that `call_owner`
2716 // is complete, then bucket results by owner spine-index. Every result
2717 // here was routed as "owned" because its id was found in `owned_ids`,
2718 // which was built from the exact same `tool_calls()` scan that
2719 // populates `call_owner` below, so the lookup is guaranteed to hit.
2720 let mut buckets: HashMap<usize, Vec<(usize, ChatMessage)>> = HashMap::new();
2721 for (orig_pos, msg) in owned_results.into_iter() {
2722 let id = msg
2723 .tool_call_id
2724 .as_deref()
2725 .filter(|id| !id.is_empty())
2726 .expect("routed as owned, so tool_call_id must be a non-empty owned id");
2727 let idx = *call_owner
2728 .get(id)
2729 .expect("owned id must have an owning assistant in call_owner");
2730 buckets.entry(idx).or_default().push((orig_pos, msg));
2731 }
2732 // Keep each bucket's results in their original relative file order.
2733 for v in buckets.values_mut() {
2734 v.sort_by_key(|(pos, _)| *pos);
2735 }
2736
2737 // 3. Rebuild: emit each spine message (which now includes orphans at
2738 // their original position, untouched) in order; immediately after
2739 // emitting an assistant message that owns one or more tool results,
2740 // emit its owned results, in original relative order.
2741 let mut out: Vec<ChatMessage> = Vec::with_capacity(spine.len() + buckets.len());
2742 for (idx, msg) in spine.into_iter().enumerate() {
2743 out.push(msg);
2744 if let Some(results) = buckets.remove(&idx) {
2745 for (_, r) in results {
2746 out.push(r);
2747 }
2748 }
2749 }
2750 *messages = out;
2751}
2752
2753/// Guarantee every assistant `tool_calls` entry is answered by a following tool
2754/// result. Interrupted/aborted turns leave a tool call with no result, which
2755/// many chat-completions endpoints reject when the conversation is replayed.
2756/// We insert a synthetic placeholder result immediately after the assistant
2757/// turn so the transcript stays valid for continuation. (Orphan results — a
2758/// tool message with no preceding call — do not occur in practice and are left
2759/// untouched.)
2760fn ensure_tool_results_paired(messages: &mut Vec<ChatMessage>) {
2761 let answered: HashSet<String> = messages
2762 .iter()
2763 .filter(|m| m.role == Role::Tool)
2764 .filter_map(|m| m.tool_call_id.clone())
2765 .collect();
2766
2767 // Nothing missing? Leave the vector byte-for-byte unchanged.
2768 let any_missing = messages.iter().any(|m| {
2769 m.tool_calls()
2770 .iter()
2771 .any(|c| !c.id.is_empty() && !answered.contains(&c.id))
2772 });
2773 if !any_missing {
2774 return;
2775 }
2776
2777 let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len() + 4);
2778 for msg in messages.drain(..) {
2779 let synth: Vec<ChatMessage> = msg
2780 .tool_calls()
2781 .iter()
2782 .filter(|c| !c.id.is_empty() && !answered.contains(&c.id))
2783 .map(|c| {
2784 let mut m = ChatMessage::tool_result(
2785 c.id.clone(),
2786 c.function.name.clone(),
2787 "[no tool result recorded — turn interrupted]".to_string(),
2788 );
2789 // TR-10: an interrupted call never executed to completion —
2790 // never a candidate for `ReductionKind::ToolInputElided`
2791 // (the "still-pending calls are never input-elided"
2792 // boundary).
2793 crate::reduce::mark_tool_error(&mut m);
2794 m
2795 })
2796 .collect();
2797 out.push(msg);
2798 out.extend(synth);
2799 }
2800 *messages = out;
2801}
2802
2803/// Whether `msg` is excluded from every replay/export path — the frozen
2804/// cross-format discipline (`docs/interop/opencode-pi-spec.md` §2.1/§2.2/§6):
2805/// a message marked `compacted_out` (pre-compaction history a source harness
2806/// itself no longer replays) or `pi_exclude_from_context` (a pi `!!` bash
2807/// escape) must never reach `to_jsonl`/`to_jsonl_spliced` for ANY target
2808/// format, not just the one that produced the marker — so a translated
2809/// compacted session replays the same sliced context the source harness
2810/// would, instead of double-including history plus its own summary.
2811fn is_replay_excluded(msg: &ChatMessage) -> bool {
2812 msg.metadata.get("compacted_out").map(String::as_str) == Some("true")
2813 || msg
2814 .metadata
2815 .get("pi_exclude_from_context")
2816 .map(String::as_str)
2817 == Some("true")
2818}
2819
2820// ---- detection ------------------------------------------------------------
2821
2822fn detect_source(text: &str) -> Option<SessionSource> {
2823 // OpenCode export-document form (`docs/interop/opencode-pi-spec.md`
2824 // §1.2/S9a): `{info: SessionInfo, messages: [...]}` — a single
2825 // pretty-printed, MULTI-LINE JSON document, unlike every other format
2826 // this crate reads. It cannot be recognized by the per-line loop below
2827 // (no individual line of a pretty-printed document is itself valid
2828 // JSON), so it gets its own whole-text parse attempt up front. Cheap to
2829 // attempt: a real JSONL file (many newline-separated objects) fails this
2830 // parse immediately (trailing-data error) and falls through unaffected.
2831 if let Ok(v) = serde_json::from_str::<Value>(text.trim()) {
2832 if v.get("info").is_some() && v.get("messages").and_then(Value::as_array).is_some() {
2833 return Some(SessionSource::OpenCode);
2834 }
2835 }
2836 for line in non_empty_lines(text) {
2837 // Tolerate a corrupt/truncated line (e.g. a partial first line) rather
2838 // than abandoning detection — the loaders themselves skip bad lines, so
2839 // bailing here would silently misroute an otherwise-valid Codex file.
2840 let Ok(v) = serde_json::from_str::<Value>(line) else {
2841 continue;
2842 };
2843 // OpenCode's frozen envelope form (§1.2): `{"key":[...],"value":...}`,
2844 // one record per line — the synthesized raw-capture unit for the
2845 // JSON-tree/SQLite generations alike. No other format's lines carry
2846 // both a top-level `key` ARRAY and a `value` field, so this is
2847 // unambiguous against Codex/Pi/Claude Code.
2848 if v.get("key").and_then(Value::as_array).is_some() && v.get("value").is_some() {
2849 return Some(SessionSource::OpenCode);
2850 }
2851 // Codex envelopes always carry a `payload`; Claude Code lines never do.
2852 if v.get("payload").is_some() {
2853 return Some(SessionSource::Codex);
2854 }
2855 // Grok's resumable `chat_history.jsonl` stores the role/type and
2856 // content directly on each record. Claude Code uses a nested
2857 // `message` envelope for the overlapping `user`/`assistant` tags.
2858 let tag = v.get("type").and_then(Value::as_str);
2859 if v.get("message").is_none()
2860 && v.get("uuid").is_none()
2861 && v.get("sessionId").is_none()
2862 && matches!(
2863 tag,
2864 Some(
2865 "system"
2866 | "user"
2867 | "assistant"
2868 | "tool_result"
2869 | "reasoning"
2870 | "backend_tool_call"
2871 )
2872 )
2873 && (v.get("content").is_some()
2874 || v.get("tool_calls").is_some()
2875 || v.get("tool_call_id").is_some()
2876 || v.get("encrypted_content").is_some()
2877 || v.get("kind").is_some())
2878 {
2879 return Some(SessionSource::Grok);
2880 }
2881 // Pi's `SessionHeader` (line 1, always `type:"session"`): a bare `id`
2882 // (the session id) with no `message`/`uuid` — Claude Code's own
2883 // `type`-bearing lines always carry one or the other, never a
2884 // `type:"session"` header shape (`docs/interop/research/pi-fields.md`
2885 // §1).
2886 if v.get("type").and_then(Value::as_str) == Some("session")
2887 && v.get("id").and_then(Value::as_str).is_some()
2888 && v.get("message").is_none()
2889 && v.get("uuid").is_none()
2890 {
2891 return Some(SessionSource::Pi);
2892 }
2893 if v.get("type").is_some() || v.get("message").is_some() {
2894 return Some(SessionSource::ClaudeCode);
2895 }
2896 }
2897 None
2898}
2899
2900/// Which on-disk OpenCode storage surface is present under a data root
2901/// (`docs/interop/opencode-pi-spec.md` §1.2/S9a): SQLite `opencode*.db` (or
2902/// `$OPENCODE_DB`), legacy JSON tree generation B, or legacy JSON tree
2903/// generation A. This is a **filesystem classifier only** — it answers
2904/// "which generation is this?" for a corpus-discovery tool; it does not
2905/// itself read/parse the surface. See [`Session::from_opencode_str`]'s docs
2906/// for the envelope form any of these three surfaces synthesizes into, and
2907/// [`Session::from_opencode_sqlite`] (PARITY-3/PARITY-16) for the `rusqlite`
2908/// reader that reconstructs that same envelope form from `Sqlite`'s rows —
2909/// `JsonTreeA`/`JsonTreeB` remain classifier-only (their `session_diff`
2910/// round-trips via the JSON store per upstream's own behavior even on a
2911/// SQLite install, so nothing is silently lost by not reading the legacy
2912/// trees directly).
2913#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2914pub enum OpenCodeStorageSurface {
2915 /// SQLite `<data>/opencode*.db` (or `$OPENCODE_DB` override). Read by
2916 /// [`Session::from_opencode_sqlite`] / [`opencode_sqlite_store_stats`] /
2917 /// [`opencode_sqlite_corpus_envelope_text`].
2918 Sqlite,
2919 /// Legacy JSON tree, generation B: `<data>/storage/{session,message,part,session_diff}/…`,
2920 /// marker file `storage/migration`.
2921 JsonTreeB,
2922 /// Legacy JSON tree, generation A: `<data>/project/<slug>/storage/session/{info,message,part}/…`.
2923 JsonTreeA,
2924}
2925
2926/// Probe `data_root` (e.g. `~/.local/share/opencode`) for the OpenCode
2927/// storage surface present, per the discovery rules frozen in
2928/// `docs/interop/opencode-pi-spec.md` §1.2/S9a: `$OPENCODE_DB` wins outright;
2929/// otherwise glob `opencode*.db` (not just `opencode.db` — dev/beta channels
2930/// suffix the filename, `database.ts:43-55`); otherwise look for the JSON
2931/// tree generation-B marker (`storage/migration`); otherwise generation-A's
2932/// `project/` subtree. Returns `None` if nothing is found.
2933pub fn detect_opencode_storage_surface(
2934 data_root: &Path,
2935) -> Option<(OpenCodeStorageSurface, PathBuf)> {
2936 if let Ok(p) = std::env::var("OPENCODE_DB") {
2937 let pb = PathBuf::from(p);
2938 if pb.is_file() {
2939 return Some((OpenCodeStorageSurface::Sqlite, pb));
2940 }
2941 }
2942 if let Ok(entries) = std::fs::read_dir(data_root) {
2943 // D8: `std::fs::read_dir`'s iteration order is filesystem-dependent,
2944 // NOT deterministic — a store with both a default-channel
2945 // `opencode.db` and a channel-suffixed `opencode-dev.db` (S9a: both
2946 // are legal, e.g. after switching install channels) previously
2947 // returned "whichever the OS happened to list first", which could
2948 // differ between two `inspect`/`audit`/`convert` runs against the
2949 // exact same directory. Collect every `opencode*.db` candidate and
2950 // pick deterministically: the exact `opencode.db` name wins if
2951 // present (the default/most-common channel); otherwise the
2952 // lexicographically-smallest match, so repeated runs always agree.
2953 let mut candidates: Vec<PathBuf> = entries
2954 .flatten()
2955 .map(|entry| entry.path())
2956 .filter(|p| {
2957 p.file_name()
2958 .and_then(|n| n.to_str())
2959 .is_some_and(|name| name.starts_with("opencode") && name.ends_with(".db"))
2960 })
2961 .collect();
2962 candidates.sort();
2963 if let Some(exact) = candidates
2964 .iter()
2965 .find(|p| p.file_name().and_then(|n| n.to_str()) == Some("opencode.db"))
2966 {
2967 return Some((OpenCodeStorageSurface::Sqlite, exact.clone()));
2968 }
2969 if let Some(first) = candidates.into_iter().next() {
2970 return Some((OpenCodeStorageSurface::Sqlite, first));
2971 }
2972 }
2973 let storage = data_root.join("storage");
2974 if storage.join("migration").is_file() {
2975 return Some((OpenCodeStorageSurface::JsonTreeB, storage));
2976 }
2977 let project_dir = data_root.join("project");
2978 if project_dir.is_dir() {
2979 return Some((OpenCodeStorageSurface::JsonTreeA, project_dir));
2980 }
2981 None
2982}
2983
2984/// STRICT-VERBATIM line split for `raw` capture (IX-1) — the counterpart to
2985/// [`non_empty_lines`]'s blank-skipping/trimming PARSE view.
2986///
2987/// Splits only on `\n` (never treats `\r\n` specially), so a CRLF source's
2988/// `\r` survives as part of the returned line's own content; blank lines and
2989/// trailing-whitespace-only lines are kept verbatim rather than dropped or
2990/// trimmed. This is what makes `Session.raw` — populated from this at every
2991/// raw-capture site — byte-for-byte faithful to the source for ANY input, not
2992/// just well-formed LF JSONL with no blank lines.
2993///
2994/// Returns `(lines, ends_with_newline)`. `lines.join("\n")` alone cannot
2995/// distinguish a source that ended with a trailing newline from one that
2996/// didn't (both split into the same line list), so `ends_with_newline`
2997/// records that fact out-of-band — [`join_lines_verbatim`] is the exact
2998/// inverse, given both. `text == ""` yields `(vec![], false)`: an empty
2999/// source has zero lines, not one blank line.
3000fn split_lines_verbatim(text: &str) -> (Vec<&str>, bool) {
3001 if text.is_empty() {
3002 return (Vec::new(), false);
3003 }
3004 let ends_with_newline = text.ends_with('\n');
3005 let body = if ends_with_newline {
3006 &text[..text.len() - 1]
3007 } else {
3008 text
3009 };
3010 (body.split('\n').collect(), ends_with_newline)
3011}
3012
3013/// The exact inverse of [`split_lines_verbatim`]: reconstruct the original
3014/// source bytes from its verbatim lines plus the trailing-newline flag.
3015fn join_lines_verbatim(lines: &[String], ends_with_newline: bool) -> String {
3016 let mut out = lines.join("\n");
3017 if ends_with_newline {
3018 out.push('\n');
3019 }
3020 out
3021}
3022
3023// ---- OpenCode SQLite (PARITY-3/PARITY-4/PARITY-16) -------------------------
3024//
3025// Reads a real `opencode*.db` store directly with `rusqlite` (bundled
3026// SQLite — no system library dependency) and reconstructs the SAME envelope
3027// form `{"key":[...],"value":...}` that `Session::from_opencode_str` already
3028// parses for the JSON-tree surfaces (`docs/interop/opencode-pi-spec.md`
3029// §1.2/S9c). Row -> envelope reconstruction mirrors the real app's own
3030// `session.ts` `fromRow` (session table: columnar fields recombined into the
3031// camelCase `SessionInfo` shape) with ONE deliberate divergence: `revert` is
3032// carried as the RAW column value, not upstream's own `fromRow`
3033// reconstruction — which silently drops the V2 `Revert.State` schema's extra
3034// `files` field (`session.ts:70-76`, `revert.ts:18-24`, spec finding S9c).
3035// `message`/`part` rows are simpler: their `data` column is already the V1
3036// `Info`/`Part` JSON minus the id columns hoisted out by the schema
3037// (`session/sql.ts:68-98`), so reconstruction is just re-injecting
3038// `id`/`sessionID`(/`messageID`).
3039
3040/// First 16 bytes of every SQLite database file — the format's own magic,
3041/// independent of file extension.
3042const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0";
3043
3044/// Whether `path` should be routed to the OpenCode SQLite loader instead of
3045/// the UTF-8 text path (PARITY-16): true when the file's first 16 bytes are
3046/// the SQLite magic, OR its extension is `.db` — the latter so a
3047/// corrupted/truncated `opencode*.db` still gets `rusqlite`'s own "not a
3048/// database" diagnostic (PARITY-3 AC03) instead of a confusing UTF-8 error.
3049/// A non-existent path is NOT considered SQLite here — the missing-file
3050/// diagnostic in that case comes from the normal load path (`with_context`
3051/// at the CLI call sites), which already names the path clearly.
3052pub fn looks_like_sqlite(path: &Path) -> bool {
3053 if !path.is_file() {
3054 return false;
3055 }
3056 if path.extension().and_then(|e| e.to_str()) == Some("db") {
3057 return true;
3058 }
3059 use std::io::Read;
3060 let Ok(mut f) = std::fs::File::open(path) else {
3061 return false;
3062 };
3063 let mut buf = [0u8; 16];
3064 f.read_exact(&mut buf).is_ok() && &buf == SQLITE_MAGIC
3065}
3066
3067/// Read `path` as UTF-8 text, translating a non-UTF-8 failure into a clear,
3068/// format-aware diagnostic (PARITY-16) instead of the raw "stream did not
3069/// contain valid UTF-8" `io::Error` — named path, and what supercode DOES
3070/// accept there. Binary SQLite input never reaches this function: callers
3071/// check [`looks_like_sqlite`] first and route to
3072/// [`Session::from_opencode_sqlite`] instead.
3073fn read_utf8_or_diagnose(path: &Path) -> Result<String> {
3074 let bytes = std::fs::read(path)?;
3075 String::from_utf8(bytes).map_err(|_| {
3076 crate::Error::Other(format!(
3077 "{} is not valid UTF-8 text, and is not a recognized OpenCode SQLite store \
3078 (no `SQLite format 3` header) — supercode reads Claude Code / Codex / Pi / \
3079 OpenCode session logs as UTF-8 JSONL, or an OpenCode `opencode*.db` SQLite file",
3080 path.display()
3081 ))
3082 })
3083}
3084
3085fn opencode_sql_err(e: rusqlite::Error, context: &str) -> crate::Error {
3086 crate::Error::Other(format!("OpenCode SQLite error while {context}: {e}"))
3087}
3088
3089/// Open `db_path` read-only and confirm it carries the expected V1 schema
3090/// (a `session` table) — the shared entry point for every SQLite read below,
3091/// so every caller gets the SAME clear diagnostics (PARITY-3 AC03): missing
3092/// path, not-a-database, and wrong/unsupported schema are each named
3093/// distinctly rather than surfacing later as "zero sessions" or a generic
3094/// parse failure.
3095fn opencode_sqlite_open(db_path: &Path) -> Result<Connection> {
3096 if !db_path.is_file() {
3097 return Err(crate::Error::Other(format!(
3098 "OpenCode SQLite store not found at {} — expected an `opencode*.db` file \
3099 (see `docs/interop/opencode-pi-spec.md` §1.2)",
3100 db_path.display()
3101 )));
3102 }
3103 let conn = Connection::open_with_flags(
3104 db_path,
3105 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
3106 )
3107 .map_err(|e| {
3108 crate::Error::Other(format!(
3109 "{} does not look like a valid OpenCode SQLite database: {e}",
3110 db_path.display()
3111 ))
3112 })?;
3113 let has_session_table: i64 = conn
3114 .query_row(
3115 "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='session'",
3116 [],
3117 |r| r.get(0),
3118 )
3119 .map_err(|e| {
3120 crate::Error::Other(format!(
3121 "failed to read the OpenCode SQLite schema at {}: {e}",
3122 db_path.display()
3123 ))
3124 })?;
3125 if has_session_table == 0 {
3126 return Err(crate::Error::Other(format!(
3127 "{} is a SQLite database but has no `session` table — not a recognized \
3128 OpenCode store (wrong file, or an unsupported/pre-SQLite OpenCode version)",
3129 db_path.display()
3130 )));
3131 }
3132 Ok(conn)
3133}
3134
3135/// Parse a nullable JSON-typed SQLite column (`metadata`, `revert`, `model`,
3136/// …). D7: an unparseable non-empty column previously degraded to
3137/// `Value::Null` with NO diagnostic — indistinguishable from a genuinely
3138/// absent/NULL column, so a corrupt `data`/`metadata` value silently
3139/// vanished (e.g. a message whose `data` fails to parse loses its entire
3140/// canonical content with no trace). A `tracing::warn!` now surfaces the
3141/// column name and context (session/record id) whenever this happens, so
3142/// the failure is diagnosable — `Value::Null` is kept as the parsed VALUE
3143/// (still the least-wrong placeholder for a broken column; changing it to a
3144/// sentinel would risk misleading every legitimate `.is_null()` check
3145/// elsewhere) but the frontend/log now knows it happened.
3146fn opencode_json_col(s: Option<String>, col: &str, context: &str) -> Value {
3147 match s.as_deref() {
3148 None => Value::Null,
3149 Some(t) => match serde_json::from_str::<Value>(t) {
3150 Ok(v) => v,
3151 Err(e) => {
3152 tracing::warn!(
3153 column = col,
3154 context,
3155 error = %e,
3156 "opencode SQLite column failed to parse as JSON — treating as absent (D7)"
3157 );
3158 Value::Null
3159 }
3160 },
3161 }
3162}
3163
3164/// Columns the `session` table has in a GIVEN store, read once per session
3165/// load via `PRAGMA table_info` (D3: real-world stores vary — a lean/older
3166/// `opencode` generation may lack columns the newest schema added, e.g.
3167/// `workspace_id`; rusqlite's `Row::get(name)` hard-errors
3168/// "Invalid column name" on an absent column, so callers must check
3169/// membership before reading a not-guaranteed column instead of reading it
3170/// unconditionally).
3171fn opencode_session_columns(
3172 conn: &Connection,
3173) -> rusqlite::Result<std::collections::HashSet<String>> {
3174 let mut stmt = conn.prepare("PRAGMA table_info(session)")?;
3175 let names = stmt.query_map([], |r| r.get::<_, String>(1))?; // column 1 = name
3176 names.collect()
3177}
3178
3179/// Reconstruct one `session` row into the camelCase `SessionInfo` envelope
3180/// value [`Session::from_opencode_str`]'s `capture_opencode_session_info`
3181/// already parses — mirrors upstream's own `session.ts` `fromRow`, EXCEPT
3182/// `revert` carries the raw column value verbatim rather than upstream's
3183/// field-selecting reconstruction (spec S9c: that reconstruction silently
3184/// drops the V2 `Revert.State` schema's extra `files` field).
3185///
3186/// D3: not every column this loader would like to read is guaranteed to
3187/// exist — a real, older/leaner `opencode` install's `session` table (e.g.
3188/// v1.2.15) lacks `workspace_id`/`path`/`metadata`/`cost`/`tokens_*`/
3189/// `agent`/`model` entirely. Those are read defensively (guarded by
3190/// [`opencode_session_columns`]); columns present in EVERY `opencode`
3191/// generation this loader has ever targeted are still read unconditionally.
3192fn opencode_row_session_info(conn: &Connection, session_id: &str) -> Result<Value> {
3193 let cols = opencode_session_columns(conn)
3194 .map_err(|e| opencode_sql_err(e, &format!("reading session `{session_id}` schema")))?;
3195 let has = |name: &str| cols.contains(name);
3196
3197 conn.query_row("SELECT * FROM session WHERE id = ?1", [session_id], |r| {
3198 let id: String = r.get("id")?;
3199 let project_id: String = r.get("project_id")?;
3200 let workspace_id: Option<String> = if has("workspace_id") {
3201 r.get("workspace_id")?
3202 } else {
3203 None
3204 };
3205 let parent_id: Option<String> = r.get("parent_id")?;
3206 let slug: String = r.get("slug")?;
3207 let directory: String = r.get("directory")?;
3208 let path: Option<String> = if has("path") { r.get("path")? } else { None };
3209 let title: String = r.get("title")?;
3210 let version: String = r.get("version")?;
3211 let share_url: Option<String> = r.get("share_url")?;
3212 let summary_additions: Option<i64> = r.get("summary_additions")?;
3213 let summary_deletions: Option<i64> = r.get("summary_deletions")?;
3214 let summary_files: Option<i64> = r.get("summary_files")?;
3215 let summary_diffs: Option<String> = r.get("summary_diffs")?;
3216 let metadata: Option<String> = if has("metadata") {
3217 r.get("metadata")?
3218 } else {
3219 None
3220 };
3221 let cost: f64 = if has("cost") { r.get("cost")? } else { 0.0 };
3222 let tokens_input: i64 = if has("tokens_input") {
3223 r.get("tokens_input")?
3224 } else {
3225 0
3226 };
3227 let tokens_output: i64 = if has("tokens_output") {
3228 r.get("tokens_output")?
3229 } else {
3230 0
3231 };
3232 let tokens_reasoning: i64 = if has("tokens_reasoning") {
3233 r.get("tokens_reasoning")?
3234 } else {
3235 0
3236 };
3237 let tokens_cache_read: i64 = if has("tokens_cache_read") {
3238 r.get("tokens_cache_read")?
3239 } else {
3240 0
3241 };
3242 let tokens_cache_write: i64 = if has("tokens_cache_write") {
3243 r.get("tokens_cache_write")?
3244 } else {
3245 0
3246 };
3247 let revert: Option<String> = r.get("revert")?;
3248 let permission: Option<String> = if has("permission") {
3249 r.get("permission")?
3250 } else {
3251 None
3252 };
3253 let agent: Option<String> = if has("agent") { r.get("agent")? } else { None };
3254 let model: Option<String> = if has("model") { r.get("model")? } else { None };
3255 let time_created: i64 = r.get("time_created")?;
3256 let time_updated: i64 = r.get("time_updated")?;
3257 let time_compacting: Option<i64> = if has("time_compacting") {
3258 r.get("time_compacting")?
3259 } else {
3260 None
3261 };
3262 let time_archived: Option<i64> = if has("time_archived") {
3263 r.get("time_archived")?
3264 } else {
3265 None
3266 };
3267
3268 let summary =
3269 (summary_additions.is_some() || summary_deletions.is_some() || summary_files.is_some())
3270 .then(|| {
3271 serde_json::json!({
3272 "additions": summary_additions.unwrap_or(0),
3273 "deletions": summary_deletions.unwrap_or(0),
3274 "files": summary_files.unwrap_or(0),
3275 "diffs": opencode_json_col(summary_diffs, "summary_diffs", session_id),
3276 })
3277 });
3278 let share = share_url.map(|u| serde_json::json!({"url": u}));
3279
3280 Ok(serde_json::json!({
3281 "id": id,
3282 "slug": slug,
3283 "projectID": project_id,
3284 "workspaceID": workspace_id,
3285 "directory": directory,
3286 "path": path,
3287 "parentID": parent_id,
3288 "summary": summary,
3289 "cost": cost,
3290 "tokens": {
3291 "input": tokens_input,
3292 "output": tokens_output,
3293 "reasoning": tokens_reasoning,
3294 "cache": {"read": tokens_cache_read, "write": tokens_cache_write},
3295 },
3296 "share": share,
3297 "title": title,
3298 "agent": agent,
3299 "model": opencode_json_col(model, "model", session_id),
3300 "version": version,
3301 "metadata": opencode_json_col(metadata, "metadata", session_id),
3302 "time": {
3303 "created": time_created,
3304 "updated": time_updated,
3305 "compacting": time_compacting,
3306 "archived": time_archived,
3307 },
3308 "permission": opencode_json_col(permission, "permission", session_id),
3309 // S9c: raw column value, not a field-selecting reconstruction —
3310 // see this function's doc comment.
3311 "revert": opencode_json_col(revert, "revert", session_id),
3312 }))
3313 })
3314 .map_err(|e| match e {
3315 rusqlite::Error::QueryReturnedNoRows => crate::Error::Other(format!(
3316 "OpenCode session `{session_id}` not found in this SQLite store"
3317 )),
3318 e => opencode_sql_err(e, &format!("reading session `{session_id}`")),
3319 })
3320}
3321
3322/// `message.data` / `part.data` already ARE the V1 `Info`/`Part` JSON minus
3323/// the id columns the schema hoists out (`session/sql.ts:68-98`) — just
3324/// re-inject them, matching what a JSON-tree file (or the export document)
3325/// carries at this same key. Also re-injects the row's own `time_created`/
3326/// `time_updated` columns (D2/S9c: these live OUTSIDE `data` in the real
3327/// schema — `Timestamps` in `database/schema.sql.ts` — and MUST be carried
3328/// in the envelope so `raw` is value-complete and re-writable without
3329/// re-minting timestamps; distinct keys from `data`'s own `"time"` object,
3330/// which is a different, in-schema field with different semantics).
3331fn opencode_row_message_value(
3332 id: &str,
3333 session_id: &str,
3334 data_json: &str,
3335 time_created: i64,
3336 time_updated: i64,
3337) -> Value {
3338 let mut v = opencode_json_col(Some(data_json.to_string()), "message.data", id);
3339 if let Value::Object(map) = &mut v {
3340 map.insert("id".to_string(), Value::String(id.to_string()));
3341 map.insert(
3342 "sessionID".to_string(),
3343 Value::String(session_id.to_string()),
3344 );
3345 map.insert("time_created".to_string(), Value::from(time_created));
3346 map.insert("time_updated".to_string(), Value::from(time_updated));
3347 }
3348 v
3349}
3350
3351fn opencode_row_part_value(
3352 id: &str,
3353 session_id: &str,
3354 message_id: &str,
3355 data_json: &str,
3356 time_created: i64,
3357 time_updated: i64,
3358) -> Value {
3359 let mut v = opencode_json_col(Some(data_json.to_string()), "part.data", id);
3360 if let Value::Object(map) = &mut v {
3361 map.insert("id".to_string(), Value::String(id.to_string()));
3362 map.insert(
3363 "sessionID".to_string(),
3364 Value::String(session_id.to_string()),
3365 );
3366 map.insert(
3367 "messageID".to_string(),
3368 Value::String(message_id.to_string()),
3369 );
3370 map.insert("time_created".to_string(), Value::from(time_created));
3371 map.insert("time_updated".to_string(), Value::from(time_updated));
3372 }
3373 v
3374}
3375
3376/// Ordered envelope lines for one session's `session`/`message`/`part`/`todo`
3377/// records (`docs/interop/opencode-pi-spec.md` §1.2's frozen order): session
3378/// info first, then each message (by `time_created, id`) immediately
3379/// followed by its own parts (by `id`) — parts MUST directly follow their
3380/// owning message line, since `Session::from_opencode_str`'s envelope parser
3381/// attaches a `part` line to whichever message id is already in its index
3382/// and silently leaves an out-of-order part `raw`-only otherwise — then
3383/// `todo` side-records, then a `session_diff` side-record if the JSON
3384/// sidecar file for this session exists (order-independent).
3385///
3386/// `db_path` is needed only for the `session_diff` sidecar (D3): §1.3 says
3387/// it "is still JSON-written even on SQLite installs" — verified against
3388/// `packages/opencode/src/session/revert.ts:76` /
3389/// `packages/opencode/src/storage/storage.ts:192` at the pinned schema
3390/// commit, which write it to `<data>/storage/session_diff/<session>.json`
3391/// (`<data>` being `db_path`'s parent directory) on every revert, entirely
3392/// separate from the `session.revert` DB column this loader already
3393/// captures. Without this, revert diffs vanish from `raw` and audit
3394/// under-counts `session_diff` records for real reverted sessions.
3395fn opencode_sqlite_session_envelope_lines(
3396 conn: &Connection,
3397 db_path: &Path,
3398 session_id: &str,
3399) -> Result<Vec<String>> {
3400 let mut lines = Vec::new();
3401
3402 let session_info = opencode_row_session_info(conn, session_id)?;
3403 let project_id = session_info
3404 .get("projectID")
3405 .and_then(Value::as_str)
3406 .unwrap_or("global")
3407 .to_string();
3408 lines.push(
3409 serde_json::json!({"key": ["session", project_id, session_id], "value": session_info})
3410 .to_string(),
3411 );
3412
3413 let mut msg_stmt = conn
3414 .prepare(
3415 "SELECT id, data, time_created, time_updated FROM message \
3416 WHERE session_id = ?1 ORDER BY time_created, id",
3417 )
3418 .map_err(|e| opencode_sql_err(e, "preparing the message query"))?;
3419 let msg_rows = msg_stmt
3420 .query_map([session_id], |r| {
3421 let id: String = r.get("id")?;
3422 let data: String = r.get("data")?;
3423 let time_created: i64 = r.get("time_created")?;
3424 let time_updated: i64 = r.get("time_updated")?;
3425 Ok((id, data, time_created, time_updated))
3426 })
3427 .map_err(|e| opencode_sql_err(e, "querying messages"))?;
3428
3429 let mut part_stmt = conn
3430 .prepare(
3431 "SELECT id, data, time_created, time_updated FROM part \
3432 WHERE message_id = ?1 ORDER BY id",
3433 )
3434 .map_err(|e| opencode_sql_err(e, "preparing the part query"))?;
3435
3436 for row in msg_rows {
3437 let (msg_id, data, msg_time_created, msg_time_updated) =
3438 row.map_err(|e| opencode_sql_err(e, "reading a message row"))?;
3439 let msg_value = opencode_row_message_value(
3440 &msg_id,
3441 session_id,
3442 &data,
3443 msg_time_created,
3444 msg_time_updated,
3445 );
3446 lines.push(
3447 serde_json::json!({"key": ["message", session_id, msg_id], "value": msg_value})
3448 .to_string(),
3449 );
3450
3451 let part_rows = part_stmt
3452 .query_map([&msg_id], |r| {
3453 let id: String = r.get("id")?;
3454 let data: String = r.get("data")?;
3455 let time_created: i64 = r.get("time_created")?;
3456 let time_updated: i64 = r.get("time_updated")?;
3457 Ok((id, data, time_created, time_updated))
3458 })
3459 .map_err(|e| opencode_sql_err(e, "querying parts"))?;
3460 for prow in part_rows {
3461 let (part_id, pdata, part_time_created, part_time_updated) =
3462 prow.map_err(|e| opencode_sql_err(e, "reading a part row"))?;
3463 let part_value = opencode_row_part_value(
3464 &part_id,
3465 session_id,
3466 &msg_id,
3467 &pdata,
3468 part_time_created,
3469 part_time_updated,
3470 );
3471 lines.push(
3472 serde_json::json!({"key": ["part", msg_id, part_id], "value": part_value})
3473 .to_string(),
3474 );
3475 }
3476 }
3477
3478 let mut todo_stmt = conn
3479 .prepare(
3480 "SELECT content, status, priority, position, time_created, time_updated \
3481 FROM todo WHERE session_id = ?1 ORDER BY position",
3482 )
3483 .map_err(|e| opencode_sql_err(e, "preparing the todo query"))?;
3484 let todo_rows = todo_stmt
3485 .query_map([session_id], |r| {
3486 let content: String = r.get("content")?;
3487 let status: String = r.get("status")?;
3488 let priority: String = r.get("priority")?;
3489 let position: i64 = r.get("position")?;
3490 let time_created: i64 = r.get("time_created")?;
3491 let time_updated: i64 = r.get("time_updated")?;
3492 Ok(serde_json::json!({
3493 "sessionID": session_id,
3494 "content": content,
3495 "status": status,
3496 "priority": priority,
3497 "position": position,
3498 "time": {"created": time_created, "updated": time_updated},
3499 }))
3500 })
3501 .map_err(|e| opencode_sql_err(e, "querying todos"))?;
3502 for trow in todo_rows {
3503 let tv = trow.map_err(|e| opencode_sql_err(e, "reading a todo row"))?;
3504 let position = tv.get("position").cloned().unwrap_or(Value::Null);
3505 lines.push(
3506 serde_json::json!({"key": ["todo", session_id, position], "value": tv}).to_string(),
3507 );
3508 }
3509
3510 if let Some(diff_value) = opencode_read_session_diff_sidecar(db_path, session_id) {
3511 lines.push(
3512 serde_json::json!({"key": ["session_diff", session_id], "value": diff_value})
3513 .to_string(),
3514 );
3515 }
3516
3517 Ok(lines)
3518}
3519
3520/// D3: read the `session_diff` JSON sidecar file for `session_id`, if
3521/// present, from `<db_path's parent>/storage/session_diff/<session_id>.json`
3522/// — the real on-disk location (`storage.ts`'s `Global.Path.data` +
3523/// `"storage"`, with `db_path` itself living at `Global.Path.data/opencode.db`
3524/// or `opencode-<channel>.db`). Best-effort: a missing file is the common
3525/// case (most sessions never revert) and is not an error; an existing-but-
3526/// unparseable file surfaces a diagnostic (D7-style) rather than silently
3527/// vanishing.
3528fn opencode_read_session_diff_sidecar(db_path: &Path, session_id: &str) -> Option<Value> {
3529 let dir = db_path.parent()?;
3530 let sidecar = dir
3531 .join("storage")
3532 .join("session_diff")
3533 .join(format!("{session_id}.json"));
3534 let text = std::fs::read_to_string(&sidecar).ok()?;
3535 match serde_json::from_str::<Value>(&text) {
3536 Ok(v) => Some(v),
3537 Err(e) => {
3538 tracing::warn!(
3539 path = %sidecar.display(),
3540 error = %e,
3541 "opencode session_diff sidecar failed to parse as JSON — skipping (D7)"
3542 );
3543 None
3544 }
3545 }
3546}
3547
3548/// Pick the "primary" session for a bare `.db` path with no explicit session
3549/// id (`Session::load`'s auto-detect entry point): the most-recently-updated
3550/// TOP-LEVEL session (`parent_id IS NULL` sorts first, then `time_updated`
3551/// descending) — a subagent/task child session is never picked over an
3552/// available root session, mirroring `most_recent_session`'s "latest wins"
3553/// convention used elsewhere in this crate for supercode's own store.
3554fn opencode_sqlite_primary_session_id(conn: &Connection) -> Result<String> {
3555 conn.query_row(
3556 "SELECT id FROM session ORDER BY (parent_id IS NULL) DESC, time_updated DESC LIMIT 1",
3557 [],
3558 |r| r.get::<_, String>(0),
3559 )
3560 .map_err(|e| match e {
3561 rusqlite::Error::QueryReturnedNoRows => {
3562 crate::Error::Other("OpenCode SQLite store contains no sessions".to_string())
3563 }
3564 e => opencode_sql_err(e, "selecting the primary session"),
3565 })
3566}
3567
3568fn opencode_sqlite_all_session_ids(conn: &Connection, limit: Option<usize>) -> Result<Vec<String>> {
3569 let mut stmt = conn
3570 .prepare("SELECT id FROM session ORDER BY time_created, id")
3571 .map_err(|e| opencode_sql_err(e, "listing sessions"))?;
3572 let rows = stmt
3573 .query_map([], |r| r.get::<_, String>(0))
3574 .map_err(|e| opencode_sql_err(e, "listing sessions"))?;
3575 let mut ids = Vec::new();
3576 for row in rows {
3577 ids.push(row.map_err(|e| opencode_sql_err(e, "reading a session id"))?);
3578 if limit.is_some_and(|n| ids.len() >= n) {
3579 break;
3580 }
3581 }
3582 Ok(ids)
3583}
3584
3585/// D6: list every session id in an OpenCode SQLite store (oldest first) —
3586/// exposed so CLI callers (`convert`, `inspect`) can detect a multi-session
3587/// store and warn before [`Session::load`]/[`Session::from_opencode_sqlite`]
3588/// silently picks just the primary one. Previously nothing surfaced this:
3589/// `convert opencode.db --to X` converted 1-of-N sessions with no warning
3590/// and no way to name a different one.
3591pub fn opencode_sqlite_session_ids(db_path: &Path) -> Result<Vec<String>> {
3592 let conn = opencode_sqlite_open(db_path)?;
3593 opencode_sqlite_all_session_ids(&conn, None)
3594}
3595
3596/// D6: the same "most-recently-updated top-level session" selection
3597/// [`Session::load`]/[`Session::from_opencode_sqlite`] make by default when
3598/// no explicit session id is given — exposed so a CLI-level warning can name
3599/// which one was chosen.
3600pub fn opencode_sqlite_primary_id(db_path: &Path) -> Result<String> {
3601 let conn = opencode_sqlite_open(db_path)?;
3602 opencode_sqlite_primary_session_id(&conn)
3603}
3604
3605/// Cheap store-level counts — `COUNT(*)` only, no row hydration — for
3606/// `inspect`'s "reports the audited real store's sessions, messages, and
3607/// parts" summary (PARITY-3 AC01).
3608#[derive(Debug, Clone, Copy, Default)]
3609#[non_exhaustive]
3610pub struct OpenCodeSqliteStoreStats {
3611 /// Row count of the `session` table.
3612 pub sessions: u64,
3613 /// Row count of the `message` table.
3614 pub messages: u64,
3615 /// Row count of the `part` table.
3616 pub parts: u64,
3617 /// Row count of the `todo` table.
3618 pub todos: u64,
3619}
3620
3621/// Count sessions/messages/parts/todos in a real OpenCode SQLite store
3622/// without loading any of them (PARITY-3 AC01).
3623pub fn opencode_sqlite_store_stats(db_path: &Path) -> Result<OpenCodeSqliteStoreStats> {
3624 let conn = opencode_sqlite_open(db_path)?;
3625 let count = |table: &str| -> Result<u64> {
3626 let sql = format!("SELECT count(*) FROM {table}");
3627 conn.query_row(&sql, [], |r| r.get::<_, i64>(0))
3628 .map(|n| n.max(0) as u64)
3629 .map_err(|e| opencode_sql_err(e, &format!("counting `{table}` rows")))
3630 };
3631 Ok(OpenCodeSqliteStoreStats {
3632 sessions: count("session")?,
3633 messages: count("message")?,
3634 parts: count("part")?,
3635 todos: count("todo")?,
3636 })
3637}
3638
3639/// Combined envelope text spanning every session in `db_path` (or up to
3640/// `limit_sessions`) — for corpus-style scanning
3641/// ([`crate::audit::audit_dir`]'s `Corpus::OpenCode` SQLite path, PARITY-4).
3642/// Safe to concatenate multiple sessions' records into one text even though
3643/// [`Session::from_opencode_str`] itself only keeps the LAST `session` line
3644/// (single-session semantics) — the audit line-classifier
3645/// (`audit_opencode_line`) scores each line independently and doesn't care
3646/// about session boundaries. Use [`Session::from_opencode_sqlite`] to load
3647/// one session as a real [`Session`].
3648pub fn opencode_sqlite_corpus_envelope_text(
3649 db_path: &Path,
3650 limit_sessions: Option<usize>,
3651) -> Result<String> {
3652 let conn = opencode_sqlite_open(db_path)?;
3653 let ids = opencode_sqlite_all_session_ids(&conn, limit_sessions)?;
3654 let mut out = String::new();
3655 for id in ids {
3656 for line in opencode_sqlite_session_envelope_lines(&conn, db_path, &id)? {
3657 out.push_str(&line);
3658 out.push('\n');
3659 }
3660 }
3661 Ok(out)
3662}
3663
3664/// PARSE-iteration view (blank/whitespace-only lines skipped, each line
3665/// trimmed, `\r` line endings normalized away by [`str::lines`]) — used
3666/// everywhere a loader walks lines looking for JSON *records*, where a blank
3667/// line is simply not a record and must not become a spurious parse
3668/// failure/empty entry. Deliberately NOT used for `raw` capture any more
3669/// (IX-1) — see [`split_lines_verbatim`] for that.
3670fn non_empty_lines(text: &str) -> impl Iterator<Item = &str> {
3671 text.lines().map(str::trim).filter(|l| !l.is_empty())
3672}
3673
3674// ---- Claude Code ----------------------------------------------------------
3675
3676/// The `subagents/` directory for a Claude Code transcript at `<dir>/<stem>.jsonl`
3677/// is `<dir>/<stem>/subagents/`. Returns it only if it exists.
3678fn subagents_dir_for(main_path: &Path) -> Option<PathBuf> {
3679 let dir = main_path.parent()?;
3680 let stem = main_path.file_stem()?.to_str()?;
3681 let candidate = dir.join(stem).join("subagents");
3682 candidate.is_dir().then_some(candidate)
3683}
3684
3685/// The first `agentId` recorded in a subagent transcript.
3686fn first_agent_id(jsonl: &str) -> Option<String> {
3687 for line in non_empty_lines(jsonl) {
3688 if let Ok(v) = serde_json::from_str::<Value>(line) {
3689 if let Some(id) = v.get("agentId").and_then(Value::as_str) {
3690 return Some(id.to_string());
3691 }
3692 }
3693 }
3694 None
3695}
3696
3697/// Find the `tool_use_id` of each parent `Task` call that spawned one of
3698/// `agent_ids`, by locating the parent transcript's `tool_result` whose
3699/// serialized content mentions the agent id. Best effort: an id with no
3700/// qualifying match is simply absent from the returned map.
3701///
3702/// Single pass over `main_text` — each line is parsed at most once,
3703/// regardless of how many agent ids are being sought — with each id's result
3704/// reproducing exactly what a per-id scan-and-parse-per-hit-line search would
3705/// return: the first line (in file order) whose raw text contains the id and
3706/// which — the first qualifying `tool_result` block in that line, in block
3707/// order — has a string `tool_use_id` and a serialized form that also
3708/// contains the id. A `tool_result` block matching on raw-line/serialized
3709/// containment but lacking a `tool_use_id` yields nothing for that id and
3710/// does not shadow a later match.
3711fn parent_tool_use_index(main_text: &str, agent_ids: &[String]) -> HashMap<String, String> {
3712 let mut index: HashMap<String, String> = HashMap::new();
3713 if agent_ids.is_empty() {
3714 return index;
3715 }
3716
3717 for line in non_empty_lines(main_text) {
3718 if index.len() == agent_ids.len() {
3719 break;
3720 }
3721 // Cheap prefilter: every match this function can ever return comes
3722 // from a block whose raw line carries the literal JSON string value
3723 // `tool_result` (no JSON-escape variants of that ASCII literal).
3724 if !line.contains("tool_result") {
3725 continue;
3726 }
3727 let still_unmapped: Vec<&String> = agent_ids
3728 .iter()
3729 .filter(|id| !index.contains_key(id.as_str()))
3730 .collect();
3731 if still_unmapped.is_empty() {
3732 break;
3733 }
3734 let Ok(v) = serde_json::from_str::<Value>(line) else {
3735 continue;
3736 };
3737 let content = v.get("message").and_then(|m| m.get("content"));
3738 let Some(Value::Array(blocks)) = content else {
3739 continue;
3740 };
3741 for b in blocks {
3742 if b.get("type").and_then(Value::as_str) != Some("tool_result") {
3743 continue;
3744 }
3745 let Some(tool_use_id) = b.get("tool_use_id").and_then(Value::as_str) else {
3746 continue;
3747 };
3748 let block_str = b.to_string();
3749 for id in &still_unmapped {
3750 if index.contains_key(id.as_str()) {
3751 continue;
3752 }
3753 if line.contains(id.as_str()) && block_str.contains(id.as_str()) {
3754 index.insert((*id).clone(), tool_use_id.to_string());
3755 }
3756 }
3757 }
3758 }
3759
3760 index
3761}
3762
3763#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3764enum ClaudeReplayKind {
3765 User,
3766 Assistant,
3767 Attachment,
3768 System,
3769}
3770
3771impl ClaudeReplayKind {
3772 fn is_conversation(self) -> bool {
3773 matches!(self, Self::User | Self::Assistant)
3774 }
3775}
3776
3777#[derive(Debug, Clone)]
3778struct ClaudeReplayNode {
3779 line_index: usize,
3780 uuid: String,
3781 parent_uuid: Option<String>,
3782 kind: ClaudeReplayKind,
3783 is_sidechain: bool,
3784 assistant_message_id: Option<String>,
3785 is_tool_result: bool,
3786 compact: Option<ClaudeCompactBoundary>,
3787}
3788
3789#[derive(Debug, Clone)]
3790struct ClaudeCompactBoundary {
3791 anchor_uuid: Option<String>,
3792 preserved_uuids: Vec<String>,
3793 preserved_segment: Option<(String, String)>,
3794}
3795
3796/// One projection of a Claude transcript graph: the source lines to replay,
3797/// plus whatever the projection had to give up to produce them (always empty
3798/// below [`Fidelity::Semantic`], which is the only level that degrades
3799/// instead of failing).
3800#[derive(Debug, Default)]
3801struct ClaudeReplaySelection {
3802 lines: Vec<usize>,
3803 residue: Vec<String>,
3804}
3805
3806#[derive(Debug, Default)]
3807struct ClaudeReplayIndex {
3808 nodes: Vec<ClaudeReplayNode>,
3809 by_uuid: HashMap<String, usize>,
3810 segment_anchors: HashSet<String>,
3811 last_prompt: Option<(String, bool)>,
3812 linear_lines: Vec<usize>,
3813}
3814
3815impl ClaudeReplayIndex {
3816 fn observe(&mut self, line_index: usize, v: &Value) -> Result<()> {
3817 if v.get("type").and_then(Value::as_str) == Some("last-prompt") {
3818 if let Some(leaf) = v.get("leafUuid").and_then(Value::as_str) {
3819 self.last_prompt = Some((
3820 leaf.to_string(),
3821 v.get("explicit").and_then(Value::as_bool) == Some(true),
3822 ));
3823 }
3824 return Ok(());
3825 }
3826
3827 // A fork-context-ref is a real Claude graph anchor, but not a replay
3828 // message. Its child is the first conversational record in the
3829 // exported fork, so reaching this UUID terminates the locally
3830 // replayable segment rather than indicating a broken parent edge.
3831 if v.get("type").and_then(Value::as_str) == Some("fork-context-ref") {
3832 if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
3833 self.segment_anchors.insert(uuid.to_string());
3834 }
3835 return Ok(());
3836 }
3837
3838 let kind = match v.get("type").and_then(Value::as_str) {
3839 Some("user") => ClaudeReplayKind::User,
3840 Some("assistant") => ClaudeReplayKind::Assistant,
3841 Some("attachment") => ClaudeReplayKind::Attachment,
3842 Some("system") => ClaudeReplayKind::System,
3843 _ => return Ok(()),
3844 };
3845 self.linear_lines.push(line_index);
3846 let Some(uuid) = v.get("uuid").and_then(Value::as_str) else {
3847 return Ok(());
3848 };
3849 if self.by_uuid.contains_key(uuid) {
3850 return Err(claude_replay_error(format!(
3851 "duplicate uuid `{uuid}` in Claude transcript"
3852 )));
3853 }
3854
3855 let compact = (kind == ClaudeReplayKind::System
3856 && v.get("subtype").and_then(Value::as_str) == Some("compact_boundary"))
3857 .then(|| ClaudeCompactBoundary::from_value(v));
3858 let assistant_message_id = (kind == ClaudeReplayKind::Assistant)
3859 .then(|| claude_assistant_message_id(v).map(str::to_string))
3860 .flatten();
3861 let is_tool_result = kind == ClaudeReplayKind::User
3862 && v.get("message")
3863 .and_then(|m| m.get("content"))
3864 .and_then(Value::as_array)
3865 .is_some_and(|blocks| {
3866 blocks
3867 .iter()
3868 .any(|b| b.get("type").and_then(Value::as_str) == Some("tool_result"))
3869 });
3870 let node = ClaudeReplayNode {
3871 line_index,
3872 uuid: uuid.to_string(),
3873 parent_uuid: v
3874 .get("parentUuid")
3875 .and_then(Value::as_str)
3876 .map(str::to_string),
3877 kind,
3878 is_sidechain: v.get("isSidechain").and_then(Value::as_bool) == Some(true),
3879 assistant_message_id,
3880 is_tool_result,
3881 compact,
3882 };
3883 self.by_uuid.insert(uuid.to_string(), self.nodes.len());
3884 self.nodes.push(node);
3885 Ok(())
3886 }
3887
3888 /// Project the transcript at `fidelity`.
3889 ///
3890 /// Below [`Fidelity::Semantic`] this is the STRICT projection every
3891 /// continuation, transfer and export path depends on: reconstruct
3892 /// Claude's own single active post-compaction branch, or fail naming what
3893 /// could not be reconstructed.
3894 ///
3895 /// [`Fidelity::Semantic`] is the read-only VIEW mode. A Claude transcript
3896 /// that has been compacted, summarized, or resumed across files routinely
3897 /// contains a live record whose `parentUuid` names a record that is no
3898 /// longer on disk. Strict projection rightly refuses — a continuation
3899 /// built on a guessed graph is silent loss — but a VIEW does not need a
3900 /// continuation, so this mode anchors each dangling edge as a segment
3901 /// root, projects every severed segment exactly as the active branch is
3902 /// projected, splices them back together in transcript order, and names
3903 /// every degradation in the returned residue instead of erroring.
3904 fn select_lines(mut self, fidelity: Fidelity) -> Result<ClaudeReplaySelection> {
3905 let lenient = fidelity.tolerates_residue();
3906 let mut residue = Vec::new();
3907 if self.nodes.is_empty() {
3908 // Older exports and many hand-authored compatibility fixtures do
3909 // not carry Claude's `uuid`/`parentUuid` graph fields. There is no
3910 // branch information to project in that shape, so preserve the
3911 // historical linear normalization behavior. Native graph-bearing
3912 // transcripts always take the projection below.
3913 return Ok(ClaudeReplaySelection {
3914 lines: self.linear_lines,
3915 residue,
3916 });
3917 }
3918 if lenient {
3919 self.anchor_dangling_parents(&mut residue);
3920 }
3921 // Last resort for a VIEW: a transcript whose graph is unprojectable
3922 // for some OTHER reason (a cycle, an unresolvable compact boundary)
3923 // still renders as the file's own record order. A read-only mirror
3924 // that cannot open a session at all is the defect this mode exists
3925 // to remove, so `Semantic` never returns an error.
3926 let fallback = lenient.then(|| self.linear_lines.clone());
3927 match self.project(lenient, &mut residue) {
3928 Ok(lines) => Ok(ClaudeReplaySelection { lines, residue }),
3929 Err(error) => match fallback {
3930 Some(lines) => {
3931 residue.push(format!(
3932 "the Claude record graph could not be projected ({error}); \
3933 every record was stitched in transcript order instead"
3934 ));
3935 Ok(ClaudeReplaySelection { lines, residue })
3936 }
3937 None => Err(error),
3938 },
3939 }
3940 }
3941
3942 /// Turn every edge that points outside the transcript into a segment
3943 /// root, naming the dangling uuids as residue.
3944 ///
3945 /// A `fork-context-ref` anchor is already a declared segment boundary,
3946 /// not a break, so it is left alone.
3947 fn anchor_dangling_parents(&mut self, residue: &mut Vec<String>) {
3948 let mut dangling = Vec::new();
3949 for idx in 0..self.nodes.len() {
3950 let Some(parent) = self.nodes[idx].parent_uuid.as_deref() else {
3951 continue;
3952 };
3953 if self.by_uuid.contains_key(parent) || self.segment_anchors.contains(parent) {
3954 continue;
3955 }
3956 dangling.push(format!("`{}` → `{parent}`", self.nodes[idx].uuid));
3957 self.nodes[idx].parent_uuid = None;
3958 }
3959 if dangling.is_empty() {
3960 return;
3961 }
3962 const NAMED: usize = 8;
3963 let total = dangling.len();
3964 let overflow = total.saturating_sub(NAMED);
3965 dangling.truncate(NAMED);
3966 let mut listed = dangling.join(", ");
3967 if overflow > 0 {
3968 listed.push_str(&format!(", and {overflow} more"));
3969 }
3970 residue.push(format!(
3971 "{total} Claude record(s) reference a parentUuid absent from the transcript and were \
3972 anchored as segment roots: {listed}"
3973 ));
3974 }
3975
3976 fn project(&mut self, lenient: bool, residue: &mut Vec<String>) -> Result<Vec<usize>> {
3977 let mut retained = vec![true; self.nodes.len()];
3978 if let Some(boundary_index) = self.nodes.iter().rposition(|n| n.compact.is_some()) {
3979 let parents: Option<Vec<Option<String>>> = lenient.then(|| {
3980 self.nodes
3981 .iter()
3982 .map(|node| node.parent_uuid.clone())
3983 .collect()
3984 });
3985 if let Err(error) = self.apply_latest_compaction(boundary_index, &mut retained) {
3986 let Some(parents) = parents else {
3987 return Err(error);
3988 };
3989 // The boundary rewrites parents as it goes, so restore the
3990 // graph it half-edited before continuing without it.
3991 for (node, parent) in self.nodes.iter_mut().zip(parents) {
3992 node.parent_uuid = parent;
3993 }
3994 retained.iter_mut().for_each(|keep| *keep = true);
3995 residue.push(format!(
3996 "the latest Claude compact boundary could not be projected ({error}); \
3997 no pre-compaction record was pruned from this view"
3998 ));
3999 }
4000 }
4001 let sidechain_only = self
4002 .nodes
4003 .iter()
4004 .enumerate()
4005 .filter(|(idx, node)| retained[*idx] && node.kind.is_conversation())
4006 .all(|(_, node)| node.is_sidechain);
4007
4008 let explicit_leaf = self
4009 .last_prompt
4010 .as_ref()
4011 .filter(|(_, explicit)| *explicit)
4012 .and_then(|(uuid, _)| self.by_uuid.get(uuid).copied())
4013 .filter(|idx| retained[*idx]);
4014 let newest_non_sidechain = self
4015 .nodes
4016 .iter()
4017 .enumerate()
4018 .rev()
4019 .find(|(idx, node)| retained[*idx] && !node.is_sidechain)
4020 .map(|(idx, _)| idx);
4021 // Dedicated Claude subagent transcripts are sidechains by design:
4022 // every record, including their root user prompt, has
4023 // `isSidechain:true`. When there is no main-chain candidate, resume
4024 // the newest retained sidechain leaf instead of rejecting the child.
4025 let newest_sidechain = self
4026 .nodes
4027 .iter()
4028 .enumerate()
4029 .rev()
4030 .find(|(idx, node)| retained[*idx] && node.is_sidechain && node.kind.is_conversation())
4031 .map(|(idx, _)| idx);
4032 let mut active = explicit_leaf
4033 .or(newest_non_sidechain)
4034 .or(newest_sidechain)
4035 .ok_or_else(|| claude_replay_error("no Claude record remains after compaction"))?;
4036
4037 // Metadata descendants such as turn_duration are leaves in the raw
4038 // graph. Claude resumes from their nearest user/assistant ancestor,
4039 // then appends those descendants to the reconstructed chain.
4040 let mut seeking = HashSet::new();
4041 while !self.nodes[active].kind.is_conversation() {
4042 if !seeking.insert(active) {
4043 return Err(claude_replay_error(
4044 "cycle while resolving active Claude leaf",
4045 ));
4046 }
4047 active = self.parent_index(active, &retained)?;
4048 }
4049
4050 let mut segments =
4051 vec![self.project_segment(active, &retained, sidechain_only, lenient)?];
4052 if lenient {
4053 for leaf in self.severed_segment_leaves(active, &retained) {
4054 segments.push(self.project_segment(leaf, &retained, sidechain_only, lenient)?);
4055 }
4056 if segments.len() > 1 {
4057 residue.push(format!(
4058 "{} conversation segments were stitched in transcript order because the \
4059 Claude record graph is severed",
4060 segments.len()
4061 ));
4062 }
4063 }
4064 // Each segment keeps its own reconstructed order; the segments
4065 // themselves are spliced by where they start in the file.
4066 segments.retain(|segment| !segment.is_empty());
4067 segments.sort_by_key(|segment| {
4068 segment
4069 .iter()
4070 .map(|idx| self.nodes[*idx].line_index)
4071 .min()
4072 .unwrap_or(usize::MAX)
4073 });
4074 let mut ordered = Vec::new();
4075 let mut placed = HashSet::new();
4076 for idx in segments.into_iter().flatten() {
4077 if placed.insert(idx) {
4078 ordered.push(idx);
4079 }
4080 }
4081
4082 self.recover_parallel_assistant_chunks(ordered, &retained)
4083 .map(|indices| {
4084 indices
4085 .into_iter()
4086 .map(|idx| self.nodes[idx].line_index)
4087 .collect()
4088 })
4089 }
4090
4091 /// Reconstruct one segment: `leaf`'s parent chain, oldest-first, plus the
4092 /// non-conversation descendants rooted at it.
4093 fn project_segment(
4094 &self,
4095 leaf: usize,
4096 retained: &[bool],
4097 sidechain_only: bool,
4098 lenient: bool,
4099 ) -> Result<Vec<usize>> {
4100 let mut reversed = Vec::new();
4101 let mut seen = HashSet::new();
4102 let mut cursor = Some(leaf);
4103 while let Some(idx) = cursor {
4104 if !seen.insert(idx) {
4105 return Err(claude_replay_error(format!(
4106 "cycle in active Claude parentUuid chain at `{}`",
4107 self.nodes[idx].uuid
4108 )));
4109 }
4110 reversed.push(idx);
4111 cursor = match self.nodes[idx].parent_uuid.as_deref() {
4112 Some(parent) => match self.by_uuid.get(parent).copied() {
4113 Some(parent) => Some(parent),
4114 None if self.segment_anchors.contains(parent) => None,
4115 // Claude can resume a background child in-place while
4116 // retaining only the new segment in that child's JSONL.
4117 // Its first record then points to a UUID not present in
4118 // the sidechain file. That external edge is a segment
4119 // boundary, not corruption; the complete source remains
4120 // available byte-for-byte in `raw`.
4121 None if sidechain_only => None,
4122 None => {
4123 return Err(claude_replay_error(format!(
4124 "active Claude record `{}` has missing parentUuid `{parent}`",
4125 self.nodes[idx].uuid
4126 )));
4127 }
4128 },
4129 None => None,
4130 };
4131 if cursor.is_some_and(|parent| !retained[parent]) {
4132 if lenient {
4133 // A compaction boundary is where this segment ends; the
4134 // records it pruned stay pruned.
4135 break;
4136 }
4137 return Err(claude_replay_error(format!(
4138 "active Claude chain crosses an excluded compaction record from `{}`",
4139 self.nodes[idx].uuid
4140 )));
4141 }
4142 }
4143 reversed.reverse();
4144
4145 // Include non-conversation descendants rooted at the segment's leaf
4146 // (turn_duration, attachments, etc.), matching Claude's QYH/n1T.
4147 let mut descendants = Vec::new();
4148 let mut frontier = vec![leaf];
4149 let mut head = 0;
4150 while head < frontier.len() {
4151 let parent = frontier[head];
4152 head += 1;
4153 for (idx, node) in self.nodes.iter().enumerate() {
4154 if !retained[idx]
4155 || node.kind.is_conversation()
4156 || seen.contains(&idx)
4157 || node.parent_uuid.as_deref() != Some(self.nodes[parent].uuid.as_str())
4158 {
4159 continue;
4160 }
4161 seen.insert(idx);
4162 descendants.push(idx);
4163 frontier.push(idx);
4164 }
4165 }
4166 descendants.sort_by_key(|idx| self.nodes[*idx].line_index);
4167 reversed.extend(descendants);
4168 Ok(reversed)
4169 }
4170
4171 /// The newest retained conversation record of every component the active
4172 /// leaf's own component cannot reach.
4173 ///
4174 /// Only a severed graph produces any: a healthy transcript is one
4175 /// component, so the abandoned branches a rewind left behind stay
4176 /// abandoned here exactly as they do under strict projection.
4177 fn severed_segment_leaves(&self, active: usize, retained: &[bool]) -> Vec<usize> {
4178 let active_root = self.component_root(active, retained);
4179 let mut newest_by_root: BTreeMap<usize, usize> = BTreeMap::new();
4180 for idx in 0..self.nodes.len() {
4181 if !retained[idx] || !self.nodes[idx].kind.is_conversation() {
4182 continue;
4183 }
4184 let Some(root) = self.component_root(idx, retained) else {
4185 continue;
4186 };
4187 if Some(root) == active_root {
4188 continue;
4189 }
4190 let newest = newest_by_root.entry(root).or_insert(idx);
4191 if self.nodes[idx].line_index > self.nodes[*newest].line_index {
4192 *newest = idx;
4193 }
4194 }
4195 newest_by_root.into_values().collect()
4196 }
4197
4198 /// Walk `idx` up to the record that anchors its component, stopping at a
4199 /// root, an edge that leaves the transcript, or a pruned parent. `None`
4200 /// when the walk cycles.
4201 fn component_root(&self, idx: usize, retained: &[bool]) -> Option<usize> {
4202 let mut cursor = idx;
4203 let mut seen = HashSet::new();
4204 loop {
4205 if !seen.insert(cursor) {
4206 return None;
4207 }
4208 let next = self.nodes[cursor]
4209 .parent_uuid
4210 .as_deref()
4211 .and_then(|parent| self.by_uuid.get(parent).copied())
4212 .filter(|parent| retained[*parent]);
4213 match next {
4214 Some(parent) => cursor = parent,
4215 None => return Some(cursor),
4216 }
4217 }
4218 }
4219
4220 fn apply_latest_compaction(
4221 &mut self,
4222 boundary_index: usize,
4223 retained: &mut [bool],
4224 ) -> Result<()> {
4225 let compact = self.nodes[boundary_index]
4226 .compact
4227 .clone()
4228 .expect("called with compact boundary");
4229 let mut preserved = compact.preserved_uuids;
4230 if preserved.is_empty() {
4231 if let Some((head, tail)) = compact.preserved_segment {
4232 preserved = self.walk_preserved_segment(&head, &tail)?;
4233 }
4234 }
4235
4236 let preserved_set: HashSet<String> = preserved.iter().cloned().collect();
4237 for uuid in &preserved {
4238 if !self.by_uuid.contains_key(uuid) {
4239 return Err(claude_replay_error(format!(
4240 "latest compact boundary references missing preserved uuid `{uuid}`"
4241 )));
4242 }
4243 }
4244
4245 let removed_uuids: HashSet<String> = self
4246 .nodes
4247 .iter()
4248 .enumerate()
4249 .filter(|(idx, node)| *idx < boundary_index && !preserved_set.contains(&node.uuid))
4250 .map(|(_, node)| node.uuid.clone())
4251 .collect();
4252 for (idx, node) in self.nodes.iter().enumerate() {
4253 if idx < boundary_index && !preserved_set.contains(&node.uuid) {
4254 retained[idx] = false;
4255 }
4256 }
4257
4258 if preserved.is_empty() {
4259 return Ok(());
4260 }
4261 let anchor = compact.anchor_uuid.ok_or_else(|| {
4262 claude_replay_error("preserved compact boundary is missing anchorUuid")
4263 })?;
4264 if !self.by_uuid.contains_key(&anchor) {
4265 return Err(claude_replay_error(format!(
4266 "latest compact boundary references missing anchor uuid `{anchor}`"
4267 )));
4268 }
4269 let tail = preserved.last().cloned().expect("non-empty preserved list");
4270 let mut parent = anchor.clone();
4271 for uuid in &preserved {
4272 let idx = self.by_uuid[uuid];
4273 self.nodes[idx].parent_uuid = Some(parent);
4274 parent = uuid.clone();
4275 }
4276 let first = &preserved[0];
4277 for node in &mut self.nodes {
4278 if node.parent_uuid.as_deref() == Some(anchor.as_str()) && node.uuid != *first {
4279 node.parent_uuid = Some(tail.clone());
4280 }
4281 }
4282 for node in &mut self.nodes {
4283 if node.kind.is_conversation()
4284 && node
4285 .parent_uuid
4286 .as_ref()
4287 .is_some_and(|parent| removed_uuids.contains(parent))
4288 {
4289 node.parent_uuid = Some(tail.clone());
4290 }
4291 }
4292 Ok(())
4293 }
4294
4295 fn walk_preserved_segment(&self, head: &str, tail: &str) -> Result<Vec<String>> {
4296 let mut reversed = Vec::new();
4297 let mut seen = HashSet::new();
4298 let mut cursor = tail;
4299 loop {
4300 if !seen.insert(cursor.to_string()) {
4301 return Err(claude_replay_error("cycle in compact preservedSegment"));
4302 }
4303 let idx = *self.by_uuid.get(cursor).ok_or_else(|| {
4304 claude_replay_error(format!(
4305 "compact preservedSegment references missing uuid `{cursor}`"
4306 ))
4307 })?;
4308 reversed.push(cursor.to_string());
4309 if cursor == head {
4310 reversed.reverse();
4311 return Ok(reversed);
4312 }
4313 cursor = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
4314 claude_replay_error(format!(
4315 "compact preservedSegment tail `{tail}` does not reach head `{head}`"
4316 ))
4317 })?;
4318 }
4319 }
4320
4321 fn parent_index(&self, idx: usize, retained: &[bool]) -> Result<usize> {
4322 let parent = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
4323 claude_replay_error(format!(
4324 "Claude record `{}` has no conversational ancestor",
4325 self.nodes[idx].uuid
4326 ))
4327 })?;
4328 let parent_idx = *self.by_uuid.get(parent).ok_or_else(|| {
4329 claude_replay_error(format!(
4330 "Claude record `{}` has missing parentUuid `{parent}`",
4331 self.nodes[idx].uuid
4332 ))
4333 })?;
4334 if !retained[parent_idx] {
4335 return Err(claude_replay_error(format!(
4336 "Claude record `{}` points into compacted-out history",
4337 self.nodes[idx].uuid
4338 )));
4339 }
4340 Ok(parent_idx)
4341 }
4342
4343 fn recover_parallel_assistant_chunks(
4344 &self,
4345 base: Vec<usize>,
4346 retained: &[bool],
4347 ) -> Result<Vec<usize>> {
4348 let selected: HashSet<usize> = base.iter().copied().collect();
4349 let mut replacements: HashMap<usize, Vec<usize>> = HashMap::new();
4350 let mut skipped_positions = HashSet::new();
4351 let mut handled_ids = HashSet::new();
4352
4353 for (base_pos, idx) in base.iter().copied().enumerate() {
4354 let Some(message_id) = self.nodes[idx].assistant_message_id.as_deref() else {
4355 continue;
4356 };
4357 if !handled_ids.insert(message_id.to_string()) {
4358 continue;
4359 }
4360 let base_positions: Vec<usize> = base
4361 .iter()
4362 .enumerate()
4363 .filter(|(_, candidate)| {
4364 self.nodes[**candidate].assistant_message_id.as_deref() == Some(message_id)
4365 })
4366 .map(|(pos, _)| pos)
4367 .collect();
4368 let anchor_pos = base_positions.first().copied().unwrap_or(base_pos);
4369 skipped_positions.extend(base_positions.iter().copied().skip(1));
4370
4371 // A streamed Anthropic response can be stored as sibling records
4372 // rather than a literal parent chain. Reassemble every chunk at
4373 // the first active occurrence and restore raw chunk order before
4374 // the normalizer coalesces their content blocks.
4375 let mut chunks: Vec<usize> = self
4376 .nodes
4377 .iter()
4378 .enumerate()
4379 .filter(|(candidate, node)| {
4380 retained[*candidate] && node.assistant_message_id.as_deref() == Some(message_id)
4381 })
4382 .map(|(candidate, _)| candidate)
4383 .collect();
4384 chunks.sort_by_key(|candidate| self.nodes[*candidate].line_index);
4385
4386 let assistant_uuids: HashSet<&str> = self
4387 .nodes
4388 .iter()
4389 .filter(|node| node.assistant_message_id.as_deref() == Some(message_id))
4390 .map(|node| node.uuid.as_str())
4391 .collect();
4392 let mut results: Vec<usize> = self
4393 .nodes
4394 .iter()
4395 .enumerate()
4396 .filter(|(candidate, node)| {
4397 retained[*candidate]
4398 && !selected.contains(candidate)
4399 && node.is_tool_result
4400 && node
4401 .parent_uuid
4402 .as_deref()
4403 .is_some_and(|parent| assistant_uuids.contains(parent))
4404 })
4405 .map(|(candidate, _)| candidate)
4406 .collect();
4407 results.sort_by_key(|candidate| self.nodes[*candidate].line_index);
4408 chunks.extend(results);
4409 replacements.insert(anchor_pos, chunks);
4410 }
4411
4412 let mut out = Vec::with_capacity(selected.len());
4413 for (pos, idx) in base.into_iter().enumerate() {
4414 if let Some(replacement) = replacements.remove(&pos) {
4415 out.extend(replacement);
4416 } else if !skipped_positions.contains(&pos) {
4417 out.push(idx);
4418 }
4419 }
4420 Ok(out)
4421 }
4422}
4423
4424impl ClaudeCompactBoundary {
4425 fn from_value(v: &Value) -> Self {
4426 let metadata = v.get("compactMetadata");
4427 let preserved_messages = metadata.and_then(|m| m.get("preservedMessages"));
4428 let anchor_uuid = preserved_messages
4429 .and_then(|p| p.get("anchorUuid"))
4430 .and_then(Value::as_str)
4431 .or_else(|| {
4432 metadata
4433 .and_then(|m| m.get("preservedSegment"))
4434 .and_then(|p| p.get("anchorUuid"))
4435 .and_then(Value::as_str)
4436 })
4437 .map(str::to_string);
4438 let preserved_uuids = preserved_messages
4439 .and_then(|p| p.get("uuids"))
4440 .and_then(Value::as_array)
4441 .map(|uuids| {
4442 uuids
4443 .iter()
4444 .filter_map(Value::as_str)
4445 .map(str::to_string)
4446 .collect()
4447 })
4448 .unwrap_or_default();
4449 let preserved_segment =
4450 metadata
4451 .and_then(|m| m.get("preservedSegment"))
4452 .and_then(|segment| {
4453 Some((
4454 segment.get("headUuid")?.as_str()?.to_string(),
4455 segment.get("tailUuid")?.as_str()?.to_string(),
4456 ))
4457 });
4458 Self {
4459 anchor_uuid,
4460 preserved_uuids,
4461 preserved_segment,
4462 }
4463 }
4464}
4465
4466fn claude_replay_error(message: impl Into<String>) -> crate::Error {
4467 crate::Error::Other(format!(
4468 "cannot reconstruct lossless Claude continuation: {}",
4469 message.into()
4470 ))
4471}
4472
4473fn claude_assistant_message_id(v: &Value) -> Option<&str> {
4474 v.get("message")
4475 .and_then(|message| message.get("id"))
4476 .and_then(Value::as_str)
4477}
4478
4479fn merge_claude_assistant_chunk(target: &mut Value, chunk: &Value) {
4480 let Some(target_message) = target.get_mut("message") else {
4481 return;
4482 };
4483 let Some(chunk_message) = chunk.get("message") else {
4484 return;
4485 };
4486 let mut content = target_message
4487 .get("content")
4488 .and_then(Value::as_array)
4489 .cloned()
4490 .unwrap_or_default();
4491 if let Some(blocks) = chunk_message.get("content").and_then(Value::as_array) {
4492 content.extend(blocks.iter().cloned());
4493 }
4494 let mut merged_message = chunk_message.clone();
4495 merged_message["content"] = Value::Array(content);
4496 *target_message = merged_message;
4497}
4498
4499fn flush_claude_assistant(pending: &mut Option<Value>, out: &mut Vec<ChatMessage>) {
4500 let Some(v) = pending.take() else {
4501 return;
4502 };
4503 let reasoning_only = claude_assistant_message_id(&v).is_some()
4504 && v.get("message")
4505 .and_then(|message| message.get("content"))
4506 .and_then(Value::as_array)
4507 .is_some_and(|blocks| {
4508 !blocks.is_empty()
4509 && blocks.iter().all(|block| {
4510 matches!(
4511 block.get("type").and_then(Value::as_str),
4512 Some("thinking" | "redacted_thinking")
4513 )
4514 })
4515 });
4516 if reasoning_only {
4517 return;
4518 }
4519 let before = out.len();
4520 push_claude_assistant(&v, out);
4521 capture_claude_record_provenance(&v, &mut out[before..]);
4522 restore_single_grok_message(&v, &mut out[before..]);
4523}
4524
4525/// Attach the record identity, clock, and actual assistant model to every
4526/// canonical message produced from one Claude JSONL record. These fields are
4527/// deliberately per-message: a continued transcript can cross a provider
4528/// boundary, so the session-level source model is not authoritative for its
4529/// appended tail.
4530fn capture_claude_record_provenance(v: &Value, messages: &mut [ChatMessage]) {
4531 let timestamp = v.get("timestamp").and_then(Value::as_str);
4532 let uuid = v.get("uuid").and_then(Value::as_str);
4533 let model = v
4534 .get("message")
4535 .and_then(|message| message.get("model"))
4536 .and_then(Value::as_str);
4537 for message in messages {
4538 if let Some(timestamp) = timestamp {
4539 message
4540 .metadata
4541 .entry("timestamp".to_string())
4542 .or_insert_with(|| timestamp.to_string());
4543 }
4544 if let Some(uuid) = uuid {
4545 message
4546 .metadata
4547 .entry("claude_uuid".to_string())
4548 .or_insert_with(|| uuid.to_string());
4549 }
4550 if let Some(model) = model {
4551 message
4552 .metadata
4553 .entry("model".to_string())
4554 .or_insert_with(|| model.to_string());
4555 }
4556 }
4557}
4558
4559fn capture_claude_meta(v: &Value, meta: &mut SessionMeta, raw_line: &str) -> Result<()> {
4560 restore_codex_provenance_from_top_level(v, meta)?;
4561 if meta.session_id.is_none() {
4562 if let Some(id) = v.get("sessionId").and_then(Value::as_str) {
4563 meta.session_id = Some(id.to_string());
4564 }
4565 }
4566 if meta.cwd.is_none() {
4567 if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
4568 meta.cwd = Some(PathBuf::from(cwd));
4569 }
4570 }
4571 if meta.model.is_none() {
4572 if let Some(model) = v
4573 .get("message")
4574 .and_then(|m| m.get("model"))
4575 .and_then(Value::as_str)
4576 {
4577 meta.model = Some(model.to_string());
4578 }
4579 }
4580 // PARITY-10 (provenance P010): `fork-context-ref` is an extremely rare
4581 // real Claude Code record with no confirmed field shape (see
4582 // `ClaudeRecord::ForkContextRef`'s doc comment) — rather than guess at
4583 // named fields and risk silently mis-modeling it, stash the WHOLE raw
4584 // line verbatim under a lineage key. `write_claude_code_records` (below)
4585 // re-emits it byte-for-byte, so the record survives the Claude Code
4586 // semantic writer (not just the CLI's raw-passthrough diagonal path) —
4587 // and `to_codex_jsonl`'s synthesized header carries a namespaced copy
4588 // so a Claude -> Codex -> Claude round trip can still reconstruct it
4589 // (dev/03). A session can only fork from one context, so the first one
4590 // seen wins, matching every other "first wins" field above.
4591 //
4592 // D4 (Fable-5 review, confirmed): this used to store `v.to_string()` —
4593 // a RE-SERIALIZATION of the parsed `Value`, not the original source
4594 // text. `serde_json::Value` here has no `preserve_order` feature (see
4595 // `Cargo.toml`), so its object keys are a `BTreeMap` and get silently
4596 // REORDERED (alphabetized) on re-emit — the "byte-for-byte" claim in
4597 // this very comment was false. Fixed the cheap+honest way: store the
4598 // caller's own already-verbatim source `raw_line` text instead of
4599 // re-serializing `v` at all, so this is now ACTUALLY byte-for-byte
4600 // (key order, spacing, everything) rather than merely
4601 // structurally-equivalent JSON.
4602 if v.get("type").and_then(Value::as_str) == Some("fork-context-ref")
4603 && !meta.lineage.contains_key("claude_fork_context_ref_raw")
4604 {
4605 meta.lineage.insert(
4606 "claude_fork_context_ref_raw".to_string(),
4607 raw_line.to_string(),
4608 );
4609 }
4610 Ok(())
4611}
4612
4613fn push_claude_user(v: &Value, out: &mut Vec<ChatMessage>) {
4614 let content = v.get("message").and_then(|m| m.get("content"));
4615 let provenance = claude_user_provenance(v);
4616 match content {
4617 Some(Value::String(s)) => {
4618 if !s.trim().is_empty() {
4619 out.push(ChatMessage::user(s.clone()).with_metas(&provenance));
4620 }
4621 }
4622 Some(Value::Array(blocks)) => {
4623 let mut text = String::new();
4624 // IX-5: image blocks alongside/instead of text — collected
4625 // separately (never synthesized on a malformed shape, see
4626 // `claude_image_block_to_part`) so a multimodal user turn
4627 // survives as `content_parts` instead of the image silently
4628 // vanishing.
4629 let mut images: Vec<Value> = Vec::new();
4630 // D5: an `image` block whose `source` isn't base64/url (e.g. a
4631 // Files-API `{"source":{"type":"file","file_id":..}}`
4632 // reference) makes `claude_image_block_to_part` return `None` —
4633 // track that it was SEEN even though it couldn't be converted,
4634 // so an image-ONLY record (no text, no convertible image) isn't
4635 // silently dropped below (the same vanishing-record bug-class
4636 // PARITY-11 fixed for reasoning-only turns).
4637 let mut saw_unconvertible_image = false;
4638 for b in blocks {
4639 match b.get("type").and_then(Value::as_str) {
4640 Some("text") => push_text(&mut text, b.get("text")),
4641 Some("tool_result") => {
4642 let id = b
4643 .get("tool_use_id")
4644 .and_then(Value::as_str)
4645 .unwrap_or_default();
4646 // PARITY-11 (nested images): `extract_tool_result_content`
4647 // captures any `image` blocks nested inside this
4648 // `tool_result` into `content_parts` (via
4649 // `claude_image_block_to_part`, the same conversion the
4650 // top-level `image` block path already uses) instead of
4651 // flattening them to the bare `[image]` marker text the
4652 // old `extract_tool_result` emitted — the everyday
4653 // "Read a PNG / screenshot tool output" shape.
4654 let (result, images) =
4655 extract_tool_result_content(b.get("content"), v.get("toolUseResult"));
4656 let mut msg = tool_message(id, result);
4657 if !images.is_empty() {
4658 // D-mix (Fable review, must-fix): `content_parts`
4659 // is a self-contained contract — the pi writer
4660 // (`pi_content_value`) reads ONLY `content_parts`
4661 // for a `Role::Tool` message and never falls back
4662 // to `msg.content`, so on a MIXED text+image
4663 // tool_result a bare `content_parts: [image]`
4664 // silently drops the sibling text on `convert
4665 // --to pi` (a regression vs. the pre-PARITY-11
4666 // baseline, which at least preserved the text).
4667 // Prepend the text as part 0, exactly mirroring
4668 // `pi_content_to_text_and_parts` and
4669 // `push_opencode_user`'s identical
4670 // self-contained-parts construction. `msg.content`
4671 // keeps the text too (unchanged) for the writers
4672 // that read text from `msg.content` and only scan
4673 // `content_parts` for `image_url` entries
4674 // (`claude_tool_result_content_value`,
4675 // `codex_tool_output_text`, the opencode
4676 // assistant writer) — those already filter
4677 // strictly on `image_url`/text-typed lookups, so
4678 // this text part is never double-counted.
4679 let mut parts = Vec::new();
4680 if let Some(t) = &msg.content {
4681 if !t.is_empty() {
4682 parts.push(serde_json::json!({"type": "text", "text": t}));
4683 }
4684 }
4685 parts.extend(images);
4686 msg.content_parts = Some(parts);
4687 }
4688 // The assistant turn that issued this tool call — the
4689 // tool-pairing graph edge (parallel to parentUuid).
4690 if let Some(src) = v.get("sourceToolAssistantUUID").and_then(Value::as_str)
4691 {
4692 msg.metadata
4693 .insert("sourceToolAssistantUUID".to_string(), src.to_string());
4694 }
4695 // TR-10: preserve the Claude wire `is_error` flag so
4696 // the reduction layer's success/failure boundary
4697 // (`ReductionKind::ToolInputElided` must never target
4698 // an errored call) survives import — `ChatMessage`
4699 // otherwise has no structural slot for it.
4700 if b.get("is_error").and_then(Value::as_bool) == Some(true) {
4701 crate::reduce::mark_tool_error(&mut msg);
4702 } else {
4703 restore_tool_outcome_extension(v, &mut msg);
4704 }
4705 out.push(msg);
4706 }
4707 Some("image") => match claude_image_block_to_part(b) {
4708 Some(part) => images.push(part),
4709 None => saw_unconvertible_image = true,
4710 },
4711 _ => {} // document / unknown — skip
4712 }
4713 }
4714 // D5: nothing convertible landed in `text`/`images` but an
4715 // image block WAS present — fold in the same short bracketed
4716 // marker convention already used for `[web_search]`/`[model
4717 // fallback: ...]` rather than letting the record vanish.
4718 if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
4719 push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
4720 }
4721 let before = out.len();
4722 if !images.is_empty() {
4723 let mut parts = Vec::new();
4724 if !text.trim().is_empty() {
4725 parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
4726 }
4727 parts.extend(images);
4728 out.push(
4729 ChatMessage {
4730 role: Role::User,
4731 content: None,
4732 content_parts: Some(parts),
4733 tool_calls: None,
4734 tool_call_id: None,
4735 name: None,
4736 metadata: Default::default(),
4737 }
4738 .with_metas(&provenance),
4739 );
4740 } else if !text.trim().is_empty() {
4741 out.push(ChatMessage::user(text).with_metas(&provenance));
4742 }
4743 if saw_unconvertible_image && out.len() > before {
4744 if let Some(msg) = out.last_mut() {
4745 msg.metadata
4746 .insert("image_source_unconvertible".to_string(), "true".to_string());
4747 }
4748 }
4749 }
4750 _ => {}
4751 }
4752}
4753
4754/// D5 (Fable-5 review): the bracketed marker folded into `text` when an
4755/// `image` block is SEEN but [`claude_image_block_to_part`] can't convert it
4756/// (e.g. a Files-API `{"source":{"type":"file",...}}` reference) and nothing
4757/// else in the record survives either — matches the existing
4758/// `[web_search]`/`[model fallback: ...]` convention rather than letting the
4759/// whole record vanish (`push_claude_user`/`push_claude_assistant`).
4760const UNCONVERTIBLE_IMAGE_MARKER: &str =
4761 "[image: source not captured — unsupported/unconvertible image reference]";
4762
4763/// Parse a Claude Code user-turn `image` content block
4764/// (`{"type":"image","source":{"type":"base64","media_type":..,"data":..}}`
4765/// or `{"type":"image","source":{"type":"url","url":..}}`) into a
4766/// `content_parts` `image_url` entry (a `data:` URI for the base64 form, the
4767/// bare URL for the url form) — the inverse of
4768/// [`claude_user_content_value`]'s emission. Only a well-formed source
4769/// (non-empty `media_type`/`data`, or non-empty `url`) is recognized;
4770/// anything else — including a well-formed but unconvertible source like a
4771/// Files-API `{"type":"file",...}` reference (D5) — is left as raw-only
4772/// residue rather than synthesizing a corrupt/empty part (mirrors the
4773/// pi/opencode loaders' `pi_image_shape`/`opencode_file_image_part`
4774/// discipline). Callers must not let that turn the record invisible though:
4775/// see [`UNCONVERTIBLE_IMAGE_MARKER`].
4776fn claude_image_block_to_part(b: &Value) -> Option<Value> {
4777 let source = b.get("source")?;
4778 match source.get("type").and_then(Value::as_str) {
4779 Some("base64") => {
4780 let mime = source.get("media_type").and_then(Value::as_str)?;
4781 let data = source.get("data").and_then(Value::as_str)?;
4782 if mime.is_empty() || data.is_empty() {
4783 return None;
4784 }
4785 Some(serde_json::json!({
4786 "type": "image_url",
4787 "image_url": {"url": format!("data:{mime};base64,{data}")},
4788 }))
4789 }
4790 Some("url") => {
4791 let url = source.get("url").and_then(Value::as_str)?;
4792 if url.is_empty() {
4793 return None;
4794 }
4795 Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
4796 }
4797 _ => None,
4798 }
4799}
4800
4801/// Rebuild a Claude Code user-turn `message.content` value from a
4802/// `ChatMessage` — the inverse of `push_claude_user`'s parse (text blocks +
4803/// [`claude_image_block_to_part`]). When `content_parts` is absent this
4804/// MUST reproduce the historical plain-string `content` exactly (IX-5's
4805/// overriding constraint: a text-only message's export stays byte-identical)
4806/// — only a multimodal message (`content_parts` present, e.g. imported from
4807/// Pi/OpenCode or a real Claude Code image turn) gets the Anthropic
4808/// content-array shape, one `text` block (if any non-empty text part) plus
4809/// one `image` block per `image_url` part (`data:` URI → `source.base64`;
4810/// any other URL → `source.url`).
4811fn claude_user_content_value(msg: &ChatMessage) -> Value {
4812 match &msg.content_parts {
4813 Some(parts) => {
4814 let mut blocks = Vec::new();
4815 for p in parts {
4816 match p.get("type").and_then(Value::as_str) {
4817 Some("text") => {
4818 if let Some(t) = p.get("text").and_then(Value::as_str) {
4819 if !t.is_empty() {
4820 blocks.push(serde_json::json!({"type": "text", "text": t}));
4821 }
4822 }
4823 }
4824 Some("image_url") => {
4825 if let Some(url) = p
4826 .get("image_url")
4827 .and_then(|u| u.get("url"))
4828 .and_then(Value::as_str)
4829 {
4830 blocks.push(match parse_data_uri(url) {
4831 Some((mime, data)) => serde_json::json!({
4832 "type": "image",
4833 "source": {"type": "base64", "media_type": mime, "data": data},
4834 }),
4835 None => serde_json::json!({
4836 "type": "image",
4837 "source": {"type": "url", "url": url},
4838 }),
4839 });
4840 }
4841 }
4842 _ => {}
4843 }
4844 }
4845 Value::Array(blocks)
4846 }
4847 None => Value::String(msg.content.clone().unwrap_or_default()),
4848 }
4849}
4850
4851/// Rebuild a Claude Code `tool_result` block's `content` value from a `Tool`
4852/// `ChatMessage` — the inverse of [`extract_tool_result_content`]'s parse
4853/// (PARITY-11). When `content_parts` is absent (or empty) this MUST reproduce
4854/// the historical plain-string `content` exactly (same IX-5-style constraint
4855/// `claude_user_content_value` follows) — only a `tool_result` that actually
4856/// carries a captured nested image gets the Anthropic content-array shape,
4857/// one `text` block (the existing `msg.content`, if any) plus one `image`
4858/// block per `image_url` part (mirrors `claude_user_content_value`'s
4859/// `data:` URI -> `source.base64` / other URL -> `source.url` mapping).
4860fn claude_tool_result_content_value(msg: &ChatMessage) -> Value {
4861 match &msg.content_parts {
4862 Some(parts) if !parts.is_empty() => {
4863 let mut blocks = Vec::new();
4864 if let Some(t) = &msg.content {
4865 if !t.is_empty() {
4866 blocks.push(serde_json::json!({"type": "text", "text": t}));
4867 }
4868 }
4869 for p in parts {
4870 if p.get("type").and_then(Value::as_str) == Some("image_url") {
4871 if let Some(url) = p
4872 .get("image_url")
4873 .and_then(|u| u.get("url"))
4874 .and_then(Value::as_str)
4875 {
4876 blocks.push(match parse_data_uri(url) {
4877 Some((mime, data)) => serde_json::json!({
4878 "type": "image",
4879 "source": {"type": "base64", "media_type": mime, "data": data},
4880 }),
4881 None => serde_json::json!({
4882 "type": "image",
4883 "source": {"type": "url", "url": url},
4884 }),
4885 });
4886 }
4887 }
4888 }
4889 Value::Array(blocks)
4890 }
4891 _ => Value::String(msg.content.clone().unwrap_or_default()),
4892 }
4893}
4894
4895/// Collect the Claude Code user-turn provenance fields that distinguish real
4896/// human input from system-injected turns and record replay-relevant state.
4897fn claude_user_provenance(v: &Value) -> Vec<(String, String)> {
4898 let mut out = Vec::new();
4899 let mut take_str = |key: &str| {
4900 if let Some(s) = v.get(key).and_then(Value::as_str) {
4901 out.push((key.to_string(), s.to_string()));
4902 }
4903 };
4904 take_str("promptSource"); // typed | queued | system | sdk
4905 take_str("interruptedMessageId");
4906 take_str("sourceToolUseID");
4907 for flag in ["isMeta", "isCompactSummary", "isVisibleInTranscriptOnly"] {
4908 if v.get(flag).and_then(Value::as_bool) == Some(true) {
4909 out.push((flag.to_string(), "true".to_string()));
4910 }
4911 }
4912 if let Some(n) = v.get("queuePriority").and_then(Value::as_i64) {
4913 out.push(("queuePriority".to_string(), n.to_string()));
4914 }
4915 // `origin` is an object like {"kind":"task-notification"} — keep its kind.
4916 if let Some(kind) = v
4917 .get("origin")
4918 .and_then(|o| o.get("kind"))
4919 .and_then(Value::as_str)
4920 {
4921 out.push(("origin".to_string(), kind.to_string()));
4922 }
4923 out
4924}
4925
4926/// Content-bearing Claude `system` events (`scheduled_task_fire`,
4927/// `local_command`, `away_summary`) carry real text that's part of the
4928/// interaction; fold them in as system context. Marker/metric subtypes
4929/// (`turn_duration`, `compact_boundary`, `api_error`, `stop_hook_summary`) carry
4930/// no conversational content and are skipped.
4931fn push_claude_system(v: &Value, out: &mut Vec<ChatMessage>) {
4932 let keep = matches!(
4933 v.get("subtype").and_then(Value::as_str),
4934 Some("scheduled_task_fire") | Some("local_command") | Some("away_summary")
4935 );
4936 if !keep {
4937 return;
4938 }
4939 if let Some(content) = v.get("content").and_then(Value::as_str) {
4940 if !content.trim().is_empty() {
4941 let subtype = v.get("subtype").and_then(Value::as_str).unwrap_or("system");
4942 out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
4943 }
4944 }
4945}
4946
4947/// Fold content-bearing Claude Code `attachment` records into the conversation
4948/// as user-role messages. Most attachment subtypes (`task_reminder`,
4949/// `deferred_tools_delta`, `skill_listing`, `hook_*`, `command_permissions`, …)
4950/// are regenerable system injections and are skipped; only the four that carry
4951/// non-regenerable user/external content are kept.
4952fn push_claude_attachment(v: &Value, out: &mut Vec<ChatMessage>) {
4953 let att = match v.get("attachment") {
4954 Some(a) => a,
4955 None => return,
4956 };
4957 let kind = match att.get("type").and_then(Value::as_str) {
4958 Some(kind) => kind,
4959 None => return,
4960 };
4961 let text = match kind {
4962 // A queued prompt. `commandMode` says whose: `prompt` is the person's
4963 // own text, `task-notification` is the runtime reporting a finished
4964 // background task. Kept verbatim below.
4965 "queued_command" => att
4966 .get("prompt")
4967 .and_then(Value::as_str)
4968 .map(str::to_string),
4969 // A file the user attached: header + contents.
4970 "file" => attachment_with_path(att, "attached file", "filename", "content"),
4971 // A user-edited file snippet.
4972 "edited_text_file" => attachment_with_path(att, "edited file", "filename", "snippet"),
4973 // Injected project memory (CLAUDE.md), point-in-time.
4974 "nested_memory" => attachment_with_path(att, "project memory", "path", "content"),
4975 _ => None, // regenerable system injection — skip
4976 };
4977 let Some(text) = text else { return };
4978 if text.trim().is_empty() {
4979 return;
4980 }
4981 // An attachment record wears the user's ROLE, but the record itself says
4982 // who actually spoke — and that fact is lost the moment the attachment is
4983 // flattened to `[label: path]` text, so carry it as metadata the way
4984 // `push_claude_system` carries `systemSubtype`. Two fields, both verbatim:
4985 //
4986 // `attachmentType` the subtype. `file` / `edited_text_file` /
4987 // `nested_memory` are envelopes the runtime built
4988 // around a file body; a frontend that trusts the role
4989 // shows the reader a numbered source listing in a
4990 // chat bubble apparently sent by themselves.
4991 // `commandMode` present on `queued_command` only, and the whole
4992 // story for it. Measured over the local Claude Code
4993 // corpus (2,512 `queued_command` attachments): 926
4994 // `prompt`, every one of them plain human text, and
4995 // 1,586 `task-notification`, every one of them a
4996 // `<task-notification>` frame — the same text Claude
4997 // Code also writes as a `type:"user"` record stamped
4998 // `origin.kind = "task-notification"`.
4999 //
5000 // Presentation policy (which of these a frontend hides) belongs to the
5001 // frontend — `isContextMessage` in `sdk/client/client.mjs`. The loader's
5002 // job is to stop discarding the producer's own answer.
5003 let mut message = ChatMessage::user(text).with_meta("attachmentType", kind);
5004 if let Some(mode) = att.get("commandMode").and_then(Value::as_str) {
5005 message = message.with_meta("commandMode", mode);
5006 }
5007 out.push(message);
5008}
5009
5010/// Format an attachment as `[<label>: <path>]\n<body>`.
5011fn attachment_with_path(
5012 att: &Value,
5013 label: &str,
5014 path_key: &str,
5015 body_key: &str,
5016) -> Option<String> {
5017 let body = att.get(body_key).and_then(Value::as_str)?;
5018 let path = att
5019 .get(path_key)
5020 .or_else(|| att.get("displayPath"))
5021 .and_then(Value::as_str)
5022 .unwrap_or("");
5023 Some(format!("[{label}: {path}]\n{body}"))
5024}
5025
5026fn push_str_field(buf: &mut String, s: &str) {
5027 if !buf.is_empty() {
5028 buf.push('\n');
5029 }
5030 buf.push_str(s);
5031}
5032
5033/// N3: build a synthesized message for reasoning that could not attach to a
5034/// following assistant turn — either interrupted mid-stream by a
5035/// non-assistant item, or dangling at EOF (an aborted-turn shape). Drains
5036/// the three pending buffers (all empty/`false` afterward) so callers don't
5037/// separately have to remember to clear them.
5038fn orphaned_reasoning_message(
5039 reasoning: &mut String,
5040 reasoning_content: &mut String,
5041 encrypted: &mut bool,
5042) -> ChatMessage {
5043 let mut msg = ChatMessage::system("[reasoning] (turn ended without a reply)".to_string());
5044 if !reasoning.is_empty() {
5045 msg = msg.with_meta("reasoning", std::mem::take(reasoning));
5046 }
5047 if !reasoning_content.is_empty() {
5048 msg = msg.with_meta("reasoning_content", std::mem::take(reasoning_content));
5049 }
5050 if *encrypted {
5051 msg = msg.with_meta("reasoning_encrypted", "true");
5052 *encrypted = false;
5053 }
5054 msg
5055}
5056
5057fn push_claude_assistant(v: &Value, out: &mut Vec<ChatMessage>) {
5058 let content = v.get("message").and_then(|m| m.get("content"));
5059 let mut text = String::new();
5060 let mut calls: Vec<ToolCall> = Vec::new();
5061 // Legacy singular fields — kept for backward compatibility with every
5062 // existing consumer of `metadata["thinking"]`/`["thinking_signature"]`/
5063 // `["redacted_thinking"]` (a concatenation of all thinking text, and the
5064 // LAST block's signature/data). D8 (Fable-5 review, confirmed): when a
5065 // message carries MULTIPLE `thinking` blocks, collapsing them down to
5066 // these singular fields silently drops every signature but the last
5067 // one's — a real Anthropic `thinking` block's `signature` cryptographically
5068 // covers ONLY that block's own text, so re-emitting block 1's text under
5069 // block 2's signature (or vice versa) produces a signature that will
5070 // never verify. `thinking_blocks` below is the fix: every block
5071 // preserved SEPARATELY, in order, each with its own (optional)
5072 // signature/data — the writer prefers it over the legacy fields
5073 // whenever present.
5074 let mut thinking = String::new();
5075 let mut signature: Option<String> = None;
5076 // PARITY-11: real Claude corpora also carry `redacted_thinking` and
5077 // `image` assistant blocks, and (rarely) a `fallback` model-routing
5078 // marker — none handled before, all silently vanishing (audit's own
5079 // census: 132,927 `thinking` / 67 `redacted_thinking` / 9 `image` / 4
5080 // `fallback` blocks in the reference corpus).
5081 //
5082 // D8: `redacted_thinking` is real data ONLY — never a fabricated
5083 // placeholder. The pre-fix code defaulted a missing `data` field to the
5084 // literal string `"<redacted>"`, which is indistinguishable from an
5085 // actual (if oddly-named) opaque payload on re-emit — a caller reading
5086 // it back has no way to tell "no data was ever captured" from "the
5087 // provider's own opaque blob happens to be the string `<redacted>`".
5088 // `redacted_thinking_seen` tracks block PRESENCE independently of
5089 // whether it had real data, so the reasoning-only-turn rescue below
5090 // still fires even when no block had a `data` field at all.
5091 let mut redacted_thinking: Option<String> = None;
5092 let mut redacted_thinking_seen = false;
5093 let mut images: Vec<Value> = Vec::new();
5094 // Real Anthropic `thinking` blocks very commonly carry an EMPTY
5095 // `thinking` string alongside a real `signature` (the summarized/
5096 // redacted-in-the-clear-but-replayable case) — `thinking.trim()` alone
5097 // would miss those, so track "a thinking block existed at all"
5098 // separately from whether it had visible text.
5099 let mut thinking_block_seen = false;
5100 // D8: every `thinking`/`redacted_thinking` block, preserved SEPARATELY
5101 // and IN ORDER — see the comment on `thinking`/`redacted_thinking`
5102 // above. Serialized as a single JSON-array metadata string
5103 // (`ChatMessage::metadata` is a flat string map) under
5104 // `"thinking_blocks"`.
5105 let mut thinking_blocks: Vec<Value> = Vec::new();
5106 // D5: mirrors `push_claude_user`'s tracking — an `image` block whose
5107 // source isn't base64/url (e.g. Files-API `{"type":"file",...}`) must
5108 // not silently vanish the whole record when nothing else survives.
5109 let mut saw_unconvertible_image = false;
5110
5111 match content {
5112 Some(Value::String(s)) => push_text(&mut text, Some(&Value::String(s.clone()))),
5113 Some(Value::Array(blocks)) => {
5114 for b in blocks {
5115 match b.get("type").and_then(Value::as_str) {
5116 Some("text") => push_text(&mut text, b.get("text")),
5117 Some("tool_use") => {
5118 let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
5119 let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
5120 let args = b
5121 .get("input")
5122 .map(|i| i.to_string())
5123 .unwrap_or_else(|| "{}".to_string());
5124 calls.push(function_call(id, name, args));
5125 }
5126 // Thinking is not replayed across providers, but retain it in
5127 // (skip-serialized) metadata so a same-model continuation can
5128 // re-inject it. See P3.
5129 Some("thinking") => {
5130 thinking_block_seen = true;
5131 let t = b.get("thinking").and_then(Value::as_str).unwrap_or("");
5132 if !t.is_empty() {
5133 push_str_field(&mut thinking, t); // legacy concatenated field
5134 }
5135 let sig = b.get("signature").and_then(Value::as_str);
5136 if let Some(s) = sig {
5137 signature = Some(s.to_string()); // legacy last-wins field
5138 }
5139 // D8: this block's OWN text + signature, not folded
5140 // into the running concatenation above.
5141 let mut block = serde_json::json!({"type": "thinking", "thinking": t});
5142 if let Some(s) = sig {
5143 block["signature"] = Value::String(s.to_string());
5144 }
5145 thinking_blocks.push(block);
5146 }
5147 // Anthropic's redacted reasoning: an opaque, provider-private
5148 // payload (flagged content the API declines to show in the
5149 // clear). Like `thinking`, it's not replayable, but the raw
5150 // `data` is retained in metadata rather than silently
5151 // vanishing — a same-model continuation can still replay it
5152 // verbatim even though supercode never renders it.
5153 Some("redacted_thinking") => {
5154 redacted_thinking_seen = true;
5155 let data = b.get("data").and_then(Value::as_str);
5156 // D8: no fabricated fallback — `data` is only ever
5157 // the real captured payload, or genuinely absent.
5158 if let Some(d) = data {
5159 redacted_thinking = Some(d.to_string()); // legacy last-wins field
5160 }
5161 let mut block = serde_json::json!({"type": "redacted_thinking"});
5162 if let Some(d) = data {
5163 block["data"] = Value::String(d.to_string());
5164 }
5165 thinking_blocks.push(block);
5166 }
5167 // An assistant-emitted image block (e.g. a generated
5168 // image) — collected exactly like `push_claude_user`'s
5169 // user-turn image handling (`claude_image_block_to_part`
5170 // is role-general), so it survives as `content_parts`
5171 // instead of vanishing.
5172 Some("image") => match claude_image_block_to_part(b) {
5173 Some(part) => images.push(part),
5174 None => saw_unconvertible_image = true,
5175 },
5176 // A provider-routing note (real shape:
5177 // `{"type":"fallback","from":{"model":..},"to":{"model":..}}`
5178 // — a mid-generation model swap, e.g. an overloaded model
5179 // falling back to another). Carries no replayable
5180 // conversational content, but folding it into `text` as a
5181 // short bracketed marker — the same convention the Codex
5182 // loader already uses for `[web_search]`/
5183 // `[image_generation] ...` — keeps it visible instead of
5184 // silently vanishing, including the case where it's the
5185 // ONLY block in the turn (see the reasoning-only-turn fix
5186 // below: before this, that shape dropped the entire
5187 // message).
5188 Some("fallback") => {
5189 let from = b
5190 .get("from")
5191 .and_then(|f| f.get("model"))
5192 .and_then(Value::as_str)
5193 .unwrap_or("?");
5194 let to = b
5195 .get("to")
5196 .and_then(|t| t.get("model"))
5197 .and_then(Value::as_str)
5198 .unwrap_or("?");
5199 push_str_field(&mut text, &format!("[model fallback: {from} -> {to}]"));
5200 }
5201 _ => {}
5202 }
5203 }
5204 }
5205 _ => {}
5206 }
5207
5208 // D5: nothing convertible landed in `text`/`images` but an image block
5209 // WAS present — fold in the same bracketed-marker convention `fallback`
5210 // uses above, so a genuinely image-only (unconvertible source) turn
5211 // doesn't vanish (mirrors `push_claude_user`'s identical fix).
5212 if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
5213 push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
5214 }
5215
5216 let before = out.len();
5217 if !images.is_empty() {
5218 let mut parts = Vec::new();
5219 if !text.trim().is_empty() {
5220 parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
5221 }
5222 parts.extend(images);
5223 out.push(ChatMessage {
5224 role: Role::Assistant,
5225 content: None,
5226 content_parts: Some(parts),
5227 tool_calls: (!calls.is_empty()).then_some(calls),
5228 tool_call_id: None,
5229 name: None,
5230 metadata: Default::default(),
5231 });
5232 } else {
5233 push_assistant(out, text, calls);
5234 // A recognized native assistant record remains transcript state even
5235 // when its content array is empty (for example, an interrupted model
5236 // turn). Force a bare message whenever `push_assistant` had nothing
5237 // to emit. This includes the reasoning-only case and also preserves
5238 // genuinely part-less records instead of silently changing turn
5239 // count/order during translation.
5240 if out.len() == before {
5241 let mut empty = ChatMessage {
5242 role: Role::Assistant,
5243 content: None,
5244 content_parts: None,
5245 tool_calls: None,
5246 tool_call_id: None,
5247 name: None,
5248 metadata: Default::default(),
5249 };
5250 if !thinking_block_seen && !redacted_thinking_seen {
5251 empty
5252 .metadata
5253 .insert("empty_assistant_record".to_string(), "true".to_string());
5254 }
5255 out.push(empty);
5256 }
5257 }
5258 // Attach retained reasoning + attribution to the message we just produced.
5259 if out.len() > before {
5260 if let Some(msg) = out.last_mut() {
5261 // Insert "thinking" (even as an empty string) whenever a
5262 // `thinking` block was actually seen, not just when it had
5263 // visible text — a real `thinking` block commonly carries an
5264 // empty `thinking` string alongside a real `signature` (the
5265 // summarized-away-but-still-replayable case), and the writer
5266 // below keys its re-emission decision off this metadata key's
5267 // PRESENCE, not its content.
5268 if thinking_block_seen {
5269 msg.metadata.insert("thinking".to_string(), thinking);
5270 }
5271 if let Some(sig) = signature {
5272 msg.metadata.insert("thinking_signature".to_string(), sig);
5273 }
5274 if let Some(rt) = redacted_thinking {
5275 msg.metadata.insert("redacted_thinking".to_string(), rt);
5276 }
5277 // D8: exact per-block re-emission list — every `thinking`/
5278 // `redacted_thinking` block preserved separately, in order, each
5279 // with its own (optional) signature/data. The writer prefers
5280 // this over the legacy singular fields above whenever present,
5281 // so a multi-block message round-trips losslessly instead of
5282 // collapsing to one block under one (now-unverifiable)
5283 // signature.
5284 if !thinking_blocks.is_empty() {
5285 msg.metadata.insert(
5286 "thinking_blocks".to_string(),
5287 Value::Array(thinking_blocks).to_string(),
5288 );
5289 }
5290 // D5: honest signal that this message contained an image block
5291 // whose source this loader couldn't convert — the actual image
5292 // content is NOT captured, only a marker/partial record.
5293 if saw_unconvertible_image {
5294 msg.metadata
5295 .insert("image_source_unconvertible".to_string(), "true".to_string());
5296 }
5297 // Attribution: which skill / subagent / MCP server+tool produced
5298 // this turn, plus the model `slug`.
5299 for key in [
5300 "attributionSkill",
5301 "attributionAgent",
5302 "attributionMcpServer",
5303 "attributionMcpTool",
5304 "slug",
5305 ] {
5306 if let Some(s) = v.get(key).and_then(Value::as_str) {
5307 msg.metadata.insert(key.to_string(), s.to_string());
5308 }
5309 }
5310 }
5311 }
5312}
5313
5314// ---- Codex ----------------------------------------------------------------
5315
5316const SUPERCODE_CODEX_PROVENANCE_KEY: &str = "_supercode_codex_provenance";
5317
5318fn codex_provenance_kind(record: &Value) -> Option<&str> {
5319 match record.get("type").and_then(Value::as_str) {
5320 Some("session_meta") => Some("session_meta"),
5321 Some("turn_context") => Some("turn_context"),
5322 Some("compacted") => Some("compacted"),
5323 Some("event_msg") => match record
5324 .get("payload")
5325 .and_then(|payload| payload.get("type"))
5326 .and_then(Value::as_str)
5327 {
5328 Some("thread_rolled_back") => Some("event_msg/thread_rolled_back"),
5329 Some("thread_goal_updated") => Some("event_msg/thread_goal_updated"),
5330 Some("entered_review_mode") => Some("event_msg/entered_review_mode"),
5331 Some("exited_review_mode") => Some("event_msg/exited_review_mode"),
5332 _ => None,
5333 },
5334 _ => None,
5335 }
5336}
5337
5338fn capture_codex_provenance_record(
5339 meta: &mut SessionMeta,
5340 record_index: usize,
5341 raw_line: &str,
5342 record: &Value,
5343) {
5344 let Some(kind) = codex_provenance_kind(record) else {
5345 return;
5346 };
5347 meta.codex_provenance.push(serde_json::json!({
5348 "record_index": record_index,
5349 "kind": kind,
5350 "raw": raw_line,
5351 }));
5352}
5353
5354fn codex_provenance_envelope(meta: &SessionMeta) -> Option<Value> {
5355 (!meta.codex_provenance.is_empty()).then(|| {
5356 serde_json::json!({
5357 "version": 1,
5358 "records": &meta.codex_provenance,
5359 })
5360 })
5361}
5362
5363fn restore_codex_provenance(extension: &Value, meta: &mut SessionMeta) -> Result<bool> {
5364 if extension.get("version").and_then(Value::as_u64) != Some(1) {
5365 return Err(Error::InvalidSession(
5366 "invalid portable Codex provenance: expected version 1".to_string(),
5367 ));
5368 }
5369 let Some(records) = extension.get("records").and_then(Value::as_array) else {
5370 return Err(Error::InvalidSession(
5371 "invalid portable Codex provenance: `records` must be an array".to_string(),
5372 ));
5373 };
5374 if records.is_empty() {
5375 return Err(Error::InvalidSession(
5376 "invalid portable Codex provenance: `records` must not be empty".to_string(),
5377 ));
5378 }
5379 let mut restored = Vec::with_capacity(records.len());
5380 for entry in records {
5381 let Some(_record_index) = entry.get("record_index").and_then(Value::as_u64) else {
5382 return Err(Error::InvalidSession(
5383 "invalid portable Codex provenance: record_index must be an integer".to_string(),
5384 ));
5385 };
5386 let Some(kind) = entry.get("kind").and_then(Value::as_str) else {
5387 return Err(Error::InvalidSession(
5388 "invalid portable Codex provenance: kind must be a string".to_string(),
5389 ));
5390 };
5391 let Some(raw) = entry.get("raw").and_then(Value::as_str) else {
5392 return Err(Error::InvalidSession(
5393 "invalid portable Codex provenance: raw must be a string".to_string(),
5394 ));
5395 };
5396 let Ok(record) = serde_json::from_str::<Value>(raw) else {
5397 return Err(Error::InvalidSession(
5398 "invalid portable Codex provenance: raw is not valid JSON".to_string(),
5399 ));
5400 };
5401 if codex_provenance_kind(&record) != Some(kind) {
5402 return Err(Error::InvalidSession(format!(
5403 "invalid portable Codex provenance: kind `{kind}` does not match raw record"
5404 )));
5405 }
5406 restored.push(entry.clone());
5407 }
5408 meta.codex_provenance = restored;
5409 meta.codex_headers.clear();
5410 for entry in &meta.codex_provenance {
5411 let Some(raw) = entry.get("raw").and_then(Value::as_str) else {
5412 continue;
5413 };
5414 let Ok(record) = serde_json::from_str::<Value>(raw) else {
5415 continue;
5416 };
5417 if matches!(
5418 record.get("type").and_then(Value::as_str),
5419 Some("session_meta") | Some("turn_context")
5420 ) {
5421 meta.codex_headers.push(record);
5422 }
5423 }
5424 Ok(true)
5425}
5426
5427fn restore_codex_provenance_from_top_level(record: &Value, meta: &mut SessionMeta) -> Result<bool> {
5428 match record.get(SUPERCODE_CODEX_PROVENANCE_KEY) {
5429 Some(extension) => restore_codex_provenance(extension, meta),
5430 None => Ok(false),
5431 }
5432}
5433
5434fn inject_first_jsonl_top_level(out: &mut String, key: &str, extension: Value) {
5435 let Some(line_end) = out.find('\n') else {
5436 return;
5437 };
5438 let Ok(mut record) = serde_json::from_str::<Value>(&out[..line_end]) else {
5439 return;
5440 };
5441 let Some(object) = record.as_object_mut() else {
5442 return;
5443 };
5444 object.insert(key.to_string(), extension);
5445 out.replace_range(..line_end, &record.to_string());
5446}
5447
5448fn inject_codex_provenance(out: &mut String, extension: Value) {
5449 let Some(line_end) = out.find('\n') else {
5450 return;
5451 };
5452 let Ok(mut record) = serde_json::from_str::<Value>(&out[..line_end]) else {
5453 return;
5454 };
5455 if record.get("type").and_then(Value::as_str) != Some("session_meta") {
5456 return;
5457 }
5458 let Some(payload) = record.get_mut("payload").and_then(Value::as_object_mut) else {
5459 return;
5460 };
5461 payload.insert(SUPERCODE_CODEX_PROVENANCE_KEY.to_string(), extension);
5462 out.replace_range(..line_end, &record.to_string());
5463}
5464
5465/// Remove the last conversational turn from `messages`: everything from the
5466/// last `user` message to the end (the user prompt plus the assistant's
5467/// response and any tool calls/results it triggered).
5468fn remove_last_turn(messages: &mut Vec<ChatMessage>) {
5469 if let Some(idx) = messages.iter().rposition(|m| m.role == Role::User) {
5470 messages.truncate(idx);
5471 } else {
5472 messages.clear();
5473 }
5474 // IX-6 fix: the new tail exposed by `truncate` may still carry
5475 // `__codex_open_turn` from when it was marked (it was NOT the last
5476 // message at that time — items after it, now removed by the rollback,
5477 // intervened). A bare `function_call` arriving after the rollback is a
5478 // genuinely NEW turn and must get its own message, not merge into this
5479 // stale marked tail — close it out here so `push_codex_item`'s
5480 // adjacency check (`out.last()` + marker) can't be fooled by the
5481 // truncation re-exposing it.
5482 if let Some(last) = messages.last_mut() {
5483 last.metadata.remove("__codex_open_turn");
5484 }
5485}
5486
5487/// The text of a Codex `agent_message` event. `message` is usually a string but
5488/// can be a structured object (e.g. review output) — fall back to its JSON.
5489fn agent_message_text(payload: &Value) -> String {
5490 match payload.get("message") {
5491 Some(Value::String(s)) => s.clone(),
5492 Some(other) => extract_text_content(Some(other)),
5493 None => String::new(),
5494 }
5495}
5496
5497/// Trimmed texts of all assistant messages present as `response_item` — the
5498/// dedup set for recovering collab-only `agent_message` narration.
5499fn collect_codex_assistant_texts(jsonl: &str) -> std::collections::HashSet<String> {
5500 let mut set = std::collections::HashSet::new();
5501 for line in non_empty_lines(jsonl) {
5502 let Ok(v) = serde_json::from_str::<Value>(line) else {
5503 continue;
5504 };
5505 if v.get("type").and_then(Value::as_str) != Some("response_item") {
5506 continue;
5507 }
5508 let payload = v.get("payload").unwrap_or(&Value::Null);
5509 if payload.get("type").and_then(Value::as_str) == Some("message")
5510 && payload.get("role").and_then(Value::as_str) == Some("assistant")
5511 {
5512 let text = extract_text_content(payload.get("content"));
5513 if !text.trim().is_empty() {
5514 set.insert(text.trim().to_string());
5515 }
5516 }
5517 }
5518 set
5519}
5520
5521fn capture_codex_session_meta(payload: &Value, meta: &mut SessionMeta) {
5522 if meta.session_id.is_none() {
5523 if let Some(id) = payload.get("id").and_then(Value::as_str) {
5524 meta.session_id = Some(id.to_string());
5525 }
5526 }
5527 if meta.cwd.is_none() {
5528 if let Some(cwd) = payload.get("cwd").and_then(Value::as_str) {
5529 meta.cwd = Some(PathBuf::from(cwd));
5530 }
5531 }
5532 if meta.system_prompt.is_none() {
5533 // `base_instructions` may be a string or `{ "text": "..." }`.
5534 let bi = payload.get("base_instructions");
5535 let text = match bi {
5536 Some(Value::String(s)) => Some(s.clone()),
5537 Some(Value::Object(_)) => bi
5538 .and_then(|b| b.get("text"))
5539 .and_then(Value::as_str)
5540 .map(str::to_string),
5541 _ => None,
5542 };
5543 meta.system_prompt = text;
5544 }
5545 if meta.model.is_none() {
5546 if let Some(m) = payload.get("model").and_then(Value::as_str) {
5547 meta.model = Some(m.to_string());
5548 }
5549 }
5550 // Cross-file lineage keys for multi-agent / forked sessions.
5551 let mut put = |key: &str, v: Option<&Value>| {
5552 if let Some(s) = v.and_then(Value::as_str) {
5553 meta.lineage.insert(key.to_string(), s.to_string());
5554 }
5555 };
5556 put("parent_thread_id", payload.get("parent_thread_id"));
5557 put("forked_from_id", payload.get("forked_from_id"));
5558 put("thread_source", payload.get("thread_source"));
5559 // PARITY-10 dev/03: the other half of `write_synthesized_codex_header`'s
5560 // passthrough — restores a captured Claude `fork-context-ref` so a
5561 // Claude -> Codex -> Claude round trip reconstructs the original record
5562 // (`to_claude_code_jsonl` re-emits whatever lands in this lineage key).
5563 if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
5564 if let Some(v) = payload.get("claude_fork_context_ref") {
5565 meta.lineage
5566 .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
5567 }
5568 }
5569 if let Some(spawn) = payload
5570 .get("source")
5571 .and_then(|s| s.get("subagent"))
5572 .and_then(|s| s.get("thread_spawn"))
5573 {
5574 // parent_thread_id can also live here (preferred when both present).
5575 if let Some(p) = spawn.get("parent_thread_id").and_then(Value::as_str) {
5576 meta.lineage
5577 .insert("parent_thread_id".to_string(), p.to_string());
5578 }
5579 for k in ["agent_role", "agent_nickname"] {
5580 if let Some(s) = spawn.get(k).and_then(Value::as_str) {
5581 meta.lineage.insert(k.to_string(), s.to_string());
5582 }
5583 }
5584 if let Some(d) = spawn.get("depth").and_then(Value::as_i64) {
5585 meta.lineage.insert("depth".to_string(), d.to_string());
5586 }
5587 }
5588}
5589
5590/// Depth of a node in the parent forest (root = 0), bounded against cycles.
5591fn depth_of(mut i: usize, parent_of: &[Option<usize>]) -> usize {
5592 let mut d = 0;
5593 let mut guard = 0;
5594 while let Some(p) = parent_of[i] {
5595 if p == i || guard > parent_of.len() {
5596 break;
5597 }
5598 i = p;
5599 d += 1;
5600 guard += 1;
5601 }
5602 d
5603}
5604
5605/// Codex per-turn grouping id, stored under `payload.metadata.turn_id`.
5606fn codex_turn_id(payload: &Value) -> Option<&str> {
5607 payload
5608 .get("metadata")
5609 .and_then(|m| m.get("turn_id"))
5610 .and_then(Value::as_str)
5611}
5612
5613/// N2 (spliced-export hardening): every Codex group id already present in
5614/// `raw_prefix` — the verbatim RAW lines [`Session::to_codex_jsonl_spliced`]
5615/// replays ahead of the appended tail it synthesizes via
5616/// `Session::write_codex_records`. This is the GROUND TRUTH of what
5617/// physically lands in the exported `out` string for the prefix: each line
5618/// is parsed as a Codex envelope and its own `payload.metadata.turn_id` (the
5619/// exact field `codex_turn_id` reads, whether it's a real native `turn_id`
5620/// or one of OUR OWN fabricated `sc-grp-N`/`<real>~dupN` ids from a prior
5621/// export) is extracted directly — no re-derivation from `self.messages`
5622/// needed (that would have to reconstruct which ids the ORIGINAL export
5623/// happened to assign, which this sidesteps entirely by reading them back
5624/// out of the bytes themselves). A line that fails to parse, isn't a
5625/// `response_item`, or carries no `turn_id` contributes nothing — headers
5626/// and non-message/call records (e.g. `session_meta`, `function_call_output`)
5627/// never carry this field to begin with.
5628fn collect_codex_group_ids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
5629 let mut ids = HashSet::new();
5630 for line in raw_prefix {
5631 if let Ok(v) = serde_json::from_str::<Value>(line) {
5632 if let Some(payload) = v.get("payload") {
5633 if let Some(tid) = codex_turn_id(payload) {
5634 ids.insert(tid.to_string());
5635 }
5636 }
5637 }
5638 }
5639 ids
5640}
5641
5642/// Stamp every `ChatMessage` appended to `messages` since index `from` with
5643/// `ts` (a Codex record's own top-level `timestamp`, ISO-8601) as the
5644/// canonical `metadata["timestamp"]` (WAVE-2 item 1) — the same
5645/// `entry(...).or_insert_with` discipline the pi/Claude loaders use, so a
5646/// message that already carries a more specific timestamp of its own is
5647/// never overwritten (none currently do on the Codex side, but this keeps
5648/// every loader consistent). A no-op when `ts` is `None` (a line with no
5649/// `timestamp` field) or `from >= messages.len()` (nothing new was pushed).
5650fn stamp_new_codex_messages(messages: &mut [ChatMessage], from: usize, ts: Option<&str>) {
5651 let Some(ts) = ts else { return };
5652 let Some(slice) = messages.get_mut(from..) else {
5653 return;
5654 };
5655 for m in slice {
5656 m.metadata
5657 .entry("timestamp".to_string())
5658 .or_insert_with(|| ts.to_string());
5659 }
5660}
5661
5662fn push_codex_item(payload: &Value, out: &mut Vec<ChatMessage>) {
5663 match payload.get("type").and_then(Value::as_str) {
5664 Some("message") => {
5665 let role = match payload.get("role").and_then(Value::as_str) {
5666 Some("user") => Role::User,
5667 Some("assistant") => Role::Assistant,
5668 // "developer" and "system" both carry operator instructions.
5669 _ => Role::System,
5670 };
5671 let content = payload.get("content");
5672 let text = extract_text_content(content);
5673 // IX-5: `input_image` blocks alongside/instead of text — see
5674 // `codex_extract_images`. A text-only message (no image blocks)
5675 // takes the historical `content: Some(text)` shape unchanged.
5676 let images = codex_extract_images(content);
5677 let is_empty_assistant =
5678 role == Role::Assistant && text.trim().is_empty() && images.is_empty();
5679 if !text.trim().is_empty() || !images.is_empty() || is_empty_assistant {
5680 let content_parts = if images.is_empty() {
5681 None
5682 } else {
5683 let mut parts = Vec::new();
5684 if !text.trim().is_empty() {
5685 parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
5686 }
5687 parts.extend(images);
5688 Some(parts)
5689 };
5690 let mut msg = ChatMessage {
5691 role,
5692 content: if content_parts.is_some() || text.is_empty() {
5693 None
5694 } else {
5695 Some(text)
5696 },
5697 content_parts,
5698 tool_calls: None,
5699 tool_call_id: None,
5700 name: None,
5701 metadata: Default::default(),
5702 };
5703 // Preserve the assistant `phase` (commentary vs final_answer) so
5704 // a reloaded transcript can distinguish narration from the answer.
5705 if role == Role::Assistant {
5706 if let Some(phase) = payload.get("phase").and_then(Value::as_str) {
5707 msg.metadata.insert("phase".to_string(), phase.to_string());
5708 }
5709 // IX-6: mark this as an open, mergeable combined-turn
5710 // candidate — a `function_call` response_item found
5711 // immediately after (still `out.last()` when reached,
5712 // i.e. no other item intervened) merges into this SAME
5713 // `ChatMessage` instead of splitting into a second one,
5714 // matching how Claude's parser keeps a text+tool_use
5715 // turn together. Stripped again before the loaded
5716 // `Session` is returned (`from_codex_str`), so it never
5717 // leaks as visible metadata.
5718 msg.metadata
5719 .insert("__codex_open_turn".to_string(), "true".to_string());
5720 }
5721 // The per-turn grouping key (Codex batches items by turn_id).
5722 if let Some(tid) = codex_turn_id(payload) {
5723 msg.metadata.insert("turn_id".to_string(), tid.to_string());
5724 }
5725 // PARITY-6 dev/02: restore the original Claude
5726 // `systemSubtype` for a `developer`/`system` message that
5727 // was itself synthesized FROM a real Claude system record
5728 // (`write_codex_records`'s `Role::System` arm stamps
5729 // `claude_system_subtype`) — the exact inverse, so
5730 // `write_claude_code_records`'s `Role::System` arm can
5731 // re-materialize the real Claude `type: "system"` record
5732 // faithfully on a Codex -> Claude Code hop instead of
5733 // guessing a fallback subtype.
5734 if role == Role::System {
5735 if let Some(subtype) = payload
5736 .get("metadata")
5737 .and_then(|m| m.get("claude_system_subtype"))
5738 .and_then(Value::as_str)
5739 {
5740 msg.metadata
5741 .insert("systemSubtype".to_string(), subtype.to_string());
5742 }
5743 }
5744 if is_empty_assistant {
5745 msg.metadata
5746 .insert("empty_assistant_record".to_string(), "true".to_string());
5747 }
5748 out.push(msg);
5749 }
5750 }
5751 Some("function_call") => {
5752 let id = payload
5753 .get("call_id")
5754 .and_then(Value::as_str)
5755 .unwrap_or_default();
5756 let raw_name = payload
5757 .get("name")
5758 .and_then(Value::as_str)
5759 .unwrap_or_default();
5760 // Preserve the MCP `namespace` by qualifying the tool name
5761 // (`<namespace>__<name>`, matching the mcp__server__tool convention),
5762 // so the tool identity isn't ambiguous on round-trip.
5763 let qualified;
5764 let name = match payload.get("namespace").and_then(Value::as_str) {
5765 Some(ns) if !ns.is_empty() && !raw_name.starts_with(ns) => {
5766 qualified = format!("{ns}__{raw_name}");
5767 qualified.as_str()
5768 }
5769 _ => raw_name,
5770 };
5771 let args = payload
5772 .get("arguments")
5773 .map(value_to_arg_string)
5774 .unwrap_or_else(|| "{}".to_string());
5775 let call = function_call(id, name, args);
5776 // IX-6: a `function_call` immediately after an assistant `message`
5777 // in the SAME turn (still `out.last()`, marked `__codex_open_turn`
5778 // by the "message" arm above, and not yet closed by anything else)
5779 // merges into that ONE `ChatMessage` — text→`content`,
5780 // call→`tool_calls` — instead of splitting into a second message.
5781 // A bare `function_call` with no such preceding turn (the marker
5782 // absent, or `out.last()` not an assistant message) is unaffected:
5783 // it still gets its own synthesized message, exactly as before.
5784 //
5785 // Belt-and-suspenders (PARITY-6/7 tightened): if this
5786 // `function_call` response_item itself carries a `turn_id` (rare
5787 // in observed real-native-Codex corpora — Codex usually only
5788 // stamps it on `message` payloads — but ALWAYS present on OUR
5789 // OWN synthesized export whenever a `ChatMessage`'s own tool
5790 // calls need merge disambiguation, see `write_codex_records`),
5791 // it must match the marked assistant message's recorded
5792 // `turn_id` EXACTLY — including "the marked message has none at
5793 // all" counting as a mismatch. That's exactly the shape of two
5794 // genuinely separate, adjacent `ChatMessage`s (an unrelated
5795 // text-only turn immediately followed by a different,
5796 // tool-call-only turn): the tool-only turn's own `function_call`s
5797 // carry a synthetic id while the unrelated preceding text
5798 // message carries none, so this correctly refuses the merge
5799 // instead of falling through to a permissive default. Only when
5800 // this `function_call` carries NO `turn_id` at all (the ordinary
5801 // real-native-Codex shape) does this fall back to the original
5802 // permissive "adjacency + open marker is enough" rule —
5803 // unchanged from before for the vast majority of real Codex
5804 // data. The truncation/clear strip above is what actually closes
5805 // the marker across rollback/compaction boundaries; this is only
5806 // an extra guard for the case where a stale-but-unstripped
5807 // marker and a turn_id mismatch coincide.
5808 let can_merge = out.last().is_some_and(|last| {
5809 last.role == Role::Assistant
5810 && last.metadata.contains_key("__codex_open_turn")
5811 && match codex_turn_id(payload) {
5812 Some(fc_tid) => {
5813 last.metadata.get("turn_id").map(String::as_str) == Some(fc_tid)
5814 }
5815 None => true,
5816 }
5817 });
5818 if can_merge {
5819 out.last_mut()
5820 .expect("can_merge implies out.last() is Some")
5821 .tool_calls
5822 .get_or_insert_with(Vec::new)
5823 .push(call);
5824 } else {
5825 push_assistant(out, String::new(), vec![call]);
5826 // PARITY-6/7: a BARE tool-call turn (no preceding `message`
5827 // in this turn, so nothing set `__codex_open_turn` above) can
5828 // still be the FIRST of several tool calls that all belong to
5829 // the SAME original `ChatMessage` (`write_codex_records`
5830 // stamps every one of a message's own tool calls with the
5831 // identical synthetic `turn_id`). Re-open THIS freshly
5832 // created message — but ONLY when a real `turn_id` is
5833 // present — so the NEXT `function_call` in the same group
5834 // merges into it instead of becoming its own message too.
5835 // Gated on `codex_turn_id(payload).is_some()` (not the bare
5836 // default `true` the belt-and-suspenders check above uses)
5837 // so real native Codex data — which almost never carries
5838 // this field on `function_call` payloads (see the comment
5839 // above) — keeps its existing "every bare tool call is its
5840 // own turn" behavior exactly as before.
5841 if let Some(tid) = codex_turn_id(payload) {
5842 if let Some(last) = out.last_mut() {
5843 last.metadata
5844 .insert("__codex_open_turn".to_string(), "true".to_string());
5845 last.metadata.insert("turn_id".to_string(), tid.to_string());
5846 }
5847 }
5848 }
5849 }
5850 Some("function_call_output") => {
5851 let id = payload
5852 .get("call_id")
5853 .and_then(Value::as_str)
5854 .unwrap_or_default();
5855 let result = match payload.get("output") {
5856 Some(Value::String(s)) => s.clone(),
5857 Some(v) => extract_text_content(Some(v)),
5858 None => String::new(),
5859 };
5860 let mut message = tool_message(id, result);
5861 // TR-13: Codex v1 exposes no structured success/error field on
5862 // this record. Free-text output is not a safe classifier, so the
5863 // reduction engine must treat the outcome as explicitly unknown
5864 // and fail closed on both success-only and error-only pruning.
5865 crate::reduce::mark_tool_outcome_unknown(&mut message);
5866 out.push(message);
5867 }
5868 // Custom / MCP tool calls are shaped like function calls but carry their
5869 // arguments under `input` (a JSON-encoded string). Normalize them the
5870 // same way so MCP-using sessions don't lose those turns.
5871 Some("custom_tool_call") => {
5872 let id = payload
5873 .get("call_id")
5874 .and_then(Value::as_str)
5875 .unwrap_or_default();
5876 let name = payload
5877 .get("name")
5878 .and_then(Value::as_str)
5879 .unwrap_or_default();
5880 // Unlike `function_call.arguments`, Codex custom tools accept a
5881 // free-form `input` string (apply_patch is the common case).
5882 // Canonical `FunctionCall::arguments` must remain valid JSON, so
5883 // retain the input's JSON type instead of treating a free-form
5884 // string as if it were already a JSON document. This lets every
5885 // target harness carry the value rather than silently replacing
5886 // it with `{}` when `parsed_arguments()` fails.
5887 let args = payload
5888 .get("input")
5889 .map(Value::to_string)
5890 .unwrap_or_else(|| "{}".to_string());
5891 push_assistant(out, String::new(), vec![function_call(id, name, args)]);
5892 if let Some(message) = out.last_mut() {
5893 message.metadata.insert(
5894 "codex_custom_tool_call_ids".to_string(),
5895 serde_json::json!([id]).to_string(),
5896 );
5897 }
5898 }
5899 Some("custom_tool_call_output") => {
5900 let id = payload
5901 .get("call_id")
5902 .and_then(Value::as_str)
5903 .unwrap_or_default();
5904 let result = match payload.get("output") {
5905 Some(Value::String(s)) => s.clone(),
5906 Some(v) => extract_text_content(Some(v)),
5907 None => String::new(),
5908 };
5909 let mut message = tool_message(id, result);
5910 crate::reduce::mark_tool_outcome_unknown(&mut message);
5911 out.push(message);
5912 }
5913 // Tool-search is a clean call/output pair keyed by call_id.
5914 //
5915 // D1: this arm used to ALWAYS start a brand-new `ChatMessage`,
5916 // ignoring the `turn_id` merge stamps `write_codex_records` puts on
5917 // its own synthesized `tool_search_call` records (see the PARITY-6/7
5918 // comment there and on `codex_turn_id`/the `function_call` arm
5919 // above). That left the same bug-class the turn_id work fixed for
5920 // `function_call` half-done here: a single Claude assistant message
5921 // containing text + a `tool_search` block reloaded as 2 messages
5922 // (1 -> 2 inflation), and a message with 2 `tool_search` blocks
5923 // reloaded as 3. Mirror the `function_call` arm's merge check
5924 // exactly so a `tool_search_call` immediately following an open
5925 // assistant turn (or another tool call sharing the same `turn_id`)
5926 // merges into that SAME `ChatMessage` instead of splitting.
5927 Some("tool_search_call") => {
5928 let id = payload
5929 .get("call_id")
5930 .and_then(Value::as_str)
5931 .unwrap_or_default();
5932 let args = payload
5933 .get("arguments")
5934 .map(value_to_arg_string)
5935 .unwrap_or_else(|| "{}".to_string());
5936 let call = function_call(id, "tool_search", args);
5937 let can_merge = out.last().is_some_and(|last| {
5938 last.role == Role::Assistant
5939 && last.metadata.contains_key("__codex_open_turn")
5940 && match codex_turn_id(payload) {
5941 Some(fc_tid) => {
5942 last.metadata.get("turn_id").map(String::as_str) == Some(fc_tid)
5943 }
5944 None => true,
5945 }
5946 });
5947 if can_merge {
5948 out.last_mut()
5949 .expect("can_merge implies out.last() is Some")
5950 .tool_calls
5951 .get_or_insert_with(Vec::new)
5952 .push(call);
5953 } else {
5954 push_assistant(out, String::new(), vec![call]);
5955 // Re-open the freshly created message so a FOLLOWING
5956 // `function_call`/`tool_search_call` sharing this same
5957 // `turn_id` merges into it too — matching the bare
5958 // `function_call` case's own re-open logic above.
5959 if let Some(tid) = codex_turn_id(payload) {
5960 if let Some(last) = out.last_mut() {
5961 last.metadata
5962 .insert("__codex_open_turn".to_string(), "true".to_string());
5963 last.metadata.insert("turn_id".to_string(), tid.to_string());
5964 }
5965 }
5966 }
5967 }
5968 Some("tool_search_output") => {
5969 let id = payload
5970 .get("call_id")
5971 .and_then(Value::as_str)
5972 .unwrap_or_default();
5973 let result = payload
5974 .get("tools")
5975 .map(value_to_arg_string)
5976 .unwrap_or_default();
5977 out.push(tool_message(id, result));
5978 }
5979 // Web-search / image-generation response_items carry no paired output
5980 // here (results live in event_msg), so emit an assistant marker rather
5981 // than a dangling unanswered tool call.
5982 Some("web_search_call") => {
5983 push_assistant(out, "[web_search]".to_string(), Vec::new());
5984 }
5985 Some("image_generation_call") => {
5986 let prompt = payload
5987 .get("revised_prompt")
5988 .and_then(Value::as_str)
5989 .unwrap_or("");
5990 push_assistant(
5991 out,
5992 format!("[image_generation] {prompt}").trim().to_string(),
5993 Vec::new(),
5994 );
5995 }
5996 // "reasoning" and anything else — dropped.
5997 _ => {}
5998 }
5999}
6000
6001// ---- Grok -------------------------------------------------------------
6002
6003fn capture_grok_scalar_metadata(value: &Value, message: &mut ChatMessage, keys: &[&str]) {
6004 for key in keys {
6005 if let Some(value) = value.get(*key) {
6006 message.metadata.insert(
6007 format!("grok_{key}"),
6008 value
6009 .as_str()
6010 .map(str::to_string)
6011 .unwrap_or_else(|| value.to_string()),
6012 );
6013 }
6014 }
6015}
6016
6017fn grok_human_user_text(raw: &str) -> Option<String> {
6018 let text = raw.trim();
6019 if text.is_empty() || text.starts_with("<user_info>") || text.starts_with("<system-reminder>") {
6020 return None;
6021 }
6022 let unwrapped = text
6023 .strip_prefix("<user_query>")
6024 .and_then(|value| value.strip_suffix("</user_query>"))
6025 .map(str::trim)
6026 .unwrap_or(text);
6027 (!unwrapped.is_empty()).then(|| unwrapped.to_string())
6028}
6029
6030/// Portable extension for messages that originated in Grok or are being
6031/// written into Grok. The native formats do not share slots for all per-turn
6032/// metadata, multimodal content, or tool-result names, but their readers
6033/// tolerate unknown namespaced fields. Keeping one adapter-owned envelope
6034/// makes Grok a lossless intermediate without pretending its stock schema can
6035/// represent every other harness's fields directly.
6036const SUPERCODE_GROK_MESSAGE_KEY: &str = "_supercode_grok_message";
6037
6038/// Namespaced line-level extension carrying the one tool-result outcome state
6039/// Claude cannot represent natively. Keeping this narrower than the full Grok
6040/// portability envelope avoids changing unrelated target-message projection.
6041const SUPERCODE_TOOL_OUTCOME_KEY: &str = "_supercode_tool_outcome";
6042
6043fn restore_tool_outcome_extension(value: &Value, message: &mut ChatMessage) {
6044 if !crate::reduce::is_tool_error(message)
6045 && value
6046 .get(SUPERCODE_TOOL_OUTCOME_KEY)
6047 .and_then(Value::as_str)
6048 == Some("unknown")
6049 {
6050 crate::reduce::mark_tool_outcome_unknown(message);
6051 }
6052}
6053
6054fn grok_message_extension(source: SessionSource, message: &ChatMessage) -> Option<Value> {
6055 let metadata = message
6056 .metadata
6057 .iter()
6058 .filter(|(key, _)| key.starts_with("grok_"))
6059 .map(|(key, value)| (key.clone(), Value::String(value.clone())))
6060 .collect::<serde_json::Map<_, _>>();
6061
6062 // `meta.source` changes after every reload. Keying portability only on
6063 // the immediate source therefore made Grok metadata survive one hop but
6064 // disappear on A -> B -> C translations. Once Grok-owned fields are
6065 // present, keep forwarding them regardless of the current container.
6066 let has_portable_metadata =
6067 !metadata.is_empty() || message.metadata.contains_key("codex_custom_tool_call_ids");
6068 (source == SessionSource::Grok || has_portable_metadata || message.content_parts.is_some())
6069 .then(|| {
6070 serde_json::json!({
6071 "schema": 2,
6072 "role": message.role,
6073 "content": message.content,
6074 "content_parts": message.content_parts,
6075 "tool_calls": message.tool_calls,
6076 "tool_call_id": message.tool_call_id,
6077 "name": message.name,
6078 "metadata": message.metadata,
6079 })
6080 })
6081}
6082
6083fn set_grok_target_message_extension(value: &mut Value, message: &ChatMessage) {
6084 value[SUPERCODE_GROK_MESSAGE_KEY] = serde_json::json!({
6085 "schema": 2,
6086 "role": message.role,
6087 "content": message.content,
6088 "content_parts": message.content_parts,
6089 "tool_calls": message.tool_calls,
6090 "tool_call_id": message.tool_call_id,
6091 "name": message.name,
6092 "metadata": message.metadata,
6093 });
6094}
6095
6096fn set_grok_message_extension(value: &mut Value, source: SessionSource, message: &ChatMessage) {
6097 if let Some(extension) = grok_message_extension(source, message) {
6098 value[SUPERCODE_GROK_MESSAGE_KEY] = extension;
6099 }
6100}
6101
6102fn restore_grok_message_extension(value: &Value, message: &mut ChatMessage) {
6103 let Some(extension) = value.get(SUPERCODE_GROK_MESSAGE_KEY) else {
6104 return;
6105 };
6106 // Codex temporarily marks a text assistant item so immediately-following
6107 // function-call items can merge back into the same canonical turn. The
6108 // portable envelope must not erase that loader-private marker before the
6109 // merge happens; `from_codex_str` removes it before returning.
6110 let codex_open_turn = message.metadata.get("__codex_open_turn").cloned();
6111 let codex_turn_id = message.metadata.get("turn_id").cloned();
6112 let extension_has_turn_id = extension
6113 .get("metadata")
6114 .and_then(Value::as_object)
6115 .is_some_and(|metadata| metadata.contains_key("turn_id"));
6116 if extension.get("schema").and_then(Value::as_u64) == Some(2) {
6117 if let Some(role) = extension
6118 .get("role")
6119 .and_then(|value| serde_json::from_value(value.clone()).ok())
6120 {
6121 message.role = role;
6122 }
6123 message.content = extension
6124 .get("content")
6125 .and_then(Value::as_str)
6126 .map(str::to_string);
6127 message.content_parts = extension
6128 .get("content_parts")
6129 .and_then(|value| serde_json::from_value(value.clone()).ok());
6130 // Tool calls are shared native structure in every supported format.
6131 // Keep the loader's reconstruction instead of restoring this copy:
6132 // Codex stores a combined text+tool turn across multiple records, so
6133 // eagerly restoring calls on its text record would duplicate them
6134 // when the following function-call records merge.
6135 message.tool_call_id = extension
6136 .get("tool_call_id")
6137 .and_then(Value::as_str)
6138 .map(str::to_string);
6139 message.name = extension
6140 .get("name")
6141 .and_then(Value::as_str)
6142 .map(str::to_string);
6143 message.metadata.clear();
6144 }
6145 if let Some(metadata) = extension.get("metadata").and_then(Value::as_object) {
6146 for (key, value) in metadata {
6147 if let Some(value) = value.as_str() {
6148 message.metadata.insert(key.clone(), value.to_string());
6149 }
6150 }
6151 }
6152 if let Some(name) = extension.get("name").and_then(Value::as_str) {
6153 message.name = Some(name.to_string());
6154 }
6155 if let Some(marker) = codex_open_turn {
6156 message
6157 .metadata
6158 .insert("__codex_open_turn".to_string(), marker);
6159 }
6160 if let Some(turn_id) = codex_turn_id {
6161 message.metadata.insert("turn_id".to_string(), turn_id);
6162 if !extension_has_turn_id {
6163 message.metadata.insert(
6164 "__grok_remove_synthetic_turn_id".to_string(),
6165 "true".to_string(),
6166 );
6167 }
6168 }
6169}
6170
6171fn restore_single_grok_message(value: &Value, messages: &mut [ChatMessage]) {
6172 if let [message] = messages {
6173 restore_grok_message_extension(value, message);
6174 }
6175}
6176
6177pub(crate) fn percent_decode_path(encoded: &str) -> Option<String> {
6178 fn hex(byte: u8) -> Option<u8> {
6179 match byte {
6180 b'0'..=b'9' => Some(byte - b'0'),
6181 b'a'..=b'f' => Some(byte - b'a' + 10),
6182 b'A'..=b'F' => Some(byte - b'A' + 10),
6183 _ => None,
6184 }
6185 }
6186
6187 let bytes = encoded.as_bytes();
6188 let mut decoded = Vec::with_capacity(bytes.len());
6189 let mut index = 0usize;
6190 while index < bytes.len() {
6191 if bytes[index] == b'%' {
6192 let high = *bytes.get(index + 1)?;
6193 let low = *bytes.get(index + 2)?;
6194 decoded.push(hex(high)? * 16 + hex(low)?);
6195 index += 3;
6196 } else {
6197 decoded.push(bytes[index]);
6198 index += 1;
6199 }
6200 }
6201 String::from_utf8(decoded).ok()
6202}
6203
6204// ---- Pi ---------------------------------------------------------------
6205
6206fn capture_pi_header(v: &Value, meta: &mut SessionMeta) -> Result<()> {
6207 restore_codex_provenance_from_top_level(v, meta)?;
6208 if let Some(id) = v.get("id").and_then(Value::as_str) {
6209 meta.session_id = Some(id.to_string());
6210 }
6211 if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
6212 meta.cwd = Some(PathBuf::from(cwd));
6213 }
6214 // Absent `version` means a pre-v3 file (`pi-fields.md` §1: "absent = v1").
6215 let version = v
6216 .get("version")
6217 .and_then(Value::as_u64)
6218 .map(|n| n.to_string())
6219 .unwrap_or_else(|| "1".to_string());
6220 meta.lineage.insert("pi_version".to_string(), version);
6221 if let Some(ts) = v.get("timestamp").and_then(Value::as_str) {
6222 meta.lineage
6223 .insert("created_at".to_string(), ts.to_string());
6224 }
6225 if let Some(ps) = v.get("parentSession").and_then(Value::as_str) {
6226 meta.lineage
6227 .insert("parent_session_path".to_string(), ps.to_string());
6228 }
6229 // D7: the other half of `push_pi_header`'s passthrough — restores a
6230 // captured Claude `fork-context-ref` so a Claude -> Pi -> Claude round
6231 // trip reconstructs the original record (mirrors
6232 // `capture_codex_session_meta`'s identical `claude_fork_context_ref`
6233 // restore for the Codex hop).
6234 if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
6235 if let Some(v) = v.get("claude_fork_context_ref") {
6236 meta.lineage
6237 .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
6238 }
6239 }
6240 Ok(())
6241}
6242
6243/// Validate one `ImageContent` block's `{mimeType, data}` shape and return
6244/// `(mime, data)` when it looks like a real image payload.
6245///
6246/// **This shape is UNVERIFIED against real pi output**: `pi-fields.md` cites
6247/// `ai:316-350` for the `ImageContent` content-block union but does not
6248/// enumerate `ImageContent`'s own fields (only `TextContent`/`ThinkingContent`/
6249/// `ToolCall` are itemized there) — `{mimeType, data}` (mirroring the OpenAI/
6250/// Anthropic multimodal wire shape) is this loader's best guess, not a
6251/// frozen-spec fact. A follow-up TR tracks confirming/correcting this shape
6252/// against a real pi corpus. Until then this function VALIDATES rather than
6253/// assumes: both fields must be present, non-empty strings, and `data` must
6254/// look like base64 (only the base64 alphabet, incl. `=` padding) — anything
6255/// else is an unknown/unexpected image shape, and the caller must route the
6256/// whole message to raw-only survival (S6-style fail loud) instead of
6257/// silently synthesizing a corrupt/empty `image_url` part.
6258fn pi_image_shape(item: &Value) -> Option<(String, String)> {
6259 let mime = item.get("mimeType").and_then(Value::as_str)?;
6260 let data = item.get("data").and_then(Value::as_str)?;
6261 if mime.is_empty() || data.is_empty() {
6262 return None;
6263 }
6264 if !data
6265 .bytes()
6266 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
6267 {
6268 return None;
6269 }
6270 Some((mime.to_string(), data.to_string()))
6271}
6272
6273/// True if `content` (a pi content value: bare string or
6274/// `(TextContent|ImageContent)[]`) contains at least one `type:"image"` block
6275/// that does not match [`pi_image_shape`] — shared by the loader (which
6276/// routes such a message to raw-only survival, never a synthesized-empty
6277/// part) and the coverage audit (`audit::Corpus::Pi`), which scores it as
6278/// `message/UnknownImageShape` instead of silently `Normalized`, so the shape
6279/// mismatch surfaces as a coverage FAILURE rather than vanishing.
6280pub(crate) fn pi_content_has_unknown_image_shape(content: Option<&Value>) -> bool {
6281 let Some(Value::Array(items)) = content else {
6282 return false;
6283 };
6284 items.iter().any(|item| {
6285 item.get("type").and_then(Value::as_str) == Some("image") && pi_image_shape(item).is_none()
6286 })
6287}
6288
6289/// Split a pi `(TextContent|ImageContent)[]` (or bare string) content value
6290/// into concatenated text plus, when a WELL-FORMED image block is present,
6291/// the full `content_parts` array (leading text block + one `image_url` part
6292/// per image, its `data:` URI carrying the exact `mimeType`/`data` bytes pi
6293/// stored) — shared by `user`/`toolResult`/`custom*` content, which all use
6294/// the identical union (`pi-fields.md` §3a/§3c/§3e).
6295///
6296/// Returns `(text, parts, unknown_image_shape)`. When an image block does NOT
6297/// match [`pi_image_shape`] (missing/empty `data`/`mimeType`, or a `data`
6298/// value that isn't recognizable base64), this NEVER synthesizes an empty/
6299/// corrupt `image_url` part — it reports `unknown_image_shape = true` and
6300/// every caller must treat that as raw-only survival for the whole message
6301/// (mirroring the unknown-`message.role` rule, S6), so a shape this loader
6302/// guessed wrong fails loud instead of silently dropping/corrupting the
6303/// image.
6304fn pi_content_to_text_and_parts(content: Option<&Value>) -> (String, Option<Vec<Value>>, bool) {
6305 match content {
6306 Some(Value::String(s)) => (s.clone(), None, false),
6307 Some(Value::Array(items)) => {
6308 let mut text = String::new();
6309 let mut parts: Vec<Value> = Vec::new();
6310 let mut has_image = false;
6311 let mut unknown_image_shape = false;
6312 for item in items {
6313 match item.get("type").and_then(Value::as_str) {
6314 Some("text") => {
6315 if let Some(t) = item.get("text").and_then(Value::as_str) {
6316 push_str_field(&mut text, t);
6317 }
6318 }
6319 Some("image") => {
6320 has_image = true;
6321 match pi_image_shape(item) {
6322 Some((mime, data)) => {
6323 parts.push(serde_json::json!({
6324 "type": "image_url",
6325 "image_url": {"url": format!("data:{mime};base64,{data}")},
6326 }));
6327 }
6328 None => unknown_image_shape = true,
6329 }
6330 }
6331 _ => {}
6332 }
6333 }
6334 if unknown_image_shape {
6335 // Never synthesize an empty/corrupt part for a shape we
6336 // don't recognize — raw-only survival for the whole message;
6337 // the coverage guard is what turns this into a visible
6338 // failure (S6-style).
6339 return (String::new(), None, true);
6340 }
6341 if has_image {
6342 if !text.trim().is_empty() {
6343 parts.insert(0, serde_json::json!({"type": "text", "text": text.clone()}));
6344 }
6345 (text, Some(parts), false)
6346 } else {
6347 (text, None, false)
6348 }
6349 }
6350 _ => (String::new(), None, false),
6351 }
6352}
6353
6354fn push_pi_user(msg_v: &Value, out: &mut Vec<ChatMessage>) {
6355 let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
6356 // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
6357 // survival, never a synthesized-empty part. `audit::Corpus::Pi`'s
6358 // `message/UnknownImageShape` bucket is what turns this into a visible
6359 // coverage failure.
6360 if unknown_image_shape {
6361 return;
6362 }
6363 if text.trim().is_empty() && parts.is_none() {
6364 return;
6365 }
6366 let mut msg = match parts {
6367 Some(parts) => ChatMessage {
6368 role: Role::User,
6369 content: None,
6370 content_parts: Some(parts),
6371 tool_calls: None,
6372 tool_call_id: None,
6373 name: None,
6374 metadata: Default::default(),
6375 },
6376 None => ChatMessage::user(text),
6377 };
6378 // WAVE-2 fidelity fix: pi's message-level unix-ms clock
6379 // (`message.timestamp`) is a DISTINCT field from the canonical
6380 // entry-level ISO `metadata["timestamp"]` WAVE-2 item 1 wires — the two
6381 // carry genuinely different values in real corpora (the fixture's are
6382 // ~6 months apart). Preserve it separately so it isn't silently lost for
6383 // every pi session; see `msg_pi_native_timestamp_ms` (the pi writer's
6384 // native round-trip consumer) and the INHERENT residue note on
6385 // `pi_dropped_keys_cross` in `interop_fidelity_matrix.rs`.
6386 if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
6387 msg.metadata
6388 .insert("pi_msg_timestamp".to_string(), ts.to_string());
6389 }
6390 out.push(msg);
6391}
6392
6393fn push_pi_assistant(msg_v: &Value, out: &mut Vec<ChatMessage>) {
6394 let mut text = String::new();
6395 let mut calls: Vec<ToolCall> = Vec::new();
6396 let mut thinking = String::new();
6397 let mut thinking_seen = false;
6398 let mut thinking_sig: Option<String> = None;
6399 let mut thinking_redacted = false;
6400 let mut text_sig: Option<String> = None;
6401 let mut thought_sig: Option<String> = None;
6402
6403 if let Some(Value::Array(blocks)) = msg_v.get("content") {
6404 for b in blocks {
6405 match b.get("type").and_then(Value::as_str) {
6406 Some("text") => {
6407 if let Some(t) = b.get("text").and_then(Value::as_str) {
6408 push_str_field(&mut text, t);
6409 }
6410 if let Some(sig) = b.get("textSignature") {
6411 text_sig = Some(match sig {
6412 Value::String(s) => s.clone(),
6413 other => other.to_string(),
6414 });
6415 }
6416 }
6417 Some("thinking") => {
6418 thinking_seen = true;
6419 if let Some(t) = b.get("thinking").and_then(Value::as_str) {
6420 push_str_field(&mut thinking, t);
6421 }
6422 if let Some(sig) = b.get("thinkingSignature").and_then(Value::as_str) {
6423 thinking_sig = Some(sig.to_string());
6424 }
6425 if b.get("redacted").and_then(Value::as_bool) == Some(true) {
6426 thinking_redacted = true;
6427 }
6428 }
6429 Some("toolCall") => {
6430 let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
6431 let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
6432 // `arguments` is a JSON OBJECT on pi's wire, not a string
6433 // (`pi-fields.md` §3b open question 4) — serialize to the
6434 // string `FunctionCall::arguments` expects.
6435 let args = b
6436 .get("arguments")
6437 .cloned()
6438 .unwrap_or_else(|| Value::Object(Default::default()));
6439 calls.push(function_call(id, name, args.to_string()));
6440 if let Some(sig) = b.get("thoughtSignature").and_then(Value::as_str) {
6441 thought_sig = Some(sig.to_string());
6442 }
6443 }
6444 _ => {}
6445 }
6446 }
6447 }
6448
6449 let before = out.len();
6450 push_assistant(out, text, calls);
6451 // A recognized native assistant entry remains transcript state even
6452 // when its content array is empty, except Pi's explicit empty error
6453 // response: that record has no replayable content and is established
6454 // raw-only residue (`pi_real_corpus_error_retry`). Preserve empty
6455 // non-error turns and Pi's standalone thinking-block shape.
6456 let is_empty_error =
6457 !thinking_seen && msg_v.get("stopReason").and_then(Value::as_str) == Some("error");
6458 if out.len() == before && !is_empty_error {
6459 let mut empty = ChatMessage {
6460 role: Role::Assistant,
6461 content: None,
6462 content_parts: None,
6463 tool_calls: None,
6464 tool_call_id: None,
6465 name: None,
6466 metadata: Default::default(),
6467 };
6468 if !thinking_seen {
6469 empty
6470 .metadata
6471 .insert("empty_assistant_record".to_string(), "true".to_string());
6472 }
6473 out.push(empty);
6474 }
6475 if out.len() > before {
6476 let msg = out.last_mut().expect("just pushed");
6477 if thinking_seen {
6478 msg.metadata.insert("thinking".to_string(), thinking);
6479 }
6480 if let Some(s) = thinking_sig {
6481 msg.metadata.insert("thinking_signature".to_string(), s);
6482 }
6483 if thinking_redacted {
6484 msg.metadata
6485 .insert("pi_thinking_redacted".to_string(), "true".to_string());
6486 }
6487 if let Some(s) = text_sig {
6488 msg.metadata.insert("pi_text_signature".to_string(), s);
6489 }
6490 if let Some(s) = thought_sig {
6491 msg.metadata.insert("pi_thought_signature".to_string(), s);
6492 }
6493 for (key, field) in [
6494 ("pi_api", "api"),
6495 ("pi_provider", "provider"),
6496 ("pi_response_model", "responseModel"),
6497 ("pi_response_id", "responseId"),
6498 ("pi_stop_reason", "stopReason"),
6499 ("pi_error_message", "errorMessage"),
6500 ] {
6501 if let Some(s) = msg_v.get(field).and_then(Value::as_str) {
6502 msg.metadata.insert(key.to_string(), s.to_string());
6503 }
6504 }
6505 if let Some(diag) = msg_v.get("diagnostics") {
6506 if !diag.is_null() {
6507 msg.metadata
6508 .insert("pi_diagnostics".to_string(), diag.to_string());
6509 }
6510 }
6511 if let Some(usage) = msg_v.get("usage") {
6512 if !usage.is_null() {
6513 msg.metadata
6514 .insert("pi_usage".to_string(), usage.to_string());
6515 }
6516 }
6517 // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
6518 // separately from the canonical entry-level ISO `timestamp` — see
6519 // `push_pi_user`.
6520 if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
6521 msg.metadata
6522 .insert("pi_msg_timestamp".to_string(), ts.to_string());
6523 }
6524 }
6525}
6526
6527fn push_pi_tool_result(msg_v: &Value, out: &mut Vec<ChatMessage>) {
6528 let id = msg_v
6529 .get("toolCallId")
6530 .and_then(Value::as_str)
6531 .unwrap_or_default();
6532 let name = msg_v
6533 .get("toolName")
6534 .and_then(Value::as_str)
6535 .unwrap_or_default();
6536 let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(msg_v.get("content"));
6537 // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
6538 // survival, never a synthesized-empty part. Dropping the toolResult
6539 // message here leaves its `toolCallId` unanswered, which
6540 // `ensure_tool_results_paired` already turns into a visible
6541 // "[no tool result recorded — turn interrupted]" placeholder — a loud
6542 // failure mode, not a silent one.
6543 if unknown_image_shape {
6544 return;
6545 }
6546 let mut msg = ChatMessage {
6547 role: Role::Tool,
6548 content: Some(text),
6549 content_parts: parts,
6550 tool_calls: None,
6551 tool_call_id: Some(id.to_string()),
6552 name: Some(name.to_string()),
6553 metadata: Default::default(),
6554 };
6555 if let Some(details) = msg_v.get("details") {
6556 if !details.is_null() {
6557 msg.metadata
6558 .insert("pi_tool_details".to_string(), details.to_string());
6559 }
6560 }
6561 let is_error = msg_v
6562 .get("isError")
6563 .and_then(Value::as_bool)
6564 .unwrap_or(false);
6565 msg.metadata
6566 .insert("pi_is_error".to_string(), is_error.to_string());
6567 if is_error {
6568 crate::reduce::mark_tool_error(&mut msg);
6569 }
6570 // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
6571 // separately from the canonical entry-level ISO `timestamp` — see
6572 // `push_pi_user`.
6573 if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
6574 msg.metadata
6575 .insert("pi_msg_timestamp".to_string(), ts.to_string());
6576 }
6577 out.push(msg);
6578}
6579
6580/// Render a pi `bashExecution` message (`!`/`!!` shell escape) to the text
6581/// pi itself sends the model, mirroring `bashExecutionToText`
6582/// (`pi-fields.md` §3d, `msg:82-98`). The exact upstream string constants
6583/// aren't reproduced in the frozen research doc (only cited by file:line),
6584/// so this is a faithful, clearly-labeled reconstruction — every structured
6585/// field is additionally preserved verbatim in `metadata`/`raw` regardless.
6586fn push_pi_bash(msg_v: &Value, out: &mut Vec<ChatMessage>) {
6587 let command = msg_v.get("command").and_then(Value::as_str).unwrap_or("");
6588 let output = msg_v.get("output").and_then(Value::as_str).unwrap_or("");
6589 let exit_code = msg_v.get("exitCode").and_then(Value::as_i64);
6590 let cancelled = msg_v
6591 .get("cancelled")
6592 .and_then(Value::as_bool)
6593 .unwrap_or(false);
6594 let truncated = msg_v
6595 .get("truncated")
6596 .and_then(Value::as_bool)
6597 .unwrap_or(false);
6598
6599 let mut text = format!("$ {command}\n{output}");
6600 if let Some(code) = exit_code {
6601 if code != 0 {
6602 text.push_str(&format!("\n[exit code: {code}]"));
6603 }
6604 }
6605 if cancelled {
6606 text.push_str("\n[cancelled]");
6607 }
6608 if truncated {
6609 text.push_str("\n[truncated]");
6610 }
6611
6612 let mut msg = ChatMessage::user(text);
6613 msg.metadata
6614 .insert("pi_bash_command".to_string(), command.to_string());
6615 msg.metadata
6616 .insert("pi_bash_output".to_string(), output.to_string());
6617 if let Some(code) = exit_code {
6618 msg.metadata
6619 .insert("pi_bash_exit_code".to_string(), code.to_string());
6620 }
6621 msg.metadata
6622 .insert("pi_bash_cancelled".to_string(), cancelled.to_string());
6623 msg.metadata
6624 .insert("pi_bash_truncated".to_string(), truncated.to_string());
6625 if let Some(p) = msg_v.get("fullOutputPath").and_then(Value::as_str) {
6626 msg.metadata
6627 .insert("pi_bash_full_output_path".to_string(), p.to_string());
6628 }
6629 // `!!` — hidden from the model context; honored by `is_replay_excluded`
6630 // on every writer, not just pi's own (§2.2).
6631 if msg_v.get("excludeFromContext").and_then(Value::as_bool) == Some(true) {
6632 msg.metadata
6633 .insert("pi_exclude_from_context".to_string(), "true".to_string());
6634 }
6635 // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
6636 // separately from the canonical entry-level ISO `timestamp` — see
6637 // `push_pi_user`.
6638 if let Some(ts) = msg_v.get("timestamp").and_then(Value::as_u64) {
6639 msg.metadata
6640 .insert("pi_msg_timestamp".to_string(), ts.to_string());
6641 }
6642 out.push(msg);
6643}
6644
6645/// B4: the `customType` marker `write_pi_entries`'s `Role::System` arm
6646/// stamps on a re-materialized content-bearing Claude `system` record (see
6647/// that arm's doc comment). Namespaced (`supercode_`-prefixed) so it can
6648/// never collide with a real pi `CustomMessage.customType` — pi's own
6649/// hook-injected custom types are hook/extension names (e.g. `hookMessage`
6650/// migration targets), never this literal string.
6651const PI_CLAUDE_SYSTEM_CUSTOM_TYPE: &str = "supercode_claude_system";
6652
6653/// Shared mapping for pi's `role:"custom"` message (§3e) and top-level
6654/// `custom_message` entries (§9) — both enter context as a `User` message
6655/// with the same `customType`/`display`/`details` residue.
6656///
6657/// B4 exception: when `customType` is [`PI_CLAUDE_SYSTEM_CUSTOM_TYPE`] (our
6658/// own marker — see `write_pi_entries`'s `Role::System` arm), this is
6659/// actually a re-materialized content-bearing Claude `system` record round-
6660/// tripping through pi, not a genuine pi extension message — restore
6661/// `Role::System` + `metadata["systemSubtype"]` (from `details.
6662/// claude_system_subtype`, falling back to `local_command` — still one of
6663/// `push_claude_system`'s own keep subtypes — exactly like
6664/// `write_codex_records`'s Codex-leg fallback) instead of the generic
6665/// `Role::User` path below, so a Claude -> Pi -> Claude round trip restores
6666/// the exact original role, not just the text.
6667fn push_pi_custom_common(v: &Value, out: &mut Vec<ChatMessage>) {
6668 if v.get("customType").and_then(Value::as_str) == Some(PI_CLAUDE_SYSTEM_CUSTOM_TYPE) {
6669 let content = v.get("content").and_then(Value::as_str).unwrap_or("");
6670 if content.trim().is_empty() {
6671 return;
6672 }
6673 let subtype = v
6674 .get("details")
6675 .and_then(|d| d.get("claude_system_subtype"))
6676 .and_then(Value::as_str)
6677 .unwrap_or("local_command");
6678 out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
6679 return;
6680 }
6681 let (text, parts, unknown_image_shape) = pi_content_to_text_and_parts(v.get("content"));
6682 // Unrecognized image shape (S6-style fail loud, FIX #2): raw-only
6683 // survival, never a synthesized-empty part.
6684 if unknown_image_shape {
6685 return;
6686 }
6687 if text.trim().is_empty() && parts.is_none() {
6688 return;
6689 }
6690 let mut msg = match parts {
6691 Some(parts) => ChatMessage {
6692 role: Role::User,
6693 content: None,
6694 content_parts: Some(parts),
6695 tool_calls: None,
6696 tool_call_id: None,
6697 name: None,
6698 metadata: Default::default(),
6699 },
6700 None => ChatMessage::user(text),
6701 };
6702 if let Some(ct) = v.get("customType").and_then(Value::as_str) {
6703 msg.metadata
6704 .insert("pi_custom_type".to_string(), ct.to_string());
6705 }
6706 if let Some(d) = v.get("display").and_then(Value::as_bool) {
6707 msg.metadata.insert("pi_display".to_string(), d.to_string());
6708 }
6709 if let Some(details) = v.get("details") {
6710 if !details.is_null() {
6711 msg.metadata
6712 .insert("pi_details".to_string(), details.to_string());
6713 }
6714 }
6715 // WAVE-2 fidelity fix: preserve pi's message-level unix-ms clock
6716 // separately from the canonical entry-level ISO `timestamp` — see
6717 // `push_pi_user`. `v` here is the `message` object for the `role:
6718 // "custom"` case; for the top-level `custom_message` case `v` is the
6719 // entry itself, whose `timestamp` is the entry-level ISO string (not a
6720 // u64), so this is a no-op there — matching pre-WAVE-2 behavior.
6721 if let Some(ts) = v.get("timestamp").and_then(Value::as_u64) {
6722 msg.metadata
6723 .insert("pi_msg_timestamp".to_string(), ts.to_string());
6724 }
6725 out.push(msg);
6726}
6727
6728/// pi's own prefix-wrapped user text for a `compaction` entry summary
6729/// (§2.1: "compaction/branch summaries (pi's own prefix-wrapped user text)").
6730/// The exact upstream wrapper string is cited (`msg:11-17`) but not
6731/// reproduced in the frozen research doc; this is a clearly-labeled
6732/// reconstruction, not pi's literal bytes — see build-report ambiguity note.
6733fn push_pi_compaction(entry_v: &Value, out: &mut Vec<ChatMessage>) {
6734 let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
6735 if summary.trim().is_empty() {
6736 return;
6737 }
6738 let mut msg = ChatMessage::user(format!("[compaction summary]\n{summary}"));
6739 msg.metadata
6740 .insert("pi_type".to_string(), "compaction".to_string());
6741 if let Some(fk) = entry_v.get("firstKeptEntryId").and_then(Value::as_str) {
6742 msg.metadata
6743 .insert("pi_first_kept_entry_id".to_string(), fk.to_string());
6744 }
6745 if let Some(tb) = entry_v.get("tokensBefore").and_then(Value::as_u64) {
6746 msg.metadata
6747 .insert("pi_tokens_before".to_string(), tb.to_string());
6748 }
6749 if let Some(d) = entry_v.get("details") {
6750 if !d.is_null() {
6751 msg.metadata.insert("pi_details".to_string(), d.to_string());
6752 }
6753 }
6754 if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
6755 msg.metadata
6756 .insert("pi_from_hook".to_string(), "true".to_string());
6757 }
6758 out.push(msg);
6759}
6760
6761/// pi's own prefix-wrapped user text for a `branch_summary` entry (a
6762/// rewind-with-summary) — same reconstruction caveat as
6763/// [`push_pi_compaction`].
6764fn push_pi_branch_summary(entry_v: &Value, out: &mut Vec<ChatMessage>) {
6765 let summary = entry_v.get("summary").and_then(Value::as_str).unwrap_or("");
6766 if summary.trim().is_empty() {
6767 return;
6768 }
6769 let mut msg = ChatMessage::user(format!("[branch summary]\n{summary}"));
6770 msg.metadata
6771 .insert("pi_type".to_string(), "branch_summary".to_string());
6772 if let Some(f) = entry_v.get("fromId").and_then(Value::as_str) {
6773 msg.metadata.insert("pi_from_id".to_string(), f.to_string());
6774 }
6775 if let Some(d) = entry_v.get("details") {
6776 if !d.is_null() {
6777 msg.metadata.insert("pi_details".to_string(), d.to_string());
6778 }
6779 }
6780 if entry_v.get("fromHook").and_then(Value::as_bool) == Some(true) {
6781 msg.metadata
6782 .insert("pi_from_hook".to_string(), "true".to_string());
6783 }
6784 out.push(msg);
6785}
6786
6787// ---- OpenCode ---------------------------------------------------------
6788
6789/// The placeholder opencode's own replay substitutes for a `tool` part's
6790/// output once `state.completed.time.compacted` is set
6791/// (`message-v2.ts:293-296 @fd9ee43`) — the REAL output is never actually
6792/// erased from the record (S1); it survives in `raw` and in this loader's
6793/// `metadata["oc_tool_output_compacted"]`.
6794pub const OPENCODE_COMPACTED_TOOL_PLACEHOLDER: &str = "[Old tool result content cleared]";
6795
6796fn capture_opencode_session_info(si: &Value, meta: &mut SessionMeta) -> Result<()> {
6797 restore_codex_provenance_from_top_level(si, meta)?;
6798 if let Some(id) = si.get("id").and_then(Value::as_str) {
6799 meta.session_id = Some(id.to_string());
6800 }
6801 if let Some(dir) = si.get("directory").and_then(Value::as_str) {
6802 meta.cwd = Some(PathBuf::from(dir));
6803 }
6804 if let Some(agent) = si.get("agent").and_then(Value::as_str) {
6805 meta.agent_id = Some(agent.to_string());
6806 }
6807 if let Some(model) = si.get("model") {
6808 let provider = model.get("providerID").and_then(Value::as_str);
6809 let id = model.get("id").and_then(Value::as_str);
6810 if let (Some(p), Some(i)) = (provider, id) {
6811 meta.model = Some(format!("{p}/{i}"));
6812 }
6813 }
6814 if let Some(project_id) = si.get("projectID").and_then(Value::as_str) {
6815 meta.lineage
6816 .insert("projectID".to_string(), project_id.to_string());
6817 }
6818 if let Some(slug) = si.get("slug").and_then(Value::as_str) {
6819 meta.lineage.insert("slug".to_string(), slug.to_string());
6820 }
6821 if let Some(ws) = si.get("workspaceID").and_then(Value::as_str) {
6822 meta.lineage
6823 .insert("workspaceID".to_string(), ws.to_string());
6824 }
6825 if let Some(parent) = si.get("parentID").and_then(Value::as_str) {
6826 meta.lineage
6827 .insert("parent_session_id".to_string(), parent.to_string());
6828 // Mirrored under the Codex-originated lineage key so the existing
6829 // generic `Session::reconstruct_tree` nests opencode subagent
6830 // sessions too, with no format-specific nesting pass (§2.1: "child
6831 // session's parentID ... → drives reconstruct_tree").
6832 meta.lineage
6833 .insert("parent_thread_id".to_string(), parent.to_string());
6834 }
6835 // D7: the other half of `synthesized_opencode_info`'s passthrough —
6836 // restores a captured Claude `fork-context-ref` so a Claude -> OpenCode
6837 // -> Claude round trip reconstructs the original record (mirrors
6838 // `capture_codex_session_meta`/`capture_pi_header`'s identical
6839 // `claude_fork_context_ref` restore for the Codex/Pi hops).
6840 if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
6841 if let Some(v) = si.get("claude_fork_context_ref") {
6842 meta.lineage
6843 .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
6844 }
6845 }
6846 Ok(())
6847}
6848
6849/// An opencode `User`/`Assistant` `file` part's image data-URI →
6850/// `content_parts` `image_url` entry (§2.1). Only `data:` URIs with an
6851/// `image/*` mime are mapped ("T3 clean for images; non-media residue") —
6852/// a bare filesystem path, an `https:` link, or a non-image mime is left as
6853/// raw-only residue (§2.3), never a corrupt/guessed `image_url`. `pub(crate)`
6854/// so [`crate::audit::audit_opencode_line`] can classify a `file` part's
6855/// coverage with the SAME test this loader uses to canonicalize it (D5) —
6856/// one definition of "is this file part actually replayed", not two.
6857pub(crate) fn opencode_file_image_part(part: &Value) -> Option<Value> {
6858 let mime = part.get("mime").and_then(Value::as_str)?;
6859 let url = part.get("url").and_then(Value::as_str)?;
6860 if !mime.starts_with("image/") || !url.starts_with("data:") {
6861 return None;
6862 }
6863 Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
6864}
6865
6866/// B4: the part-`metadata` key `append_synthesized_opencode_messages`'s
6867/// `Role::System` arm stamps on the one `synthetic: true` text part of a
6868/// re-materialized content-bearing Claude `system` record (see that arm's
6869/// doc comment). Namespaced (`supercode_`-prefixed) so it can never collide
6870/// with real opencode/provider part metadata (e.g. `anthropic.signature`).
6871const OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: &str = "supercode_claude_system_subtype";
6872
6873/// Detect `append_synthesized_opencode_messages`'s own marker shape: a
6874/// `User` message with EXACTLY one `synthetic: true` text part carrying
6875/// [`OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY`] in its part-level `metadata`. Real
6876/// opencode data is never misclassified — a genuine opencode `synthetic`
6877/// text part never carries this supercode-namespaced key, and a real
6878/// multi-part user message (text + an attached file, say) never matches
6879/// (`parts.len() != 1` bails). Returns the original Claude `systemSubtype`
6880/// (e.g. `local_command`) on a match.
6881fn opencode_claude_system_subtype(parts: &[Value]) -> Option<String> {
6882 let [part] = parts else { return None };
6883 if part.get("type").and_then(Value::as_str) != Some("text") {
6884 return None;
6885 }
6886 if part.get("synthetic").and_then(Value::as_bool) != Some(true) {
6887 return None;
6888 }
6889 part.get("metadata")
6890 .and_then(|m| m.get(OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY))
6891 .and_then(Value::as_str)
6892 .map(str::to_string)
6893}
6894
6895/// The [`opencode_claude_system_subtype`] match arm: restores `Role::System`
6896/// and `metadata["systemSubtype"]` from the marked text part instead of
6897/// `push_opencode_user`'s generic `Role::User` path, so a Claude ->
6898/// OpenCode -> Claude round trip restores the exact original role, not just
6899/// the text. Content is never fabricated — only emitted when non-empty.
6900fn push_opencode_claude_system(
6901 msg_value: &Value,
6902 parts: &[Value],
6903 subtype: String,
6904 out: &mut Vec<ChatMessage>,
6905) {
6906 let Some(text) = parts
6907 .first()
6908 .and_then(|p| p.get("text"))
6909 .and_then(Value::as_str)
6910 else {
6911 return;
6912 };
6913 if text.trim().is_empty() {
6914 return;
6915 }
6916 let mut msg = ChatMessage::system(text.to_string()).with_meta("systemSubtype", subtype);
6917 set_opencode_msg_timestamp(&mut msg, msg_value);
6918 out.push(msg);
6919}
6920
6921/// Map an opencode `User` message (`msg_value`) + its parts to zero or one
6922/// canonical `Role::User` `ChatMessage` (§2.1). `text` parts concatenate
6923/// (an `ignored` one is never included — §2.2, "must not be re-emitted to
6924/// the model"); `file` parts with a recognized image shape become
6925/// `content_parts`. `System` (opencode's per-turn `User.system`) fills
6926/// `SessionMeta.system_prompt` on the first turn that carries it, and
6927/// `metadata["system"]` on every turn that does (§2.1: "System prompt is
6928/// per-user-message, not per-session").
6929/// Fold an opencode message envelope's `time.created` (unix-ms) into the
6930/// canonical `metadata["timestamp"]` (ISO-8601, WAVE-2 item 1) — the same
6931/// field claude/codex/pi loaders populate. Lossless to millisecond precision
6932/// (opencode's own wire granularity); a `None`/malformed `time.created`
6933/// leaves `metadata["timestamp"]` unset, so the writer falls back to
6934/// `SYNTH_TS`/`SYNTH_TS_MS`.
6935fn set_opencode_msg_timestamp(msg: &mut ChatMessage, msg_value: &Value) {
6936 if let Some(ms) = msg_value
6937 .get("time")
6938 .and_then(|t| t.get("created"))
6939 .and_then(Value::as_i64)
6940 {
6941 msg.metadata
6942 .insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
6943 }
6944}
6945
6946fn push_opencode_user(
6947 msg_value: &Value,
6948 parts: &[Value],
6949 out: &mut Vec<ChatMessage>,
6950 meta: &mut SessionMeta,
6951 first_system_seen: &mut bool,
6952) {
6953 let mut text = String::new();
6954 let mut image_parts: Vec<Value> = Vec::new();
6955 let mut has_ignored = false;
6956 for p in parts {
6957 match p.get("type").and_then(Value::as_str) {
6958 Some("text") => {
6959 if p.get("ignored").and_then(Value::as_bool) == Some(true) {
6960 has_ignored = true;
6961 continue; // must never be replayed (§2.2)
6962 }
6963 if let Some(t) = p.get("text").and_then(Value::as_str) {
6964 push_str_field(&mut text, t);
6965 }
6966 }
6967 Some("file") => {
6968 if let Some(img) = opencode_file_image_part(p) {
6969 image_parts.push(img);
6970 }
6971 }
6972 // reasoning/tool never appear on a User message; step-start,
6973 // step-finish, snapshot, patch, agent, subtask, retry have no
6974 // clean home (§2.3); compaction is read separately by the
6975 // caller (tail_start_id) and tagged onto the message below.
6976 _ => {}
6977 }
6978 }
6979
6980 let has_images = !image_parts.is_empty();
6981 if text.trim().is_empty() && !has_images {
6982 return;
6983 }
6984 let mut msg = if has_images {
6985 let mut all = Vec::new();
6986 if !text.trim().is_empty() {
6987 all.push(serde_json::json!({"type": "text", "text": text.clone()}));
6988 }
6989 all.extend(image_parts);
6990 ChatMessage {
6991 role: Role::User,
6992 content: None,
6993 content_parts: Some(all),
6994 tool_calls: None,
6995 tool_call_id: None,
6996 name: None,
6997 metadata: Default::default(),
6998 }
6999 } else {
7000 ChatMessage::user(text)
7001 };
7002
7003 if has_ignored {
7004 msg.metadata
7005 .insert("oc_has_ignored_part".to_string(), "true".to_string());
7006 }
7007 if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
7008 msg.metadata
7009 .insert("oc_message_id".to_string(), id.to_string());
7010 }
7011 if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
7012 msg.metadata.insert("agent".to_string(), agent.to_string());
7013 }
7014 if let Some(model) = msg_value.get("model") {
7015 if !model.is_null() {
7016 msg.metadata.insert("model".to_string(), model.to_string());
7017 }
7018 }
7019 if let Some(system) = msg_value.get("system").and_then(Value::as_str) {
7020 if !*first_system_seen {
7021 meta.system_prompt = Some(system.to_string());
7022 *first_system_seen = true;
7023 }
7024 msg.metadata
7025 .insert("system".to_string(), system.to_string());
7026 }
7027 for p in parts {
7028 if p.get("type").and_then(Value::as_str) == Some("compaction") {
7029 msg.metadata
7030 .insert("phase".to_string(), "compaction".to_string());
7031 if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
7032 msg.metadata
7033 .insert("tail_start_id".to_string(), t.to_string());
7034 }
7035 }
7036 }
7037 set_opencode_msg_timestamp(&mut msg, msg_value);
7038 restore_grok_message_extension(msg_value, &mut msg);
7039 out.push(msg);
7040}
7041
7042/// Map an opencode `Assistant` message + its parts to a canonical
7043/// `Role::Assistant` `ChatMessage` (text + `tool_calls`), followed
7044/// immediately by one `Role::Tool` `ChatMessage` per `tool` part that
7045/// reached `completed`/`error` — the split-by-`callID` opencode's single
7046/// part→two-messages mapping (§2.1). `pending`/`running` calls (an
7047/// interrupted turn) synthesize no tool call/result of their own here; the
7048/// shared [`ensure_tool_results_paired`] fills that gap uniformly, exactly
7049/// like the other three loaders. A `tool` part whose `state.status` is none
7050/// of the four known values is skipped entirely — raw-only survival, never
7051/// guessed — so [`crate::audit::Corpus::OpenCode`] can flag it.
7052fn push_opencode_assistant(
7053 msg_value: &Value,
7054 parts: &[Value],
7055 out: &mut Vec<ChatMessage>,
7056 meta: &mut SessionMeta,
7057) {
7058 let mut text = String::new();
7059 let mut calls: Vec<ToolCall> = Vec::new();
7060 let mut thinking = String::new();
7061 let mut reasoning_seen = false;
7062 let mut thinking_sig: Option<String> = None;
7063 // (call_id, tool_name, the tool part itself) — deferred so the
7064 // assistant message (carrying `tool_calls`) is pushed FIRST, matching
7065 // every other loader's message ordering (call, then result).
7066 let mut tool_results: Vec<(String, String, Value)> = Vec::new();
7067
7068 for p in parts {
7069 match p.get("type").and_then(Value::as_str) {
7070 Some("text") => {
7071 if p.get("ignored").and_then(Value::as_bool) == Some(true) {
7072 continue;
7073 }
7074 if let Some(t) = p.get("text").and_then(Value::as_str) {
7075 push_str_field(&mut text, t);
7076 }
7077 }
7078 Some("reasoning") => {
7079 reasoning_seen = true;
7080 if let Some(t) = p.get("text").and_then(Value::as_str) {
7081 push_str_field(&mut thinking, t);
7082 }
7083 if let Some(sig) = p
7084 .get("metadata")
7085 .and_then(|m| m.get("anthropic"))
7086 .and_then(|a| a.get("signature"))
7087 .and_then(Value::as_str)
7088 {
7089 thinking_sig = Some(sig.to_string());
7090 }
7091 }
7092 Some("tool") => {
7093 let call_id = p.get("callID").and_then(Value::as_str).unwrap_or_default();
7094 let tool_name = p.get("tool").and_then(Value::as_str).unwrap_or_default();
7095 let status = p
7096 .get("state")
7097 .and_then(|s| s.get("status"))
7098 .and_then(Value::as_str);
7099 let known_status = matches!(
7100 status,
7101 Some("pending") | Some("running") | Some("completed") | Some("error")
7102 );
7103 if call_id.is_empty() || !known_status {
7104 // Unknown/unrecognized status, or a malformed part with
7105 // no callID — raw-only survival, never synthesized.
7106 continue;
7107 }
7108 let input = p
7109 .get("state")
7110 .and_then(|s| s.get("input"))
7111 .cloned()
7112 .unwrap_or_else(|| Value::Object(Default::default()));
7113 calls.push(function_call(call_id, tool_name, input.to_string()));
7114 if matches!(status, Some("completed") | Some("error")) {
7115 tool_results.push((call_id.to_string(), tool_name.to_string(), p.clone()));
7116 }
7117 }
7118 // file/step-start/step-finish/snapshot/patch/agent/subtask/retry
7119 // — no clean home on an Assistant turn (§2.3).
7120 _ => {}
7121 }
7122 }
7123
7124 let before = out.len();
7125 push_assistant(out, text, calls);
7126 // A native OpenCode assistant record is transcript state even when it
7127 // has no parts. Real stores contain these after an interrupted/empty
7128 // model turn; dropping the record here loses its id, timestamp, model,
7129 // token/cost metadata, and shifts the conversation on every export.
7130 // Keep one empty canonical assistant message so all target writers can
7131 // preserve the turn. This also covers reasoning-only records (whose
7132 // reasoning payload is attached as metadata just below).
7133 if out.len() == before {
7134 let mut empty = ChatMessage {
7135 role: Role::Assistant,
7136 content: None,
7137 content_parts: None,
7138 tool_calls: None,
7139 tool_call_id: None,
7140 name: None,
7141 metadata: Default::default(),
7142 };
7143 if !reasoning_seen {
7144 empty
7145 .metadata
7146 .insert("empty_assistant_record".to_string(), "true".to_string());
7147 }
7148 out.push(empty);
7149 }
7150 if out.len() > before {
7151 let msg = out.last_mut().expect("just pushed");
7152 if reasoning_seen {
7153 msg.metadata.insert("thinking".to_string(), thinking);
7154 }
7155 if let Some(sig) = thinking_sig {
7156 msg.metadata.insert("thinking_signature".to_string(), sig);
7157 }
7158 if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
7159 msg.metadata
7160 .insert("oc_message_id".to_string(), id.to_string());
7161 }
7162 if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
7163 msg.metadata.insert("agent".to_string(), agent.to_string());
7164 if meta.agent_id.is_none() {
7165 meta.agent_id = Some(agent.to_string());
7166 }
7167 }
7168 let provider = msg_value.get("providerID").and_then(Value::as_str);
7169 let model_id = msg_value.get("modelID").and_then(Value::as_str);
7170 if let (Some(p), Some(i)) = (provider, model_id) {
7171 let full = format!("{p}/{i}");
7172 msg.metadata.insert("model".to_string(), full.clone());
7173 if meta.model.is_none() {
7174 meta.model = Some(full);
7175 }
7176 }
7177 if let Some(cwd) = msg_value
7178 .get("path")
7179 .and_then(|p| p.get("cwd"))
7180 .and_then(Value::as_str)
7181 {
7182 if meta.cwd.is_none() {
7183 meta.cwd = Some(PathBuf::from(cwd));
7184 }
7185 }
7186 if msg_value.get("summary").and_then(Value::as_bool) == Some(true) {
7187 msg.metadata
7188 .insert("is_summary".to_string(), "true".to_string());
7189 }
7190 for (key, field) in [
7191 ("finish", "finish"),
7192 ("variant", "variant"),
7193 ("mode", "mode"),
7194 ] {
7195 if let Some(s) = msg_value.get(field).and_then(Value::as_str) {
7196 msg.metadata.insert(key.to_string(), s.to_string());
7197 }
7198 }
7199 for (key, field) in [
7200 ("cost", "cost"),
7201 ("tokens", "tokens"),
7202 ("error", "error"),
7203 ("structured", "structured"),
7204 ] {
7205 if let Some(v) = msg_value.get(field) {
7206 if !v.is_null() {
7207 msg.metadata.insert(key.to_string(), v.to_string());
7208 }
7209 }
7210 }
7211 // Subagent linkage (§2.1): a `task` tool's own `metadata` carries
7212 // the spawned child session id — keyed by callID so multiple `task`
7213 // calls in one message never collide.
7214 // `resolve_opencode_parent_tool_use_ids` reads these back once a
7215 // whole session set is loaded.
7216 for p in parts {
7217 if p.get("type").and_then(Value::as_str) == Some("tool")
7218 && p.get("tool").and_then(Value::as_str) == Some("task")
7219 {
7220 if let (Some(call_id), Some(child)) = (
7221 p.get("callID").and_then(Value::as_str),
7222 p.get("metadata")
7223 .and_then(|m| m.get("sessionId"))
7224 .and_then(Value::as_str),
7225 ) {
7226 msg.metadata.insert(
7227 format!("oc_task_child_session_id__{call_id}"),
7228 child.to_string(),
7229 );
7230 }
7231 }
7232 }
7233 set_opencode_msg_timestamp(msg, msg_value);
7234 restore_grok_message_extension(msg_value, msg);
7235 }
7236
7237 // Second pass: the paired Tool-role message for each completed/error
7238 // tool part, split by callID (§2.1 — "the SAME part carries call and
7239 // result").
7240 for (call_id, tool_name, part) in tool_results {
7241 let status = part
7242 .get("state")
7243 .and_then(|s| s.get("status"))
7244 .and_then(Value::as_str);
7245 let compacted_at = part
7246 .get("state")
7247 .and_then(|s| s.get("time"))
7248 .and_then(|t| t.get("compacted"))
7249 .and_then(Value::as_i64);
7250 let real_output = part
7251 .get("state")
7252 .and_then(|s| s.get("output"))
7253 .and_then(Value::as_str)
7254 .unwrap_or("")
7255 .to_string();
7256 let (content, is_error) = match status {
7257 Some("completed") => {
7258 if compacted_at.is_some() {
7259 (OPENCODE_COMPACTED_TOOL_PLACEHOLDER.to_string(), false)
7260 } else {
7261 (real_output.clone(), false)
7262 }
7263 }
7264 Some("error") => {
7265 let err = part
7266 .get("state")
7267 .and_then(|s| s.get("error"))
7268 .and_then(Value::as_str)
7269 .unwrap_or("")
7270 .to_string();
7271 (err, true)
7272 }
7273 _ => (String::new(), false),
7274 };
7275 let mut tmsg = ChatMessage {
7276 role: Role::Tool,
7277 content: Some(content),
7278 content_parts: None,
7279 tool_calls: None,
7280 tool_call_id: Some(call_id),
7281 name: Some(tool_name),
7282 metadata: Default::default(),
7283 };
7284 if let Some(original_position) = part
7285 .get(OPENCODE_SUPERCODE_RESULT_POSITION)
7286 .and_then(Value::as_u64)
7287 {
7288 tmsg.metadata.insert(
7289 OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
7290 original_position.to_string(),
7291 );
7292 }
7293 if is_error {
7294 crate::reduce::mark_tool_error(&mut tmsg);
7295 }
7296 restore_tool_outcome_extension(&part, &mut tmsg);
7297 if let Some(ts) = compacted_at {
7298 // S1: the real output is preserved — reversible, never erased.
7299 tmsg.metadata
7300 .insert("oc_tool_output_compacted".to_string(), real_output);
7301 tmsg.metadata
7302 .insert("oc_tool_time_compacted".to_string(), ts.to_string());
7303 }
7304 if status == Some("completed") {
7305 if let Some(atts) = part
7306 .get("state")
7307 .and_then(|s| s.get("attachments"))
7308 .and_then(Value::as_array)
7309 {
7310 let images: Vec<Value> = atts.iter().filter_map(opencode_file_image_part).collect();
7311 if !images.is_empty() {
7312 // D-mix consistency fix (Fable-recommended, same
7313 // pattern as `push_claude_user`'s tool_result arm above):
7314 // a completed opencode tool part with BOTH `state.output`
7315 // text and `state.attachments` images is the same
7316 // non-self-contained hybrid shape — `content_parts` here
7317 // used to hold images only, so opencode -> pi silently
7318 // dropped the output text (`pi_content_value` reads
7319 // `content_parts` exclusively for `Role::Tool`). Prepend
7320 // the text as part 0 so `content_parts` is
7321 // self-contained; `tmsg.content` keeps the text too,
7322 // unchanged, for writers that read it from there and
7323 // only scan `content_parts` for `image_url` entries.
7324 let mut parts = Vec::new();
7325 if let Some(t) = &tmsg.content {
7326 if !t.is_empty() {
7327 parts.push(serde_json::json!({"type": "text", "text": t}));
7328 }
7329 }
7330 parts.extend(images);
7331 tmsg.content_parts = Some(parts);
7332 }
7333 }
7334 }
7335 if let Some(id) = part.get("id").and_then(Value::as_str) {
7336 tmsg.metadata
7337 .insert("oc_part_id".to_string(), id.to_string());
7338 }
7339 // WAVE-2 item 1: a tool part's own `state.time.{end,start}` (unix-ms,
7340 // real opencode wire shape — `pi-fields.md`/`opencode-pi-spec.md`
7341 // cite `state.time.compacted`, but the SAME object also carries
7342 // `start`/`end` on every completed/error call) is this Tool
7343 // message's real source timestamp; prefer `end` (completion, closer
7344 // to when the RESULT — this message's content — was produced) and
7345 // fall back to `start` when only that is present.
7346 let tool_ts = part
7347 .get("state")
7348 .and_then(|s| s.get("time"))
7349 .and_then(|t| t.get("end").or_else(|| t.get("start")))
7350 .and_then(Value::as_i64);
7351 if let Some(ms) = tool_ts {
7352 tmsg.metadata
7353 .insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
7354 }
7355 out.push(tmsg);
7356 }
7357}
7358
7359// ---- shared helpers -------------------------------------------------------
7360
7361fn push_text(buf: &mut String, v: Option<&Value>) {
7362 if let Some(Value::String(s)) = v {
7363 if !buf.is_empty() {
7364 buf.push('\n');
7365 }
7366 buf.push_str(s);
7367 }
7368}
7369
7370/// Extract a Claude `tool_result` block's content, preserving non-text items
7371/// instead of silently dropping them:
7372///
7373/// - text blocks are concatenated;
7374/// - PARITY-11 (nested images, real-session-confirmed — the everyday "Read a
7375/// PNG / screenshot tool output" shape): `image` blocks are captured into
7376/// the returned `content_parts`-shaped `Vec<Value>` via
7377/// [`claude_image_block_to_part`] — the SAME base64/url conversion the
7378/// top-level `image` content-block path (`push_claude_user`) already uses
7379/// — instead of being flattened to the bare `[image]` marker text that used
7380/// to make the data unrecoverable from every writer. An unconvertible
7381/// source (D5 discipline — a Files-API `{"type":"file",...}` reference,
7382/// etc.) folds [`UNCONVERTIBLE_IMAGE_MARKER`] into the text instead of
7383/// vanishing, exactly like the top-level path;
7384/// - `tool_reference` blocks become `[tool_reference: <name>]`;
7385///
7386/// and if the block yields no text/images at all, fall back to the record's
7387/// `toolUseResult` field (string used directly, structured value serialized),
7388/// which is where Claude Code stores the actual result in many cases.
7389///
7390/// Returns `(text, images)`; callers that only need the old text-only
7391/// behavior can ignore the second element — every caller MUST fold non-empty
7392/// `images` into the resulting `ChatMessage.content_parts` themselves (this
7393/// function has no `ChatMessage` to attach to).
7394fn extract_tool_result_content(
7395 content: Option<&Value>,
7396 tool_use_result: Option<&Value>,
7397) -> (String, Vec<Value>) {
7398 let mut parts: Vec<String> = Vec::new();
7399 let mut images: Vec<Value> = Vec::new();
7400 match content {
7401 Some(Value::String(s)) => {
7402 if !s.is_empty() {
7403 parts.push(s.clone());
7404 }
7405 }
7406 Some(Value::Array(items)) => {
7407 for item in items {
7408 match item.get("type").and_then(Value::as_str) {
7409 Some("text") => {
7410 if let Some(t) = item.get("text").and_then(Value::as_str) {
7411 parts.push(t.to_string());
7412 }
7413 }
7414 Some("image") => match claude_image_block_to_part(item) {
7415 Some(part) => images.push(part),
7416 None => parts.push(UNCONVERTIBLE_IMAGE_MARKER.to_string()),
7417 },
7418 Some("tool_reference") => {
7419 let name = item.get("tool_name").and_then(Value::as_str).unwrap_or("?");
7420 parts.push(format!("[tool_reference: {name}]"));
7421 }
7422 _ => {
7423 if let Some(s) = item.as_str() {
7424 parts.push(s.to_string());
7425 }
7426 }
7427 }
7428 }
7429 }
7430 Some(other) => parts.push(other.to_string()),
7431 None => {}
7432 }
7433
7434 let joined = parts.join("\n");
7435 if !joined.trim().is_empty() || !images.is_empty() {
7436 return (joined, images);
7437 }
7438 // Empty tool_result content — recover from toolUseResult.
7439 match tool_use_result {
7440 Some(Value::String(s)) => (s.clone(), images),
7441 Some(v) => (v.to_string(), images),
7442 None => (joined, images),
7443 }
7444}
7445
7446/// Pull readable text out of a content value that may be a plain string or an
7447/// array of `{ "text": "..." }`-bearing blocks (any block type).
7448fn extract_text_content(v: Option<&Value>) -> String {
7449 match v {
7450 Some(Value::String(s)) => s.clone(),
7451 Some(Value::Array(items)) => {
7452 let mut parts = Vec::new();
7453 for item in items {
7454 if let Some(t) = item.get("text").and_then(Value::as_str) {
7455 parts.push(t.to_string());
7456 } else if let Some(s) = item.as_str() {
7457 parts.push(s.to_string());
7458 }
7459 }
7460 parts.join("\n")
7461 }
7462 Some(other) => other.to_string(),
7463 None => String::new(),
7464 }
7465}
7466
7467/// Extract Codex `input_image` content blocks from a `message` response_item's
7468/// `content` value into `content_parts` `image_url` entries — the inverse of
7469/// [`codex_message_content_blocks`]'s `input_image` emission (IX-5). Only a
7470/// block whose `image_url` is a non-empty string is recognized; anything else
7471/// (a missing/malformed `image_url`, a `file_id`-only reference) is left as
7472/// raw-only residue rather than synthesizing a corrupt/empty part — mirroring
7473/// the pi/opencode/Claude loaders' image-shape discipline.
7474fn codex_extract_images(content: Option<&Value>) -> Vec<Value> {
7475 let Some(Value::Array(items)) = content else {
7476 return Vec::new();
7477 };
7478 items
7479 .iter()
7480 .filter(|item| item.get("type").and_then(Value::as_str) == Some("input_image"))
7481 .filter_map(|item| {
7482 let url = item.get("image_url").and_then(Value::as_str)?;
7483 if url.is_empty() {
7484 return None;
7485 }
7486 Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
7487 })
7488 .collect()
7489}
7490
7491fn value_to_arg_string(v: &Value) -> String {
7492 match v {
7493 Value::String(s) => s.clone(),
7494 other => other.to_string(),
7495 }
7496}
7497
7498fn function_call(id: &str, name: &str, arguments: String) -> ToolCall {
7499 ToolCall {
7500 id: id.to_string(),
7501 kind: "function".to_string(),
7502 function: FunctionCall {
7503 name: name.to_string(),
7504 arguments,
7505 },
7506 }
7507}
7508
7509fn tool_message(tool_call_id: &str, content: String) -> ChatMessage {
7510 ChatMessage {
7511 role: Role::Tool,
7512 content: Some(content),
7513 content_parts: None,
7514 tool_calls: None,
7515 tool_call_id: Some(tool_call_id.to_string()),
7516 name: None,
7517 metadata: Default::default(),
7518 }
7519}
7520
7521/// Emit a single assistant message combining accumulated text and tool calls.
7522/// A turn with neither (e.g. thinking-only) produces nothing.
7523fn push_assistant(out: &mut Vec<ChatMessage>, text: String, calls: Vec<ToolCall>) {
7524 let has_text = !text.trim().is_empty();
7525 if !has_text && calls.is_empty() {
7526 return;
7527 }
7528 out.push(ChatMessage {
7529 role: Role::Assistant,
7530 content: has_text.then_some(text),
7531 content_parts: None,
7532 tool_calls: (!calls.is_empty()).then_some(calls),
7533 tool_call_id: None,
7534 name: None,
7535 metadata: Default::default(),
7536 });
7537}
7538
7539// ---- writers --------------------------------------------------------------
7540
7541/// A placeholder timestamp for SYNTHESIZED records only — WAVE-2 item 1's
7542/// fallback (`docs/interop` build brief): every writer now emits a message's
7543/// REAL source timestamp (`metadata["timestamp"]`, the canonical ISO-8601
7544/// field every loader populates) when one is present. `SYNTH_TS` fires only
7545/// for a message with no source timestamp at all — a turn synthesized/
7546/// appended after import (the live agent loop, a splice's appended tail,
7547/// ...), which was never loaded from a real per-message timestamp to begin
7548/// with. Both tools tolerate identical timestamps; callers that need real
7549/// ones for a synthesized turn can post-process.
7550const SYNTH_TS: &str = "2026-01-01T00:00:00.000Z";
7551
7552/// The unix-millisecond twin of [`SYNTH_TS`], for OpenCode's numeric
7553/// `time.created`/`time.updated` fields.
7554const SYNTH_TS_MS: i64 = 1_767_225_600_000;
7555
7556/// `msg`'s real source timestamp (`metadata["timestamp"]`, canonical
7557/// ISO-8601 — WAVE-2 item 1) when present AND well-formed, else the
7558/// [`SYNTH_TS`] fallback. Validated via [`crate::sidecar::rfc3339_to_ms`] (a
7559/// parse, not just a presence check) so an absent, empty, or malformed
7560/// source value all degrade to the same documented fallback rather than
7561/// propagating garbage verbatim. Used by every writer that emits an
7562/// ISO-8601 timestamp field
7563/// (Claude Code, Codex, pi's entry-level `timestamp`).
7564fn msg_timestamp_or_synth(msg: &ChatMessage) -> &str {
7565 match msg.metadata.get("timestamp") {
7566 Some(ts) if crate::sidecar::rfc3339_to_ms(ts).is_some() => ts.as_str(),
7567 _ => SYNTH_TS,
7568 }
7569}
7570
7571/// OpenCode reloads an export document by sorting messages on
7572/// `time.created`, so a timestamp-less appended continuation cannot reuse
7573/// the fixed historical [`SYNTH_TS_MS`] fallback when its imported prefix is
7574/// newer. Advance a deterministic cursor for synthesized clocks while still
7575/// preserving every real source timestamp verbatim.
7576fn opencode_message_timestamp(msg: &ChatMessage, cursor: &mut i64) -> Result<i64> {
7577 if let Some(real) = msg
7578 .metadata
7579 .get("timestamp")
7580 .and_then(|s| crate::sidecar::rfc3339_to_ms(s))
7581 {
7582 // A NativeTurn timestamp is durable provenance minted by supercode,
7583 // not an OpenCode source clock that must be replayed verbatim.
7584 // Multiple turns may be recorded in the same millisecond, while
7585 // OpenCode sorts solely by `time.created`; allocate such turns after
7586 // the existing cursor so their persisted order cannot collapse. This
7587 // also preserves the fail-closed i64::MAX exhaustion behavior.
7588 if msg.metadata.contains_key("supercode_native_uuid") && real <= *cursor {
7589 *cursor = cursor.checked_add(1).ok_or_else(|| {
7590 crate::Error::Other(
7591 "cannot synthesize an OpenCode continuation timestamp after i64::MAX"
7592 .to_string(),
7593 )
7594 })?;
7595 return Ok(*cursor);
7596 }
7597 *cursor = (*cursor).max(real);
7598 return Ok(real);
7599 }
7600 let next = cursor.checked_add(1).ok_or_else(|| {
7601 crate::Error::Other(
7602 "cannot synthesize an OpenCode continuation timestamp after i64::MAX".to_string(),
7603 )
7604 })?;
7605 *cursor = next.max(SYNTH_TS_MS);
7606 Ok(*cursor)
7607}
7608
7609/// Largest integer nested under any OpenCode `time` object. Imported
7610/// prefixes carry more clocks than `message.time.created` (assistant
7611/// completion, tool start/end, session updated); a synthesized continuation
7612/// must follow all of them, not merely sort after message creation times.
7613fn opencode_max_timestamp(value: &Value) -> Option<i64> {
7614 fn max_number(value: &Value) -> Option<i64> {
7615 match value {
7616 Value::Number(n) => n.as_i64(),
7617 Value::Array(values) => values.iter().filter_map(max_number).max(),
7618 Value::Object(fields) => fields.values().filter_map(max_number).max(),
7619 _ => None,
7620 }
7621 }
7622
7623 match value {
7624 Value::Array(values) => values.iter().filter_map(opencode_max_timestamp).max(),
7625 Value::Object(fields) => fields
7626 .iter()
7627 .filter_map(|(key, value)| {
7628 if key == "time" {
7629 max_number(value)
7630 } else {
7631 opencode_max_timestamp(value)
7632 }
7633 })
7634 .max(),
7635 _ => None,
7636 }
7637}
7638
7639/// pi's own message-level unix-ms clock (`metadata["pi_msg_timestamp"]`,
7640/// restored by the pi loader's `push_pi_*` helpers) — DISTINCT from the
7641/// canonical entry-level ISO `metadata["timestamp"]` [`msg_timestamp_or_synth`]
7642/// reads. The two carry genuinely different values in real pi corpora (a
7643/// message-level clock reading vs. the entry's own wall-clock stamp), so this
7644/// is intentionally its own accessor. Used ONLY by [`Session::write_pi_entries`]'s
7645/// nested `message.timestamp` field, so a pi -> pi native round-trip
7646/// preserves the source message-level clock value-exact instead of deriving
7647/// it from the (distinct) entry-level timestamp. Falls back to
7648/// [`SYNTH_TS_MS`] for a message that never carried a pi message-level clock
7649/// reading (non-pi-sourced, or a synthesized/appended turn).
7650fn msg_pi_native_timestamp_ms(msg: &ChatMessage) -> i64 {
7651 msg.metadata
7652 .get("pi_msg_timestamp")
7653 .and_then(|s| s.parse::<i64>().ok())
7654 .unwrap_or(SYNTH_TS_MS)
7655}
7656
7657/// A format-valid (v4-shaped) but deterministic uuid, derived from a counter.
7658fn synth_uuid(n: usize) -> String {
7659 format!("00000000-0000-4000-8000-{n:012x}")
7660}
7661
7662/// R1 (Skeptic B, spliced-export uuid-collision hardening — the same bug
7663/// class N2 closed for the Codex spliced path's group ids, see
7664/// `collect_codex_group_ids_from_raw`/`write_codex_records`): mint the next
7665/// `synth_uuid`, skipping any value already present in `seed_used_ids` — the
7666/// uuids [`collect_claude_uuids_from_raw`] found already sitting in the
7667/// verbatim raw prefix [`Session::to_claude_code_jsonl_spliced`] replays
7668/// ahead of the tail this counter mints. Without this, re-splicing a
7669/// previously-exported-then-reimported session (export -> reimport -> append
7670/// -> export again) restarts `counter` at 1 with no memory of the prior
7671/// export's tail uuids now sitting in the prefix, so the second tail
7672/// fabricates the SAME `00000000-0000-4000-8000-…` values the first one did
7673/// — a uuid collision across prefix and tail that can mis-link any
7674/// uuid-keyed consumer (fork/tree lineage, `parentUuid`). `counter` keeps
7675/// climbing monotonically even across skips. `used_ids` is also updated for
7676/// each minted or metadata-backed identity, so collisions are prevented both
7677/// against the replayed prefix and within the appended tail.
7678fn next_claude_uuid(counter: &mut usize, used_ids: &mut HashSet<String>) -> String {
7679 loop {
7680 let candidate = synth_uuid(*counter);
7681 *counter += 1;
7682 if used_ids.insert(candidate.clone()) {
7683 return candidate;
7684 }
7685 }
7686}
7687
7688/// Reuse a message's durable native/source UUID when available, falling back
7689/// to the deterministic synthesized sequence only for hand-built or legacy
7690/// messages that never carried identity metadata.
7691fn claude_message_uuid(
7692 msg: &ChatMessage,
7693 counter: &mut usize,
7694 used_ids: &mut HashSet<String>,
7695) -> String {
7696 for key in ["claude_uuid", "supercode_native_uuid"] {
7697 if let Some(candidate) = msg.metadata.get(key) {
7698 if !candidate.is_empty() && used_ids.insert(candidate.clone()) {
7699 return candidate.clone();
7700 }
7701 }
7702 }
7703 next_claude_uuid(counter, used_ids)
7704}
7705
7706/// Companion to [`next_claude_uuid`]: every `uuid` already present in
7707/// `raw_prefix` — the verbatim RAW lines
7708/// [`Session::to_claude_code_jsonl_spliced`] replays ahead of the appended
7709/// tail it synthesizes via [`Session::write_claude_code_records`]. This is
7710/// the GROUND TRUTH of what physically lands in the exported `out` string
7711/// for the prefix (mirrors `collect_codex_group_ids_from_raw`'s approach on
7712/// the Codex side): each line is parsed as a Claude Code JSONL record and
7713/// its own top-level `uuid` field is read back out of the bytes directly, no
7714/// re-derivation from `self.messages` needed. A line that fails to parse, or
7715/// parses but carries no `uuid` (e.g. a trailing `file-history-snapshot`
7716/// record), contributes nothing.
7717fn collect_claude_uuids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
7718 let mut ids = HashSet::new();
7719 for line in raw_prefix {
7720 if let Ok(v) = serde_json::from_str::<Value>(line) {
7721 if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
7722 ids.insert(uuid.to_string());
7723 }
7724 }
7725 }
7726 ids
7727}
7728
7729fn push_jsonl(out: &mut String, value: &Value) {
7730 out.push_str(&value.to_string());
7731 out.push('\n');
7732}
7733
7734/// Replay a raw (verbatim) JSONL `line` into `out`, rewriting `key` to
7735/// `new_id` when the line parses as a JSON object carrying that key — used
7736/// by A12's Claude Code splice, where the session id lives at the top level
7737/// of (almost) every record under `key = "sessionId"`. A line that fails to
7738/// parse, or parses but lacks `key`, is copied through byte-for-byte
7739/// (nothing to patch, so nothing is reserialized).
7740fn push_spliced_line(out: &mut String, line: &str, new_id: Option<&str>, key: &str) {
7741 if let Some(new_id) = new_id {
7742 if let Ok(mut v) = serde_json::from_str::<Value>(line) {
7743 if v.get(key).is_some() {
7744 v[key] = Value::String(new_id.to_string());
7745 out.push_str(&v.to_string());
7746 out.push('\n');
7747 return;
7748 }
7749 }
7750 }
7751 out.push_str(line);
7752 out.push('\n');
7753}
7754
7755impl Session {
7756 fn cwd_string(&self) -> String {
7757 self.meta
7758 .cwd
7759 .as_ref()
7760 .map(|p| p.to_string_lossy().into_owned())
7761 .unwrap_or_else(|| ".".to_string())
7762 }
7763
7764 /// `(raw_prefix_len, message_prefix_len)` for A12 splicing: how many
7765 /// leading `raw` lines / `messages` came from the imported log, as
7766 /// opposed to being appended after import.
7767 ///
7768 /// `imported_message_count` (see its doc comment) pins the message-side
7769 /// boundary directly. The raw-side boundary isn't separately tracked —
7770 /// [`Self::from_native_str`]'s turn-appending loop pushes exactly one
7771 /// `raw` line per appended message, so the two lists grow by the same
7772 /// `appended_count` from the same starting point, and
7773 /// `raw.len() - appended_count` recovers it without a second counter.
7774 fn spliced_prefix_lens(&self) -> (usize, usize) {
7775 let message_prefix_len = self
7776 .imported_message_count
7777 .unwrap_or(self.messages.len())
7778 .min(self.messages.len());
7779 let appended_count = self.messages.len() - message_prefix_len;
7780 let raw_prefix_len = self.raw.len().saturating_sub(appended_count);
7781 (raw_prefix_len, message_prefix_len)
7782 }
7783
7784 /// Synthesize a Claude Code transcript.
7785 ///
7786 /// Claude Code transcripts have no slot for the *session-level system
7787 /// prompt* (that lives in the CLI, not the log) — but PARITY-6 dev/02
7788 /// (Fable-5 corpus audit) surfaced that content-bearing `System`
7789 /// `ChatMessage`s (Claude's own `type: "system"` records with a
7790 /// content-bearing `subtype` like `local_command`/`scheduled_task_fire`/
7791 /// `away_summary` — see `push_claude_system`, the exact inverse of what
7792 /// this writer now does) DO have a first-class slot: the real `type:
7793 /// "system"` record itself. This function used to unconditionally drop
7794 /// every `System` message, silently losing e.g. a real
7795 /// `<local-command-stdout>` record on any format -> Claude Code hop
7796 /// (confirmed on a real 2,982-message corpus session: Codex -> Claude
7797 /// Code dropped its one surviving `system` message, 2215 -> 2214, with
7798 /// no loss manifest). `write_claude_code_records`'s `Role::System` arm
7799 /// now re-materializes it instead.
7800 fn to_claude_code_jsonl(&self) -> String {
7801 let session_id = self
7802 .meta
7803 .session_id
7804 .clone()
7805 .unwrap_or_else(|| synth_uuid(0));
7806 let cwd = self.cwd_string();
7807 let mut out = String::new();
7808 // PARITY-10: a captured `fork-context-ref` (see `capture_claude_meta`)
7809 // re-emitted byte-for-byte, ahead of the conversation it applies to —
7810 // this is what makes the record survive the SEMANTIC Claude Code
7811 // writer (the raw-passthrough diagonal in `crates/cli` already
7812 // preserves it by construction; this covers the library `to_jsonl`
7813 // path too, e.g. a `--session-id` override that forces the semantic
7814 // writer).
7815 if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
7816 out.push_str(raw);
7817 out.push('\n');
7818 }
7819 // Full synthesis: `out` at this point has no raw prefix ahead of it
7820 // (unlike the A12 splice below), so there are no uuids yet in play
7821 // to seed against — see `next_claude_uuid`'s doc comment.
7822 self.write_claude_code_records(
7823 &mut out,
7824 &self.messages,
7825 &session_id,
7826 &cwd,
7827 None,
7828 1,
7829 &HashSet::new(),
7830 );
7831 if let Some(extension) = codex_provenance_envelope(&self.meta) {
7832 if out.is_empty() {
7833 push_jsonl(
7834 &mut out,
7835 &serde_json::json!({
7836 "type": "file-history-snapshot",
7837 "messageId": synth_uuid(1),
7838 "snapshot": {},
7839 "sessionId": session_id,
7840 "cwd": cwd,
7841 "timestamp": SYNTH_TS,
7842 }),
7843 );
7844 }
7845 inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
7846 }
7847 out
7848 }
7849
7850 /// Synthesize Claude Code records for `messages` (a full session or an
7851 /// appended tail — A12's [`Self::to_jsonl_spliced`] reuses this for just
7852 /// the latter), starting the `parentUuid` chain at `parent` and the
7853 /// `synth_uuid` counter at `counter`. Factored out of
7854 /// [`Self::to_claude_code_jsonl`] so the record shape is defined once.
7855 ///
7856 /// `seed_used_ids` primes [`next_claude_uuid`]'s collision guard with
7857 /// every uuid that will ALREADY be present in `out` before this call
7858 /// ever runs — see that function's doc comment for why the A12 splice
7859 /// path needs this and full synthesis doesn't.
7860 // R1: this was already at clippy's `too_many_arguments` threshold (7,
7861 // including `&self`) before the fix; the added `seed_used_ids` param
7862 // pushes it to 8. Every argument here is independently meaningful (two
7863 // record-shape inputs, two id/parent-chain threading values, and now
7864 // the collision seed) — bundling them into a params struct is a larger
7865 // refactor of this already-widely-called private helper than the R1 fix
7866 // warrants, so this is allowed rather than restructured.
7867 #[allow(clippy::too_many_arguments)]
7868 fn write_claude_code_records(
7869 &self,
7870 out: &mut String,
7871 messages: &[ChatMessage],
7872 session_id: &str,
7873 cwd: &str,
7874 mut parent: Option<String>,
7875 mut counter: usize,
7876 seed_used_ids: &HashSet<String>,
7877 ) {
7878 let mut used_ids = seed_used_ids.clone();
7879 for msg in messages {
7880 if is_replay_excluded(msg) {
7881 continue;
7882 }
7883 let blocks: Vec<Value> = match msg.role {
7884 // PARITY-6 dev/02: re-materialize a content-bearing System
7885 // `ChatMessage` as a real Claude Code `type: "system"`
7886 // record — the exact inverse of `push_claude_system`, which
7887 // is what produced it in the first place for a message
7888 // loaded FROM a real Claude Code transcript. `subtype`
7889 // prefers the original `systemSubtype` metadata
7890 // (`push_claude_system`'s `.with_meta`, round-tripped
7891 // through the Codex hop via `write_codex_records`'s
7892 // `claude_system_subtype` metadata channel and restored by
7893 // `push_codex_item`); when that channel didn't carry it
7894 // (e.g. a genuinely native, non-Claude-origin developer
7895 // message), fall back to `local_command` — the observed
7896 // common case, and still one of `push_claude_system`'s own
7897 // `keep` subtypes, so the record survives a *subsequent*
7898 // reload rather than being silently re-dropped. This never
7899 // fabricates content: the real text is always carried
7900 // verbatim, only the subtype label is a best-effort guess
7901 // when the true one wasn't recoverable.
7902 Role::System => {
7903 let content = msg.content.clone().unwrap_or_default();
7904 if content.trim().is_empty() {
7905 continue;
7906 }
7907 let subtype = msg
7908 .metadata
7909 .get("systemSubtype")
7910 .cloned()
7911 .unwrap_or_else(|| "local_command".to_string());
7912 // R1/B3 union: this mint must ALSO route through
7913 // `next_claude_uuid` + `seed_used_ids` like the other
7914 // three arms below — otherwise this System arm (added by
7915 // B3 after R1 landed) mints a raw `synth_uuid` that can
7916 // collide with a uuid already sitting in the A12 splice's
7917 // raw prefix (see `next_claude_uuid`'s doc comment).
7918 let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
7919 let mut line = serde_json::json!({
7920 "parentUuid": parent,
7921 "type": "system",
7922 "subtype": subtype,
7923 "content": content,
7924 "uuid": uuid,
7925 "sessionId": session_id,
7926 "cwd": cwd,
7927 "timestamp": msg_timestamp_or_synth(msg),
7928 });
7929 set_grok_message_extension(&mut line, self.meta.source, msg);
7930 push_jsonl(out, &line);
7931 parent = Some(uuid);
7932 continue;
7933 }
7934 Role::User => {
7935 let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
7936 let mut line = serde_json::json!({
7937 "parentUuid": parent,
7938 "type": "user",
7939 "message": {
7940 "role": "user",
7941 "content": claude_user_content_value(msg),
7942 },
7943 "uuid": uuid,
7944 "sessionId": session_id,
7945 "cwd": cwd,
7946 "timestamp": msg_timestamp_or_synth(msg),
7947 });
7948 set_grok_message_extension(&mut line, self.meta.source, msg);
7949 push_jsonl(out, &line);
7950 parent = Some(uuid);
7951 continue;
7952 }
7953 Role::Tool => {
7954 let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
7955 let mut line = serde_json::json!({
7956 "parentUuid": parent,
7957 "type": "user",
7958 "message": {
7959 "role": "user",
7960 "content": [{
7961 "type": "tool_result",
7962 "tool_use_id": msg.tool_call_id.clone().unwrap_or_default(),
7963 "content": claude_tool_result_content_value(msg),
7964 }],
7965 },
7966 "uuid": uuid,
7967 "sessionId": session_id,
7968 "cwd": cwd,
7969 "timestamp": msg_timestamp_or_synth(msg),
7970 });
7971 if crate::reduce::tool_outcome(msg) == crate::reduce::ToolOutcome::Unknown {
7972 line[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
7973 }
7974 set_grok_message_extension(&mut line, self.meta.source, msg);
7975 push_jsonl(out, &line);
7976 parent = Some(uuid);
7977 continue;
7978 }
7979 Role::Assistant => {
7980 let mut blocks = Vec::new();
7981 // PARITY-16 (found via the REAL pi corpus, PARITY-5
7982 // dev/01): thinking/redacted_thinking must be re-emitted
7983 // BEFORE text/tool_use, unconditionally whenever
7984 // retained metadata is present — not only when `blocks`
7985 // is otherwise empty. The previous `if blocks.is_empty()`
7986 // gate (now below, applied unconditionally instead)
7987 // meant a turn that thinks AND THEN answers/calls a tool
7988 // in the SAME turn — pi's own default emission shape,
7989 // and the overwhelmingly common real-world case for any
7990 // reasoning model, not the rare reasoning-only edge case
7991 // this gate's comment described — silently dropped its
7992 // entire `thinking` block on Pi -> Claude Code export. A
7993 // genuine multi-turn pi session driven through pi's own
7994 // real Agent loop (faux provider, see
7995 // `pi_interop.rs`'s live-corpus tests) exposed this: its
7996 // thinking+text turns lost the thinking block entirely.
7997 // D8: prefer the exact per-block list when present —
7998 // every `thinking`/`redacted_thinking` block re-emitted
7999 // SEPARATELY with its own signature/data, exactly as
8000 // captured (`push_claude_assistant`), instead of the
8001 // legacy singular fields' lossy collapse (which drops
8002 // every signature but the last one's on a multi-block
8003 // message). Falls back to the legacy fields only for a
8004 // `Session` that never populated `thinking_blocks` (e.g.
8005 // hand-constructed in another loader/test, or loaded
8006 // from a non-Claude-Code source like Pi).
8007 match msg
8008 .metadata
8009 .get("thinking_blocks")
8010 .and_then(|s| serde_json::from_str::<Value>(s).ok())
8011 .and_then(|v| v.as_array().cloned())
8012 {
8013 Some(saved_blocks) => blocks.extend(saved_blocks),
8014 None => {
8015 if let Some(t) = msg.metadata.get("thinking") {
8016 let mut block =
8017 serde_json::json!({"type": "thinking", "thinking": t});
8018 if let Some(sig) = msg.metadata.get("thinking_signature") {
8019 block["signature"] = Value::String(sig.clone());
8020 }
8021 blocks.push(block);
8022 }
8023 if let Some(rt) = msg.metadata.get("redacted_thinking") {
8024 blocks.push(
8025 serde_json::json!({"type": "redacted_thinking", "data": rt}),
8026 );
8027 }
8028 }
8029 }
8030 if let Some(t) = &msg.content {
8031 if !t.is_empty() {
8032 blocks.push(serde_json::json!({"type": "text", "text": t}));
8033 }
8034 }
8035 // PARITY-11: an assistant-emitted image (`content_parts`,
8036 // e.g. a generated image — `push_claude_assistant`'s
8037 // load-side counterpart) has no slot in `msg.content`;
8038 // without this, `blocks` stayed empty for an image-only
8039 // turn and the whole message vanished on Claude Code
8040 // semantic export, same failure mode the IX-6 Codex
8041 // writer fix already closed on that side.
8042 if let Some(parts) = &msg.content_parts {
8043 for p in parts {
8044 if p.get("type").and_then(Value::as_str) == Some("image_url") {
8045 if let Some(url) = p
8046 .get("image_url")
8047 .and_then(|u| u.get("url"))
8048 .and_then(Value::as_str)
8049 {
8050 blocks.push(match parse_data_uri(url) {
8051 Some((mime, data)) => serde_json::json!({
8052 "type": "image",
8053 "source": {"type": "base64", "media_type": mime, "data": data},
8054 }),
8055 None => serde_json::json!({
8056 "type": "image",
8057 "source": {"type": "url", "url": url},
8058 }),
8059 });
8060 }
8061 }
8062 }
8063 }
8064 for tc in msg.tool_calls() {
8065 let input = tc
8066 .function
8067 .parsed_arguments()
8068 .unwrap_or_else(|_| Value::Object(Default::default()));
8069 blocks.push(serde_json::json!({
8070 "type": "tool_use",
8071 "id": tc.id,
8072 "name": tc.function.name,
8073 "input": input,
8074 }));
8075 }
8076 // PARITY-11 / PARITY-16: a genuinely reasoning-only turn
8077 // (no text, no tool_use, no image) still doesn't vanish
8078 // — the thinking/redacted_thinking prepend above already
8079 // ran unconditionally, so `blocks` is non-empty here
8080 // whenever any of those were present.
8081 blocks
8082 }
8083 };
8084
8085 // An empty assistant content array is a valid native interrupted
8086 // turn and must remain a record. Every non-assistant arm above
8087 // already `continue`s after writing its own shape, so an empty
8088 // `blocks` value here belongs specifically to that assistant.
8089 let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
8090 let mut message = serde_json::json!({"role": "assistant", "content": blocks});
8091 if let Some(model) = msg.metadata.get("model").or(self.meta.model.as_ref()) {
8092 message["model"] = Value::String(model.clone());
8093 }
8094 let mut line = serde_json::json!({
8095 "parentUuid": parent,
8096 "type": "assistant",
8097 "message": message,
8098 "uuid": uuid,
8099 "sessionId": session_id,
8100 "cwd": cwd,
8101 "timestamp": msg_timestamp_or_synth(msg),
8102 });
8103 set_grok_message_extension(&mut line, self.meta.source, msg);
8104 push_jsonl(out, &line);
8105 parent = Some(uuid);
8106 }
8107 }
8108
8109 /// A12 splice: replay the imported Claude Code `raw` prefix verbatim
8110 /// (patching `sessionId` on each line when `session_id` is `Some`), then
8111 /// synthesize records only for the appended tail, via
8112 /// [`Self::write_claude_code_records`] — chaining `parentUuid` from the
8113 /// last original `uuid` found anywhere in the raw prefix (not just its
8114 /// final line: a trailing loader-skipped record, e.g.
8115 /// `file-history-snapshot`, may carry no `uuid` of its own).
8116 fn to_claude_code_jsonl_spliced(&self, session_id: Option<&str>) -> String {
8117 let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
8118 let sid = session_id
8119 .map(str::to_string)
8120 .or_else(|| self.meta.session_id.clone())
8121 .unwrap_or_else(|| synth_uuid(0));
8122 let cwd = self.cwd_string();
8123
8124 let mut out = String::new();
8125 let mut parent: Option<String> = None;
8126 for line in &self.raw[..raw_prefix_len] {
8127 push_spliced_line(&mut out, line, session_id, "sessionId");
8128 if let Ok(v) = serde_json::from_str::<Value>(line) {
8129 if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
8130 parent = Some(uuid.to_string());
8131 }
8132 }
8133 }
8134
8135 // R1 (spliced-path hardening, mirrors the Codex N2 fix above): seed
8136 // the tail's collision guard with every uuid the just-replayed RAW
8137 // prefix already carries, so `write_claude_code_records` never
8138 // fabricates a `synth_uuid` for the appended tail that collides with
8139 // one already sitting in the prefix (see `next_claude_uuid`'s and
8140 // `collect_claude_uuids_from_raw`'s doc comments).
8141 let seed_used_ids = collect_claude_uuids_from_raw(&self.raw[..raw_prefix_len]);
8142 self.write_claude_code_records(
8143 &mut out,
8144 &self.messages[message_prefix_len..],
8145 &sid,
8146 &cwd,
8147 parent,
8148 1,
8149 &seed_used_ids,
8150 );
8151 out
8152 }
8153
8154 /// Synthesize a Codex rollout.
8155 fn to_codex_jsonl(&self) -> String {
8156 let mut out = String::new();
8157
8158 if self.meta.codex_headers.is_empty() {
8159 self.write_synthesized_codex_header(&mut out);
8160 } else {
8161 // Replay the exact header records the original tool wrote — Codex's
8162 // reader validates the header shape strictly — overriding only the
8163 // session id when the caller changed it.
8164 for header in &self.meta.codex_headers {
8165 let mut header = header.clone();
8166 if header.get("type").and_then(Value::as_str) == Some("session_meta") {
8167 if let Some(id) = &self.meta.session_id {
8168 if let Some(payload) = header.get_mut("payload") {
8169 payload["id"] = Value::String(id.clone());
8170 }
8171 }
8172 }
8173 push_jsonl(&mut out, &header);
8174 }
8175 }
8176
8177 // Full synthesis: `out` at this point is only the header, so there
8178 // are no group ids yet in play to seed against (see
8179 // `write_codex_records`'s doc comment).
8180 self.write_codex_records(&mut out, &self.messages, &std::collections::HashSet::new());
8181 if let Some(extension) = codex_provenance_envelope(&self.meta) {
8182 inject_codex_provenance(&mut out, extension);
8183 }
8184 out
8185 }
8186
8187 /// Synthesize Codex `response_item` records for `messages` (a full
8188 /// session or an appended tail — A12's [`Self::to_jsonl_spliced`] reuses
8189 /// this for just the latter). Factored out of [`Self::to_codex_jsonl`] so
8190 /// the record shape is defined once; `tool_search_call_ids` pairing is
8191 /// scoped to this call's `messages`, matching the header-replay
8192 /// contract that only appended records need synthesizing.
8193 ///
8194 /// `seed_used_ids` primes the N2 collision guard below with every group
8195 /// id that will ALREADY be present in `out` before this call ever runs —
8196 /// [`Self::to_codex_jsonl`] (full synthesis, `out` starts as just the
8197 /// header) passes an empty set, since every group id in that case is
8198 /// assigned by this very loop. [`Self::to_codex_jsonl_spliced`] (the A12
8199 /// splice) passes the ids already used by the verbatim RAW prefix it
8200 /// replayed into `out` just before calling this for the appended tail —
8201 /// without that seed, the tail's own `used_group_ids`/`next_group_id`
8202 /// start blind to the prefix and can fabricate/reuse a group id that
8203 /// COLLIDES with one still "open" at the end of the prefix, letting
8204 /// reimport's merge check (`can_merge`, `push_codex_item`) wrongly splice
8205 /// an unrelated appended message into a historical one — the same
8206 /// bug-class N2 closed for full synthesis, reopened here because the
8207 /// spliced tail's tracking set used to always start empty regardless of
8208 /// what the replayed prefix already contained.
8209 fn write_codex_records(
8210 &self,
8211 out: &mut String,
8212 messages: &[ChatMessage],
8213 seed_used_ids: &std::collections::HashSet<String>,
8214 ) {
8215 // Call ids of assistant `tool_search` calls (B6 agent intrinsic), so
8216 // the matching tool result below can be emitted as the paired
8217 // `tool_search_output` record rather than a generic
8218 // `function_call_output` — the exact inverse of the importer's
8219 // `tool_search_call`/`tool_search_output` normalization
8220 // (`push_codex_item`, above).
8221 let mut tool_search_call_ids: std::collections::HashSet<String> = Default::default();
8222 // PARITY-6/7: two ADJACENT but genuinely SEPARATE Claude assistant
8223 // records (e.g. a text-only narration turn immediately followed by a
8224 // bare tool-call turn, no user turn between — a real, common Claude
8225 // Code shape) each become their own Codex `message`/`function_call`
8226 // response_item(s) here. Codex's own reader (`push_codex_item`, IX-6)
8227 // opportunistically RE-MERGES an assistant `message` immediately
8228 // followed by a `function_call` back into ONE `ChatMessage`, to match
8229 // how a genuinely single Claude turn (text+tool_use in the SAME
8230 // record) round-trips — but with no distinguishing signal, it can't
8231 // tell that case apart from two originally-separate records that
8232 // just happen to be adjacent, so it wrongly recombines them too,
8233 // silently shrinking the message count on every Claude -> Codex ->
8234 // (inspect) hop. Fix: stamp a synthetic `metadata.turn_id` — unique
8235 // per ORIGINAL `ChatMessage` — onto the `message` record AND every
8236 // `function_call`/`tool_search_call` record THAT SAME `ChatMessage`
8237 // itself emits. `push_codex_item`'s merge already treats a turn_id
8238 // mismatch as "different turn, do not merge" (the pre-existing
8239 // belt-and-suspenders check); real native Codex data almost never
8240 // carries this field (per that check's own comment), so this is a
8241 // no-op there and only sharpens fidelity for OUR OWN synthesized
8242 // export.
8243 let mut next_group_id: u64 = 0;
8244 // N2 (Fable-5 review, turn_id-collision hardening): every group id
8245 // this export has already assigned — whether REUSED from a real
8246 // `turn_id` or FABRICATED as `sc-grp-N` — so a later assistant
8247 // `ChatMessage` never emits one that's already in use. Two concrete
8248 // mis-merge scenarios motivate this:
8249 //
8250 // (a) Claude->Codex export fabricates `sc-grp-0` for msg A (msg A's
8251 // own text+tool_use); reload makes A carry REAL turn_id
8252 // `sc-grp-0`. A bare tool-call msg B (metadata has no turn_id of
8253 // its own) is then appended. Re-export: A reuses its real
8254 // `sc-grp-0`, but B independently fabricates a FRESH id starting
8255 // from `next_group_id == 0` again (nothing bumped it when A's id
8256 // was reused rather than fabricated) — also `sc-grp-0`.
8257 // Collision. If A's call has no output (interrupted session),
8258 // reimport sees message(sc-grp-0)+call(sc-grp-0)+call(sc-grp-0)
8259 // adjacent with nothing to break the run and merges all three
8260 // into ONE message (2 -> 1).
8261 // (b) Native Codex: `message(turn-7)` opens the merge marker, a
8262 // truncation/clear event strips `__codex_open_turn` (closing the
8263 // turn without changing the id), then `function_call(turn-7)`
8264 // loads as a SECOND, separate `ChatMessage` that still carries
8265 // the SAME real `turn_id` (the reopen step in `push_codex_item`
8266 // restamps it). Full-synthesis export naively reuses `turn-7`
8267 // verbatim for BOTH messages (they're two different loop
8268 // iterations, each independently reusing its own `real_turn_id`)
8269 // and emits them adjacent — reimport's merge check can't tell
8270 // this apart from a single message's own multi-call turn and
8271 // recombines them (2 -> 1).
8272 //
8273 // Fix: the fabricated-id counter is advanced (skipped) past any id
8274 // already in `used_group_ids`, AND a real id that's already been
8275 // used gets disambiguated (`<real>~dupN`) instead of reused verbatim
8276 // — never letting two DIFFERENT `ChatMessage`s in this export share
8277 // one group id, since `push_codex_item`'s merge check treats a
8278 // shared id as "same turn, merge". A single `ChatMessage`'s own
8279 // message record + its own tool call records still share ONE group
8280 // id (computed once per loop iteration below, before insertion), so
8281 // the D1 tool_search merge and ordinary same-turn multi-call
8282 // grouping are unaffected — this only stops REUSE across iterations.
8283 //
8284 // Seeded from `seed_used_ids` (see this fn's doc comment) so the
8285 // spliced-export tail is likewise blind-proof against the prefix it
8286 // doesn't itself write.
8287 let mut used_group_ids: std::collections::HashSet<String> = seed_used_ids.clone();
8288
8289 for msg in messages {
8290 if is_replay_excluded(msg) {
8291 continue;
8292 }
8293 // D3 (Fable-5 review): a message loaded FROM real native Codex
8294 // carries its OWN real `turn_id` in `msg.metadata["turn_id"]`
8295 // (`push_codex_item`'s "message" arm stamps it whenever the
8296 // source record itself has one). The group-id logic below used
8297 // to ALWAYS fabricate a fresh `"sc-grp-N"` value regardless,
8298 // silently overwriting/discarding that real id on any
8299 // native-Codex -> load -> export-Codex hop. Reuse it verbatim
8300 // when present; only fabricate a synthetic id as a fallback for
8301 // our own merge-disambiguation need (PARITY-6/7) when the
8302 // message has no real one of its own.
8303 let real_turn_id = msg.metadata.get("turn_id").map(String::as_str);
8304 match msg.role {
8305 Role::System => {
8306 // PARITY-6 dev/02: carry the original Claude
8307 // `systemSubtype` (`push_claude_system`'s `.with_meta`)
8308 // through as `metadata.claude_system_subtype`, so
8309 // `push_codex_item`'s reverse load can restore it and
8310 // `write_claude_code_records`'s `Role::System` arm can
8311 // re-materialize the EXACT original subtype rather than
8312 // guessing on a Codex -> Claude hop.
8313 let subtype_meta = msg
8314 .metadata
8315 .get("systemSubtype")
8316 .map(|s| ("claude_system_subtype", s.as_str()));
8317 self.push_codex_message(
8318 out,
8319 "developer",
8320 "input_text",
8321 msg,
8322 real_turn_id,
8323 subtype_meta,
8324 )
8325 }
8326 Role::User => {
8327 self.push_codex_message(out, "user", "input_text", msg, real_turn_id, None)
8328 }
8329 Role::Assistant => {
8330 // Emit the message record whenever there is text OR
8331 // content_parts (IX-6 follow-up): an image-only assistant
8332 // message has `content: None, content_parts:
8333 // Some([image])` (the loader's `codex_extract_images` is
8334 // role-general, so this shape can occur on the assistant
8335 // side too) — gating on `msg.content` alone silently
8336 // dropped the whole message, image included. A
8337 // text-only message (content_parts: None) keeps taking
8338 // the historical byte-identical path via
8339 // `codex_message_content_blocks`'s `None` arm. A real
8340 // empty native assistant record carries the
8341 // loader's explicit marker and must also be emitted.
8342 // Reasoning-only cross-provider turns deliberately lack
8343 // that marker and keep the documented Codex residue.
8344 let has_text = msg.content.as_deref().is_some_and(|t| !t.is_empty());
8345 let has_message_record = has_text
8346 || msg.content_parts.is_some()
8347 || msg.metadata.contains_key("empty_assistant_record");
8348 // Only assign a synthetic group id when there's actual
8349 // merge ambiguity to resolve (a message AND its own tool
8350 // calls, or 2+ of this message's own tool calls) — a
8351 // pure-text message with no tool calls, or a lone tool
8352 // call with nothing else from the same `ChatMessage`,
8353 // has nothing to disambiguate, so it keeps the exact
8354 // historical byte shape (no `metadata` key at all).
8355 let group_id: Option<String> = if let Some(real) = real_turn_id {
8356 if used_group_ids.contains(real) {
8357 // N2: this real turn_id was already used by an
8358 // earlier (now-closed) `ChatMessage` in this same
8359 // export — reusing it verbatim would let the
8360 // reimport merge check recombine two originally
8361 // separate messages (see the doc comment above).
8362 let mut n = 1u64;
8363 let mut candidate = format!("{real}~dup{n}");
8364 while used_group_ids.contains(&candidate) {
8365 n += 1;
8366 candidate = format!("{real}~dup{n}");
8367 }
8368 Some(candidate)
8369 } else {
8370 Some(real.to_string())
8371 }
8372 } else if !msg.tool_calls().is_empty() {
8373 // N2: skip past any id already used (e.g. a REAL
8374 // turn_id that happens to look like `sc-grp-N`, or an
8375 // id an earlier reused-real case landed on).
8376 let mut candidate = format!("sc-grp-{next_group_id}");
8377 next_group_id += 1;
8378 while used_group_ids.contains(&candidate) {
8379 candidate = format!("sc-grp-{next_group_id}");
8380 next_group_id += 1;
8381 }
8382 Some(candidate)
8383 } else {
8384 None
8385 };
8386 if let Some(g) = &group_id {
8387 used_group_ids.insert(g.clone());
8388 }
8389 if has_message_record {
8390 self.push_codex_message(
8391 out,
8392 "assistant",
8393 "output_text",
8394 msg,
8395 group_id.as_deref(),
8396 None,
8397 );
8398 }
8399 for tc in msg.tool_calls() {
8400 let custom_tool_call = msg
8401 .metadata
8402 .get("codex_custom_tool_call_ids")
8403 .and_then(|raw| serde_json::from_str::<Vec<String>>(raw).ok())
8404 .is_some_and(|ids| ids.iter().any(|id| id == &tc.id));
8405 if custom_tool_call {
8406 let input = tc
8407 .function
8408 .parsed_arguments()
8409 .unwrap_or_else(|_| Value::String(tc.function.arguments.clone()));
8410 let mut payload = with_turn_id(
8411 serde_json::json!({
8412 "type": "custom_tool_call",
8413 "name": tc.function.name,
8414 "input": input,
8415 "call_id": tc.id,
8416 }),
8417 group_id.as_deref(),
8418 );
8419 set_grok_message_extension(&mut payload, self.meta.source, msg);
8420 push_jsonl(
8421 out,
8422 &codex_response_item(payload, msg_timestamp_or_synth(msg)),
8423 );
8424 } else if tc.function.name == "tool_search" {
8425 tool_search_call_ids.insert(tc.id.clone());
8426 let mut payload = with_turn_id(
8427 serde_json::json!({
8428 "type": "tool_search_call",
8429 "arguments": tc.function.arguments,
8430 "call_id": tc.id,
8431 }),
8432 group_id.as_deref(),
8433 );
8434 set_grok_message_extension(&mut payload, self.meta.source, msg);
8435 push_jsonl(
8436 out,
8437 &codex_response_item(payload, msg_timestamp_or_synth(msg)),
8438 );
8439 } else {
8440 let mut payload = with_turn_id(
8441 serde_json::json!({
8442 "type": "function_call",
8443 "name": tc.function.name,
8444 "arguments": tc.function.arguments,
8445 "call_id": tc.id,
8446 }),
8447 group_id.as_deref(),
8448 );
8449 set_grok_message_extension(&mut payload, self.meta.source, msg);
8450 push_jsonl(
8451 out,
8452 &codex_response_item(payload, msg_timestamp_or_synth(msg)),
8453 );
8454 }
8455 }
8456 // PARITY-11: a genuinely reasoning-only turn (Claude
8457 // `thinking`/`redacted_thinking` with no text, tool_use,
8458 // or image — `push_claude_assistant`'s load-side fix for
8459 // the ~21% of real assistant records that are exactly
8460 // this shape) has no message record and no tool calls,
8461 // so nothing above writes anything for it. This is
8462 // DELIBERATE, not a residual gap: Codex's `reasoning`
8463 // response_item is understood on import (see the
8464 // `response_item`/`"reasoning"` arm above), but its
8465 // real-native semantics is "the reasoning immediately
8466 // BEFORE the next turn" — the reader attaches it to
8467 // whatever response_item comes next, unconditionally.
8468 // For a genuinely standalone Claude reasoning-only turn
8469 // (no related turn follows in Codex's export at all),
8470 // emitting one here would get silently misattributed as
8471 // belonging to some later, unrelated turn instead —
8472 // strictly worse than the current honest, accounted-for
8473 // absence (thinking/redacted_thinking is provider-
8474 // private and "not replayed across providers" by
8475 // original design; the audit correctly classifies it
8476 // `Coverage::Dropped`, not `Unmodeled`). See the
8477 // PARITY-6/7 corpus test's `is_replayable` filter for
8478 // why this doesn't count as a message-count regression.
8479 }
8480 Role::Tool
8481 if msg
8482 .tool_call_id
8483 .as_deref()
8484 .is_some_and(|id| tool_search_call_ids.contains(id)) =>
8485 {
8486 let content = msg.content.clone().unwrap_or_default();
8487 let tools =
8488 serde_json::from_str::<Value>(&content).unwrap_or(Value::String(content));
8489 let mut payload = serde_json::json!({
8490 "type": "tool_search_output",
8491 "call_id": msg.tool_call_id.clone().unwrap_or_default(),
8492 "tools": tools,
8493 });
8494 set_grok_message_extension(&mut payload, self.meta.source, msg);
8495 push_jsonl(
8496 out,
8497 &codex_response_item(payload, msg_timestamp_or_synth(msg)),
8498 );
8499 }
8500 Role::Tool => {
8501 let mut payload = serde_json::json!({
8502 "type": "function_call_output",
8503 "call_id": msg.tool_call_id.clone().unwrap_or_default(),
8504 "output": codex_tool_output_text(msg),
8505 });
8506 set_grok_message_extension(&mut payload, self.meta.source, msg);
8507 push_jsonl(
8508 out,
8509 &codex_response_item(payload, msg_timestamp_or_synth(msg)),
8510 );
8511 }
8512 }
8513 }
8514 }
8515
8516 /// A12 splice: replay the imported Codex `raw` prefix verbatim — every
8517 /// line, not just the `session_meta`/`turn_context` headers
8518 /// [`Self::to_codex_jsonl`] replays — overriding only
8519 /// `session_meta.payload.id` when `session_id` is `Some` (every other
8520 /// line, including `response_item`s the stock synthesis would otherwise
8521 /// rebuild from scratch, is untouched byte-for-byte). Then synthesizes
8522 /// `response_item` records only for the appended tail, via
8523 /// [`Self::write_codex_records`].
8524 fn to_codex_jsonl_spliced(&self, session_id: Option<&str>) -> String {
8525 let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
8526
8527 let mut out = String::new();
8528 for line in &self.raw[..raw_prefix_len] {
8529 match session_id {
8530 Some(id) => {
8531 let patched = serde_json::from_str::<Value>(line)
8532 .ok()
8533 .filter(|v| v.get("type").and_then(Value::as_str) == Some("session_meta"))
8534 .map(|mut v| {
8535 if let Some(payload) = v.get_mut("payload") {
8536 payload["id"] = Value::String(id.to_string());
8537 }
8538 v.to_string()
8539 });
8540 out.push_str(patched.as_deref().unwrap_or(line));
8541 }
8542 None => out.push_str(line),
8543 }
8544 out.push('\n');
8545 }
8546
8547 // N2 (spliced-path hardening): seed the tail's collision guard with
8548 // every group id the just-replayed RAW prefix already carries, so
8549 // `write_codex_records` never fabricates/reuses an id for the
8550 // appended tail that collides with one still open at the end of the
8551 // prefix (see that fn's doc comment, and
8552 // `collect_codex_group_ids_from_raw`'s).
8553 let mut seed_used_ids = collect_codex_group_ids_from_raw(&self.raw[..raw_prefix_len]);
8554 // Belt-and-suspenders: also union in the prefix `messages`' own
8555 // recorded `turn_id` metadata. In the ordinary case this is already
8556 // a subset of what the raw-line scan above found (the loader stamps
8557 // `metadata["turn_id"]` from the very same `payload.metadata.turn_id`
8558 // field the scan reads) — but scanning `messages` too costs nothing
8559 // and means this stays correct even if some future loader path ever
8560 // derives a message's `turn_id` by some means other than a literal
8561 // `payload.metadata.turn_id` copy.
8562 for msg in &self.messages[..message_prefix_len] {
8563 if let Some(tid) = msg.metadata.get("turn_id") {
8564 seed_used_ids.insert(tid.clone());
8565 }
8566 }
8567 self.write_codex_records(
8568 &mut out,
8569 &self.messages[message_prefix_len..],
8570 &seed_used_ids,
8571 );
8572 out
8573 }
8574
8575 /// Build a Codex header from scratch (used when converting from another
8576 /// format, where no original Codex header exists to replay). Emits the
8577 /// fields Codex requires on `session_meta`.
8578 fn write_synthesized_codex_header(&self, out: &mut String) {
8579 let mut meta_payload = serde_json::json!({
8580 "id": self.meta.session_id.clone().unwrap_or_else(|| synth_uuid(0)),
8581 "timestamp": SYNTH_TS,
8582 "cwd": self.cwd_string(),
8583 "originator": "supercode",
8584 "cli_version": env!("CARGO_PKG_VERSION"),
8585 "source": "exec",
8586 "thread_source": "user",
8587 "model_provider": "openai",
8588 });
8589 if let Some(sp) = &self.meta.system_prompt {
8590 meta_payload["base_instructions"] = serde_json::json!({"text": sp});
8591 }
8592 // PARITY-10 dev/03: carry a captured Claude `fork-context-ref` (see
8593 // `capture_claude_meta`) through the Codex hop under a clearly
8594 // namespaced custom field — real Codex tooling ignores unknown
8595 // `session_meta.payload` keys, and `capture_codex_session_meta`
8596 // reads this same key back on import, so a Claude -> Codex -> Claude
8597 // round trip still reconstructs the original record instead of
8598 // silently losing the lineage note on the cross-format hop.
8599 if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
8600 meta_payload["claude_fork_context_ref"] =
8601 serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
8602 }
8603 push_jsonl(
8604 out,
8605 &serde_json::json!({"timestamp": SYNTH_TS, "type": "session_meta", "payload": meta_payload}),
8606 );
8607 if let Some(model) = &self.meta.model {
8608 push_jsonl(
8609 out,
8610 &serde_json::json!({
8611 "timestamp": SYNTH_TS,
8612 "type": "turn_context",
8613 "payload": {"model": model, "cwd": self.cwd_string()},
8614 }),
8615 );
8616 }
8617 }
8618
8619 /// `turn_id`: see the PARITY-6/7 (and D3) comment on
8620 /// [`Self::write_codex_records`] — `Some` when the source message
8621 /// carries its own REAL `turn_id` (a native-Codex round-trip), or
8622 /// (assistant only) a synthetic disambiguation id when it owns tool
8623 /// calls needing merge disambiguation and has no real id of its own;
8624 /// `None` reproduces the exact historical shape (no `metadata` key at
8625 /// all).
8626 /// `extra_metadata`: PARITY-6 dev/02 — an additional `(key, value)`
8627 /// pair folded into `payload.metadata` alongside `turn_id` (used by the
8628 /// `Role::System` case in [`Self::write_codex_records`] to carry
8629 /// `claude_system_subtype`, so a Claude `<local-command-stdout>`-style
8630 /// system record's subtype survives the Claude -> Codex -> Claude round
8631 /// trip instead of only its text; `None` for every other caller,
8632 /// preserving the exact historical shape).
8633 fn push_codex_message(
8634 &self,
8635 out: &mut String,
8636 role: &str,
8637 text_type: &str,
8638 msg: &ChatMessage,
8639 turn_id: Option<&str>,
8640 extra_metadata: Option<(&str, &str)>,
8641 ) {
8642 let mut payload = with_turn_id(
8643 serde_json::json!({
8644 "type": "message",
8645 "role": role,
8646 "content": codex_message_content_blocks(text_type, msg),
8647 }),
8648 turn_id,
8649 );
8650 if let Some((k, v)) = extra_metadata {
8651 if payload.get("metadata").is_none() {
8652 payload["metadata"] = serde_json::json!({});
8653 }
8654 payload["metadata"][k] = serde_json::json!(v);
8655 }
8656 set_grok_message_extension(&mut payload, self.meta.source, msg);
8657 push_jsonl(
8658 out,
8659 &codex_response_item(payload, msg_timestamp_or_synth(msg)),
8660 );
8661 }
8662
8663 /// Synthesize a fresh pi v3 session from the canonical `messages`
8664 /// (T3 cross-format/full synthesis — `to_jsonl(other)`'s "view round-trip"
8665 /// tier, §3/§4.1: this is NOT the byte-lossless native path, which goes
8666 /// through `raw` + `to_native_jsonl(_v2)` instead).
8667 fn to_pi_jsonl(&self) -> String {
8668 let session_id = self
8669 .meta
8670 .session_id
8671 .clone()
8672 .unwrap_or_else(|| synth_uuid(0));
8673 let cwd = self.cwd_string();
8674 let mut out = String::new();
8675 push_pi_header(
8676 &mut out,
8677 &session_id,
8678 &cwd,
8679 self.meta
8680 .lineage
8681 .get("parent_session_path")
8682 .map(String::as_str),
8683 self.meta.lineage.get("created_at").map(String::as_str),
8684 // D7: carry a captured Claude `fork-context-ref` (see
8685 // `capture_claude_meta`) through the Pi hop too — mirrors the
8686 // Codex hop's `claude_fork_context_ref` passthrough
8687 // (`write_synthesized_codex_header`) so a Claude -> Pi -> Claude
8688 // round trip doesn't silently lose fork lineage just because Pi
8689 // has no native slot for it.
8690 self.meta
8691 .lineage
8692 .get("claude_fork_context_ref_raw")
8693 .map(String::as_str),
8694 );
8695 let mut used_ids: HashSet<String> = HashSet::new();
8696 let mut counter: u64 = 0;
8697 self.write_pi_entries(&mut out, &self.messages, None, &mut used_ids, &mut counter);
8698 if let Some(extension) = codex_provenance_envelope(&self.meta) {
8699 inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
8700 }
8701 out
8702 }
8703
8704 /// Synthesize pi `message` entries for `messages` (a full session, or —
8705 /// for [`Self::to_pi_jsonl_spliced`] — just the appended tail), chaining
8706 /// `parentId` from `parent` and drawing fresh ids from `used_ids`/
8707 /// `counter`. Skips [`is_replay_excluded`] messages (`compacted_out`,
8708 /// `pi_exclude_from_context`) exactly like the Claude/Codex writers.
8709 fn write_pi_entries(
8710 &self,
8711 out: &mut String,
8712 messages: &[ChatMessage],
8713 mut parent: Option<String>,
8714 used_ids: &mut HashSet<String>,
8715 counter: &mut u64,
8716 ) {
8717 // Claude Code and Codex do not repeat the tool name on their native
8718 // tool-result records. Recover that redundant Pi field from the
8719 // paired assistant call when a cross-format round trip therefore
8720 // returns a canonical Tool message with `name == None`.
8721 let mut paired_tool_names = HashMap::<String, String>::new();
8722 for msg in messages {
8723 if is_replay_excluded(msg) {
8724 continue;
8725 }
8726 for call in msg.tool_calls() {
8727 paired_tool_names.insert(call.id.clone(), call.function.name.clone());
8728 }
8729 let id = pi_fresh_id(used_ids, counter);
8730 let mut entry = match msg.role {
8731 // B4: pi has no session-level system/developer PROMPT slot
8732 // (§1.1: "no system-prompt... rebuilt at runtime"), but a
8733 // content-bearing `Role::System` message loaded from a real
8734 // Claude Code `type: "system"` record (`push_claude_system`'s
8735 // keep-listed subtypes: `local_command`, `scheduled_task_fire`,
8736 // `away_summary`) is NOT a system prompt — it's a real,
8737 // non-regenerable transcript event. Pi's own `role:"custom"`
8738 // `CustomMessage` (§3e: "extension-injected... sent to the LLM
8739 // as a user message") is the closest existing, non-fabricated
8740 // slot pi's own parser already understands, so this
8741 // re-materializes the record there instead of silently
8742 // dropping it — the exact allowance push_claude_system's own
8743 // doc comment describes in reverse. `customType` is a
8744 // supercode-namespaced marker (`push_pi_custom_common`
8745 // recognizes it on reload and restores `Role::System` +
8746 // `metadata["systemSubtype"]`, exactly like `push_claude_system`
8747 // produced in the first place); a real pi customType never
8748 // collides with this name. `details.claude_system_subtype`
8749 // carries the original subtype losslessly through the pi leg
8750 // (mirrors `write_codex_records`'s `claude_system_subtype`
8751 // metadata channel on the Codex leg, PARITY-6 dev/02). Content
8752 // is never fabricated — only emitted when non-empty.
8753 Role::System => {
8754 let content = msg.content.clone().unwrap_or_default();
8755 if content.trim().is_empty() {
8756 continue;
8757 }
8758 let subtype = msg
8759 .metadata
8760 .get("systemSubtype")
8761 .cloned()
8762 .unwrap_or_else(|| "local_command".to_string());
8763 serde_json::json!({
8764 "type": "message",
8765 "id": id,
8766 "parentId": parent,
8767 "timestamp": msg_timestamp_or_synth(msg),
8768 "message": {
8769 "role": "custom",
8770 "customType": PI_CLAUDE_SYSTEM_CUSTOM_TYPE,
8771 "content": content,
8772 "display": true,
8773 "details": {"claude_system_subtype": subtype},
8774 "timestamp": msg_pi_native_timestamp_ms(msg),
8775 },
8776 })
8777 }
8778 Role::User => serde_json::json!({
8779 "type": "message",
8780 "id": id,
8781 "parentId": parent,
8782 "timestamp": msg_timestamp_or_synth(msg),
8783 "message": {
8784 "role": "user",
8785 "content": pi_content_value(msg),
8786 "timestamp": msg_pi_native_timestamp_ms(msg),
8787 },
8788 }),
8789 Role::Assistant => {
8790 let api = msg
8791 .metadata
8792 .get("pi_api")
8793 .cloned()
8794 .unwrap_or_else(|| "anthropic-messages".to_string());
8795 let provider = msg
8796 .metadata
8797 .get("pi_provider")
8798 .cloned()
8799 .unwrap_or_else(|| "anthropic".to_string());
8800 let model = self
8801 .meta
8802 .model
8803 .clone()
8804 .unwrap_or_else(|| "unknown".to_string());
8805 let usage = msg
8806 .metadata
8807 .get("pi_usage")
8808 .and_then(|s| serde_json::from_str::<Value>(s).ok())
8809 .unwrap_or_else(default_pi_usage);
8810 let stop_reason = msg
8811 .metadata
8812 .get("pi_stop_reason")
8813 .cloned()
8814 .unwrap_or_else(|| "stop".to_string());
8815 serde_json::json!({
8816 "type": "message",
8817 "id": id,
8818 "parentId": parent,
8819 "timestamp": msg_timestamp_or_synth(msg),
8820 "message": {
8821 "role": "assistant",
8822 "content": pi_assistant_content_value(msg),
8823 "api": api,
8824 "provider": provider,
8825 "model": model,
8826 "usage": usage,
8827 "stopReason": stop_reason,
8828 "timestamp": msg_pi_native_timestamp_ms(msg),
8829 },
8830 })
8831 }
8832 Role::Tool => serde_json::json!({
8833 "type": "message",
8834 "id": id,
8835 "parentId": parent,
8836 "timestamp": msg_timestamp_or_synth(msg),
8837 "message": {
8838 "role": "toolResult",
8839 "toolCallId": msg.tool_call_id.clone().unwrap_or_default(),
8840 "toolName": msg.name.as_deref().or_else(|| {
8841 msg.tool_call_id
8842 .as_deref()
8843 .and_then(|id| paired_tool_names.get(id).map(String::as_str))
8844 }).unwrap_or_default(),
8845 "content": pi_content_value(msg),
8846 "isError": is_tool_error_flag(msg),
8847 "timestamp": msg_pi_native_timestamp_ms(msg),
8848 },
8849 }),
8850 };
8851 if msg.role == Role::Tool
8852 && crate::reduce::tool_outcome(msg) == crate::reduce::ToolOutcome::Unknown
8853 {
8854 entry[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
8855 }
8856 set_grok_message_extension(&mut entry, self.meta.source, msg);
8857 push_jsonl(out, &entry);
8858 parent = Some(id);
8859 if msg.role == Role::Tool {
8860 if let Some(call_id) = msg.tool_call_id.as_deref() {
8861 paired_tool_names.remove(call_id);
8862 }
8863 }
8864 }
8865 }
8866
8867 /// A12-style splice for pi (§1.1/§4.2): replay the imported `raw` prefix
8868 /// **verbatim** — the header line always has its `version` normalized to
8869 /// 3 (§1.3: pi rewrites any pre-v3 file in place on load, destroying
8870 /// byte-identity, so the writer never re-emits one; this intentionally
8871 /// breaks byte-identity for pre-v3 originals only, the accepted
8872 /// trade-off) and its `id` overridden when `session_id` is `Some`. Every
8873 /// other raw line — every entry — is untouched (pi repeats the session
8874 /// id on no other line, `pi-fields.md` §1). Then synthesizes `message`
8875 /// entries only for the appended tail via [`Self::write_pi_entries`],
8876 /// chaining from the last entry `id` found in the raw prefix.
8877 fn to_pi_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
8878 let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
8879 if raw_prefix_len == 0 {
8880 return Ok(self.to_pi_jsonl());
8881 }
8882
8883 let mut out = String::new();
8884 let mut used_ids: HashSet<String> = HashSet::new();
8885 let mut leaf: Option<String> = None;
8886 for (i, line) in self.raw[..raw_prefix_len].iter().enumerate() {
8887 if i == 0 {
8888 if let Ok(v) = serde_json::from_str::<Value>(line) {
8889 if v.get("type").and_then(Value::as_str) == Some("session") {
8890 let needs_v3 = v.get("version").and_then(Value::as_u64) != Some(3);
8891 // Only reparse+reserialize the header when something
8892 // actually needs to change — this crate doesn't
8893 // enable serde_json's `preserve_order`, so a no-op
8894 // round-trip through `Value` would reorder keys
8895 // alphabetically and silently break the "prefix
8896 // bytes unchanged" splice guarantee for the (common)
8897 // already-v3, no-override case.
8898 if needs_v3 || session_id.is_some() {
8899 let mut v = v;
8900 v["version"] = serde_json::json!(3);
8901 if let Some(new_id) = session_id {
8902 v["id"] = Value::String(new_id.to_string());
8903 }
8904 out.push_str(&v.to_string());
8905 out.push('\n');
8906 continue;
8907 }
8908 }
8909 }
8910 }
8911 out.push_str(line);
8912 out.push('\n');
8913 if let Ok(v) = serde_json::from_str::<Value>(line) {
8914 if let Some(id) = v.get("id").and_then(Value::as_str) {
8915 used_ids.insert(id.to_string());
8916 leaf = Some(id.to_string());
8917 }
8918 }
8919 }
8920
8921 let mut counter: u64 = 0;
8922 self.write_pi_entries(
8923 &mut out,
8924 &self.messages[message_prefix_len..],
8925 leaf,
8926 &mut used_ids,
8927 &mut counter,
8928 );
8929 Ok(out)
8930 }
8931
8932 // ---- Grok writers -----------------------------------------------
8933
8934 /// Synthesize Grok's resumable `chat_history.jsonl` transcript.
8935 fn to_grok_jsonl(&self) -> String {
8936 let mut out = String::new();
8937 if let Some(prompt) = self
8938 .meta
8939 .system_prompt
8940 .as_deref()
8941 .filter(|prompt| !prompt.is_empty())
8942 {
8943 push_jsonl(
8944 &mut out,
8945 &serde_json::json!({
8946 "type": "system",
8947 "content": prompt,
8948 }),
8949 );
8950 }
8951 self.write_grok_records(&mut out, &self.messages);
8952 if let Some(extension) = codex_provenance_envelope(&self.meta) {
8953 if out.is_empty() {
8954 push_jsonl(
8955 &mut out,
8956 &serde_json::json!({"type": "system", "content": ""}),
8957 );
8958 }
8959 inject_first_jsonl_top_level(&mut out, SUPERCODE_CODEX_PROVENANCE_KEY, extension);
8960 }
8961 out
8962 }
8963
8964 fn write_grok_records(&self, out: &mut String, messages: &[ChatMessage]) {
8965 for message in messages {
8966 if is_replay_excluded(message) {
8967 continue;
8968 }
8969 let mut value = match message.role {
8970 Role::System => serde_json::json!({
8971 "type": "user",
8972 "content": [{"type": "text", "text": message.content.clone().unwrap_or_default()}],
8973 "synthetic_reason": "supercode_system_event",
8974 }),
8975 Role::User => {
8976 let mut value = serde_json::json!({
8977 "type": "user",
8978 "content": [{"type": "text", "text": message.content.clone().unwrap_or_default()}],
8979 });
8980 if let Some(object) = value.as_object_mut() {
8981 for (metadata, field) in [
8982 ("grok_prompt_index", "prompt_index"),
8983 ("grok_prior_turn_interrupt", "prior_turn_interrupt"),
8984 ("grok_synthetic_reason", "synthetic_reason"),
8985 ] {
8986 if let Some(raw) = message.metadata.get(metadata) {
8987 object.insert(
8988 field.to_string(),
8989 serde_json::from_str(raw)
8990 .unwrap_or_else(|_| Value::String(raw.clone())),
8991 );
8992 }
8993 }
8994 }
8995 value
8996 }
8997 Role::Assistant => {
8998 let calls = message
8999 .tool_calls()
9000 .iter()
9001 .map(|call| {
9002 serde_json::json!({
9003 "id": call.id,
9004 "name": call.function.name,
9005 "arguments": call.function.arguments,
9006 })
9007 })
9008 .collect::<Vec<_>>();
9009 let mut value = serde_json::json!({
9010 "type": "assistant",
9011 "content": message.content.clone().unwrap_or_default(),
9012 "tool_calls": calls,
9013 "model_id": message.metadata.get("grok_model_id")
9014 .or(self.meta.model.as_ref())
9015 .cloned()
9016 .unwrap_or_else(|| "unknown".to_string()),
9017 });
9018 if let Some(object) = value.as_object_mut() {
9019 for (metadata, field) in [
9020 ("grok_model_fingerprint", "model_fingerprint"),
9021 ("grok_reasoning_effort", "reasoning_effort"),
9022 ] {
9023 if let Some(raw) = message.metadata.get(metadata) {
9024 object.insert(field.to_string(), Value::String(raw.clone()));
9025 }
9026 }
9027 }
9028 value
9029 }
9030 Role::Tool => serde_json::json!({
9031 "type": "tool_result",
9032 "tool_call_id": message.tool_call_id.clone().unwrap_or_default(),
9033 "content": message.content.clone().unwrap_or_default(),
9034 }),
9035 };
9036 set_grok_target_message_extension(&mut value, message);
9037 push_jsonl(out, &value);
9038 }
9039 }
9040
9041 /// Replay a Grok imported prefix verbatim, then append newly-created
9042 /// canonical turns. Grok stores the session id in the directory name,
9043 /// not in transcript records, so there is no in-file id to rewrite.
9044 fn to_grok_jsonl_spliced(&self) -> String {
9045 let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
9046 if raw_prefix_len == 0 {
9047 return self.to_grok_jsonl();
9048 }
9049 let mut out = String::new();
9050 for line in &self.raw[..raw_prefix_len] {
9051 out.push_str(line);
9052 out.push('\n');
9053 }
9054 self.write_grok_records(&mut out, &self.messages[message_prefix_len..]);
9055 out
9056 }
9057
9058 // ---- OpenCode writers ---------------------------------------------
9059
9060 /// Re-derive the structured OpenCode records (`SessionInfo`, an ordered
9061 /// `(message value, part values)` list) directly from `self.raw`'s
9062 /// envelope lines — the same classification
9063 /// [`Self::from_opencode_str`] performs, but returning the raw VALUES
9064 /// rather than canonical `ChatMessage`s. Used by
9065 /// [`Self::to_opencode_jsonl_spliced`] (§4.2 S5: the imported prefix
9066 /// must be VALUE-EQUAL at its position, not re-synthesized from lossy
9067 /// `messages`) and [`Self::to_opencode_direct_write`] (S5: the fidelity
9068 /// path for excess keys/timestamps/side-records `opencode import`
9069 /// cannot restore).
9070 fn opencode_records_from_raw(&self) -> (Option<Value>, Vec<(Value, Vec<Value>)>) {
9071 let mut session_info: Option<Value> = None;
9072 let mut msg_order: Vec<String> = Vec::new();
9073 let mut msg_values: HashMap<String, Value> = HashMap::new();
9074 let mut msg_parts: HashMap<String, Vec<Value>> = HashMap::new();
9075 for line in &self.raw {
9076 let Ok(env) = serde_json::from_str::<Value>(line) else {
9077 continue;
9078 };
9079 let Some(key) = env.get("key").and_then(Value::as_array) else {
9080 continue;
9081 };
9082 let value = env.get("value").cloned().unwrap_or(Value::Null);
9083 match key.first().and_then(Value::as_str) {
9084 Some("session") => session_info = Some(value),
9085 Some("message") => {
9086 if let Some(id) = value.get("id").and_then(Value::as_str) {
9087 if !msg_values.contains_key(id) {
9088 msg_order.push(id.to_string());
9089 }
9090 msg_values.insert(id.to_string(), value);
9091 }
9092 }
9093 Some("part") => {
9094 if let Some(mid) = value.get("messageID").and_then(Value::as_str) {
9095 msg_parts.entry(mid.to_string()).or_default().push(value);
9096 }
9097 }
9098 _ => {}
9099 }
9100 }
9101 let mut ordered: Vec<(String, i64)> = msg_order
9102 .iter()
9103 .map(|id| {
9104 let tc = msg_values
9105 .get(id)
9106 .and_then(|v| v.get("time"))
9107 .and_then(|t| t.get("created"))
9108 .and_then(Value::as_i64)
9109 .unwrap_or(0);
9110 (id.clone(), tc)
9111 })
9112 .collect();
9113 ordered.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
9114 let mut out = Vec::new();
9115 for (id, _) in ordered {
9116 let mut parts = msg_parts.remove(&id).unwrap_or_default();
9117 parts.sort_by(|a, b| {
9118 let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
9119 let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
9120 ai.cmp(bi)
9121 });
9122 if let Some(v) = msg_values.remove(&id) {
9123 out.push((v, parts));
9124 }
9125 }
9126 (session_info, out)
9127 }
9128
9129 /// Best-effort `SessionInfo` synthesized from `self.meta` — used when no
9130 /// `raw` prefix exists to replay (a fresh/cross-format-converted
9131 /// session). T3 tier: only what `SessionMeta` carries survives.
9132 fn synthesized_opencode_info(&self) -> Value {
9133 let id = self
9134 .meta
9135 .session_id
9136 .clone()
9137 .unwrap_or_else(|| "ses_supercode00000000000001".to_string());
9138 let mut info = serde_json::json!({
9139 "id": id,
9140 "projectID": self.meta.lineage.get("projectID").cloned().unwrap_or_else(|| "global".to_string()),
9141 // OpenCode 1.2.15's import path writes this into a NOT NULL
9142 // SQLite column. Preserve a real source slug when available and
9143 // mint a stable, human-readable fallback for foreign sessions.
9144 "slug": self.meta.lineage.get("slug").cloned().unwrap_or_else(|| "supercode-export".to_string()),
9145 "directory": self.cwd_string(),
9146 "title": "supercode export",
9147 "version": env!("CARGO_PKG_VERSION"),
9148 "time": {"created": SYNTH_TS_MS, "updated": SYNTH_TS_MS},
9149 });
9150 if let Some(agent) = &self.meta.agent_id {
9151 info["agent"] = Value::String(agent.clone());
9152 }
9153 if let Some(model) = &self.meta.model {
9154 if let Some((provider, mid)) = model.split_once('/') {
9155 info["model"] = serde_json::json!({"providerID": provider, "id": mid});
9156 }
9157 }
9158 if let Some(parent) = self.meta.lineage.get("parent_session_id") {
9159 info["parentID"] = Value::String(parent.clone());
9160 }
9161 // D7: carry a captured Claude `fork-context-ref` through the
9162 // OpenCode hop too, exactly like the Codex (`claude_fork_context_ref`
9163 // on `session_meta.payload`) and Pi (`claude_fork_context_ref` on
9164 // the `session` header) hops already do — namespaced so real
9165 // OpenCode tooling ignores it, and `capture_opencode_session_info`
9166 // reads this same key back on import so a Claude -> OpenCode ->
9167 // Claude round trip doesn't silently lose fork lineage either.
9168 //
9169 // DOCUMENTED LIMITATION (D7 caveat, per the frozen interop spec —
9170 // `interop-research-spec@f168465`, `opencode-pi-spec.md` ~line 123):
9171 // this `claude_fork_context_ref` key on `SessionInfo` survives
9172 // supercode's OWN round-trip (write here, read back by
9173 // `capture_opencode_session_info` above) but NOT a real upstream
9174 // `opencode import` ingestion — that path decodes with
9175 // `Schema.decodeUnknownSync`, which strips any key its schema
9176 // doesn't declare. The direct-file/DB fallback (bypassing
9177 // `opencode import` entirely) is the per-spec fidelity path for
9178 // this lineage to actually reach real OpenCode.
9179 if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
9180 info["claude_fork_context_ref"] =
9181 serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
9182 }
9183 if let Some(extension) = codex_provenance_envelope(&self.meta) {
9184 info[SUPERCODE_CODEX_PROVENANCE_KEY] = extension;
9185 }
9186 info
9187 }
9188
9189 /// OpenCode uses `SessionInfo.time.updated` for recency selection. Any
9190 /// synthesized continuation message therefore has to advance the
9191 /// session clock along with its own `time.created` value.
9192 fn touch_opencode_session_updated(info: &mut Value, timestamp: i64) {
9193 if !info.get("time").is_some_and(Value::is_object) {
9194 info["time"] = serde_json::json!({});
9195 }
9196 info["time"]["updated"] = serde_json::json!(timestamp);
9197 }
9198
9199 /// Synthesize opencode `{info, parts}` message objects for `messages`
9200 /// (T3 tier — full synthesis from canonical `ChatMessage`s, the inverse
9201 /// of [`push_opencode_user`]/[`push_opencode_assistant`]), appending
9202 /// them to `out`. Every `Tool` message anywhere in `messages` is folded
9203 /// back into its call's assistant `tool` part (match by
9204 /// `tool_call_id`/`callID`, pairing repeated ids by occurrence over the
9205 /// whole slice)
9206 /// — the exact inverse of the loader's call/result split. This is a
9207 /// GLOBAL match, not a scan of the contiguous run of `Tool` messages
9208 /// immediately following each assistant: two-or-more consecutive
9209 /// assistant-with-tool-call messages before their results (streamed /
9210 /// parallel tool calls) otherwise strand the earlier call's real result
9211 /// behind a later assistant message, silently downgrading it to
9212 /// "pending" and losing the recorded output. Skips [`is_replay_excluded`]
9213 /// messages exactly like every other writer.
9214 fn append_synthesized_opencode_messages(
9215 &self,
9216 out: &mut Vec<Value>,
9217 messages: &[ChatMessage],
9218 session_id: &str,
9219 counter: &mut u64,
9220 timestamp_cursor: &mut i64,
9221 ) -> Result<()> {
9222 // Pair each Tool message to its call GLOBALLY by `tool_call_id`,
9223 // over the ENTIRE slice being processed, rather than by scanning
9224 // only the contiguous run of `Role::Tool` messages immediately
9225 // following a given assistant message. Two-or-more consecutive
9226 // assistant-with-tool-call messages before their results (streamed
9227 // / parallel tool calls — extremely common in real Claude Code and
9228 // Codex sessions) break the contiguous-run assumption: the first
9229 // assistant's own result(s) land AFTER a second assistant message,
9230 // not immediately after the first, so a contiguous scan starting
9231 // right after the first assistant finds nothing and silently drops
9232 // its real tool output into the `None => "pending"` branch below.
9233 // A single `id -> result` map is still insufficient: long real
9234 // sessions can reuse provider call ids. Last-write-wins then attaches
9235 // the final output to every earlier occurrence. Collect calls and
9236 // results independently and zip their occurrences in transcript
9237 // order, giving every concrete call position its own result.
9238 let mut calls_by_id: HashMap<&str, Vec<(usize, usize)>> = HashMap::new();
9239 let mut results_by_id: HashMap<&str, Vec<(usize, &ChatMessage)>> = HashMap::new();
9240 for (message_index, message) in messages.iter().enumerate() {
9241 if message.role == Role::Assistant {
9242 for (tool_index, call) in message.tool_calls().iter().enumerate() {
9243 calls_by_id
9244 .entry(call.id.as_str())
9245 .or_default()
9246 .push((message_index, tool_index));
9247 }
9248 } else if message.role == Role::Tool {
9249 if let Some(id) = &message.tool_call_id {
9250 results_by_id
9251 .entry(id.as_str())
9252 .or_default()
9253 .push((message_index, message));
9254 }
9255 }
9256 }
9257 let mut paired_results: HashMap<(usize, usize), (usize, &ChatMessage)> = HashMap::new();
9258 for (id, calls) in calls_by_id {
9259 let Some(results) = results_by_id.get(id) else {
9260 continue;
9261 };
9262 for (call_position, result) in calls.into_iter().zip(results.iter().copied()) {
9263 paired_results.insert(call_position, result);
9264 }
9265 }
9266 let mut i = 0;
9267 while i < messages.len() {
9268 let msg = &messages[i];
9269 if is_replay_excluded(msg) {
9270 i += 1;
9271 continue;
9272 }
9273 match msg.role {
9274 // B4: opencode V1 has no session-level system-PROMPT slot
9275 // either — `User.system` is a per-turn system-PROMPT
9276 // OVERRIDE (§2.1), a different thing from a content-bearing
9277 // `Role::System` message loaded from a real Claude `type:
9278 // "system"` record (`push_claude_system`'s keep-listed
9279 // subtypes). Stuffing real transcript content into
9280 // `User.system` would be a genuine misuse — it overrides the
9281 // replayed system prompt, not just annotates a turn — so
9282 // this instead reuses opencode's own `text` part `synthetic`
9283 // flag (§3.1: "injected by opencode, not typed by user"),
9284 // which is EXACTLY the right existing, non-fabricated
9285 // semantic for "system-originated content presented as a
9286 // user turn": a dedicated `User` message with one
9287 // `synthetic: true` text part, tagged with a
9288 // supercode-namespaced part-`metadata` key so
9289 // `opencode_claude_system_subtype`/`push_opencode_claude_system`
9290 // recognize it on reload and restore `Role::System` +
9291 // `metadata["systemSubtype"]` rather than treating it as a
9292 // real user turn. Content is never fabricated — only
9293 // emitted when non-empty.
9294 Role::System => {
9295 let content = msg.content.clone().unwrap_or_default();
9296 if content.trim().is_empty() {
9297 i += 1;
9298 continue;
9299 }
9300 let subtype = msg
9301 .metadata
9302 .get("systemSubtype")
9303 .cloned()
9304 .unwrap_or_else(|| "local_command".to_string());
9305 let msg_id = opencode_fresh_id("msg", counter);
9306 let part_id = opencode_fresh_id("prt", counter);
9307 let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
9308 let mut info = serde_json::json!({
9309 "id": msg_id,
9310 "sessionID": session_id,
9311 "role": "user",
9312 "time": {"created": timestamp},
9313 });
9314 info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
9315 let parts = vec![serde_json::json!({
9316 "id": part_id,
9317 "sessionID": session_id,
9318 "messageID": msg_id,
9319 "type": "text",
9320 "text": content,
9321 "synthetic": true,
9322 "metadata": {OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: subtype},
9323 })];
9324 out.push(serde_json::json!({"info": info, "parts": parts}));
9325 i += 1;
9326 }
9327 Role::User => {
9328 let msg_id = opencode_fresh_id("msg", counter);
9329 let parts = opencode_user_parts_from_message(msg, &msg_id, session_id, counter);
9330 let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
9331 let mut info = serde_json::json!({
9332 "id": msg_id,
9333 "sessionID": session_id,
9334 "role": "user",
9335 "time": {"created": timestamp},
9336 });
9337 info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
9338 opencode_restore_agent_model_fields(
9339 &mut info, msg, /* is_assistant */ false,
9340 );
9341 set_grok_message_extension(&mut info, self.meta.source, msg);
9342 out.push(serde_json::json!({
9343 "info": info,
9344 "parts": parts,
9345 }));
9346 i += 1;
9347 }
9348 Role::Assistant => {
9349 let msg_id = opencode_fresh_id("msg", counter);
9350 let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
9351 let mut parts = Vec::new();
9352 if let Some(thinking) = msg.metadata.get("thinking") {
9353 let mut part = serde_json::json!({
9354 "id": opencode_fresh_id("prt", counter),
9355 "sessionID": session_id,
9356 "messageID": msg_id,
9357 "type": "reasoning",
9358 "text": thinking,
9359 // Required by OpenCode V1's native reasoning
9360 // schema. A synthesized part has no distinct
9361 // stream start/end, so the source message clock
9362 // is the honest zero-duration span.
9363 "time": {"start": timestamp, "end": timestamp},
9364 });
9365 if let Some(signature) = msg.metadata.get("thinking_signature") {
9366 part["metadata"] = serde_json::json!({
9367 "anthropic": {"signature": signature},
9368 });
9369 }
9370 parts.push(part);
9371 }
9372 if let Some(t) = &msg.content {
9373 if !t.is_empty() {
9374 parts.push(serde_json::json!({
9375 "id": opencode_fresh_id("prt", counter),
9376 "sessionID": session_id,
9377 "messageID": msg_id,
9378 "type": "text",
9379 "text": t,
9380 }));
9381 }
9382 }
9383 // Fold each tool call's result back into ONE `tool`
9384 // part, matched by tool_call_id via the GLOBAL
9385 // `all_results` map built above (not a contiguous scan)
9386 // — a result may be many messages away when other
9387 // assistant turns with their own pending calls
9388 // intervene before it appears.
9389 for (tool_index, tc) in msg.tool_calls().iter().enumerate() {
9390 let input = tc
9391 .function
9392 .parsed_arguments()
9393 .unwrap_or_else(|_| Value::Object(Default::default()));
9394 let paired_result = paired_results.get(&(i, tool_index)).copied();
9395 let state = match paired_result {
9396 Some((_, result)) if crate::reduce::is_tool_error(result) => {
9397 let result_timestamp =
9398 opencode_message_timestamp(result, timestamp_cursor)?;
9399 serde_json::json!({
9400 "status": "error",
9401 "input": input,
9402 "error": result.content.clone().unwrap_or_default(),
9403 "time": {"end": result_timestamp},
9404 })
9405 }
9406 Some((_, result)) => {
9407 let result_timestamp =
9408 opencode_message_timestamp(result, timestamp_cursor)?;
9409 let mut s = serde_json::json!({
9410 "status": "completed",
9411 "input": input,
9412 "output": result.content.clone().unwrap_or_default(),
9413 "title": tc.function.name,
9414 "time": {"end": result_timestamp},
9415 });
9416 // PARITY-11 (nested images): the LOADER already
9417 // reads a completed tool part's
9418 // `state.attachments` back into `content_parts`
9419 // (`opencode_file_image_part`, above) — this is
9420 // the missing WRITE-side inverse. Without it, a
9421 // Claude `tool_result`'s nested image (now
9422 // captured into `content_parts` by
9423 // `extract_tool_result_content`) reached
9424 // `content_parts` on the canonical `ChatMessage`
9425 // but was silently dropped again on re-export to
9426 // OpenCode, because nothing ever read it back
9427 // out. `mime`/`url` shape matches exactly what
9428 // `opencode_file_image_part` expects on reload.
9429 if let Some(cps) = &result.content_parts {
9430 let atts: Vec<Value> = cps
9431 .iter()
9432 .filter(|p| {
9433 p.get("type").and_then(Value::as_str)
9434 == Some("image_url")
9435 })
9436 .filter_map(|p| {
9437 let url = p
9438 .get("image_url")
9439 .and_then(|u| u.get("url"))
9440 .and_then(Value::as_str)?;
9441 let mime = url
9442 .strip_prefix("data:")
9443 .and_then(|r| r.split_once(','))
9444 .map(|(m, _)| m.trim_end_matches(";base64"))
9445 .unwrap_or("application/octet-stream");
9446 Some(serde_json::json!({
9447 "mime": mime,
9448 "url": url,
9449 }))
9450 })
9451 .collect();
9452 if !atts.is_empty() {
9453 s["attachments"] = Value::Array(atts);
9454 }
9455 }
9456 s
9457 }
9458 None => serde_json::json!({"status": "pending", "input": input}),
9459 };
9460 let mut part = serde_json::json!({
9461 "id": opencode_fresh_id("prt", counter),
9462 "sessionID": session_id,
9463 "messageID": msg_id,
9464 "type": "tool",
9465 "callID": tc.id,
9466 "tool": tc.function.name,
9467 "state": state,
9468 });
9469 if let Some((result_position, _)) = paired_result {
9470 part[OPENCODE_SUPERCODE_RESULT_POSITION] =
9471 serde_json::json!(result_position);
9472 }
9473 if paired_result.is_some_and(|(_, result)| {
9474 crate::reduce::tool_outcome(result)
9475 == crate::reduce::ToolOutcome::Unknown
9476 }) {
9477 part[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
9478 }
9479 parts.push(part);
9480 }
9481 let mut info = serde_json::json!({
9482 "id": msg_id,
9483 "sessionID": session_id,
9484 "role": "assistant",
9485 "time": {"created": timestamp},
9486 });
9487 info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
9488 opencode_restore_agent_model_fields(
9489 &mut info, msg, /* is_assistant */ true,
9490 );
9491 set_grok_message_extension(&mut info, self.meta.source, msg);
9492 out.push(serde_json::json!({
9493 "info": info,
9494 "parts": parts,
9495 }));
9496 i += 1;
9497 }
9498 // A Tool message is always folded into its call's assistant
9499 // `tool` part above (via occurrence-aware global pairing, not
9500 // positional adjacency), so it never needs its own entry
9501 // here — just advance past it.
9502 Role::Tool => i += 1,
9503 }
9504 }
9505 Ok(())
9506 }
9507
9508 /// Synthesize a fresh OpenCode export DOCUMENT from the canonical
9509 /// `messages` (T3 cross-format/full synthesis tier — mirrors
9510 /// [`Self::to_pi_jsonl`]'s doc comment: this is NOT the value-lossless
9511 /// native path, which goes through `raw` + `to_native_jsonl(_v2)`
9512 /// instead). Shape: `{info: SessionInfo, messages: [{info, parts}, …]}`
9513 /// (§1.2 — the `opencode export`/`import` interchange shape).
9514 fn to_opencode_jsonl(&self) -> Result<String> {
9515 let mut info = self.synthesized_opencode_info();
9516 let ses_id = info
9517 .get("id")
9518 .and_then(Value::as_str)
9519 .unwrap_or("ses_new")
9520 .to_string();
9521 let mut messages_json: Vec<Value> = Vec::new();
9522 let mut counter: u64 = 0;
9523 let mut timestamp_cursor =
9524 opencode_max_timestamp(&info).unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
9525 self.append_synthesized_opencode_messages(
9526 &mut messages_json,
9527 &self.messages,
9528 &ses_id,
9529 &mut counter,
9530 &mut timestamp_cursor,
9531 )?;
9532 if !messages_json.is_empty() {
9533 Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
9534 }
9535 let doc = serde_json::json!({"info": info, "messages": messages_json});
9536 Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
9537 }
9538
9539 /// A12-style splice for OpenCode (§1.2/§4.2 point 1, S5): replay the
9540 /// imported records **value-equal at their position** in the export
9541 /// doc's `messages[]`/`parts[]` — reconstructed directly from `self.raw`
9542 /// via [`Self::opencode_records_from_raw`], never re-derived from the
9543 /// lossy canonical `messages` — then append freshly synthesized
9544 /// `{info, parts}` objects for the tail via
9545 /// [`Self::append_synthesized_opencode_messages`]. Unlike the
9546 /// line-oriented formats' splice, `out` here is a single export
9547 /// DOCUMENT, not a line stream (§4.2 restates the "prefix verbatim"
9548 /// assertion accordingly: value-equality at position, not byte
9549 /// equality of a line range).
9550 fn to_opencode_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
9551 if self.raw.is_empty() {
9552 return self.to_opencode_jsonl();
9553 }
9554 let (session_info, records) = self.opencode_records_from_raw();
9555 let (_, message_prefix_len) = self.spliced_prefix_lens();
9556
9557 let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
9558 if let Some(id) = session_id {
9559 info["id"] = Value::String(id.to_string());
9560 }
9561 let ses_id_for_new = info
9562 .get("id")
9563 .and_then(Value::as_str)
9564 .unwrap_or("ses_new")
9565 .to_string();
9566
9567 let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
9568 .chain(records.iter().flat_map(|(msg, parts)| {
9569 std::iter::once(opencode_max_timestamp(msg))
9570 .chain(parts.iter().map(opencode_max_timestamp))
9571 }))
9572 .flatten()
9573 .max()
9574 .unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
9575
9576 let mut messages_json: Vec<Value> = records
9577 .into_iter()
9578 .map(|(msg, parts)| serde_json::json!({"info": msg, "parts": parts}))
9579 .collect();
9580 let imported_len = messages_json.len();
9581
9582 let mut counter: u64 = 0;
9583 self.append_synthesized_opencode_messages(
9584 &mut messages_json,
9585 &self.messages[message_prefix_len..],
9586 &ses_id_for_new,
9587 &mut counter,
9588 &mut timestamp_cursor,
9589 )?;
9590 if messages_json.len() > imported_len {
9591 Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
9592 }
9593
9594 let doc = serde_json::json!({"info": info, "messages": messages_json});
9595 Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
9596 }
9597
9598 /// The **required** direct-write fallback (S5): write the imported
9599 /// OpenCode records **verbatim** — excess/unknown keys, part-row
9600 /// timestamps, and `session_diff`/`todo` side-records intact — to a
9601 /// generation-B JSON-file storage tree
9602 /// (`docs/interop/opencode-pi-spec.md` §1.2), the fidelity path
9603 /// `opencode import` cannot provide (S5: import re-decodes through a
9604 /// strict schema and STRIPS excess keys; inserts part rows without
9605 /// `time_created`/`time_updated`, so those reset to `Date.now()`; and
9606 /// has no ingestion path for `session_diff`/`todo` at all).
9607 ///
9608 /// Writes the JSON-FILE layout rather than a live SQLite write
9609 /// specifically to avoid a new `rusqlite`-class dependency on this
9610 /// build's memory-constrained box (see the build report); `session_diff`
9611 /// itself is still JSON-written by upstream even on SQLite installs
9612 /// (§1.3), so this is a real fidelity path, not a fictional one.
9613 ///
9614 /// Returns the `storage/session/<projectID>/` directory written to.
9615 pub fn to_opencode_direct_write(&self, data_root: &Path) -> Result<PathBuf> {
9616 let (session_info, mut records) = self.opencode_records_from_raw();
9617 let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
9618 let ses_id = info
9619 .get("id")
9620 .and_then(Value::as_str)
9621 .unwrap_or("ses_new")
9622 .to_string();
9623 if info.get("id").is_none() {
9624 info["id"] = Value::String(ses_id.clone());
9625 }
9626 let project_id = info
9627 .get("projectID")
9628 .and_then(Value::as_str)
9629 .unwrap_or("global")
9630 .to_string();
9631
9632 // Appended tail (messages produced after import): synthesize fresh
9633 // message/part VALUES via the same T3 synthesis the splice writer
9634 // uses, so continuation turns get files too. Do this BEFORE creating
9635 // any directories: timestamp exhaustion must fail atomically rather
9636 // than leave a partial direct-write tree behind.
9637 let (_, message_prefix_len) = self.spliced_prefix_lens();
9638 let mut counter: u64 = 0;
9639 let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
9640 .chain(records.iter().flat_map(|(msg, parts)| {
9641 std::iter::once(opencode_max_timestamp(msg))
9642 .chain(parts.iter().map(opencode_max_timestamp))
9643 }))
9644 .flatten()
9645 .max()
9646 .unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
9647 let mut appended_json: Vec<Value> = Vec::new();
9648 self.append_synthesized_opencode_messages(
9649 &mut appended_json,
9650 &self.messages[message_prefix_len..],
9651 &ses_id,
9652 &mut counter,
9653 &mut timestamp_cursor,
9654 )?;
9655 if !appended_json.is_empty() {
9656 Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
9657 }
9658 for entry in appended_json {
9659 let msg = entry.get("info").cloned().unwrap_or(Value::Null);
9660 let parts = entry
9661 .get("parts")
9662 .and_then(Value::as_array)
9663 .cloned()
9664 .unwrap_or_default();
9665 records.push((msg, parts));
9666 }
9667
9668 let storage = data_root.join("storage");
9669 let session_dir = storage.join("session").join(&project_id);
9670 std::fs::create_dir_all(&session_dir)?;
9671 std::fs::write(
9672 session_dir.join(format!("{ses_id}.json")),
9673 serde_json::to_string_pretty(&info).unwrap_or_default(),
9674 )?;
9675
9676 let message_dir = storage.join("message").join(&ses_id);
9677 let part_dir = storage.join("part");
9678 std::fs::create_dir_all(&message_dir)?;
9679
9680 for (msg, parts) in &records {
9681 let Some(msg_id) = msg.get("id").and_then(Value::as_str) else {
9682 continue;
9683 };
9684 std::fs::write(
9685 message_dir.join(format!("{msg_id}.json")),
9686 serde_json::to_string_pretty(msg).unwrap_or_default(),
9687 )?;
9688 let this_part_dir = part_dir.join(msg_id);
9689 std::fs::create_dir_all(&this_part_dir)?;
9690 for part in parts {
9691 let Some(part_id) = part.get("id").and_then(Value::as_str) else {
9692 continue;
9693 };
9694 std::fs::write(
9695 this_part_dir.join(format!("{part_id}.json")),
9696 serde_json::to_string_pretty(part).unwrap_or_default(),
9697 )?;
9698 }
9699 }
9700
9701 // Side-records (S5c): session_diff / todo have NO ingestion path via
9702 // `opencode import` at all — the direct write is their only
9703 // fidelity path.
9704 for header in &self.meta.opencode_headers {
9705 let Some(key) = header.get("key").and_then(Value::as_array) else {
9706 continue;
9707 };
9708 let Some(kind) = key.first().and_then(Value::as_str) else {
9709 continue;
9710 };
9711 let value = header.get("value").cloned().unwrap_or(Value::Null);
9712 if !matches!(kind, "session_diff" | "todo") {
9713 continue;
9714 }
9715 let dir = storage.join(kind);
9716 std::fs::create_dir_all(&dir)?;
9717 std::fs::write(
9718 dir.join(format!("{ses_id}.json")),
9719 serde_json::to_string_pretty(&value).unwrap_or_default(),
9720 )?;
9721 }
9722
9723 Ok(session_dir)
9724 }
9725}
9726
9727fn opencode_fresh_id(prefix: &str, counter: &mut u64) -> String {
9728 *counter += 1;
9729 format!("{prefix}_synth{counter:06}")
9730}
9731
9732/// WAVE-2 fidelity item 2: restore the `agent`/`cost`/`finish`/`is_summary`/
9733/// `model`/`tokens` fields onto a synthesized OpenCode `info` record, in the
9734/// EXACT native shape opencode's own loaders (`push_opencode_user` /
9735/// `push_opencode_assistant`, above) parse back out — so an opencode->opencode
9736/// export-doc round-trip (write here, reload via [`Session::from_opencode_str`])
9737/// reconstructs value-identical `ChatMessage.metadata`. Each field is emitted
9738/// ONLY when its metadata key is present (a synthesized continuation turn, or
9739/// a User message that never carried `agent`, stays clean — no spurious
9740/// null/empty fields).
9741///
9742/// - `agent`: plain string on BOTH User (`v1/session.ts:346`) and Assistant
9743/// (`:465`) — `metadata["agent"]` is inserted verbatim by both loaders from
9744/// `msg_value.get("agent")`, so it's re-emitted verbatim here too.
9745/// - `model`: the loaders capture it in TWO DIFFERENT shapes depending on
9746/// role (`docs/interop/research/opencode-fields.md` §3 2a/2b), so the
9747/// inverse must match per-role:
9748/// - User: `push_opencode_user` stores `metadata["model"]` as the
9749/// STRINGIFIED `{providerID, modelID, variant?}` object
9750/// (`msg_value.get("model")...to_string()`) — re-parsed and re-emitted
9751/// as that same object under `"model"`.
9752/// - Assistant: `push_opencode_assistant` stores `metadata["model"]` as
9753/// `"{providerID}/{modelID}"` (from the top-level `providerID`/`modelID`
9754/// fields, joined) — split back on the FIRST `/` (matching `format!`'s
9755/// join; a `modelID` containing further `/`s round-trips correctly since
9756/// `split_once` only consumes the first) and re-emitted as the two
9757/// top-level `providerID`/`modelID` fields the loader actually reads.
9758/// - `is_summary`/`cost`/`finish`/`tokens`: Assistant-only concepts (no such
9759/// fields exist on opencode's `User` schema) — `is_summary` re-expands
9760/// `"true"` back to the native `summary: true` bool (the loader only ever
9761/// sets the metadata key on `Some(true)`, never on absent/false, so the
9762/// inverse never needs to emit `false`); `finish` is a plain string;
9763/// `cost`/`tokens` were captured via `v.to_string()` on the raw JSON
9764/// `Value` (a number and an object respectively), so they're re-parsed
9765/// from that stringified form and re-emitted as the native JSON value —
9766/// NOT as strings — matching `msg_value.get(field)` shape exactly.
9767fn opencode_restore_agent_model_fields(info: &mut Value, msg: &ChatMessage, is_assistant: bool) {
9768 if let Some(agent) = msg.metadata.get("agent") {
9769 info["agent"] = Value::String(agent.clone());
9770 }
9771 if let Some(model) = msg.metadata.get("model") {
9772 if is_assistant {
9773 if let Some((provider, model_id)) = model.split_once('/') {
9774 info["providerID"] = Value::String(provider.to_string());
9775 info["modelID"] = Value::String(model_id.to_string());
9776 }
9777 } else if let Ok(v) = serde_json::from_str::<Value>(model) {
9778 info["model"] = v;
9779 }
9780 }
9781 if !is_assistant {
9782 return;
9783 }
9784 if msg.metadata.get("is_summary").map(String::as_str) == Some("true") {
9785 info["summary"] = Value::Bool(true);
9786 }
9787 if let Some(finish) = msg.metadata.get("finish") {
9788 info["finish"] = Value::String(finish.clone());
9789 }
9790 if let Some(cost) = msg.metadata.get("cost") {
9791 if let Ok(v) = serde_json::from_str::<Value>(cost) {
9792 info["cost"] = v;
9793 }
9794 }
9795 if let Some(tokens) = msg.metadata.get("tokens") {
9796 if let Ok(v) = serde_json::from_str::<Value>(tokens) {
9797 info["tokens"] = v;
9798 }
9799 }
9800}
9801
9802fn opencode_user_parts_from_message(
9803 msg: &ChatMessage,
9804 msg_id: &str,
9805 session_id: &str,
9806 counter: &mut u64,
9807) -> Vec<Value> {
9808 let mut parts = Vec::new();
9809 if let Some(cps) = &msg.content_parts {
9810 for p in cps {
9811 match p.get("type").and_then(Value::as_str) {
9812 Some("text") => {
9813 if let Some(t) = p.get("text").and_then(Value::as_str) {
9814 parts.push(serde_json::json!({
9815 "id": opencode_fresh_id("prt", counter),
9816 "sessionID": session_id,
9817 "messageID": msg_id,
9818 "type": "text",
9819 "text": t,
9820 }));
9821 }
9822 }
9823 Some("image_url") => {
9824 if let Some(url) = p
9825 .get("image_url")
9826 .and_then(|u| u.get("url"))
9827 .and_then(Value::as_str)
9828 {
9829 let mime = url
9830 .strip_prefix("data:")
9831 .and_then(|r| r.split_once(','))
9832 .map(|(m, _)| m.trim_end_matches(";base64"))
9833 .unwrap_or("application/octet-stream");
9834 parts.push(serde_json::json!({
9835 "id": opencode_fresh_id("prt", counter),
9836 "sessionID": session_id,
9837 "messageID": msg_id,
9838 "type": "file",
9839 "mime": mime,
9840 "url": url,
9841 }));
9842 }
9843 }
9844 _ => {}
9845 }
9846 }
9847 } else if let Some(t) = &msg.content {
9848 if !t.is_empty() {
9849 parts.push(serde_json::json!({
9850 "id": opencode_fresh_id("prt", counter),
9851 "sessionID": session_id,
9852 "messageID": msg_id,
9853 "type": "text",
9854 "text": t,
9855 }));
9856 }
9857 }
9858 parts
9859}
9860
9861fn codex_response_item(payload: Value, ts: &str) -> Value {
9862 serde_json::json!({"timestamp": ts, "type": "response_item", "payload": payload})
9863}
9864
9865/// Stamp `payload.metadata.turn_id` when `turn_id` is `Some` (PARITY-6/7,
9866/// see [`Session::write_codex_records`]); a no-op returning `payload`
9867/// untouched when `None`, so the historical byte shape is preserved for
9868/// every record that has no merge ambiguity to disambiguate.
9869fn with_turn_id(mut payload: Value, turn_id: Option<&str>) -> Value {
9870 if let Some(tid) = turn_id {
9871 payload["metadata"] = serde_json::json!({"turn_id": tid});
9872 }
9873 payload
9874}
9875
9876/// Build a Codex `message` response_item's `content` block array from a
9877/// `ChatMessage` — the inverse of [`codex_extract_images`]/`extract_text_content`'s
9878/// parse. When `content_parts` is `None` this MUST reproduce the historical
9879/// single-block shape exactly (IX-5's overriding constraint: a text-only
9880/// message's export stays byte-identical) — only a multimodal message gets
9881/// one `{text_type}` block per non-empty text part plus one native Codex
9882/// `input_image` block (`{"type":"input_image","image_url":<data:URI or
9883/// URL>}` — the Responses-API-shaped image content Codex's own `input_text`/
9884/// `output_text` blocks already follow the family of) per `image_url` part.
9885fn codex_message_content_blocks(text_type: &str, msg: &ChatMessage) -> Value {
9886 match &msg.content_parts {
9887 Some(parts) => {
9888 let mut blocks = Vec::new();
9889 for p in parts {
9890 match p.get("type").and_then(Value::as_str) {
9891 Some("text") => {
9892 if let Some(t) = p.get("text").and_then(Value::as_str) {
9893 if !t.is_empty() {
9894 blocks.push(serde_json::json!({"type": text_type, "text": t}));
9895 }
9896 }
9897 }
9898 Some("image_url") => {
9899 if let Some(url) = p
9900 .get("image_url")
9901 .and_then(|u| u.get("url"))
9902 .and_then(Value::as_str)
9903 {
9904 blocks.push(serde_json::json!({
9905 "type": "input_image",
9906 "image_url": url,
9907 }));
9908 }
9909 }
9910 _ => {}
9911 }
9912 }
9913 Value::Array(blocks)
9914 }
9915 None => {
9916 let text = msg.content.clone().unwrap_or_default();
9917 Value::Array(vec![serde_json::json!({"type": text_type, "text": text})])
9918 }
9919 }
9920}
9921
9922/// PARITY-11 (nested images, honest-residue side): a Codex
9923/// `function_call_output` response_item's `output` field is a BARE STRING
9924/// (real `codex-rs` protocol shape — unlike a `message` response_item, it has
9925/// no structured content array, so [`codex_message_content_blocks`]'s
9926/// `input_image` slot genuinely does not apply here). A nested image captured
9927/// off a Claude `tool_result` (`extract_tool_result_content`,
9928/// `content_parts`) therefore CANNOT be carried through this hop — but rather
9929/// than silently re-emitting the old bare `[image]` marker (indistinguishable
9930/// from a real, intentional annotation and impossible to tell apart from
9931/// "the data survived") or dropping it with zero trace, fold in an honest,
9932/// countable disclosure of exactly how many images were dropped and why —
9933/// same bracketed-note convention as [`UNCONVERTIBLE_IMAGE_MARKER`], applied
9934/// on the WRITE side instead of the read side. `content_parts` being `None`
9935/// (every pre-existing call site, and any tool result with no nested image)
9936/// reproduces the historical `msg.content` text byte-for-byte.
9937fn codex_tool_output_text(msg: &ChatMessage) -> String {
9938 let mut text = msg.content.clone().unwrap_or_default();
9939 if let Some(parts) = &msg.content_parts {
9940 let n = parts
9941 .iter()
9942 .filter(|p| p.get("type").and_then(Value::as_str) == Some("image_url"))
9943 .count();
9944 if n > 0 {
9945 if !text.is_empty() {
9946 text.push('\n');
9947 }
9948 text.push_str(&format!(
9949 "[image: {n} nested image(s) dropped — codex tool output has no \
9950 structured content slot to carry them]"
9951 ));
9952 }
9953 }
9954 text
9955}
9956
9957// ---- Pi writer helpers -----------------------------------------------------
9958
9959/// Emit the pi v3 `session` header line. Frozen (§1.1/§1.3): `version` is
9960/// ALWAYS 3, never a lower value, so pi never rewrites a supercode-emitted
9961/// file in place on first resume (`pi-fields.md` sm:848-850).
9962fn push_pi_header(
9963 out: &mut String,
9964 id: &str,
9965 cwd: &str,
9966 parent_session: Option<&str>,
9967 created_at: Option<&str>,
9968 claude_fork_context_ref: Option<&str>,
9969) {
9970 let mut header = serde_json::json!({
9971 "type": "session",
9972 "version": 3,
9973 "id": id,
9974 "timestamp": created_at.unwrap_or(SYNTH_TS),
9975 "cwd": cwd,
9976 });
9977 if let Some(ps) = parent_session {
9978 header["parentSession"] = Value::String(ps.to_string());
9979 }
9980 // D7: namespaced passthrough field, exactly like the Codex writer's
9981 // `session_meta.payload.claude_fork_context_ref` — pi tolerates unknown
9982 // header keys, and `capture_pi_header` reads this same key back on
9983 // import, so a Claude -> Pi -> Claude round trip still reconstructs the
9984 // fork-context-ref record instead of silently losing it on this hop.
9985 if let Some(raw) = claude_fork_context_ref {
9986 header["claude_fork_context_ref"] =
9987 serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()));
9988 }
9989 push_jsonl(out, &header);
9990}
9991
9992/// A fresh 8-hex entry id, collision-checked against `used` (mirrors pi's own
9993/// `randomUUID().slice(0,8)` + collision check, `pi-fields.md` sm:216-224 —
9994/// deterministic here rather than random, which still satisfies "fresh,
9995/// collision-free" without an extra RNG dependency).
9996fn pi_fresh_id(used: &mut HashSet<String>, counter: &mut u64) -> String {
9997 loop {
9998 *counter += 1;
9999 let h = counter.wrapping_mul(0x9E3779B97F4A7C15);
10000 let id = format!("{:08x}", (h >> 32) as u32);
10001 if used.insert(id.clone()) {
10002 return id;
10003 }
10004 }
10005}
10006
10007/// Parse a `data:<mime>[;base64],<data>` URI back into `(mime, data)` — the
10008/// inverse of the loader's `data:{mime};base64,{data}` construction.
10009fn parse_data_uri(url: &str) -> Option<(String, String)> {
10010 let rest = url.strip_prefix("data:")?;
10011 let (meta, data) = rest.split_once(',')?;
10012 let mime = meta.strip_suffix(";base64").unwrap_or(meta);
10013 Some((mime.to_string(), data.to_string()))
10014}
10015
10016/// Rebuild a pi `(TextContent|ImageContent)[]` (or bare string) content value
10017/// from a `ChatMessage`'s `content`/`content_parts` — shared by `user` and
10018/// `toolResult` entries (both use the identical union on the wire).
10019fn pi_content_value(msg: &ChatMessage) -> Value {
10020 if let Some(parts) = &msg.content_parts {
10021 let mut arr = Vec::new();
10022 for p in parts {
10023 match p.get("type").and_then(Value::as_str) {
10024 Some("text") => {
10025 if let Some(t) = p.get("text").and_then(Value::as_str) {
10026 arr.push(serde_json::json!({"type": "text", "text": t}));
10027 }
10028 }
10029 Some("image_url") => {
10030 if let Some(url) = p
10031 .get("image_url")
10032 .and_then(|u| u.get("url"))
10033 .and_then(Value::as_str)
10034 {
10035 if let Some((mime, data)) = parse_data_uri(url) {
10036 arr.push(
10037 serde_json::json!({"type": "image", "mimeType": mime, "data": data}),
10038 );
10039 }
10040 }
10041 }
10042 _ => {}
10043 }
10044 }
10045 Value::Array(arr)
10046 } else {
10047 Value::String(msg.content.clone().unwrap_or_default())
10048 }
10049}
10050
10051fn pi_assistant_content_value(msg: &ChatMessage) -> Value {
10052 let mut arr = Vec::new();
10053 if let Some(thinking) = msg.metadata.get("thinking") {
10054 let mut block = serde_json::json!({"type": "thinking", "thinking": thinking});
10055 if let Some(sig) = msg.metadata.get("thinking_signature") {
10056 block["thinkingSignature"] = Value::String(sig.clone());
10057 }
10058 if msg.metadata.get("pi_thinking_redacted").map(String::as_str) == Some("true") {
10059 block["redacted"] = Value::Bool(true);
10060 }
10061 arr.push(block);
10062 }
10063 if let Some(text) = &msg.content {
10064 if !text.is_empty() {
10065 let mut block = serde_json::json!({"type": "text", "text": text});
10066 if let Some(sig) = msg.metadata.get("pi_text_signature") {
10067 block["textSignature"] = Value::String(sig.clone());
10068 }
10069 arr.push(block);
10070 }
10071 }
10072 for tc in msg.tool_calls() {
10073 let args = tc
10074 .function
10075 .parsed_arguments()
10076 .unwrap_or_else(|_| Value::Object(Default::default()));
10077 let mut block = serde_json::json!({
10078 "type": "toolCall",
10079 "id": tc.id,
10080 "name": tc.function.name,
10081 "arguments": args,
10082 });
10083 if let Some(sig) = msg.metadata.get("pi_thought_signature") {
10084 block["thoughtSignature"] = Value::String(sig.clone());
10085 }
10086 arr.push(block);
10087 }
10088 Value::Array(arr)
10089}
10090
10091fn default_pi_usage() -> Value {
10092 serde_json::json!({
10093 "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "totalTokens": 0,
10094 "cost": {"input": 0.0, "output": 0.0, "cacheRead": 0.0, "cacheWrite": 0.0, "total": 0.0},
10095 })
10096}
10097
10098fn is_tool_error_flag(msg: &ChatMessage) -> bool {
10099 msg.metadata.get("pi_is_error").map(String::as_str) == Some("true")
10100 || crate::reduce::is_tool_error(msg)
10101}
10102
10103#[cfg(test)]
10104mod tests {
10105 use super::{opencode_message_timestamp, parent_tool_use_index, Session, SessionFormat};
10106 use crate::message::ChatMessage;
10107
10108 #[test]
10109 fn malformed_discriminated_native_turn_is_counted_as_parse_loss() {
10110 let base = Session::from_native_messages(Vec::new());
10111 let mut native = base.to_native_jsonl_v2(&[]);
10112 native.push_str("{\"supercode_turn\":1}\n");
10113
10114 let parsed = Session::from_native_str(&native).unwrap();
10115 assert_eq!(parsed.parse_error_lines, 1);
10116 assert!(parsed.messages.is_empty());
10117 assert_eq!(
10118 parsed.raw.last().map(String::as_str),
10119 Some("{\"supercode_turn\":1}")
10120 );
10121 }
10122
10123 #[test]
10124 fn spliced_export_refuses_a_native_wrapper_with_parse_loss() {
10125 let imported = Session::from_claude_code_str(
10126 r#"{"type":"user","sessionId":"s","cwd":"/tmp","message":{"role":"user","content":"hi"}}"#,
10127 )
10128 .unwrap();
10129 let mut native = imported.to_native_jsonl_v2(&[ChatMessage::assistant("continued")]);
10130 native.push_str("{\"supercode_turn\":1}\n");
10131
10132 let parsed = Session::from_native_str(&native).unwrap();
10133 let error = parsed
10134 .to_jsonl_spliced(SessionFormat::ClaudeCode, None)
10135 .unwrap_err();
10136 assert!(error.to_string().contains("parse loss"), "{error}");
10137 }
10138
10139 #[test]
10140 fn sidecar_loader_requires_a_supported_native_header() {
10141 for malformed in [
10142 "",
10143 "not-json\n",
10144 "{}\n",
10145 "{\"supercode_native\":2}\n",
10146 "{\"supercode_native\":99,\"source\":\"native\"}\n",
10147 ] {
10148 let error = Session::from_sidecar_str(malformed).unwrap_err();
10149 assert!(error.to_string().contains("sidecar header"), "{error}");
10150 }
10151 }
10152
10153 #[test]
10154 fn opencode_synthetic_timestamp_fails_closed_at_i64_max() {
10155 let msg = ChatMessage::user("continuation");
10156 let mut cursor = i64::MAX - 1;
10157 assert_eq!(
10158 opencode_message_timestamp(&msg, &mut cursor).unwrap(),
10159 i64::MAX
10160 );
10161 let err = opencode_message_timestamp(&msg, &mut cursor).unwrap_err();
10162 assert!(err.to_string().contains("after i64::MAX"));
10163 assert_eq!(cursor, i64::MAX, "overflow must not wrap or mutate state");
10164 }
10165
10166 /// Pin of the single-pass indexer against the committed fixture (SUP-21).
10167 /// `crates/core/tests/fixtures/claude_code_session.jsonl` line 8 carries a
10168 /// `tool_result` block with `tool_use_id = "toolu_01SYjhg9qRCzUWY2GTa3iazQ"`
10169 /// whose serialized text mentions agent id `ad8dc6cf98b49eea6` — the exact
10170 /// value the old per-agent `parent_tool_use_for_agent(main_text,
10171 /// "ad8dc6cf98b49eea6")` returned before this refactor. An id absent from
10172 /// the transcript must still map to nothing.
10173 #[test]
10174 fn parent_tool_use_index_matches_known_fixture_linkage() {
10175 let main_text = std::fs::read_to_string(
10176 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
10177 .join("tests/fixtures/claude_code_session.jsonl"),
10178 )
10179 .expect("fixture present");
10180
10181 let ids = vec![
10182 "ad8dc6cf98b49eea6".to_string(),
10183 "no-such-agent-id".to_string(),
10184 ];
10185 let index = parent_tool_use_index(&main_text, &ids);
10186
10187 assert_eq!(
10188 index.get("ad8dc6cf98b49eea6").map(String::as_str),
10189 Some("toolu_01SYjhg9qRCzUWY2GTa3iazQ"),
10190 "known agent id must resolve to the pinned parent tool_use_id"
10191 );
10192 assert_eq!(
10193 index.get("no-such-agent-id"),
10194 None,
10195 "unknown agent id must yield no entry (best-effort None)"
10196 );
10197 }
10198
10199 #[test]
10200 fn parent_tool_use_index_empty_ids_returns_empty_map() {
10201 let index = parent_tool_use_index("irrelevant text", &[]);
10202 assert!(index.is_empty());
10203 }
10204}