Skip to main content

hotl_types/
lib.rs

1//! L1 — canonical conversation types.
2//!
3//! Pure data + serde. No tokio, no I/O. Forward-compat serde is policy:
4//! `#[serde(other)] Unknown` on persisted enums, `format_version` in the
5//! session header, optional fields default + skip-when-none.
6//!
7//! Assistant content is kept as **verbatim provider blocks** (`serde_json::Value`)
8//! rather than re-typed structs: signed thinking blocks must echo back to the
9//! provider byte-faithfully or replay breaks (review A11), and unknown future
10//! block types survive a round-trip losslessly. Typed *views* are provided for
11//! the engine (`assistant_text`, `assistant_tool_uses`).
12
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16/// Bumped only on breaking changes to the persisted entry format.
17pub const FORMAT_VERSION: u32 = 1;
18
19/// Structural provenance on every injected user item (grok 04):
20/// no consumer ever parses message text to learn where it came from.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum SyntheticReason {
24    ProjectInstructions,
25    SystemReminder,
26    Steer,
27    CompactionSummary,
28    SubagentResult,
29    DoomLoopNudge,
30    RetryFeedback,
31    Moim,
32    Memory,
33    SubdirInstructions,
34    Todos,
35    #[serde(other)]
36    Unknown,
37}
38
39/// One conversation item. Internally tagged so `#[serde(other)]` can absorb
40/// item kinds this binary doesn't know yet (payload dropped, no crash).
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42#[serde(tag = "type", rename_all = "snake_case")]
43pub enum Item {
44    System {
45        text: String,
46    },
47    User {
48        text: String,
49        #[serde(default, skip_serializing_if = "Option::is_none")]
50        synthetic: Option<SyntheticReason>,
51    },
52    /// Verbatim provider content blocks (text / tool_use / thinking / ...).
53    Assistant {
54        blocks: Vec<Value>,
55    },
56    /// All results for one assistant turn's tool calls, in source order
57    /// (the API requires them in a single user message).
58    ToolResults {
59        results: Vec<ToolResultItem>,
60    },
61    #[serde(other)]
62    Unknown,
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct ToolResultItem {
67    pub tool_use_id: String,
68    pub content: String,
69    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
70    pub is_error: bool,
71}
72
73/// A session checklist item (`todo_write`, M4/tier-1 gap #3). Full-state
74/// replace: the model rewrites the whole list each call, so there is no
75/// separate id/patch shape to reconcile.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum TodoStatus {
79    Pending,
80    InProgress,
81    Completed,
82    #[serde(other)]
83    Unknown,
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87pub struct Todo {
88    pub content: String,
89    pub status: TodoStatus,
90    /// Present-tense form shown while in progress ("wiring the gate"); optional.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub active_form: Option<String>,
93}
94
95/// A tool invocation extracted from assistant blocks.
96#[derive(Debug, Clone, PartialEq)]
97pub struct ToolUse {
98    pub id: String,
99    pub name: String,
100    pub input: Value,
101}
102
103/// Concatenated text of the assistant's text blocks.
104pub fn assistant_text(blocks: &[Value]) -> String {
105    blocks
106        .iter()
107        .filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
108        .filter_map(|b| b.get("text").and_then(Value::as_str))
109        .collect::<Vec<_>>()
110        .join("")
111}
112
113/// Tool-use blocks in source order.
114pub fn assistant_tool_uses(blocks: &[Value]) -> Vec<ToolUse> {
115    blocks
116        .iter()
117        .filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_use"))
118        .filter_map(|b| {
119            Some(ToolUse {
120                id: b.get("id")?.as_str()?.to_string(),
121                name: b.get("name")?.as_str()?.to_string(),
122                input: b.get("input").cloned().unwrap_or(Value::Null),
123            })
124        })
125        .collect()
126}
127
128/// Why a sample stopped. `Other` absorbs stop reasons newer than this binary.
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum StopReason {
132    EndTurn,
133    MaxTokens,
134    ToolUse,
135    StopSequence,
136    PauseTurn,
137    Refusal,
138    #[serde(other)]
139    Other,
140}
141
142/// `serde(skip_serializing_if)` needs a named function, not an inline
143/// closure — this is the zero-check every new `TokenUsage` bucket shares.
144fn is_zero_u64(n: &u64) -> bool {
145    *n == 0
146}
147
148/// Normalized usage; fields absent from a provider response default to zero.
149#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
150pub struct TokenUsage {
151    #[serde(default)]
152    pub input_tokens: u64,
153    #[serde(default)]
154    pub output_tokens: u64,
155    #[serde(default)]
156    pub cache_read_input_tokens: u64,
157    #[serde(default)]
158    pub cache_creation_input_tokens: u64,
159    /// Cache-write tokens billed at the 5-minute TTL rate — a refinement of
160    /// `cache_creation_input_tokens` (which stays the authoritative total),
161    /// not a replacement for it. `skip_serializing_if` keeps a payload with
162    /// no per-TTL breakdown byte-identical to one from before this field
163    /// existed: this struct is persisted in the session log and serialized
164    /// into `--json`/ACP frames, so a zero bucket must stay invisible on
165    /// the wire.
166    #[serde(default, skip_serializing_if = "is_zero_u64")]
167    pub cache_creation_5m_input_tokens: u64,
168    /// Cache-write tokens billed at the 1-hour TTL rate (2x input, vs. 1.25x
169    /// for the 5-minute default). See `cache_creation_5m_input_tokens`.
170    #[serde(default, skip_serializing_if = "is_zero_u64")]
171    pub cache_creation_1h_input_tokens: u64,
172}
173
174impl std::ops::AddAssign for TokenUsage {
175    fn add_assign(&mut self, rhs: Self) {
176        self.input_tokens += rhs.input_tokens;
177        self.output_tokens += rhs.output_tokens;
178        self.cache_read_input_tokens += rhs.cache_read_input_tokens;
179        self.cache_creation_input_tokens += rhs.cache_creation_input_tokens;
180        self.cache_creation_5m_input_tokens += rhs.cache_creation_5m_input_tokens;
181        self.cache_creation_1h_input_tokens += rhs.cache_creation_1h_input_tokens;
182    }
183}
184
185impl TokenUsage {
186    /// Fraction of prompt tokens (input + cache reads + cache writes) served
187    /// from the cache. `None` when there was no cache activity at all (no
188    /// reads, no writes) — that is "nothing to report", not a 0% hit rate,
189    /// so a plain uncached request never shows a misleading `0%`. Division
190    /// by zero never happens: the guard only lets the divide run once the
191    /// denominator has a cache-derived term in it.
192    pub fn hit_ratio(&self) -> Option<f64> {
193        if self.cache_read_input_tokens == 0 && self.cache_creation_input_tokens == 0 {
194            return None;
195        }
196        let total =
197            self.input_tokens + self.cache_read_input_tokens + self.cache_creation_input_tokens;
198        Some(self.cache_read_input_tokens as f64 / total as f64)
199    }
200}
201
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203pub struct SessionHeader {
204    pub format_version: u32,
205    pub session_id: String,
206    /// Reserved for fork/resume (M3); always serialized so old logs stay readable.
207    pub parent_session_id: Option<String>,
208    pub model: String,
209    pub created_at_ms: u64,
210}
211
212/// One appended log record. `parent_id` forms a chain (a tree from M3);
213/// M0 logs are strictly linear.
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
215pub struct Entry {
216    pub id: String,
217    pub parent_id: Option<String>,
218    pub ts_ms: u64,
219    pub payload: EntryPayload,
220}
221
222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
223#[serde(tag = "kind", rename_all = "snake_case")]
224pub enum EntryPayload {
225    Header {
226        header: SessionHeader,
227    },
228    Item {
229        item: Item,
230    },
231    Usage {
232        usage: TokenUsage,
233    },
234    Cancelled {
235        reason: String,
236    },
237    /// Compaction re-points the projection: history before `kept_from` is
238    /// replaced by `digest` items; the log itself keeps everything.
239    Compaction {
240        digest: Vec<Item>,
241        /// Leading items of the pre-compaction projection preserved verbatim.
242        prefix_end: usize,
243        /// Index into the pre-compaction projection where the verbatim tail
244        /// starts. Both indices are relative to the projection *at compaction
245        /// time*; replay reconstructs by applying compactions in log order.
246        kept_from: usize,
247        /// True when the summarize call failed and the floor was applied.
248        degraded: bool,
249    },
250    /// Re-point the projection to its first `keep_items` items — the
251    /// `branch_move` of the commit-protocol vocabulary, expressed against
252    /// the linear projection (M3b). Fork UIs arrive with M4; the entry and
253    /// its replay semantics are settled here.
254    BranchMove {
255        keep_items: usize,
256    },
257    /// Digest of an abandoned branch, appended after a `branch_move` so the
258    /// lesson survives without the tokens (commit-protocol `supersede`).
259    Supersede {
260        digest: Vec<Item>,
261    },
262    /// A permission ask committed **before** it surfaces (durable asks):
263    /// if the process dies before a matching `ask_resolved`, replay
264    /// sees a dangling ask and resume re-surfaces it. Log-only (not a
265    /// projection item).
266    PendingAsk {
267        id: String,
268        summary: String,
269        #[serde(default, skip_serializing_if = "Option::is_none")]
270        protected_why: Option<String>,
271    },
272    /// Resolution of a `pending_ask` (§2b): the human answered.
273    AskResolved {
274        id: String,
275        allowed: bool,
276    },
277    /// Sets/overwrites the session's display name. Log-only — not a
278    /// projection item (like `PendingAsk`); the last one wins on replay.
279    Rename {
280        name: String,
281    },
282    /// Sets the session's effective permission mode (plan mode's
283    /// approve-and-continue, `/mode`, `session/set_mode`). Log-only, like
284    /// `Rename` — not a projection item; the last one wins on replay, so
285    /// `hotl resume` restores the mode the session was actually in. A
286    /// string, not the enum, for forward-compat: the engine maps it.
287    ModeSet {
288        mode: String,
289    },
290    /// A structured question (`ask_user`, tier-1 gap #4) committed durably
291    /// **before** it surfaces — mirrors `PendingAsk`/`AskResolved` exactly:
292    /// if the process dies before a matching `question_resolved`, replay
293    /// sees a dangling question and resume can re-surface it. Log-only (not
294    /// a projection item).
295    PendingQuestion {
296        id: String,
297        question: Question,
298    },
299    /// Resolution of a `pending_question`: the human's answer (already
300    /// formatted to the plain text the model reads — labels joined for a
301    /// selection, the free-text body, or the no-human guidance).
302    QuestionResolved {
303        id: String,
304        answer: String,
305    },
306    /// Durable snapshot of the `todo_write` checklist (M4/tier-1 gap #3).
307    /// Log-only, like `Rename`/`ModeSet` — not a projection item, so it never
308    /// rides in the model transcript; the last one wins on replay. The live
309    /// list itself is ephemeral session context injected as a tagged user
310    /// reminder (`SyntheticReason::Todos`), never committed as an `Item`.
311    Todos {
312        items: Vec<Todo>,
313    },
314    #[serde(other)]
315    Unknown,
316}
317
318/// One selectable choice in a structured [`Question`] (`ask_user`, tier-1
319/// gap #4). `description` is an optional one-line elaboration shown under
320/// the label.
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322pub struct QuestionOption {
323    pub label: String,
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub description: Option<String>,
326}
327
328/// A structured multiple-choice question the agent asks the human
329/// (`ask_user`) — a header, a prompt, and 2-4 labelled options (plus an
330/// always-available free-text "other" the surfaces provide, not encoded
331/// here). `multi` reserves multi-select for a future surface; today's
332/// surfaces treat every question as single-select.
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334pub struct Question {
335    pub header: String,
336    pub prompt: String,
337    pub options: Vec<QuestionOption>,
338    #[serde(default)]
339    pub multi: bool,
340}
341
342/// A human's answer to an `ask_user` question (tier-1 gap #4). Lives here
343/// (not hotl-engine, where the plan first sketched it) so both hotl-tools's
344/// `QuestionSink` and hotl-engine's `EngineEvent::Question` can share one
345/// definition without either crate depending on the other — the same
346/// cycle-avoidance the plan flagged as open, resolved the way `Question`
347/// itself already is. `NoHuman` is the documented default when no reply
348/// arrives (headless, `DontAsk`, a dropped reply channel): the model must
349/// always get an answer, never a hang.
350#[derive(Debug, Clone, PartialEq)]
351pub enum QuestionAnswer {
352    Selected(Vec<String>),
353    FreeText(String),
354    NoHuman,
355}
356
357pub fn new_ulid() -> String {
358    ulid::Ulid::new().to_string()
359}
360
361/// A session display name: trimmed, non-empty, at most 64 chars.
362/// The one validator every entry point (CLI, ACP, TUI) funnels through.
363pub fn normalize_session_name(raw: &str) -> Option<String> {
364    let name = raw.trim();
365    (!name.is_empty() && name.chars().count() <= 64).then(|| name.to_string())
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    fn roundtrip<T: Serialize + for<'a> Deserialize<'a>>(v: &T) -> String {
373        let a = serde_json::to_string(v).unwrap();
374        let back: T = serde_json::from_str(&a).unwrap();
375        let b = serde_json::to_string(&back).unwrap();
376        assert_eq!(
377            a, b,
378            "serialize → deserialize → serialize must be byte-identical"
379        );
380        a
381    }
382
383    #[test]
384    fn items_roundtrip_byte_identical() {
385        let items = vec![
386            Item::System {
387                text: "you are hotl".into(),
388            },
389            Item::User {
390                text: "hi".into(),
391                synthetic: None,
392            },
393            Item::User {
394                text: "<project-instructions>...</project-instructions>".into(),
395                synthetic: Some(SyntheticReason::ProjectInstructions),
396            },
397            Item::Assistant {
398                blocks: vec![
399                    serde_json::json!({"type":"thinking","thinking":"","signature":"sig=="}),
400                    serde_json::json!({"type":"text","text":"hello"}),
401                    serde_json::json!({"type":"tool_use","id":"toolu_1","name":"read","input":{"path":"a.rs"}}),
402                ],
403            },
404            Item::ToolResults {
405                results: vec![ToolResultItem {
406                    tool_use_id: "toolu_1".into(),
407                    content: "fn main() {}".into(),
408                    is_error: false,
409                }],
410            },
411        ];
412        for item in &items {
413            roundtrip(item);
414        }
415    }
416
417    #[test]
418    fn question_types_and_entries_roundtrip() {
419        let q = Question {
420            header: "Auth".into(),
421            prompt: "Which provider?".into(),
422            options: vec![
423                QuestionOption {
424                    label: "Keycloak".into(),
425                    description: Some("self-hosted".into()),
426                },
427                QuestionOption {
428                    label: "Auth0".into(),
429                    description: None,
430                },
431            ],
432            multi: false,
433        };
434        let pj = serde_json::to_string(&EntryPayload::PendingQuestion {
435            id: "q1".into(),
436            question: q.clone(),
437        })
438        .unwrap();
439        assert!(pj.contains("\"kind\":\"pending_question\""));
440        assert_eq!(
441            serde_json::from_str::<EntryPayload>(&pj).unwrap(),
442            EntryPayload::PendingQuestion {
443                id: "q1".into(),
444                question: q
445            }
446        );
447        let rj = serde_json::to_string(&EntryPayload::QuestionResolved {
448            id: "q1".into(),
449            answer: "Keycloak".into(),
450        })
451        .unwrap();
452        assert!(rj.contains("\"kind\":\"question_resolved\""));
453    }
454
455    #[test]
456    fn entry_roundtrip_and_mutation() {
457        let mut e = Entry {
458            id: new_ulid(),
459            parent_id: None,
460            ts_ms: 1,
461            payload: EntryPayload::Item {
462                item: Item::User {
463                    text: "x".into(),
464                    synthetic: None,
465                },
466            },
467        };
468        roundtrip(&e);
469        // mutate, re-serialize — still stable
470        e.ts_ms = 2;
471        roundtrip(&e);
472    }
473
474    #[test]
475    fn unknown_variants_survive() {
476        let item: Item = serde_json::from_str(r#"{"type":"hologram","payload":{"x":1}}"#).unwrap();
477        assert_eq!(item, Item::Unknown);
478        let reason: SyntheticReason = serde_json::from_str(r#""quantum_nudge""#).unwrap();
479        assert_eq!(reason, SyntheticReason::Unknown);
480        let payload: EntryPayload =
481            serde_json::from_str(r#"{"kind":"visibility","target":"e1"}"#).unwrap();
482        assert_eq!(payload, EntryPayload::Unknown);
483        let stop: StopReason = serde_json::from_str(r#""cosmic_ray""#).unwrap();
484        assert_eq!(stop, StopReason::Other);
485    }
486
487    #[test]
488    fn assistant_views() {
489        let blocks = vec![
490            serde_json::json!({"type":"text","text":"I'll read "}),
491            serde_json::json!({"type":"text","text":"the file."}),
492            serde_json::json!({"type":"tool_use","id":"t1","name":"read","input":{"path":"x"}}),
493        ];
494        assert_eq!(assistant_text(&blocks), "I'll read the file.");
495        let uses = assistant_tool_uses(&blocks);
496        assert_eq!(uses.len(), 1);
497        assert_eq!(uses[0].name, "read");
498    }
499
500    #[test]
501    fn rename_entry_roundtrips_with_snake_case_kind() {
502        let json = roundtrip(&EntryPayload::Rename {
503            name: "fix-auth".into(),
504        });
505        assert!(json.contains("\"kind\":\"rename\""), "wire kind: {json}");
506        assert!(json.contains("\"name\":\"fix-auth\""), "wire name: {json}");
507    }
508
509    #[test]
510    fn mode_set_entry_roundtrips_snake_case() {
511        let j = serde_json::to_string(&EntryPayload::ModeSet {
512            mode: "plan".into(),
513        })
514        .unwrap();
515        assert!(j.contains("\"kind\":\"mode_set\""), "wire kind: {j}");
516        let back: EntryPayload = serde_json::from_str(&j).unwrap();
517        assert_eq!(
518            back,
519            EntryPayload::ModeSet {
520                mode: "plan".into()
521            }
522        );
523    }
524
525    #[test]
526    fn todo_types_roundtrip_and_absorb_unknown_status() {
527        let t = Todo {
528            content: "wire the gate".into(),
529            status: TodoStatus::InProgress,
530            active_form: Some("wiring the gate".into()),
531        };
532        let j = serde_json::to_string(&t).unwrap();
533        assert!(j.contains("\"status\":\"in_progress\""));
534        let back: Todo = serde_json::from_str(&j).unwrap();
535        assert_eq!(back, t);
536        let unk: TodoStatus = serde_json::from_str("\"blocked_on_ci\"").unwrap();
537        assert_eq!(unk, TodoStatus::Unknown);
538        let e = EntryPayload::Todos { items: vec![t] };
539        let ej = serde_json::to_string(&e).unwrap();
540        assert!(ej.contains("\"kind\":\"todos\""));
541        assert_eq!(serde_json::from_str::<EntryPayload>(&ej).unwrap(), e);
542    }
543
544    #[test]
545    fn hit_ratio_is_absent_without_cache_activity() {
546        // Plain uncached usage: no reads, no writes. `Some(0.0)` would read
547        // as "0% cache hit"; the correct signal is "no cache info at all".
548        let usage = TokenUsage {
549            input_tokens: 100,
550            output_tokens: 20,
551            ..Default::default()
552        };
553        assert_eq!(usage.hit_ratio(), None);
554    }
555
556    #[test]
557    fn hit_ratio_divides_reads_by_total_prompt_tokens() {
558        let usage = TokenUsage {
559            input_tokens: 25,
560            output_tokens: 10,
561            cache_read_input_tokens: 50,
562            cache_creation_input_tokens: 25,
563            ..Default::default()
564        };
565        assert_eq!(usage.hit_ratio(), Some(0.5));
566    }
567
568    #[test]
569    fn hit_ratio_is_present_and_zero_on_a_cache_write_with_no_reads() {
570        // A cold prefix write with nothing yet read back: cache activity
571        // happened (so the ratio is meaningful), but the hit rate is 0%.
572        let usage = TokenUsage {
573            input_tokens: 0,
574            output_tokens: 0,
575            cache_read_input_tokens: 0,
576            cache_creation_input_tokens: 100,
577            ..Default::default()
578        };
579        assert_eq!(usage.hit_ratio(), Some(0.0));
580    }
581
582    #[test]
583    fn hit_ratio_never_divides_by_zero() {
584        assert_eq!(TokenUsage::default().hit_ratio(), None);
585    }
586
587    #[test]
588    fn token_usage_with_zero_ttl_buckets_serializes_byte_identical_to_before() {
589        // The pre-change struct's exact JSON shape for this usage value —
590        // pinned literally so a future edit that starts emitting the new
591        // bucket keys at zero is caught here, not downstream in a session
592        // log or a --json consumer.
593        let usage = TokenUsage {
594            input_tokens: 10,
595            output_tokens: 20,
596            cache_read_input_tokens: 5,
597            cache_creation_input_tokens: 7,
598            ..Default::default()
599        };
600        let json = serde_json::to_string(&usage).unwrap();
601        assert_eq!(
602            json,
603            "{\"input_tokens\":10,\"output_tokens\":20,\"cache_read_input_tokens\":5,\
604             \"cache_creation_input_tokens\":7}"
605        );
606        assert!(!json.contains("cache_creation_5m_input_tokens"));
607        assert!(!json.contains("cache_creation_1h_input_tokens"));
608        let back: TokenUsage = serde_json::from_str(&json).unwrap();
609        assert_eq!(back, usage);
610    }
611
612    #[test]
613    fn token_usage_with_nonzero_ttl_buckets_round_trips() {
614        let usage = TokenUsage {
615            input_tokens: 10,
616            cache_creation_input_tokens: 300,
617            cache_creation_5m_input_tokens: 100,
618            cache_creation_1h_input_tokens: 200,
619            ..Default::default()
620        };
621        let json = serde_json::to_string(&usage).unwrap();
622        assert!(json.contains("\"cache_creation_5m_input_tokens\":100"));
623        assert!(json.contains("\"cache_creation_1h_input_tokens\":200"));
624        let back: TokenUsage = serde_json::from_str(&json).unwrap();
625        assert_eq!(back, usage);
626    }
627
628    #[test]
629    fn token_usage_deserializes_old_bytes_with_no_ttl_buckets() {
630        // A pre-existing session-log entry / --json frame with none of the
631        // new keys must still parse, with both buckets defaulting to zero.
632        let old = r#"{"input_tokens":1,"output_tokens":2,"cache_read_input_tokens":3,"cache_creation_input_tokens":4}"#;
633        let usage: TokenUsage = serde_json::from_str(old).unwrap();
634        assert_eq!(usage.cache_creation_5m_input_tokens, 0);
635        assert_eq!(usage.cache_creation_1h_input_tokens, 0);
636        assert_eq!(usage.cache_creation_input_tokens, 4);
637    }
638
639    #[test]
640    fn normalize_session_name_trims_and_bounds() {
641        assert_eq!(
642            normalize_session_name("  fix auth  "),
643            Some("fix auth".into())
644        );
645        assert_eq!(normalize_session_name("   "), None);
646        assert_eq!(normalize_session_name(""), None);
647        let long = "x".repeat(65);
648        assert_eq!(normalize_session_name(&long), None);
649        let max = "é".repeat(64); // chars, not bytes
650        assert_eq!(normalize_session_name(&max), Some(max.clone()));
651    }
652}