Skip to main content

supercode_interchange/session/
opencode.rs

1//! OpenCode session codec: JSON/SQLite loaders, writers and helpers.
2
3use super::*;
4
5impl Session {
6    /// Load an OpenCode session from a file — either read surface, see
7    /// [`Self::from_opencode_str`].
8    pub fn from_opencode(path: impl AsRef<Path>) -> Result<Session> {
9        Self::from_opencode_str(&std::fs::read_to_string(path.as_ref())?)
10    }
11
12    pub fn from_opencode_sqlite(db_path: &Path, session_id: Option<&str>) -> Result<Session> {
13        let conn = opencode_sqlite_open(db_path)?;
14        let id = match session_id {
15            Some(id) => id.to_string(),
16            None => opencode_sqlite_primary_session_id(&conn)?,
17        };
18        let lines = opencode_sqlite_session_envelope_lines(&conn, db_path, &id)?;
19        let mut text = lines.join("\n");
20        text.push('\n');
21        let mut session = Self::from_opencode_str(&text)?;
22        // D4: `text` above is a SYNTHESIZED reconstruction from SQL rows —
23        // not the original source bytes (a binary `.db` file has no
24        // "verbatim" line-oriented form to begin with). `from_opencode_str`
25        // defaults `raw_is_verbatim` to `true` because for its OTHER two
26        // callers (an actual envelope-form file's own text, an actual
27        // export-document's text) that really is the source. It is NEVER
28        // true for this diagonal — mirrors the export-document fix just
29        // above for the same reason (`from_opencode_export_doc`, `false`).
30        // `convert opencode.db --to opencode` must not claim byte-identical.
31        session.raw_is_verbatim = false;
32        Ok(session)
33    }
34
35    /// Parse an OpenCode session from either of its two frozen **read
36    /// surfaces** (`docs/interop/opencode-pi-spec.md` §1.2, `S9a`,
37    /// `opencode-fields.md`):
38    ///
39    /// - the **envelope form**: each line is
40    ///   `{"key":[<storage key path>],"value":<record>}`, minified — the
41    ///   synthesized raw-capture unit for the JSON-tree/SQLite storage
42    ///   generations;
43    /// - the **export-document form**: a single pretty-printed JSON document
44    ///   `{info: SessionInfo, messages:[{info: Message, parts:[Part]}, …]}`
45    ///   — the `opencode export`/`import` interchange shape, and EXACTLY
46    ///   what the OpenCode writer emits.
47    ///
48    /// Both forms are parsed into the same `(session_info, side_records,
49    /// Vec<OcMsg>)` shape and funnel through the SAME shared canonicalizer,
50    /// `opencode_session_from_records` — so the same underlying records
51    /// produce identical `messages` regardless of which surface carried
52    /// them in. This is what makes `load(to_opencode_jsonl(S))` round-trip
53    /// (§4.1's OpenCode diagonal, the exact circuit the fidelity matrix
54    /// exercises): previously this function parsed the envelope form only
55    /// and silently returned an empty-but-`Ok` `Session` for an export
56    /// document — the confirmed footgun this now closes.
57    ///
58    /// Record classification (envelope form) is driven by the envelope
59    /// `key`'s first component (`"session"` / `"message"` / `"part"` /
60    /// `"session_diff"` / `"todo"`) — the frozen key scheme for all three
61    /// storage generations plus SQLite rows (§1.2 S9c: a SQLite row's
62    /// envelope synthesizes `key:["<table>","<ses>",...ids]` and MUST carry
63    /// every column, `data` and non-`data` alike — e.g. the `session` row's
64    /// `revert` column under the V2 `Revert.State` schema, whose extra
65    /// `files` field the CLI's own row→V1 reconstruction drops; the
66    /// envelope's `raw` capture keeps that raw column value regardless of
67    /// what this loader's canonicalization understands).
68    ///
69    /// Mapping to canonical `messages` (§2.1, shared by both forms via
70    /// `push_opencode_user`/`push_opencode_assistant`): `User`/`Assistant`
71    /// text parts → `content`; a `User` `file` part whose `mime` is an image
72    /// and whose `url` is a `data:` URI → an `image_url` `content_parts`
73    /// entry; an `Assistant` `tool` part's `callID` + `state.*.input` → a
74    /// `ToolCall`, and the SAME part's `state.completed.output` /
75    /// `state.error.error` → a paired `Tool` message split by `callID`
76    /// (opencode keeps call+result on one record; this loader splits it
77    /// into the two OpenAI-shape messages the other loaders already
78    /// produce).
79    ///
80    /// **S1 (`time.compacted`):** when a `tool` part's
81    /// `state.completed.time.compacted` is set, the emitted `Tool`
82    /// message's `content` is the placeholder
83    /// [`OPENCODE_COMPACTED_TOOL_PLACEHOLDER`] — mirroring what opencode's
84    /// own `toModelMessage` replays — while the REAL output survives in
85    /// `raw` (always) and in `metadata["oc_tool_output_compacted"]` (full
86    /// text) + `metadata["oc_tool_time_compacted"]` (the mask timestamp), so
87    /// it is reversible, never actually lost.
88    ///
89    /// **Compaction boundary:** a `compaction` part's `tail_start_id` marks
90    /// every message strictly before that message id
91    /// `metadata["compacted_out"]="true"` (honored uniformly by
92    /// `is_replay_excluded`) — except a `summary:true` `Assistant`
93    /// message, which opencode itself hoists in FRONT of the retained tail
94    /// on replay (`message-v2.ts:521-572`) and so must never be excluded
95    /// regardless of its position, mirroring pi's identical exemption for
96    /// its own compaction/branch-summary entries.
97    ///
98    /// **Unknown part `type` or unknown `tool.state.status`:** never
99    /// canonicalized — raw-only survival, exactly like an unmodeled Pi
100    /// `message.role` (S6-style fail-loud). The OpenCode corpus audit
101    /// is what turns that into a visible coverage failure rather than a
102    /// silent drop.
103    ///
104    /// **Export-document `raw`:** an export document is a single
105    /// pretty-printed JSON value with no per-line envelope structure of its
106    /// own to capture verbatim, so `raw` here is RE-SYNTHESIZED — one
107    /// envelope line per `session`/`message`/`part` record found in the
108    /// document, in the exact `{"key":[...],"value":...}` shape the native
109    /// envelope form uses — so every native/T1-value-tier path
110    /// (`to_native_jsonl`, `opencode_records_from_raw`, the
111    /// splice/direct-write writers) stays consistent regardless of which
112    /// read surface produced this `Session`.
113    ///
114    /// **Malformed input:** input that reaches this function non-empty but
115    /// yields zero session/message/part records under EITHER form returns a
116    /// clear `Err` rather than a silently-empty `Ok(Session)` — the
117    /// confirmed footgun (`supercode resume`/`convert`/`inspect` on such
118    /// input must not silently succeed with an empty session). A
119    /// legitimately-empty session — a real `session` record with zero
120    /// messages, or a valid export document with an empty `messages` array
121    /// — is not an error.
122    pub fn from_opencode_str(text: &str) -> Result<Session> {
123        let trimmed = text.trim();
124
125        // Export-document form first (§1.2/S9a) — mirrors `detect_source`'s
126        // own precedence: try the whole-text parse before the per-line
127        // envelope loop below, since a pretty-printed multi-line document
128        // has no individually-valid-JSON lines for that loop to match.
129        if let Ok(doc) = serde_json::from_str::<Value>(trimmed) {
130            if doc.get("info").is_some() && doc.get("messages").and_then(Value::as_array).is_some()
131            {
132                return Self::from_opencode_export_doc(&doc);
133            }
134        }
135
136        // Envelope form: each line `{"key":[<storage key path>],"value":<record>}`.
137        // IX-1: `raw` is captured STRICT-VERBATIM — separate from the
138        // blank-skipping PARSE walk just below, which keeps skipping
139        // blank/whitespace-only lines when it looks for envelope records.
140        let (raw_lines, raw_trailing_newline) = split_lines_verbatim(text);
141        let raw: Vec<String> = raw_lines.iter().map(|l| l.to_string()).collect();
142        let mut session_info: Option<Value> = None;
143        let mut side_records: Vec<Value> = Vec::new();
144        let mut msgs: Vec<OcMsg> = Vec::new();
145        let mut msg_index: HashMap<String, usize> = HashMap::new();
146        // PARITY-15: see `from_claude_code_str`'s identical counter — only
147        // a genuinely malformed line (fails to deserialize as JSON at all),
148        // not a well-formed envelope this loader simply doesn't recognize.
149        let mut parse_error_lines = 0usize;
150
151        for line in non_empty_lines(text) {
152            let Ok(env) = serde_json::from_str::<Value>(line) else {
153                parse_error_lines += 1;
154                continue; // malformed line — raw-only, exactly like the other loaders
155            };
156            let Some(key) = env.get("key").and_then(Value::as_array) else {
157                continue; // not an envelope record — raw-only
158            };
159            let value = env.get("value").cloned().unwrap_or(Value::Null);
160            match key.first().and_then(Value::as_str) {
161                Some("session") => session_info = Some(value),
162                Some("message") => {
163                    let Some(id) = value.get("id").and_then(Value::as_str) else {
164                        continue;
165                    };
166                    let time_created = value
167                        .get("time")
168                        .and_then(|t| t.get("created"))
169                        .and_then(Value::as_i64)
170                        .unwrap_or(0);
171                    msg_index.insert(id.to_string(), msgs.len());
172                    msgs.push(OcMsg {
173                        id: id.to_string(),
174                        time_created,
175                        value,
176                        parts: Vec::new(),
177                    });
178                }
179                Some("part") => {
180                    if let Some(msg_id) = value.get("messageID").and_then(Value::as_str) {
181                        if let Some(&idx) = msg_index.get(msg_id) {
182                            msgs[idx].parts.push(value);
183                        }
184                        // A part whose message wasn't captured (out-of-order
185                        // envelope) — still fully present in `raw`, just not
186                        // attached to a canonical message.
187                    }
188                }
189                Some("session_diff") | Some("todo") => {
190                    side_records.push(serde_json::json!({"key": key, "value": value}));
191                }
192                _ => {} // unrecognized top-level key — raw-only
193            }
194        }
195
196        opencode_guard_against_silent_empty(
197            !trimmed.is_empty(),
198            &session_info,
199            &msgs,
200            &side_records,
201        )?;
202        opencode_session_from_records(
203            session_info,
204            side_records,
205            msgs,
206            raw,
207            raw_trailing_newline,
208            // Envelope form: `raw` is split directly out of the source text
209            // (strict-verbatim, IX-1) — genuinely reproduces the original
210            // bytes on replay.
211            true,
212            parse_error_lines,
213        )
214    }
215
216    /// The **export-document** read surface of [`Self::from_opencode_str`]
217    /// — see that function's doc comment for the shared canonicalization
218    /// and the `raw` re-synthesis this performs. `doc` is already known to
219    /// have the `{info, messages:[...]}` shape (the caller checks this,
220    /// matching `detect_source`'s own S9a check) before calling this.
221    fn from_opencode_export_doc(doc: &Value) -> Result<Session> {
222        let session_info = doc.get("info").cloned().filter(|v| !v.is_null());
223        let messages_arr = doc
224            .get("messages")
225            .and_then(Value::as_array)
226            .cloned()
227            .unwrap_or_default();
228
229        let session_id = session_info
230            .as_ref()
231            .and_then(|si| si.get("id"))
232            .and_then(Value::as_str)
233            .unwrap_or("ses_unknown")
234            .to_string();
235        let project_id = session_info
236            .as_ref()
237            .and_then(|si| si.get("projectID"))
238            .and_then(Value::as_str)
239            .unwrap_or("global")
240            .to_string();
241
242        // Re-synthesize one envelope line per record — see the doc comment
243        // on `from_opencode_str` ("Export-document `raw`").
244        let mut raw: Vec<String> = Vec::new();
245        if let Some(si) = &session_info {
246            raw.push(
247                serde_json::json!({"key": ["session", project_id, session_id], "value": si})
248                    .to_string(),
249            );
250        }
251
252        let mut msgs: Vec<OcMsg> = Vec::new();
253        for entry in &messages_arr {
254            let Some(info) = entry.get("info") else {
255                continue; // malformed message entry — no clean home, raw-only
256            };
257            let Some(id) = info.get("id").and_then(Value::as_str) else {
258                continue;
259            };
260            let time_created = info
261                .get("time")
262                .and_then(|t| t.get("created"))
263                .and_then(Value::as_i64)
264                .unwrap_or(0);
265            let parts: Vec<Value> = entry
266                .get("parts")
267                .and_then(Value::as_array)
268                .cloned()
269                .unwrap_or_default();
270
271            raw.push(
272                serde_json::json!({"key": ["message", session_id, id], "value": info}).to_string(),
273            );
274            for p in &parts {
275                let part_id = p.get("id").and_then(Value::as_str).unwrap_or("");
276                raw.push(serde_json::json!({"key": ["part", id, part_id], "value": p}).to_string());
277            }
278
279            msgs.push(OcMsg {
280                id: id.to_string(),
281                time_created,
282                value: info.clone(),
283                parts,
284            });
285        }
286
287        opencode_guard_against_silent_empty(true, &session_info, &msgs, &[])?;
288        opencode_session_from_records(
289            session_info,
290            Vec::new(),
291            msgs,
292            raw,
293            // Synthesized `raw` (§1.2's "Export-document raw" — one envelope
294            // line re-derived per record, no real per-line source bytes to
295            // measure) — matches the historical always-newline-terminated
296            // behavior; see `Session::raw_trailing_newline`'s doc comment.
297            true,
298            // Export-document form: `raw` above is RE-SYNTHESIZED, one
299            // envelope line derived per record — not the original document's
300            // bytes (see this function's doc comment). `convert`'s
301            // byte-identical claim must not fire on this diagonal.
302            false,
303            // PARITY-15: a pretty-printed export document is parsed WHOLE
304            // (the caller's `if let Ok(doc) = serde_json::from_str(...)`) —
305            // there's no per-line parse-loss concept here; a malformed
306            // document fails that top-level parse and never reaches this
307            // function at all.
308            0,
309        )
310    }
311}
312
313/// One opencode `message` record plus its `part` children, gathered from
314/// EITHER read surface (envelope-form records or export-document
315/// `{info, parts}` entries) before the shared per-record canonicalization
316/// in [`opencode_session_from_records`].
317struct OcMsg {
318    id: String,
319    time_created: i64,
320    value: Value,
321    parts: Vec<Value>,
322}
323
324const OPENCODE_SUPERCODE_MESSAGE_POSITION: &str = "_supercode_message_position";
325const OPENCODE_SUPERCODE_RESULT_POSITION: &str = "_supercode_result_position";
326const OPENCODE_INTERNAL_ORIGINAL_POSITION: &str = "__supercode_original_position";
327
328/// Guard against the confirmed footgun: input that reached
329/// [`Session::from_opencode_str`] non-empty but produced no
330/// session/message/part record under either read surface returns `Err`
331/// instead of a silently-empty `Ok(Session)`. A legitimately-empty session
332/// (a real session record with zero messages, or a valid empty `messages`
333/// array) is not an error — only genuinely unparseable content is.
334fn opencode_guard_against_silent_empty(
335    non_empty_input: bool,
336    session_info: &Option<Value>,
337    msgs: &[OcMsg],
338    side_records: &[Value],
339) -> Result<()> {
340    let has_any_record = session_info.as_ref().is_some_and(|v| !v.is_null())
341        || !msgs.is_empty()
342        || !side_records.is_empty();
343    if non_empty_input && !has_any_record {
344        return Err(crate::Error::Other(
345            "opencode input was recognized as an OpenCode source (envelope or \
346             export-document form) but no session/message/part record could be parsed from \
347             it — refusing to silently return an empty session"
348                .to_string(),
349        ));
350    }
351    Ok(())
352}
353
354/// The shared per-record canonicalization for BOTH of
355/// [`Session::from_opencode_str`]'s read surfaces (envelope form and
356/// export-document form): frozen ordering, `SessionMeta` capture, the
357/// compaction boundary pass, and the `User`/`Assistant` → `messages`
358/// mapping via [`push_opencode_user`]/[`push_opencode_assistant`]. Fed the
359/// same underlying `(session_info, side_records, msgs)` regardless of which
360/// surface produced them, this produces byte-for-byte identical `messages`
361/// — the invariant that makes `load(to_opencode_jsonl(S))` round-trip.
362fn opencode_session_from_records(
363    session_info: Option<Value>,
364    side_records: Vec<Value>,
365    mut msgs: Vec<OcMsg>,
366    raw: Vec<String>,
367    raw_trailing_newline: bool,
368    raw_is_verbatim: bool,
369    parse_error_lines: usize,
370) -> Result<Session> {
371    let mut meta = SessionMeta::new(SessionSource::OpenCode);
372
373    // `msg_index` is captured BEFORE the frozen-order sort below, mapping
374    // each message id to its PRE-sort position — used only to resolve a
375    // `tail_start_id` reference in the compaction-boundary pass further
376    // down. In every real opencode session (either surface) records
377    // already arrive/are listed in creation order, so pre- and post-sort
378    // positions coincide; this mirrors the original envelope-only
379    // implementation's behavior exactly (not a new invariant introduced by
380    // sharing this code across both surfaces).
381    let msg_index: HashMap<String, usize> = msgs
382        .iter()
383        .enumerate()
384        .map(|(i, m)| (m.id.clone(), i))
385        .collect();
386
387    // Frozen order (§1.2): messages by (time.created, id); each
388    // message's parts by id.
389    msgs.sort_by(|a, b| {
390        a.time_created
391            .cmp(&b.time_created)
392            .then_with(|| a.id.cmp(&b.id))
393    });
394    for m in &mut msgs {
395        m.parts.sort_by(|a, b| {
396            let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
397            let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
398            ai.cmp(bi)
399        });
400    }
401
402    meta.opencode_headers
403        .push(session_info.clone().unwrap_or(Value::Null));
404    meta.opencode_headers.extend(side_records);
405    if let Some(si) = &session_info {
406        capture_opencode_session_info(si, &mut meta)?;
407    }
408
409    // Compaction boundary (§2.1/§2.2 S3): the LATEST `tail_start_id`
410    // seen — mirrors pi's `kept_from_pos` discipline (there is only one
411    // active path in opencode's own linear message list, so no branch
412    // walk is needed the way pi's tree requires).
413    let mut tail_start_pos: Option<usize> = None;
414    for m in &msgs {
415        for p in &m.parts {
416            if p.get("type").and_then(Value::as_str) == Some("compaction") {
417                if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
418                    if let Some(&tp) = msg_index.get(t) {
419                        tail_start_pos = Some(tail_start_pos.map_or(tp, |cur| cur.max(tp)));
420                    }
421                }
422            }
423        }
424    }
425
426    let mut messages = Vec::new();
427    let mut first_system_seen = false;
428    for (pos, m) in msgs.iter().enumerate() {
429        let before = messages.len();
430        match m.value.get("role").and_then(Value::as_str) {
431            // B4: a `User` message that's actually
432            // `append_synthesized_opencode_messages`'s own re-materialized
433            // Claude `system` record (one `synthetic: true` text part
434            // carrying the supercode marker key — see
435            // `opencode_claude_system_subtype`'s doc comment) restores
436            // `Role::System`, not a genuine user turn.
437            Some("user") => match opencode_claude_system_subtype(&m.parts) {
438                Some(subtype) => {
439                    push_opencode_claude_system(&m.value, &m.parts, subtype, &mut messages)
440                }
441                None => push_opencode_user(
442                    &m.value,
443                    &m.parts,
444                    &mut messages,
445                    &mut meta,
446                    &mut first_system_seen,
447                ),
448            },
449            Some("assistant") => {
450                push_opencode_assistant(&m.value, &m.parts, &mut messages, &mut meta)
451            }
452            // Unrecognized/missing role — raw-only survival;
453            // `audit::Corpus::OpenCode` scores this as Unmodeled.
454            _ => {}
455        }
456        if let Some(original_position) = m
457            .value
458            .get(OPENCODE_SUPERCODE_MESSAGE_POSITION)
459            .and_then(Value::as_u64)
460        {
461            if let Some(message) = messages[before..]
462                .iter_mut()
463                .find(|message| message.role != Role::Tool)
464            {
465                message.metadata.insert(
466                    OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
467                    original_position.to_string(),
468                );
469            }
470        }
471        for msg in &mut messages[before..] {
472            let is_summary = msg.metadata.get("is_summary").map(String::as_str) == Some("true");
473            if !is_summary {
474                if let Some(tsp) = tail_start_pos {
475                    if pos < tsp {
476                        msg.metadata
477                            .insert("compacted_out".to_string(), "true".to_string());
478                    }
479                }
480            }
481        }
482    }
483
484    let marked_slots = messages
485        .iter()
486        .enumerate()
487        .filter_map(|(index, message)| {
488            message
489                .metadata
490                .contains_key(OPENCODE_INTERNAL_ORIGINAL_POSITION)
491                .then_some(index)
492        })
493        .collect::<Vec<_>>();
494    if !marked_slots.is_empty() {
495        // A spliced OpenCode export can contain an unmarked native prefix
496        // followed by a marked synthesized tail. Reorder only among the
497        // marked slots so the tail never jumps in front of its raw prefix.
498        let mut marked_messages = marked_slots
499            .iter()
500            .map(|index| messages[*index].clone())
501            .collect::<Vec<_>>();
502        marked_messages.sort_by_key(|message| {
503            message
504                .metadata
505                .get(OPENCODE_INTERNAL_ORIGINAL_POSITION)
506                .and_then(|position| position.parse::<usize>().ok())
507                .unwrap_or(usize::MAX)
508        });
509        for (slot, message) in marked_slots.into_iter().zip(marked_messages) {
510            messages[slot] = message;
511        }
512        for message in &mut messages {
513            message.metadata.remove(OPENCODE_INTERNAL_ORIGINAL_POSITION);
514        }
515    }
516    ensure_tool_results_paired(&mut messages);
517    let imported_message_count = Some(messages.len());
518    Ok(Session {
519        meta,
520        messages,
521        subagents: Vec::new(),
522        raw,
523        raw_trailing_newline,
524        imported_message_count,
525        raw_is_verbatim,
526        parse_error_lines,
527        load_residue: Vec::new(),
528    })
529}
530
531/// Resolve each opencode subagent (`task`) child session's
532/// `meta.parent_tool_use_id` from its parent's own `task` tool part
533/// `callID` (`docs/interop/opencode-pi-spec.md` §2.1 — the analogue of
534/// Claude Code's `agentId`/`parent_tool_use_id` linkage,
535/// `opencode-fields.md` `task.ts:145,171-176`).
536///
537/// Nesting itself needs no opencode-specific pass:
538/// `capture_opencode_session_info` already mirrors `SessionInfo.parentID`
539/// into `lineage["parent_thread_id"]` (the same key Codex subagents use),
540/// so the existing generic [`Session::reconstruct_tree`] nests these
541/// sessions correctly on its own. Call this FIRST — it only reads
542/// `session_id`/`messages`/metadata, never reorders `sessions` — then feed
543/// the same `Vec` to `reconstruct_tree`.
544pub fn resolve_opencode_parent_tool_use_ids(sessions: &mut [Session]) {
545    let ids: Vec<Option<String>> = sessions.iter().map(|s| s.meta.session_id.clone()).collect();
546    for i in 0..sessions.len() {
547        let child_id = sessions[i].meta.session_id.clone();
548        let parent_id = sessions[i].meta.lineage.get("parent_session_id").cloned();
549        let (Some(child_id), Some(parent_id)) = (child_id, parent_id) else {
550            continue;
551        };
552        let Some(parent_idx) = ids
553            .iter()
554            .position(|id| id.as_deref() == Some(parent_id.as_str()))
555        else {
556            continue;
557        };
558        for m in &sessions[parent_idx].messages {
559            for (k, v) in &m.metadata {
560                if let Some(call_id) = k.strip_prefix("oc_task_child_session_id__") {
561                    if v == &child_id {
562                        sessions[i].meta.parent_tool_use_id = Some(call_id.to_string());
563                    }
564                }
565            }
566        }
567    }
568}
569
570// ---- OpenCode SQLite (PARITY-3/PARITY-4/PARITY-16) -------------------------
571//
572// Reads a real `opencode*.db` store directly with `rusqlite` (bundled
573// SQLite — no system library dependency) and reconstructs the SAME envelope
574// form `{"key":[...],"value":...}` that `Session::from_opencode_str` already
575// parses for the JSON-tree surfaces (`docs/interop/opencode-pi-spec.md`
576// §1.2/S9c). Row -> envelope reconstruction mirrors the real app's own
577// `session.ts` `fromRow` (session table: columnar fields recombined into the
578// camelCase `SessionInfo` shape) with ONE deliberate divergence: `revert` is
579// carried as the RAW column value, not upstream's own `fromRow`
580// reconstruction — which silently drops the V2 `Revert.State` schema's extra
581// `files` field (`session.ts:70-76`, `revert.ts:18-24`, spec finding S9c).
582// `message`/`part` rows are simpler: their `data` column is already the V1
583// `Info`/`Part` JSON minus the id columns hoisted out by the schema
584// (`session/sql.ts:68-98`), so reconstruction is just re-injecting
585// `id`/`sessionID`(/`messageID`).
586
587fn opencode_sql_err(e: rusqlite::Error, context: &str) -> crate::Error {
588    crate::Error::Other(format!("OpenCode SQLite error while {context}: {e}"))
589}
590
591fn opencode_sqlite_open(db_path: &Path) -> Result<Connection> {
592    if !db_path.is_file() {
593        return Err(crate::Error::Other(format!(
594            "OpenCode SQLite store not found at {} — expected an `opencode*.db` file \
595             (see `docs/interop/opencode-pi-spec.md` §1.2)",
596            db_path.display()
597        )));
598    }
599    let conn = Connection::open_with_flags(
600        db_path,
601        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
602    )
603    .map_err(|e| {
604        crate::Error::Other(format!(
605            "{} does not look like a valid OpenCode SQLite database: {e}",
606            db_path.display()
607        ))
608    })?;
609    let has_session_table: i64 = conn
610        .query_row(
611            "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='session'",
612            [],
613            |r| r.get(0),
614        )
615        .map_err(|e| {
616            crate::Error::Other(format!(
617                "failed to read the OpenCode SQLite schema at {}: {e}",
618                db_path.display()
619            ))
620        })?;
621    if has_session_table == 0 {
622        return Err(crate::Error::Other(format!(
623            "{} is a SQLite database but has no `session` table — not a recognized \
624             OpenCode store (wrong file, or an unsupported/pre-SQLite OpenCode version)",
625            db_path.display()
626        )));
627    }
628    Ok(conn)
629}
630
631/// Parse a nullable JSON-typed SQLite column (`metadata`, `revert`, `model`,
632/// …). D7: an unparseable non-empty column previously degraded to
633/// `Value::Null` with NO diagnostic — indistinguishable from a genuinely
634/// absent/NULL column, so a corrupt `data`/`metadata` value silently
635/// vanished (e.g. a message whose `data` fails to parse loses its entire
636/// canonical content with no trace). A `tracing::warn!` now surfaces the
637/// column name and context (session/record id) whenever this happens, so
638/// the failure is diagnosable — `Value::Null` is kept as the parsed VALUE
639/// (still the least-wrong placeholder for a broken column; changing it to a
640/// sentinel would risk misleading every legitimate `.is_null()` check
641/// elsewhere) but the frontend/log now knows it happened.
642fn opencode_json_col(s: Option<String>, col: &str, context: &str) -> Value {
643    match s.as_deref() {
644        None => Value::Null,
645        Some(t) => match serde_json::from_str::<Value>(t) {
646            Ok(v) => v,
647            Err(e) => {
648                tracing::warn!(
649                    column = col,
650                    context,
651                    error = %e,
652                    "opencode SQLite column failed to parse as JSON — treating as absent (D7)"
653                );
654                Value::Null
655            }
656        },
657    }
658}
659
660/// Columns the `session` table has in a GIVEN store, read once per session
661/// load via `PRAGMA table_info` (D3: real-world stores vary — a lean/older
662/// `opencode` generation may lack columns the newest schema added, e.g.
663/// `workspace_id`; rusqlite's `Row::get(name)` hard-errors
664/// "Invalid column name" on an absent column, so callers must check
665/// membership before reading a not-guaranteed column instead of reading it
666/// unconditionally).
667fn opencode_session_columns(
668    conn: &Connection,
669) -> rusqlite::Result<std::collections::HashSet<String>> {
670    let mut stmt = conn.prepare("PRAGMA table_info(session)")?;
671    let names = stmt.query_map([], |r| r.get::<_, String>(1))?; // column 1 = name
672    names.collect()
673}
674
675/// Reconstruct one `session` row into the camelCase `SessionInfo` envelope
676/// value [`Session::from_opencode_str`]'s `capture_opencode_session_info`
677/// already parses — mirrors upstream's own `session.ts` `fromRow`, EXCEPT
678/// `revert` carries the raw column value verbatim rather than upstream's
679/// field-selecting reconstruction (spec S9c: that reconstruction silently
680/// drops the V2 `Revert.State` schema's extra `files` field).
681///
682/// D3: not every column this loader would like to read is guaranteed to
683/// exist — a real, older/leaner `opencode` install's `session` table (e.g.
684/// v1.2.15) lacks `workspace_id`/`path`/`metadata`/`cost`/`tokens_*`/
685/// `agent`/`model` entirely. Those are read defensively (guarded by
686/// [`opencode_session_columns`]); columns present in EVERY `opencode`
687/// generation this loader has ever targeted are still read unconditionally.
688fn opencode_row_session_info(conn: &Connection, session_id: &str) -> Result<Value> {
689    let cols = opencode_session_columns(conn)
690        .map_err(|e| opencode_sql_err(e, &format!("reading session `{session_id}` schema")))?;
691    let has = |name: &str| cols.contains(name);
692
693    conn.query_row("SELECT * FROM session WHERE id = ?1", [session_id], |r| {
694        let id: String = r.get("id")?;
695        let project_id: String = r.get("project_id")?;
696        let workspace_id: Option<String> = if has("workspace_id") {
697            r.get("workspace_id")?
698        } else {
699            None
700        };
701        let parent_id: Option<String> = r.get("parent_id")?;
702        let slug: String = r.get("slug")?;
703        let directory: String = r.get("directory")?;
704        let path: Option<String> = if has("path") { r.get("path")? } else { None };
705        let title: String = r.get("title")?;
706        let version: String = r.get("version")?;
707        let share_url: Option<String> = r.get("share_url")?;
708        let summary_additions: Option<i64> = r.get("summary_additions")?;
709        let summary_deletions: Option<i64> = r.get("summary_deletions")?;
710        let summary_files: Option<i64> = r.get("summary_files")?;
711        let summary_diffs: Option<String> = r.get("summary_diffs")?;
712        let metadata: Option<String> = if has("metadata") {
713            r.get("metadata")?
714        } else {
715            None
716        };
717        let cost: f64 = if has("cost") { r.get("cost")? } else { 0.0 };
718        let tokens_input: i64 = if has("tokens_input") {
719            r.get("tokens_input")?
720        } else {
721            0
722        };
723        let tokens_output: i64 = if has("tokens_output") {
724            r.get("tokens_output")?
725        } else {
726            0
727        };
728        let tokens_reasoning: i64 = if has("tokens_reasoning") {
729            r.get("tokens_reasoning")?
730        } else {
731            0
732        };
733        let tokens_cache_read: i64 = if has("tokens_cache_read") {
734            r.get("tokens_cache_read")?
735        } else {
736            0
737        };
738        let tokens_cache_write: i64 = if has("tokens_cache_write") {
739            r.get("tokens_cache_write")?
740        } else {
741            0
742        };
743        let revert: Option<String> = r.get("revert")?;
744        let permission: Option<String> = if has("permission") {
745            r.get("permission")?
746        } else {
747            None
748        };
749        let agent: Option<String> = if has("agent") { r.get("agent")? } else { None };
750        let model: Option<String> = if has("model") { r.get("model")? } else { None };
751        let time_created: i64 = r.get("time_created")?;
752        let time_updated: i64 = r.get("time_updated")?;
753        let time_compacting: Option<i64> = if has("time_compacting") {
754            r.get("time_compacting")?
755        } else {
756            None
757        };
758        let time_archived: Option<i64> = if has("time_archived") {
759            r.get("time_archived")?
760        } else {
761            None
762        };
763
764        let summary =
765            (summary_additions.is_some() || summary_deletions.is_some() || summary_files.is_some())
766                .then(|| {
767                    serde_json::json!({
768                        "additions": summary_additions.unwrap_or(0),
769                        "deletions": summary_deletions.unwrap_or(0),
770                        "files": summary_files.unwrap_or(0),
771                        "diffs": opencode_json_col(summary_diffs, "summary_diffs", session_id),
772                    })
773                });
774        let share = share_url.map(|u| serde_json::json!({"url": u}));
775
776        Ok(serde_json::json!({
777            "id": id,
778            "slug": slug,
779            "projectID": project_id,
780            "workspaceID": workspace_id,
781            "directory": directory,
782            "path": path,
783            "parentID": parent_id,
784            "summary": summary,
785            "cost": cost,
786            "tokens": {
787                "input": tokens_input,
788                "output": tokens_output,
789                "reasoning": tokens_reasoning,
790                "cache": {"read": tokens_cache_read, "write": tokens_cache_write},
791            },
792            "share": share,
793            "title": title,
794            "agent": agent,
795            "model": opencode_json_col(model, "model", session_id),
796            "version": version,
797            "metadata": opencode_json_col(metadata, "metadata", session_id),
798            "time": {
799                "created": time_created,
800                "updated": time_updated,
801                "compacting": time_compacting,
802                "archived": time_archived,
803            },
804            "permission": opencode_json_col(permission, "permission", session_id),
805            // S9c: raw column value, not a field-selecting reconstruction —
806            // see this function's doc comment.
807            "revert": opencode_json_col(revert, "revert", session_id),
808        }))
809    })
810    .map_err(|e| match e {
811        rusqlite::Error::QueryReturnedNoRows => crate::Error::Other(format!(
812            "OpenCode session `{session_id}` not found in this SQLite store"
813        )),
814        e => opencode_sql_err(e, &format!("reading session `{session_id}`")),
815    })
816}
817
818/// `message.data` / `part.data` already ARE the V1 `Info`/`Part` JSON minus
819/// the id columns the schema hoists out (`session/sql.ts:68-98`) — just
820/// re-inject them, matching what a JSON-tree file (or the export document)
821/// carries at this same key. Also re-injects the row's own `time_created`/
822/// `time_updated` columns (D2/S9c: these live OUTSIDE `data` in the real
823/// schema — `Timestamps` in `database/schema.sql.ts` — and MUST be carried
824/// in the envelope so `raw` is value-complete and re-writable without
825/// re-minting timestamps; distinct keys from `data`'s own `"time"` object,
826/// which is a different, in-schema field with different semantics).
827fn opencode_row_message_value(
828    id: &str,
829    session_id: &str,
830    data_json: &str,
831    time_created: i64,
832    time_updated: i64,
833) -> Value {
834    let mut v = opencode_json_col(Some(data_json.to_string()), "message.data", id);
835    if let Value::Object(map) = &mut v {
836        map.insert("id".to_string(), Value::String(id.to_string()));
837        map.insert(
838            "sessionID".to_string(),
839            Value::String(session_id.to_string()),
840        );
841        map.insert("time_created".to_string(), Value::from(time_created));
842        map.insert("time_updated".to_string(), Value::from(time_updated));
843    }
844    v
845}
846
847fn opencode_row_part_value(
848    id: &str,
849    session_id: &str,
850    message_id: &str,
851    data_json: &str,
852    time_created: i64,
853    time_updated: i64,
854) -> Value {
855    let mut v = opencode_json_col(Some(data_json.to_string()), "part.data", id);
856    if let Value::Object(map) = &mut v {
857        map.insert("id".to_string(), Value::String(id.to_string()));
858        map.insert(
859            "sessionID".to_string(),
860            Value::String(session_id.to_string()),
861        );
862        map.insert(
863            "messageID".to_string(),
864            Value::String(message_id.to_string()),
865        );
866        map.insert("time_created".to_string(), Value::from(time_created));
867        map.insert("time_updated".to_string(), Value::from(time_updated));
868    }
869    v
870}
871
872/// Ordered envelope lines for one session's `session`/`message`/`part`/`todo`
873/// records (`docs/interop/opencode-pi-spec.md` §1.2's frozen order): session
874/// info first, then each message (by `time_created, id`) immediately
875/// followed by its own parts (by `id`) — parts MUST directly follow their
876/// owning message line, since `Session::from_opencode_str`'s envelope parser
877/// attaches a `part` line to whichever message id is already in its index
878/// and silently leaves an out-of-order part `raw`-only otherwise — then
879/// `todo` side-records, then a `session_diff` side-record if the JSON
880/// sidecar file for this session exists (order-independent).
881///
882/// `db_path` is needed only for the `session_diff` sidecar (D3): §1.3 says
883/// it "is still JSON-written even on SQLite installs" — verified against
884/// `packages/opencode/src/session/revert.ts:76` /
885/// `packages/opencode/src/storage/storage.ts:192` at the pinned schema
886/// commit, which write it to `<data>/storage/session_diff/<session>.json`
887/// (`<data>` being `db_path`'s parent directory) on every revert, entirely
888/// separate from the `session.revert` DB column this loader already
889/// captures. Without this, revert diffs vanish from `raw` and audit
890/// under-counts `session_diff` records for real reverted sessions.
891fn opencode_sqlite_session_envelope_lines(
892    conn: &Connection,
893    db_path: &Path,
894    session_id: &str,
895) -> Result<Vec<String>> {
896    let mut lines = Vec::new();
897
898    let session_info = opencode_row_session_info(conn, session_id)?;
899    let project_id = session_info
900        .get("projectID")
901        .and_then(Value::as_str)
902        .unwrap_or("global")
903        .to_string();
904    lines.push(
905        serde_json::json!({"key": ["session", project_id, session_id], "value": session_info})
906            .to_string(),
907    );
908
909    let mut msg_stmt = conn
910        .prepare(
911            "SELECT id, data, time_created, time_updated FROM message \
912             WHERE session_id = ?1 ORDER BY time_created, id",
913        )
914        .map_err(|e| opencode_sql_err(e, "preparing the message query"))?;
915    let msg_rows = msg_stmt
916        .query_map([session_id], |r| {
917            let id: String = r.get("id")?;
918            let data: String = r.get("data")?;
919            let time_created: i64 = r.get("time_created")?;
920            let time_updated: i64 = r.get("time_updated")?;
921            Ok((id, data, time_created, time_updated))
922        })
923        .map_err(|e| opencode_sql_err(e, "querying messages"))?;
924
925    let mut part_stmt = conn
926        .prepare(
927            "SELECT id, data, time_created, time_updated FROM part \
928             WHERE message_id = ?1 ORDER BY id",
929        )
930        .map_err(|e| opencode_sql_err(e, "preparing the part query"))?;
931
932    for row in msg_rows {
933        let (msg_id, data, msg_time_created, msg_time_updated) =
934            row.map_err(|e| opencode_sql_err(e, "reading a message row"))?;
935        let msg_value = opencode_row_message_value(
936            &msg_id,
937            session_id,
938            &data,
939            msg_time_created,
940            msg_time_updated,
941        );
942        lines.push(
943            serde_json::json!({"key": ["message", session_id, msg_id], "value": msg_value})
944                .to_string(),
945        );
946
947        let part_rows = part_stmt
948            .query_map([&msg_id], |r| {
949                let id: String = r.get("id")?;
950                let data: String = r.get("data")?;
951                let time_created: i64 = r.get("time_created")?;
952                let time_updated: i64 = r.get("time_updated")?;
953                Ok((id, data, time_created, time_updated))
954            })
955            .map_err(|e| opencode_sql_err(e, "querying parts"))?;
956        for prow in part_rows {
957            let (part_id, pdata, part_time_created, part_time_updated) =
958                prow.map_err(|e| opencode_sql_err(e, "reading a part row"))?;
959            let part_value = opencode_row_part_value(
960                &part_id,
961                session_id,
962                &msg_id,
963                &pdata,
964                part_time_created,
965                part_time_updated,
966            );
967            lines.push(
968                serde_json::json!({"key": ["part", msg_id, part_id], "value": part_value})
969                    .to_string(),
970            );
971        }
972    }
973
974    let mut todo_stmt = conn
975        .prepare(
976            "SELECT content, status, priority, position, time_created, time_updated \
977             FROM todo WHERE session_id = ?1 ORDER BY position",
978        )
979        .map_err(|e| opencode_sql_err(e, "preparing the todo query"))?;
980    let todo_rows = todo_stmt
981        .query_map([session_id], |r| {
982            let content: String = r.get("content")?;
983            let status: String = r.get("status")?;
984            let priority: String = r.get("priority")?;
985            let position: i64 = r.get("position")?;
986            let time_created: i64 = r.get("time_created")?;
987            let time_updated: i64 = r.get("time_updated")?;
988            Ok(serde_json::json!({
989                "sessionID": session_id,
990                "content": content,
991                "status": status,
992                "priority": priority,
993                "position": position,
994                "time": {"created": time_created, "updated": time_updated},
995            }))
996        })
997        .map_err(|e| opencode_sql_err(e, "querying todos"))?;
998    for trow in todo_rows {
999        let tv = trow.map_err(|e| opencode_sql_err(e, "reading a todo row"))?;
1000        let position = tv.get("position").cloned().unwrap_or(Value::Null);
1001        lines.push(
1002            serde_json::json!({"key": ["todo", session_id, position], "value": tv}).to_string(),
1003        );
1004    }
1005
1006    if let Some(diff_value) = opencode_read_session_diff_sidecar(db_path, session_id) {
1007        lines.push(
1008            serde_json::json!({"key": ["session_diff", session_id], "value": diff_value})
1009                .to_string(),
1010        );
1011    }
1012
1013    Ok(lines)
1014}
1015
1016/// D3: read the `session_diff` JSON sidecar file for `session_id`, if
1017/// present, from `<db_path's parent>/storage/session_diff/<session_id>.json`
1018/// — the real on-disk location (`storage.ts`'s `Global.Path.data` +
1019/// `"storage"`, with `db_path` itself living at `Global.Path.data/opencode.db`
1020/// or `opencode-<channel>.db`). Best-effort: a missing file is the common
1021/// case (most sessions never revert) and is not an error; an existing-but-
1022/// unparseable file surfaces a diagnostic (D7-style) rather than silently
1023/// vanishing.
1024fn opencode_read_session_diff_sidecar(db_path: &Path, session_id: &str) -> Option<Value> {
1025    let dir = db_path.parent()?;
1026    let sidecar = dir
1027        .join("storage")
1028        .join("session_diff")
1029        .join(format!("{session_id}.json"));
1030    let text = std::fs::read_to_string(&sidecar).ok()?;
1031    match serde_json::from_str::<Value>(&text) {
1032        Ok(v) => Some(v),
1033        Err(e) => {
1034            tracing::warn!(
1035                path = %sidecar.display(),
1036                error = %e,
1037                "opencode session_diff sidecar failed to parse as JSON — skipping (D7)"
1038            );
1039            None
1040        }
1041    }
1042}
1043
1044/// Pick the "primary" session for a bare `.db` path with no explicit session
1045/// id (`Session::load`'s auto-detect entry point): the most-recently-updated
1046/// TOP-LEVEL session (`parent_id IS NULL` sorts first, then `time_updated`
1047/// descending) — a subagent/task child session is never picked over an
1048/// available root session, mirroring `most_recent_session`'s "latest wins"
1049/// convention used elsewhere in this crate for supercode's own store.
1050fn opencode_sqlite_primary_session_id(conn: &Connection) -> Result<String> {
1051    conn.query_row(
1052        "SELECT id FROM session ORDER BY (parent_id IS NULL) DESC, time_updated DESC LIMIT 1",
1053        [],
1054        |r| r.get::<_, String>(0),
1055    )
1056    .map_err(|e| match e {
1057        rusqlite::Error::QueryReturnedNoRows => {
1058            crate::Error::Other("OpenCode SQLite store contains no sessions".to_string())
1059        }
1060        e => opencode_sql_err(e, "selecting the primary session"),
1061    })
1062}
1063
1064fn opencode_sqlite_all_session_ids(conn: &Connection, limit: Option<usize>) -> Result<Vec<String>> {
1065    let mut stmt = conn
1066        .prepare("SELECT id FROM session ORDER BY time_created, id")
1067        .map_err(|e| opencode_sql_err(e, "listing sessions"))?;
1068    let rows = stmt
1069        .query_map([], |r| r.get::<_, String>(0))
1070        .map_err(|e| opencode_sql_err(e, "listing sessions"))?;
1071    let mut ids = Vec::new();
1072    for row in rows {
1073        ids.push(row.map_err(|e| opencode_sql_err(e, "reading a session id"))?);
1074        if limit.is_some_and(|n| ids.len() >= n) {
1075            break;
1076        }
1077    }
1078    Ok(ids)
1079}
1080
1081/// D6: list every session id in an OpenCode SQLite store (oldest first) —
1082/// exposed so CLI callers (`convert`, `inspect`) can detect a multi-session
1083/// store and warn before [`Session::load`]/[`Session::from_opencode_sqlite`]
1084/// silently picks just the primary one. Previously nothing surfaced this:
1085/// `convert opencode.db --to X` converted 1-of-N sessions with no warning
1086/// and no way to name a different one.
1087pub fn opencode_sqlite_session_ids(db_path: &Path) -> Result<Vec<String>> {
1088    let conn = opencode_sqlite_open(db_path)?;
1089    opencode_sqlite_all_session_ids(&conn, None)
1090}
1091
1092/// D6: the same "most-recently-updated top-level session" selection
1093/// [`Session::load`]/[`Session::from_opencode_sqlite`] make by default when
1094/// no explicit session id is given — exposed so a CLI-level warning can name
1095/// which one was chosen.
1096pub fn opencode_sqlite_primary_id(db_path: &Path) -> Result<String> {
1097    let conn = opencode_sqlite_open(db_path)?;
1098    opencode_sqlite_primary_session_id(&conn)
1099}
1100
1101/// Cheap store-level counts — `COUNT(*)` only, no row hydration — for
1102/// `inspect`'s "reports the audited real store's sessions, messages, and
1103/// parts" summary (PARITY-3 AC01).
1104#[derive(Debug, Clone, Copy, Default)]
1105#[non_exhaustive]
1106pub struct OpenCodeSqliteStoreStats {
1107    /// Row count of the `session` table.
1108    pub sessions: u64,
1109    /// Row count of the `message` table.
1110    pub messages: u64,
1111    /// Row count of the `part` table.
1112    pub parts: u64,
1113    /// Row count of the `todo` table.
1114    pub todos: u64,
1115}
1116
1117/// Count sessions/messages/parts/todos in a real OpenCode SQLite store
1118/// without loading any of them (PARITY-3 AC01).
1119pub fn opencode_sqlite_store_stats(db_path: &Path) -> Result<OpenCodeSqliteStoreStats> {
1120    let conn = opencode_sqlite_open(db_path)?;
1121    let count = |table: &str| -> Result<u64> {
1122        let sql = format!("SELECT count(*) FROM {table}");
1123        conn.query_row(&sql, [], |r| r.get::<_, i64>(0))
1124            .map(|n| n.max(0) as u64)
1125            .map_err(|e| opencode_sql_err(e, &format!("counting `{table}` rows")))
1126    };
1127    Ok(OpenCodeSqliteStoreStats {
1128        sessions: count("session")?,
1129        messages: count("message")?,
1130        parts: count("part")?,
1131        todos: count("todo")?,
1132    })
1133}
1134
1135/// Combined envelope text spanning every session in `db_path` (or up to
1136/// `limit_sessions`) — for corpus-style scanning
1137/// (the OpenCode SQLite corpus-audit path, PARITY-4).
1138/// Safe to concatenate multiple sessions' records into one text even though
1139/// [`Session::from_opencode_str`] itself only keeps the LAST `session` line
1140/// (single-session semantics) — the audit line-classifier
1141/// (`audit_opencode_line`) scores each line independently and doesn't care
1142/// about session boundaries. Use [`Session::from_opencode_sqlite`] to load
1143/// one session as a real [`Session`].
1144pub fn opencode_sqlite_corpus_envelope_text(
1145    db_path: &Path,
1146    limit_sessions: Option<usize>,
1147) -> Result<String> {
1148    let conn = opencode_sqlite_open(db_path)?;
1149    let ids = opencode_sqlite_all_session_ids(&conn, limit_sessions)?;
1150    let mut out = String::new();
1151    for id in ids {
1152        for line in opencode_sqlite_session_envelope_lines(&conn, db_path, &id)? {
1153            out.push_str(&line);
1154            out.push('\n');
1155        }
1156    }
1157    Ok(out)
1158}
1159
1160// ---- OpenCode ---------------------------------------------------------
1161
1162/// The placeholder opencode's own replay substitutes for a `tool` part's
1163/// output once `state.completed.time.compacted` is set
1164/// (`message-v2.ts:293-296 @fd9ee43`) — the REAL output is never actually
1165/// erased from the record (S1); it survives in `raw` and in this loader's
1166/// `metadata["oc_tool_output_compacted"]`.
1167pub const OPENCODE_COMPACTED_TOOL_PLACEHOLDER: &str = "[Old tool result content cleared]";
1168
1169fn capture_opencode_session_info(si: &Value, meta: &mut SessionMeta) -> Result<()> {
1170    restore_codex_provenance_from_top_level(si, meta)?;
1171    if let Some(id) = si.get("id").and_then(Value::as_str) {
1172        meta.session_id = Some(id.to_string());
1173    }
1174    if let Some(dir) = si.get("directory").and_then(Value::as_str) {
1175        meta.cwd = Some(PathBuf::from(dir));
1176    }
1177    if let Some(agent) = si.get("agent").and_then(Value::as_str) {
1178        meta.agent_id = Some(agent.to_string());
1179    }
1180    if let Some(model) = si.get("model") {
1181        let provider = model.get("providerID").and_then(Value::as_str);
1182        let id = model.get("id").and_then(Value::as_str);
1183        if let (Some(p), Some(i)) = (provider, id) {
1184            meta.model = Some(format!("{p}/{i}"));
1185        }
1186    }
1187    if let Some(project_id) = si.get("projectID").and_then(Value::as_str) {
1188        meta.lineage
1189            .insert("projectID".to_string(), project_id.to_string());
1190    }
1191    if let Some(slug) = si.get("slug").and_then(Value::as_str) {
1192        meta.lineage.insert("slug".to_string(), slug.to_string());
1193    }
1194    if let Some(ws) = si.get("workspaceID").and_then(Value::as_str) {
1195        meta.lineage
1196            .insert("workspaceID".to_string(), ws.to_string());
1197    }
1198    if let Some(parent) = si.get("parentID").and_then(Value::as_str) {
1199        meta.lineage
1200            .insert("parent_session_id".to_string(), parent.to_string());
1201        // Mirrored under the Codex-originated lineage key so the existing
1202        // generic `Session::reconstruct_tree` nests opencode subagent
1203        // sessions too, with no format-specific nesting pass (§2.1: "child
1204        // session's parentID ... → drives reconstruct_tree").
1205        meta.lineage
1206            .insert("parent_thread_id".to_string(), parent.to_string());
1207    }
1208    // D7: the other half of `synthesized_opencode_info`'s passthrough —
1209    // restores a captured Claude `fork-context-ref` so a Claude -> OpenCode
1210    // -> Claude round trip reconstructs the original record (mirrors
1211    // `capture_codex_session_meta`/`capture_pi_header`'s identical
1212    // `claude_fork_context_ref` restore for the Codex/Pi hops).
1213    if !meta.lineage.contains_key("claude_fork_context_ref_raw") {
1214        if let Some(v) = si.get("claude_fork_context_ref") {
1215            meta.lineage
1216                .insert("claude_fork_context_ref_raw".to_string(), v.to_string());
1217        }
1218    }
1219    Ok(())
1220}
1221
1222/// An opencode `User`/`Assistant` `file` part's image data-URI →
1223/// `content_parts` `image_url` entry (§2.1). Only `data:` URIs with an
1224/// `image/*` mime are mapped ("T3 clean for images; non-media residue") —
1225/// a bare filesystem path, an `https:` link, or a non-image mime is left as
1226/// raw-only residue (§2.3), never a corrupt/guessed `image_url`. `pub(crate)`
1227/// so [`crate::audit::audit_opencode_line`] can classify a `file` part's
1228/// coverage with the SAME test this loader uses to canonicalize it (D5) —
1229/// one definition of "is this file part actually replayed", not two.
1230#[doc(hidden)]
1231pub fn opencode_file_image_part(part: &Value) -> Option<Value> {
1232    let mime = part.get("mime").and_then(Value::as_str)?;
1233    let url = part.get("url").and_then(Value::as_str)?;
1234    if !mime.starts_with("image/") || !url.starts_with("data:") {
1235        return None;
1236    }
1237    Some(serde_json::json!({"type": "image_url", "image_url": {"url": url}}))
1238}
1239
1240/// B4: the part-`metadata` key `append_synthesized_opencode_messages`'s
1241/// `Role::System` arm stamps on the one `synthetic: true` text part of a
1242/// re-materialized content-bearing Claude `system` record (see that arm's
1243/// doc comment). Namespaced (`supercode_`-prefixed) so it can never collide
1244/// with real opencode/provider part metadata (e.g. `anthropic.signature`).
1245const OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: &str = "supercode_claude_system_subtype";
1246
1247/// Detect `append_synthesized_opencode_messages`'s own marker shape: a
1248/// `User` message with EXACTLY one `synthetic: true` text part carrying
1249/// [`OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY`] in its part-level `metadata`. Real
1250/// opencode data is never misclassified — a genuine opencode `synthetic`
1251/// text part never carries this supercode-namespaced key, and a real
1252/// multi-part user message (text + an attached file, say) never matches
1253/// (`parts.len() != 1` bails). Returns the original Claude `systemSubtype`
1254/// (e.g. `local_command`) on a match.
1255fn opencode_claude_system_subtype(parts: &[Value]) -> Option<String> {
1256    let [part] = parts else { return None };
1257    if part.get("type").and_then(Value::as_str) != Some("text") {
1258        return None;
1259    }
1260    if part.get("synthetic").and_then(Value::as_bool) != Some(true) {
1261        return None;
1262    }
1263    part.get("metadata")
1264        .and_then(|m| m.get(OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY))
1265        .and_then(Value::as_str)
1266        .map(str::to_string)
1267}
1268
1269/// The [`opencode_claude_system_subtype`] match arm: restores `Role::System`
1270/// and `metadata["systemSubtype"]` from the marked text part instead of
1271/// `push_opencode_user`'s generic `Role::User` path, so a Claude ->
1272/// OpenCode -> Claude round trip restores the exact original role, not just
1273/// the text. Content is never fabricated — only emitted when non-empty.
1274fn push_opencode_claude_system(
1275    msg_value: &Value,
1276    parts: &[Value],
1277    subtype: String,
1278    out: &mut Vec<ChatMessage>,
1279) {
1280    let Some(text) = parts
1281        .first()
1282        .and_then(|p| p.get("text"))
1283        .and_then(Value::as_str)
1284    else {
1285        return;
1286    };
1287    if text.trim().is_empty() {
1288        return;
1289    }
1290    let mut msg = ChatMessage::system(text.to_string()).with_meta("systemSubtype", subtype);
1291    set_opencode_msg_timestamp(&mut msg, msg_value);
1292    out.push(msg);
1293}
1294
1295/// Map an opencode `User` message (`msg_value`) + its parts to zero or one
1296/// canonical `Role::User` `ChatMessage` (§2.1). `text` parts concatenate
1297/// (an `ignored` one is never included — §2.2, "must not be re-emitted to
1298/// the model"); `file` parts with a recognized image shape become
1299/// `content_parts`. `System` (opencode's per-turn `User.system`) fills
1300/// `SessionMeta.system_prompt` on the first turn that carries it, and
1301/// `metadata["system"]` on every turn that does (§2.1: "System prompt is
1302/// per-user-message, not per-session").
1303/// Fold an opencode message envelope's `time.created` (unix-ms) into the
1304/// canonical `metadata["timestamp"]` (ISO-8601, WAVE-2 item 1) — the same
1305/// field claude/codex/pi loaders populate. Lossless to millisecond precision
1306/// (opencode's own wire granularity); a `None`/malformed `time.created`
1307/// leaves `metadata["timestamp"]` unset, so the writer falls back to
1308/// `SYNTH_TS`/`SYNTH_TS_MS`.
1309fn set_opencode_msg_timestamp(msg: &mut ChatMessage, msg_value: &Value) {
1310    if let Some(ms) = msg_value
1311        .get("time")
1312        .and_then(|t| t.get("created"))
1313        .and_then(Value::as_i64)
1314    {
1315        msg.metadata
1316            .insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
1317    }
1318}
1319
1320fn push_opencode_user(
1321    msg_value: &Value,
1322    parts: &[Value],
1323    out: &mut Vec<ChatMessage>,
1324    meta: &mut SessionMeta,
1325    first_system_seen: &mut bool,
1326) {
1327    let mut text = String::new();
1328    let mut image_parts: Vec<Value> = Vec::new();
1329    let mut has_ignored = false;
1330    for p in parts {
1331        match p.get("type").and_then(Value::as_str) {
1332            Some("text") => {
1333                if p.get("ignored").and_then(Value::as_bool) == Some(true) {
1334                    has_ignored = true;
1335                    continue; // must never be replayed (§2.2)
1336                }
1337                if let Some(t) = p.get("text").and_then(Value::as_str) {
1338                    push_str_field(&mut text, t);
1339                }
1340            }
1341            Some("file") => {
1342                if let Some(img) = opencode_file_image_part(p) {
1343                    image_parts.push(img);
1344                }
1345            }
1346            // reasoning/tool never appear on a User message; step-start,
1347            // step-finish, snapshot, patch, agent, subtask, retry have no
1348            // clean home (§2.3); compaction is read separately by the
1349            // caller (tail_start_id) and tagged onto the message below.
1350            _ => {}
1351        }
1352    }
1353
1354    let has_images = !image_parts.is_empty();
1355    if text.trim().is_empty() && !has_images {
1356        return;
1357    }
1358    let mut msg = if has_images {
1359        let mut all = Vec::new();
1360        if !text.trim().is_empty() {
1361            all.push(serde_json::json!({"type": "text", "text": text.clone()}));
1362        }
1363        all.extend(image_parts);
1364        ChatMessage {
1365            role: Role::User,
1366            content: None,
1367            content_parts: Some(all),
1368            tool_calls: None,
1369            tool_call_id: None,
1370            name: None,
1371            metadata: Default::default(),
1372        }
1373    } else {
1374        ChatMessage::user(text)
1375    };
1376
1377    if has_ignored {
1378        msg.metadata
1379            .insert("oc_has_ignored_part".to_string(), "true".to_string());
1380    }
1381    if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
1382        msg.metadata
1383            .insert("oc_message_id".to_string(), id.to_string());
1384    }
1385    if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
1386        msg.metadata.insert("agent".to_string(), agent.to_string());
1387    }
1388    if let Some(model) = msg_value.get("model") {
1389        if !model.is_null() {
1390            msg.metadata.insert("model".to_string(), model.to_string());
1391        }
1392    }
1393    if let Some(system) = msg_value.get("system").and_then(Value::as_str) {
1394        if !*first_system_seen {
1395            meta.system_prompt = Some(system.to_string());
1396            *first_system_seen = true;
1397        }
1398        msg.metadata
1399            .insert("system".to_string(), system.to_string());
1400    }
1401    for p in parts {
1402        if p.get("type").and_then(Value::as_str) == Some("compaction") {
1403            msg.metadata
1404                .insert("phase".to_string(), "compaction".to_string());
1405            if let Some(t) = p.get("tail_start_id").and_then(Value::as_str) {
1406                msg.metadata
1407                    .insert("tail_start_id".to_string(), t.to_string());
1408            }
1409        }
1410    }
1411    set_opencode_msg_timestamp(&mut msg, msg_value);
1412    restore_grok_message_extension(msg_value, &mut msg);
1413    out.push(msg);
1414}
1415
1416/// Map an opencode `Assistant` message + its parts to a canonical
1417/// `Role::Assistant` `ChatMessage` (text + `tool_calls`), followed
1418/// immediately by one `Role::Tool` `ChatMessage` per `tool` part that
1419/// reached `completed`/`error` — the split-by-`callID` opencode's single
1420/// part→two-messages mapping (§2.1). `pending`/`running` calls (an
1421/// interrupted turn) synthesize no tool call/result of their own here; the
1422/// shared [`ensure_tool_results_paired`] fills that gap uniformly, exactly
1423/// like the other three loaders. A `tool` part whose `state.status` is none
1424/// of the four known values is skipped entirely — raw-only survival, never
1425/// guessed — so [`crate::audit::Corpus::OpenCode`] can flag it.
1426fn push_opencode_assistant(
1427    msg_value: &Value,
1428    parts: &[Value],
1429    out: &mut Vec<ChatMessage>,
1430    meta: &mut SessionMeta,
1431) {
1432    let mut text = String::new();
1433    let mut calls: Vec<ToolCall> = Vec::new();
1434    let mut thinking = String::new();
1435    let mut reasoning_seen = false;
1436    let mut thinking_sig: Option<String> = None;
1437    // (call_id, tool_name, the tool part itself) — deferred so the
1438    // assistant message (carrying `tool_calls`) is pushed FIRST, matching
1439    // every other loader's message ordering (call, then result).
1440    let mut tool_results: Vec<(String, String, Value)> = Vec::new();
1441
1442    for p in parts {
1443        match p.get("type").and_then(Value::as_str) {
1444            Some("text") => {
1445                if p.get("ignored").and_then(Value::as_bool) == Some(true) {
1446                    continue;
1447                }
1448                if let Some(t) = p.get("text").and_then(Value::as_str) {
1449                    push_str_field(&mut text, t);
1450                }
1451            }
1452            Some("reasoning") => {
1453                reasoning_seen = true;
1454                if let Some(t) = p.get("text").and_then(Value::as_str) {
1455                    push_str_field(&mut thinking, t);
1456                }
1457                if let Some(sig) = p
1458                    .get("metadata")
1459                    .and_then(|m| m.get("anthropic"))
1460                    .and_then(|a| a.get("signature"))
1461                    .and_then(Value::as_str)
1462                {
1463                    thinking_sig = Some(sig.to_string());
1464                }
1465            }
1466            Some("tool") => {
1467                let call_id = p.get("callID").and_then(Value::as_str).unwrap_or_default();
1468                let tool_name = p.get("tool").and_then(Value::as_str).unwrap_or_default();
1469                let status = p
1470                    .get("state")
1471                    .and_then(|s| s.get("status"))
1472                    .and_then(Value::as_str);
1473                let known_status = matches!(
1474                    status,
1475                    Some("pending") | Some("running") | Some("completed") | Some("error")
1476                );
1477                if call_id.is_empty() || !known_status {
1478                    // Unknown/unrecognized status, or a malformed part with
1479                    // no callID — raw-only survival, never synthesized.
1480                    continue;
1481                }
1482                let input = p
1483                    .get("state")
1484                    .and_then(|s| s.get("input"))
1485                    .cloned()
1486                    .unwrap_or_else(|| Value::Object(Default::default()));
1487                calls.push(function_call(call_id, tool_name, input.to_string()));
1488                if matches!(status, Some("completed") | Some("error")) {
1489                    tool_results.push((call_id.to_string(), tool_name.to_string(), p.clone()));
1490                }
1491            }
1492            // file/step-start/step-finish/snapshot/patch/agent/subtask/retry
1493            // — no clean home on an Assistant turn (§2.3).
1494            _ => {}
1495        }
1496    }
1497
1498    let before = out.len();
1499    push_assistant(out, text, calls);
1500    // A native OpenCode assistant record is transcript state even when it
1501    // has no parts. Real stores contain these after an interrupted/empty
1502    // model turn; dropping the record here loses its id, timestamp, model,
1503    // token/cost metadata, and shifts the conversation on every export.
1504    // Keep one empty canonical assistant message so all target writers can
1505    // preserve the turn. This also covers reasoning-only records (whose
1506    // reasoning payload is attached as metadata just below).
1507    if out.len() == before {
1508        let mut empty = ChatMessage {
1509            role: Role::Assistant,
1510            content: None,
1511            content_parts: None,
1512            tool_calls: None,
1513            tool_call_id: None,
1514            name: None,
1515            metadata: Default::default(),
1516        };
1517        if !reasoning_seen {
1518            empty
1519                .metadata
1520                .insert("empty_assistant_record".to_string(), "true".to_string());
1521        }
1522        out.push(empty);
1523    }
1524    if out.len() > before {
1525        let msg = out.last_mut().expect("just pushed");
1526        if reasoning_seen {
1527            msg.metadata.insert("thinking".to_string(), thinking);
1528        }
1529        if let Some(sig) = thinking_sig {
1530            msg.metadata.insert("thinking_signature".to_string(), sig);
1531        }
1532        if let Some(id) = msg_value.get("id").and_then(Value::as_str) {
1533            msg.metadata
1534                .insert("oc_message_id".to_string(), id.to_string());
1535        }
1536        if let Some(agent) = msg_value.get("agent").and_then(Value::as_str) {
1537            msg.metadata.insert("agent".to_string(), agent.to_string());
1538            if meta.agent_id.is_none() {
1539                meta.agent_id = Some(agent.to_string());
1540            }
1541        }
1542        let provider = msg_value.get("providerID").and_then(Value::as_str);
1543        let model_id = msg_value.get("modelID").and_then(Value::as_str);
1544        if let (Some(p), Some(i)) = (provider, model_id) {
1545            let full = format!("{p}/{i}");
1546            msg.metadata.insert("model".to_string(), full.clone());
1547            if meta.model.is_none() {
1548                meta.model = Some(full);
1549            }
1550        }
1551        if let Some(cwd) = msg_value
1552            .get("path")
1553            .and_then(|p| p.get("cwd"))
1554            .and_then(Value::as_str)
1555        {
1556            if meta.cwd.is_none() {
1557                meta.cwd = Some(PathBuf::from(cwd));
1558            }
1559        }
1560        if msg_value.get("summary").and_then(Value::as_bool) == Some(true) {
1561            msg.metadata
1562                .insert("is_summary".to_string(), "true".to_string());
1563        }
1564        for (key, field) in [
1565            ("finish", "finish"),
1566            ("variant", "variant"),
1567            ("mode", "mode"),
1568        ] {
1569            if let Some(s) = msg_value.get(field).and_then(Value::as_str) {
1570                msg.metadata.insert(key.to_string(), s.to_string());
1571            }
1572        }
1573        for (key, field) in [
1574            ("cost", "cost"),
1575            ("tokens", "tokens"),
1576            ("error", "error"),
1577            ("structured", "structured"),
1578        ] {
1579            if let Some(v) = msg_value.get(field) {
1580                if !v.is_null() {
1581                    msg.metadata.insert(key.to_string(), v.to_string());
1582                }
1583            }
1584        }
1585        // Subagent linkage (§2.1): a `task` tool's own `metadata` carries
1586        // the spawned child session id — keyed by callID so multiple `task`
1587        // calls in one message never collide.
1588        // `resolve_opencode_parent_tool_use_ids` reads these back once a
1589        // whole session set is loaded.
1590        for p in parts {
1591            if p.get("type").and_then(Value::as_str) == Some("tool")
1592                && p.get("tool").and_then(Value::as_str) == Some("task")
1593            {
1594                if let (Some(call_id), Some(child)) = (
1595                    p.get("callID").and_then(Value::as_str),
1596                    p.get("metadata")
1597                        .and_then(|m| m.get("sessionId"))
1598                        .and_then(Value::as_str),
1599                ) {
1600                    msg.metadata.insert(
1601                        format!("oc_task_child_session_id__{call_id}"),
1602                        child.to_string(),
1603                    );
1604                }
1605            }
1606        }
1607        set_opencode_msg_timestamp(msg, msg_value);
1608        restore_grok_message_extension(msg_value, msg);
1609    }
1610
1611    // Second pass: the paired Tool-role message for each completed/error
1612    // tool part, split by callID (§2.1 — "the SAME part carries call and
1613    // result").
1614    for (call_id, tool_name, part) in tool_results {
1615        let status = part
1616            .get("state")
1617            .and_then(|s| s.get("status"))
1618            .and_then(Value::as_str);
1619        let compacted_at = part
1620            .get("state")
1621            .and_then(|s| s.get("time"))
1622            .and_then(|t| t.get("compacted"))
1623            .and_then(Value::as_i64);
1624        let real_output = part
1625            .get("state")
1626            .and_then(|s| s.get("output"))
1627            .and_then(Value::as_str)
1628            .unwrap_or("")
1629            .to_string();
1630        let (content, is_error) = match status {
1631            Some("completed") => {
1632                if compacted_at.is_some() {
1633                    (OPENCODE_COMPACTED_TOOL_PLACEHOLDER.to_string(), false)
1634                } else {
1635                    (real_output.clone(), false)
1636                }
1637            }
1638            Some("error") => {
1639                let err = part
1640                    .get("state")
1641                    .and_then(|s| s.get("error"))
1642                    .and_then(Value::as_str)
1643                    .unwrap_or("")
1644                    .to_string();
1645                (err, true)
1646            }
1647            _ => (String::new(), false),
1648        };
1649        let mut tmsg = ChatMessage {
1650            role: Role::Tool,
1651            content: Some(content),
1652            content_parts: None,
1653            tool_calls: None,
1654            tool_call_id: Some(call_id),
1655            name: Some(tool_name),
1656            metadata: Default::default(),
1657        };
1658        if let Some(original_position) = part
1659            .get(OPENCODE_SUPERCODE_RESULT_POSITION)
1660            .and_then(Value::as_u64)
1661        {
1662            tmsg.metadata.insert(
1663                OPENCODE_INTERNAL_ORIGINAL_POSITION.to_string(),
1664                original_position.to_string(),
1665            );
1666        }
1667        if is_error {
1668            crate::mark_tool_error(&mut tmsg);
1669        }
1670        restore_tool_outcome_extension(&part, &mut tmsg);
1671        if let Some(ts) = compacted_at {
1672            // S1: the real output is preserved — reversible, never erased.
1673            tmsg.metadata
1674                .insert("oc_tool_output_compacted".to_string(), real_output);
1675            tmsg.metadata
1676                .insert("oc_tool_time_compacted".to_string(), ts.to_string());
1677        }
1678        if status == Some("completed") {
1679            if let Some(atts) = part
1680                .get("state")
1681                .and_then(|s| s.get("attachments"))
1682                .and_then(Value::as_array)
1683            {
1684                let images: Vec<Value> = atts.iter().filter_map(opencode_file_image_part).collect();
1685                if !images.is_empty() {
1686                    // D-mix consistency fix (Fable-recommended, same
1687                    // pattern as `push_claude_user`'s tool_result arm above):
1688                    // a completed opencode tool part with BOTH `state.output`
1689                    // text and `state.attachments` images is the same
1690                    // non-self-contained hybrid shape — `content_parts` here
1691                    // used to hold images only, so opencode -> pi silently
1692                    // dropped the output text (`pi_content_value` reads
1693                    // `content_parts` exclusively for `Role::Tool`). Prepend
1694                    // the text as part 0 so `content_parts` is
1695                    // self-contained; `tmsg.content` keeps the text too,
1696                    // unchanged, for writers that read it from there and
1697                    // only scan `content_parts` for `image_url` entries.
1698                    let mut parts = Vec::new();
1699                    if let Some(t) = &tmsg.content {
1700                        if !t.is_empty() {
1701                            parts.push(serde_json::json!({"type": "text", "text": t}));
1702                        }
1703                    }
1704                    parts.extend(images);
1705                    tmsg.content_parts = Some(parts);
1706                }
1707            }
1708        }
1709        if let Some(id) = part.get("id").and_then(Value::as_str) {
1710            tmsg.metadata
1711                .insert("oc_part_id".to_string(), id.to_string());
1712        }
1713        // WAVE-2 item 1: a tool part's own `state.time.{end,start}` (unix-ms,
1714        // real opencode wire shape — `pi-fields.md`/`opencode-pi-spec.md`
1715        // cite `state.time.compacted`, but the SAME object also carries
1716        // `start`/`end` on every completed/error call) is this Tool
1717        // message's real source timestamp; prefer `end` (completion, closer
1718        // to when the RESULT — this message's content — was produced) and
1719        // fall back to `start` when only that is present.
1720        let tool_ts = part
1721            .get("state")
1722            .and_then(|s| s.get("time"))
1723            .and_then(|t| t.get("end").or_else(|| t.get("start")))
1724            .and_then(Value::as_i64);
1725        if let Some(ms) = tool_ts {
1726            tmsg.metadata
1727                .insert("timestamp".to_string(), crate::sidecar::ms_to_rfc3339(ms));
1728        }
1729        // OpenCode folds a canonical tool result into the assistant's tool
1730        // part. Restore the portable envelope from that part after native
1731        // fields have been captured so A -> OpenCode -> A retains fields
1732        // OpenCode does not model independently (for example Goose's
1733        // message-level metadata and an intentionally absent tool name).
1734        restore_grok_message_extension(&part, &mut tmsg);
1735        out.push(tmsg);
1736    }
1737}
1738
1739/// OpenCode reloads an export document by sorting messages on
1740/// `time.created`, so a timestamp-less appended continuation cannot reuse
1741/// the fixed historical [`SYNTH_TS_MS`] fallback when its imported prefix is
1742/// newer. Advance a deterministic cursor for synthesized clocks while still
1743/// preserving every real source timestamp verbatim.
1744pub(super) fn opencode_message_timestamp(msg: &ChatMessage, cursor: &mut i64) -> Result<i64> {
1745    if let Some(real) = msg
1746        .metadata
1747        .get("timestamp")
1748        .and_then(|s| crate::sidecar::rfc3339_to_ms(s))
1749    {
1750        // A NativeTurn timestamp is durable provenance minted by supercode,
1751        // not an OpenCode source clock that must be replayed verbatim.
1752        // Multiple turns may be recorded in the same millisecond, while
1753        // OpenCode sorts solely by `time.created`; allocate such turns after
1754        // the existing cursor so their persisted order cannot collapse. This
1755        // also preserves the fail-closed i64::MAX exhaustion behavior.
1756        if msg.metadata.contains_key("supercode_native_uuid") && real <= *cursor {
1757            *cursor = cursor.checked_add(1).ok_or_else(|| {
1758                crate::Error::Other(
1759                    "cannot synthesize an OpenCode continuation timestamp after i64::MAX"
1760                        .to_string(),
1761                )
1762            })?;
1763            return Ok(*cursor);
1764        }
1765        *cursor = (*cursor).max(real);
1766        return Ok(real);
1767    }
1768    let next = cursor.checked_add(1).ok_or_else(|| {
1769        crate::Error::Other(
1770            "cannot synthesize an OpenCode continuation timestamp after i64::MAX".to_string(),
1771        )
1772    })?;
1773    *cursor = next.max(SYNTH_TS_MS);
1774    Ok(*cursor)
1775}
1776
1777/// Largest integer nested under any OpenCode `time` object. Imported
1778/// prefixes carry more clocks than `message.time.created` (assistant
1779/// completion, tool start/end, session updated); a synthesized continuation
1780/// must follow all of them, not merely sort after message creation times.
1781fn opencode_max_timestamp(value: &Value) -> Option<i64> {
1782    fn max_number(value: &Value) -> Option<i64> {
1783        match value {
1784            Value::Number(n) => n.as_i64(),
1785            Value::Array(values) => values.iter().filter_map(max_number).max(),
1786            Value::Object(fields) => fields.values().filter_map(max_number).max(),
1787            _ => None,
1788        }
1789    }
1790
1791    match value {
1792        Value::Array(values) => values.iter().filter_map(opencode_max_timestamp).max(),
1793        Value::Object(fields) => fields
1794            .iter()
1795            .filter_map(|(key, value)| {
1796                if key == "time" {
1797                    max_number(value)
1798                } else {
1799                    opencode_max_timestamp(value)
1800                }
1801            })
1802            .max(),
1803        _ => None,
1804    }
1805}
1806
1807impl Session {
1808    // ---- OpenCode writers ---------------------------------------------
1809
1810    /// Re-derive the structured OpenCode records (`SessionInfo`, an ordered
1811    /// `(message value, part values)` list) directly from `self.raw`'s
1812    /// envelope lines — the same classification
1813    /// [`Self::from_opencode_str`] performs, but returning the raw VALUES
1814    /// rather than canonical `ChatMessage`s. Used by
1815    /// [`Self::to_opencode_jsonl_spliced`] (§4.2 S5: the imported prefix
1816    /// must be VALUE-EQUAL at its position, not re-synthesized from lossy
1817    /// `messages`) and [`Self::to_opencode_direct_write`] (S5: the fidelity
1818    /// path for excess keys/timestamps/side-records `opencode import`
1819    /// cannot restore).
1820    fn opencode_records_from_raw(&self) -> (Option<Value>, Vec<(Value, Vec<Value>)>) {
1821        let mut session_info: Option<Value> = None;
1822        let mut msg_order: Vec<String> = Vec::new();
1823        let mut msg_values: HashMap<String, Value> = HashMap::new();
1824        let mut msg_parts: HashMap<String, Vec<Value>> = HashMap::new();
1825        for line in &self.raw {
1826            let Ok(env) = serde_json::from_str::<Value>(line) else {
1827                continue;
1828            };
1829            let Some(key) = env.get("key").and_then(Value::as_array) else {
1830                continue;
1831            };
1832            let value = env.get("value").cloned().unwrap_or(Value::Null);
1833            match key.first().and_then(Value::as_str) {
1834                Some("session") => session_info = Some(value),
1835                Some("message") => {
1836                    if let Some(id) = value.get("id").and_then(Value::as_str) {
1837                        if !msg_values.contains_key(id) {
1838                            msg_order.push(id.to_string());
1839                        }
1840                        msg_values.insert(id.to_string(), value);
1841                    }
1842                }
1843                Some("part") => {
1844                    if let Some(mid) = value.get("messageID").and_then(Value::as_str) {
1845                        msg_parts.entry(mid.to_string()).or_default().push(value);
1846                    }
1847                }
1848                _ => {}
1849            }
1850        }
1851        let mut ordered: Vec<(String, i64)> = msg_order
1852            .iter()
1853            .map(|id| {
1854                let tc = msg_values
1855                    .get(id)
1856                    .and_then(|v| v.get("time"))
1857                    .and_then(|t| t.get("created"))
1858                    .and_then(Value::as_i64)
1859                    .unwrap_or(0);
1860                (id.clone(), tc)
1861            })
1862            .collect();
1863        ordered.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
1864        let mut out = Vec::new();
1865        for (id, _) in ordered {
1866            let mut parts = msg_parts.remove(&id).unwrap_or_default();
1867            parts.sort_by(|a, b| {
1868                let ai = a.get("id").and_then(Value::as_str).unwrap_or("");
1869                let bi = b.get("id").and_then(Value::as_str).unwrap_or("");
1870                ai.cmp(bi)
1871            });
1872            if let Some(v) = msg_values.remove(&id) {
1873                out.push((v, parts));
1874            }
1875        }
1876        (session_info, out)
1877    }
1878
1879    /// Best-effort `SessionInfo` synthesized from `self.meta` — used when no
1880    /// `raw` prefix exists to replay (a fresh/cross-format-converted
1881    /// session). T3 tier: only what `SessionMeta` carries survives.
1882    fn synthesized_opencode_info(&self) -> Value {
1883        let id = self
1884            .meta
1885            .session_id
1886            .clone()
1887            .unwrap_or_else(|| "ses_supercode00000000000001".to_string());
1888        let mut info = serde_json::json!({
1889            "id": id,
1890            "projectID": self.meta.lineage.get("projectID").cloned().unwrap_or_else(|| "global".to_string()),
1891            // OpenCode 1.2.15's import path writes this into a NOT NULL
1892            // SQLite column. Preserve a real source slug when available and
1893            // mint a stable, human-readable fallback for foreign sessions.
1894            "slug": self.meta.lineage.get("slug").cloned().unwrap_or_else(|| "supercode-export".to_string()),
1895            "directory": self.cwd_string(),
1896            "title": "supercode export",
1897            "version": env!("CARGO_PKG_VERSION"),
1898            "time": {"created": SYNTH_TS_MS, "updated": SYNTH_TS_MS},
1899        });
1900        if let Some(agent) = &self.meta.agent_id {
1901            info["agent"] = Value::String(agent.clone());
1902        }
1903        if let Some(model) = &self.meta.model {
1904            if let Some((provider, mid)) = model.split_once('/') {
1905                info["model"] = serde_json::json!({"providerID": provider, "id": mid});
1906            }
1907        }
1908        if let Some(parent) = self.meta.lineage.get("parent_session_id") {
1909            info["parentID"] = Value::String(parent.clone());
1910        }
1911        // D7: carry a captured Claude `fork-context-ref` through the
1912        // OpenCode hop too, exactly like the Codex (`claude_fork_context_ref`
1913        // on `session_meta.payload`) and Pi (`claude_fork_context_ref` on
1914        // the `session` header) hops already do — namespaced so real
1915        // OpenCode tooling ignores it, and `capture_opencode_session_info`
1916        // reads this same key back on import so a Claude -> OpenCode ->
1917        // Claude round trip doesn't silently lose fork lineage either.
1918        //
1919        // DOCUMENTED LIMITATION (D7 caveat, per the frozen interop spec —
1920        // `interop-research-spec@f168465`, `opencode-pi-spec.md` ~line 123):
1921        // this `claude_fork_context_ref` key on `SessionInfo` survives
1922        // supercode's OWN round-trip (write here, read back by
1923        // `capture_opencode_session_info` above) but NOT a real upstream
1924        // `opencode import` ingestion — that path decodes with
1925        // `Schema.decodeUnknownSync`, which strips any key its schema
1926        // doesn't declare. The direct-file/DB fallback (bypassing
1927        // `opencode import` entirely) is the per-spec fidelity path for
1928        // this lineage to actually reach real OpenCode.
1929        if let Some(raw) = self.meta.lineage.get("claude_fork_context_ref_raw") {
1930            info["claude_fork_context_ref"] =
1931                serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.clone()));
1932        }
1933        if let Some(extension) = native_residue_envelope(&self.meta) {
1934            info[SUPERCODE_NATIVE_RESIDUE_SUMMARY_KEY] = native_residue_summary(&extension);
1935            info[SUPERCODE_NATIVE_RESIDUE_KEY] = extension;
1936        }
1937        info
1938    }
1939
1940    /// OpenCode uses `SessionInfo.time.updated` for recency selection. Any
1941    /// synthesized continuation message therefore has to advance the
1942    /// session clock along with its own `time.created` value.
1943    fn touch_opencode_session_updated(info: &mut Value, timestamp: i64) {
1944        if !info.get("time").is_some_and(Value::is_object) {
1945            info["time"] = serde_json::json!({});
1946        }
1947        info["time"]["updated"] = serde_json::json!(timestamp);
1948    }
1949
1950    /// Synthesize opencode `{info, parts}` message objects for `messages`
1951    /// (T3 tier — full synthesis from canonical `ChatMessage`s, the inverse
1952    /// of [`push_opencode_user`]/[`push_opencode_assistant`]), appending
1953    /// them to `out`. Every `Tool` message anywhere in `messages` is folded
1954    /// back into its call's assistant `tool` part (match by
1955    /// `tool_call_id`/`callID`, pairing repeated ids by occurrence over the
1956    /// whole slice)
1957    /// — the exact inverse of the loader's call/result split. This is a
1958    /// GLOBAL match, not a scan of the contiguous run of `Tool` messages
1959    /// immediately following each assistant: two-or-more consecutive
1960    /// assistant-with-tool-call messages before their results (streamed /
1961    /// parallel tool calls) otherwise strand the earlier call's real result
1962    /// behind a later assistant message, silently downgrading it to
1963    /// "pending" and losing the recorded output. Skips [`is_replay_excluded`]
1964    /// messages exactly like every other writer.
1965    fn append_synthesized_opencode_messages(
1966        &self,
1967        out: &mut Vec<Value>,
1968        messages: &[ChatMessage],
1969        session_id: &str,
1970        counter: &mut u64,
1971        timestamp_cursor: &mut i64,
1972    ) -> Result<()> {
1973        // Pair each Tool message to its call GLOBALLY by `tool_call_id`,
1974        // over the ENTIRE slice being processed, rather than by scanning
1975        // only the contiguous run of `Role::Tool` messages immediately
1976        // following a given assistant message. Two-or-more consecutive
1977        // assistant-with-tool-call messages before their results (streamed
1978        // / parallel tool calls — extremely common in real Claude Code and
1979        // Codex sessions) break the contiguous-run assumption: the first
1980        // assistant's own result(s) land AFTER a second assistant message,
1981        // not immediately after the first, so a contiguous scan starting
1982        // right after the first assistant finds nothing and silently drops
1983        // its real tool output into the `None => "pending"` branch below.
1984        // A single `id -> result` map is still insufficient: long real
1985        // sessions can reuse provider call ids. Last-write-wins then attaches
1986        // the final output to every earlier occurrence. Collect calls and
1987        // results independently and zip their occurrences in transcript
1988        // order, giving every concrete call position its own result.
1989        let mut calls_by_id: HashMap<&str, Vec<(usize, usize)>> = HashMap::new();
1990        let mut results_by_id: HashMap<&str, Vec<(usize, &ChatMessage)>> = HashMap::new();
1991        for (message_index, message) in messages.iter().enumerate() {
1992            if message.role == Role::Assistant {
1993                for (tool_index, call) in message.tool_calls().iter().enumerate() {
1994                    calls_by_id
1995                        .entry(call.id.as_str())
1996                        .or_default()
1997                        .push((message_index, tool_index));
1998                }
1999            } else if message.role == Role::Tool {
2000                if let Some(id) = &message.tool_call_id {
2001                    results_by_id
2002                        .entry(id.as_str())
2003                        .or_default()
2004                        .push((message_index, message));
2005                }
2006            }
2007        }
2008        let mut paired_results: HashMap<(usize, usize), (usize, &ChatMessage)> = HashMap::new();
2009        for (id, calls) in calls_by_id {
2010            let Some(results) = results_by_id.get(id) else {
2011                continue;
2012            };
2013            for (call_position, result) in calls.into_iter().zip(results.iter().copied()) {
2014                paired_results.insert(call_position, result);
2015            }
2016        }
2017        let mut i = 0;
2018        while i < messages.len() {
2019            let msg = &messages[i];
2020            if is_replay_excluded(msg) {
2021                i += 1;
2022                continue;
2023            }
2024            match msg.role {
2025                // B4: opencode V1 has no session-level system-PROMPT slot
2026                // either — `User.system` is a per-turn system-PROMPT
2027                // OVERRIDE (§2.1), a different thing from a content-bearing
2028                // `Role::System` message loaded from a real Claude `type:
2029                // "system"` record (`push_claude_system`'s keep-listed
2030                // subtypes). Stuffing real transcript content into
2031                // `User.system` would be a genuine misuse — it overrides the
2032                // replayed system prompt, not just annotates a turn — so
2033                // this instead reuses opencode's own `text` part `synthetic`
2034                // flag (§3.1: "injected by opencode, not typed by user"),
2035                // which is EXACTLY the right existing, non-fabricated
2036                // semantic for "system-originated content presented as a
2037                // user turn": a dedicated `User` message with one
2038                // `synthetic: true` text part, tagged with a
2039                // supercode-namespaced part-`metadata` key so
2040                // `opencode_claude_system_subtype`/`push_opencode_claude_system`
2041                // recognize it on reload and restore `Role::System` +
2042                // `metadata["systemSubtype"]` rather than treating it as a
2043                // real user turn. Content is never fabricated — only
2044                // emitted when non-empty.
2045                Role::System => {
2046                    let content = msg.content.clone().unwrap_or_default();
2047                    if content.trim().is_empty() {
2048                        i += 1;
2049                        continue;
2050                    }
2051                    let subtype = msg
2052                        .metadata
2053                        .get("systemSubtype")
2054                        .cloned()
2055                        .unwrap_or_else(|| "local_command".to_string());
2056                    let msg_id = opencode_fresh_id("msg", counter);
2057                    let part_id = opencode_fresh_id("prt", counter);
2058                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
2059                    let mut info = serde_json::json!({
2060                        "id": msg_id,
2061                        "sessionID": session_id,
2062                        "role": "user",
2063                        "time": {"created": timestamp},
2064                    });
2065                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
2066                    let parts = vec![serde_json::json!({
2067                        "id": part_id,
2068                        "sessionID": session_id,
2069                        "messageID": msg_id,
2070                        "type": "text",
2071                        "text": content,
2072                        "synthetic": true,
2073                        "metadata": {OPENCODE_CLAUDE_SYSTEM_SUBTYPE_KEY: subtype},
2074                    })];
2075                    out.push(serde_json::json!({"info": info, "parts": parts}));
2076                    i += 1;
2077                }
2078                Role::User => {
2079                    let msg_id = opencode_fresh_id("msg", counter);
2080                    let parts = opencode_user_parts_from_message(msg, &msg_id, session_id, counter);
2081                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
2082                    let mut info = serde_json::json!({
2083                        "id": msg_id,
2084                        "sessionID": session_id,
2085                        "role": "user",
2086                        "time": {"created": timestamp},
2087                    });
2088                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
2089                    opencode_restore_agent_model_fields(
2090                        &mut info, msg, /* is_assistant */ false,
2091                    );
2092                    set_grok_message_extension(&mut info, self.meta.source, msg);
2093                    out.push(serde_json::json!({
2094                        "info": info,
2095                        "parts": parts,
2096                    }));
2097                    i += 1;
2098                }
2099                Role::Assistant => {
2100                    let msg_id = opencode_fresh_id("msg", counter);
2101                    let timestamp = opencode_message_timestamp(msg, timestamp_cursor)?;
2102                    let mut parts = Vec::new();
2103                    if let Some(thinking) = msg.metadata.get("thinking") {
2104                        let mut part = serde_json::json!({
2105                            "id": opencode_fresh_id("prt", counter),
2106                            "sessionID": session_id,
2107                            "messageID": msg_id,
2108                            "type": "reasoning",
2109                            "text": thinking,
2110                            // Required by OpenCode V1's native reasoning
2111                            // schema. A synthesized part has no distinct
2112                            // stream start/end, so the source message clock
2113                            // is the honest zero-duration span.
2114                            "time": {"start": timestamp, "end": timestamp},
2115                        });
2116                        if let Some(signature) = msg.metadata.get("thinking_signature") {
2117                            part["metadata"] = serde_json::json!({
2118                                "anthropic": {"signature": signature},
2119                            });
2120                        }
2121                        parts.push(part);
2122                    }
2123                    if let Some(t) = &msg.content {
2124                        if !t.is_empty() {
2125                            parts.push(serde_json::json!({
2126                                "id": opencode_fresh_id("prt", counter),
2127                                "sessionID": session_id,
2128                                "messageID": msg_id,
2129                                "type": "text",
2130                                "text": t,
2131                            }));
2132                        }
2133                    }
2134                    // Fold each tool call's result back into ONE `tool`
2135                    // part, matched by tool_call_id via the GLOBAL
2136                    // `all_results` map built above (not a contiguous scan)
2137                    // — a result may be many messages away when other
2138                    // assistant turns with their own pending calls
2139                    // intervene before it appears.
2140                    for (tool_index, tc) in msg.tool_calls().iter().enumerate() {
2141                        let input = tc
2142                            .function
2143                            .parsed_arguments()
2144                            .unwrap_or_else(|_| Value::Object(Default::default()));
2145                        let paired_result = paired_results.get(&(i, tool_index)).copied();
2146                        let state = match paired_result {
2147                            Some((_, result)) if crate::is_tool_error(result) => {
2148                                let result_timestamp =
2149                                    opencode_message_timestamp(result, timestamp_cursor)?;
2150                                serde_json::json!({
2151                                    "status": "error",
2152                                    "input": input,
2153                                    "error": result.content.clone().unwrap_or_default(),
2154                                    "time": {"end": result_timestamp},
2155                                })
2156                            }
2157                            Some((_, result)) => {
2158                                let result_timestamp =
2159                                    opencode_message_timestamp(result, timestamp_cursor)?;
2160                                let mut s = serde_json::json!({
2161                                    "status": "completed",
2162                                    "input": input,
2163                                    "output": result.content.clone().unwrap_or_default(),
2164                                    "title": tc.function.name,
2165                                    "time": {"end": result_timestamp},
2166                                });
2167                                // PARITY-11 (nested images): the LOADER already
2168                                // reads a completed tool part's
2169                                // `state.attachments` back into `content_parts`
2170                                // (`opencode_file_image_part`, above) — this is
2171                                // the missing WRITE-side inverse. Without it, a
2172                                // Claude `tool_result`'s nested image (now
2173                                // captured into `content_parts` by
2174                                // `extract_tool_result_content`) reached
2175                                // `content_parts` on the canonical `ChatMessage`
2176                                // but was silently dropped again on re-export to
2177                                // OpenCode, because nothing ever read it back
2178                                // out. `mime`/`url` shape matches exactly what
2179                                // `opencode_file_image_part` expects on reload.
2180                                if let Some(cps) = &result.content_parts {
2181                                    let atts: Vec<Value> = cps
2182                                        .iter()
2183                                        .filter(|p| {
2184                                            p.get("type").and_then(Value::as_str)
2185                                                == Some("image_url")
2186                                        })
2187                                        .filter_map(|p| {
2188                                            let url = p
2189                                                .get("image_url")
2190                                                .and_then(|u| u.get("url"))
2191                                                .and_then(Value::as_str)?;
2192                                            let mime = url
2193                                                .strip_prefix("data:")
2194                                                .and_then(|r| r.split_once(','))
2195                                                .map(|(m, _)| m.trim_end_matches(";base64"))
2196                                                .unwrap_or("application/octet-stream");
2197                                            Some(serde_json::json!({
2198                                                "mime": mime,
2199                                                "url": url,
2200                                            }))
2201                                        })
2202                                        .collect();
2203                                    if !atts.is_empty() {
2204                                        s["attachments"] = Value::Array(atts);
2205                                    }
2206                                }
2207                                s
2208                            }
2209                            None => serde_json::json!({"status": "pending", "input": input}),
2210                        };
2211                        let mut part = serde_json::json!({
2212                            "id": opencode_fresh_id("prt", counter),
2213                            "sessionID": session_id,
2214                            "messageID": msg_id,
2215                            "type": "tool",
2216                            "callID": tc.id,
2217                            "tool": tc.function.name,
2218                            "state": state,
2219                        });
2220                        if let Some((result_position, _)) = paired_result {
2221                            part[OPENCODE_SUPERCODE_RESULT_POSITION] =
2222                                serde_json::json!(result_position);
2223                        }
2224                        if paired_result.is_some_and(|(_, result)| {
2225                            crate::tool_outcome(result) == crate::ToolOutcome::Unknown
2226                        }) {
2227                            part[SUPERCODE_TOOL_OUTCOME_KEY] = Value::String("unknown".to_string());
2228                        }
2229                        if let Some((_, result)) = paired_result {
2230                            set_grok_message_extension(&mut part, self.meta.source, result);
2231                        }
2232                        parts.push(part);
2233                    }
2234                    let mut info = serde_json::json!({
2235                        "id": msg_id,
2236                        "sessionID": session_id,
2237                        "role": "assistant",
2238                        "time": {"created": timestamp},
2239                    });
2240                    info[OPENCODE_SUPERCODE_MESSAGE_POSITION] = serde_json::json!(i);
2241                    opencode_restore_agent_model_fields(
2242                        &mut info, msg, /* is_assistant */ true,
2243                    );
2244                    set_grok_message_extension(&mut info, self.meta.source, msg);
2245                    out.push(serde_json::json!({
2246                        "info": info,
2247                        "parts": parts,
2248                    }));
2249                    i += 1;
2250                }
2251                // A Tool message is always folded into its call's assistant
2252                // `tool` part above (via occurrence-aware global pairing, not
2253                // positional adjacency), so it never needs its own entry
2254                // here — just advance past it.
2255                Role::Tool => i += 1,
2256            }
2257        }
2258        Ok(())
2259    }
2260
2261    /// Synthesize a fresh OpenCode export DOCUMENT from the canonical
2262    /// `messages` (T3 cross-format/full synthesis tier — mirrors
2263    /// [`Self::to_pi_jsonl`]'s doc comment: this is NOT the value-lossless
2264    /// native path, which goes through `raw` + `to_native_jsonl(_v2)`
2265    /// instead). Shape: `{info: SessionInfo, messages: [{info, parts}, …]}`
2266    /// (§1.2 — the `opencode export`/`import` interchange shape).
2267    pub(super) fn to_opencode_jsonl(&self) -> Result<String> {
2268        let mut info = self.synthesized_opencode_info();
2269        let ses_id = info
2270            .get("id")
2271            .and_then(Value::as_str)
2272            .unwrap_or("ses_new")
2273            .to_string();
2274        let mut messages_json: Vec<Value> = Vec::new();
2275        let mut counter: u64 = 0;
2276        let mut timestamp_cursor =
2277            opencode_max_timestamp(&info).unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
2278        self.append_synthesized_opencode_messages(
2279            &mut messages_json,
2280            &self.messages,
2281            &ses_id,
2282            &mut counter,
2283            &mut timestamp_cursor,
2284        )?;
2285        if !messages_json.is_empty() {
2286            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
2287        }
2288        let doc = serde_json::json!({"info": info, "messages": messages_json});
2289        Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
2290    }
2291
2292    /// A12-style splice for OpenCode (§1.2/§4.2 point 1, S5): replay the
2293    /// imported records **value-equal at their position** in the export
2294    /// doc's `messages[]`/`parts[]` — reconstructed directly from `self.raw`
2295    /// via [`Self::opencode_records_from_raw`], never re-derived from the
2296    /// lossy canonical `messages` — then append freshly synthesized
2297    /// `{info, parts}` objects for the tail via
2298    /// [`Self::append_synthesized_opencode_messages`]. Unlike the
2299    /// line-oriented formats' splice, `out` here is a single export
2300    /// DOCUMENT, not a line stream (§4.2 restates the "prefix verbatim"
2301    /// assertion accordingly: value-equality at position, not byte
2302    /// equality of a line range).
2303    pub(super) fn to_opencode_jsonl_spliced(&self, session_id: Option<&str>) -> Result<String> {
2304        if self.raw.is_empty() {
2305            return self.to_opencode_jsonl();
2306        }
2307        let (session_info, records) = self.opencode_records_from_raw();
2308        let (_, message_prefix_len) = self.spliced_prefix_lens();
2309
2310        let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
2311        if let Some(id) = session_id {
2312            info["id"] = Value::String(id.to_string());
2313        }
2314        let ses_id_for_new = info
2315            .get("id")
2316            .and_then(Value::as_str)
2317            .unwrap_or("ses_new")
2318            .to_string();
2319
2320        let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
2321            .chain(records.iter().flat_map(|(msg, parts)| {
2322                std::iter::once(opencode_max_timestamp(msg))
2323                    .chain(parts.iter().map(opencode_max_timestamp))
2324            }))
2325            .flatten()
2326            .max()
2327            .unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
2328
2329        let mut messages_json: Vec<Value> = records
2330            .into_iter()
2331            .map(|(msg, parts)| serde_json::json!({"info": msg, "parts": parts}))
2332            .collect();
2333        let imported_len = messages_json.len();
2334
2335        let mut counter: u64 = 0;
2336        self.append_synthesized_opencode_messages(
2337            &mut messages_json,
2338            &self.messages[message_prefix_len..],
2339            &ses_id_for_new,
2340            &mut counter,
2341            &mut timestamp_cursor,
2342        )?;
2343        if messages_json.len() > imported_len {
2344            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
2345        }
2346
2347        let doc = serde_json::json!({"info": info, "messages": messages_json});
2348        Ok(serde_json::to_string_pretty(&doc).unwrap_or_default())
2349    }
2350
2351    /// The **required** direct-write fallback (S5): write the imported
2352    /// OpenCode records **verbatim** — excess/unknown keys, part-row
2353    /// timestamps, and `session_diff`/`todo` side-records intact — to a
2354    /// generation-B JSON-file storage tree
2355    /// (`docs/interop/opencode-pi-spec.md` §1.2), the fidelity path
2356    /// `opencode import` cannot provide (S5: import re-decodes through a
2357    /// strict schema and STRIPS excess keys; inserts part rows without
2358    /// `time_created`/`time_updated`, so those reset to `Date.now()`; and
2359    /// has no ingestion path for `session_diff`/`todo` at all).
2360    ///
2361    /// Writes the JSON-FILE layout rather than a live SQLite write
2362    /// specifically to avoid a new `rusqlite`-class dependency on this
2363    /// build's memory-constrained box (see the build report); `session_diff`
2364    /// itself is still JSON-written by upstream even on SQLite installs
2365    /// (§1.3), so this is a real fidelity path, not a fictional one.
2366    ///
2367    /// Returns the `storage/session/<projectID>/` directory written to.
2368    pub fn to_opencode_direct_write(&self, data_root: &Path) -> Result<PathBuf> {
2369        let (session_info, mut records) = self.opencode_records_from_raw();
2370        let mut info = session_info.unwrap_or_else(|| self.synthesized_opencode_info());
2371        let ses_id = info
2372            .get("id")
2373            .and_then(Value::as_str)
2374            .unwrap_or("ses_new")
2375            .to_string();
2376        if info.get("id").is_none() {
2377            info["id"] = Value::String(ses_id.clone());
2378        }
2379        let project_id = info
2380            .get("projectID")
2381            .and_then(Value::as_str)
2382            .unwrap_or("global")
2383            .to_string();
2384
2385        // Appended tail (messages produced after import): synthesize fresh
2386        // message/part VALUES via the same T3 synthesis the splice writer
2387        // uses, so continuation turns get files too. Do this BEFORE creating
2388        // any directories: timestamp exhaustion must fail atomically rather
2389        // than leave a partial direct-write tree behind.
2390        let (_, message_prefix_len) = self.spliced_prefix_lens();
2391        let mut counter: u64 = 0;
2392        let mut timestamp_cursor = std::iter::once(opencode_max_timestamp(&info))
2393            .chain(records.iter().flat_map(|(msg, parts)| {
2394                std::iter::once(opencode_max_timestamp(msg))
2395                    .chain(parts.iter().map(opencode_max_timestamp))
2396            }))
2397            .flatten()
2398            .max()
2399            .unwrap_or_else(|| SYNTH_TS_MS.saturating_sub(1));
2400        let mut appended_json: Vec<Value> = Vec::new();
2401        self.append_synthesized_opencode_messages(
2402            &mut appended_json,
2403            &self.messages[message_prefix_len..],
2404            &ses_id,
2405            &mut counter,
2406            &mut timestamp_cursor,
2407        )?;
2408        if !appended_json.is_empty() {
2409            Self::touch_opencode_session_updated(&mut info, timestamp_cursor);
2410        }
2411        for entry in appended_json {
2412            let msg = entry.get("info").cloned().unwrap_or(Value::Null);
2413            let parts = entry
2414                .get("parts")
2415                .and_then(Value::as_array)
2416                .cloned()
2417                .unwrap_or_default();
2418            records.push((msg, parts));
2419        }
2420
2421        let storage = data_root.join("storage");
2422        let session_dir = storage.join("session").join(&project_id);
2423        std::fs::create_dir_all(&session_dir)?;
2424        std::fs::write(
2425            session_dir.join(format!("{ses_id}.json")),
2426            serde_json::to_string_pretty(&info).unwrap_or_default(),
2427        )?;
2428
2429        let message_dir = storage.join("message").join(&ses_id);
2430        let part_dir = storage.join("part");
2431        std::fs::create_dir_all(&message_dir)?;
2432
2433        for (msg, parts) in &records {
2434            let Some(msg_id) = msg.get("id").and_then(Value::as_str) else {
2435                continue;
2436            };
2437            std::fs::write(
2438                message_dir.join(format!("{msg_id}.json")),
2439                serde_json::to_string_pretty(msg).unwrap_or_default(),
2440            )?;
2441            let this_part_dir = part_dir.join(msg_id);
2442            std::fs::create_dir_all(&this_part_dir)?;
2443            for part in parts {
2444                let Some(part_id) = part.get("id").and_then(Value::as_str) else {
2445                    continue;
2446                };
2447                std::fs::write(
2448                    this_part_dir.join(format!("{part_id}.json")),
2449                    serde_json::to_string_pretty(part).unwrap_or_default(),
2450                )?;
2451            }
2452        }
2453
2454        // Side-records (S5c): session_diff / todo have NO ingestion path via
2455        // `opencode import` at all — the direct write is their only
2456        // fidelity path.
2457        for header in &self.meta.opencode_headers {
2458            let Some(key) = header.get("key").and_then(Value::as_array) else {
2459                continue;
2460            };
2461            let Some(kind) = key.first().and_then(Value::as_str) else {
2462                continue;
2463            };
2464            let value = header.get("value").cloned().unwrap_or(Value::Null);
2465            if !matches!(kind, "session_diff" | "todo") {
2466                continue;
2467            }
2468            let dir = storage.join(kind);
2469            std::fs::create_dir_all(&dir)?;
2470            std::fs::write(
2471                dir.join(format!("{ses_id}.json")),
2472                serde_json::to_string_pretty(&value).unwrap_or_default(),
2473            )?;
2474        }
2475
2476        Ok(session_dir)
2477    }
2478}
2479
2480fn opencode_fresh_id(prefix: &str, counter: &mut u64) -> String {
2481    *counter += 1;
2482    format!("{prefix}_synth{counter:06}")
2483}
2484
2485/// WAVE-2 fidelity item 2: restore the `agent`/`cost`/`finish`/`is_summary`/
2486/// `model`/`tokens` fields onto a synthesized OpenCode `info` record, in the
2487/// EXACT native shape opencode's own loaders (`push_opencode_user` /
2488/// `push_opencode_assistant`, above) parse back out — so an opencode->opencode
2489/// export-doc round-trip (write here, reload via [`Session::from_opencode_str`])
2490/// reconstructs value-identical `ChatMessage.metadata`. Each field is emitted
2491/// ONLY when its metadata key is present (a synthesized continuation turn, or
2492/// a User message that never carried `agent`, stays clean — no spurious
2493/// null/empty fields).
2494///
2495/// - `agent`: plain string on BOTH User (`v1/session.ts:346`) and Assistant
2496///   (`:465`) — `metadata["agent"]` is inserted verbatim by both loaders from
2497///   `msg_value.get("agent")`, so it's re-emitted verbatim here too.
2498/// - `model`: the loaders capture it in TWO DIFFERENT shapes depending on
2499///   role (`docs/interop/research/opencode-fields.md` §3 2a/2b), so the
2500///   inverse must match per-role:
2501///   - User: `push_opencode_user` stores `metadata["model"]` as the
2502///     STRINGIFIED `{providerID, modelID, variant?}` object
2503///     (`msg_value.get("model")...to_string()`) — re-parsed and re-emitted
2504///     as that same object under `"model"`.
2505///   - Assistant: `push_opencode_assistant` stores `metadata["model"]` as
2506///     `"{providerID}/{modelID}"` (from the top-level `providerID`/`modelID`
2507///     fields, joined) — split back on the FIRST `/` (matching `format!`'s
2508///     join; a `modelID` containing further `/`s round-trips correctly since
2509///     `split_once` only consumes the first) and re-emitted as the two
2510///     top-level `providerID`/`modelID` fields the loader actually reads.
2511/// - `is_summary`/`cost`/`finish`/`tokens`: Assistant-only concepts (no such
2512///   fields exist on opencode's `User` schema) — `is_summary` re-expands
2513///   `"true"` back to the native `summary: true` bool (the loader only ever
2514///   sets the metadata key on `Some(true)`, never on absent/false, so the
2515///   inverse never needs to emit `false`); `finish` is a plain string;
2516///   `cost`/`tokens` were captured via `v.to_string()` on the raw JSON
2517///   `Value` (a number and an object respectively), so they're re-parsed
2518///   from that stringified form and re-emitted as the native JSON value —
2519///   NOT as strings — matching `msg_value.get(field)` shape exactly.
2520fn opencode_restore_agent_model_fields(info: &mut Value, msg: &ChatMessage, is_assistant: bool) {
2521    if let Some(agent) = msg.metadata.get("agent") {
2522        info["agent"] = Value::String(agent.clone());
2523    }
2524    if let Some(model) = msg.metadata.get("model") {
2525        if is_assistant {
2526            if let Some((provider, model_id)) = model.split_once('/') {
2527                info["providerID"] = Value::String(provider.to_string());
2528                info["modelID"] = Value::String(model_id.to_string());
2529            }
2530        } else if let Ok(v) = serde_json::from_str::<Value>(model) {
2531            info["model"] = v;
2532        }
2533    }
2534    if !is_assistant {
2535        return;
2536    }
2537    if msg.metadata.get("is_summary").map(String::as_str) == Some("true") {
2538        info["summary"] = Value::Bool(true);
2539    }
2540    if let Some(finish) = msg.metadata.get("finish") {
2541        info["finish"] = Value::String(finish.clone());
2542    }
2543    if let Some(cost) = msg.metadata.get("cost") {
2544        if let Ok(v) = serde_json::from_str::<Value>(cost) {
2545            info["cost"] = v;
2546        }
2547    }
2548    if let Some(tokens) = msg.metadata.get("tokens") {
2549        if let Ok(v) = serde_json::from_str::<Value>(tokens) {
2550            info["tokens"] = v;
2551        }
2552    }
2553}
2554
2555fn opencode_user_parts_from_message(
2556    msg: &ChatMessage,
2557    msg_id: &str,
2558    session_id: &str,
2559    counter: &mut u64,
2560) -> Vec<Value> {
2561    let mut parts = Vec::new();
2562    if let Some(cps) = &msg.content_parts {
2563        for p in cps {
2564            match p.get("type").and_then(Value::as_str) {
2565                Some("text") => {
2566                    if let Some(t) = p.get("text").and_then(Value::as_str) {
2567                        parts.push(serde_json::json!({
2568                            "id": opencode_fresh_id("prt", counter),
2569                            "sessionID": session_id,
2570                            "messageID": msg_id,
2571                            "type": "text",
2572                            "text": t,
2573                        }));
2574                    }
2575                }
2576                Some("image_url") => {
2577                    if let Some(url) = p
2578                        .get("image_url")
2579                        .and_then(|u| u.get("url"))
2580                        .and_then(Value::as_str)
2581                    {
2582                        let mime = url
2583                            .strip_prefix("data:")
2584                            .and_then(|r| r.split_once(','))
2585                            .map(|(m, _)| m.trim_end_matches(";base64"))
2586                            .unwrap_or("application/octet-stream");
2587                        parts.push(serde_json::json!({
2588                            "id": opencode_fresh_id("prt", counter),
2589                            "sessionID": session_id,
2590                            "messageID": msg_id,
2591                            "type": "file",
2592                            "mime": mime,
2593                            "url": url,
2594                        }));
2595                    }
2596                }
2597                _ => {}
2598            }
2599        }
2600    } else if let Some(t) = &msg.content {
2601        if !t.is_empty() {
2602            parts.push(serde_json::json!({
2603                "id": opencode_fresh_id("prt", counter),
2604                "sessionID": session_id,
2605                "messageID": msg_id,
2606                "type": "text",
2607                "text": t,
2608            }));
2609        }
2610    }
2611    parts
2612}
2613
2614#[cfg(test)]
2615mod tests {
2616    use super::*;
2617
2618    #[test]
2619    fn opencode_synthetic_timestamp_fails_closed_at_i64_max() {
2620        let msg = ChatMessage::user("continuation");
2621        let mut cursor = i64::MAX - 1;
2622        assert_eq!(
2623            opencode_message_timestamp(&msg, &mut cursor).unwrap(),
2624            i64::MAX
2625        );
2626        let err = opencode_message_timestamp(&msg, &mut cursor).unwrap_err();
2627        assert!(err.to_string().contains("after i64::MAX"));
2628        assert_eq!(cursor, i64::MAX, "overflow must not wrap or mutate state");
2629    }
2630}