Skip to main content

objects/object/
timeline.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Agent timeline operation objects.
3
4use std::fmt;
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
7
8use crate::object::{ChangeId, ContentHash};
9
10/// Current timeline operation schema version.
11pub const TIMELINE_OPERATION_SCHEMA_VERSION: u16 = LatestTimelineOperationSchema::VERSION;
12
13mod sealed {
14    pub trait Sealed {}
15}
16
17trait VersionedTimelineOperationSchema: sealed::Sealed {
18    const VERSION: u16;
19    const NAME: &'static str;
20
21    fn encode(envelope: &TimelineOperationEnvelope) -> Result<Vec<u8>, TimelineCodecError>;
22    fn decode(bytes: &[u8]) -> Result<TimelineOperationEnvelope, TimelineCodecError>;
23}
24
25struct TimelineOperationV1Schema;
26type LatestTimelineOperationSchema = TimelineOperationV1Schema;
27
28impl sealed::Sealed for TimelineOperationV1Schema {}
29
30impl VersionedTimelineOperationSchema for TimelineOperationV1Schema {
31    const VERSION: u16 = 1;
32    const NAME: &'static str = "timeline-operation-v1";
33
34    fn encode(envelope: &TimelineOperationEnvelope) -> Result<Vec<u8>, TimelineCodecError> {
35        if envelope.schema_version != Self::VERSION {
36            return Err(TimelineCodecError::UnsupportedVersion(
37                envelope.schema_version,
38            ));
39        }
40        if envelope.kind != envelope.body.kind() {
41            return Err(TimelineCodecError::KindBodyMismatch {
42                kind: envelope.kind,
43                body: envelope.body.kind(),
44            });
45        }
46        let wire = TimelineOperationEnvelopeWireV1 {
47            schema_version: Self::VERSION,
48            kind: envelope.kind.as_str().to_string(),
49            labels: canonical_timeline_labels(&envelope.labels),
50            body: envelope.body.encode_body()?,
51        };
52        rmp_serde::to_vec_named(&wire).map_err(|err| TimelineCodecError::Encoding(err.to_string()))
53    }
54
55    fn decode(bytes: &[u8]) -> Result<TimelineOperationEnvelope, TimelineCodecError> {
56        let wire: TimelineOperationEnvelopeWireV1 =
57            rmp_serde::from_slice(bytes).map_err(|err| {
58                TimelineCodecError::Decoding(format!("decode {} envelope: {err}", Self::NAME))
59            })?;
60        if wire.schema_version != Self::VERSION {
61            return Err(TimelineCodecError::UnsupportedVersion(wire.schema_version));
62        }
63        let kind = TimelineOperationKind::try_from(wire.kind.as_str())?;
64        let body = TimelineOperationBodyV1::decode_body(kind, &wire.body)?;
65        Ok(TimelineOperationEnvelope {
66            schema_version: wire.schema_version,
67            kind,
68            labels: canonical_timeline_labels(&wire.labels),
69            body,
70        })
71    }
72}
73
74/// Content-addressed identifier for a timeline operation envelope.
75#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
76#[serde(transparent)]
77pub struct TimelineOperationId([u8; 32]);
78
79impl TimelineOperationId {
80    /// Compute an operation id from canonical timeline operation envelope bytes.
81    pub fn for_bytes(bytes: &[u8]) -> Self {
82        let hash = ContentHash::compute_typed("timeline-operation", bytes);
83        Self(*hash.as_bytes())
84    }
85
86    /// Create an id from raw bytes.
87    pub fn from_bytes(bytes: [u8; 32]) -> Self {
88        Self(bytes)
89    }
90
91    /// Decode from a 32-byte slice.
92    pub fn try_from_slice(bytes: &[u8]) -> Result<Self, TimelineOperationIdParseError> {
93        if bytes.len() != 32 {
94            return Err(TimelineOperationIdParseError::InvalidLength);
95        }
96        let mut arr = [0u8; 32];
97        arr.copy_from_slice(bytes);
98        Ok(Self(arr))
99    }
100
101    /// Get the raw bytes.
102    pub fn as_bytes(&self) -> &[u8; 32] {
103        &self.0
104    }
105
106    /// Convert to hexadecimal for filesystem storage.
107    pub fn to_hex(&self) -> String {
108        hex::encode(self.0)
109    }
110
111    /// Full display form.
112    pub fn to_string_full(&self) -> String {
113        format!(
114            "tl-{}",
115            base32::encode(base32::Alphabet::Crockford, &self.0).to_lowercase()
116        )
117    }
118
119    /// Short display form.
120    pub fn short(&self) -> String {
121        let full = self.to_string_full();
122        full[..18.min(full.len())].to_string()
123    }
124}
125
126impl fmt::Debug for TimelineOperationId {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        write!(f, "TimelineOperationId({})", self.short())
129    }
130}
131
132impl fmt::Display for TimelineOperationId {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        f.write_str(&self.short())
135    }
136}
137
138/// Error parsing a timeline operation id.
139#[derive(Debug, Clone, thiserror::Error)]
140pub enum TimelineOperationIdParseError {
141    #[error("invalid length (expected 32 bytes)")]
142    InvalidLength,
143}
144
145macro_rules! timeline_string_id {
146    ($name:ident, $prefix:literal) => {
147        #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
148        #[serde(transparent)]
149        pub struct $name(pub String);
150
151        impl $name {
152            /// Generate a new opaque id.
153            pub fn generate() -> Self {
154                let bytes: [u8; 10] = rand::random();
155                Self(format!(
156                    "{}{}",
157                    $prefix,
158                    base32::encode(base32::Alphabet::Crockford, &bytes).to_lowercase()
159                ))
160            }
161
162            /// Create an id from an existing string.
163            pub fn new(value: impl Into<String>) -> Self {
164                Self(value.into())
165            }
166
167            /// Borrow the id string.
168            pub fn as_str(&self) -> &str {
169                &self.0
170            }
171        }
172
173        impl fmt::Display for $name {
174            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175                f.write_str(&self.0)
176            }
177        }
178    };
179}
180
181timeline_string_id!(TimelineStepId, "tls-");
182timeline_string_id!(TimelineBranchId, "tlb-");
183
184/// Explicit timeline operation kind stored in every operation envelope.
185#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
186pub enum TimelineOperationKind {
187    ToolCallStarted,
188    ToolCallFinished,
189    CursorMoved,
190    BranchCreated,
191}
192
193impl TimelineOperationKind {
194    /// Stable wire string for this operation kind.
195    pub fn as_str(self) -> &'static str {
196        match self {
197            Self::ToolCallStarted => "tool_call_started",
198            Self::ToolCallFinished => "tool_call_finished",
199            Self::CursorMoved => "cursor_moved",
200            Self::BranchCreated => "branch_created",
201        }
202    }
203}
204
205impl TryFrom<&str> for TimelineOperationKind {
206    type Error = TimelineCodecError;
207
208    fn try_from(value: &str) -> Result<Self, Self::Error> {
209        match value {
210            "tool_call_started" => Ok(Self::ToolCallStarted),
211            "tool_call_finished" => Ok(Self::ToolCallFinished),
212            "cursor_moved" => Ok(Self::CursorMoved),
213            "branch_created" => Ok(Self::BranchCreated),
214            other => Err(TimelineCodecError::UnknownKind(other.to_string())),
215        }
216    }
217}
218
219impl Serialize for TimelineOperationKind {
220    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
221    where
222        S: Serializer,
223    {
224        serializer.serialize_str(self.as_str())
225    }
226}
227
228impl<'de> Deserialize<'de> for TimelineOperationKind {
229    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
230    where
231        D: Deserializer<'de>,
232    {
233        let value = String::deserialize(deserializer)?;
234        Self::try_from(value.as_str()).map_err(de::Error::custom)
235    }
236}
237
238/// Safety labels attached to timeline operations.
239#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "kebab-case")]
241pub enum TimelineLabel {
242    RepoReversible,
243    ExternalSideEffectsUnknown,
244    IgnoredPathTouched,
245    OutsideRepoTouched,
246    PurgeBoundary,
247    CaptureFailed,
248}
249
250/// Scrubbed metadata for native tool payloads.
251#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
252pub struct TimelineToolPayloadMetadata {
253    pub summary: Option<String>,
254    pub hash: Option<ContentHash>,
255}
256
257/// Native harness tool-call identity.
258#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
259pub struct NativeToolCallRefV1 {
260    pub harness: String,
261    pub session_id: Option<String>,
262    pub message_id: Option<String>,
263    pub tool_call_id: String,
264}
265
266/// Tool-call terminal status.
267#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
268#[serde(rename_all = "kebab-case")]
269pub enum TimelineToolCallStatus {
270    Succeeded,
271    Failed,
272    Cancelled,
273}
274
275/// Why the timeline cursor moved.
276#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(rename_all = "kebab-case")]
278pub enum TimelineCursorMoveReason {
279    SeekToolCall,
280    Undo,
281    Redo,
282    Reset,
283    AutoAdvance,
284}
285
286/// Why a timeline branch was created.
287#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
288#[serde(rename_all = "kebab-case")]
289pub enum TimelineBranchReason {
290    EditFromRewoundCursor,
291    ExplicitFork,
292    Retry,
293    FanOut,
294}
295
296/// A v1 timeline operation envelope.
297#[derive(Clone, Debug, PartialEq, Eq)]
298pub struct TimelineOperationEnvelope {
299    pub schema_version: u16,
300    pub kind: TimelineOperationKind,
301    pub labels: Vec<TimelineLabel>,
302    pub body: TimelineOperationBodyV1,
303}
304
305impl TimelineOperationEnvelope {
306    /// Build a v1 envelope for a body.
307    pub fn new(body: TimelineOperationBodyV1, labels: Vec<TimelineLabel>) -> Self {
308        Self {
309            schema_version: TIMELINE_OPERATION_SCHEMA_VERSION,
310            kind: body.kind(),
311            labels,
312            body,
313        }
314    }
315
316    /// Encode the envelope as canonical msgpack bytes.
317    pub fn encode(&self) -> Result<Vec<u8>, TimelineCodecError> {
318        LatestTimelineOperationSchema::encode(self)
319    }
320
321    /// Decode canonical msgpack bytes into an envelope.
322    pub fn decode(bytes: &[u8]) -> Result<Self, TimelineCodecError> {
323        match timeline_operation_schema_version(bytes)? {
324            TimelineOperationV1Schema::VERSION => TimelineOperationV1Schema::decode(bytes),
325            other => Err(TimelineCodecError::UnsupportedVersion(other)),
326        }
327    }
328
329    /// Compute this envelope's content-addressed operation id.
330    pub fn operation_id(&self) -> Result<TimelineOperationId, TimelineCodecError> {
331        Ok(TimelineOperationId::for_bytes(&self.encode()?))
332    }
333}
334
335#[derive(Serialize, Deserialize)]
336struct TimelineOperationEnvelopeVersionProbe {
337    schema_version: u16,
338}
339
340#[derive(Serialize, Deserialize)]
341struct TimelineOperationEnvelopeWireV1 {
342    schema_version: u16,
343    kind: String,
344    labels: Vec<TimelineLabel>,
345    body: Vec<u8>,
346}
347
348/// V1 timeline operation body variants.
349#[derive(Clone, Debug, PartialEq, Eq)]
350pub enum TimelineOperationBodyV1 {
351    ToolCallStarted(ToolCallStartedV1),
352    ToolCallFinished(ToolCallFinishedV1),
353    CursorMoved(CursorMovedV1),
354    BranchCreated(BranchCreatedV1),
355}
356
357impl TimelineOperationBodyV1 {
358    fn kind(&self) -> TimelineOperationKind {
359        match self {
360            Self::ToolCallStarted(_) => TimelineOperationKind::ToolCallStarted,
361            Self::ToolCallFinished(_) => TimelineOperationKind::ToolCallFinished,
362            Self::CursorMoved(_) => TimelineOperationKind::CursorMoved,
363            Self::BranchCreated(_) => TimelineOperationKind::BranchCreated,
364        }
365    }
366
367    fn encode_body(&self) -> Result<Vec<u8>, TimelineCodecError> {
368        match self {
369            Self::ToolCallStarted(body) => encode_body(body),
370            Self::ToolCallFinished(body) => encode_body(body),
371            Self::CursorMoved(body) => encode_body(body),
372            Self::BranchCreated(body) => encode_body(body),
373        }
374    }
375
376    fn decode_body(kind: TimelineOperationKind, bytes: &[u8]) -> Result<Self, TimelineCodecError> {
377        match kind {
378            TimelineOperationKind::ToolCallStarted => decode_body(bytes).map(Self::ToolCallStarted),
379            TimelineOperationKind::ToolCallFinished => {
380                decode_body(bytes).map(Self::ToolCallFinished)
381            }
382            TimelineOperationKind::CursorMoved => decode_body(bytes).map(Self::CursorMoved),
383            TimelineOperationKind::BranchCreated => decode_body(bytes).map(Self::BranchCreated),
384        }
385    }
386}
387
388/// Tool-call start operation body.
389#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
390pub struct ToolCallStartedV1 {
391    pub thread: String,
392    pub step_id: TimelineStepId,
393    pub branch_id: TimelineBranchId,
394    pub parent_step_id: Option<TimelineStepId>,
395    pub native: NativeToolCallRefV1,
396    pub tool_name: String,
397    pub before_state: ChangeId,
398    pub payload: Option<TimelineToolPayloadMetadata>,
399    pub started_at_ms: i64,
400}
401
402/// Tool-call finish operation body.
403#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
404pub struct ToolCallFinishedV1 {
405    pub thread: String,
406    pub step_id: TimelineStepId,
407    pub branch_id: TimelineBranchId,
408    pub native: NativeToolCallRefV1,
409    pub status: TimelineToolCallStatus,
410    pub before_state: ChangeId,
411    pub after_state: ChangeId,
412    pub capture_state: Option<ChangeId>,
413    pub capture_oplog_batch_id: Option<u64>,
414    pub changed: bool,
415    pub touched_paths: Vec<String>,
416    pub payload: Option<TimelineToolPayloadMetadata>,
417    pub finished_at_ms: i64,
418}
419
420/// Cursor movement operation body.
421#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
422pub struct CursorMovedV1 {
423    pub thread: String,
424    pub branch_id: TimelineBranchId,
425    pub from_step_id: Option<TimelineStepId>,
426    pub to_step_id: Option<TimelineStepId>,
427    pub from_state: ChangeId,
428    pub to_state: ChangeId,
429    pub reason: TimelineCursorMoveReason,
430    pub moved_at_ms: i64,
431}
432
433/// Branch creation operation body.
434#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
435pub struct BranchCreatedV1 {
436    pub thread: String,
437    pub branch_id: TimelineBranchId,
438    pub parent_branch_id: Option<TimelineBranchId>,
439    pub from_step_id: Option<TimelineStepId>,
440    pub from_state: ChangeId,
441    pub reason: TimelineBranchReason,
442    pub created_at_ms: i64,
443}
444
445/// Timeline operation codec error.
446#[derive(Debug, thiserror::Error)]
447pub enum TimelineCodecError {
448    #[error("unsupported timeline operation schema version {0}")]
449    UnsupportedVersion(u16),
450    #[error("unknown timeline operation kind {0}")]
451    UnknownKind(String),
452    #[error("timeline operation kind {kind:?} does not match body kind {body:?}")]
453    KindBodyMismatch {
454        kind: TimelineOperationKind,
455        body: TimelineOperationKind,
456    },
457    #[error("timeline operation encoding error: {0}")]
458    Encoding(String),
459    #[error("timeline operation decoding error: {0}")]
460    Decoding(String),
461}
462
463fn encode_body<T: Serialize>(body: &T) -> Result<Vec<u8>, TimelineCodecError> {
464    rmp_serde::to_vec_named(body).map_err(|err| TimelineCodecError::Encoding(err.to_string()))
465}
466
467fn decode_body<T: for<'de> Deserialize<'de>>(bytes: &[u8]) -> Result<T, TimelineCodecError> {
468    rmp_serde::from_slice(bytes).map_err(|err| TimelineCodecError::Decoding(err.to_string()))
469}
470
471fn timeline_operation_schema_version(bytes: &[u8]) -> Result<u16, TimelineCodecError> {
472    let probe: TimelineOperationEnvelopeVersionProbe = rmp_serde::from_slice(bytes)
473        .map_err(|err| TimelineCodecError::Decoding(format!("decode timeline version: {err}")))?;
474    Ok(probe.schema_version)
475}
476
477fn canonical_timeline_labels(labels: &[TimelineLabel]) -> Vec<TimelineLabel> {
478    let mut labels = labels.to_vec();
479    labels.sort_by_key(timeline_label_order);
480    labels.dedup();
481    labels
482}
483
484fn timeline_label_order(label: &TimelineLabel) -> u8 {
485    match label {
486        TimelineLabel::RepoReversible => 0,
487        TimelineLabel::ExternalSideEffectsUnknown => 1,
488        TimelineLabel::IgnoredPathTouched => 2,
489        TimelineLabel::OutsideRepoTouched => 3,
490        TimelineLabel::PurgeBoundary => 4,
491        TimelineLabel::CaptureFailed => 5,
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    fn sample_body() -> TimelineOperationBodyV1 {
500        TimelineOperationBodyV1::ToolCallStarted(ToolCallStartedV1 {
501            thread: "main".to_string(),
502            step_id: TimelineStepId::new("tls-step"),
503            branch_id: TimelineBranchId::new("tlb-main"),
504            parent_step_id: None,
505            native: NativeToolCallRefV1 {
506                harness: "opencode".to_string(),
507                session_id: Some("session-1".to_string()),
508                message_id: Some("message-1".to_string()),
509                tool_call_id: "call-1".to_string(),
510            },
511            tool_name: "shell".to_string(),
512            before_state: ChangeId::from_bytes([1; 16]),
513            payload: Some(TimelineToolPayloadMetadata {
514                summary: Some("listed files".to_string()),
515                hash: Some(ContentHash::compute_typed(
516                    "timeline-tool-payload",
517                    b"scrubbed",
518                )),
519            }),
520            started_at_ms: 1_700_000_000_000,
521        })
522    }
523
524    fn sample_envelope() -> TimelineOperationEnvelope {
525        TimelineOperationEnvelope::new(
526            sample_body(),
527            vec![
528                TimelineLabel::RepoReversible,
529                TimelineLabel::IgnoredPathTouched,
530            ],
531        )
532    }
533
534    fn sample_native(tool_call_id: &str) -> NativeToolCallRefV1 {
535        NativeToolCallRefV1 {
536            harness: "opencode".to_string(),
537            session_id: Some("session-1".to_string()),
538            message_id: Some("message-1".to_string()),
539            tool_call_id: tool_call_id.to_string(),
540        }
541    }
542
543    fn sample_payload(summary: &str) -> TimelineToolPayloadMetadata {
544        TimelineToolPayloadMetadata {
545            summary: Some(summary.to_string()),
546            hash: Some(ContentHash::compute_typed(
547                "timeline-tool-payload",
548                summary.as_bytes(),
549            )),
550        }
551    }
552
553    fn golden_envelopes() -> Vec<(&'static str, TimelineOperationEnvelope)> {
554        vec![
555            (
556                "tool_call_started",
557                TimelineOperationEnvelope::new(
558                    TimelineOperationBodyV1::ToolCallStarted(ToolCallStartedV1 {
559                        thread: "main".to_string(),
560                        step_id: TimelineStepId::new("tls-step"),
561                        branch_id: TimelineBranchId::new("tlb-main"),
562                        parent_step_id: None,
563                        native: sample_native("call-1"),
564                        tool_name: "bash".to_string(),
565                        before_state: ChangeId::from_bytes([1; 16]),
566                        payload: Some(sample_payload("started")),
567                        started_at_ms: 1_700_000_000_001,
568                    }),
569                    vec![
570                        TimelineLabel::IgnoredPathTouched,
571                        TimelineLabel::RepoReversible,
572                        TimelineLabel::IgnoredPathTouched,
573                    ],
574                ),
575            ),
576            (
577                "tool_call_finished",
578                TimelineOperationEnvelope::new(
579                    TimelineOperationBodyV1::ToolCallFinished(ToolCallFinishedV1 {
580                        thread: "main".to_string(),
581                        step_id: TimelineStepId::new("tls-step"),
582                        branch_id: TimelineBranchId::new("tlb-main"),
583                        native: sample_native("call-1"),
584                        status: TimelineToolCallStatus::Succeeded,
585                        before_state: ChangeId::from_bytes([1; 16]),
586                        after_state: ChangeId::from_bytes([2; 16]),
587                        capture_state: Some(ChangeId::from_bytes([2; 16])),
588                        capture_oplog_batch_id: Some(42),
589                        changed: true,
590                        touched_paths: vec!["tracked.txt".to_string()],
591                        payload: Some(sample_payload("finished")),
592                        finished_at_ms: 1_700_000_000_002,
593                    }),
594                    vec![
595                        TimelineLabel::ExternalSideEffectsUnknown,
596                        TimelineLabel::RepoReversible,
597                    ],
598                ),
599            ),
600            (
601                "cursor_moved",
602                TimelineOperationEnvelope::new(
603                    TimelineOperationBodyV1::CursorMoved(CursorMovedV1 {
604                        thread: "main".to_string(),
605                        branch_id: TimelineBranchId::new("tlb-main"),
606                        from_step_id: Some(TimelineStepId::new("tls-step")),
607                        to_step_id: None,
608                        from_state: ChangeId::from_bytes([2; 16]),
609                        to_state: ChangeId::from_bytes([1; 16]),
610                        reason: TimelineCursorMoveReason::Undo,
611                        moved_at_ms: 1_700_000_000_003,
612                    }),
613                    Vec::new(),
614                ),
615            ),
616            (
617                "branch_created",
618                TimelineOperationEnvelope::new(
619                    TimelineOperationBodyV1::BranchCreated(BranchCreatedV1 {
620                        thread: "main".to_string(),
621                        branch_id: TimelineBranchId::new("tlb-child"),
622                        parent_branch_id: Some(TimelineBranchId::new("tlb-main")),
623                        from_step_id: Some(TimelineStepId::new("tls-step")),
624                        from_state: ChangeId::from_bytes([2; 16]),
625                        reason: TimelineBranchReason::ExplicitFork,
626                        created_at_ms: 1_700_000_000_004,
627                    }),
628                    vec![TimelineLabel::RepoReversible],
629                ),
630            ),
631        ]
632    }
633
634    #[test]
635    fn timeline_encode_decode_round_trips() {
636        let envelope = sample_envelope();
637        let bytes = envelope.encode().unwrap();
638        let decoded = TimelineOperationEnvelope::decode(&bytes).unwrap();
639        assert_eq!(decoded, envelope);
640        assert_eq!(decoded.schema_version, TIMELINE_OPERATION_SCHEMA_VERSION);
641        assert_eq!(decoded.kind, TimelineOperationKind::ToolCallStarted);
642    }
643
644    #[test]
645    fn timeline_operation_id_is_stable_over_bytes() {
646        let bytes = sample_envelope().encode().unwrap();
647        let id = TimelineOperationId::for_bytes(&bytes);
648        assert_eq!(id, TimelineOperationId::for_bytes(&bytes));
649        assert_ne!(id, TimelineOperationId::for_bytes(b"different"));
650        assert_eq!(
651            TimelineOperationId::try_from_slice(id.as_bytes()).unwrap(),
652            id
653        );
654        assert!(id.to_string().starts_with("tl-"));
655    }
656
657    #[test]
658    fn timeline_operation_golden_fixtures_match_canonical_bytes_and_ids() {
659        let actual = golden_envelopes()
660            .into_iter()
661            .map(|(name, envelope)| {
662                let bytes = envelope.encode().unwrap();
663                let decoded = TimelineOperationEnvelope::decode(&bytes).unwrap();
664                assert_eq!(decoded.encode().unwrap(), bytes);
665                format!(
666                    "{name}:{}:{}",
667                    hex::encode(&bytes),
668                    TimelineOperationId::for_bytes(&bytes).to_hex()
669                )
670            })
671            .collect::<Vec<_>>();
672        let expected = vec![
673            "tool_call_started:84ae736368656d615f76657273696f6e01a46b696e64b1746f6f6c5f63616c6c5f73746172746564a66c6162656c7392af7265706f2d72657665727369626c65b469676e6f7265642d706174682d746f7563686564a4626f6479dc0131cc89cca6746872656164cca46d61696ecca7737465705f6964cca8746c732d73746570cca96272616e63685f6964cca8746c622d6d61696eccae706172656e745f737465705f6964ccc0cca66e6174697665cc84cca76861726e657373cca86f70656e636f6465ccaa73657373696f6e5f6964cca973657373696f6e2d31ccaa6d6573736167655f6964cca96d6573736167652d31ccac746f6f6c5f63616c6c5f6964cca663616c6c2d31cca9746f6f6c5f6e616d65cca462617368ccac6265666f72655f7374617465ccdc001001010101010101010101010101010101cca77061796c6f6164cc82cca773756d6d617279cca773746172746564cca468617368ccdc00206e3dccccccfa0e2eccccccf4cccccc94764f5dccccccafccccccd0ccccccf5cccccca7cccccc90ccccccb4ccccccdeccccccddccccccef603acccccc9dccccccefccccccb8086d10cccccc8a7320cccccc9dccccccb1ccad737461727465645f61745f6d73cccf000001cc8bcccfcce56801:37911d1d8858d0eb8bc22606a27f366bd6aae4db7de86b27c5577e7461bce86a".to_string(),
674            "tool_call_finished:84ae736368656d615f76657273696f6e01a46b696e64b2746f6f6c5f63616c6c5f66696e6973686564a66c6162656c7392af7265706f2d72657665727369626c65bd65787465726e616c2d736964652d656666656374732d756e6b6e6f776ea4626f6479dc019ecc8dcca6746872656164cca46d61696ecca7737465705f6964cca8746c732d73746570cca96272616e63685f6964cca8746c622d6d61696ecca66e6174697665cc84cca76861726e657373cca86f70656e636f6465ccaa73657373696f6e5f6964cca973657373696f6e2d31ccaa6d6573736167655f6964cca96d6573736167652d31ccac746f6f6c5f63616c6c5f6964cca663616c6c2d31cca6737461747573cca9737563636565646564ccac6265666f72655f7374617465ccdc001001010101010101010101010101010101ccab61667465725f7374617465ccdc001002020202020202020202020202020202ccad636170747572655f7374617465ccdc001002020202020202020202020202020202ccb6636170747572655f6f706c6f675f62617463685f69642acca76368616e676564ccc3ccad746f75636865645f7061746873cc91ccab747261636b65642e747874cca77061796c6f6164cc82cca773756d6d617279cca866696e6973686564cca468617368ccdc00200fccccccbd60ccccccc9ccccccf675ccccccc4ccccccf22036cccccca07dcccccc8f5b5b6ecccccca6cccccce7615318ccccccf7cccccc88ccccccc5ccccccf17d6e23cccccc8ecccccc974121ccae66696e69736865645f61745f6d73cccf000001cc8bcccfcce56802:d73dfa15ed34bd9b097bfe78566b32bc6af6489ebd09c2db51bab310040f6fc5".to_string(),
675            "cursor_moved:84ae736368656d615f76657273696f6e01a46b696e64ac637572736f725f6d6f766564a66c6162656c7390a4626f6479dc009dcc88cca6746872656164cca46d61696ecca96272616e63685f6964cca8746c622d6d61696eccac66726f6d5f737465705f6964cca8746c732d73746570ccaa746f5f737465705f6964ccc0ccaa66726f6d5f7374617465ccdc001002020202020202020202020202020202cca8746f5f7374617465ccdc001001010101010101010101010101010101cca6726561736f6ecca4756e646fccab6d6f7665645f61745f6d73cccf000001cc8bcccfcce56803:ba4e8547435e4865b645f365edc7997d034a55991c68ed08a25eff7015f41e19".to_string(),
676            "branch_created:84ae736368656d615f76657273696f6e01a46b696e64ae6272616e63685f63726561746564a66c6162656c7391af7265706f2d72657665727369626c65a4626f6479dc009bcc87cca6746872656164cca46d61696ecca96272616e63685f6964cca9746c622d6368696c64ccb0706172656e745f6272616e63685f6964cca8746c622d6d61696eccac66726f6d5f737465705f6964cca8746c732d73746570ccaa66726f6d5f7374617465ccdc001002020202020202020202020202020202cca6726561736f6eccad6578706c696369742d666f726bccad637265617465645f61745f6d73cccf000001cc8bcccfcce56804:94ad46e244cd4b9436237be0a5678532915f9ceb15211a5b03bc856716650666".to_string(),
677        ];
678        assert_eq!(actual, expected);
679    }
680
681    #[test]
682    fn timeline_decode_rejects_unknown_version() {
683        let bytes = sample_envelope().encode().unwrap();
684        let mut wire: TimelineOperationEnvelopeWireV1 = rmp_serde::from_slice(&bytes).unwrap();
685        wire.schema_version = 99;
686        let bytes = rmp_serde::to_vec_named(&wire).unwrap();
687        assert!(matches!(
688            TimelineOperationEnvelope::decode(&bytes),
689            Err(TimelineCodecError::UnsupportedVersion(99))
690        ));
691    }
692
693    #[test]
694    fn timeline_decode_rejects_unknown_kind() {
695        let bytes = sample_envelope().encode().unwrap();
696        let mut wire: TimelineOperationEnvelopeWireV1 = rmp_serde::from_slice(&bytes).unwrap();
697        wire.kind = "tool_call_teleported".to_string();
698        let bytes = rmp_serde::to_vec_named(&wire).unwrap();
699        assert!(matches!(
700            TimelineOperationEnvelope::decode(&bytes),
701            Err(TimelineCodecError::UnknownKind(kind)) if kind == "tool_call_teleported"
702        ));
703    }
704
705    #[test]
706    fn generated_step_and_branch_ids_are_prefixed() {
707        assert!(TimelineStepId::generate().as_str().starts_with("tls-"));
708        assert!(TimelineBranchId::generate().as_str().starts_with("tlb-"));
709    }
710}