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}