Skip to main content

supercode_interchange/session/
claude_code.rs

1//! Claude Code session codec: loaders, writers and native-record helpers.
2
3use super::*;
4
5impl Session {
6    /// Load a Claude Code transcript from a file, attaching any subagent
7    /// (`Task`) sub-conversations stored alongside it.
8    pub fn from_claude_code(path: impl AsRef<Path>) -> Result<Session> {
9        Self::from_claude_code_with_fidelity(path, Fidelity::ByteLossless)
10    }
11
12    /// [`Session::from_claude_code`] at a declared [`Fidelity`] — see
13    /// [`Session::load_with_fidelity`] for what [`Fidelity::Semantic`] buys.
14    pub fn from_claude_code_with_fidelity(
15        path: impl AsRef<Path>,
16        fidelity: Fidelity,
17    ) -> Result<Session> {
18        let text = std::fs::read_to_string(path.as_ref())?;
19        let mut session = Self::from_claude_code_str_with_fidelity(&text, fidelity)?;
20        session.attach_claude_subagents(path.as_ref(), &text, fidelity)?;
21        Ok(session)
22    }
23
24    /// Discover and load `<session>/subagents/agent-*.jsonl` files for a
25    /// Claude Code transcript at `main_path`, linking each back to the parent
26    /// `Task` tool call via the agent id embedded in the parent's tool result.
27    pub(super) fn attach_claude_subagents(
28        &mut self,
29        main_path: &Path,
30        main_text: &str,
31        fidelity: Fidelity,
32    ) -> Result<()> {
33        let Some(dir) = subagents_dir_for(main_path) else {
34            return Ok(());
35        };
36        let entries = std::fs::read_dir(&dir).map_err(|error| {
37            crate::Error::Other(format!(
38                "failed to enumerate Claude subagents at {}: {error}",
39                dir.display()
40            ))
41        })?;
42        let mut files = Vec::new();
43        for entry in entries {
44            let entry = entry.map_err(|error| {
45                crate::Error::Other(format!(
46                    "failed to enumerate Claude subagents at {}: {error}",
47                    dir.display()
48                ))
49            })?;
50            let path = entry.path();
51            if path.extension().and_then(|extension| extension.to_str()) == Some("jsonl") {
52                files.push(path);
53            }
54        }
55        files.sort();
56
57        // Phase 1 — collect each subagent + its recovered agent id, without
58        // touching the main transcript yet.
59        let mut collected: Vec<(Session, Option<String>)> = Vec::with_capacity(files.len());
60        for file in files {
61            let text = read_utf8_or_diagnose(&file).map_err(|error| {
62                crate::Error::Other(format!(
63                    "failed to read Claude subagent {}: {error}",
64                    file.display()
65                ))
66            })?;
67            let sub = match Self::from_claude_code_str_with_fidelity(&text, fidelity) {
68                Ok(sub) => sub,
69                // A read-only VIEW keeps the main conversation rather than
70                // losing the whole session to one unreconstructable child;
71                // the skip is named, not silent. Every stricter fidelity
72                // still propagates the child's failure.
73                Err(error) if fidelity.tolerates_residue() => {
74                    self.load_residue.push(format!(
75                        "Claude subagent {} could not be reconstructed ({error}); it was omitted from this view",
76                        file.display()
77                    ));
78                    continue;
79                }
80                Err(error) => {
81                    return Err(crate::Error::Other(format!(
82                        "failed to reconstruct Claude subagent {}: {error}",
83                        file.display()
84                    )))
85                }
86            };
87            // agentId: prefer the file's own record, fall back to the filename stem.
88            let agent_id = first_agent_id(&text).or_else(|| {
89                file.file_stem()
90                    .and_then(|s| s.to_str())
91                    .map(|s| s.trim_start_matches("agent-").to_string())
92            });
93            collected.push((sub, agent_id));
94        }
95
96        // Phase 2 — single pass over the main transcript to index every
97        // requested agent id at once, then assign each subagent's parent by
98        // an O(1) lookup.
99        let agent_ids: Vec<String> = collected.iter().filter_map(|(_, id)| id.clone()).collect();
100        let index = parent_tool_use_index(main_text, &agent_ids);
101
102        for (mut sub, agent_id) in collected {
103            sub.meta.parent_tool_use_id = agent_id.as_ref().and_then(|id| index.get(id).cloned());
104            sub.meta.agent_id = agent_id;
105            self.subagents.push(sub);
106        }
107        Ok(())
108    }
109
110    /// Parse a Claude Code transcript from an in-memory JSONL string.
111    pub fn from_claude_code_str(jsonl: &str) -> Result<Session> {
112        Self::from_claude_code_str_with_fidelity(jsonl, Fidelity::ByteLossless)
113    }
114
115    /// [`Session::from_claude_code_str`] at a declared [`Fidelity`] — see
116    /// [`Session::load_with_fidelity`] for what [`Fidelity::Semantic`] buys.
117    pub fn from_claude_code_str_with_fidelity(jsonl: &str, fidelity: Fidelity) -> Result<Session> {
118        let mut meta = SessionMeta::new(SessionSource::ClaudeCode);
119        let mut messages = Vec::new();
120        // IX-1: `raw` is captured STRICT-VERBATIM (blank lines, CRLF, trailing
121        // whitespace all preserved) — separate from the blank-skipping
122        // `non_empty_lines` walk just below, which still parses records only
123        // (a blank line is not a JSON record and must not become one).
124        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(jsonl);
125        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
126        // PARITY-15: a malformed/truncated line is still tolerated (a
127        // single bad line must not make an otherwise-healthy multi-
128        // thousand-line session unloadable) — but it's no longer INVISIBLE.
129        let mut parse_error_lines = 0usize;
130        let mut index = ClaudeReplayIndex::default();
131
132        // Claude transcripts are append-only trees, not linear chat logs.
133        // Build a lightweight graph index first so normalization sees the
134        // same single active, post-compaction branch Claude Code would
135        // resume. `raw` above deliberately remains the complete source.
136        for (line_index, line) in raw_lines.iter().enumerate() {
137            if line.trim().is_empty() {
138                continue;
139            }
140            let v: Value = match serde_json::from_str(line) {
141                Ok(v) => v,
142                Err(_) => {
143                    parse_error_lines += 1; // tolerate stray/corrupt lines
144                    continue;
145                }
146            };
147            capture_claude_meta(&v, &mut meta, line)?;
148            index.observe(line_index, &v)?;
149        }
150
151        let ClaudeReplaySelection {
152            lines: replay_lines,
153            residue: load_residue,
154        } = index.select_lines(fidelity)?;
155        let mut pending_assistant: Option<Value> = None;
156
157        // PARITY-23 capture pass: scan EVERY raw line (not just replay
158        // selections) for claude-code residue records, so a foreign hop can
159        // carry them and the return hop can restore them exactly.
160        for (record_index, line) in raw_lines.iter().enumerate() {
161            if let Ok(record) = serde_json::from_str::<Value>(line) {
162                capture_claude_residue(&mut meta, record_index, line, &record);
163            }
164        }
165
166        for line_index in replay_lines {
167            let line = raw_lines[line_index];
168            let v: Value = serde_json::from_str(line).map_err(crate::Error::Decode)?;
169
170            if v.get("type").and_then(Value::as_str) == Some("assistant") {
171                if v.get("isApiErrorMessage").and_then(Value::as_bool) == Some(true) {
172                    flush_claude_assistant(&mut pending_assistant, &mut messages);
173                    continue;
174                }
175                if let Some(pending) = pending_assistant.as_mut() {
176                    if claude_assistant_message_id(pending).is_some_and(|message_id| {
177                        claude_assistant_message_id(&v) == Some(message_id)
178                    }) {
179                        merge_claude_assistant_chunk(pending, &v);
180                        continue;
181                    }
182                    flush_claude_assistant(&mut pending_assistant, &mut messages);
183                }
184                pending_assistant = Some(v);
185                continue;
186            }
187
188            flush_claude_assistant(&mut pending_assistant, &mut messages);
189
190            // WAVE-2 item 1: every Claude Code record carries a real
191            // top-level `timestamp` (ISO-8601) — provenance stamping below
192            // attaches it to every canonical `ChatMessage` this line
193            // produces, together with the record UUID and assistant model.
194            // `entry(...).or_insert_with` preserves any more-precise value a
195            // role-specific loader already supplied.
196            let before = messages.len();
197            match v.get("type").and_then(Value::as_str) {
198                Some("user") => push_claude_user(&v, &mut messages),
199                Some("assistant") => push_claude_assistant(&v, &mut messages),
200                Some("attachment") => push_claude_attachment(&v, &mut messages),
201                Some("system") => push_claude_system(&v, &mut messages),
202                _ => {} // mode, queue-operation, ... — skip
203            }
204            // UUID/model provenance remains meaningful even for legacy
205            // records that predate Claude Code's timestamp field.
206            capture_claude_record_provenance(&v, &mut messages[before..]);
207            restore_single_grok_message(&v, &mut messages[before..]);
208        }
209        flush_claude_assistant(&mut pending_assistant, &mut messages);
210
211        reorder_tool_results_after_calls(&mut messages);
212        ensure_tool_results_paired(&mut messages);
213        let imported_message_count = Some(messages.len());
214        Ok(Session {
215            meta,
216            messages,
217            subagents: Vec::new(),
218            raw,
219            raw_trailing_newline,
220            imported_message_count,
221            // Claude Code is line-oriented: `raw` is split directly out of
222            // the source text (strict-verbatim, IX-1).
223            raw_is_verbatim: true,
224            parse_error_lines,
225            load_residue,
226        })
227    }
228}
229
230// ---- Claude Code ----------------------------------------------------------
231
232/// The `subagents/` directory for a Claude Code transcript at `<dir>/<stem>.jsonl`
233/// is `<dir>/<stem>/subagents/`. Returns it only if it exists.
234fn subagents_dir_for(main_path: &Path) -> Option<PathBuf> {
235    let dir = main_path.parent()?;
236    let stem = main_path.file_stem()?.to_str()?;
237    let candidate = dir.join(stem).join("subagents");
238    candidate.is_dir().then_some(candidate)
239}
240
241/// The first `agentId` recorded in a subagent transcript.
242fn first_agent_id(jsonl: &str) -> Option<String> {
243    for line in non_empty_lines(jsonl) {
244        if let Ok(v) = serde_json::from_str::<Value>(line) {
245            if let Some(id) = v.get("agentId").and_then(Value::as_str) {
246                return Some(id.to_string());
247            }
248        }
249    }
250    None
251}
252
253/// Find the `tool_use_id` of each parent `Task` call that spawned one of
254/// `agent_ids`, by locating the parent transcript's `tool_result` whose
255/// serialized content mentions the agent id. Best effort: an id with no
256/// qualifying match is simply absent from the returned map.
257///
258/// Single pass over `main_text` — each line is parsed at most once,
259/// regardless of how many agent ids are being sought — with each id's result
260/// reproducing exactly what a per-id scan-and-parse-per-hit-line search would
261/// return: the first line (in file order) whose raw text contains the id and
262/// which — the first qualifying `tool_result` block in that line, in block
263/// order — has a string `tool_use_id` and a serialized form that also
264/// contains the id. A `tool_result` block matching on raw-line/serialized
265/// containment but lacking a `tool_use_id` yields nothing for that id and
266/// does not shadow a later match.
267pub(super) fn parent_tool_use_index(
268    main_text: &str,
269    agent_ids: &[String],
270) -> HashMap<String, String> {
271    let mut index: HashMap<String, String> = HashMap::new();
272    if agent_ids.is_empty() {
273        return index;
274    }
275
276    for line in non_empty_lines(main_text) {
277        if index.len() == agent_ids.len() {
278            break;
279        }
280        // Cheap prefilter: every match this function can ever return comes
281        // from a block whose raw line carries the literal JSON string value
282        // `tool_result` (no JSON-escape variants of that ASCII literal).
283        if !line.contains("tool_result") {
284            continue;
285        }
286        let still_unmapped: Vec<&String> = agent_ids
287            .iter()
288            .filter(|id| !index.contains_key(id.as_str()))
289            .collect();
290        if still_unmapped.is_empty() {
291            break;
292        }
293        let Ok(v) = serde_json::from_str::<Value>(line) else {
294            continue;
295        };
296        let content = v.get("message").and_then(|m| m.get("content"));
297        let Some(Value::Array(blocks)) = content else {
298            continue;
299        };
300        for b in blocks {
301            if b.get("type").and_then(Value::as_str) != Some("tool_result") {
302                continue;
303            }
304            let Some(tool_use_id) = b.get("tool_use_id").and_then(Value::as_str) else {
305                continue;
306            };
307            let block_str = b.to_string();
308            for id in &still_unmapped {
309                if index.contains_key(id.as_str()) {
310                    continue;
311                }
312                if line.contains(id.as_str()) && block_str.contains(id.as_str()) {
313                    index.insert((*id).clone(), tool_use_id.to_string());
314                }
315            }
316        }
317    }
318
319    index
320}
321
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323enum ClaudeReplayKind {
324    User,
325    Assistant,
326    Attachment,
327    System,
328}
329
330impl ClaudeReplayKind {
331    fn is_conversation(self) -> bool {
332        matches!(self, Self::User | Self::Assistant)
333    }
334}
335
336#[derive(Debug, Clone)]
337struct ClaudeReplayNode {
338    line_index: usize,
339    uuid: String,
340    parent_uuid: Option<String>,
341    kind: ClaudeReplayKind,
342    is_sidechain: bool,
343    assistant_message_id: Option<String>,
344    is_tool_result: bool,
345    compact: Option<ClaudeCompactBoundary>,
346}
347
348#[derive(Debug, Clone)]
349struct ClaudeCompactBoundary {
350    anchor_uuid: Option<String>,
351    preserved_uuids: Vec<String>,
352    preserved_segment: Option<(String, String)>,
353}
354
355/// One projection of a Claude transcript graph: the source lines to replay,
356/// plus whatever the projection had to give up to produce them (always empty
357/// below [`Fidelity::Semantic`], which is the only level that degrades
358/// instead of failing).
359#[derive(Debug, Default)]
360struct ClaudeReplaySelection {
361    lines: Vec<usize>,
362    residue: Vec<String>,
363}
364
365#[derive(Debug, Default)]
366struct ClaudeReplayIndex {
367    nodes: Vec<ClaudeReplayNode>,
368    by_uuid: HashMap<String, usize>,
369    segment_anchors: HashSet<String>,
370    last_prompt: Option<(String, bool)>,
371    linear_lines: Vec<usize>,
372}
373
374impl ClaudeReplayIndex {
375    fn observe(&mut self, line_index: usize, v: &Value) -> Result<()> {
376        if v.get("type").and_then(Value::as_str) == Some("last-prompt") {
377            if let Some(leaf) = v.get("leafUuid").and_then(Value::as_str) {
378                self.last_prompt = Some((
379                    leaf.to_string(),
380                    v.get("explicit").and_then(Value::as_bool) == Some(true),
381                ));
382            }
383            return Ok(());
384        }
385
386        // A fork-context-ref is a real Claude graph anchor, but not a replay
387        // message. Its child is the first conversational record in the
388        // exported fork, so reaching this UUID terminates the locally
389        // replayable segment rather than indicating a broken parent edge.
390        if v.get("type").and_then(Value::as_str) == Some("fork-context-ref") {
391            if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
392                self.segment_anchors.insert(uuid.to_string());
393            }
394            return Ok(());
395        }
396
397        let kind = match v.get("type").and_then(Value::as_str) {
398            Some("user") => ClaudeReplayKind::User,
399            Some("assistant") => ClaudeReplayKind::Assistant,
400            Some("attachment") => ClaudeReplayKind::Attachment,
401            Some("system") => ClaudeReplayKind::System,
402            _ => return Ok(()),
403        };
404        self.linear_lines.push(line_index);
405        let Some(uuid) = v.get("uuid").and_then(Value::as_str) else {
406            return Ok(());
407        };
408        if self.by_uuid.contains_key(uuid) {
409            return Err(claude_replay_error(format!(
410                "duplicate uuid `{uuid}` in Claude transcript"
411            )));
412        }
413
414        let compact = (kind == ClaudeReplayKind::System
415            && v.get("subtype").and_then(Value::as_str) == Some("compact_boundary"))
416        .then(|| ClaudeCompactBoundary::from_value(v));
417        let assistant_message_id = (kind == ClaudeReplayKind::Assistant)
418            .then(|| claude_assistant_message_id(v).map(str::to_string))
419            .flatten();
420        let is_tool_result = kind == ClaudeReplayKind::User
421            && v.get("message")
422                .and_then(|m| m.get("content"))
423                .and_then(Value::as_array)
424                .is_some_and(|blocks| {
425                    blocks
426                        .iter()
427                        .any(|b| b.get("type").and_then(Value::as_str) == Some("tool_result"))
428                });
429        let node = ClaudeReplayNode {
430            line_index,
431            uuid: uuid.to_string(),
432            parent_uuid: v
433                .get("parentUuid")
434                .and_then(Value::as_str)
435                .map(str::to_string),
436            kind,
437            is_sidechain: v.get("isSidechain").and_then(Value::as_bool) == Some(true),
438            assistant_message_id,
439            is_tool_result,
440            compact,
441        };
442        self.by_uuid.insert(uuid.to_string(), self.nodes.len());
443        self.nodes.push(node);
444        Ok(())
445    }
446
447    /// Project the transcript at `fidelity`.
448    ///
449    /// Below [`Fidelity::Semantic`] this is the STRICT projection every
450    /// continuation, transfer and export path depends on: reconstruct
451    /// Claude's own single active post-compaction branch, or fail naming what
452    /// could not be reconstructed.
453    ///
454    /// [`Fidelity::Semantic`] is the read-only VIEW mode. A Claude transcript
455    /// that has been compacted, summarized, or resumed across files routinely
456    /// contains a live record whose `parentUuid` names a record that is no
457    /// longer on disk. Strict projection rightly refuses — a continuation
458    /// built on a guessed graph is silent loss — but a VIEW does not need a
459    /// continuation, so this mode anchors each dangling edge as a segment
460    /// root, projects every severed segment exactly as the active branch is
461    /// projected, splices them back together in transcript order, and names
462    /// every degradation in the returned residue instead of erroring.
463    fn select_lines(mut self, fidelity: Fidelity) -> Result<ClaudeReplaySelection> {
464        let lenient = fidelity.tolerates_residue();
465        let mut residue = Vec::new();
466        if self.nodes.is_empty() {
467            // Older exports and many hand-authored compatibility fixtures do
468            // not carry Claude's `uuid`/`parentUuid` graph fields. There is no
469            // branch information to project in that shape, so preserve the
470            // historical linear normalization behavior. Native graph-bearing
471            // transcripts always take the projection below.
472            return Ok(ClaudeReplaySelection {
473                lines: self.linear_lines,
474                residue,
475            });
476        }
477        if lenient {
478            self.anchor_dangling_parents(&mut residue);
479        }
480        // Last resort for a VIEW: a transcript whose graph is unprojectable
481        // for some OTHER reason (a cycle, an unresolvable compact boundary)
482        // still renders as the file's own record order. A read-only mirror
483        // that cannot open a session at all is the defect this mode exists
484        // to remove, so `Semantic` never returns an error.
485        let fallback = lenient.then(|| self.linear_lines.clone());
486        match self.project(lenient, &mut residue) {
487            Ok(lines) => Ok(ClaudeReplaySelection { lines, residue }),
488            Err(error) => match fallback {
489                Some(lines) => {
490                    residue.push(format!(
491                        "the Claude record graph could not be projected ({error}); \
492                         every record was stitched in transcript order instead"
493                    ));
494                    Ok(ClaudeReplaySelection { lines, residue })
495                }
496                None => Err(error),
497            },
498        }
499    }
500
501    /// Turn every edge that points outside the transcript into a segment
502    /// root, naming the dangling uuids as residue.
503    ///
504    /// A `fork-context-ref` anchor is already a declared segment boundary,
505    /// not a break, so it is left alone.
506    fn anchor_dangling_parents(&mut self, residue: &mut Vec<String>) {
507        let mut dangling = Vec::new();
508        for idx in 0..self.nodes.len() {
509            let Some(parent) = self.nodes[idx].parent_uuid.as_deref() else {
510                continue;
511            };
512            if self.by_uuid.contains_key(parent) || self.segment_anchors.contains(parent) {
513                continue;
514            }
515            dangling.push(format!("`{}` → `{parent}`", self.nodes[idx].uuid));
516            self.nodes[idx].parent_uuid = None;
517        }
518        if dangling.is_empty() {
519            return;
520        }
521        const NAMED: usize = 8;
522        let total = dangling.len();
523        let overflow = total.saturating_sub(NAMED);
524        dangling.truncate(NAMED);
525        let mut listed = dangling.join(", ");
526        if overflow > 0 {
527            listed.push_str(&format!(", and {overflow} more"));
528        }
529        residue.push(format!(
530            "{total} Claude record(s) reference a parentUuid absent from the transcript and were \
531             anchored as segment roots: {listed}"
532        ));
533    }
534
535    fn project(&mut self, lenient: bool, residue: &mut Vec<String>) -> Result<Vec<usize>> {
536        let mut retained = vec![true; self.nodes.len()];
537        if let Some(boundary_index) = self.nodes.iter().rposition(|n| n.compact.is_some()) {
538            let parents: Option<Vec<Option<String>>> = lenient.then(|| {
539                self.nodes
540                    .iter()
541                    .map(|node| node.parent_uuid.clone())
542                    .collect()
543            });
544            if let Err(error) = self.apply_latest_compaction(boundary_index, &mut retained) {
545                let Some(parents) = parents else {
546                    return Err(error);
547                };
548                // The boundary rewrites parents as it goes, so restore the
549                // graph it half-edited before continuing without it.
550                for (node, parent) in self.nodes.iter_mut().zip(parents) {
551                    node.parent_uuid = parent;
552                }
553                retained.iter_mut().for_each(|keep| *keep = true);
554                residue.push(format!(
555                    "the latest Claude compact boundary could not be projected ({error}); \
556                     no pre-compaction record was pruned from this view"
557                ));
558            }
559        }
560        let sidechain_only = self
561            .nodes
562            .iter()
563            .enumerate()
564            .filter(|(idx, node)| retained[*idx] && node.kind.is_conversation())
565            .all(|(_, node)| node.is_sidechain);
566
567        let explicit_leaf = self
568            .last_prompt
569            .as_ref()
570            .filter(|(_, explicit)| *explicit)
571            .and_then(|(uuid, _)| self.by_uuid.get(uuid).copied())
572            .filter(|idx| retained[*idx]);
573        let newest_non_sidechain = self
574            .nodes
575            .iter()
576            .enumerate()
577            .rev()
578            .find(|(idx, node)| retained[*idx] && !node.is_sidechain)
579            .map(|(idx, _)| idx);
580        // Dedicated Claude subagent transcripts are sidechains by design:
581        // every record, including their root user prompt, has
582        // `isSidechain:true`. When there is no main-chain candidate, resume
583        // the newest retained sidechain leaf instead of rejecting the child.
584        let newest_sidechain = self
585            .nodes
586            .iter()
587            .enumerate()
588            .rev()
589            .find(|(idx, node)| retained[*idx] && node.is_sidechain && node.kind.is_conversation())
590            .map(|(idx, _)| idx);
591        let mut active = explicit_leaf
592            .or(newest_non_sidechain)
593            .or(newest_sidechain)
594            .ok_or_else(|| claude_replay_error("no Claude record remains after compaction"))?;
595
596        // Metadata descendants such as turn_duration are leaves in the raw
597        // graph. Claude resumes from their nearest user/assistant ancestor,
598        // then appends those descendants to the reconstructed chain.
599        let mut seeking = HashSet::new();
600        while !self.nodes[active].kind.is_conversation() {
601            if !seeking.insert(active) {
602                return Err(claude_replay_error(
603                    "cycle while resolving active Claude leaf",
604                ));
605            }
606            active = self.parent_index(active, &retained)?;
607        }
608
609        let mut segments =
610            vec![self.project_segment(active, &retained, sidechain_only, lenient)?];
611        if lenient {
612            for leaf in self.severed_segment_leaves(active, &retained) {
613                segments.push(self.project_segment(leaf, &retained, sidechain_only, lenient)?);
614            }
615            if segments.len() > 1 {
616                residue.push(format!(
617                    "{} conversation segments were stitched in transcript order because the \
618                     Claude record graph is severed",
619                    segments.len()
620                ));
621            }
622        }
623        // Each segment keeps its own reconstructed order; the segments
624        // themselves are spliced by where they start in the file.
625        segments.retain(|segment| !segment.is_empty());
626        segments.sort_by_key(|segment| {
627            segment
628                .iter()
629                .map(|idx| self.nodes[*idx].line_index)
630                .min()
631                .unwrap_or(usize::MAX)
632        });
633        let mut ordered = Vec::new();
634        let mut placed = HashSet::new();
635        for idx in segments.into_iter().flatten() {
636            if placed.insert(idx) {
637                ordered.push(idx);
638            }
639        }
640
641        self.recover_parallel_assistant_chunks(ordered, &retained)
642            .map(|indices| {
643                indices
644                    .into_iter()
645                    .map(|idx| self.nodes[idx].line_index)
646                    .collect()
647            })
648    }
649
650    /// Reconstruct one segment: `leaf`'s parent chain, oldest-first, plus the
651    /// non-conversation descendants rooted at it.
652    fn project_segment(
653        &self,
654        leaf: usize,
655        retained: &[bool],
656        sidechain_only: bool,
657        lenient: bool,
658    ) -> Result<Vec<usize>> {
659        let mut reversed = Vec::new();
660        let mut seen = HashSet::new();
661        let mut cursor = Some(leaf);
662        while let Some(idx) = cursor {
663            if !seen.insert(idx) {
664                return Err(claude_replay_error(format!(
665                    "cycle in active Claude parentUuid chain at `{}`",
666                    self.nodes[idx].uuid
667                )));
668            }
669            reversed.push(idx);
670            cursor = match self.nodes[idx].parent_uuid.as_deref() {
671                Some(parent) => match self.by_uuid.get(parent).copied() {
672                    Some(parent) => Some(parent),
673                    None if self.segment_anchors.contains(parent) => None,
674                    // Claude can resume a background child in-place while
675                    // retaining only the new segment in that child's JSONL.
676                    // Its first record then points to a UUID not present in
677                    // the sidechain file. That external edge is a segment
678                    // boundary, not corruption; the complete source remains
679                    // available byte-for-byte in `raw`.
680                    None if sidechain_only => None,
681                    None => {
682                        return Err(claude_replay_error(format!(
683                            "active Claude record `{}` has missing parentUuid `{parent}`",
684                            self.nodes[idx].uuid
685                        )));
686                    }
687                },
688                None => None,
689            };
690            if cursor.is_some_and(|parent| !retained[parent]) {
691                if lenient {
692                    // A compaction boundary is where this segment ends; the
693                    // records it pruned stay pruned.
694                    break;
695                }
696                return Err(claude_replay_error(format!(
697                    "active Claude chain crosses an excluded compaction record from `{}`",
698                    self.nodes[idx].uuid
699                )));
700            }
701        }
702        reversed.reverse();
703
704        // Include non-conversation descendants rooted at the segment's leaf
705        // (turn_duration, attachments, etc.), matching Claude's QYH/n1T.
706        let mut descendants = Vec::new();
707        let mut frontier = vec![leaf];
708        let mut head = 0;
709        while head < frontier.len() {
710            let parent = frontier[head];
711            head += 1;
712            for (idx, node) in self.nodes.iter().enumerate() {
713                if !retained[idx]
714                    || node.kind.is_conversation()
715                    || seen.contains(&idx)
716                    || node.parent_uuid.as_deref() != Some(self.nodes[parent].uuid.as_str())
717                {
718                    continue;
719                }
720                seen.insert(idx);
721                descendants.push(idx);
722                frontier.push(idx);
723            }
724        }
725        descendants.sort_by_key(|idx| self.nodes[*idx].line_index);
726        reversed.extend(descendants);
727        Ok(reversed)
728    }
729
730    /// The newest retained conversation record of every component the active
731    /// leaf's own component cannot reach.
732    ///
733    /// Only a severed graph produces any: a healthy transcript is one
734    /// component, so the abandoned branches a rewind left behind stay
735    /// abandoned here exactly as they do under strict projection.
736    fn severed_segment_leaves(&self, active: usize, retained: &[bool]) -> Vec<usize> {
737        let active_root = self.component_root(active, retained);
738        let mut newest_by_root: BTreeMap<usize, usize> = BTreeMap::new();
739        for idx in 0..self.nodes.len() {
740            if !retained[idx] || !self.nodes[idx].kind.is_conversation() {
741                continue;
742            }
743            let Some(root) = self.component_root(idx, retained) else {
744                continue;
745            };
746            if Some(root) == active_root {
747                continue;
748            }
749            let newest = newest_by_root.entry(root).or_insert(idx);
750            if self.nodes[idx].line_index > self.nodes[*newest].line_index {
751                *newest = idx;
752            }
753        }
754        newest_by_root.into_values().collect()
755    }
756
757    /// Walk `idx` up to the record that anchors its component, stopping at a
758    /// root, an edge that leaves the transcript, or a pruned parent. `None`
759    /// when the walk cycles.
760    fn component_root(&self, idx: usize, retained: &[bool]) -> Option<usize> {
761        let mut cursor = idx;
762        let mut seen = HashSet::new();
763        loop {
764            if !seen.insert(cursor) {
765                return None;
766            }
767            let next = self.nodes[cursor]
768                .parent_uuid
769                .as_deref()
770                .and_then(|parent| self.by_uuid.get(parent).copied())
771                .filter(|parent| retained[*parent]);
772            match next {
773                Some(parent) => cursor = parent,
774                None => return Some(cursor),
775            }
776        }
777    }
778
779    fn apply_latest_compaction(
780        &mut self,
781        boundary_index: usize,
782        retained: &mut [bool],
783    ) -> Result<()> {
784        let compact = self.nodes[boundary_index]
785            .compact
786            .clone()
787            .expect("called with compact boundary");
788        let mut preserved = compact.preserved_uuids;
789        if preserved.is_empty() {
790            if let Some((head, tail)) = compact.preserved_segment {
791                preserved = self.walk_preserved_segment(&head, &tail)?;
792            }
793        }
794
795        let preserved_set: HashSet<String> = preserved.iter().cloned().collect();
796        for uuid in &preserved {
797            if !self.by_uuid.contains_key(uuid) {
798                return Err(claude_replay_error(format!(
799                    "latest compact boundary references missing preserved uuid `{uuid}`"
800                )));
801            }
802        }
803
804        let removed_uuids: HashSet<String> = self
805            .nodes
806            .iter()
807            .enumerate()
808            .filter(|(idx, node)| *idx < boundary_index && !preserved_set.contains(&node.uuid))
809            .map(|(_, node)| node.uuid.clone())
810            .collect();
811        for (idx, node) in self.nodes.iter().enumerate() {
812            if idx < boundary_index && !preserved_set.contains(&node.uuid) {
813                retained[idx] = false;
814            }
815        }
816
817        if preserved.is_empty() {
818            return Ok(());
819        }
820        let anchor = compact.anchor_uuid.ok_or_else(|| {
821            claude_replay_error("preserved compact boundary is missing anchorUuid")
822        })?;
823        if !self.by_uuid.contains_key(&anchor) {
824            return Err(claude_replay_error(format!(
825                "latest compact boundary references missing anchor uuid `{anchor}`"
826            )));
827        }
828        let tail = preserved.last().cloned().expect("non-empty preserved list");
829        let mut parent = anchor.clone();
830        for uuid in &preserved {
831            let idx = self.by_uuid[uuid];
832            self.nodes[idx].parent_uuid = Some(parent);
833            parent = uuid.clone();
834        }
835        let first = &preserved[0];
836        for node in &mut self.nodes {
837            if node.parent_uuid.as_deref() == Some(anchor.as_str()) && node.uuid != *first {
838                node.parent_uuid = Some(tail.clone());
839            }
840        }
841        for node in &mut self.nodes {
842            if node.kind.is_conversation()
843                && node
844                    .parent_uuid
845                    .as_ref()
846                    .is_some_and(|parent| removed_uuids.contains(parent))
847            {
848                node.parent_uuid = Some(tail.clone());
849            }
850        }
851        Ok(())
852    }
853
854    fn walk_preserved_segment(&self, head: &str, tail: &str) -> Result<Vec<String>> {
855        let mut reversed = Vec::new();
856        let mut seen = HashSet::new();
857        let mut cursor = tail;
858        loop {
859            if !seen.insert(cursor.to_string()) {
860                return Err(claude_replay_error("cycle in compact preservedSegment"));
861            }
862            let idx = *self.by_uuid.get(cursor).ok_or_else(|| {
863                claude_replay_error(format!(
864                    "compact preservedSegment references missing uuid `{cursor}`"
865                ))
866            })?;
867            reversed.push(cursor.to_string());
868            if cursor == head {
869                reversed.reverse();
870                return Ok(reversed);
871            }
872            cursor = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
873                claude_replay_error(format!(
874                    "compact preservedSegment tail `{tail}` does not reach head `{head}`"
875                ))
876            })?;
877        }
878    }
879
880    fn parent_index(&self, idx: usize, retained: &[bool]) -> Result<usize> {
881        let parent = self.nodes[idx].parent_uuid.as_deref().ok_or_else(|| {
882            claude_replay_error(format!(
883                "Claude record `{}` has no conversational ancestor",
884                self.nodes[idx].uuid
885            ))
886        })?;
887        let parent_idx = *self.by_uuid.get(parent).ok_or_else(|| {
888            claude_replay_error(format!(
889                "Claude record `{}` has missing parentUuid `{parent}`",
890                self.nodes[idx].uuid
891            ))
892        })?;
893        if !retained[parent_idx] {
894            return Err(claude_replay_error(format!(
895                "Claude record `{}` points into compacted-out history",
896                self.nodes[idx].uuid
897            )));
898        }
899        Ok(parent_idx)
900    }
901
902    fn recover_parallel_assistant_chunks(
903        &self,
904        base: Vec<usize>,
905        retained: &[bool],
906    ) -> Result<Vec<usize>> {
907        let selected: HashSet<usize> = base.iter().copied().collect();
908        let mut replacements: HashMap<usize, Vec<usize>> = HashMap::new();
909        let mut skipped_positions = HashSet::new();
910        let mut handled_ids = HashSet::new();
911
912        for (base_pos, idx) in base.iter().copied().enumerate() {
913            let Some(message_id) = self.nodes[idx].assistant_message_id.as_deref() else {
914                continue;
915            };
916            if !handled_ids.insert(message_id.to_string()) {
917                continue;
918            }
919            let base_positions: Vec<usize> = base
920                .iter()
921                .enumerate()
922                .filter(|(_, candidate)| {
923                    self.nodes[**candidate].assistant_message_id.as_deref() == Some(message_id)
924                })
925                .map(|(pos, _)| pos)
926                .collect();
927            let anchor_pos = base_positions.first().copied().unwrap_or(base_pos);
928            skipped_positions.extend(base_positions.iter().copied().skip(1));
929
930            // A streamed Anthropic response can be stored as sibling records
931            // rather than a literal parent chain. Reassemble every chunk at
932            // the first active occurrence and restore raw chunk order before
933            // the normalizer coalesces their content blocks.
934            let mut chunks: Vec<usize> = self
935                .nodes
936                .iter()
937                .enumerate()
938                .filter(|(candidate, node)| {
939                    retained[*candidate] && node.assistant_message_id.as_deref() == Some(message_id)
940                })
941                .map(|(candidate, _)| candidate)
942                .collect();
943            chunks.sort_by_key(|candidate| self.nodes[*candidate].line_index);
944
945            let assistant_uuids: HashSet<&str> = self
946                .nodes
947                .iter()
948                .filter(|node| node.assistant_message_id.as_deref() == Some(message_id))
949                .map(|node| node.uuid.as_str())
950                .collect();
951            let mut results: Vec<usize> = self
952                .nodes
953                .iter()
954                .enumerate()
955                .filter(|(candidate, node)| {
956                    retained[*candidate]
957                        && !selected.contains(candidate)
958                        && node.is_tool_result
959                        && node
960                            .parent_uuid
961                            .as_deref()
962                            .is_some_and(|parent| assistant_uuids.contains(parent))
963                })
964                .map(|(candidate, _)| candidate)
965                .collect();
966            results.sort_by_key(|candidate| self.nodes[*candidate].line_index);
967            chunks.extend(results);
968            replacements.insert(anchor_pos, chunks);
969        }
970
971        let mut out = Vec::with_capacity(selected.len());
972        for (pos, idx) in base.into_iter().enumerate() {
973            if let Some(replacement) = replacements.remove(&pos) {
974                out.extend(replacement);
975            } else if !skipped_positions.contains(&pos) {
976                out.push(idx);
977            }
978        }
979        Ok(out)
980    }
981}
982
983impl ClaudeCompactBoundary {
984    fn from_value(v: &Value) -> Self {
985        let metadata = v.get("compactMetadata");
986        let preserved_messages = metadata.and_then(|m| m.get("preservedMessages"));
987        let anchor_uuid = preserved_messages
988            .and_then(|p| p.get("anchorUuid"))
989            .and_then(Value::as_str)
990            .or_else(|| {
991                metadata
992                    .and_then(|m| m.get("preservedSegment"))
993                    .and_then(|p| p.get("anchorUuid"))
994                    .and_then(Value::as_str)
995            })
996            .map(str::to_string);
997        let preserved_uuids = preserved_messages
998            .and_then(|p| p.get("uuids"))
999            .and_then(Value::as_array)
1000            .map(|uuids| {
1001                uuids
1002                    .iter()
1003                    .filter_map(Value::as_str)
1004                    .map(str::to_string)
1005                    .collect()
1006            })
1007            .unwrap_or_default();
1008        let preserved_segment =
1009            metadata
1010                .and_then(|m| m.get("preservedSegment"))
1011                .and_then(|segment| {
1012                    Some((
1013                        segment.get("headUuid")?.as_str()?.to_string(),
1014                        segment.get("tailUuid")?.as_str()?.to_string(),
1015                    ))
1016                });
1017        Self {
1018            anchor_uuid,
1019            preserved_uuids,
1020            preserved_segment,
1021        }
1022    }
1023}
1024
1025fn claude_replay_error(message: impl Into<String>) -> crate::Error {
1026    crate::Error::Other(format!(
1027        "cannot reconstruct lossless Claude continuation: {}",
1028        message.into()
1029    ))
1030}
1031
1032fn claude_assistant_message_id(v: &Value) -> Option<&str> {
1033    v.get("message")
1034        .and_then(|message| message.get("id"))
1035        .and_then(Value::as_str)
1036}
1037
1038fn merge_claude_assistant_chunk(target: &mut Value, chunk: &Value) {
1039    let Some(target_message) = target.get_mut("message") else {
1040        return;
1041    };
1042    let Some(chunk_message) = chunk.get("message") else {
1043        return;
1044    };
1045    let mut content = target_message
1046        .get("content")
1047        .and_then(Value::as_array)
1048        .cloned()
1049        .unwrap_or_default();
1050    if let Some(blocks) = chunk_message.get("content").and_then(Value::as_array) {
1051        content.extend(blocks.iter().cloned());
1052    }
1053    let mut merged_message = chunk_message.clone();
1054    merged_message["content"] = Value::Array(content);
1055    *target_message = merged_message;
1056}
1057
1058fn flush_claude_assistant(pending: &mut Option<Value>, out: &mut Vec<ChatMessage>) {
1059    let Some(v) = pending.take() else {
1060        return;
1061    };
1062    let reasoning_only = claude_assistant_message_id(&v).is_some()
1063        && v.get("message")
1064            .and_then(|message| message.get("content"))
1065            .and_then(Value::as_array)
1066            .is_some_and(|blocks| {
1067                !blocks.is_empty()
1068                    && blocks.iter().all(|block| {
1069                        matches!(
1070                            block.get("type").and_then(Value::as_str),
1071                            Some("thinking" | "redacted_thinking")
1072                        )
1073                    })
1074            });
1075    if reasoning_only {
1076        return;
1077    }
1078    let before = out.len();
1079    push_claude_assistant(&v, out);
1080    capture_claude_record_provenance(&v, &mut out[before..]);
1081    restore_single_grok_message(&v, &mut out[before..]);
1082}
1083
1084/// Attach the record identity, clock, and actual assistant model to every
1085/// canonical message produced from one Claude JSONL record. These fields are
1086/// deliberately per-message: a continued transcript can cross a provider
1087/// boundary, so the session-level source model is not authoritative for its
1088/// appended tail.
1089fn capture_claude_record_provenance(v: &Value, messages: &mut [ChatMessage]) {
1090    let timestamp = v.get("timestamp").and_then(Value::as_str);
1091    let uuid = v.get("uuid").and_then(Value::as_str);
1092    let model = v
1093        .get("message")
1094        .and_then(|message| message.get("model"))
1095        .and_then(Value::as_str);
1096    for message in messages {
1097        if let Some(timestamp) = timestamp {
1098            message
1099                .metadata
1100                .entry("timestamp".to_string())
1101                .or_insert_with(|| timestamp.to_string());
1102        }
1103        if let Some(uuid) = uuid {
1104            message
1105                .metadata
1106                .entry("claude_uuid".to_string())
1107                .or_insert_with(|| uuid.to_string());
1108        }
1109        if let Some(model) = model {
1110            message
1111                .metadata
1112                .entry("model".to_string())
1113                .or_insert_with(|| model.to_string());
1114        }
1115    }
1116}
1117
1118fn capture_claude_meta(v: &Value, meta: &mut SessionMeta, raw_line: &str) -> Result<()> {
1119    restore_codex_provenance_from_top_level(v, meta)?;
1120    if meta.session_id.is_none() {
1121        if let Some(id) = v.get("sessionId").and_then(Value::as_str) {
1122            meta.session_id = Some(id.to_string());
1123        }
1124    }
1125    if meta.cwd.is_none() {
1126        if let Some(cwd) = v.get("cwd").and_then(Value::as_str) {
1127            meta.cwd = Some(PathBuf::from(cwd));
1128        }
1129    }
1130    if meta.model.is_none() {
1131        if let Some(model) = v
1132            .get("message")
1133            .and_then(|m| m.get("model"))
1134            .and_then(Value::as_str)
1135        {
1136            meta.model = Some(model.to_string());
1137        }
1138    }
1139    // PARITY-10 (provenance P010): `fork-context-ref` is an extremely rare
1140    // real Claude Code record with no confirmed field shape (see
1141    // `ClaudeRecord::ForkContextRef`'s doc comment) — rather than guess at
1142    // named fields and risk silently mis-modeling it, stash the WHOLE raw
1143    // line verbatim under a lineage key. `write_claude_code_records` (below)
1144    // re-emits it byte-for-byte, so the record survives the Claude Code
1145    // semantic writer (not just the CLI's raw-passthrough diagonal path) —
1146    // and `to_codex_jsonl`'s synthesized header carries a namespaced copy
1147    // so a Claude -> Codex -> Claude round trip can still reconstruct it
1148    // (dev/03). A session can only fork from one context, so the first one
1149    // seen wins, matching every other "first wins" field above.
1150    //
1151    // D4 (Fable-5 review, confirmed): this used to store `v.to_string()` —
1152    // a RE-SERIALIZATION of the parsed `Value`, not the original source
1153    // text. `serde_json::Value` here has no `preserve_order` feature (see
1154    // `Cargo.toml`), so its object keys are a `BTreeMap` and get silently
1155    // REORDERED (alphabetized) on re-emit — the "byte-for-byte" claim in
1156    // this very comment was false. Fixed the cheap+honest way: store the
1157    // caller's own already-verbatim source `raw_line` text instead of
1158    // re-serializing `v` at all, so this is now ACTUALLY byte-for-byte
1159    // (key order, spacing, everything) rather than merely
1160    // structurally-equivalent JSON.
1161    if v.get("type").and_then(Value::as_str) == Some("fork-context-ref")
1162        && !meta.lineage.contains_key("claude_fork_context_ref_raw")
1163    {
1164        meta.lineage.insert(
1165            "claude_fork_context_ref_raw".to_string(),
1166            raw_line.to_string(),
1167        );
1168    }
1169    Ok(())
1170}
1171
1172fn push_claude_user(v: &Value, out: &mut Vec<ChatMessage>) {
1173    let content = v.get("message").and_then(|m| m.get("content"));
1174    let provenance = claude_user_provenance(v);
1175    match content {
1176        Some(Value::String(s)) => {
1177            if !s.trim().is_empty() {
1178                out.push(ChatMessage::user(s.clone()).with_metas(&provenance));
1179            }
1180        }
1181        Some(Value::Array(blocks)) => {
1182            let mut text = String::new();
1183            // IX-5: image blocks alongside/instead of text — collected
1184            // separately (never synthesized on a malformed shape, see
1185            // `claude_image_block_to_part`) so a multimodal user turn
1186            // survives as `content_parts` instead of the image silently
1187            // vanishing.
1188            let mut images: Vec<Value> = Vec::new();
1189            // D5: an `image` block whose `source` isn't base64/url (e.g. a
1190            // Files-API `{"source":{"type":"file","file_id":..}}`
1191            // reference) makes `claude_image_block_to_part` return `None` —
1192            // track that it was SEEN even though it couldn't be converted,
1193            // so an image-ONLY record (no text, no convertible image) isn't
1194            // silently dropped below (the same vanishing-record bug-class
1195            // PARITY-11 fixed for reasoning-only turns).
1196            let mut saw_unconvertible_image = false;
1197            for b in blocks {
1198                match b.get("type").and_then(Value::as_str) {
1199                    Some("text") => push_text(&mut text, b.get("text")),
1200                    Some("tool_result") => {
1201                        let id = b
1202                            .get("tool_use_id")
1203                            .and_then(Value::as_str)
1204                            .unwrap_or_default();
1205                        // PARITY-11 (nested images): `extract_tool_result_content`
1206                        // captures any `image` blocks nested inside this
1207                        // `tool_result` into `content_parts` (via
1208                        // `claude_image_block_to_part`, the same conversion the
1209                        // top-level `image` block path already uses) instead of
1210                        // flattening them to the bare `[image]` marker text the
1211                        // old `extract_tool_result` emitted — the everyday
1212                        // "Read a PNG / screenshot tool output" shape.
1213                        let (result, images) =
1214                            extract_tool_result_content(b.get("content"), v.get("toolUseResult"));
1215                        let mut msg = tool_message(id, result);
1216                        if !images.is_empty() {
1217                            // D-mix (Fable review, must-fix): `content_parts`
1218                            // is a self-contained contract — the pi writer
1219                            // (`pi_content_value`) reads ONLY `content_parts`
1220                            // for a `Role::Tool` message and never falls back
1221                            // to `msg.content`, so on a MIXED text+image
1222                            // tool_result a bare `content_parts: [image]`
1223                            // silently drops the sibling text on `convert
1224                            // --to pi` (a regression vs. the pre-PARITY-11
1225                            // baseline, which at least preserved the text).
1226                            // Prepend the text as part 0, exactly mirroring
1227                            // `pi_content_to_text_and_parts` and
1228                            // `push_opencode_user`'s identical
1229                            // self-contained-parts construction. `msg.content`
1230                            // keeps the text too (unchanged) for the writers
1231                            // that read text from `msg.content` and only scan
1232                            // `content_parts` for `image_url` entries
1233                            // (`claude_tool_result_content_value`,
1234                            // `codex_tool_output_text`, the opencode
1235                            // assistant writer) — those already filter
1236                            // strictly on `image_url`/text-typed lookups, so
1237                            // this text part is never double-counted.
1238                            let mut parts = Vec::new();
1239                            if let Some(t) = &msg.content {
1240                                if !t.is_empty() {
1241                                    parts.push(serde_json::json!({"type": "text", "text": t}));
1242                                }
1243                            }
1244                            parts.extend(images);
1245                            msg.content_parts = Some(parts);
1246                        }
1247                        // The assistant turn that issued this tool call — the
1248                        // tool-pairing graph edge (parallel to parentUuid).
1249                        if let Some(src) = v.get("sourceToolAssistantUUID").and_then(Value::as_str)
1250                        {
1251                            msg.metadata
1252                                .insert("sourceToolAssistantUUID".to_string(), src.to_string());
1253                        }
1254                        // TR-10: preserve the Claude wire `is_error` flag so
1255                        // the reduction layer's success/failure boundary
1256                        // (`ReductionKind::ToolInputElided` must never target
1257                        // an errored call) survives import — `ChatMessage`
1258                        // otherwise has no structural slot for it.
1259                        if b.get("is_error").and_then(Value::as_bool) == Some(true) {
1260                            crate::mark_tool_error(&mut msg);
1261                        } else {
1262                            restore_tool_outcome_extension(v, &mut msg);
1263                        }
1264                        out.push(msg);
1265                    }
1266                    Some("image") => match claude_image_block_to_part(b) {
1267                        Some(part) => images.push(part),
1268                        None => saw_unconvertible_image = true,
1269                    },
1270                    _ => {} // document / unknown — skip
1271                }
1272            }
1273            // D5: nothing convertible landed in `text`/`images` but an
1274            // image block WAS present — fold in the same short bracketed
1275            // marker convention already used for `[web_search]`/`[model
1276            // fallback: ...]` rather than letting the record vanish.
1277            if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
1278                push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
1279            }
1280            let before = out.len();
1281            if !images.is_empty() {
1282                let mut parts = Vec::new();
1283                if !text.trim().is_empty() {
1284                    parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
1285                }
1286                parts.extend(images);
1287                out.push(
1288                    ChatMessage {
1289                        role: Role::User,
1290                        content: None,
1291                        content_parts: Some(parts),
1292                        tool_calls: None,
1293                        tool_call_id: None,
1294                        name: None,
1295                        metadata: Default::default(),
1296                    }
1297                    .with_metas(&provenance),
1298                );
1299            } else if !text.trim().is_empty() {
1300                out.push(ChatMessage::user(text).with_metas(&provenance));
1301            }
1302            if saw_unconvertible_image && out.len() > before {
1303                if let Some(msg) = out.last_mut() {
1304                    msg.metadata
1305                        .insert("image_source_unconvertible".to_string(), "true".to_string());
1306                }
1307            }
1308        }
1309        _ => {}
1310    }
1311}
1312
1313/// D5 (Fable-5 review): the bracketed marker folded into `text` when an
1314/// `image` block is SEEN but [`claude_image_block_to_part`] can't convert it
1315/// (e.g. a Files-API `{"source":{"type":"file",...}}` reference) and nothing
1316/// else in the record survives either — matches the existing
1317/// `[web_search]`/`[model fallback: ...]` convention rather than letting the
1318/// whole record vanish (`push_claude_user`/`push_claude_assistant`).
1319pub(super) const UNCONVERTIBLE_IMAGE_MARKER: &str =
1320    "[image: source not captured — unsupported/unconvertible image reference]";
1321
1322/// Parse a Claude Code user-turn `image` content block
1323/// (`{"type":"image","source":{"type":"base64","media_type":..,"data":..}}`
1324/// or `{"type":"image","source":{"type":"url","url":..}}`) into a
1325/// `content_parts` `image_url` entry (a `data:` URI for the base64 form, the
1326/// bare URL for the url form) — the inverse of
1327/// [`claude_user_content_value`]'s emission. Only a well-formed source
1328/// (non-empty `media_type`/`data`, or non-empty `url`) is recognized;
1329/// anything else — including a well-formed but unconvertible source like a
1330/// Files-API `{"type":"file",...}` reference (D5) — is left as raw-only
1331/// residue rather than synthesizing a corrupt/empty part (mirrors the
1332/// pi/opencode loaders' `pi_image_shape`/`opencode_file_image_part`
1333/// discipline). Callers must not let that turn the record invisible though:
1334/// see [`UNCONVERTIBLE_IMAGE_MARKER`].
1335pub(super) fn claude_image_block_to_part(b: &Value) -> Option<Value> {
1336    let source = b.get("source")?;
1337    match source.get("type").and_then(Value::as_str) {
1338        Some("base64") => {
1339            let mime = source.get("media_type").and_then(Value::as_str)?;
1340            let data = source.get("data").and_then(Value::as_str)?;
1341            if mime.is_empty() || data.is_empty() {
1342                return None;
1343            }
1344            Some(serde_json::json!({
1345                "type": "image_url",
1346                "image_url": {"url": format!("data:{mime};base64,{data}")},
1347            }))
1348        }
1349        Some("url") => {
1350            let url = source.get("url").and_then(Value::as_str)?;
1351            if url.is_empty() {
1352                return None;
1353            }
1354            Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
1355        }
1356        _ => None,
1357    }
1358}
1359
1360/// Rebuild a Claude Code user-turn `message.content` value from a
1361/// `ChatMessage` — the inverse of `push_claude_user`'s parse (text blocks +
1362/// [`claude_image_block_to_part`]). When `content_parts` is absent this
1363/// MUST reproduce the historical plain-string `content` exactly (IX-5's
1364/// overriding constraint: a text-only message's export stays byte-identical)
1365/// — only a multimodal message (`content_parts` present, e.g. imported from
1366/// Pi/OpenCode or a real Claude Code image turn) gets the Anthropic
1367/// content-array shape, one `text` block (if any non-empty text part) plus
1368/// one `image` block per `image_url` part (`data:` URI → `source.base64`;
1369/// any other URL → `source.url`).
1370fn claude_user_content_value(msg: &ChatMessage) -> Value {
1371    match &msg.content_parts {
1372        Some(parts) => {
1373            let mut blocks = Vec::new();
1374            for p in parts {
1375                match p.get("type").and_then(Value::as_str) {
1376                    Some("text") => {
1377                        if let Some(t) = p.get("text").and_then(Value::as_str) {
1378                            if !t.is_empty() {
1379                                blocks.push(serde_json::json!({"type": "text", "text": t}));
1380                            }
1381                        }
1382                    }
1383                    Some("image_url") => {
1384                        if let Some(url) = p
1385                            .get("image_url")
1386                            .and_then(|u| u.get("url"))
1387                            .and_then(Value::as_str)
1388                        {
1389                            blocks.push(match parse_data_uri(url) {
1390                                Some((mime, data)) => serde_json::json!({
1391                                    "type": "image",
1392                                    "source": {"type": "base64", "media_type": mime, "data": data},
1393                                }),
1394                                None => serde_json::json!({
1395                                    "type": "image",
1396                                    "source": {"type": "url", "url": url},
1397                                }),
1398                            });
1399                        }
1400                    }
1401                    _ => {}
1402                }
1403            }
1404            Value::Array(blocks)
1405        }
1406        None => Value::String(msg.content.clone().unwrap_or_default()),
1407    }
1408}
1409
1410/// Rebuild a Claude Code `tool_result` block's `content` value from a `Tool`
1411/// `ChatMessage` — the inverse of [`extract_tool_result_content`]'s parse
1412/// (PARITY-11). When `content_parts` is absent (or empty) this MUST reproduce
1413/// the historical plain-string `content` exactly (same IX-5-style constraint
1414/// `claude_user_content_value` follows) — only a `tool_result` that actually
1415/// carries a captured nested image gets the Anthropic content-array shape,
1416/// one `text` block (the existing `msg.content`, if any) plus one `image`
1417/// block per `image_url` part (mirrors `claude_user_content_value`'s
1418/// `data:` URI -> `source.base64` / other URL -> `source.url` mapping).
1419fn claude_tool_result_content_value(msg: &ChatMessage) -> Value {
1420    match &msg.content_parts {
1421        Some(parts) if !parts.is_empty() => {
1422            let mut blocks = Vec::new();
1423            if let Some(t) = &msg.content {
1424                if !t.is_empty() {
1425                    blocks.push(serde_json::json!({"type": "text", "text": t}));
1426                }
1427            }
1428            for p in parts {
1429                if p.get("type").and_then(Value::as_str) == Some("image_url") {
1430                    if let Some(url) = p
1431                        .get("image_url")
1432                        .and_then(|u| u.get("url"))
1433                        .and_then(Value::as_str)
1434                    {
1435                        blocks.push(match parse_data_uri(url) {
1436                            Some((mime, data)) => serde_json::json!({
1437                                "type": "image",
1438                                "source": {"type": "base64", "media_type": mime, "data": data},
1439                            }),
1440                            None => serde_json::json!({
1441                                "type": "image",
1442                                "source": {"type": "url", "url": url},
1443                            }),
1444                        });
1445                    }
1446                }
1447            }
1448            Value::Array(blocks)
1449        }
1450        _ => Value::String(msg.content.clone().unwrap_or_default()),
1451    }
1452}
1453
1454/// Collect the Claude Code user-turn provenance fields that distinguish real
1455/// human input from system-injected turns and record replay-relevant state.
1456pub(crate) fn claude_user_provenance(v: &Value) -> Vec<(String, String)> {
1457    let mut out = Vec::new();
1458    let mut take_str = |key: &str| {
1459        if let Some(s) = v.get(key).and_then(Value::as_str) {
1460            out.push((key.to_string(), s.to_string()));
1461        }
1462    };
1463    take_str("promptSource"); // typed | queued | system | sdk
1464    take_str("interruptedMessageId");
1465    take_str("sourceToolUseID");
1466    for flag in ["isMeta", "isCompactSummary", "isVisibleInTranscriptOnly"] {
1467        if v.get(flag).and_then(Value::as_bool) == Some(true) {
1468            out.push((flag.to_string(), "true".to_string()));
1469        }
1470    }
1471    if let Some(n) = v.get("queuePriority").and_then(Value::as_i64) {
1472        out.push(("queuePriority".to_string(), n.to_string()));
1473    }
1474    // `origin` is an object like {"kind":"task-notification"} — keep its kind.
1475    if let Some(kind) = v
1476        .get("origin")
1477        .and_then(|o| o.get("kind"))
1478        .and_then(Value::as_str)
1479    {
1480        out.push(("origin".to_string(), kind.to_string()));
1481    }
1482    out
1483}
1484
1485/// Content-bearing Claude `system` events (`scheduled_task_fire`,
1486/// `local_command`, `away_summary`) carry real text that's part of the
1487/// interaction; fold them in as system context. Marker/metric subtypes
1488/// (`turn_duration`, `compact_boundary`, `api_error`, `stop_hook_summary`) carry
1489/// no conversational content and are skipped.
1490fn push_claude_system(v: &Value, out: &mut Vec<ChatMessage>) {
1491    let keep = matches!(
1492        v.get("subtype").and_then(Value::as_str),
1493        Some("scheduled_task_fire") | Some("local_command") | Some("away_summary")
1494    );
1495    if !keep {
1496        return;
1497    }
1498    if let Some(content) = v.get("content").and_then(Value::as_str) {
1499        if !content.trim().is_empty() {
1500            let subtype = v.get("subtype").and_then(Value::as_str).unwrap_or("system");
1501            out.push(ChatMessage::system(content.to_string()).with_meta("systemSubtype", subtype));
1502        }
1503    }
1504}
1505
1506/// Fold content-bearing Claude Code `attachment` records into the conversation
1507/// as user-role messages. Most attachment subtypes (`task_reminder`,
1508/// `deferred_tools_delta`, `skill_listing`, `hook_*`, `command_permissions`, …)
1509/// are regenerable system injections and are skipped; only the four that carry
1510/// non-regenerable user/external content are kept.
1511fn push_claude_attachment(v: &Value, out: &mut Vec<ChatMessage>) {
1512    let att = match v.get("attachment") {
1513        Some(a) => a,
1514        None => return,
1515    };
1516    let kind = match att.get("type").and_then(Value::as_str) {
1517        Some(kind) => kind,
1518        None => return,
1519    };
1520    let text = match kind {
1521        // A queued prompt. `commandMode` says whose: `prompt` is the person's
1522        // own text, `task-notification` is the runtime reporting a finished
1523        // background task. Kept verbatim below.
1524        "queued_command" => att
1525            .get("prompt")
1526            .and_then(Value::as_str)
1527            .map(str::to_string),
1528        // A file the user attached: header + contents.
1529        "file" => attachment_with_path(att, "attached file", "filename", "content"),
1530        // A user-edited file snippet.
1531        "edited_text_file" => attachment_with_path(att, "edited file", "filename", "snippet"),
1532        // Injected project memory (CLAUDE.md), point-in-time.
1533        "nested_memory" => attachment_with_path(att, "project memory", "path", "content"),
1534        _ => None, // regenerable system injection — skip
1535    };
1536    let Some(text) = text else { return };
1537    if text.trim().is_empty() {
1538        return;
1539    }
1540    // An attachment record wears the user's ROLE, but the record itself says
1541    // who actually spoke — and that fact is lost the moment the attachment is
1542    // flattened to `[label: path]` text, so carry it as metadata the way
1543    // `push_claude_system` carries `systemSubtype`. Two fields, both verbatim:
1544    //
1545    //   `attachmentType`  the subtype. `file` / `edited_text_file` /
1546    //                     `nested_memory` are envelopes the runtime built
1547    //                     around a file body; a frontend that trusts the role
1548    //                     shows the reader a numbered source listing in a
1549    //                     chat bubble apparently sent by themselves.
1550    //   `commandMode`     present on `queued_command` only, and the whole
1551    //                     story for it. Measured over the local Claude Code
1552    //                     corpus (2,512 `queued_command` attachments): 926
1553    //                     `prompt`, every one of them plain human text, and
1554    //                     1,586 `task-notification`, every one of them a
1555    //                     `<task-notification>` frame — the same text Claude
1556    //                     Code also writes as a `type:"user"` record stamped
1557    //                     `origin.kind = "task-notification"`.
1558    //
1559    // Presentation policy (which of these a frontend hides) belongs to the
1560    // frontend — `isContextMessage` in `sdk/client/client.mjs`. The loader's
1561    // job is to stop discarding the producer's own answer.
1562    let mut message = ChatMessage::user(text).with_meta("attachmentType", kind);
1563    if let Some(mode) = att.get("commandMode").and_then(Value::as_str) {
1564        message = message.with_meta("commandMode", mode);
1565    }
1566    out.push(message);
1567}
1568
1569/// Format an attachment as `[<label>: <path>]\n<body>`.
1570fn attachment_with_path(
1571    att: &Value,
1572    label: &str,
1573    path_key: &str,
1574    body_key: &str,
1575) -> Option<String> {
1576    let body = att.get(body_key).and_then(Value::as_str)?;
1577    let path = att
1578        .get(path_key)
1579        .or_else(|| att.get("displayPath"))
1580        .and_then(Value::as_str)
1581        .unwrap_or("");
1582    Some(format!("[{label}: {path}]\n{body}"))
1583}
1584
1585pub(super) fn push_str_field(buf: &mut String, s: &str) {
1586    if !buf.is_empty() {
1587        buf.push('\n');
1588    }
1589    buf.push_str(s);
1590}
1591
1592/// N3: build a synthesized message for reasoning that could not attach to a
1593/// following assistant turn — either interrupted mid-stream by a
1594/// non-assistant item, or dangling at EOF (an aborted-turn shape). Drains
1595/// the three pending buffers (all empty/`false` afterward) so callers don't
1596/// separately have to remember to clear them.
1597pub(super) fn orphaned_reasoning_message(
1598    reasoning: &mut String,
1599    reasoning_content: &mut String,
1600    encrypted: &mut bool,
1601) -> ChatMessage {
1602    let mut msg = ChatMessage::system("[reasoning] (turn ended without a reply)".to_string());
1603    if !reasoning.is_empty() {
1604        msg = msg.with_meta("reasoning", std::mem::take(reasoning));
1605    }
1606    if !reasoning_content.is_empty() {
1607        msg = msg.with_meta("reasoning_content", std::mem::take(reasoning_content));
1608    }
1609    if *encrypted {
1610        msg = msg.with_meta("reasoning_encrypted", "true");
1611        *encrypted = false;
1612    }
1613    msg
1614}
1615
1616fn push_claude_assistant(v: &Value, out: &mut Vec<ChatMessage>) {
1617    let content = v.get("message").and_then(|m| m.get("content"));
1618    let mut text = String::new();
1619    let mut calls: Vec<ToolCall> = Vec::new();
1620    // Legacy singular fields — kept for backward compatibility with every
1621    // existing consumer of `metadata["thinking"]`/`["thinking_signature"]`/
1622    // `["redacted_thinking"]` (a concatenation of all thinking text, and the
1623    // LAST block's signature/data). D8 (Fable-5 review, confirmed): when a
1624    // message carries MULTIPLE `thinking` blocks, collapsing them down to
1625    // these singular fields silently drops every signature but the last
1626    // one's — a real Anthropic `thinking` block's `signature` cryptographically
1627    // covers ONLY that block's own text, so re-emitting block 1's text under
1628    // block 2's signature (or vice versa) produces a signature that will
1629    // never verify. `thinking_blocks` below is the fix: every block
1630    // preserved SEPARATELY, in order, each with its own (optional)
1631    // signature/data — the writer prefers it over the legacy fields
1632    // whenever present.
1633    let mut thinking = String::new();
1634    let mut signature: Option<String> = None;
1635    // PARITY-11: real Claude corpora also carry `redacted_thinking` and
1636    // `image` assistant blocks, and (rarely) a `fallback` model-routing
1637    // marker — none handled before, all silently vanishing (audit's own
1638    // census: 132,927 `thinking` / 67 `redacted_thinking` / 9 `image` / 4
1639    // `fallback` blocks in the reference corpus).
1640    //
1641    // D8: `redacted_thinking` is real data ONLY — never a fabricated
1642    // placeholder. The pre-fix code defaulted a missing `data` field to the
1643    // literal string `"<redacted>"`, which is indistinguishable from an
1644    // actual (if oddly-named) opaque payload on re-emit — a caller reading
1645    // it back has no way to tell "no data was ever captured" from "the
1646    // provider's own opaque blob happens to be the string `<redacted>`".
1647    // `redacted_thinking_seen` tracks block PRESENCE independently of
1648    // whether it had real data, so the reasoning-only-turn rescue below
1649    // still fires even when no block had a `data` field at all.
1650    let mut redacted_thinking: Option<String> = None;
1651    let mut redacted_thinking_seen = false;
1652    let mut images: Vec<Value> = Vec::new();
1653    // Real Anthropic `thinking` blocks very commonly carry an EMPTY
1654    // `thinking` string alongside a real `signature` (the summarized/
1655    // redacted-in-the-clear-but-replayable case) — `thinking.trim()` alone
1656    // would miss those, so track "a thinking block existed at all"
1657    // separately from whether it had visible text.
1658    let mut thinking_block_seen = false;
1659    // D8: every `thinking`/`redacted_thinking` block, preserved SEPARATELY
1660    // and IN ORDER — see the comment on `thinking`/`redacted_thinking`
1661    // above. Serialized as a single JSON-array metadata string
1662    // (`ChatMessage::metadata` is a flat string map) under
1663    // `"thinking_blocks"`.
1664    let mut thinking_blocks: Vec<Value> = Vec::new();
1665    // D5: mirrors `push_claude_user`'s tracking — an `image` block whose
1666    // source isn't base64/url (e.g. Files-API `{"type":"file",...}`) must
1667    // not silently vanish the whole record when nothing else survives.
1668    let mut saw_unconvertible_image = false;
1669
1670    match content {
1671        Some(Value::String(s)) => push_text(&mut text, Some(&Value::String(s.clone()))),
1672        Some(Value::Array(blocks)) => {
1673            for b in blocks {
1674                match b.get("type").and_then(Value::as_str) {
1675                    Some("text") => push_text(&mut text, b.get("text")),
1676                    Some("tool_use") => {
1677                        let id = b.get("id").and_then(Value::as_str).unwrap_or_default();
1678                        let name = b.get("name").and_then(Value::as_str).unwrap_or_default();
1679                        let args = b
1680                            .get("input")
1681                            .map(|i| i.to_string())
1682                            .unwrap_or_else(|| "{}".to_string());
1683                        calls.push(function_call(id, name, args));
1684                    }
1685                    // Thinking is not replayed across providers, but retain it in
1686                    // (skip-serialized) metadata so a same-model continuation can
1687                    // re-inject it. See P3.
1688                    Some("thinking") => {
1689                        thinking_block_seen = true;
1690                        let t = b.get("thinking").and_then(Value::as_str).unwrap_or("");
1691                        if !t.is_empty() {
1692                            push_str_field(&mut thinking, t); // legacy concatenated field
1693                        }
1694                        let sig = b.get("signature").and_then(Value::as_str);
1695                        if let Some(s) = sig {
1696                            signature = Some(s.to_string()); // legacy last-wins field
1697                        }
1698                        // D8: this block's OWN text + signature, not folded
1699                        // into the running concatenation above.
1700                        let mut block = serde_json::json!({"type": "thinking", "thinking": t});
1701                        if let Some(s) = sig {
1702                            block["signature"] = Value::String(s.to_string());
1703                        }
1704                        thinking_blocks.push(block);
1705                    }
1706                    // Anthropic's redacted reasoning: an opaque, provider-private
1707                    // payload (flagged content the API declines to show in the
1708                    // clear). Like `thinking`, it's not replayable, but the raw
1709                    // `data` is retained in metadata rather than silently
1710                    // vanishing — a same-model continuation can still replay it
1711                    // verbatim even though supercode never renders it.
1712                    Some("redacted_thinking") => {
1713                        redacted_thinking_seen = true;
1714                        let data = b.get("data").and_then(Value::as_str);
1715                        // D8: no fabricated fallback — `data` is only ever
1716                        // the real captured payload, or genuinely absent.
1717                        if let Some(d) = data {
1718                            redacted_thinking = Some(d.to_string()); // legacy last-wins field
1719                        }
1720                        let mut block = serde_json::json!({"type": "redacted_thinking"});
1721                        if let Some(d) = data {
1722                            block["data"] = Value::String(d.to_string());
1723                        }
1724                        thinking_blocks.push(block);
1725                    }
1726                    // An assistant-emitted image block (e.g. a generated
1727                    // image) — collected exactly like `push_claude_user`'s
1728                    // user-turn image handling (`claude_image_block_to_part`
1729                    // is role-general), so it survives as `content_parts`
1730                    // instead of vanishing.
1731                    Some("image") => match claude_image_block_to_part(b) {
1732                        Some(part) => images.push(part),
1733                        None => saw_unconvertible_image = true,
1734                    },
1735                    // A provider-routing note (real shape:
1736                    // `{"type":"fallback","from":{"model":..},"to":{"model":..}}`
1737                    // — a mid-generation model swap, e.g. an overloaded model
1738                    // falling back to another). Carries no replayable
1739                    // conversational content, but folding it into `text` as a
1740                    // short bracketed marker — the same convention the Codex
1741                    // loader already uses for `[web_search]`/
1742                    // `[image_generation] ...` — keeps it visible instead of
1743                    // silently vanishing, including the case where it's the
1744                    // ONLY block in the turn (see the reasoning-only-turn fix
1745                    // below: before this, that shape dropped the entire
1746                    // message).
1747                    Some("fallback") => {
1748                        let from = b
1749                            .get("from")
1750                            .and_then(|f| f.get("model"))
1751                            .and_then(Value::as_str)
1752                            .unwrap_or("?");
1753                        let to = b
1754                            .get("to")
1755                            .and_then(|t| t.get("model"))
1756                            .and_then(Value::as_str)
1757                            .unwrap_or("?");
1758                        push_str_field(&mut text, &format!("[model fallback: {from} -> {to}]"));
1759                    }
1760                    _ => {}
1761                }
1762            }
1763        }
1764        _ => {}
1765    }
1766
1767    // D5: nothing convertible landed in `text`/`images` but an image block
1768    // WAS present — fold in the same bracketed-marker convention `fallback`
1769    // uses above, so a genuinely image-only (unconvertible source) turn
1770    // doesn't vanish (mirrors `push_claude_user`'s identical fix).
1771    if images.is_empty() && text.trim().is_empty() && saw_unconvertible_image {
1772        push_str_field(&mut text, UNCONVERTIBLE_IMAGE_MARKER);
1773    }
1774
1775    let before = out.len();
1776    if !images.is_empty() {
1777        let mut parts = Vec::new();
1778        if !text.trim().is_empty() {
1779            parts.push(serde_json::json!({"type": "text", "text": text.clone()}));
1780        }
1781        parts.extend(images);
1782        out.push(ChatMessage {
1783            role: Role::Assistant,
1784            content: None,
1785            content_parts: Some(parts),
1786            tool_calls: (!calls.is_empty()).then_some(calls),
1787            tool_call_id: None,
1788            name: None,
1789            metadata: Default::default(),
1790        });
1791    } else {
1792        push_assistant(out, text, calls);
1793        // A recognized native assistant record remains transcript state even
1794        // when its content array is empty (for example, an interrupted model
1795        // turn). Force a bare message whenever `push_assistant` had nothing
1796        // to emit. This includes the reasoning-only case and also preserves
1797        // genuinely part-less records instead of silently changing turn
1798        // count/order during translation.
1799        if out.len() == before {
1800            let mut empty = ChatMessage {
1801                role: Role::Assistant,
1802                content: None,
1803                content_parts: None,
1804                tool_calls: None,
1805                tool_call_id: None,
1806                name: None,
1807                metadata: Default::default(),
1808            };
1809            if !thinking_block_seen && !redacted_thinking_seen {
1810                empty
1811                    .metadata
1812                    .insert("empty_assistant_record".to_string(), "true".to_string());
1813            }
1814            out.push(empty);
1815        }
1816    }
1817    // Attach retained reasoning + attribution to the message we just produced.
1818    if out.len() > before {
1819        if let Some(msg) = out.last_mut() {
1820            // Insert "thinking" (even as an empty string) whenever a
1821            // `thinking` block was actually seen, not just when it had
1822            // visible text — a real `thinking` block commonly carries an
1823            // empty `thinking` string alongside a real `signature` (the
1824            // summarized-away-but-still-replayable case), and the writer
1825            // below keys its re-emission decision off this metadata key's
1826            // PRESENCE, not its content.
1827            if thinking_block_seen {
1828                msg.metadata.insert("thinking".to_string(), thinking);
1829            }
1830            if let Some(sig) = signature {
1831                msg.metadata.insert("thinking_signature".to_string(), sig);
1832            }
1833            if let Some(rt) = redacted_thinking {
1834                msg.metadata.insert("redacted_thinking".to_string(), rt);
1835            }
1836            // D8: exact per-block re-emission list — every `thinking`/
1837            // `redacted_thinking` block preserved separately, in order, each
1838            // with its own (optional) signature/data. The writer prefers
1839            // this over the legacy singular fields above whenever present,
1840            // so a multi-block message round-trips losslessly instead of
1841            // collapsing to one block under one (now-unverifiable)
1842            // signature.
1843            if !thinking_blocks.is_empty() {
1844                msg.metadata.insert(
1845                    "thinking_blocks".to_string(),
1846                    Value::Array(thinking_blocks).to_string(),
1847                );
1848            }
1849            // D5: honest signal that this message contained an image block
1850            // whose source this loader couldn't convert — the actual image
1851            // content is NOT captured, only a marker/partial record.
1852            if saw_unconvertible_image {
1853                msg.metadata
1854                    .insert("image_source_unconvertible".to_string(), "true".to_string());
1855            }
1856            // Attribution: which skill / subagent / MCP server+tool produced
1857            // this turn, plus the model `slug`.
1858            for key in [
1859                "attributionSkill",
1860                "attributionAgent",
1861                "attributionMcpServer",
1862                "attributionMcpTool",
1863                "slug",
1864            ] {
1865                if let Some(s) = v.get(key).and_then(Value::as_str) {
1866                    msg.metadata.insert(key.to_string(), s.to_string());
1867                }
1868            }
1869        }
1870    }
1871}
1872
1873/// PARITY-23: Claude Code records that affect replay or carry source-native
1874/// state but have no canonical home — the claude-code residue inventory
1875/// (design doc §per-format). `last-prompt` steers leaf selection,
1876/// `fork-context-ref` anchors the replay graph, `file-history-snapshot` /
1877/// `queue-operation` / `mode` are durable native state.
1878pub(super) fn claude_residue_kind(record: &Value) -> Option<&'static str> {
1879    match record.get("type").and_then(Value::as_str) {
1880        Some("file-history-snapshot") => Some("file-history-snapshot"),
1881        Some("queue-operation") => Some("queue-operation"),
1882        Some("last-prompt") => Some("last-prompt"),
1883        Some("mode") => Some("mode"),
1884        Some("fork-context-ref") => Some("fork-context-ref"),
1885        _ => None,
1886    }
1887}
1888
1889/// Re-emit a claude residue record under the session's CURRENT metadata:
1890/// `sessionId`/`cwd` keys are rewritten only when they exist and differ (an
1891/// explicit `--session-id`/`--cwd` override is a deliberate rewrite request
1892/// that extends to residue). When nothing differs — the plain return
1893/// diagonal — the raw line is emitted byte-exact.
1894fn claude_residue_line_for_emit(raw: &str, session_id: &str, cwd: &str) -> String {
1895    let Ok(mut value) = serde_json::from_str::<Value>(raw) else {
1896        return raw.to_string();
1897    };
1898    let Some(object) = value.as_object_mut() else {
1899        return raw.to_string();
1900    };
1901    let mut changed = false;
1902    for (key, current) in [("sessionId", session_id), ("cwd", cwd)] {
1903        if object
1904            .get(key)
1905            .and_then(Value::as_str)
1906            .is_some_and(|existing| existing != current)
1907        {
1908            object.insert(key.to_string(), Value::String(current.to_string()));
1909            changed = true;
1910        }
1911    }
1912    if changed {
1913        value.to_string()
1914    } else {
1915        raw.to_string()
1916    }
1917}
1918
1919fn capture_claude_residue(
1920    meta: &mut SessionMeta,
1921    record_index: usize,
1922    raw_line: &str,
1923    record: &Value,
1924) {
1925    let Some(kind) = claude_residue_kind(record) else {
1926        return;
1927    };
1928    capture_native_residue(meta, "claude_code", record_index, raw_line, record, kind);
1929}
1930
1931/// R1 (Skeptic B, spliced-export uuid-collision hardening — the same bug
1932/// class N2 closed for the Codex spliced path's group ids, see
1933/// `collect_codex_group_ids_from_raw`/`write_codex_records`): mint the next
1934/// `synth_uuid`, skipping any value already present in `seed_used_ids` — the
1935/// uuids [`collect_claude_uuids_from_raw`] found already sitting in the
1936/// verbatim raw prefix [`Session::to_claude_code_jsonl_spliced`] replays
1937/// ahead of the tail this counter mints. Without this, re-splicing a
1938/// previously-exported-then-reimported session (export -> reimport -> append
1939/// -> export again) restarts `counter` at 1 with no memory of the prior
1940/// export's tail uuids now sitting in the prefix, so the second tail
1941/// fabricates the SAME `00000000-0000-4000-8000-…` values the first one did
1942/// — a uuid collision across prefix and tail that can mis-link any
1943/// uuid-keyed consumer (fork/tree lineage, `parentUuid`). `counter` keeps
1944/// climbing monotonically even across skips. `used_ids` is also updated for
1945/// each minted or metadata-backed identity, so collisions are prevented both
1946/// against the replayed prefix and within the appended tail.
1947fn next_claude_uuid(counter: &mut usize, used_ids: &mut HashSet<String>) -> String {
1948    loop {
1949        let candidate = synth_uuid(*counter);
1950        *counter += 1;
1951        if used_ids.insert(candidate.clone()) {
1952            return candidate;
1953        }
1954    }
1955}
1956
1957/// Reuse a message's durable native/source UUID when available, falling back
1958/// to the deterministic synthesized sequence only for hand-built or legacy
1959/// messages that never carried identity metadata.
1960fn claude_message_uuid(
1961    msg: &ChatMessage,
1962    counter: &mut usize,
1963    used_ids: &mut HashSet<String>,
1964) -> String {
1965    for key in ["claude_uuid", "supercode_native_uuid"] {
1966        if let Some(candidate) = msg.metadata.get(key) {
1967            if !candidate.is_empty() && used_ids.insert(candidate.clone()) {
1968                return candidate.clone();
1969            }
1970        }
1971    }
1972    next_claude_uuid(counter, used_ids)
1973}
1974
1975/// Companion to [`next_claude_uuid`]: every `uuid` already present in
1976/// `raw_prefix` — the verbatim RAW lines
1977/// [`Session::to_claude_code_jsonl_spliced`] replays ahead of the appended
1978/// tail it synthesizes via [`Session::write_claude_code_records`]. This is
1979/// the GROUND TRUTH of what physically lands in the exported `out` string
1980/// for the prefix (mirrors `collect_codex_group_ids_from_raw`'s approach on
1981/// the Codex side): each line is parsed as a Claude Code JSONL record and
1982/// its own top-level `uuid` field is read back out of the bytes directly, no
1983/// re-derivation from `self.messages` needed. A line that fails to parse, or
1984/// parses but carries no `uuid` (e.g. a trailing `file-history-snapshot`
1985/// record), contributes nothing.
1986fn collect_claude_uuids_from_raw(raw_prefix: &[String]) -> HashSet<String> {
1987    let mut ids = HashSet::new();
1988    for line in raw_prefix {
1989        if let Ok(v) = serde_json::from_str::<Value>(line) {
1990            if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
1991                ids.insert(uuid.to_string());
1992            }
1993        }
1994    }
1995    ids
1996}
1997
1998impl Session {
1999    /// Synthesize a Claude Code transcript.
2000    ///
2001    /// Claude Code transcripts have no slot for the *session-level system
2002    /// prompt* (that lives in the CLI, not the log) — but PARITY-6 dev/02
2003    /// (Fable-5 corpus audit) surfaced that content-bearing `System`
2004    /// `ChatMessage`s (Claude's own `type: "system"` records with a
2005    /// content-bearing `subtype` like `local_command`/`scheduled_task_fire`/
2006    /// `away_summary` — see `push_claude_system`, the exact inverse of what
2007    /// this writer now does) DO have a first-class slot: the real `type:
2008    /// "system"` record itself. This function used to unconditionally drop
2009    /// every `System` message, silently losing e.g. a real
2010    /// `<local-command-stdout>` record on any format -> Claude Code hop
2011    /// (confirmed on a real 2,982-message corpus session: Codex -> Claude
2012    /// Code dropped its one surviving `system` message, 2215 -> 2214, with
2013    /// no loss manifest). `write_claude_code_records`'s `Role::System` arm
2014    /// now re-materializes it instead.
2015    pub(super) fn to_claude_code_jsonl(&self) -> String {
2016        let session_id = self
2017            .meta
2018            .session_id
2019            .clone()
2020            .unwrap_or_else(|| synth_uuid(0));
2021        let cwd = self.cwd_string();
2022        let mut out = String::new();
2023        // PARITY-10: a captured `fork-context-ref` (see `capture_claude_meta`)
2024        // re-emitted byte-for-byte, ahead of the conversation it applies to —
2025        // this is what makes the record survive the SEMANTIC Claude Code
2026        // writer (the raw-passthrough diagonal in `crates/cli` already
2027        // preserves it by construction; this covers the library `to_jsonl`
2028        // path too, e.g. a `--session-id` override that forces the semantic
2029        // writer).
2030        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
2031            out.push_str(raw);
2032            out.push('\n');
2033        }
2034        // Full synthesis: `out` at this point has no raw prefix ahead of it
2035        // (unlike the A12 splice below), so there are no uuids yet in play
2036        // to seed against — see `next_claude_uuid`'s doc comment.
2037        self.write_claude_code_records(
2038            &mut out,
2039            &self.messages,
2040            &session_id,
2041            &cwd,
2042            None,
2043            1,
2044            &HashSet::new(),
2045        );
2046        // PARITY-23: claude-source residue restored from a foreign hop is
2047        // NATIVE here again — re-emit the exact source records (relative
2048        // order preserved) instead of wrapping them in an envelope.
2049        if self.meta.native_residue_source.as_deref() == Some("claude_code") {
2050            let mut records: Vec<&Value> = self.meta.native_residue.iter().collect();
2051            records.sort_by_key(|entry| {
2052                entry
2053                    .get("record_index")
2054                    .and_then(Value::as_u64)
2055                    .unwrap_or(u64::MAX)
2056            });
2057            for entry in records {
2058                if let Some(raw) = entry.get("raw").and_then(Value::as_str) {
2059                    out.push_str(&claude_residue_line_for_emit(raw, &session_id, &cwd));
2060                    out.push('\n');
2061                }
2062            }
2063        } else if let Some(extension) = native_residue_envelope(&self.meta) {
2064            if out.is_empty() {
2065                push_jsonl(
2066                    &mut out,
2067                    &serde_json::json!({
2068                        "type": "file-history-snapshot",
2069                        "messageId": synth_uuid(1),
2070                        "snapshot": {},
2071                        "sessionId": session_id,
2072                        "cwd": cwd,
2073                        "timestamp": SYNTH_TS,
2074                    }),
2075                );
2076            }
2077            inject_first_jsonl_top_level(
2078                &mut out,
2079                SUPERCODE_NATIVE_RESIDUE_SUMMARY_KEY,
2080                native_residue_summary(&extension),
2081            );
2082            inject_first_jsonl_top_level(&mut out, SUPERCODE_NATIVE_RESIDUE_KEY, extension);
2083        }
2084        out
2085    }
2086
2087    /// Synthesize Claude Code records for `messages` (a full session or an
2088    /// appended tail — A12's [`Self::to_jsonl_spliced`] reuses this for just
2089    /// the latter), starting the `parentUuid` chain at `parent` and the
2090    /// `synth_uuid` counter at `counter`. Factored out of
2091    /// [`Self::to_claude_code_jsonl`] so the record shape is defined once.
2092    ///
2093    /// `seed_used_ids` primes [`next_claude_uuid`]'s collision guard with
2094    /// every uuid that will ALREADY be present in `out` before this call
2095    /// ever runs — see that function's doc comment for why the A12 splice
2096    /// path needs this and full synthesis doesn't.
2097    // R1: this was already at clippy's `too_many_arguments` threshold (7,
2098    // including `&self`) before the fix; the added `seed_used_ids` param
2099    // pushes it to 8. Every argument here is independently meaningful (two
2100    // record-shape inputs, two id/parent-chain threading values, and now
2101    // the collision seed) — bundling them into a params struct is a larger
2102    // refactor of this already-widely-called private helper than the R1 fix
2103    // warrants, so this is allowed rather than restructured.
2104    #[allow(clippy::too_many_arguments)]
2105    fn write_claude_code_records(
2106        &self,
2107        out: &mut String,
2108        messages: &[ChatMessage],
2109        session_id: &str,
2110        cwd: &str,
2111        mut parent: Option<String>,
2112        mut counter: usize,
2113        seed_used_ids: &HashSet<String>,
2114    ) {
2115        let mut used_ids = seed_used_ids.clone();
2116        for msg in messages {
2117            if is_replay_excluded(msg) {
2118                continue;
2119            }
2120            let blocks: Vec<Value> = match msg.role {
2121                // PARITY-6 dev/02: re-materialize a content-bearing System
2122                // `ChatMessage` as a real Claude Code `type: "system"`
2123                // record — the exact inverse of `push_claude_system`, which
2124                // is what produced it in the first place for a message
2125                // loaded FROM a real Claude Code transcript. `subtype`
2126                // prefers the original `systemSubtype` metadata
2127                // (`push_claude_system`'s `.with_meta`, round-tripped
2128                // through the Codex hop via `write_codex_records`'s
2129                // `claude_system_subtype` metadata channel and restored by
2130                // `push_codex_item`); when that channel didn't carry it
2131                // (e.g. a genuinely native, non-Claude-origin developer
2132                // message), fall back to `local_command` — the observed
2133                // common case, and still one of `push_claude_system`'s own
2134                // `keep` subtypes, so the record survives a *subsequent*
2135                // reload rather than being silently re-dropped. This never
2136                // fabricates content: the real text is always carried
2137                // verbatim, only the subtype label is a best-effort guess
2138                // when the true one wasn't recoverable.
2139                Role::System => {
2140                    let content = msg.content.clone().unwrap_or_default();
2141                    if content.trim().is_empty() {
2142                        continue;
2143                    }
2144                    let subtype = msg
2145                        .metadata
2146                        .get("systemSubtype")
2147                        .cloned()
2148                        .unwrap_or_else(|| "local_command".to_string());
2149                    // R1/B3 union: this mint must ALSO route through
2150                    // `next_claude_uuid` + `seed_used_ids` like the other
2151                    // three arms below — otherwise this System arm (added by
2152                    // B3 after R1 landed) mints a raw `synth_uuid` that can
2153                    // collide with a uuid already sitting in the A12 splice's
2154                    // raw prefix (see `next_claude_uuid`'s doc comment).
2155                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
2156                    let mut line = serde_json::json!({
2157                        "parentUuid": parent,
2158                        "type": "system",
2159                        "subtype": subtype,
2160                        "content": content,
2161                        "uuid": uuid,
2162                        "sessionId": session_id,
2163                        "cwd": cwd,
2164                        "timestamp": msg_timestamp_or_synth(msg),
2165                    });
2166                    set_grok_message_extension(&mut line, self.meta.source, msg);
2167                    push_jsonl(out, &line);
2168                    parent = Some(uuid);
2169                    continue;
2170                }
2171                Role::User => {
2172                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
2173                    let mut line = serde_json::json!({
2174                        "parentUuid": parent,
2175                        "type": "user",
2176                        "message": {
2177                            "role": "user",
2178                            "content": claude_user_content_value(msg),
2179                        },
2180                        "uuid": uuid,
2181                        "sessionId": session_id,
2182                        "cwd": cwd,
2183                        "timestamp": msg_timestamp_or_synth(msg),
2184                    });
2185                    set_grok_message_extension(&mut line, self.meta.source, msg);
2186                    push_jsonl(out, &line);
2187                    parent = Some(uuid);
2188                    continue;
2189                }
2190                Role::Tool => {
2191                    let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
2192                    let mut line = serde_json::json!({
2193                        "parentUuid": parent,
2194                        "type": "user",
2195                        "message": {
2196                            "role": "user",
2197                            "content": [{
2198                                "type": "tool_result",
2199                                "tool_use_id": msg.tool_call_id.clone().unwrap_or_default(),
2200                                "content": claude_tool_result_content_value(msg),
2201                            }],
2202                        },
2203                        "uuid": uuid,
2204                        "sessionId": session_id,
2205                        "cwd": cwd,
2206                        "timestamp": msg_timestamp_or_synth(msg),
2207                    });
2208                    if crate::tool_outcome(msg) == crate::ToolOutcome::Unknown {
2209                        line[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
2210                    }
2211                    set_grok_message_extension(&mut line, self.meta.source, msg);
2212                    push_jsonl(out, &line);
2213                    parent = Some(uuid);
2214                    continue;
2215                }
2216                Role::Assistant => {
2217                    let mut blocks = Vec::new();
2218                    // PARITY-16 (found via the REAL pi corpus, PARITY-5
2219                    // dev/01): thinking/redacted_thinking must be re-emitted
2220                    // BEFORE text/tool_use, unconditionally whenever
2221                    // retained metadata is present — not only when `blocks`
2222                    // is otherwise empty. The previous `if blocks.is_empty()`
2223                    // gate (now below, applied unconditionally instead)
2224                    // meant a turn that thinks AND THEN answers/calls a tool
2225                    // in the SAME turn — pi's own default emission shape,
2226                    // and the overwhelmingly common real-world case for any
2227                    // reasoning model, not the rare reasoning-only edge case
2228                    // this gate's comment described — silently dropped its
2229                    // entire `thinking` block on Pi -> Claude Code export. A
2230                    // genuine multi-turn pi session driven through pi's own
2231                    // real Agent loop (faux provider, see
2232                    // `pi_interop.rs`'s live-corpus tests) exposed this: its
2233                    // thinking+text turns lost the thinking block entirely.
2234                    // D8: prefer the exact per-block list when present —
2235                    // every `thinking`/`redacted_thinking` block re-emitted
2236                    // SEPARATELY with its own signature/data, exactly as
2237                    // captured (`push_claude_assistant`), instead of the
2238                    // legacy singular fields' lossy collapse (which drops
2239                    // every signature but the last one's on a multi-block
2240                    // message). Falls back to the legacy fields only for a
2241                    // `Session` that never populated `thinking_blocks` (e.g.
2242                    // hand-constructed in another loader/test, or loaded
2243                    // from a non-Claude-Code source like Pi).
2244                    match msg
2245                        .metadata
2246                        .get("thinking_blocks")
2247                        .and_then(|s| serde_json::from_str::<Value>(s).ok())
2248                        .and_then(|v| v.as_array().cloned())
2249                    {
2250                        Some(saved_blocks) => blocks.extend(saved_blocks),
2251                        None => {
2252                            if let Some(t) = msg.metadata.get("thinking") {
2253                                let mut block =
2254                                    serde_json::json!({"type": "thinking", "thinking": t});
2255                                if let Some(sig) = msg.metadata.get("thinking_signature") {
2256                                    block["signature"] = Value::String(sig.clone());
2257                                }
2258                                blocks.push(block);
2259                            }
2260                            if let Some(rt) = msg.metadata.get("redacted_thinking") {
2261                                blocks.push(
2262                                    serde_json::json!({"type": "redacted_thinking", "data": rt}),
2263                                );
2264                            }
2265                        }
2266                    }
2267                    if let Some(t) = &msg.content {
2268                        if !t.is_empty() {
2269                            blocks.push(serde_json::json!({"type": "text", "text": t}));
2270                        }
2271                    }
2272                    // PARITY-11: an assistant-emitted image (`content_parts`,
2273                    // e.g. a generated image — `push_claude_assistant`'s
2274                    // load-side counterpart) has no slot in `msg.content`;
2275                    // without this, `blocks` stayed empty for an image-only
2276                    // turn and the whole message vanished on Claude Code
2277                    // semantic export, same failure mode the IX-6 Codex
2278                    // writer fix already closed on that side.
2279                    if let Some(parts) = &msg.content_parts {
2280                        for p in parts {
2281                            if p.get("type").and_then(Value::as_str) == Some("image_url") {
2282                                if let Some(url) = p
2283                                    .get("image_url")
2284                                    .and_then(|u| u.get("url"))
2285                                    .and_then(Value::as_str)
2286                                {
2287                                    blocks.push(match parse_data_uri(url) {
2288                                        Some((mime, data)) => serde_json::json!({
2289                                            "type": "image",
2290                                            "source": {"type": "base64", "media_type": mime, "data": data},
2291                                        }),
2292                                        None => serde_json::json!({
2293                                            "type": "image",
2294                                            "source": {"type": "url", "url": url},
2295                                        }),
2296                                    });
2297                                }
2298                            }
2299                        }
2300                    }
2301                    for tc in msg.tool_calls() {
2302                        let input = tc
2303                            .function
2304                            .parsed_arguments()
2305                            .unwrap_or_else(|_| Value::Object(Default::default()));
2306                        blocks.push(serde_json::json!({
2307                            "type": "tool_use",
2308                            "id": tc.id,
2309                            "name": tc.function.name,
2310                            "input": input,
2311                        }));
2312                    }
2313                    // PARITY-11 / PARITY-16: a genuinely reasoning-only turn
2314                    // (no text, no tool_use, no image) still doesn't vanish
2315                    // — the thinking/redacted_thinking prepend above already
2316                    // ran unconditionally, so `blocks` is non-empty here
2317                    // whenever any of those were present.
2318                    blocks
2319                }
2320            };
2321
2322            // An empty assistant content array is a valid native interrupted
2323            // turn and must remain a record. Every non-assistant arm above
2324            // already `continue`s after writing its own shape, so an empty
2325            // `blocks` value here belongs specifically to that assistant.
2326            let uuid = claude_message_uuid(msg, &mut counter, &mut used_ids);
2327            let mut message = serde_json::json!({"role": "assistant", "content": blocks});
2328            if let Some(model) = msg.metadata.get("model").or(self.meta.model.as_ref()) {
2329                message["model"] = Value::String(model.clone());
2330            }
2331            let mut line = serde_json::json!({
2332                "parentUuid": parent,
2333                "type": "assistant",
2334                "message": message,
2335                "uuid": uuid,
2336                "sessionId": session_id,
2337                "cwd": cwd,
2338                "timestamp": msg_timestamp_or_synth(msg),
2339            });
2340            set_grok_message_extension(&mut line, self.meta.source, msg);
2341            push_jsonl(out, &line);
2342            parent = Some(uuid);
2343        }
2344    }
2345
2346    /// A12 splice: replay the imported Claude Code `raw` prefix verbatim
2347    /// (patching `sessionId` on each line when `session_id` is `Some`), then
2348    /// synthesize records only for the appended tail, via
2349    /// [`Self::write_claude_code_records`] — chaining `parentUuid` from the
2350    /// last original `uuid` found anywhere in the raw prefix (not just its
2351    /// final line: a trailing loader-skipped record, e.g.
2352    /// `file-history-snapshot`, may carry no `uuid` of its own).
2353    pub(super) fn to_claude_code_jsonl_spliced(&self, session_id: Option<&str>) -> String {
2354        let (raw_prefix_len, message_prefix_len) = self.spliced_prefix_lens();
2355        let sid = session_id
2356            .map(str::to_string)
2357            .or_else(|| self.meta.session_id.clone())
2358            .unwrap_or_else(|| synth_uuid(0));
2359        let cwd = self.cwd_string();
2360
2361        let mut out = String::new();
2362        let mut parent: Option<String> = None;
2363        for line in &self.raw[..raw_prefix_len] {
2364            push_spliced_line(&mut out, line, session_id, "sessionId");
2365            if let Ok(v) = serde_json::from_str::<Value>(line) {
2366                if let Some(uuid) = v.get("uuid").and_then(Value::as_str) {
2367                    parent = Some(uuid.to_string());
2368                }
2369            }
2370        }
2371
2372        // R1 (spliced-path hardening, mirrors the Codex N2 fix above): seed
2373        // the tail's collision guard with every uuid the just-replayed RAW
2374        // prefix already carries, so `write_claude_code_records` never
2375        // fabricates a `synth_uuid` for the appended tail that collides with
2376        // one already sitting in the prefix (see `next_claude_uuid`'s and
2377        // `collect_claude_uuids_from_raw`'s doc comments).
2378        let seed_used_ids = collect_claude_uuids_from_raw(&self.raw[..raw_prefix_len]);
2379        self.write_claude_code_records(
2380            &mut out,
2381            &self.messages[message_prefix_len..],
2382            &sid,
2383            &cwd,
2384            parent,
2385            1,
2386            &seed_used_ids,
2387        );
2388        out
2389    }
2390}
2391
2392#[cfg(test)]
2393mod tests {
2394    use super::*;
2395
2396    /// Pin of the single-pass indexer against the relevant Claude tool-result
2397    /// shape (SUP-21). An id absent from the transcript must map to nothing.
2398    #[test]
2399    fn parent_tool_use_index_matches_known_fixture_linkage() {
2400        let main_text = r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01SYjhg9qRCzUWY2GTa3iazQ","type":"tool_result","content":[{"type":"text","text":"agentId: ad8dc6cf98b49eea6"}]}]},"toolUseResult":{"agentId":"ad8dc6cf98b49eea6"}}"#;
2401
2402        let ids = vec![
2403            "ad8dc6cf98b49eea6".to_string(),
2404            "no-such-agent-id".to_string(),
2405        ];
2406        let index = parent_tool_use_index(main_text, &ids);
2407
2408        assert_eq!(
2409            index.get("ad8dc6cf98b49eea6").map(String::as_str),
2410            Some("toolu_01SYjhg9qRCzUWY2GTa3iazQ"),
2411            "known agent id must resolve to the pinned parent tool_use_id"
2412        );
2413        assert_eq!(
2414            index.get("no-such-agent-id"),
2415            None,
2416            "unknown agent id must yield no entry (best-effort None)"
2417        );
2418    }
2419
2420    #[test]
2421    fn parent_tool_use_index_empty_ids_returns_empty_map() {
2422        let index = parent_tool_use_index("irrelevant text", &[]);
2423        assert!(index.is_empty());
2424    }
2425}