Skip to main content

switchyard_libsy/algorithms/util/
tool_signals.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Tool-result context signals extracted from the conversation history.
5//!
6//! The extractor walks normalized messages, finds tool calls and results,
7//! reads explicit failure flags, matches text against an error table, and aggregates
8//! conversation-history metrics used by [`crate::StageRouter`] and the
9//! advisor gate's request-side guards.
10//!
11//! All logic is pure and deterministic — no I/O, no shared state.
12
13#![allow(dead_code)]
14
15use std::collections::HashSet;
16use std::path::Path;
17
18use async_trait::async_trait;
19use serde::Deserialize;
20use serde_json::Value;
21use switchyard_protocol::codex_namespaces::{split_qualified_name, tool_namespaces};
22use switchyard_protocol::{ContentBlock, Request, Role, WireFormat};
23
24use crate::{LibsyError, Result};
25
26use crate::core::processor::{Event, Processor};
27use crate::core::state::State;
28
29// ─── severity constants ───────────────────────────────────────────────────────
30
31const SOFT: f32 = 0.3;
32const HARD: f32 = 0.7;
33const CRITICAL: f32 = 1.0;
34
35// ─── pattern table ────────────────────────────────────────────────────────────
36
37/// (name, severity, lower-cased substrings — any hit fires the pattern)
38static ERROR_PATTERNS: &[(&str, f32, &[&str])] = &[
39    (
40        "oom",
41        CRITICAL,
42        &["out of memory", "memoryerror", "cannot allocate memory"],
43    ),
44    (
45        "connection_refused",
46        HARD,
47        &[
48            "connection refused",
49            "connectionrefusederror",
50            "econnrefused",
51        ],
52    ),
53    ("traceback", HARD, &["traceback (most recent call last)"]),
54    (
55        "import_error",
56        HARD,
57        &["modulenotfounderror:", "importerror:", "no module named "],
58    ),
59    (
60        "cmd_not_found",
61        HARD,
62        &["command not found", "not found\n", "/usr/bin/env: "],
63    ),
64    ("assertion", HARD, &["assertionerror"]),
65    ("value_error", HARD, &["valueerror:"]),
66    ("syntax_error", HARD, &["syntaxerror:"]),
67    (
68        "timeout",
69        HARD,
70        &[
71            "timed out",
72            "timeouterror",
73            "timeout expired",
74            "deadline exceeded",
75        ],
76    ),
77    (
78        "no_such_file",
79        HARD,
80        &[
81            "filenotfounderror:",
82            "no such file or directory",
83            // Claude Code Read-tool miss. Anchored as "file does not exist" (not a
84            // bare "does not exist", which fires on `ls` output and prose) — trace-
85            // mined across 1006 local trajectories at 22 true / 2 false positives.
86            "file does not exist",
87        ],
88    ),
89    // SOFT: plain non-zero exit without a recognisable exception traceback.
90    ("exit_nonzero", SOFT, &["returned non-zero"]),
91];
92
93static NONZERO_EXIT_PHRASES: &[&str] = &[
94    "exit code",
95    "exit status",
96    "exited with code",
97    "exited with status",
98];
99
100static EDIT_TOOL_NAMES: &[&str] = &[
101    "edit",
102    "multiedit",
103    "notebookedit",
104    "str_replace",
105    "str_replace_based_edit_tool",
106    "apply_patch", // codex's edit tool
107    "text_editor",
108    "patch", // hermes's str_replace-style edit tool
109];
110
111/// Editor tools whose `command` argument picks the action. `view` only reads.
112static EDITOR_TOOL_NAMES: &[&str] = &["str_replace_based_edit_tool", "text_editor"];
113
114static WRITE_TOOL_NAMES: &[&str] = &["write", "create_file", "new_file", "write_file"];
115
116// Bash subcommand patterns. Lowercased; callers must lowercase the command
117// before matching. Bucketed into write_count / edit_count alongside the
118// dedicated `Write` / `Edit` tools.
119static BASH_WRITE_PATTERNS: &[&str] = &[
120    "cat >",
121    "cat >>",
122    "echo >",
123    "echo >>",
124    "tee ",
125    "printf >",
126    "printf >>",
127    "> /",
128    ">> /",
129    "<< 'eof'",
130    "<<eof",
131    "<<'eof'",
132    "<< eof",
133];
134
135/// Python file-write expressions, which only indicate a write when an
136/// interpreter is running them rather than a search looking for them.
137static PYTHON_WRITE_PATTERNS: &[&str] = &["write_text(", "writelines(", ".write("];
138
139static JAVASCRIPT_WRITE_PATTERNS: &[&str] = &[
140    "writefilesync(",
141    "writefile(",
142    "appendfilesync(",
143    "appendfile(",
144];
145
146static BASH_EDIT_PATTERNS: &[&str] = &[
147    "sed -i",
148    "sed --in-place",
149    "awk -i inplace",
150    "awk 'inplace=1'",
151    "patch ",
152    "patch -p",
153    "perl -i",
154    "perl -p -i",
155    "perl -pi",
156];
157
158// Read-like Bash inspections. Match only when none of the write/edit patterns
159// fire (redirection / in-place edit trumps the read intent of the command).
160static BASH_READ_PATTERNS: &[&str] = &[
161    "cat /", "cat ./", "cat ../", "grep ", "ls ", "ls -", "find ", "head ", "tail ", "wc ",
162    "diff ", "which ", "ps ", "df ", "du ", "stat ", "file ", "less ", "more ",
163];
164
165/// Read-only shell programs seen in Codex trajectories. Matching is limited to
166/// command-segment starts so prose and arguments do not masquerade as actions.
167static BASH_READ_COMMANDS: &[&str] = &[
168    "cat", "rg", "nl", "jq", "pwd", "tree", "sed", "grep", "ls", "find", "head", "tail", "wc",
169    "diff", "which", "ps", "df", "du", "stat", "file", "less", "more", "readlink", "realpath",
170    "basename", "dirname", "printenv",
171];
172
173static GIT_READ_SUBCOMMANDS: &[&str] = &[
174    "status",
175    "diff",
176    "log",
177    "show",
178    "show-ref",
179    "rev-parse",
180    "ls-files",
181    "ls-remote",
182    "ls-tree",
183    "grep",
184    "blame",
185    "merge-base",
186    "check-ignore",
187    "tag",
188];
189
190static READ_TOOL_NAMES: &[&str] = &[
191    "read",
192    "view",
193    "read_file",
194    "search_files",
195    "glob",
196    "grep",
197    "find",
198    "ls",
199];
200
201// Planning / scratchpad tool calls — investigative (non-producing) activity.
202// `update_plan` is codex's equivalent of `todowrite`.
203static PLAN_TOOL_NAMES: &[&str] = &[
204    "todowrite",
205    "todo_write",
206    "todo",
207    "update_plan",
208    "todo_list",
209];
210
211// Tool names that route through Bash-command pattern matching. `bash` is
212// claude-code's name; `shell_command` is codex's; `shell` / `local_shell_call`
213// are seen on some OpenAI-derived harnesses; `terminal` is hermes's (it carries
214// a `command` arg like the others, so its intent comes from the pattern match).
215static BASH_TOOL_NAMES: &[&str] = &[
216    "bash",
217    "shell_command",
218    "shell",
219    "local_shell_call",
220    "terminal",
221    "exec_command", // codex
222    "exec",         // openclaw
223    "powershell",   // pi on Windows
224];
225
226// Prefer false negatives: tests_passed clears a capable hold, so a false positive
227// could hand an unfinished task back too early.
228static TEST_PASS_PHRASES: &[&str] = &[
229    " passed",
230    "passed in",
231    "tests passed",
232    "all tests passed",
233    "test ok",
234    "test result: ok",
235    "passed.\n",
236    "tests pass",
237    "\nok ", // go test; newline-anchored to avoid "...lookup..." mid-text
238    "✓ ",
239];
240
241// Literal failure phrases that cannot appear inside a clean run. Substring
242// matched as-is. Patterns that pair with a count (e.g. "failed", "errors")
243// are handled separately by `has_nonzero_failure_count` so "0 failed" /
244// "0 errors" do not trigger a false negative.
245static TEST_FAILURE_LITERAL: &[&str] = &["✗ ", "fatal:", "assertionerror", "error:"];
246
247// Count-prefixed failure keywords. Trip only when a nonzero integer precedes
248// the keyword (modulo whitespace), so cargo's "0 failed" and go's
249// "0 errors" summaries on a clean run are not misread as failures.
250static NUMERIC_FAILURE_KEYWORDS: &[&str] = &["failed", "failure", "failures", "errors", "error"];
251
252/// Default sliding-window size for `recent_*` counts and windowed severity.
253///
254/// A short horizon captures "what is the agent doing right now" while keeping
255/// signals sticky — an error or stall persists a few recovery turns instead of
256/// flickering off the moment one clean result lands. Override per request by
257/// passing a window to [`ToolSignals::from_request`].
258pub const DEFAULT_RECENT_WINDOW: usize = 3;
259
260/// Exact tool-name semantics added to the built-in vocabulary.
261///
262/// Matching is ASCII case-insensitive. An MCP or Codex namespaced tool also
263/// matches by its bare tool name. These lists are additive: built-in tool names
264/// cannot be reclassified.
265#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
266#[serde(default, deny_unknown_fields)]
267pub struct ToolSemantics {
268    /// Read-only lookup or inspection tools.
269    pub observe: Vec<String>,
270    /// Tools that change task or external state.
271    pub mutate: Vec<String>,
272    /// Explicit planning or task-decomposition tools.
273    pub plan: Vec<String>,
274    /// Tools that demonstrate new forward activity without favoring either tier.
275    pub new: Vec<String>,
276}
277
278impl ToolSemantics {
279    /// Rejects ambiguous mappings and attempts to reclassify built-in tools.
280    pub fn validate(&self) -> Result<()> {
281        let mut seen: Vec<(String, &'static str)> = Vec::new();
282        for (category, names) in [
283            ("observe", &self.observe),
284            ("mutate", &self.mutate),
285            ("plan", &self.plan),
286            ("new", &self.new),
287        ] {
288            for name in names {
289                if name.trim().is_empty() {
290                    return Err(tool_semantics_error(format!(
291                        "tool_semantics.{category} contains an empty tool name"
292                    )));
293                }
294                let normalized = name.to_ascii_lowercase();
295                if is_builtin_tool_name(&name.to_lowercase()) {
296                    return Err(tool_semantics_error(format!(
297                        "tool {name:?} already has built-in semantics and cannot be reclassified"
298                    )));
299                }
300                if let Some((_, previous)) = seen.iter().find(|(seen, _)| seen == &normalized) {
301                    return Err(tool_semantics_error(format!(
302                        "tool {name:?} appears in both tool_semantics.{previous} and tool_semantics.{category}"
303                    )));
304                }
305                seen.push((normalized, category));
306            }
307        }
308        Ok(())
309    }
310
311    fn classify(&self, name: &str) -> Option<ToolSemantic> {
312        if contains_name(&self.observe, name) {
313            Some(ToolSemantic::Observe)
314        } else if contains_name(&self.mutate, name) {
315            // The stage scorer treats writes and edits identically. Custom
316            // mutations use the write counter to preserve the public signal shape.
317            Some(ToolSemantic::Mutate(MutationKind::Write))
318        } else if contains_name(&self.plan, name) {
319            Some(ToolSemantic::Plan)
320        } else if contains_name(&self.new, name) {
321            Some(ToolSemantic::New)
322        } else {
323            None
324        }
325    }
326}
327
328fn contains_name(names: &[String], candidate: &str) -> bool {
329    names
330        .iter()
331        .any(|name| name.eq_ignore_ascii_case(candidate))
332}
333
334fn tool_semantics_error(message: String) -> LibsyError {
335    LibsyError::AlgorithmError { message }
336}
337
338// ─── output type ─────────────────────────────────────────────────────────────
339
340/// Tool-execution signals extracted from a normalized [`Request`].
341///
342/// A request-side processor stores these signals in [`State`](crate::State) for
343/// [`crate::StageRouter`] and its classifier to consume. The advisor gate's
344/// request-side guards read the conversation-shape counts directly via
345/// [`ToolSignals::from_request`].
346#[derive(Clone, Debug, Default)]
347pub struct ToolSignals {
348    /// Max severity across the recent window (last `recent_window` tool results):
349    /// `0.0` clean · `0.3` soft (exit_nonzero) · `0.7` hard · `1.0` critical.
350    /// Windowed so an error persists through the recovery turns instead of clearing
351    /// the instant the next result is clean.
352    pub severity: f32,
353    /// The same hard-or-critical failure appeared at least twice in the recent
354    /// tool-result window.
355    pub repeated_failure: bool,
356    /// Consecutive clean tool results back from the most recent. `0` if the last failed.
357    pub no_error_streak: u32,
358    /// Total edit-style tool calls in the request.
359    pub edit_count: u32,
360    /// Total write-style tool calls in the request.
361    pub write_count: u32,
362    /// Read-type calls (Read tool + read-like Bash). Used by the build-pit gate.
363    pub read_count: u32,
364    /// TodoWrite / planning tool calls. Investigative (non-producing) activity —
365    /// recent todowrites distinguish `exploring` from `spinning` in the scorer.
366    pub todowrite_count: u32,
367    /// Edit-type calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
368    pub recent_edit_count: u32,
369    /// Write-type calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
370    pub recent_write_count: u32,
371    /// Read-type calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
372    pub recent_read_count: u32,
373    /// TodoWrite calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
374    pub recent_todowrite_count: u32,
375    /// Configured `new` tool calls across the full request history.
376    pub new_count: u32,
377    /// Configured `new` tool calls within the recent window.
378    pub recent_new_count: u32,
379    /// Consecutive trailing tool calls in the `Unknown` category (no Write/Edit/Read/
380    /// Plan match). Surfaced in the classifier state summary; not scored directly.
381    pub pure_bash_streak: u32,
382    /// A tool result after the latest recent failure matched a test-pass pattern.
383    pub tests_passed: bool,
384    /// Total `ToolResult` blocks, counted per block (a message batching N
385    /// results contributes N) and including empty-content results.
386    pub tool_result_count: u32,
387    /// Messages with `Role::Assistant`, unlike [`ToolSignals::turn_depth`],
388    /// which counts every message regardless of role.
389    pub assistant_turn_count: u32,
390    /// Message-count proxy for turn depth. Wire-format dependent (Anthropic batches
391    /// tool results into fewer messages than OpenAI-chat), so gates keyed on it are
392    /// approximate across request origins.
393    pub turn_depth: u32,
394    /// The request carries a context-compaction summary (the agent's context was
395    /// summarised after overflowing). Compaction resets the router's accumulated
396    /// signals, so a task that was on the strong tier de-escalates back to weak — the
397    /// picker uses this to force + hold the strong tier. Self-latching: the summary
398    /// stays in the context prefix on every subsequent turn.
399    pub compacted: bool,
400}
401
402impl ToolSignals {
403    /// Extracts tool and activity signals from `request`.
404    ///
405    /// `window_size` limits recent counters to the newest tool results. `None`
406    /// uses [`DEFAULT_RECENT_WINDOW`].
407    pub fn from_request(request: &Request, window_size: Option<usize>) -> Self {
408        Self::from_request_with_semantics(request, window_size, &ToolSemantics::default())
409    }
410
411    /// Extracts signals using the built-in vocabulary plus additive semantics.
412    pub fn from_request_with_semantics(
413        request: &Request,
414        window_size: Option<usize>,
415        semantics: &ToolSemantics,
416    ) -> Self {
417        extract_tool_signals_with_window_and_semantics(
418            request,
419            window_size.unwrap_or(DEFAULT_RECENT_WINDOW),
420            semantics,
421        )
422    }
423}
424
425// `command` is the lowercased Bash command line; None for non-Bash tools.
426// `bare_name` is the tool's own name when `name` joins it to a namespace or MCP server.
427#[derive(Debug, Clone)]
428struct ObservedToolCall<'a> {
429    name: String,
430    bare_name: Option<&'a str>,
431    command: Option<String>,
432}
433
434#[derive(Debug, Clone, Copy, PartialEq, Eq)]
435enum MutationKind {
436    Write,
437    Edit,
438}
439
440/// Domain-neutral meaning assigned to an observed tool call.
441#[derive(Debug, Clone, Copy, PartialEq, Eq)]
442enum ToolSemantic {
443    Mutate(MutationKind),
444    Observe,
445    Plan,
446    New,
447    Unknown,
448}
449
450/// Request-side processor that extracts tool-result signals from each request
451/// and stores them on the request `State` for downstream routing.
452#[derive(Debug, Clone)]
453pub struct ToolSignalProcessor {
454    /// Number of trailing tool results the `recent_*` counts and windowed
455    /// severity are computed over.
456    pub recent_window: usize,
457    /// Route-scoped additions to the built-in tool vocabulary.
458    pub tool_semantics: ToolSemantics,
459}
460
461impl Default for ToolSignalProcessor {
462    fn default() -> Self {
463        Self {
464            recent_window: DEFAULT_RECENT_WINDOW,
465            tool_semantics: ToolSemantics::default(),
466        }
467    }
468}
469
470#[async_trait]
471impl Processor<State> for ToolSignalProcessor {
472    async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
473        if let Event::Request { request: req, .. } = event {
474            let tool_signal = ToolSignals::from_request_with_semantics(
475                req,
476                Some(self.recent_window),
477                &self.tool_semantics,
478            );
479            state.tool_signals = Some(tool_signal);
480        }
481        Ok(())
482    }
483}
484
485fn classify_tool_call(name: &str, command: Option<&str>) -> ToolSemantic {
486    classify_tool_call_with_semantics(name, command, &ToolSemantics::default())
487}
488
489fn classify_tool_call_with_semantics(
490    name: &str,
491    command: Option<&str>,
492    semantics: &ToolSemantics,
493) -> ToolSemantic {
494    // Built-in names and Bash command inference take precedence over route-scoped mappings.
495    let lower = name.to_lowercase();
496    if WRITE_TOOL_NAMES.contains(&lower.as_str()) {
497        return ToolSemantic::Mutate(MutationKind::Write);
498    }
499    if EDITOR_TOOL_NAMES.contains(&lower.as_str()) && command == Some("view") {
500        return ToolSemantic::Observe;
501    }
502    if EDIT_TOOL_NAMES.contains(&lower.as_str()) {
503        return ToolSemantic::Mutate(MutationKind::Edit);
504    }
505    if READ_TOOL_NAMES.contains(&lower.as_str()) {
506        return ToolSemantic::Observe;
507    }
508    if PLAN_TOOL_NAMES.contains(&lower.as_str()) {
509        return ToolSemantic::Plan;
510    }
511    if BASH_TOOL_NAMES.contains(&lower.as_str())
512        && let Some(cmd) = command
513    {
514        // Write/edit redirection trumps read-like operands.
515        if BASH_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_write(cmd) {
516            return ToolSemantic::Mutate(MutationKind::Write);
517        }
518        if cmd.contains("python") && PYTHON_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
519            return ToolSemantic::Mutate(MutationKind::Write);
520        }
521        if shell_invokes_program(cmd, "node")
522            && JAVASCRIPT_WRITE_PATTERNS
523                .iter()
524                .any(|pattern| cmd.contains(pattern))
525        {
526            return ToolSemantic::Mutate(MutationKind::Write);
527        }
528        if BASH_EDIT_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_edit(cmd) {
529            return ToolSemantic::Mutate(MutationKind::Edit);
530        }
531        if BASH_READ_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_read(cmd) {
532            return ToolSemantic::Observe;
533        }
534    }
535    semantics.classify(name).unwrap_or(ToolSemantic::Unknown)
536}
537
538/// Built-in tools that return file or search contents instead of running anything.
539fn is_retrieval_tool(name: &str, command: Option<&str>) -> bool {
540    let lower = name.to_lowercase();
541    READ_TOOL_NAMES.contains(&lower.as_str())
542        || (EDITOR_TOOL_NAMES.contains(&lower.as_str()) && command == Some("view"))
543}
544
545fn is_builtin_tool_name(lower: &str) -> bool {
546    WRITE_TOOL_NAMES.contains(&lower)
547        || EDIT_TOOL_NAMES.contains(&lower)
548        || READ_TOOL_NAMES.contains(&lower)
549        || PLAN_TOOL_NAMES.contains(&lower)
550        || BASH_TOOL_NAMES.contains(&lower)
551}
552
553/// Split a shell line at unquoted command separators. This intentionally avoids
554/// pretending to be a full shell parser; only the leading program and flags of
555/// each segment are inspected below.
556fn shell_segments(command: &str) -> impl Iterator<Item = &str> {
557    let mut chars = command.char_indices();
558    let mut start = 0usize;
559    let mut quote = None;
560    let mut escaped = false;
561    let mut finished = false;
562
563    std::iter::from_fn(move || {
564        loop {
565            for (index, character) in chars.by_ref() {
566                if escaped {
567                    escaped = false;
568                } else if character == '\\' && quote != Some('\'') {
569                    escaped = true;
570                } else if quote == Some(character) {
571                    quote = None;
572                } else if quote.is_none() && matches!(character, '\'' | '"') {
573                    quote = Some(character);
574                } else if quote.is_none() && matches!(character, '\n' | ';' | '|' | '&') {
575                    let segment = command[start..index].trim();
576                    start = index + character.len_utf8();
577                    if !segment.is_empty() {
578                        return Some(segment);
579                    }
580                }
581            }
582
583            if finished {
584                return None;
585            }
586            finished = true;
587            let segment = command[start..].trim();
588            if !segment.is_empty() {
589                return Some(segment);
590            }
591        }
592    })
593}
594
595fn shell_words(segment: &str) -> std::iter::Peekable<std::str::SplitAsciiWhitespace<'_>> {
596    let mut words = segment.split_ascii_whitespace().peekable();
597
598    if words.peek().copied() == Some("env") {
599        words.next();
600        while words.peek().is_some_and(|word| word.starts_with('-')) {
601            words.next();
602        }
603    }
604    while words
605        .peek()
606        .is_some_and(|word| word.contains('=') && !word.starts_with('='))
607    {
608        words.next();
609    }
610
611    words
612}
613
614fn program_name(word: &str) -> &str {
615    Path::new(word)
616        .file_name()
617        .and_then(|name| name.to_str())
618        .unwrap_or(word)
619}
620
621fn shell_invokes_program(command: &str, expected: &str) -> bool {
622    shell_segments(command).any(|segment| {
623        shell_words(segment)
624            .next()
625            .is_some_and(|word| program_name(word) == expected)
626    })
627}
628
629fn shell_command_is_write(command: &str) -> bool {
630    shell_segments(command).any(|segment| {
631        let mut words = shell_words(segment);
632        let Some(program) = words.next().map(program_name) else {
633            return false;
634        };
635        if matches!(program, "cp" | "mkdir" | "touch" | "install") {
636            return true;
637        }
638
639        let redirects_output = words.any(|word| matches!(word, ">" | ">>"));
640        redirects_output
641            && (matches!(program, "echo" | "printf" | "git")
642                || BASH_READ_COMMANDS.contains(&program))
643    })
644}
645
646fn shell_command_is_edit(command: &str) -> bool {
647    shell_segments(command).any(|segment| {
648        let mut words = shell_words(segment);
649        let Some(program) = words.next().map(program_name) else {
650            return false;
651        };
652        let has_arg = |arg: &str| words.clone().any(|word| word == arg);
653
654        match program {
655            "mv" | "rm" => true,
656            "perl" => words
657                .take_while(|word| word.starts_with('-'))
658                .any(|option| {
659                    option
660                        .trim_start_matches('-')
661                        .chars()
662                        .any(|flag| flag == 'i')
663                }),
664            "git" => words
665                .next()
666                .is_some_and(|subcommand| matches!(subcommand, "apply" | "am" | "restore")),
667            "gofmt" => has_arg("-w"),
668            "cargo" => words.clone().next() == Some("fmt") && !has_arg("--check"),
669            "ruff" => {
670                let subcommand = words.clone().next();
671                (subcommand == Some("format") && !has_arg("--check"))
672                    || (subcommand == Some("check") && has_arg("--fix"))
673            }
674            "prettier" => has_arg("--write"),
675            "black" => !has_arg("--check"),
676            _ => {
677                (words.clone().any(|word| program_name(word) == "prettier") && has_arg("--write"))
678                    || (words.clone().any(|word| program_name(word) == "ruff")
679                        && ((has_arg("format") && !has_arg("--check"))
680                            || (has_arg("check") && has_arg("--fix"))))
681            }
682        }
683    })
684}
685
686fn shell_command_is_read(command: &str) -> bool {
687    shell_segments(command).any(|segment| {
688        if segment == "env" {
689            return true;
690        }
691        let mut words = shell_words(segment);
692        let Some(program) = words.next().map(program_name) else {
693            return false;
694        };
695
696        if BASH_READ_COMMANDS.contains(&program) {
697            return true;
698        }
699        if program == "command" && words.next() == Some("-v") {
700            return true;
701        }
702        if program == "type" {
703            return true;
704        }
705        if program != "git" {
706            return false;
707        }
708
709        match words.next() {
710            Some("branch") => words.next().is_none_or(|arg| arg.starts_with('-')),
711            Some("remote") => words
712                .next()
713                .is_none_or(|arg| arg.starts_with('-') || arg == "get-url"),
714            Some("config") => words
715                .next()
716                .is_some_and(|arg| matches!(arg, "--get" | "--get-all" | "--list" | "-l")),
717            Some(subcommand) => GIT_READ_SUBCOMMANDS.contains(&subcommand),
718            None => false,
719        }
720    })
721}
722
723// ─── extraction entry point ───────────────────────────────────────────────────
724
725/// Extract all tool-execution signals from a normalized [`Request`].
726///
727/// Returns [`ToolSignals::default()`] when the message history contains no tool
728/// activity, so callers can always inspect the signal fields.
729fn extract_tool_signals_with_window(request: &Request, recent_window: usize) -> ToolSignals {
730    extract_tool_signals_with_window_and_semantics(
731        request,
732        recent_window,
733        &ToolSemantics::default(),
734    )
735}
736
737fn extract_tool_signals_with_window_and_semantics(
738    request: &Request,
739    recent_window: usize,
740    semantics: &ToolSemantics,
741) -> ToolSignals {
742    // Read the decoded conversation, including preserved built-in tool outputs.
743    let messages = &request.llm_request.messages;
744    let namespaces = tool_namespaces(&request.llm_request.extensions);
745    let mut tool_texts: Vec<(String, bool)> = Vec::new();
746    let mut tool_calls: Vec<ObservedToolCall> = Vec::new();
747    // IDs whose latest call is a retrieval tool.
748    let mut retrieval_calls: HashSet<&str> = HashSet::new();
749    let mut shell_read_calls: HashSet<&str> = HashSet::new();
750    let mut compacted = false;
751    let mut tool_result_count = 0usize;
752    let mut assistant_turn_count = 0usize;
753
754    for message in messages {
755        if message.role == Role::Assistant {
756            assistant_turn_count += 1;
757        }
758        for block in &message.content {
759            match block {
760                ContentBlock::ToolCall(call) => {
761                    // Responses namespaced tools arrive as `<namespace>__<tool>`.
762                    let bare_name = namespaces
763                        .and_then(|namespaces| split_qualified_name(namespaces, &call.name))
764                        .map(|(tool, _)| tool)
765                        .or_else(|| mcp_tool_name(&call.name));
766                    let command = command_of(&call.arguments);
767                    if !call.id.is_empty() {
768                        // The joined name wins, as in `build_signal`. A joined name
769                        // configured as observe still counts when its bare name is a
770                        // retrieval tool, such as `mcp__files__read`.
771                        let full = classify_tool_call_with_semantics(
772                            &call.name,
773                            command.as_deref(),
774                            semantics,
775                        );
776                        let name = match (full, bare_name) {
777                            (ToolSemantic::Unknown | ToolSemantic::Observe, Some(bare_name)) => {
778                                bare_name
779                            }
780                            _ => call.name.as_str(),
781                        };
782                        // A reused ID links to its latest call.
783                        if is_retrieval_tool(name, command.as_deref()) {
784                            retrieval_calls.insert(call.id.as_str());
785                        } else {
786                            retrieval_calls.remove(call.id.as_str());
787                        }
788                        if BASH_TOOL_NAMES.contains(&name.to_lowercase().as_str())
789                            && command.as_deref().is_some_and(|command| {
790                                !command.trim().is_empty()
791                                    && shell_segments(command).all(|segment| {
792                                        classify_tool_call(name, Some(segment))
793                                            == ToolSemantic::Observe
794                                    })
795                            })
796                        {
797                            shell_read_calls.insert(call.id.as_str());
798                        } else {
799                            shell_read_calls.remove(call.id.as_str());
800                        }
801                    }
802                    tool_calls.push(ObservedToolCall {
803                        name: call.name.clone(),
804                        bare_name,
805                        command,
806                    });
807                }
808                ContentBlock::ToolResult(result) => {
809                    // Before the empty-text filter: empty results still count.
810                    tool_result_count += 1;
811                    let text = result
812                        .content
813                        .iter()
814                        .filter_map(text_of)
815                        .collect::<Vec<_>>()
816                        .join("\n");
817                    let is_error = result.is_error == Some(true);
818                    // Shell reads can return bare JSON from a file. Hermes wraps
819                    // terminal output with output and exit_code fields.
820                    let is_shell_read_json = shell_read_calls
821                        .contains(result.tool_call_id.as_str())
822                        && serde_json::from_str::<Value>(&text).is_ok_and(|value| {
823                            value.is_object()
824                                && !(value["output"].is_string()
825                                    && value.get("exit_code").is_some())
826                        });
827                    let is_retrieval_result = !is_error
828                        && (retrieval_calls.contains(result.tool_call_id.as_str())
829                            || is_shell_read_json);
830                    // An explicit failure remains a signal even without text.
831                    if !text.is_empty() || is_error {
832                        // Read and search results show file contents, not the outcome
833                        // of a run. Drop the text but keep the slot so windows don't shift.
834                        let text = if is_retrieval_result {
835                            String::new()
836                        } else {
837                            text
838                        };
839                        tool_texts.push((text, is_error));
840                    }
841                }
842                // Built-in tool history stays opaque so it can be replayed unchanged.
843                ContentBlock::Unknown { provider, raw }
844                    if provider.as_str() == WireFormat::OpenAiResponses.as_str()
845                        && raw.get("type").and_then(Value::as_str)
846                            == Some("apply_patch_call_output") =>
847                {
848                    tool_result_count += 1;
849                    let text = raw
850                        .get("output")
851                        .and_then(Value::as_str)
852                        .unwrap_or_default();
853                    let is_error = raw.get("status").and_then(Value::as_str) == Some("failed");
854                    tool_texts.push((text.to_owned(), is_error));
855                }
856                ContentBlock::Unknown { provider, raw }
857                    if provider.as_str() == WireFormat::OpenAiResponses.as_str()
858                        && raw.get("type").and_then(Value::as_str) == Some("shell_call_output") =>
859                {
860                    tool_result_count += 1;
861                    let mut texts = Vec::new();
862                    let mut is_error = false;
863                    if let Some(outputs) = raw.get("output").and_then(Value::as_array) {
864                        for output in outputs {
865                            for field in ["stdout", "stderr"] {
866                                if let Some(text) = output.get(field).and_then(Value::as_str)
867                                    && !text.is_empty()
868                                {
869                                    texts.push(text);
870                                }
871                            }
872                            if let Some(outcome) = output.get("outcome") {
873                                is_error |= match outcome.get("type").and_then(Value::as_str) {
874                                    Some("timeout") => true,
875                                    Some("exit")
876                                        if outcome
877                                            .get("exit_code")
878                                            .and_then(Value::as_i64)
879                                            .is_some_and(|code| code != 0) =>
880                                    {
881                                        // Keep plain nonzero exits SOFT, including empty output.
882                                        texts.push("returned non-zero");
883                                        output
884                                            .get("stderr")
885                                            .and_then(Value::as_str)
886                                            .is_some_and(|text| !text.trim().is_empty())
887                                    }
888                                    _ => false,
889                                };
890                            }
891                        }
892                    }
893                    let text = texts.join("\n");
894                    tool_texts.push((text, is_error));
895                }
896                // Compaction is detected anywhere in the conversation: the summary
897                // stays in the prefix on every later turn, so this self-latches
898                // once it fires.
899                ContentBlock::Text { text } => {
900                    compacted |= text.to_lowercase().contains(COMPACTION_MARKER);
901                }
902                _ => {}
903            }
904        }
905    }
906
907    let mut signal = build_signal(
908        tool_texts,
909        tool_calls,
910        messages.len() as u32,
911        recent_window,
912        semantics,
913    );
914    signal.compacted = compacted;
915    signal.tool_result_count = u32::try_from(tool_result_count).unwrap_or(u32::MAX);
916    signal.assistant_turn_count = u32::try_from(assistant_turn_count).unwrap_or(u32::MAX);
917    signal
918}
919
920/// Distinctive preamble Claude Code injects as a user message when it compacts an
921/// overflowed context. Matched case-insensitively; normal task text never contains it.
922const COMPACTION_MARKER: &str = "session is being continued";
923
924/// The tool part of an `mcp__<server>__<tool>` name, the form Claude Code uses
925/// for MCP tools. The server name is assumed not to contain `__`; the tool name
926/// may.
927fn mcp_tool_name(name: &str) -> Option<&str> {
928    let (_server, tool) = name.strip_prefix("mcp__")?.split_once("__")?;
929    (!tool.is_empty()).then_some(tool)
930}
931
932/// The shell command a tool call carries, when it has one. Harnesses name the
933/// field `command`; anything else is a tool whose category comes from its name.
934fn command_of(arguments: &Value) -> Option<String> {
935    // the Responses wire format sends arguments as a JSON string, not an object
936    let decoded = arguments
937        .as_str()
938        .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
939    let object = decoded.as_ref().unwrap_or(arguments);
940
941    ["command", "cmd", "input"]
942        .iter()
943        .filter_map(|key| object.get(*key))
944        .find_map(command_text)
945}
946
947/// A command field as lowercase text, from a string or an argv array.
948fn command_text(value: &Value) -> Option<String> {
949    match value {
950        Value::String(text) => Some(text.to_lowercase()),
951        Value::Array(parts) => {
952            let joined = parts
953                .iter()
954                .filter_map(Value::as_str)
955                .collect::<Vec<_>>()
956                .join(" ");
957            (!joined.is_empty()).then(|| joined.to_lowercase())
958        }
959        _ => None,
960    }
961}
962
963/// Text carried by a content block, ignoring the non-textual kinds.
964fn text_of(block: &ContentBlock) -> Option<&str> {
965    match block {
966        ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
967        _ => None,
968    }
969}
970
971fn build_signal(
972    tool_texts: Vec<(String, bool)>,
973    tool_calls: Vec<ObservedToolCall>,
974    turn_depth: u32,
975    recent_window: usize,
976    semantics: &ToolSemantics,
977) -> ToolSignals {
978    // Windowed severity: take the MAX severity across the last `recent_window` tool
979    // results rather than only the last one. An error's severity then persists for
980    // the recent window and decays out of it — parallel to the windowed `recent_*`
981    // counts — so a fix written a couple of turns after an error still routes on the
982    // error signal instead of the router flapping straight back to the weak tier.
983    let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
984    let mut severity = 0.0f32;
985    let mut failure_fingerprints = Vec::new();
986    let mut repeated_failure = false;
987    for (text, is_error) in &tool_texts[sev_start..] {
988        let (sev, _patterns) = classify_text(text);
989        // Explicit failure is at least hard; retain stronger text diagnostics.
990        let sev = if *is_error { sev.max(HARD) } else { sev };
991        if sev > severity {
992            severity = sev;
993        }
994        if let Some(fingerprint) = failure_fingerprint(text, *is_error) {
995            repeated_failure |= failure_fingerprints.contains(&fingerprint);
996            failure_fingerprints.push(fingerprint);
997        }
998    }
999
1000    let no_error_streak = compute_no_error_streak(&tool_texts);
1001
1002    // Single pass: cumulative + sliding-window counters together. Also tracks
1003    // the trailing pure-bash streak (consecutive `Unknown` calls back
1004    // from the end) — the build-pit proxy.
1005    let recent_start = tool_calls.len().saturating_sub(recent_window);
1006    let mut write_count = 0u32;
1007    let mut edit_count = 0u32;
1008    let mut read_count = 0u32;
1009    let mut todowrite_count = 0u32;
1010    let mut recent_write_count = 0u32;
1011    let mut recent_edit_count = 0u32;
1012    let mut recent_read_count = 0u32;
1013    let mut recent_todowrite_count = 0u32;
1014    let mut new_count = 0u32;
1015    let mut recent_new_count = 0u32;
1016    let mut pure_bash_streak = 0u32;
1017    let mut streak_open = true;
1018    for (i, tc) in tool_calls.iter().enumerate().rev() {
1019        // The joined name wins, so configs that list it keep working.
1020        let mut cat = classify_tool_call_with_semantics(&tc.name, tc.command.as_deref(), semantics);
1021        if matches!(cat, ToolSemantic::Unknown)
1022            && let Some(bare_name) = tc.bare_name
1023        {
1024            cat = classify_tool_call_with_semantics(bare_name, tc.command.as_deref(), semantics);
1025        }
1026        if streak_open {
1027            if matches!(cat, ToolSemantic::Unknown) {
1028                pure_bash_streak += 1;
1029            } else {
1030                streak_open = false;
1031            }
1032        }
1033        match cat {
1034            ToolSemantic::Mutate(MutationKind::Write) => {
1035                write_count += 1;
1036                if i >= recent_start {
1037                    recent_write_count += 1;
1038                }
1039            }
1040            ToolSemantic::Mutate(MutationKind::Edit) => {
1041                edit_count += 1;
1042                if i >= recent_start {
1043                    recent_edit_count += 1;
1044                }
1045            }
1046            ToolSemantic::Observe => {
1047                read_count += 1;
1048                if i >= recent_start {
1049                    recent_read_count += 1;
1050                }
1051            }
1052            ToolSemantic::Plan => {
1053                todowrite_count += 1;
1054                if i >= recent_start {
1055                    recent_todowrite_count += 1;
1056                }
1057            }
1058            ToolSemantic::New => {
1059                new_count += 1;
1060                if i >= recent_start {
1061                    recent_new_count += 1;
1062                }
1063            }
1064            ToolSemantic::Unknown => {}
1065        }
1066    }
1067
1068    let tests_passed = detect_tests_passed(&tool_texts, recent_window);
1069
1070    ToolSignals {
1071        severity,
1072        repeated_failure,
1073        no_error_streak,
1074        edit_count,
1075        write_count,
1076        read_count,
1077        todowrite_count,
1078        recent_edit_count,
1079        recent_write_count,
1080        recent_read_count,
1081        recent_todowrite_count,
1082        new_count,
1083        recent_new_count,
1084        pure_bash_streak,
1085        tests_passed,
1086        turn_depth,
1087        // Set by extract_tool_signals_with_window after the format-specific extract,
1088        // which scans all message contents for the compaction marker and tallies
1089        // the raw conversation-shape counts.
1090        tool_result_count: 0,
1091        assistant_turn_count: 0,
1092        compacted: false,
1093    }
1094}
1095
1096// ─── pure helpers ─────────────────────────────────────────────────────────────
1097
1098/// Normalise a JSON tool-result content value to a plain string.
1099fn content_to_text(content: Option<&Value>) -> Option<String> {
1100    match content? {
1101        Value::String(s) => Some(s.clone()),
1102        Value::Array(blocks) => {
1103            let parts: Vec<&str> = blocks
1104                .iter()
1105                .filter_map(|b| {
1106                    b.as_object()
1107                        .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
1108                        .and_then(|o| o.get("text"))
1109                        .and_then(Value::as_str)
1110                })
1111                .collect();
1112            if parts.is_empty() {
1113                None
1114            } else {
1115                Some(parts.join("\n"))
1116            }
1117        }
1118        _ => None,
1119    }
1120}
1121
1122/// Match tool text and structured result fields against error patterns.
1123///
1124/// Returns `(max_severity, matched_pattern_names)`.
1125pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
1126    let lower = text.to_lowercase();
1127    let mut patterns = Vec::new();
1128    let mut severity: f32 = 0.0;
1129    for (name, sev, substrings) in ERROR_PATTERNS {
1130        if substrings.iter().any(|sub| lower.contains(sub)) {
1131            patterns.push(name.to_string());
1132            severity = severity.max(*sev);
1133        }
1134    }
1135    // Hermes can append a loop warning after the JSON result.
1136    let result = serde_json::Deserializer::from_str(text)
1137        .into_iter::<Value>()
1138        .next()
1139        .and_then(|result| result.ok())
1140        .unwrap_or_default();
1141    let nonzero_exit = result["exit_code"].as_i64().is_some_and(|code| code != 0);
1142    let tool_error = result["success"].as_bool() == Some(false)
1143        || result["error"]
1144            .as_str()
1145            .is_some_and(|error| !error.trim().is_empty());
1146    if (nonzero_exit || has_nonzero_exit_status(&lower))
1147        && !patterns.iter().any(|p| p == "exit_nonzero")
1148    {
1149        patterns.push("exit_nonzero".to_string());
1150        severity = severity.max(SOFT);
1151    }
1152    for (name, matched) in [
1153        ("tool_error", tool_error),
1154        ("compile_error", has_compiler_diagnostic(&lower)),
1155        ("runtime_exception", has_runtime_exception(&lower)),
1156        ("runtime_panic", has_runtime_panic(&lower)),
1157        ("patch_error", has_patch_failure(&lower)),
1158    ] {
1159        if matched && !patterns.iter().any(|pattern| pattern == name) {
1160            patterns.push(name.to_string());
1161            severity = severity.max(HARD);
1162        }
1163    }
1164    (severity, patterns)
1165}
1166
1167/// Stable identity for a material failure. Soft non-zero exits need an explicit
1168/// failure flag to count as a repeated mistake.
1169fn failure_fingerprint(text: &str, is_error: bool) -> Option<String> {
1170    let (severity, patterns) = classify_text(text);
1171    if severity < HARD && !is_error {
1172        return None;
1173    }
1174
1175    let lower = text.to_lowercase();
1176    let diagnostic = lower
1177        .lines()
1178        .find(|line| is_failure_diagnostic(line))
1179        .or_else(|| lower.lines().find(|line| !line.trim().is_empty()))
1180        .unwrap_or_default();
1181    let normalized = normalize_failure_text(diagnostic);
1182    Some(format!("{}|{normalized}", patterns.join(",")))
1183}
1184
1185fn is_failure_diagnostic(line: &str) -> bool {
1186    let line = line.trim();
1187    [
1188        "error",
1189        "exception",
1190        "panic",
1191        "failed",
1192        "timed out",
1193        "timeout",
1194        "connection refused",
1195        "cannot allocate memory",
1196        "out of memory",
1197        "not found",
1198    ]
1199    .iter()
1200    .any(|marker| line.contains(marker))
1201}
1202
1203/// Removes values that normally change between retries while retaining the
1204/// diagnostic wording that distinguishes one failure from another.
1205fn normalize_failure_text(text: &str) -> String {
1206    let mut normalized = String::new();
1207    for word in text.split_whitespace() {
1208        if !normalized.is_empty() {
1209            normalized.push(' ');
1210        }
1211        let mut in_digits = false;
1212        if word.starts_with('/') || word.contains("/src/") || word.contains("/tmp/") {
1213            normalized.push_str("<path>");
1214            continue;
1215        }
1216        for character in word.chars() {
1217            if character.is_ascii_digit() {
1218                if !in_digits {
1219                    normalized.push('#');
1220                    in_digits = true;
1221                }
1222            } else {
1223                normalized.push(character);
1224                in_digits = false;
1225            }
1226        }
1227    }
1228    normalized.chars().take(240).collect()
1229}
1230
1231fn has_compiler_diagnostic(lower: &str) -> bool {
1232    lower.lines().any(|line| {
1233        let line = line.trim_start();
1234        if matches!(
1235            line,
1236            "compilation failed" | "error: compilation failed" | "error: could not compile"
1237        ) || line.starts_with("error: could not compile ")
1238        {
1239            return true;
1240        }
1241
1242        let Some(rest) = line.strip_prefix("error[e") else {
1243            return false;
1244        };
1245        let Some((code, _)) = rest.split_once("]:") else {
1246            return false;
1247        };
1248        !code.is_empty() && code.chars().all(|character| character.is_ascii_digit())
1249    })
1250}
1251
1252fn has_runtime_exception(lower: &str) -> bool {
1253    let has_exception_line = lower.lines().any(|line| {
1254        let line = line.trim_start();
1255        [
1256            "typeerror:",
1257            "referenceerror:",
1258            "rangeerror:",
1259            "runtimeerror:",
1260            "keyerror:",
1261            "attributeerror:",
1262        ]
1263        .iter()
1264        .any(|prefix| line.starts_with(prefix))
1265    });
1266    has_exception_line && (lower.contains("\n    at ") || lower.contains("\n  at "))
1267}
1268
1269fn has_runtime_panic(lower: &str) -> bool {
1270    lower
1271        .lines()
1272        .any(|line| line.trim_start().starts_with("panic: runtime error:"))
1273        && (lower.contains("\ngoroutine ") || lower.contains("[signal sig"))
1274}
1275
1276fn has_patch_failure(lower: &str) -> bool {
1277    lower.lines().any(|line| {
1278        let line = line.trim_start();
1279        line.starts_with("error: patch failed:")
1280            || line.starts_with("patch failed:")
1281            || line.contains(": patch does not apply")
1282            || line.starts_with("invalid context")
1283    })
1284}
1285
1286/// Detects `exit_nonzero` only when a supported exit phrase is followed by a
1287/// nonzero decimal status.
1288///
1289/// Codex includes "Process exited with code 0" on clean tool results, so exit
1290/// phrases must parse their numeric status instead of matching the phrase alone.
1291fn has_nonzero_exit_status(lower: &str) -> bool {
1292    NONZERO_EXIT_PHRASES
1293        .iter()
1294        .any(|phrase| phrase_followed_by_nonzero_integer(lower, phrase))
1295}
1296
1297/// Matches common "exit code/status N" spellings after optional separators.
1298fn phrase_followed_by_nonzero_integer(lower: &str, phrase: &str) -> bool {
1299    let mut cursor = 0usize;
1300    while let Some(rel) = lower[cursor..].find(phrase) {
1301        let value_start = cursor + rel + phrase.len();
1302        let rest = lower[value_start..].trim_start_matches(|c: char| {
1303            c.is_ascii_whitespace() || matches!(c, ':' | '=' | '\'' | '"' | '`')
1304        });
1305        let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
1306        if !digits.is_empty() && digits.chars().any(|d| d != '0') {
1307            return true;
1308        }
1309        cursor = value_start;
1310    }
1311    false
1312}
1313
1314fn compute_no_error_streak(tool_texts: &[(String, bool)]) -> u32 {
1315    let mut streak = 0u32;
1316    for (text, is_error) in tool_texts.iter().rev() {
1317        let (sev, _) = classify_text(text);
1318        if *is_error || sev > 0.0 {
1319            break;
1320        }
1321        streak += 1;
1322    }
1323    streak
1324}
1325
1326fn detect_tests_passed(tool_texts: &[(String, bool)], recent_window: usize) -> bool {
1327    let start = tool_texts.len().saturating_sub(recent_window.max(1));
1328    let recent = &tool_texts[start..];
1329    let after_latest_failure = recent
1330        .iter()
1331        .rposition(|(text, is_error)| *is_error || classify_text(text).0 > 0.0)
1332        .map_or(recent, |index| &recent[index + 1..]);
1333    after_latest_failure.iter().any(|(text, _)| {
1334        let lower = text.to_lowercase();
1335        TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
1336            && !TEST_FAILURE_LITERAL.iter().any(|p| lower.contains(p))
1337            && !has_nonzero_failure_count(&lower)
1338    })
1339}
1340
1341// True iff `lower` contains a `NUMERIC_FAILURE_KEYWORDS` token preceded
1342// (modulo whitespace) by a nonzero integer. The "modulo whitespace" lets
1343// "1 failed", "1\nfailed", and "1  failed" all trip; the nonzero guard
1344// keeps cargo's "0 failed" / go's "0 errors" / pytest's "0 errors in"
1345// summaries from being misread as failures on a clean run.
1346fn has_nonzero_failure_count(lower: &str) -> bool {
1347    for kw in NUMERIC_FAILURE_KEYWORDS {
1348        let mut cursor = 0usize;
1349        while let Some(rel) = lower[cursor..].find(kw) {
1350            let kw_start = cursor + rel;
1351            let kw_end = kw_start + kw.len();
1352            // Word boundary AFTER the keyword — "errors" mid-word (e.g.
1353            // "errored") shouldn't count as a failure-count site.
1354            let boundary_after = lower[kw_end..]
1355                .chars()
1356                .next()
1357                .is_none_or(|c| !c.is_ascii_alphanumeric());
1358            if boundary_after {
1359                let prefix = &lower[..kw_start];
1360                let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
1361                let digits_rev: String = trimmed
1362                    .chars()
1363                    .rev()
1364                    .take_while(|c| c.is_ascii_digit())
1365                    .collect();
1366                if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
1367                    return true;
1368                }
1369            }
1370            cursor = kw_start + kw.len();
1371        }
1372    }
1373    false
1374}
1375
1376// ─── tests ───────────────────────────────────────────────────────────────────
1377
1378#[cfg(test)]
1379mod tests {
1380    use super::*;
1381    use crate::algorithms::util::stage::score_signal;
1382    use serde_json::json;
1383    use switchyard_protocol::codex_namespaces::TOOL_NAMESPACES_KEY;
1384    use switchyard_protocol::{
1385        ContentBlock, LlmRequest, Message, Metadata, Role, ToolCall, ToolResult,
1386    };
1387
1388    fn with_messages(messages: Vec<Message>) -> Request {
1389        Request {
1390            llm_request: LlmRequest {
1391                messages,
1392                ..LlmRequest::default()
1393            },
1394            raw_request: None,
1395            metadata: None,
1396        }
1397    }
1398
1399    // assistant message with a single named tool call
1400    fn tc(name: &str) -> Message {
1401        Message {
1402            role: Role::Assistant,
1403            content: vec![ContentBlock::ToolCall(ToolCall {
1404                id: String::new(),
1405                name: name.to_string(),
1406                arguments: json!({}),
1407            })],
1408        }
1409    }
1410
1411    // assistant Bash message carrying `command`
1412    fn bash(command: &str) -> Message {
1413        Message {
1414            role: Role::Assistant,
1415            content: vec![ContentBlock::ToolCall(ToolCall {
1416                id: String::new(),
1417                name: "Bash".to_string(),
1418                arguments: json!({"command": command}),
1419            })],
1420        }
1421    }
1422
1423    // a tool result message (goes in a user-role message, as in Anthropic's normalised form)
1424    fn tr(text: &str) -> Message {
1425        Message {
1426            role: Role::User,
1427            content: vec![ContentBlock::ToolResult(ToolResult {
1428                tool_call_id: String::new(),
1429                content: vec![ContentBlock::Text {
1430                    text: text.to_string(),
1431                }],
1432                is_error: None,
1433            })],
1434        }
1435    }
1436
1437    #[test]
1438    fn clean_text_has_zero_severity() {
1439        let (sev, patterns) = classify_text("everything went fine");
1440        assert_eq!(sev, 0.0);
1441        assert!(patterns.is_empty());
1442    }
1443
1444    #[test]
1445    fn structured_tool_failures_affect_recovery_signals() {
1446        for (text, severity) in [
1447            (r#"{"output":"","exit_code":7,"error":null}"#, SOFT),
1448            (r#"{"output":"","exit_code":-1,"error":null}"#, SOFT),
1449            (r#"{"success":false,"error":"No matching text"}"#, HARD),
1450            (r#"{"success":false,"error":null}"#, HARD),
1451            (
1452                r#"{"error":"Overwrite refused","stale_write_blocked":true}"#,
1453                HARD,
1454            ),
1455            (r#"{"output":"out of memory","exit_code":1}"#, CRITICAL),
1456            (r#"{"output":"done","exit_code":0,"error":null}"#, 0.0),
1457            (r#"{"success":true,"error":"  "}"#, 0.0),
1458            (r#"{"output":"running","exit_code":null,"error":null}"#, 0.0),
1459            (r#"{"output":{"success":false},"exit_code":0}"#, 0.0),
1460        ] {
1461            let warned = format!("{text}\n\n[Tool loop warning: repeated identical call]");
1462            let request = with_messages(vec![tr("5 passed in 0.12s"), tr(text), tr(&warned)]);
1463            let signal = ToolSignals::from_request(&request, None);
1464            let clean = severity == 0.0;
1465            assert_eq!(signal.severity, severity, "{text}");
1466            assert_eq!(signal.no_error_streak, if clean { 3 } else { 0 }, "{text}");
1467            assert_eq!(signal.repeated_failure, severity >= HARD, "{text}");
1468            assert_eq!(signal.tests_passed, clean, "{text}");
1469        }
1470    }
1471
1472    #[test]
1473    fn traceback_is_hard() {
1474        let (sev, patterns) = classify_text("Traceback (most recent call last):\n  ValueError");
1475        assert_eq!(sev, HARD);
1476        assert!(patterns.contains(&"traceback".to_string()));
1477    }
1478
1479    #[test]
1480    fn oom_is_critical() {
1481        let (sev, _) = classify_text("Out of memory: kill process 1234");
1482        assert_eq!(sev, CRITICAL);
1483    }
1484
1485    #[test]
1486    fn connection_refused_is_hard() {
1487        let (severity, _) = classify_text("Connection refused on port 8000");
1488        assert_eq!(severity, HARD);
1489    }
1490
1491    #[test]
1492    fn repeated_failure_ignores_volatile_paths_and_numbers() {
1493        let request = with_messages(vec![
1494            tr("error[E0308]: mismatched types at /tmp/a/src/lib.rs:12"),
1495            tr("error[E0308]: mismatched types at /tmp/b/src/lib.rs:47"),
1496        ]);
1497        assert!(ToolSignals::from_request(&request, None).repeated_failure);
1498    }
1499
1500    #[test]
1501    fn different_failures_are_not_repeated() {
1502        let request = with_messages(vec![
1503            tr("error[E0308]: mismatched types"),
1504            tr("error[E0509]: cannot move out"),
1505        ]);
1506        assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1507    }
1508
1509    #[test]
1510    fn one_material_failure_is_not_repeated() {
1511        let request = with_messages(vec![tr("Connection refused on port 8000")]);
1512        assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1513    }
1514
1515    /// Explicit failures count even without diagnostic text and cannot signal recovery.
1516    #[test]
1517    fn structured_tool_failures_feed_error_and_recovery_signals() {
1518        for text in ["Dependency unavailable", "", "5 passed in 0.12s"] {
1519            let mut failed = tr(text);
1520            let ContentBlock::ToolResult(result) = &mut failed.content[0] else {
1521                panic!("expected tool result");
1522            };
1523            result.is_error = Some(true);
1524            let mut request = with_messages(vec![tr("5 passed in 0.12s"), failed.clone()]);
1525            let signals = ToolSignals::from_request(&request, Some(3));
1526            assert_eq!(signals.severity, HARD);
1527            assert!(!signals.repeated_failure);
1528            assert_eq!(signals.no_error_streak, 0);
1529            assert!(!signals.tests_passed);
1530
1531            request.llm_request.messages.push(failed);
1532            assert!(ToolSignals::from_request(&request, Some(3)).repeated_failure);
1533            request.llm_request.messages.push(tr("5 passed in 0.12s"));
1534            let recovered = ToolSignals::from_request(&request, Some(1));
1535            assert_eq!(recovered.severity, 0.0);
1536            assert!(!recovered.repeated_failure);
1537            assert_eq!(recovered.no_error_streak, 1);
1538            assert!(recovered.tests_passed);
1539        }
1540    }
1541
1542    #[test]
1543    fn severity_is_max_across_patterns() {
1544        // exit_nonzero (SOFT) + traceback (HARD) → HARD.
1545        let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
1546        assert_eq!(sev, HARD);
1547    }
1548
1549    #[test]
1550    fn codex_process_exit_zero_stays_clean() {
1551        let (sev, patterns) =
1552            classify_text("Chunk ID: abc\nProcess exited with code 0\nOutput:\nok");
1553        assert_eq!(sev, 0.0);
1554        assert!(!patterns.contains(&"exit_nonzero".to_string()));
1555    }
1556
1557    #[test]
1558    fn nonzero_exit_codes_are_soft_errors() {
1559        let cases = [
1560            "Process exited with code 1",
1561            "Process exited with code 127",
1562            "exit code: 2",
1563            "exit status 3",
1564            "exited with status 9",
1565        ];
1566        for case in cases {
1567            let (sev, patterns) = classify_text(case);
1568            assert_eq!(sev, SOFT, "expected soft severity for {case}");
1569            assert!(patterns.contains(&"exit_nonzero".to_string()));
1570        }
1571    }
1572
1573    #[test]
1574    fn partial_process_failures_are_hard_errors() {
1575        let cases = [
1576            (
1577                "Process running with session ID 12\nOutput:\nerror[E0509]: cannot move out",
1578                "compile_error",
1579            ),
1580            (
1581                "Process exited with code 0\nOutput:\nTypeError: value is undefined\n    at main.js:1:2",
1582                "runtime_exception",
1583            ),
1584            (
1585                "Process running with session ID 13\nOutput:\npanic: runtime error: index out of range\n\ngoroutine 6 [running]:",
1586                "runtime_panic",
1587            ),
1588            (
1589                "Process exited with code 0\nOutput:\nerror: patch failed: src/lib.rs:4\nerror: src/lib.rs: patch does not apply",
1590                "patch_error",
1591            ),
1592        ];
1593        for (text, expected_pattern) in cases {
1594            let (severity, patterns) = classify_text(text);
1595            assert_eq!(severity, HARD, "expected hard severity for {text}");
1596            assert!(patterns.iter().any(|pattern| pattern == expected_pattern));
1597        }
1598    }
1599
1600    #[test]
1601    fn source_text_that_names_exceptions_stays_clean() {
1602        let text =
1603            "pub enum TypeError: this is documentation\nlet sample = 'panic: runtime error:';";
1604        assert_eq!(classify_text(text).0, 0.0);
1605    }
1606
1607    #[test]
1608    fn file_does_not_exist_is_hard() {
1609        // Claude Code Read-tool miss. Trace-mined addition (22 true / 2 false positives).
1610        let (sev, patterns) =
1611            classify_text("Error: File does not exist. Note: current working directory is /app.");
1612        assert_eq!(sev, HARD);
1613        assert!(patterns.contains(&"no_such_file".to_string()));
1614    }
1615
1616    #[test]
1617    fn bare_does_not_exist_stays_clean() {
1618        // Precision guard: only the anchored "file does not exist" fires, so a bare
1619        // "does not exist" in prose or directory output must not trip a false error.
1620        let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
1621        assert_eq!(sev, 0.0);
1622    }
1623
1624    #[test]
1625    fn no_error_streak_all_clean() {
1626        let texts = vec![("ok".to_string(), false), ("all good".to_string(), false)];
1627        assert_eq!(compute_no_error_streak(&texts), 2);
1628    }
1629
1630    #[test]
1631    fn no_error_streak_stops_at_error() {
1632        let texts = vec![
1633            ("Traceback (most recent call last):".to_string(), false),
1634            ("ok".to_string(), false),
1635            ("ok".to_string(), false),
1636        ];
1637        assert_eq!(compute_no_error_streak(&texts), 2);
1638    }
1639
1640    #[test]
1641    fn tests_passed_detects_pytest_output() {
1642        assert!(detect_tests_passed(
1643            &[("====== 5 passed in 0.12s ======".to_string(), false)],
1644            DEFAULT_RECENT_WINDOW
1645        ));
1646    }
1647
1648    #[test]
1649    fn tests_passed_ignores_partial_failures() {
1650        assert!(!detect_tests_passed(
1651            &[("2 failed, 5 passed in 0.56s".to_string(), false)],
1652            DEFAULT_RECENT_WINDOW
1653        ));
1654    }
1655
1656    #[test]
1657    fn tests_passed_must_follow_the_latest_failure() {
1658        assert!(!detect_tests_passed(
1659            &[
1660                ("5 passed in 0.12s".to_string(), false),
1661                (
1662                    "Traceback (most recent call last):\nValueError".to_string(),
1663                    false
1664                ),
1665                ("edit applied".to_string(), false),
1666            ],
1667            DEFAULT_RECENT_WINDOW
1668        ));
1669        assert!(detect_tests_passed(
1670            &[
1671                (
1672                    "Traceback (most recent call last):\nValueError".to_string(),
1673                    false
1674                ),
1675                ("5 passed in 0.12s".to_string(), false),
1676            ],
1677            DEFAULT_RECENT_WINDOW
1678        ));
1679    }
1680
1681    #[test]
1682    fn retrieved_file_contents_are_ignored() {
1683        let call = |id: &str, name: &str, arguments: Value| Message {
1684            role: Role::Assistant,
1685            content: vec![ContentBlock::ToolCall(ToolCall {
1686                id: id.to_string(),
1687                name: name.to_string(),
1688                arguments,
1689            })],
1690        };
1691        let result = |id: &str, text: &str| Message {
1692            role: Role::User,
1693            content: vec![ContentBlock::ToolResult(ToolResult {
1694                tool_call_id: id.to_string(),
1695                content: vec![ContentBlock::Text {
1696                    text: text.to_string(),
1697                }],
1698                is_error: None,
1699            })],
1700        };
1701        let signal = extract_tool_signals_with_window(
1702            &with_messages(vec![
1703                call("a", "Bash", json!({"command": "pytest"})),
1704                result("a", "Traceback (most recent call last):\nValueError"),
1705                call("b", "Read", json!({"file_path": "notes.md"})),
1706                result(
1707                    "b",
1708                    r#"{"success":false,"error":"out of memory","exit_code":7}"#,
1709                ),
1710                call("c", "Grep", json!({"pattern": "passed"})),
1711                result("c", "CHANGELOG.md: all tests passed"),
1712            ]),
1713            DEFAULT_RECENT_WINDOW,
1714        );
1715        // Only the real pytest run counts.
1716        assert_eq!(signal.severity, HARD);
1717        assert!(!signal.tests_passed);
1718        assert_eq!(signal.tool_result_count, 3);
1719
1720        for (command, text, severity) in [
1721            ("cat config.json", r#"{"error":"fixture data"}"#, 0.0),
1722            (
1723                "cat missing.json",
1724                r#"{"output":"","exit_code":7,"error":null}"#,
1725                SOFT,
1726            ),
1727            (
1728                "cat config.json && python check.py",
1729                r#"{"error":"check rejected"}"#,
1730                HARD,
1731            ),
1732        ] {
1733            let request = with_messages(vec![
1734                call("shell", "Bash", json!({"command": command})),
1735                result("shell", text),
1736            ]);
1737            assert_eq!(
1738                ToolSignals::from_request(&request, None).severity,
1739                severity,
1740                "{text}"
1741            );
1742        }
1743    }
1744
1745    #[test]
1746    fn severity_is_windowed_over_recent_results() {
1747        // An error two results back, then two clean results.
1748        let request = with_messages(vec![
1749            tr("Traceback (most recent call last):\n  ValueError"),
1750            tr("ok"),
1751            tr("ok"),
1752        ]);
1753        // window covers the error → severity persists (max over the window)
1754        assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
1755        // window of 1 sees only the last (clean) result → severity has decayed out
1756        assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
1757    }
1758
1759    #[test]
1760    fn extract_openai_chat_tool_results() {
1761        let request = with_messages(vec![
1762            Message::text(Role::User, "do something"),
1763            tc("Edit"),
1764            tr("Traceback (most recent call last):\n  ValueError"),
1765        ]);
1766        let sig = ToolSignals::from_request(&request, None);
1767        assert_eq!(sig.severity, HARD);
1768        assert_eq!(sig.edit_count, 1);
1769        assert_eq!(sig.turn_depth, 3);
1770    }
1771
1772    #[test]
1773    fn extract_anthropic_tool_results() {
1774        let request = with_messages(vec![tr("Traceback (most recent call last):\n  ValueError")]);
1775        let sig = ToolSignals::from_request(&request, None);
1776        assert_eq!(sig.severity, HARD);
1777    }
1778
1779    #[test]
1780    fn extract_responses_api_tool_results() {
1781        let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
1782        let sig = ToolSignals::from_request(&request, None);
1783        assert_eq!(sig.severity, 0.0);
1784        assert_eq!(sig.write_count, 1);
1785    }
1786
1787    #[test]
1788    fn responses_builtin_tool_failures_escalate() {
1789        use crate::algorithms::util::stage::{PickOutcome, PickerMode, Tier, pick_tier};
1790
1791        let mut cases = Vec::new();
1792        for (status, output) in [
1793            (
1794                "failed",
1795                "Synthetic dependency unavailable; retry with the recovery path.",
1796            ),
1797            (
1798                "completed",
1799                "Synthetic dependency unavailable; retry with the recovery path.",
1800            ),
1801            ("failed", ""),
1802        ] {
1803            cases.push((
1804                json!({
1805                    "type": "apply_patch_call_output",
1806                    "status": status,
1807                    "output": output,
1808                }),
1809                if status == "failed" { HARD } else { 0.0 },
1810            ));
1811        }
1812        for (outcome, stdout, stderr, severity) in [
1813            (json!({"type": "exit", "exit_code": 1}), "", "", SOFT),
1814            (json!({"type": "exit", "exit_code": 1}), "", " \n", SOFT),
1815            (
1816                json!({"type": "exit", "exit_code": 1}),
1817                "",
1818                "command failed",
1819                HARD,
1820            ),
1821            (json!({"type": "timeout"}), "", "", HARD),
1822            (json!({"type": "exit", "exit_code": 0}), "done", "", 0.0),
1823            (
1824                json!({"type": "exit", "exit_code": 0}),
1825                "Traceback (most recent call last):",
1826                "",
1827                HARD,
1828            ),
1829            (
1830                json!({"type": "exit", "exit_code": 0}),
1831                "",
1832                "Traceback (most recent call last):",
1833                HARD,
1834            ),
1835        ] {
1836            cases.push((
1837                json!({
1838                    "type": "shell_call_output",
1839                    "output": [
1840                        {"stdout": stdout, "stderr": stderr, "outcome": outcome},
1841                        {"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}
1842                    ],
1843                }),
1844                severity,
1845            ));
1846        }
1847        for (raw, severity) in cases {
1848            let is_error = severity >= HARD;
1849            let mut request = with_messages(
1850                ["call_1", "call_2"]
1851                    .into_iter()
1852                    .map(|call_id| {
1853                        let mut raw = raw.clone();
1854                        raw["call_id"] = json!(call_id);
1855                        Message {
1856                            role: Role::User,
1857                            content: vec![ContentBlock::Unknown {
1858                                provider: WireFormat::OpenAiResponses.into(),
1859                                raw,
1860                            }],
1861                        }
1862                    })
1863                    .collect(),
1864            );
1865            let signal = ToolSignals::from_request(&request, Some(3));
1866            assert_eq!(signal.severity, severity, "{raw}");
1867            assert_eq!(signal.repeated_failure, is_error, "{raw}");
1868            assert_eq!(signal.tool_result_count, 2);
1869            assert_eq!(
1870                matches!(
1871                    pick_tier(&signal, PickerMode::EfficientFirst, 0.5),
1872                    PickOutcome::Resolved {
1873                        tier: Tier::Capable,
1874                        ..
1875                    }
1876                ),
1877                is_error,
1878                "{raw}"
1879            );
1880
1881            let mut success = match raw["type"].as_str() {
1882                Some("apply_patch_call_output") => json!({
1883                    "type": "apply_patch_call_output", "status": "completed", "output": ""
1884                }),
1885                Some("shell_call_output") => json!({
1886                    "type": "shell_call_output",
1887                    "output": [{"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}]
1888                }),
1889                _ => unreachable!(),
1890            };
1891            for index in 0..3 {
1892                success["call_id"] = json!(format!("success_{index}"));
1893                request.llm_request.messages.push(Message {
1894                    role: Role::User,
1895                    content: vec![ContentBlock::Unknown {
1896                        provider: WireFormat::OpenAiResponses.into(),
1897                        raw: success.clone(),
1898                    }],
1899                });
1900            }
1901            let recovered = ToolSignals::from_request(&request, Some(3));
1902            assert_eq!(recovered.severity, 0.0, "{raw}");
1903            assert!(!recovered.repeated_failure, "{raw}");
1904            assert!(recovered.no_error_streak >= 3, "{raw}");
1905            assert_eq!(recovered.tool_result_count, 5);
1906        }
1907    }
1908
1909    #[test]
1910    fn conversation_counts_are_per_block_and_role_aware() {
1911        // A batched user message (Anthropic shape) contributes one count per
1912        // ToolResult block, empty-content results included. assistant_turn_count
1913        // tracks Role::Assistant only, while turn_depth counts every message.
1914        let result = |content: Vec<ContentBlock>| {
1915            ContentBlock::ToolResult(ToolResult {
1916                tool_call_id: String::new(),
1917                content,
1918                is_error: None,
1919            })
1920        };
1921        let request = with_messages(vec![
1922            Message::text(Role::User, "do something"),
1923            Message::text(Role::Assistant, "working"),
1924            Message {
1925                role: Role::User,
1926                content: vec![
1927                    result(vec![ContentBlock::Text {
1928                        text: "ok".to_string(),
1929                    }]),
1930                    result(Vec::new()),
1931                ],
1932            },
1933            tc("Bash"),
1934        ]);
1935        let sig = ToolSignals::from_request(&request, None);
1936        assert_eq!(sig.tool_result_count, 2);
1937        assert_eq!(sig.assistant_turn_count, 2);
1938        assert_eq!(sig.turn_depth, 4);
1939    }
1940
1941    #[test]
1942    fn recent_window_counts_only_last_default_window_tool_calls() {
1943        // 5 writes + 1 edit at the end → the default window (3) should see
1944        // the last 3 calls: 1 edit + 2 writes (not all 6 calls).
1945        let request = with_messages(vec![
1946            tc("Write"),
1947            tr("ok"),
1948            tc("Write"),
1949            tr("ok"),
1950            tc("Write"),
1951            tr("ok"),
1952            tc("Write"),
1953            tr("ok"),
1954            tc("Write"),
1955            tr("ok"),
1956            tc("Edit"),
1957            tr("ok"),
1958        ]);
1959        let sig = ToolSignals::from_request(&request, None);
1960        assert_eq!(sig.write_count, 5);
1961        assert_eq!(sig.edit_count, 1);
1962        assert_eq!(sig.recent_write_count, 2);
1963        assert_eq!(sig.recent_edit_count, 1);
1964    }
1965
1966    #[test]
1967    fn codex_apply_patch_counts_as_an_edit() {
1968        let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
1969        let sig = ToolSignals::from_request(&request, None);
1970        assert_eq!(sig.edit_count, 1);
1971        assert_eq!(sig.recent_edit_count, 1);
1972    }
1973
1974    fn exec_command(cmd: Value) -> Message {
1975        Message {
1976            role: Role::Assistant,
1977            content: vec![ContentBlock::ToolCall(ToolCall {
1978                id: String::new(),
1979                name: "exec_command".to_string(),
1980                arguments: cmd,
1981            })],
1982        }
1983    }
1984
1985    #[test]
1986    fn codex_exec_command_is_classified() {
1987        // arguments arrive as a JSON string, with the command under `cmd`
1988        let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
1989        let request = with_messages(vec![exec_command(args), tr("ok")]);
1990        assert_eq!(
1991            ToolSignals::from_request(&request, None).recent_edit_count,
1992            1
1993        );
1994    }
1995
1996    #[test]
1997    fn python_write_expressions_need_a_python_command() {
1998        let write = with_messages(vec![
1999            exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
2000            tr("ok"),
2001        ]);
2002        assert_eq!(
2003            ToolSignals::from_request(&write, None).recent_write_count,
2004            1
2005        );
2006
2007        let search = with_messages(vec![
2008            exec_command(json!({"cmd": "grep -R '.write(' src"})),
2009            tr("ok"),
2010        ]);
2011        assert_eq!(
2012            ToolSignals::from_request(&search, None).recent_write_count,
2013            0
2014        );
2015    }
2016
2017    #[test]
2018    fn recent_window_size_is_caller_overridable() {
2019        // Same six tool calls (1 edit at the end, 5 writes before).
2020        // With recent_window=3 → recent_writes=2, recent_edits=1.
2021        // With recent_window=6 → recent_writes=5, recent_edits=1 (all calls).
2022        let request = with_messages(vec![
2023            tc("Write"),
2024            tr("ok"),
2025            tc("Write"),
2026            tr("ok"),
2027            tc("Write"),
2028            tr("ok"),
2029            tc("Write"),
2030            tr("ok"),
2031            tc("Write"),
2032            tr("ok"),
2033            tc("Edit"),
2034            tr("ok"),
2035        ]);
2036        let narrow = extract_tool_signals_with_window(&request, 3);
2037        assert_eq!(narrow.recent_write_count, 2);
2038        assert_eq!(narrow.recent_edit_count, 1);
2039
2040        let wide = extract_tool_signals_with_window(&request, 6);
2041        assert_eq!(wide.recent_write_count, 5);
2042        assert_eq!(wide.recent_edit_count, 1);
2043    }
2044
2045    #[test]
2046    fn compaction_marker_sets_compacted() {
2047        // The compaction summary is a user message carrying Claude Code's preamble.
2048        let request = with_messages(vec![
2049            Message::text(
2050                Role::User,
2051                "This session is being continued from a previous conversation that ran out of context.",
2052            ),
2053            bash("ls"),
2054        ]);
2055        assert!(ToolSignals::from_request(&request, None).compacted);
2056    }
2057
2058    #[test]
2059    fn codex_compaction_metadata_stays_on_parent_route() {
2060        let mut request = with_messages(vec![bash("ls")]);
2061        request.metadata = Some(Metadata {
2062            is_subagent: true,
2063            agent_kind: Some("compact".to_string()),
2064            ..Default::default()
2065        });
2066        assert!(!ToolSignals::from_request(&request, None).compacted);
2067    }
2068
2069    #[test]
2070    fn no_compaction_marker_stays_uncompacted() {
2071        let request = with_messages(vec![
2072            Message::text(Role::User, "Write a script that parses the log file."),
2073            bash("ls"),
2074        ]);
2075        assert!(!ToolSignals::from_request(&request, None).compacted);
2076    }
2077
2078    #[test]
2079    fn bash_heredoc_counts_as_write() {
2080        // Claude Code's pattern on TB 2.0 — write a scratch file via heredoc.
2081        let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
2082        let sig = ToolSignals::from_request(&request, None);
2083        assert_eq!(
2084            sig.write_count, 1,
2085            "Bash heredoc should bucket into write_count"
2086        );
2087        assert_eq!(sig.edit_count, 0);
2088    }
2089
2090    #[test]
2091    fn bash_sed_inplace_counts_as_edit() {
2092        let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
2093        let sig = ToolSignals::from_request(&request, None);
2094        assert_eq!(
2095            sig.edit_count, 1,
2096            "Bash sed -i should bucket into edit_count"
2097        );
2098        assert_eq!(sig.write_count, 0);
2099    }
2100
2101    #[test]
2102    fn bash_non_mutating_does_not_count() {
2103        // ls, cat, grep — should not increment either counter.
2104        let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
2105        let sig = ToolSignals::from_request(&request, None);
2106        assert_eq!(sig.write_count, 0);
2107        assert_eq!(sig.edit_count, 0);
2108    }
2109
2110    #[test]
2111    fn tests_passed_detects_pytest_with_failure_block() {
2112        // Mixed pytest run: 2 failed + 5 passed → NOT considered tests_passed.
2113        assert!(!detect_tests_passed(
2114            &[("2 failed, 5 passed in 0.56s".to_string(), false)],
2115            DEFAULT_RECENT_WINDOW
2116        ));
2117    }
2118
2119    #[test]
2120    fn tests_passed_accepts_cargo_clean_summary() {
2121        // Cargo's clean-run summary contains "0 failed" — must not trip the
2122        // failure list (regression: previously substring-matched "failed").
2123        assert!(detect_tests_passed(
2124            &[(
2125                "running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string(),
2126                false
2127            )],
2128            DEFAULT_RECENT_WINDOW
2129        ));
2130    }
2131
2132    #[test]
2133    fn tests_passed_rejects_cargo_real_failure() {
2134        // Cargo's actual-failure summary: nonzero count before "failed".
2135        assert!(!detect_tests_passed(
2136            &[(
2137                "running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string(),
2138                false
2139            )],
2140            DEFAULT_RECENT_WINDOW
2141        ));
2142    }
2143
2144    #[test]
2145    fn tests_passed_accepts_go_clean_summary() {
2146        // Go test's clean-run "0 errors" must not trip (regression).
2147        assert!(detect_tests_passed(
2148            &[(
2149                "ok  github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string(),
2150                false
2151            )],
2152            DEFAULT_RECENT_WINDOW
2153        ));
2154    }
2155
2156    #[test]
2157    fn tests_passed_accepts_pytest_zero_errors() {
2158        // Pytest long-form: "0 errors in 0.3s" on a clean run.
2159        assert!(detect_tests_passed(
2160            &[("5 passed, 0 errors in 0.30s".to_string(), false)],
2161            DEFAULT_RECENT_WINDOW
2162        ));
2163    }
2164
2165    #[test]
2166    fn tests_passed_detects_diy_checkmark() {
2167        assert!(detect_tests_passed(
2168            &[("✓ all checks passed".to_string(), false)],
2169            DEFAULT_RECENT_WINDOW
2170        ));
2171    }
2172
2173    #[test]
2174    fn anthropic_bash_heredoc_extracts_command() {
2175        // Anthropic format: tool_use.input is an object, not a JSON string.
2176        let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
2177        let sig = ToolSignals::from_request(&request, None);
2178        assert_eq!(
2179            sig.write_count, 1,
2180            "Anthropic Bash heredoc must also be detected"
2181        );
2182    }
2183
2184    #[test]
2185    fn recent_window_falls_back_to_full_history_when_short() {
2186        let request = with_messages(vec![tc("Write")]);
2187        let sig = ToolSignals::from_request(&request, None);
2188        assert_eq!(sig.recent_write_count, 1);
2189        assert_eq!(sig.recent_edit_count, 0);
2190    }
2191
2192    #[test]
2193    fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
2194        let request = with_messages(vec![tr("output ok"), tr("another ok")]);
2195        let sig = ToolSignals::from_request(&request, None);
2196        assert_eq!(sig.severity, 0.0);
2197        assert_eq!(sig.no_error_streak, 2);
2198    }
2199
2200    // ─── asymmetric-signal extensions ────────────────────────────────────
2201
2202    #[test]
2203    fn todowrite_classifies_as_plan() {
2204        assert_eq!(classify_tool_call("TodoWrite", None), ToolSemantic::Plan);
2205        assert_eq!(classify_tool_call("todo_write", None), ToolSemantic::Plan);
2206    }
2207
2208    #[test]
2209    fn codex_update_plan_classifies_as_plan() {
2210        assert_eq!(classify_tool_call("update_plan", None), ToolSemantic::Plan);
2211    }
2212
2213    #[test]
2214    fn codex_shell_command_runs_bash_pattern_match() {
2215        // shell_command + heredoc -> Write.
2216        assert_eq!(
2217            classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
2218            ToolSemantic::Mutate(MutationKind::Write),
2219        );
2220        // shell_command + read-like inspection -> Read.
2221        assert_eq!(
2222            classify_tool_call("shell_command", Some("ls /app")),
2223            ToolSemantic::Observe,
2224        );
2225        // shell_command without matching patterns -> Unknown.
2226        assert_eq!(
2227            classify_tool_call("shell_command", Some("./run_tests.sh")),
2228            ToolSemantic::Unknown,
2229        );
2230    }
2231
2232    #[test]
2233    fn text_editor_view_is_a_read() {
2234        for name in ["str_replace_based_edit_tool", "text_editor"] {
2235            assert_eq!(
2236                classify_tool_call(name, Some("view")),
2237                ToolSemantic::Observe
2238            );
2239            for command in [
2240                Some("create"),
2241                Some("insert"),
2242                Some("str_replace"),
2243                Some("undo_edit"),
2244                None,
2245            ] {
2246                assert_eq!(
2247                    classify_tool_call(name, command),
2248                    ToolSemantic::Mutate(MutationKind::Edit),
2249                );
2250            }
2251        }
2252
2253        let arguments = [
2254            json!({"command": "view", "path": "/app/main.py"}),
2255            // the Responses wire format sends arguments as a JSON string
2256            json!(r#"{"command":"view","path":"/app/main.py"}"#),
2257        ];
2258        for arguments in arguments {
2259            let call = Message {
2260                role: Role::Assistant,
2261                content: vec![ContentBlock::ToolCall(ToolCall {
2262                    id: String::new(),
2263                    name: "str_replace_based_edit_tool".to_string(),
2264                    arguments,
2265                })],
2266            };
2267            let request = with_messages(vec![call, tr("print('hi')")]);
2268            let sig = ToolSignals::from_request(&request, None);
2269            assert_eq!(sig.read_count, 1);
2270            assert_eq!(sig.recent_read_count, 1);
2271            assert_eq!(sig.edit_count, 0);
2272        }
2273    }
2274
2275    #[test]
2276    fn read_tool_classifies_as_read() {
2277        assert_eq!(classify_tool_call("Read", None), ToolSemantic::Observe);
2278        assert_eq!(classify_tool_call("View", None), ToolSemantic::Observe);
2279    }
2280
2281    #[test]
2282    fn hermes_tool_names_classify() {
2283        // Hermes (NousResearch) file tools route by name.
2284        assert_eq!(
2285            classify_tool_call("write_file", None),
2286            ToolSemantic::Mutate(MutationKind::Write)
2287        );
2288        assert_eq!(
2289            classify_tool_call("patch", None),
2290            ToolSemantic::Mutate(MutationKind::Edit)
2291        );
2292        assert_eq!(classify_tool_call("read_file", None), ToolSemantic::Observe);
2293        assert_eq!(
2294            classify_tool_call("search_files", None),
2295            ToolSemantic::Observe
2296        );
2297        // Hermes runs shell through `terminal`, which carries a `command` arg,
2298        // so its intent comes from the Bash-pattern match like codex's shell_command.
2299        assert_eq!(
2300            classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
2301            ToolSemantic::Mutate(MutationKind::Edit),
2302        );
2303        assert_eq!(
2304            classify_tool_call("terminal", Some("grep foo /app")),
2305            ToolSemantic::Observe,
2306        );
2307        assert_eq!(
2308            classify_tool_call("terminal", Some("./run_tests.sh")),
2309            ToolSemantic::Unknown,
2310        );
2311    }
2312
2313    #[test]
2314    fn bash_read_patterns_classify_as_read() {
2315        let cases = [
2316            "cat /etc/passwd",
2317            "grep foo bar.txt",
2318            "ls /app",
2319            "find . -name '*.py'",
2320        ];
2321        for cmd in cases {
2322            assert_eq!(
2323                classify_tool_call("Bash", Some(cmd)),
2324                ToolSemantic::Observe,
2325                "expected Read for {cmd}"
2326            );
2327        }
2328    }
2329
2330    #[test]
2331    fn codex_inspection_commands_classify_as_read() {
2332        let cases = [
2333            "sed -n '1,80p' src/lib.rs",
2334            "rg -n 'needle' src",
2335            "nl -ba src/lib.rs",
2336            "cat package.json",
2337            "jq '.scripts' package.json",
2338            "git status --short",
2339            "git log --oneline -5",
2340            "git show HEAD:src/lib.rs",
2341            "git branch --show-current",
2342            "git remote -v",
2343            "git config --get remote.origin.url",
2344        ];
2345        for command in cases {
2346            assert_eq!(
2347                classify_tool_call("exec_command", Some(command)),
2348                ToolSemantic::Observe,
2349                "expected Read for {command}"
2350            );
2351        }
2352    }
2353
2354    #[test]
2355    fn quoted_shell_separators_do_not_create_commands() {
2356        for command in ["rg 'foo|rm obsolete.rs'", "rg \"foo; rm obsolete.rs\""] {
2357            assert_eq!(
2358                classify_tool_call("exec_command", Some(command)),
2359                ToolSemantic::Observe,
2360                "quoted text must not be parsed as a command: {command}"
2361            );
2362        }
2363    }
2364
2365    #[test]
2366    fn codex_shell_mutations_classify_as_production() {
2367        let writes = [
2368            "cp source.rs destination.rs",
2369            "mkdir -p src/generated",
2370            "touch src/generated/mod.rs",
2371            "git show HEAD:file.rs > file.rs",
2372            "node <<'node'\nfs.writefilesync('file.js', text)\nnode",
2373        ];
2374        for command in writes {
2375            assert_eq!(
2376                classify_tool_call("exec_command", Some(command)),
2377                ToolSemantic::Mutate(MutationKind::Write),
2378                "expected Write for {command}"
2379            );
2380        }
2381
2382        let edits = [
2383            "mv old.rs new.rs",
2384            "rm obsolete.rs",
2385            "gofmt -w main.go",
2386            "cargo fmt",
2387            "ruff check --fix src",
2388            "perl -0pi -e 's/old/new/' src/lib.rs",
2389            "npx prettier --write src/lib.ts",
2390            "uv run ruff format src",
2391            "git apply fix.patch",
2392        ];
2393        for command in edits {
2394            assert_eq!(
2395                classify_tool_call("exec_command", Some(command)),
2396                ToolSemantic::Mutate(MutationKind::Edit),
2397                "expected Edit for {command}"
2398            );
2399        }
2400    }
2401
2402    #[test]
2403    fn formatter_checks_are_not_edits() {
2404        for command in [
2405            "cargo fmt --check",
2406            "ruff format --check src",
2407            "black --check src",
2408        ] {
2409            assert_ne!(
2410                classify_tool_call("exec_command", Some(command)),
2411                ToolSemantic::Mutate(MutationKind::Edit),
2412                "read-only formatter check must not be Edit: {command}"
2413            );
2414        }
2415    }
2416
2417    #[test]
2418    fn embedded_comparison_is_not_a_shell_write() {
2419        let command = "node <<'node'\nif (index > 0) console.log(index)\nnode";
2420        assert_eq!(
2421            classify_tool_call("exec_command", Some(command)),
2422            ToolSemantic::Unknown
2423        );
2424    }
2425
2426    #[test]
2427    fn bash_write_precedence_over_read() {
2428        // `cat /file > out` contains both `cat /` (read) and ` > ` (write);
2429        // write redirection must win.
2430        assert_eq!(
2431            classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
2432            ToolSemantic::Mutate(MutationKind::Write),
2433        );
2434    }
2435
2436    #[test]
2437    fn pure_bash_streak_counts_trailing_other() {
2438        // 5 trailing non-classified Bash calls → streak == 5.
2439        let request = with_messages(vec![
2440            bash("make"),
2441            tr("ok"),
2442            bash("./configure"),
2443            tr("ok"),
2444            bash("make install"),
2445            tr("ok"),
2446            bash("./run.sh"),
2447            tr("ok"),
2448            bash("./test"),
2449            tr("ok"),
2450        ]);
2451        let sig = ToolSignals::from_request(&request, None);
2452        assert_eq!(sig.pure_bash_streak, 5);
2453        assert_eq!(sig.write_count, 0);
2454        assert_eq!(sig.read_count, 0);
2455    }
2456
2457    #[test]
2458    fn pure_bash_streak_resets_on_write() {
2459        let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
2460        let sig = ToolSignals::from_request(&request, None);
2461        assert_eq!(sig.pure_bash_streak, 0);
2462        assert_eq!(sig.write_count, 1);
2463    }
2464
2465    #[test]
2466    fn recent_window_tracks_todowrite_and_read() {
2467        // Final 3 tool calls: TodoWrite, Read, TodoWrite.
2468        let request = with_messages(vec![
2469            bash("make"),
2470            tr("ok"),
2471            tc("TodoWrite"),
2472            tr("ok"),
2473            tc("Read"),
2474            tr("ok"),
2475            tc("TodoWrite"),
2476            tr("ok"),
2477        ]);
2478        let sig = ToolSignals::from_request(&request, None);
2479        assert_eq!(sig.todowrite_count, 2);
2480        assert_eq!(sig.recent_todowrite_count, 2);
2481        assert_eq!(sig.read_count, 1);
2482        assert_eq!(sig.recent_read_count, 1);
2483    }
2484
2485    #[test]
2486    fn configured_tool_semantics_extend_the_builtin_vocabulary() {
2487        let semantics = ToolSemantics {
2488            observe: vec!["KB_search".to_string()],
2489            mutate: vec!["send_payment_request".to_string()],
2490            plan: vec!["create_research_plan".to_string()],
2491            new: vec!["send_message_to_user".to_string()],
2492        };
2493        semantics.validate().expect("valid additive semantics");
2494        let request = with_messages(vec![
2495            tc("Read"),
2496            tc("Write"),
2497            tc("TodoWrite"),
2498            tc("kb_SEARCH"),
2499            tc("send_payment_request"),
2500            tc("create_research_plan"),
2501            tc("send_message_to_user"),
2502            tc("unlisted_tool"),
2503        ]);
2504
2505        let signal = ToolSignals::from_request_with_semantics(&request, None, &semantics);
2506
2507        assert_eq!(signal.read_count, 2);
2508        assert_eq!(signal.write_count, 2);
2509        assert_eq!(signal.todowrite_count, 2);
2510        assert_eq!(signal.new_count, 1);
2511        assert_eq!(signal.recent_new_count, 1);
2512        assert_eq!(signal.pure_bash_streak, 1);
2513    }
2514
2515    #[test]
2516    fn configured_tool_semantics_match_namespaced_and_mcp_tools() {
2517        // The Responses decoder flattens namespaced tools and records the mapping.
2518        let mut request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
2519        request.llm_request.extensions.fields.insert(
2520            TOOL_NAMESPACES_KEY.to_string(),
2521            json!({"mcp__billing__send_payment_request": "mcp__billing"}),
2522        );
2523
2524        // Claude Code sends MCP tools flat, with no namespace mapping.
2525        let claude_request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
2526
2527        for request in [&request, &claude_request] {
2528            for name in ["send_payment_request", "mcp__billing__send_payment_request"] {
2529                let semantics = ToolSemantics {
2530                    mutate: vec![name.to_string()],
2531                    ..Default::default()
2532                };
2533                let signal = ToolSignals::from_request_with_semantics(request, None, &semantics);
2534                assert_eq!(signal.write_count, 1, "{name}");
2535            }
2536        }
2537    }
2538
2539    #[test]
2540    fn configured_tool_semantics_only_fold_ascii_case() {
2541        let semantics = ToolSemantics {
2542            observe: vec!["kb_search".to_string()],
2543            ..Default::default()
2544        };
2545
2546        assert_eq!(
2547            classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
2548            ToolSemantic::Observe
2549        );
2550        // U+212A lowercases to ASCII `k` under Unicode rules, but custom names
2551        // intentionally ignore only ASCII case.
2552        assert_eq!(
2553            classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
2554            ToolSemantic::Unknown
2555        );
2556    }
2557
2558    #[test]
2559    fn custom_semantics_preserve_builtin_unicode_lowercasing() {
2560        let semantics = ToolSemantics {
2561            observe: vec!["lookup_customer".to_string()],
2562            ..Default::default()
2563        };
2564
2565        // This matched the built-in `notebookedit` before custom semantics existed.
2566        assert_eq!(
2567            classify_tool_call_with_semantics("notebooKedit", None, &semantics),
2568            ToolSemantic::Mutate(MutationKind::Edit)
2569        );
2570    }
2571
2572    #[test]
2573    fn configured_semantics_never_replace_builtin_classifications() {
2574        let semantics = ToolSemantics {
2575            observe: vec!["lookup_customer".to_string()],
2576            mutate: vec!["send_payment".to_string()],
2577            plan: vec!["create_workflow".to_string()],
2578            new: vec!["send_message".to_string()],
2579        };
2580
2581        for name in WRITE_TOOL_NAMES {
2582            assert_eq!(
2583                classify_tool_call_with_semantics(name, None, &semantics),
2584                ToolSemantic::Mutate(MutationKind::Write),
2585                "write tool {name:?} changed classification"
2586            );
2587        }
2588        for name in EDIT_TOOL_NAMES {
2589            assert_eq!(
2590                classify_tool_call_with_semantics(name, None, &semantics),
2591                ToolSemantic::Mutate(MutationKind::Edit),
2592                "edit tool {name:?} changed classification"
2593            );
2594        }
2595        for name in READ_TOOL_NAMES {
2596            assert_eq!(
2597                classify_tool_call_with_semantics(name, None, &semantics),
2598                ToolSemantic::Observe,
2599                "read tool {name:?} changed classification"
2600            );
2601        }
2602        for name in PLAN_TOOL_NAMES {
2603            assert_eq!(
2604                classify_tool_call_with_semantics(name, None, &semantics),
2605                ToolSemantic::Plan,
2606                "plan tool {name:?} changed classification"
2607            );
2608        }
2609
2610        for (command, expected) in [
2611            ("cat /tmp/input", ToolSemantic::Observe),
2612            (
2613                "cat /tmp/input > /tmp/output",
2614                ToolSemantic::Mutate(MutationKind::Write),
2615            ),
2616            (
2617                "sed -i 's/a/b/' /tmp/file",
2618                ToolSemantic::Mutate(MutationKind::Edit),
2619            ),
2620            ("./run_tests.sh", ToolSemantic::Unknown),
2621        ] {
2622            assert_eq!(
2623                classify_tool_call_with_semantics("BASH", Some(command), &semantics),
2624                expected,
2625                "bash command {command:?} changed classification"
2626            );
2627        }
2628    }
2629
2630    #[test]
2631    fn configured_semantics_score_like_their_builtin_equivalents() {
2632        let semantics = ToolSemantics {
2633            observe: vec!["lookup_customer".to_string()],
2634            mutate: vec!["send_payment".to_string()],
2635            plan: vec!["create_workflow".to_string()],
2636            ..Default::default()
2637        };
2638
2639        for (builtin, configured) in [
2640            ("Read", "lookup_customer"),
2641            ("Write", "send_payment"),
2642            ("TodoWrite", "create_workflow"),
2643        ] {
2644            let messages_before_tool = || {
2645                vec![
2646                    Message::text(Role::User, "start"),
2647                    Message::text(Role::Assistant, "working"),
2648                    Message::text(Role::User, "continue"),
2649                    Message::text(Role::Assistant, "working"),
2650                    Message::text(Role::User, "continue"),
2651                    Message::text(Role::Assistant, "working"),
2652                    Message::text(Role::User, "continue"),
2653                ]
2654            };
2655            let mut builtin_messages = messages_before_tool();
2656            builtin_messages.push(tc(builtin));
2657            let mut configured_messages = messages_before_tool();
2658            configured_messages.push(tc(configured));
2659
2660            let builtin_score = score_signal(&ToolSignals::from_request(
2661                &with_messages(builtin_messages),
2662                None,
2663            ));
2664            let configured_score = score_signal(&ToolSignals::from_request_with_semantics(
2665                &with_messages(configured_messages),
2666                None,
2667                &semantics,
2668            ));
2669
2670            assert_ne!(
2671                builtin_score.score, 0.0,
2672                "the {builtin:?} control must exercise a scoring dimension"
2673            );
2674            assert_eq!(
2675                configured_score, builtin_score,
2676                "configured tool {configured:?} must score exactly like {builtin:?}"
2677            );
2678        }
2679    }
2680
2681    #[test]
2682    fn tool_semantics_reject_duplicates_and_builtin_reclassification() {
2683        let duplicate = ToolSemantics {
2684            observe: vec!["lookup".to_string()],
2685            mutate: vec!["LOOKUP".to_string()],
2686            ..Default::default()
2687        };
2688        assert!(
2689            duplicate
2690                .validate()
2691                .expect_err("duplicate should fail")
2692                .to_string()
2693                .contains("appears in both")
2694        );
2695
2696        let builtin = ToolSemantics {
2697            new: vec!["write_file".to_string()],
2698            ..Default::default()
2699        };
2700        assert!(
2701            builtin
2702                .validate()
2703                .expect_err("built-in should fail")
2704                .to_string()
2705                .contains("built-in semantics")
2706        );
2707
2708        let empty = ToolSemantics {
2709            observe: vec![" \t".to_string()],
2710            ..Default::default()
2711        };
2712        assert!(
2713            empty
2714                .validate()
2715                .expect_err("empty name should fail")
2716                .to_string()
2717                .contains("empty tool name")
2718        );
2719    }
2720}