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}