Skip to main content

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