Skip to main content

supercode_interchange/
sidecar.rs

1//! The supercode-native **v2** wire record for a conversation turn appended
2//! after import.
3//!
4//! [`NativeTurn`] is a *serialization* of the existing [`ChatMessage`], not a
5//! new conversation model — see the module-level note in `session.rs` and
6//! SPEC.md §1.1 ("one canonical `Session`, no second message type"). It exists
7//! only so a turn the agent loop produces after import can be appended to the
8//! native-v2 sidecar file (`Session::to_native_jsonl_v2`) with the same
9//! fidelity `Session.raw` gives the imported prefix — including `metadata`,
10//! which `ChatMessage`'s hand-rolled wire [`Serialize`] deliberately drops
11//! (`message.rs:57-79`) so a provider request body never sees it. Metadata
12//! (thinking, attribution, Codex `phase`/`turn_id`, ...) must never reach the
13//! wire but must never be lost on disk either — `NativeTurn` is where that
14//! distinction is drawn.
15
16use std::collections::BTreeMap;
17use std::fs::{File, OpenOptions};
18use std::io::Write;
19use std::path::Path;
20
21use serde::{Deserialize, Serialize};
22
23use crate::{ChatMessage, InterchangeError as Error, Result, Role, Session, ToolCall};
24
25/// One appended-after-import conversation turn, as written to a native-v2
26/// sidecar file (one JSON object per line, following the imported body).
27///
28/// Discriminated by [`Self::supercode_turn`] so the tolerant per-source
29/// parsers (`Session::from_claude_code_str`, `Session::from_codex_str`) skip
30/// it as an unrecognized record rather than erroring — neither format's
31/// records carry a `supercode_turn` key, `type`, or `payload`.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct NativeTurn {
34    /// Discriminant identifying this line as a `NativeTurn` record. Always `1`.
35    pub supercode_turn: u8,
36    /// RFC3339 (UTC) timestamp of when the turn was appended.
37    pub ts: String,
38    /// Mirrors [`ChatMessage::role`].
39    pub role: Role,
40    /// Mirrors [`ChatMessage::content`].
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub content: Option<String>,
43    /// Mirrors [`ChatMessage::content_parts`].
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub content_parts: Option<Vec<serde_json::Value>>,
46    /// Mirrors [`ChatMessage::tool_calls`].
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub tool_calls: Option<Vec<ToolCall>>,
49    /// Mirrors [`ChatMessage::tool_call_id`].
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub tool_call_id: Option<String>,
52    /// Mirrors [`ChatMessage::name`].
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub name: Option<String>,
55    /// Mirrors [`ChatMessage::metadata`] — but UNLIKE `ChatMessage`'s wire
56    /// `Serialize` (which omits it, `message.rs:53-54`/`57-79`), it serializes
57    /// in full here. This is the whole reason `NativeTurn` exists rather than
58    /// reusing `ChatMessage`'s own (de)serializer: the sidecar must retain
59    /// what the wire serde must drop.
60    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
61    pub metadata: BTreeMap<String, String>,
62}
63
64impl From<&ChatMessage> for NativeTurn {
65    fn from(msg: &ChatMessage) -> Self {
66        Self::from_with_timestamp_and_index(msg, now_rfc3339(), 0)
67    }
68}
69
70impl NativeTurn {
71    pub(crate) fn from_with_timestamp_and_index(
72        msg: &ChatMessage,
73        ts: String,
74        turn_index: u64,
75    ) -> Self {
76        let mut metadata = msg.metadata.clone();
77        metadata
78            .entry("timestamp".to_string())
79            .or_insert_with(|| ts.clone());
80        metadata
81            .entry("supercode_native_uuid".to_string())
82            .or_insert_with(|| native_turn_uuid(msg, &ts, turn_index));
83        NativeTurn {
84            supercode_turn: 1,
85            ts,
86            role: msg.role,
87            content: msg.content.clone(),
88            content_parts: msg.content_parts.clone(),
89            tool_calls: msg.tool_calls.clone(),
90            tool_call_id: msg.tool_call_id.clone(),
91            name: msg.name.clone(),
92            metadata,
93        }
94    }
95
96    /// Recover the plain [`ChatMessage`] this record represents, discarding
97    /// the `supercode_turn`/`ts` sidecar framing (which have no `ChatMessage`
98    /// slot).
99    pub fn into_message(self) -> ChatMessage {
100        ChatMessage {
101            role: self.role,
102            content: self.content,
103            content_parts: self.content_parts,
104            tool_calls: self.tool_calls,
105            tool_call_id: self.tool_call_id,
106            name: self.name,
107            metadata: self.metadata,
108        }
109    }
110}
111
112/// Mint a stable, format-valid UUID for a newly recorded native turn.
113///
114/// The value is written into the sidecar metadata, so every later export of
115/// that turn reuses the same identity. The timestamp, sidecar-local turn
116/// index, and semantic message identity make the UUID unique within a
117/// session while remaining byte-identical across equivalent runtime
118/// surfaces (ACP, HTTP, embedded, Codex, opencode, and Goose).
119fn native_turn_uuid(msg: &ChatMessage, timestamp: &str, turn_index: u64) -> String {
120    let mut hasher = blake3::Hasher::new();
121    hasher.update(b"supercode-native-turn-uuid-v1\0");
122    hasher.update(timestamp.as_bytes());
123    hasher.update(&turn_index.to_le_bytes());
124    if let Ok(identity) = serde_json::to_vec(&NativeTurnIdentity::from(msg)) {
125        hasher.update(&identity);
126    }
127    let mut bytes = [0u8; 16];
128    bytes.copy_from_slice(&hasher.finalize().as_bytes()[..16]);
129    bytes[6] = (bytes[6] & 0x0f) | 0x40;
130    bytes[8] = (bytes[8] & 0x3f) | 0x80;
131    format!(
132        "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
133        bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
134        bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
135    )
136}
137
138#[derive(Serialize)]
139struct NativeTurnIdentity<'a> {
140    role: Role,
141    content: &'a Option<String>,
142    content_parts: &'a Option<Vec<serde_json::Value>>,
143    tool_calls: &'a Option<Vec<ToolCall>>,
144    tool_call_id: &'a Option<String>,
145    name: &'a Option<String>,
146}
147
148impl<'a> From<&'a ChatMessage> for NativeTurnIdentity<'a> {
149    fn from(msg: &'a ChatMessage) -> Self {
150        Self {
151            role: msg.role,
152            content: &msg.content,
153            content_parts: &msg.content_parts,
154            tool_calls: &msg.tool_calls,
155            tool_call_id: &msg.tool_call_id,
156            name: &msg.name,
157        }
158    }
159}
160
161/// Append-only writer for a native-v2 sidecar file (A2 store layout; A3 live
162/// persistence): the durable, full-fidelity home for a live agent
163/// conversation. Every append is line-atomic — full line + `\n`, then
164/// flushed — so a crash mid-write can only ever leave the *next* unwritten
165/// record torn, never corrupt one already on disk; [`Session::from_native_str`]
166/// tolerates a torn trailing line exactly as the per-source loaders already
167/// tolerate corrupt lines.
168pub struct SidecarWriter {
169    file: File,
170    path: std::path::PathBuf,
171    fixed_timestamp: Option<String>,
172    next_turn_index: u64,
173}
174
175impl SidecarWriter {
176    /// Create a new sidecar file at `path` for `session` (the just-imported
177    /// session about to be recorded): writes the v2 header plus every
178    /// `session.raw` line verbatim, via [`Session::to_native_jsonl_v2`] with
179    /// an empty `appended` slice — reusing that header+body construction
180    /// rather than duplicating it, since the imported prefix already has its
181    /// own `raw` lines and no turns have been appended yet. Overwrites
182    /// whatever was previously at `path`.
183    pub fn create(path: &Path, session: &Session) -> Result<Self> {
184        Self::create_inner(path, session, None)
185    }
186
187    /// Create the same production sidecar writer with a fixed RFC3339
188    /// framing timestamp. This isolates time as the sole nondeterministic
189    /// field when byte-comparing two independently driven runtime surfaces;
190    /// message content, ordering, metadata, flushing, and persistence all
191    /// use the normal writer path. Ordinary callers should use [`Self::create`].
192    pub fn create_with_timestamp(
193        path: &Path,
194        session: &Session,
195        timestamp: impl Into<String>,
196    ) -> Result<Self> {
197        let timestamp = timestamp.into();
198        if !is_canonical_rfc3339_millis(&timestamp) {
199            return Err(Error::Other(format!(
200                "invalid fixed sidecar timestamp: {timestamp:?}"
201            )));
202        }
203        Self::create_inner(path, session, Some(timestamp))
204    }
205
206    fn create_inner(
207        path: &Path,
208        session: &Session,
209        fixed_timestamp: Option<String>,
210    ) -> Result<Self> {
211        std::fs::write(
212            path,
213            session.to_native_jsonl_v2_with_timestamp(&[], fixed_timestamp.as_deref()),
214        )?;
215        let file = OpenOptions::new().append(true).open(path)?;
216        Ok(SidecarWriter {
217            file,
218            path: path.to_path_buf(),
219            fixed_timestamp,
220            next_turn_index: 0,
221        })
222    }
223
224    /// Open an already-existing sidecar file at `path` for appending
225    /// (resuming a session that was already being recorded).
226    pub fn open_append(path: &Path) -> Result<Self> {
227        let next_turn_index = native_turn_count(path)?;
228        let file = OpenOptions::new().append(true).open(path)?;
229        Ok(SidecarWriter {
230            file,
231            path: path.to_path_buf(),
232            fixed_timestamp: None,
233            next_turn_index,
234        })
235    }
236
237    /// The file this writer appends to. TR-1's rehydration intrinsics use
238    /// this to reload the recorded full-fidelity messages when resolving an
239    /// `expand_reduction`/`sidecar_search` call — the recorded copy is the
240    /// only place a `cap_tool_output`-capped tool result's full bytes still
241    /// exist (`Agent::run_loop` records the full output BEFORE capping).
242    pub fn path(&self) -> &Path {
243        &self.path
244    }
245
246    /// Append one [`ChatMessage`] as a [`NativeTurn`] record: full line +
247    /// `\n`, then flushed immediately (line-atomic — see the struct docs).
248    pub fn append(&mut self, msg: &ChatMessage) -> Result<()> {
249        let timestamp = self.fixed_timestamp.clone().unwrap_or_else(now_rfc3339);
250        let turn = NativeTurn::from_with_timestamp_and_index(msg, timestamp, self.next_turn_index);
251        let mut line = serde_json::to_string(&turn).map_err(Error::Decode)?;
252        line.push('\n');
253        self.file.write_all(line.as_bytes())?;
254        self.file.flush()?;
255        self.next_turn_index = self.next_turn_index.saturating_add(1);
256        Ok(())
257    }
258}
259
260fn native_turn_count(path: &Path) -> Result<u64> {
261    let body = std::fs::read_to_string(path)?;
262    Ok(body
263        .lines()
264        .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
265        .filter(|record| {
266            record
267                .get("supercode_turn")
268                .and_then(|value| value.as_u64())
269                == Some(1)
270        })
271        .count() as u64)
272}
273
274fn is_canonical_rfc3339_millis(timestamp: &str) -> bool {
275    timestamp.len() == 24
276        && timestamp.as_bytes().get(4) == Some(&b'-')
277        && timestamp.as_bytes().get(7) == Some(&b'-')
278        && timestamp.as_bytes().get(10) == Some(&b'T')
279        && timestamp.as_bytes().get(13) == Some(&b':')
280        && timestamp.as_bytes().get(16) == Some(&b':')
281        && timestamp.as_bytes().get(19) == Some(&b'.')
282        && timestamp.as_bytes().get(23) == Some(&b'Z')
283        && timestamp.bytes().enumerate().all(|(index, byte)| {
284            matches!(index, 4 | 7 | 10 | 13 | 16 | 19 | 23) || byte.is_ascii_digit()
285        })
286        && rfc3339_to_ms(timestamp).is_some_and(|millis| ms_to_rfc3339(millis) == timestamp)
287}
288
289/// The current time as an RFC3339 UTC timestamp (`YYYY-MM-DDTHH:MM:SS.mmmZ`).
290///
291/// Hand-rolled rather than pulled from a dependency: the workspace has no
292/// chrono/time crate (checked `Cargo.toml`), and the one place that already
293/// timestamps things (`cli/src/main.rs:1079`) uses raw `SystemTime` epoch
294/// micros for sortable session names, not a calendar format — there's no
295/// existing formatter to reuse. The civil-calendar conversion below is Howard
296/// Hinnant's well-known `civil_from_days` algorithm (proleptic Gregorian, no
297/// leap seconds — adequate for a "when was this turn appended" timestamp).
298#[doc(hidden)]
299pub fn now_rfc3339() -> String {
300    let dur = std::time::SystemTime::now()
301        .duration_since(std::time::UNIX_EPOCH)
302        .unwrap_or_default();
303    civil_rfc3339(dur.as_secs(), dur.subsec_millis())
304}
305
306fn civil_rfc3339(unix_secs: u64, millis: u32) -> String {
307    let secs = unix_secs as i64;
308    let days = secs.div_euclid(86_400);
309    let rem = secs.rem_euclid(86_400);
310    let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
311
312    // civil_from_days (Hinnant, public domain).
313    let z = days + 719_468;
314    let era = z.div_euclid(146_097);
315    let doe = z - era * 146_097; // [0, 146096]
316    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
317    let y = yoe + era * 400;
318    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
319    let mp = (5 * doy + 2) / 153; // [0, 11]
320    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
321    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
322    let year = if m <= 2 { y + 1 } else { y };
323
324    format!("{year:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{millis:03}Z")
325}
326
327/// Convert a unix-millisecond timestamp (pi's `message.timestamp`, opencode's
328/// `time.created`/`time.updated`) to an RFC3339 UTC string — the canonical
329/// per-message timestamp representation every loader in `session.rs`
330/// populates (`metadata["timestamp"]`) and every writer reads. Lossless to
331/// millisecond precision (the finest grain any of the four wire formats
332/// carries): `ms` splits exactly into whole seconds + a 0-999 millisecond
333/// remainder, and [`civil_rfc3339`] renders both without rounding.
334#[doc(hidden)]
335pub fn ms_to_rfc3339(ms: i64) -> String {
336    let secs = ms.div_euclid(1000);
337    let millis = ms.rem_euclid(1000) as u32;
338    // `civil_rfc3339` takes `u64`; every real-world timestamp here is
339    // post-epoch (pi/opencode/claude/codex all stamp with `Date.now()`-style
340    // values), so the non-negative case is the only one that matters — a
341    // negative/pre-epoch `secs` saturates to the epoch rather than
342    // wrapping/panicking.
343    civil_rfc3339(secs.max(0) as u64, millis)
344}
345
346/// The inverse of [`ms_to_rfc3339`]: parse an RFC3339 UTC string
347/// (`YYYY-MM-DDTHH:MM:SS[.fff]Z`, the shape every loader/writer in
348/// `session.rs` produces/consumes) back to unix milliseconds. Returns `None`
349/// on anything that doesn't match that shape rather than guessing — callers
350/// fall back to the `SYNTH_TS`/`SYNTH_TS_MS` placeholders on `None`, so a
351/// malformed timestamp degrades to the documented fallback instead of
352/// panicking or silently producing a wrong instant.
353///
354/// Fractional seconds are truncated/padded to exactly 3 digits (millisecond
355/// precision — matching every wire format's own granularity, so this is
356/// lossless for every timestamp this crate itself ever emits).
357#[doc(hidden)]
358pub fn rfc3339_to_ms(s: &str) -> Option<i64> {
359    let s = s.trim();
360    let s = s.strip_suffix('Z').unwrap_or(s);
361    let (date, time) = s.split_once('T')?;
362    let mut date_parts = date.splitn(3, '-');
363    let y: i64 = date_parts.next()?.parse().ok()?;
364    let mo: i64 = date_parts.next()?.parse().ok()?;
365    let d: i64 = date_parts.next()?.parse().ok()?;
366
367    let (time_main, frac) = match time.split_once('.') {
368        Some((t, f)) => (t, Some(f)),
369        None => (time, None),
370    };
371    let mut time_parts = time_main.splitn(3, ':');
372    let h: i64 = time_parts.next()?.parse().ok()?;
373    let mi: i64 = time_parts.next()?.parse().ok()?;
374    let sec: i64 = time_parts.next()?.parse().ok()?;
375    let millis: i64 = match frac {
376        Some(f) => {
377            let digits: String = f.chars().take_while(|c| c.is_ascii_digit()).collect();
378            if digits.is_empty() {
379                return None;
380            }
381            let mut padded = digits;
382            padded.truncate(3);
383            while padded.len() < 3 {
384                padded.push('0');
385            }
386            padded.parse().ok()?
387        }
388        None => 0,
389    };
390
391    let days = days_from_civil(y, mo, d)?;
392    let secs = days
393        .checked_mul(86_400)?
394        .checked_add(h * 3600 + mi * 60 + sec)?;
395    secs.checked_mul(1000)?.checked_add(millis)
396}
397
398/// `days_from_civil` (Hinnant, public domain) — the inverse of
399/// [`civil_rfc3339`]'s embedded `civil_from_days`: proleptic-Gregorian
400/// `(year, month, day)` to a signed day count relative to the unix epoch.
401/// `None` on an out-of-range month/day (`1..=12`/`1..=31`) rather than
402/// silently normalizing a malformed date.
403fn days_from_civil(y: i64, m: i64, d: i64) -> Option<i64> {
404    if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
405        return None;
406    }
407    let y = if m <= 2 { y - 1 } else { y };
408    let era = if y >= 0 { y } else { y - 399 }.div_euclid(400);
409    let yoe = y - era * 400; // [0, 399]
410    let mp = if m > 2 { m - 3 } else { m + 9 }; // [0, 11]
411    let doy = (153 * mp + 2) / 5 + d - 1; // [0, 365]
412    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
413    Some(era * 146_097 + doe - 719_468)
414}
415
416#[cfg(test)]
417mod tests {
418    use super::{civil_rfc3339, ms_to_rfc3339, rfc3339_to_ms, SidecarWriter};
419    use crate::session::Session;
420
421    #[test]
422    fn civil_rfc3339_known_epochs() {
423        // Cross-checked against `date -u -d @<secs>`.
424        assert_eq!(civil_rfc3339(0, 0), "1970-01-01T00:00:00.000Z");
425        assert_eq!(civil_rfc3339(1_700_000_000, 0), "2023-11-14T22:13:20.000Z");
426        assert_eq!(civil_rfc3339(1_893_456_000, 0), "2030-01-01T00:00:00.000Z");
427        // Leap day.
428        assert_eq!(civil_rfc3339(1_582_934_400, 0), "2020-02-29T00:00:00.000Z");
429        assert_eq!(civil_rfc3339(0, 7), "1970-01-01T00:00:00.007Z");
430    }
431
432    #[test]
433    fn ms_iso_round_trip() {
434        for ms in [
435            0i64,
436            7,
437            1_700_000_000_123,
438            1_751_900_002_100,
439            1_893_456_000_000,
440            1_582_934_400_999,
441        ] {
442            let iso = ms_to_rfc3339(ms);
443            assert_eq!(
444                rfc3339_to_ms(&iso),
445                Some(ms),
446                "ms->iso->ms must be lossless for {ms} (iso={iso})"
447            );
448        }
449    }
450
451    #[test]
452    fn rfc3339_to_ms_known_values() {
453        assert_eq!(rfc3339_to_ms("1970-01-01T00:00:00.000Z"), Some(0));
454        assert_eq!(
455            rfc3339_to_ms("2023-11-14T22:13:20.000Z"),
456            Some(1_700_000_000_000)
457        );
458        assert_eq!(rfc3339_to_ms("not-a-timestamp"), None);
459        assert_eq!(rfc3339_to_ms(""), None);
460        // No fractional part — still parses, at :000 millis.
461        assert_eq!(rfc3339_to_ms("1970-01-01T00:00:00Z"), Some(0));
462    }
463
464    #[test]
465    fn fixed_writer_timestamp_requires_canonical_rfc3339_milliseconds() {
466        let dir = std::env::temp_dir().join(format!(
467            "supercode-sidecar-fixed-timestamp-{}",
468            std::process::id()
469        ));
470        std::fs::create_dir_all(&dir).unwrap();
471        let session = Session::from_claude_code_str("").unwrap();
472        for (index, malformed) in [
473            "2026-07-19T12:00:00.000",
474            "2026-07-19T12:00:00.000Zjunk",
475            "2026-07-19T25:00:00.000Z",
476            "2026-07-19T12:60:00.000Z",
477            "2026-07-19T12:00:60.000Z",
478            "2026-02-31T12:00:00.000Z",
479            "2026-07-19T12:00:00Z",
480        ]
481        .into_iter()
482        .enumerate()
483        {
484            assert!(
485                SidecarWriter::create_with_timestamp(
486                    &dir.join(format!("invalid-{index}.jsonl")),
487                    &session,
488                    malformed,
489                )
490                .is_err(),
491                "malformed timestamp was accepted: {malformed}"
492            );
493        }
494        let valid_path = dir.join("valid.jsonl");
495        SidecarWriter::create_with_timestamp(&valid_path, &session, "2026-07-19T12:00:00.000Z")
496            .unwrap();
497        assert!(std::fs::read_to_string(valid_path)
498            .unwrap()
499            .contains(r#""created":"2026-07-19T12:00:00.000Z""#));
500        std::fs::remove_dir_all(dir).ok();
501    }
502}