Skip to main content

mermaid_model/
ids.rs

1//! Typed identifiers used throughout the reducer.
2//!
3//! These are nonces, not indices — the reducer uses them to drop stale
4//! effect results that arrive after the turn they belong to has been
5//! superseded. `TurnId` is the most important: every `Msg` that carries
6//! effect output tags itself with the `TurnId` of the turn that produced
7//! it; the reducer compares against `state.turn.id()` and ignores any
8//! mismatch. That turns the whole "stale stream event fires after the
9//! user cancelled" class of bugs into a type-level non-issue.
10//!
11//! None of these types do anything clever. They wrap `u64` so they're
12//! Copy + Ord + serializable (useful for `--record` / `--replay`), and
13//! they're newtypes so the type system catches accidental swaps
14//! (a `ToolCallId` can't be used where a `TurnId` is expected).
15
16use serde::{Deserialize, Serialize};
17use std::fmt;
18
19/// One "turn" = one user prompt + the entire model+tools cascade that
20/// follows, ending when the reducer returns to `TurnState::Idle`. A
21/// `CancelTurn` ends the current turn immediately (after cleanup
22/// effects dispatch); the next prompt starts a fresh `TurnId`.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
24pub struct TurnId(pub u64);
25
26impl TurnId {
27    pub const ZERO: Self = Self(0);
28}
29
30impl fmt::Display for TurnId {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        write!(f, "turn#{}", self.0)
33    }
34}
35
36/// Stable identifier for a single tool call inside a turn. The reducer
37/// uses this to match `ToolFinished` results back to the slot in
38/// `TurnState::ExecutingTools::outcomes` so results can land out of
39/// order without ambiguity.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
41pub struct ToolCallId(pub u64);
42
43impl fmt::Display for ToolCallId {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        write!(f, "tool#{}", self.0)
46    }
47}
48
49/// Monotonic ID allocator. `State` owns one of these per "kind" (turn,
50/// tool call) and hands out fresh IDs by incrementing. Reset happens
51/// only on a full session replay.
52#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
53pub struct IdAllocator {
54    next: u64,
55}
56
57impl Default for IdAllocator {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63impl IdAllocator {
64    /// Start at 1 so `TurnId::ZERO` / `ToolCallId(0)` stay reserved as
65    /// sentinel values — no real allocation ever collides with them.
66    #[must_use]
67    pub const fn new() -> Self {
68        Self { next: 1 }
69    }
70
71    /// Start handing out IDs from `next`. Used when resuming a conversation to
72    /// continue a global counter past the highest value already persisted (e.g.
73    /// image numbers), so a resumed session never re-issues a number that an
74    /// earlier message already used.
75    #[must_use]
76    pub const fn starting_at(next: u64) -> Self {
77        Self { next }
78    }
79
80    /// Hand out the next ID.
81    #[expect(clippy::should_implement_trait)]
82    pub fn next(&mut self) -> u64 {
83        let id = self.next;
84        self.next = self.next.saturating_add(1);
85        id
86    }
87
88    /// Reset to 1. Used by `--replay` when loading a fresh log.
89    pub fn reset(&mut self) {
90        self.next = 1;
91    }
92
93    /// Peek without advancing.
94    #[must_use]
95    pub fn peek(&self) -> u64 {
96        self.next
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn id_allocator_hands_out_monotonic_ids() {
106        let mut alloc = IdAllocator::new();
107        assert_eq!(alloc.next(), 1);
108        assert_eq!(alloc.next(), 2);
109        assert_eq!(alloc.next(), 3);
110    }
111
112    #[test]
113    fn id_allocator_reset_starts_from_one() {
114        let mut alloc = IdAllocator::new();
115        alloc.next();
116        alloc.next();
117        alloc.reset();
118        assert_eq!(alloc.next(), 1);
119    }
120
121    #[test]
122    fn id_types_are_distinct_at_the_type_level() {
123        // Compile-time check: can't accidentally pass a ToolCallId where
124        // a TurnId is expected. No `From` impl between them.
125        fn only_turn(_: TurnId) {}
126        only_turn(TurnId(1));
127        // only_turn(ToolCallId(1));  // would fail to compile — correct
128    }
129
130    #[test]
131    fn turn_id_display_format() {
132        assert_eq!(format!("{}", TurnId(42)), "turn#42");
133        assert_eq!(format!("{}", ToolCallId(7)), "tool#7");
134    }
135
136    #[test]
137    fn ids_serialize_as_bare_numbers() {
138        // Wrapped u64 means JSON is just the number — keeps the replay
139        // log readable and small.
140        let json = serde_json::to_string(&TurnId(7)).unwrap();
141        assert_eq!(json, "7");
142    }
143}