1use std::fmt;
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
7
8use crate::object::{ChangeId, ContentHash};
9
10pub 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#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
76#[serde(transparent)]
77pub struct TimelineOperationId([u8; 32]);
78
79impl TimelineOperationId {
80 pub fn for_bytes(bytes: &[u8]) -> Self {
82 let hash = ContentHash::compute_typed("timeline-operation", bytes);
83 Self(*hash.as_bytes())
84 }
85
86 pub fn from_bytes(bytes: [u8; 32]) -> Self {
88 Self(bytes)
89 }
90
91 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 pub fn as_bytes(&self) -> &[u8; 32] {
103 &self.0
104 }
105
106 pub fn to_hex(&self) -> String {
108 hex::encode(self.0)
109 }
110
111 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 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#[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 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 pub fn new(value: impl Into<String>) -> Self {
164 Self(value.into())
165 }
166
167 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
186pub enum TimelineOperationKind {
187 ToolCallStarted,
188 ToolCallFinished,
189 CursorMoved,
190 BranchCreated,
191}
192
193impl TimelineOperationKind {
194 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#[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#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
252pub struct TimelineToolPayloadMetadata {
253 pub summary: Option<String>,
254 pub hash: Option<ContentHash>,
255}
256
257#[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#[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#[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#[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#[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 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 pub fn encode(&self) -> Result<Vec<u8>, TimelineCodecError> {
318 LatestTimelineOperationSchema::encode(self)
319 }
320
321 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 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#[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#[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#[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#[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#[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#[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}