Skip to main content

agent_uri/
agent_id.rs

1//! Agent identifier type using `TypeID` format.
2//!
3//! # Grammar Reference
4//!
5//! The agent ID grammar is defined in `grammar.abnf`:
6//!
7//! ```abnf
8//! agent-id     = agent-prefix "_" type-suffix
9//! agent-prefix = type-class *( "_" type-modifier )
10//! type-class   = "llm" / "rule" / "human" / "composite"
11//!              / "sensor" / "actuator" / "hybrid"
12//!              / extension-class
13//! type-suffix  = suffix-first 25( BASE32-TYPEID )
14//! suffix-first = %x30-37  ; "0" through "7"
15//! ```
16//!
17//! Maximum agent ID length: 90 characters (63 prefix + 1 underscore + 26 suffix).
18
19use std::cmp::Ordering;
20use std::fmt;
21use std::str::FromStr;
22
23use mti::prelude::*;
24
25use crate::agent_prefix::AgentPrefix;
26use crate::constants::{AGENT_SUFFIX_LENGTH, MAX_AGENT_ID_LENGTH, MAX_AGENT_PREFIX_LENGTH};
27use crate::error::AgentIdError;
28
29/// Base32 alphabet for `TypeID` suffix (Crockford-derived).
30/// Excludes: i, l, o, u (visually ambiguous).
31///
32/// A `str` rather than a byte string so membership is asked of a whole
33/// character. Asking it of `c as u8` truncates: `'İ'` (U+0130) becomes `0x30`,
34/// which is `'0'`, and twelve of them read as twelve zeros (issue #33).
35const BASE32_ALPHABET: &str = "0123456789abcdefghjkmnpqrstvwxyz";
36
37/// The type class a prefix falls back to when sanitizing leaves nothing.
38///
39/// [`AgentId::new`] rewrites rather than refuses, and rewriting can consume the
40/// whole input: `new("123")` has no letter to keep. A bare `TypeID` may have an
41/// empty prefix, but this grammar's `agent-prefix` may not, so there is nothing
42/// to fall back to except a class that says only what the input already said,
43/// which is that the thing is an agent.
44const FALLBACK_TYPE_CLASS: &str = "agent";
45
46/// Rewrites any string into a prefix `agent-prefix` accepts.
47///
48/// The rules are the ones `mti` applies to a bare `TypeID` prefix, tightened
49/// where this grammar is tighter:
50///
51/// - ASCII uppercase folds to lowercase; every other character outside
52///   `[a-z_]` is dropped. Folding is ASCII-only on purpose: mapping `İ` to `i`
53///   would let a non-ASCII character name an ASCII agent (issue #33).
54/// - A run of underscores collapses to one, and a leading underscore is
55///   dropped, because `type-modifier` is `1*LOWER` and an empty modifier is
56///   not a modifier.
57/// - The result is cut to 63 characters and then loses any trailing
58///   underscore, because a prefix must end in a letter.
59/// - If nothing survives, the result is [`FALLBACK_TYPE_CLASS`].
60///
61/// The output always parses as an [`AgentPrefix`]; `sanitizing_is_total` and
62/// the `agent_id_new_never_panics` property test hold this invariant up.
63fn sanitize_prefix(input: &str) -> String {
64    let mut out = String::new();
65
66    for c in input.chars().map(|c| c.to_ascii_lowercase()) {
67        if out.len() == MAX_AGENT_PREFIX_LENGTH {
68            break;
69        }
70        match c {
71            'a'..='z' => out.push(c),
72            '_' if !out.is_empty() && !out.ends_with('_') => out.push('_'),
73            _ => {}
74        }
75    }
76
77    // The loop only ever keeps an underscore that had a letter before it. The
78    // letter after it is what the cut above can take away.
79    while out.ends_with('_') {
80        out.pop();
81    }
82
83    if out.is_empty() {
84        FALLBACK_TYPE_CLASS.to_string()
85    } else {
86        out
87    }
88}
89
90/// A validated agent identifier in `TypeID` format.
91///
92/// The agent ID uniquely identifies an agent and encodes:
93/// - Semantic classification (the prefix)
94/// - Unique identity (the `UUIDv7` suffix)
95/// - Creation timestamp (encoded in `UUIDv7`)
96///
97/// # Format
98///
99/// `<prefix>_<suffix>` where:
100/// - prefix: 1-63 lowercase letters and underscores
101/// - suffix: 26 character base32-encoded `UUIDv7`
102///
103/// # Examples
104///
105/// ```
106/// use agent_uri::AgentId;
107///
108/// let id = AgentId::parse("llm_01h455vb4pex5vsknk084sn02q").unwrap();
109/// assert_eq!(id.prefix().as_str(), "llm");
110/// assert_eq!(id.suffix(), "01h455vb4pex5vsknk084sn02q");
111///
112/// // Create a new agent ID
113/// let id = AgentId::new("llm_chat");
114/// assert_eq!(id.suffix().len(), 26);
115/// ```
116///
117/// # Constructing one
118///
119/// [`AgentId::parse`] reads an existing ID. The other two mint a fresh one and
120/// differ only in what they do with a prefix the grammar rejects:
121/// [`AgentId::new`] rewrites it, [`AgentId::try_new`] reports it. Prefer
122/// `try_new` for any prefix that did not come from your own source.
123#[derive(Debug, Clone, PartialEq, Eq, Hash)]
124pub struct AgentId {
125    prefix: AgentPrefix,
126    suffix: String,
127    inner: MagicTypeId,
128}
129
130impl AgentId {
131    /// Creates a new agent ID with the given prefix and a fresh `UUIDv7`,
132    /// sanitizing the prefix if the grammar does not already accept it.
133    ///
134    /// This is the split `mti` draws, and this crate mints `TypeID`s with
135    /// `mti`: `create_type_id` sanitizes and `try_create_type_id` refuses.
136    /// Sanitizing is described on [`AgentId::try_new`]'s counterpart below;
137    /// in short, anything outside `[a-z_]` is dropped, uppercase is folded,
138    /// and a prefix left with nothing becomes `agent`.
139    ///
140    /// # Choosing between this and [`AgentId::try_new`]
141    ///
142    /// Sanitizing means you get an agent ID with *a* prefix, not necessarily
143    /// the one you named. That is what you want when the prefix is a label you
144    /// wrote: minting an ID should not be fallible over a string literal. It is
145    /// not what you want when the prefix arrived from somewhere — a peer, a
146    /// config file, a URI — because a rewritten prefix is a different agent
147    /// class than the one that was asked for, and silence about that is how a
148    /// caller ends up registering under a name it never chose. Untrusted input
149    /// belongs in [`AgentId::try_new`] or [`AgentId::parse`], which refuse.
150    ///
151    /// # Examples
152    ///
153    /// ```
154    /// use agent_uri::AgentId;
155    ///
156    /// // A prefix the grammar already accepts is kept verbatim.
157    /// let id = AgentId::new("llm_chat");
158    /// assert_eq!(id.prefix().as_str(), "llm_chat");
159    ///
160    /// // Anything else is rewritten rather than refused.
161    /// assert_eq!(AgentId::new("LLM Chat!").prefix().as_str(), "llmchat");
162    /// assert_eq!(AgentId::new("__llm__chat__").prefix().as_str(), "llm_chat");
163    /// assert_eq!(AgentId::new("123").prefix().as_str(), "agent");
164    ///
165    /// // Every one of them is a parsable agent ID.
166    /// assert!(AgentId::parse(&AgentId::new("123").to_string()).is_ok());
167    /// ```
168    #[must_use]
169    pub fn new(prefix: &str) -> Self {
170        let sanitized = sanitize_prefix(prefix);
171        let Ok(agent_prefix) = AgentPrefix::parse(&sanitized) else {
172            unreachable!("sanitize_prefix returns a parsable prefix, got {sanitized:?}")
173        };
174
175        Self::mint(agent_prefix)
176    }
177
178    /// Creates a new agent ID with the given prefix and a fresh `UUIDv7`,
179    /// refusing a prefix the grammar does not accept.
180    ///
181    /// Use this whenever the prefix came from outside your own source: it
182    /// reports what [`AgentId::new`] would have quietly rewritten.
183    ///
184    /// # Errors
185    ///
186    /// Returns `AgentIdError` if the prefix is invalid.
187    ///
188    /// # Examples
189    ///
190    /// ```
191    /// use agent_uri::AgentId;
192    ///
193    /// assert!(AgentId::try_new("llm_chat").is_ok());
194    /// assert!(AgentId::try_new("LLM Chat!").is_err());
195    /// ```
196    pub fn try_new(prefix: &str) -> Result<Self, AgentIdError> {
197        let agent_prefix = AgentPrefix::parse(prefix).map_err(AgentIdError::InvalidPrefix)?;
198
199        Ok(Self::mint(agent_prefix))
200    }
201
202    /// Mints an agent ID from a prefix that has already been validated.
203    ///
204    /// Both constructors end here, and nothing here can fail: the prefix has
205    /// already been validated, a fresh `UUIDv7` suffix is always well formed,
206    /// and `mti` assembles a `TypeID` from the two without consulting either.
207    /// Minting used to go through `mti`'s fallible accessors, which gave
208    /// [`AgentId::new`] a failure to `expect` on top of the one it already had.
209    fn mint(prefix: AgentPrefix) -> Self {
210        let suffix = TypeIdSuffix::new::<V7>();
211        let suffix_string = suffix.to_string();
212        // A no-op: `AgentPrefix` is strictly narrower than a `TypeID` prefix.
213        // It is how a `TypeIdPrefix` is obtained without a fallible parse.
214        let inner = MagicTypeId::new(prefix.as_str().create_prefix_sanitized(), suffix);
215
216        Self {
217            prefix,
218            suffix: suffix_string,
219            inner,
220        }
221    }
222
223    /// Parses an agent ID from a string.
224    ///
225    /// # Errors
226    ///
227    /// Returns `AgentIdError` if:
228    /// - The input is empty
229    /// - The input exceeds 90 characters
230    /// - The prefix is invalid
231    /// - The suffix is invalid (not valid base32 or wrong length)
232    /// - The separator is missing
233    pub fn parse(input: &str) -> Result<Self, AgentIdError> {
234        if input.is_empty() {
235            return Err(AgentIdError::Empty);
236        }
237
238        if input.len() > MAX_AGENT_ID_LENGTH {
239            return Err(AgentIdError::TooLong {
240                max: MAX_AGENT_ID_LENGTH,
241                actual: input.len(),
242            });
243        }
244
245        // Find the last underscore (separator between prefix and suffix)
246        let sep_idx = input.rfind('_').ok_or(AgentIdError::MissingSeparator)?;
247
248        if sep_idx == 0 {
249            return Err(AgentIdError::InvalidPrefix(
250                crate::error::AgentPrefixError::Empty,
251            ));
252        }
253
254        let prefix_str = &input[..sep_idx];
255        let suffix_str = &input[sep_idx + 1..];
256
257        // Validate prefix
258        let prefix = AgentPrefix::parse(prefix_str).map_err(AgentIdError::InvalidPrefix)?;
259
260        // Validate suffix
261        Self::validate_suffix(suffix_str)?;
262
263        // Parse as MagicTypeId
264        let inner =
265            MagicTypeId::from_str(input).map_err(|e| AgentIdError::TypeIdError(e.to_string()))?;
266
267        Ok(Self {
268            prefix,
269            suffix: suffix_str.to_string(),
270            inner,
271        })
272    }
273
274    /// Returns the prefix (semantic classification).
275    #[must_use]
276    pub const fn prefix(&self) -> &AgentPrefix {
277        &self.prefix
278    }
279
280    /// Returns the suffix (base32-encoded `UUIDv7`).
281    #[must_use]
282    pub fn suffix(&self) -> &str {
283        &self.suffix
284    }
285
286    /// Returns the underlying [`MagicTypeId`].
287    #[must_use]
288    pub const fn inner(&self) -> &MagicTypeId {
289        &self.inner
290    }
291
292    /// Returns the UUID from the suffix.
293    ///
294    /// # Errors
295    ///
296    /// Returns an error if UUID extraction fails.
297    pub fn uuid(&self) -> Result<uuid::Uuid, AgentIdError> {
298        self.inner
299            .uuid()
300            .map_err(|e| AgentIdError::TypeIdError(e.to_string()))
301    }
302
303    fn validate_suffix(suffix: &str) -> Result<(), AgentIdError> {
304        // Characters, not bytes. An upper bound on bytes only ever rejects more
305        // than it means to, but this is an equality: a suffix of thirteen
306        // two-byte characters occupies twenty-six bytes and would pass a check
307        // that counted them, having answered a question nobody asked.
308        if suffix.chars().count() != AGENT_SUFFIX_LENGTH {
309            return Err(AgentIdError::InvalidSuffix {
310                value: suffix.to_string(),
311                reason: "suffix must be exactly 26 characters",
312            });
313        }
314
315        // First character must be 0-7 (prevent 130-bit overflow)
316        let first = suffix
317            .chars()
318            .next()
319            .expect("already checked suffix is not empty");
320        if !('0'..='7').contains(&first) {
321            return Err(AgentIdError::InvalidSuffix {
322                value: suffix.to_string(),
323                reason: "first character must be 0-7",
324            });
325        }
326
327        // All characters must be valid base32
328        for c in suffix.chars() {
329            if !BASE32_ALPHABET.contains(c) {
330                return Err(AgentIdError::InvalidSuffix {
331                    value: suffix.to_string(),
332                    reason: "contains invalid base32 character",
333                });
334            }
335        }
336
337        Ok(())
338    }
339}
340
341impl fmt::Display for AgentId {
342    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
343        write!(f, "{}", self.inner)
344    }
345}
346
347impl FromStr for AgentId {
348    type Err = AgentIdError;
349
350    fn from_str(s: &str) -> Result<Self, Self::Err> {
351        Self::parse(s)
352    }
353}
354
355impl AsRef<MagicTypeId> for AgentId {
356    fn as_ref(&self) -> &MagicTypeId {
357        &self.inner
358    }
359}
360
361impl TryFrom<&str> for AgentId {
362    type Error = AgentIdError;
363
364    fn try_from(s: &str) -> Result<Self, Self::Error> {
365        Self::parse(s)
366    }
367}
368
369impl PartialOrd for AgentId {
370    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
371        Some(self.cmp(other))
372    }
373}
374
375/// Orders by the rendered `prefix_suffix`, which is what
376/// [`Display`](fmt::Display) writes.
377///
378/// The comparison is the one this always made; what is gone is the pair of
379/// `String`s it used to build to make it. `MagicTypeId` keeps its own rendering
380/// and hands out a borrow of it, so nothing has to be rendered again here.
381/// `MagicTypeId`'s own `Ord` is not that comparison — it sorts by suffix first,
382/// which would order agents by creation time rather than by class.
383impl Ord for AgentId {
384    fn cmp(&self, other: &Self) -> Ordering {
385        let (this, that): (&str, &str) = (self.inner.as_ref(), other.inner.as_ref());
386        this.cmp(that)
387    }
388}
389
390#[cfg(feature = "serde")]
391impl serde::Serialize for AgentId {
392    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
393    where
394        S: serde::Serializer,
395    {
396        serializer.serialize_str(&self.to_string())
397    }
398}
399
400#[cfg(feature = "serde")]
401impl<'de> serde::Deserialize<'de> for AgentId {
402    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
403    where
404        D: serde::Deserializer<'de>,
405    {
406        let s = String::deserialize(deserializer)?;
407        Self::parse(&s).map_err(serde::de::Error::custom)
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn create_new_id() {
417        let id = AgentId::new("llm_chat");
418        assert_eq!(id.prefix().as_str(), "llm_chat");
419        assert_eq!(id.suffix().len(), 26);
420    }
421
422    #[test]
423    fn creating_an_id_from_an_invalid_prefix_sanitizes_rather_than_panics() {
424        // The whole of issue #28: this call used to be a panic, and the
425        // signature never said so.
426        let id = AgentId::new("LLM Chat!");
427        assert_eq!(id.prefix().as_str(), "llmchat");
428        assert_eq!(id.suffix().len(), AGENT_SUFFIX_LENGTH);
429    }
430
431    #[test]
432    fn sanitizing_keeps_a_prefix_the_grammar_already_accepts() {
433        // Sanitizing has to be the identity on valid input, or `new` would be
434        // renaming agents that were named correctly.
435        for prefix in [
436            "llm",
437            "a",
438            "llm_chat",
439            "llm_chat_streaming",
440            "composite",
441            &"a".repeat(MAX_AGENT_PREFIX_LENGTH),
442        ] {
443            assert_eq!(sanitize_prefix(prefix), prefix);
444            assert_eq!(AgentId::new(prefix).prefix().as_str(), prefix);
445        }
446    }
447
448    #[test]
449    fn sanitizing_drops_what_the_grammar_has_no_room_for() {
450        assert_eq!(sanitize_prefix("LLM"), "llm");
451        assert_eq!(sanitize_prefix("llm-chat"), "llmchat");
452        assert_eq!(sanitize_prefix("llm2chat"), "llmchat");
453        assert_eq!(sanitize_prefix("llm chat"), "llmchat");
454        // A run of underscores would leave an empty modifier, and
455        // `type-modifier` is `1*LOWER`.
456        assert_eq!(sanitize_prefix("__llm__chat__"), "llm_chat");
457        // Non-ASCII is dropped, not transliterated: `İ` must not become `i`
458        // and name an agent nobody asked for (issue #33).
459        assert_eq!(sanitize_prefix("llm\u{0130}chat"), "llmchat");
460        assert_eq!(sanitize_prefix("\u{1f300}"), FALLBACK_TYPE_CLASS);
461    }
462
463    #[test]
464    fn sanitizing_a_prefix_down_to_nothing_yields_the_fallback_class() {
465        for prefix in ["", "123", "!!!", "___", "-", " "] {
466            assert_eq!(
467                sanitize_prefix(prefix),
468                FALLBACK_TYPE_CLASS,
469                "{prefix:?} left something behind"
470            );
471            assert_eq!(AgentId::new(prefix).prefix().as_str(), FALLBACK_TYPE_CLASS);
472        }
473    }
474
475    #[test]
476    fn sanitizing_cuts_to_the_maximum_prefix_length() {
477        let sanitized = sanitize_prefix(&"a".repeat(MAX_AGENT_PREFIX_LENGTH + 37));
478        assert_eq!(sanitized.len(), MAX_AGENT_PREFIX_LENGTH);
479
480        // The cut can strand the separator on the end, where a prefix must
481        // have a letter.
482        let sanitized = sanitize_prefix(&format!("{}_b", "a".repeat(MAX_AGENT_PREFIX_LENGTH)));
483        assert_eq!(sanitized.len(), MAX_AGENT_PREFIX_LENGTH);
484        assert!(!sanitized.ends_with('_'));
485    }
486
487    #[test]
488    fn sanitizing_is_total() {
489        // The invariant `new` rests on: whatever comes out parses. The
490        // property test in `no_panic_proptest.rs` says the same over
491        // arbitrary input; these are the cases worth naming.
492        for prefix in [
493            "",
494            "!!!",
495            "___",
496            "9",
497            "_a",
498            "a_",
499            "A__B",
500            "\u{0130}",
501            "\u{200b}llm",
502            &"_".repeat(200),
503            &format!("{}_x", "z".repeat(MAX_AGENT_PREFIX_LENGTH - 1)),
504        ] {
505            let sanitized = sanitize_prefix(prefix);
506            assert!(
507                AgentPrefix::parse(&sanitized).is_ok(),
508                "{prefix:?} sanitized to {sanitized:?}, which the grammar refuses"
509            );
510        }
511    }
512
513    #[test]
514    fn try_new_reports_what_new_would_have_rewritten() {
515        assert!(AgentId::try_new("llm_chat").is_ok());
516        for prefix in ["LLM Chat!", "", "123", "llm-chat"] {
517            assert!(
518                AgentId::try_new(prefix).is_err(),
519                "{prefix:?} was accepted verbatim"
520            );
521        }
522    }
523
524    #[test]
525    fn a_minted_id_always_parses_back() {
526        // `new` returning a value the crate's own parser refuses would be a
527        // quieter failure than the panic it replaced.
528        for prefix in ["llm_chat", "LLM Chat!", "123", "", &"a".repeat(200)] {
529            let minted = AgentId::new(prefix).to_string();
530            assert!(
531                AgentId::parse(&minted).is_ok(),
532                "{prefix:?} minted {minted:?}, which does not parse"
533            );
534        }
535    }
536
537    #[test]
538    fn parse_valid_id() {
539        let id = AgentId::parse("llm_chat_01h455vb4pex5vsknk084sn02q").unwrap();
540        assert_eq!(id.prefix().as_str(), "llm_chat");
541        assert_eq!(id.suffix(), "01h455vb4pex5vsknk084sn02q");
542    }
543
544    #[test]
545    fn parse_single_letter_prefix() {
546        // Vectors parse-015, aid-004, and len-001 (issue #20).
547        let id = AgentId::parse("a_01h455vb4pex5vsknk084sn02q").unwrap();
548        assert_eq!(id.prefix().as_str(), "a");
549        assert_eq!(id.suffix(), "01h455vb4pex5vsknk084sn02q");
550    }
551
552    #[test]
553    fn parse_simple_prefix() {
554        let id = AgentId::parse("llm_01h455vb4pex5vsknk084sn02q").unwrap();
555        assert_eq!(id.prefix().as_str(), "llm");
556    }
557
558    #[test]
559    fn parse_prefix_with_consecutive_underscores() {
560        // Vector aid-019 (issue #96). TypeID permits a run of underscores
561        // inside a prefix, and an agent ID is a TypeID, so refusing this would
562        // reject identifiers other implementations mint. Only the last
563        // underscore separates prefix from suffix.
564        let id = AgentId::parse("llm__chat_01h455vb4pex5vsknk084sn02q").unwrap();
565
566        assert_eq!(id.prefix().as_str(), "llm__chat");
567        assert_eq!(id.suffix(), "01h455vb4pex5vsknk084sn02q");
568    }
569
570    #[test]
571    fn parse_empty_fails() {
572        let result = AgentId::parse("");
573        assert!(matches!(result, Err(AgentIdError::Empty)));
574    }
575
576    #[test]
577    fn parse_too_long_fails() {
578        let long_prefix = "a".repeat(65);
579        let input = format!("{long_prefix}_01h455vb4pex5vsknk084sn02q");
580        let result = AgentId::parse(&input);
581        assert!(matches!(result, Err(AgentIdError::TooLong { .. })));
582    }
583
584    #[test]
585    fn parse_missing_separator_fails() {
586        let result = AgentId::parse("llm01h455vb4pex5vsknk084sn02q");
587        assert!(matches!(result, Err(AgentIdError::MissingSeparator)));
588    }
589
590    #[test]
591    fn parse_invalid_prefix_fails() {
592        let result = AgentId::parse("LLM_01h455vb4pex5vsknk084sn02q");
593        assert!(matches!(result, Err(AgentIdError::InvalidPrefix(_))));
594    }
595
596    #[test]
597    fn parse_suffix_wrong_length_fails() {
598        let result = AgentId::parse("llm_01h455vb4pex");
599        assert!(matches!(result, Err(AgentIdError::InvalidSuffix { .. })));
600    }
601
602    #[test]
603    fn parse_suffix_invalid_first_char_fails() {
604        // First char must be 0-7
605        let result = AgentId::parse("llm_91h455vb4pex5vsknk084sn02q");
606        assert!(matches!(result, Err(AgentIdError::InvalidSuffix { .. })));
607    }
608
609    #[test]
610    fn parse_suffix_invalid_base32_char_fails() {
611        // 'i' is not in base32 alphabet
612        let result = AgentId::parse("llm_01h455vb4pex5vsknk084sn0iq");
613        assert!(matches!(result, Err(AgentIdError::InvalidSuffix { .. })));
614    }
615
616    #[test]
617    fn a_non_ascii_char_cannot_impersonate_an_alphabet_byte() {
618        // 'İ' (U+0130) truncates to 0x30 under `as u8`, which is ASCII '0', a
619        // member of the alphabet, so a validator that casts before comparing
620        // reads it as a zero. Twenty-six characters and a valid first one, so
621        // neither the length check nor the first-character check can answer
622        // this: the alphabet check has to. Nothing downstream of this crate is
623        // entitled to catch it for us (issue #33).
624        let suffix = format!("0{}\u{0130}", "1".repeat(24));
625        assert_eq!(suffix.chars().count(), AGENT_SUFFIX_LENGTH);
626
627        let result = AgentId::parse(&format!("llm_{suffix}"));
628
629        let Err(AgentIdError::InvalidSuffix { reason, .. }) = result else {
630            panic!("a non-ASCII character in the suffix must be refused: {result:?}");
631        };
632        assert!(
633            reason.contains("base32"),
634            "refused for the wrong reason: {reason}"
635        );
636    }
637
638    #[test]
639    fn suffix_length_is_counted_in_characters() {
640        // Fourteen characters occupying twenty-six bytes. This is what made the
641        // truncation above reachable in the first place: a byte count is the
642        // only reading under which this string is the right length.
643        let suffix = format!("0{}1", "\u{0130}".repeat(12));
644        assert_eq!(suffix.len(), AGENT_SUFFIX_LENGTH);
645        assert_eq!(suffix.chars().count(), 14);
646
647        let result = AgentId::parse(&format!("llm_{suffix}"));
648
649        let Err(AgentIdError::InvalidSuffix { reason, .. }) = result else {
650            panic!("a fourteen-character suffix must be refused: {result:?}");
651        };
652        assert!(
653            reason.contains("26 characters"),
654            "refused for the wrong reason: {reason}"
655        );
656    }
657
658    #[test]
659    fn roundtrip_display_parse() {
660        let id1 = AgentId::new("llm_chat");
661        let display = id1.to_string();
662        let id2 = AgentId::parse(&display).unwrap();
663        assert_eq!(id1.prefix(), id2.prefix());
664        assert_eq!(id1.suffix(), id2.suffix());
665    }
666
667    #[test]
668    fn ordering_is_the_order_of_the_rendered_id() {
669        // The comparison no longer renders both sides to make it, so this
670        // pins that it still makes the same one.
671        let ids = [
672            AgentId::parse("a_01h455vb4pex5vsknk084sn02q").unwrap(),
673            AgentId::parse("llm_01h455vb4pex5vsknk084sn02q").unwrap(),
674            AgentId::parse("llm_chat_01h455vb4pex5vsknk084sn02q").unwrap(),
675            AgentId::parse("llm_01h455vb4pex5vsknk084sn03q").unwrap(),
676            AgentId::parse("rule_01h455vb4pex5vsknk084sn02q").unwrap(),
677        ];
678
679        for left in &ids {
680            for right in &ids {
681                assert_eq!(
682                    left.cmp(right),
683                    left.to_string().cmp(&right.to_string()),
684                    "{left} vs {right}"
685                );
686                assert_eq!(left.cmp(right) == Ordering::Equal, left == right);
687            }
688        }
689    }
690
691    #[test]
692    fn ordering_is_not_the_underlying_type_id_ordering() {
693        // `MagicTypeId` sorts by suffix first, which would group agents by
694        // when they were created rather than by what they are.
695        let older_rule = AgentId::parse("rule_01h455vb4pex5vsknk084sn02q").unwrap();
696        let newer_llm = AgentId::parse("llm_01h455vb4pex5vsknk084sn03q").unwrap();
697
698        assert!(newer_llm < older_rule);
699        assert!(older_rule.inner() < newer_llm.inner());
700    }
701}