Skip to main content

sloop/
vendor_error.rs

1//! Data-driven classification of rejected agent requests. Catalogs describe
2//! evidence only; outcome and cooldown policy remain in the scheduler.
3
4use std::collections::HashSet;
5use std::fmt;
6
7use serde::{Deserialize, Serialize};
8use serde_json::{Map as JsonMap, Value as JsonValue};
9
10const SCHEMA_VERSION: u32 = 1;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum VendorErrorClass {
15    AuthenticationRequired,
16    InvalidConfiguration,
17    RateLimited,
18    UnknownRejection,
19}
20
21impl VendorErrorClass {
22    pub fn requires_cooldown(self) -> bool {
23        matches!(self, Self::RateLimited | Self::UnknownRejection)
24    }
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct VendorErrorMatch {
29    pub class: VendorErrorClass,
30    pub vendor: String,
31    pub rule_id: String,
32    pub diagnostic: String,
33}
34
35impl VendorErrorMatch {
36    pub fn evidence_json(&self, cooldown_until_ms: Option<i64>) -> String {
37        serde_json::json!({
38            "class": self.class,
39            "vendor": self.vendor,
40            "rule_id": self.rule_id,
41            "diagnostic": self.diagnostic,
42            "cooldown_until_ms": cooldown_until_ms,
43        })
44        .to_string()
45    }
46}
47
48#[derive(Debug, Clone)]
49pub struct VendorErrorClassifier {
50    rules: Vec<Rule>,
51}
52
53impl VendorErrorClassifier {
54    pub fn built_in() -> Result<Self, CatalogError> {
55        Self::from_yaml(&[
56            ("codex", include_str!("codex/errors.yaml")),
57            ("opencode", include_str!("opencode/errors.yaml")),
58            ("claude", include_str!("claude/errors.yaml")),
59        ])
60    }
61
62    fn from_yaml(catalogs: &[(&str, &str)]) -> Result<Self, CatalogError> {
63        let mut rules = Vec::new();
64        let mut ids = HashSet::new();
65        for (expected_vendor, yaml) in catalogs {
66            let catalog: Catalog = serde_yaml::from_str(yaml).map_err(|error| {
67                CatalogError(format!("invalid {expected_vendor} error catalog: {error}"))
68            })?;
69            if catalog.version != SCHEMA_VERSION {
70                return Err(CatalogError(format!(
71                    "unsupported {} error catalog schema version {}; expected {SCHEMA_VERSION}",
72                    catalog.vendor, catalog.version
73                )));
74            }
75            if catalog.vendor != *expected_vendor {
76                return Err(CatalogError(format!(
77                    "error catalog vendor `{}` does not match `{expected_vendor}`",
78                    catalog.vendor
79                )));
80            }
81            for raw in catalog.rules {
82                if raw.id.trim().is_empty() {
83                    return Err(CatalogError(format!(
84                        "{expected_vendor} error catalog contains an empty rule ID"
85                    )));
86                }
87                if !ids.insert(raw.id.clone()) {
88                    return Err(CatalogError(format!(
89                        "duplicate vendor error rule ID `{}`",
90                        raw.id
91                    )));
92                }
93                if raw.diagnostic.trim().is_empty() {
94                    return Err(CatalogError(format!(
95                        "vendor error rule `{}` has an empty diagnostic",
96                        raw.id
97                    )));
98                }
99                if raw.conditions.message_signatures.is_empty() {
100                    return Err(CatalogError(format!(
101                        "vendor error rule `{}` has no message signatures",
102                        raw.id
103                    )));
104                }
105                if raw
106                    .conditions
107                    .message_signatures
108                    .iter()
109                    .any(|signature| signature.is_empty())
110                {
111                    return Err(CatalogError(format!(
112                        "vendor error rule `{}` has an empty message signature",
113                        raw.id
114                    )));
115                }
116                if !raw.conditions.record_any_of.is_empty() && catalog.framing.is_none() {
117                    return Err(CatalogError(format!(
118                        "vendor error rule `{}` selects records, but the {expected_vendor} \
119                         catalog declares no framing",
120                        raw.id
121                    )));
122                }
123                if raw
124                    .conditions
125                    .record_any_of
126                    .iter()
127                    .any(|predicate| predicate.is_empty())
128                {
129                    return Err(CatalogError(format!(
130                        "vendor error rule `{}` has an empty record predicate, which would \
131                         select every record",
132                        raw.id
133                    )));
134                }
135                rules.push(Rule {
136                    vendor: catalog.vendor.clone(),
137                    id: raw.id,
138                    class: raw.class,
139                    diagnostic: raw.diagnostic,
140                    framing: catalog.framing,
141                    conditions: raw.conditions,
142                });
143            }
144        }
145        Ok(Self { rules })
146    }
147
148    pub fn classify(
149        &self,
150        exit_status: Option<i32>,
151        stdout: &[u8],
152        stderr: &[u8],
153    ) -> Option<VendorErrorMatch> {
154        let mut scanner = self.scanner(exit_status);
155        scanner.feed_stdout(stdout);
156        scanner.feed_stderr(stderr);
157        scanner.finish()
158    }
159
160    pub fn scanner(&self, exit_status: Option<i32>) -> VendorErrorScanner<'_> {
161        let framed = self.rules.iter().any(|rule| rule.framing.is_some());
162        VendorErrorScanner {
163            classifier: self,
164            exit_status,
165            states: self
166                .rules
167                .iter()
168                .map(|rule| RuleStates {
169                    stdout: ConditionState::new(&rule.conditions),
170                    stderr: ConditionState::new(&rule.conditions),
171                })
172                .collect(),
173            pending: framed.then(PendingLines::default),
174        }
175    }
176}
177
178pub struct VendorErrorScanner<'a> {
179    classifier: &'a VendorErrorClassifier,
180    exit_status: Option<i32>,
181    states: Vec<RuleStates>,
182    /// Partial trailing line per stream, kept only when some rule is framed.
183    pending: Option<PendingLines>,
184}
185
186#[derive(Default)]
187struct PendingLines {
188    stdout: Vec<u8>,
189    stderr: Vec<u8>,
190}
191
192impl PendingLines {
193    fn get_mut(&mut self, stream: Stream) -> &mut Vec<u8> {
194        match stream {
195            Stream::Stdout => &mut self.stdout,
196            Stream::Stderr => &mut self.stderr,
197        }
198    }
199}
200
201impl VendorErrorScanner<'_> {
202    pub fn feed_stdout(&mut self, bytes: &[u8]) {
203        self.feed(Stream::Stdout, bytes);
204    }
205
206    pub fn feed_stderr(&mut self, bytes: &[u8]) {
207        self.feed(Stream::Stderr, bytes);
208    }
209
210    fn feed(&mut self, stream: Stream, bytes: &[u8]) {
211        self.feed_rules(stream, bytes, |rule| rule.framing.is_none(), None);
212        if self.pending.is_none() {
213            return;
214        }
215        let mut lines = Vec::new();
216        let buffer = self
217            .pending
218            .as_mut()
219            .expect("checked above")
220            .get_mut(stream);
221        buffer.extend_from_slice(bytes);
222        while let Some(index) = buffer.iter().position(|byte| *byte == b'\n') {
223            let mut line: Vec<u8> = buffer.drain(..=index).collect();
224            line.pop();
225            lines.push(line);
226        }
227        for line in lines {
228            self.feed_framed_line(stream, &line);
229        }
230    }
231
232    /// Feed one framed record to the rules allowed to read it.
233    fn feed_framed_line(&mut self, stream: Stream, line: &[u8]) {
234        let record = serde_json::from_slice::<JsonMap<String, JsonValue>>(line).ok();
235        self.feed_rules(
236            stream,
237            line,
238            |rule| rule.framing.is_some(),
239            Some(record.as_ref()),
240        );
241    }
242
243    fn feed_rules(
244        &mut self,
245        stream: Stream,
246        bytes: &[u8],
247        selects: impl Fn(&Rule) -> bool,
248        record: Option<Option<&JsonMap<String, JsonValue>>>,
249    ) {
250        for (rule, states) in self.classifier.rules.iter().zip(&mut self.states) {
251            if !selects(rule) {
252                continue;
253            }
254            if rule.conditions.stream.is_some() && rule.conditions.stream != Some(stream) {
255                continue;
256            }
257            if let Some(record) = record
258                && !rule.record_eligible(record)
259            {
260                continue;
261            }
262            states.get_mut(stream).feed(&rule.conditions, bytes, false);
263        }
264    }
265
266    pub fn finish(mut self) -> Option<VendorErrorMatch> {
267        if self.pending.is_some() {
268            for stream in [Stream::Stdout, Stream::Stderr] {
269                let rest = std::mem::take(
270                    self.pending
271                        .as_mut()
272                        .expect("checked above")
273                        .get_mut(stream),
274                );
275                if !rest.is_empty() {
276                    self.feed_framed_line(stream, &rest);
277                }
278            }
279        }
280        for (rule, states) in self.classifier.rules.iter().zip(&mut self.states) {
281            if !rule.matches_exit(self.exit_status) {
282                continue;
283            }
284            states.stdout.feed(&rule.conditions, &[], true);
285            states.stderr.feed(&rule.conditions, &[], true);
286            let matched = match rule.conditions.stream {
287                Some(Stream::Stdout) => states.stdout.matched(),
288                Some(Stream::Stderr) => states.stderr.matched(),
289                None => states.stdout.matched() || states.stderr.matched(),
290            };
291            if matched {
292                return Some(VendorErrorMatch {
293                    class: rule.class,
294                    vendor: rule.vendor.clone(),
295                    rule_id: rule.id.clone(),
296                    diagnostic: rule.diagnostic.clone(),
297                });
298            }
299        }
300        None
301    }
302}
303
304#[derive(Debug)]
305pub struct CatalogError(String);
306
307impl fmt::Display for CatalogError {
308    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
309        formatter.write_str(&self.0)
310    }
311}
312
313impl std::error::Error for CatalogError {}
314
315#[derive(Debug, Deserialize)]
316#[serde(deny_unknown_fields)]
317struct Catalog {
318    version: u32,
319    vendor: String,
320    #[serde(default)]
321    framing: Option<Framing>,
322    rules: Vec<RawRule>,
323}
324
325/// How a target frames its output.
326///
327/// Without this the classifier sees one undifferentiated byte stream, so an
328/// agent that merely *reads* a file containing a vendor's rejection wording
329/// looks identical to the vendor rejecting the request. Declaring the framing
330/// lets a rule say which records the vendor itself authored.
331#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
332#[serde(rename_all = "snake_case")]
333enum Framing {
334    /// One JSON object per line. Lines that do not parse are plain text and
335    /// stay eligible, so buffered output classifies exactly as before.
336    Ndjson,
337}
338
339#[derive(Debug, Deserialize)]
340#[serde(deny_unknown_fields)]
341struct RawRule {
342    id: String,
343    class: VendorErrorClass,
344    diagnostic: String,
345    #[serde(rename = "match")]
346    conditions: MatchConditions,
347}
348
349#[derive(Debug, Clone)]
350struct Rule {
351    vendor: String,
352    id: String,
353    class: VendorErrorClass,
354    diagnostic: String,
355    framing: Option<Framing>,
356    conditions: MatchConditions,
357}
358
359impl Rule {
360    fn matches_exit(&self, exit_status: Option<i32>) -> bool {
361        self.conditions
362            .exit_status
363            .is_none_or(|expected| exit_status == Some(expected))
364    }
365
366    /// Whether one framed record's bytes may be classified by this rule.
367    ///
368    /// `record` is `None` for a line that is not a JSON object, which is plain
369    /// text rather than a stream record and stays eligible. Predicates compare
370    /// against *top-level* fields only: that is what separates the vendor
371    /// speaking from an agent quoting it, because a quoted rejection arrives
372    /// as an escaped string inside some other record's field, never as fields
373    /// of the enclosing record.
374    fn record_eligible(&self, record: Option<&JsonMap<String, JsonValue>>) -> bool {
375        let Some(record) = record else {
376            return true;
377        };
378        self.conditions.record_any_of.is_empty()
379            || self.conditions.record_any_of.iter().any(|predicate| {
380                predicate
381                    .iter()
382                    .all(|(field, expected)| record.get(field) == Some(expected))
383            })
384    }
385}
386
387#[derive(Debug, Clone, Deserialize)]
388#[serde(deny_unknown_fields)]
389struct MatchConditions {
390    #[serde(default)]
391    exit_status: Option<i32>,
392    #[serde(default)]
393    stream: Option<Stream>,
394    #[serde(default)]
395    status_code: Option<u16>,
396    #[serde(default)]
397    error_code: Option<String>,
398    /// Top-level field predicates selecting the records this rule may read.
399    /// Empty means every record; otherwise any one predicate matching is
400    /// enough, and all of that predicate's fields must be equal.
401    #[serde(default)]
402    record_any_of: Vec<JsonMap<String, JsonValue>>,
403    message_signatures: Vec<String>,
404}
405
406impl MatchConditions {
407    fn overlap_len(&self) -> usize {
408        let signatures = self
409            .message_signatures
410            .iter()
411            .map(|signature| signature.len());
412        let status = self
413            .status_code
414            .into_iter()
415            .flat_map(status_code_patterns)
416            .map(|pattern| pattern.len());
417        let error = self
418            .error_code
419            .as_deref()
420            .into_iter()
421            .flat_map(error_code_patterns)
422            .map(|pattern| pattern.len());
423        signatures.chain(status).chain(error).max().unwrap_or(1)
424    }
425}
426
427#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
428#[serde(rename_all = "snake_case")]
429enum Stream {
430    Stdout,
431    Stderr,
432}
433
434struct RuleStates {
435    stdout: ConditionState,
436    stderr: ConditionState,
437}
438
439impl RuleStates {
440    fn get_mut(&mut self, stream: Stream) -> &mut ConditionState {
441        match stream {
442            Stream::Stdout => &mut self.stdout,
443            Stream::Stderr => &mut self.stderr,
444        }
445    }
446}
447
448struct ConditionState {
449    status_found: bool,
450    error_found: bool,
451    signatures_found: Vec<bool>,
452    tail: Vec<u8>,
453    overlap_len: usize,
454}
455
456impl ConditionState {
457    fn new(conditions: &MatchConditions) -> Self {
458        Self {
459            status_found: conditions.status_code.is_none(),
460            error_found: conditions.error_code.is_none(),
461            signatures_found: vec![false; conditions.message_signatures.len()],
462            tail: Vec::new(),
463            overlap_len: conditions.overlap_len(),
464        }
465    }
466
467    fn feed(&mut self, conditions: &MatchConditions, bytes: &[u8], final_chunk: bool) {
468        let mut window = std::mem::take(&mut self.tail);
469        window.extend_from_slice(bytes);
470        if !self.status_found
471            && let Some(code) = conditions.status_code
472        {
473            self.status_found = contains_status_code(&window, code, final_chunk);
474        }
475        if !self.error_found
476            && let Some(code) = conditions.error_code.as_deref()
477        {
478            self.error_found = contains_error_code(&window, code, final_chunk);
479        }
480        for (found, signature) in self
481            .signatures_found
482            .iter_mut()
483            .zip(&conditions.message_signatures)
484        {
485            *found |= contains(&window, signature.as_bytes());
486        }
487        // Keep one full pattern length because status/error codes also need
488        // the following byte to prove their token boundary.
489        let keep = self.overlap_len.min(window.len());
490        self.tail.extend_from_slice(&window[window.len() - keep..]);
491    }
492
493    fn matched(&self) -> bool {
494        self.status_found && self.error_found && self.signatures_found.iter().all(|found| *found)
495    }
496}
497
498fn contains(bytes: &[u8], needle: &[u8]) -> bool {
499    !needle.is_empty() && bytes.windows(needle.len()).any(|window| window == needle)
500}
501
502fn status_code_patterns(code: u16) -> Vec<String> {
503    let code = code.to_string();
504    vec![
505        format!("status {code}"),
506        format!("status: {code}"),
507        format!("status={code}"),
508        format!("\"status\":{code}"),
509        format!("\"status\": {code}"),
510        format!("\"status_code\":{code}"),
511        format!("\"statuscode\":{code}"),
512    ]
513}
514
515fn contains_status_code(bytes: &[u8], code: u16, allow_end: bool) -> bool {
516    let text = String::from_utf8_lossy(bytes).to_ascii_lowercase();
517    status_code_patterns(code).iter().any(|pattern| {
518        contains_with_boundary(&text, pattern, allow_end, |byte| byte.is_ascii_digit())
519    })
520}
521
522fn error_code_patterns(code: &str) -> Vec<String> {
523    let code = code.to_ascii_lowercase();
524    vec![
525        format!("code {code}"),
526        format!("code: {code}"),
527        format!("code={code}"),
528        format!("\"code\":\"{code}\""),
529        format!("\"code\": \"{code}\""),
530        format!("\"error_code\":\"{code}\""),
531    ]
532}
533
534fn contains_error_code(bytes: &[u8], code: &str, allow_end: bool) -> bool {
535    let text = String::from_utf8_lossy(bytes).to_ascii_lowercase();
536    error_code_patterns(code).iter().any(|pattern| {
537        contains_with_boundary(&text, pattern, allow_end, |byte| {
538            byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')
539        })
540    })
541}
542
543fn contains_with_boundary(
544    haystack: &str,
545    needle: &str,
546    allow_end: bool,
547    continues_value: impl Fn(u8) -> bool,
548) -> bool {
549    haystack.match_indices(needle).any(|(start, _)| {
550        haystack
551            .as_bytes()
552            .get(start + needle.len())
553            .map_or(allow_end, |byte| !continues_value(*byte))
554    })
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    const VALID: &str = r#"
562version: 1
563vendor: test
564rules:
565  - id: test.rate-limit
566    class: rate_limited
567    diagnostic: Request rate limited
568    match:
569      exit_status: 1
570      stream: stderr
571      status_code: 429
572      error_code: rate_limit_exceeded
573      message_signatures: ["try again later"]
574"#;
575
576    #[test]
577    fn built_in_catalogs_validate_and_match_captured_vendor_fixtures() {
578        let classifier = VendorErrorClassifier::built_in().unwrap();
579        let cases = [
580            (
581                br#"Error: status 401 Missing bearer or basic authentication in header"#.as_slice(),
582                VendorErrorClass::AuthenticationRequired,
583                "codex.authentication.missing-header",
584            ),
585            (
586                br#"request failed: status: 400 model is not supported when using Codex with a ChatGPT account"#.as_slice(),
587                VendorErrorClass::InvalidConfiguration,
588                "codex.configuration.unsupported-chatgpt-model",
589            ),
590            (
591                br#"UnknownError: Unexpected server error. Check server logs for details."#.as_slice(),
592                VendorErrorClass::UnknownRejection,
593                "opencode.rejection.unexpected-server-error",
594            ),
595            (
596                br#"API Error: You've hit your limit; resets 12am (UTC)"#.as_slice(),
597                VendorErrorClass::RateLimited,
598                "claude.rate-limit.usage-limit",
599            ),
600        ];
601        for (stderr, class, id) in cases {
602            let matched = classifier.classify(Some(1), b"", stderr).unwrap();
603            assert_eq!(matched.class, class);
604            assert_eq!(matched.rule_id, id);
605        }
606    }
607
608    /// Verbatim stdout line captured from a rejected `claude` run, whose
609    /// `--output-format stream-json` command reports the vendor's refusal as a
610    /// synthetic assistant message rather than as stderr text.
611    const CLAUDE_SESSION_LIMIT_STDOUT: &str = r#"{"type":"assistant","message":{"id":"462199bc-1a76-4c09-aaff-df681c45b92c","model":"<synthetic>","role":"assistant","stop_reason":"stop_sequence","stop_sequence":"","type":"message","content":[{"type":"text","text":"You've hit your session limit · resets 12:50pm (Australia/Sydney)"}]},"session_id":"7f4420d2-0557-4535-849c-975a1adda3bc","error":"rate_limit","is_api_error_message":true}"#;
612
613    /// The signature is searched in the raw captured bytes, so it has to be
614    /// found inside the JSON payload without the catalog knowing the envelope.
615    #[test]
616    fn a_claude_session_limit_on_stdout_is_rate_limited() {
617        let classifier = VendorErrorClassifier::built_in().unwrap();
618        let matched = classifier
619            .classify(Some(1), CLAUDE_SESSION_LIMIT_STDOUT.as_bytes(), b"")
620            .expect("the captured session limit classifies");
621        assert_eq!(matched.class, VendorErrorClass::RateLimited);
622        assert_eq!(matched.rule_id, "claude.rate-limit.usage-limit");
623        assert!(matched.class.requires_cooldown());
624
625        // The earlier plain-limit wording keeps matching, on either stream.
626        let plain = b"API Error: You've hit your limit; resets 12am (UTC)".as_slice();
627        assert!(classifier.classify(Some(1), plain, b"").is_some());
628        assert!(classifier.classify(Some(1), b"", plain).is_some());
629        assert!(
630            classifier
631                .classify(Some(1), b"error: could not compile `sloop`", b"")
632                .is_none()
633        );
634    }
635
636    /// The vendor's own verdict record, which accompanies the synthetic
637    /// message above and carries the same wording as its whole result.
638    const CLAUDE_LIMIT_RESULT: &str = r#"{"type":"result","subtype":"success","is_error":true,"num_turns":49,"result":"You've hit your session limit · resets 12:50pm (Australia/Sydney)"}"#;
639
640    /// Records a *successful* run produces when its agent works on this file.
641    /// Each quotes the limit wording; none of them is the vendor speaking.
642    /// Shapes and nesting are taken from runs that were wrongly discarded.
643    const AGENT_QUOTING_THE_LIMIT: &[&str] = &[
644        // Reading a file whose fixtures contain a verbatim rejection. The
645        // escaped payload carries `is_api_error_message` and an `error` field,
646        // but as string content, not as fields of this record.
647        r##"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"const CLAUDE_SESSION_LIMIT_STDOUT: &str = r#\"{\"type\":\"assistant\",\"message\":{\"content\":[{\"text\":\"You've hit your session limit\"}]},\"error\":\"rate_limit\",\"is_api_error_message\":true}\"#;"}]}}"##,
648        // Searching for the wording.
649        r#"{"type":"assistant","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"tool_use","id":"toolu_02","name":"Bash","input":{"command":"rg -n \"You've hit your\" src/","description":"Find the limit signature"}}]}}"#,
650        // Explaining the wording in prose.
651        r#"{"type":"assistant","message":{"role":"assistant","model":"claude-opus-5","content":[{"type":"text","text":"The catalog matches on \"You've hit your\", which the repository also contains."}]}}"#,
652        // Reporting on the work, in a run that succeeded.
653        r#"{"type":"result","subtype":"success","is_error":false,"num_turns":113,"result":"Done. The rule keys on \"You've hit your\" and now also checks record structure."}"#,
654    ];
655
656    /// The bug this guards: an agent that reads a rejection is not a rejection.
657    ///
658    /// Both runs that provoked this classified as rate limited, were discarded
659    /// despite exiting cleanly with work committed, and put the whole target
660    /// into cooldown. Every match they produced was tool traffic.
661    #[test]
662    fn an_agent_reading_or_quoting_a_limit_is_not_a_limit() {
663        let classifier = VendorErrorClassifier::built_in().unwrap();
664        for record in AGENT_QUOTING_THE_LIMIT {
665            let stdout = format!("{record}\n");
666            assert!(
667                classifier
668                    .classify(Some(0), stdout.as_bytes(), b"")
669                    .is_none(),
670                "agent-authored record classified as a vendor rejection: {record}"
671            );
672        }
673
674        // A whole run's worth of them, interleaved and split across chunk
675        // boundaries that fall inside records, still classifies as clean.
676        let transcript = format!("{}\n", AGENT_QUOTING_THE_LIMIT.join("\n"));
677        let mut scanner = classifier.scanner(Some(0));
678        for chunk in transcript.as_bytes().chunks(17) {
679            scanner.feed_stdout(chunk);
680        }
681        assert!(scanner.finish().is_none());
682    }
683
684    /// The vendor's records still classify, including when the run exits 0 and
685    /// when the transcript around them is full of agent quotations.
686    #[test]
687    fn a_vendor_authored_limit_still_classifies_among_agent_traffic() {
688        let classifier = VendorErrorClassifier::built_in().unwrap();
689        for genuine in [CLAUDE_SESSION_LIMIT_STDOUT, CLAUDE_LIMIT_RESULT] {
690            let matched = classifier
691                .classify(Some(0), format!("{genuine}\n").as_bytes(), b"")
692                .expect("a vendor-authored limit classifies");
693            assert_eq!(matched.rule_id, "claude.rate-limit.usage-limit");
694
695            let mut transcript = AGENT_QUOTING_THE_LIMIT.join("\n");
696            transcript.push('\n');
697            transcript.push_str(genuine);
698            assert!(
699                classifier
700                    .classify(Some(0), transcript.as_bytes(), b"")
701                    .is_some(),
702                "a real limit was masked by surrounding agent traffic"
703            );
704        }
705    }
706
707    /// Targets that do not stream JSON emit the rejection as a bare line, and
708    /// unframed catalogs keep scanning the whole stream.
709    #[test]
710    fn unframed_output_and_catalogs_are_unaffected_by_framing() {
711        let classifier = VendorErrorClassifier::built_in().unwrap();
712        let plain = b"You've hit your session limit \xc2\xb7 resets 12am (UTC)".as_slice();
713        assert!(classifier.classify(Some(1), plain, b"").is_some());
714        assert!(classifier.classify(Some(1), b"", plain).is_some());
715
716        // An unframed catalog's conditions may still be spread across lines.
717        let opencode = b"UnknownError:\nUnexpected server error. Check server logs for details.";
718        assert!(classifier.classify(Some(1), b"", opencode).is_some());
719    }
720
721    #[test]
722    fn schema_validation_rejects_record_predicates_without_framing() {
723        let selecting = VALID.replace(
724            "      message_signatures:",
725            "      record_any_of:\n        - is_api_error_message: true\n      message_signatures:",
726        );
727        let error = VendorErrorClassifier::from_yaml(&[("test", &selecting)])
728            .unwrap_err()
729            .to_string();
730        assert!(error.contains("declares no framing"), "{error}");
731
732        let framed = format!("framing: ndjson\n{selecting}");
733        assert!(VendorErrorClassifier::from_yaml(&[("test", &framed)]).is_ok());
734
735        let empty = framed.replace("- is_api_error_message: true", "- {}");
736        let error = VendorErrorClassifier::from_yaml(&[("test", &empty)])
737            .unwrap_err()
738            .to_string();
739        assert!(error.contains("empty record predicate"), "{error}");
740    }
741
742    #[test]
743    fn schema_validation_rejects_unknown_classes_duplicate_ids_and_empty_signatures() {
744        let unknown = VALID.replace("rate_limited", "retry_someday");
745        assert!(VendorErrorClassifier::from_yaml(&[("test", &unknown)]).is_err());
746
747        let duplicate = format!("{VALID}\n{}", "");
748        assert!(
749            VendorErrorClassifier::from_yaml(&[("test", &duplicate), ("test", VALID)]).is_err()
750        );
751
752        let empty = VALID.replace("[\"try again later\"]", "[\"\"]");
753        let error = VendorErrorClassifier::from_yaml(&[("test", &empty)])
754            .unwrap_err()
755            .to_string();
756        assert!(error.contains("empty message signature"), "{error}");
757    }
758
759    #[test]
760    fn all_conditions_must_match_in_one_selected_stream() {
761        let classifier = VendorErrorClassifier::from_yaml(&[("test", VALID)]).unwrap();
762        let matching =
763            br#"{"status":429,"code":"rate_limit_exceeded","message":"try again later"}"#;
764        assert!(classifier.classify(Some(1), b"", matching).is_some());
765        assert!(classifier.classify(Some(0), b"", matching).is_none());
766        assert!(classifier.classify(Some(1), matching, b"").is_none());
767        assert!(
768            classifier
769                .classify(
770                    Some(1),
771                    br#"{"status":429,"code":"rate_limit_exceeded"}"#,
772                    b"try again later"
773                )
774                .is_none()
775        );
776
777        let mut scanner = classifier.scanner(Some(1));
778        for chunk in [
779            b"{\"sta".as_slice(),
780            b"tus\":429,\"code\":\"rate_",
781            b"limit_exceeded\",\"message\":\"try ag",
782            b"ain later\"}",
783        ] {
784            scanner.feed_stderr(chunk);
785        }
786        assert!(scanner.finish().is_some());
787    }
788
789    #[test]
790    fn duplicate_signatures_binary_bytes_and_near_matches_are_safe() {
791        let classifier = VendorErrorClassifier::from_yaml(&[("test", VALID)]).unwrap();
792        let mut bytes = vec![0xff, 0x00];
793        bytes.extend_from_slice(
794            br#"status 429 code rate_limit_exceeded try again later try again later"#,
795        );
796        assert!(classifier.classify(Some(1), b"", &bytes).is_some());
797        assert!(
798            classifier
799                .classify(
800                    Some(1),
801                    b"",
802                    b"status 429 code rate_limit_exceeded try again much later"
803                )
804                .is_none()
805        );
806        assert!(
807            classifier
808                .classify(Some(1), b"", b"plain number 429")
809                .is_none()
810        );
811        assert!(
812            classifier
813                .classify(
814                    Some(1),
815                    b"",
816                    b"status 4290 code rate_limit_exceeded try again later"
817                )
818                .is_none()
819        );
820    }
821}