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                    let text = text.to_lowercase();
901                    compacted |= text.contains(COMPACTION_MARKER)
902                        || text.lines().any(|line| {
903                            HERMES_COMPACTION_MARKERS
904                                .iter()
905                                .any(|marker| line.trim_start().starts_with(marker))
906                        });
907                }
908                _ => {}
909            }
910        }
911    }
912
913    let mut signal = build_signal(
914        tool_texts,
915        tool_calls,
916        messages.len() as u32,
917        recent_window,
918        semantics,
919    );
920    signal.compacted = compacted;
921    signal.tool_result_count = u32::try_from(tool_result_count).unwrap_or(u32::MAX);
922    signal.assistant_turn_count = u32::try_from(assistant_turn_count).unwrap_or(u32::MAX);
923    signal
924}
925
926/// Distinctive preamble Claude Code injects as a user message when it compacts an
927/// overflowed context. Matched case-insensitively; normal task text never contains it.
928const COMPACTION_MARKER: &str = "session is being continued";
929
930// Hermes can merge the summary into an existing message or restate an active task.
931const HERMES_COMPACTION_MARKERS: &[&str] = &[
932    "[context compaction \u{2014} reference only]",
933    "[still in progress \u{2014} this is the active request, restated after the compaction boundary",
934];
935
936/// The tool part of an `mcp__<server>__<tool>` name, the form Claude Code uses
937/// for MCP tools. The server name is assumed not to contain `__`; the tool name
938/// may.
939fn mcp_tool_name(name: &str) -> Option<&str> {
940    let (_server, tool) = name.strip_prefix("mcp__")?.split_once("__")?;
941    (!tool.is_empty()).then_some(tool)
942}
943
944/// The shell command a tool call carries, when it has one. Harnesses name the
945/// field `command`; anything else is a tool whose category comes from its name.
946fn command_of(arguments: &Value) -> Option<String> {
947    // the Responses wire format sends arguments as a JSON string, not an object
948    let decoded = arguments
949        .as_str()
950        .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
951    let object = decoded.as_ref().unwrap_or(arguments);
952
953    ["command", "cmd", "input"]
954        .iter()
955        .filter_map(|key| object.get(*key))
956        .find_map(command_text)
957}
958
959/// A command field as lowercase text, from a string or an argv array.
960fn command_text(value: &Value) -> Option<String> {
961    match value {
962        Value::String(text) => Some(text.to_lowercase()),
963        Value::Array(parts) => {
964            let joined = parts
965                .iter()
966                .filter_map(Value::as_str)
967                .collect::<Vec<_>>()
968                .join(" ");
969            (!joined.is_empty()).then(|| joined.to_lowercase())
970        }
971        _ => None,
972    }
973}
974
975/// Text carried by a content block, ignoring the non-textual kinds.
976fn text_of(block: &ContentBlock) -> Option<&str> {
977    match block {
978        ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
979        _ => None,
980    }
981}
982
983fn build_signal(
984    tool_texts: Vec<(String, bool)>,
985    tool_calls: Vec<ObservedToolCall>,
986    turn_depth: u32,
987    recent_window: usize,
988    semantics: &ToolSemantics,
989) -> ToolSignals {
990    // Windowed severity: take the MAX severity across the last `recent_window` tool
991    // results rather than only the last one. An error's severity then persists for
992    // the recent window and decays out of it — parallel to the windowed `recent_*`
993    // counts — so a fix written a couple of turns after an error still routes on the
994    // error signal instead of the router flapping straight back to the weak tier.
995    let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
996    let mut severity = 0.0f32;
997    let mut failure_fingerprints = Vec::new();
998    let mut repeated_failure = false;
999    for (text, is_error) in &tool_texts[sev_start..] {
1000        let (sev, _patterns) = classify_text(text);
1001        // Explicit failure is at least hard; retain stronger text diagnostics.
1002        let sev = if *is_error { sev.max(HARD) } else { sev };
1003        if sev > severity {
1004            severity = sev;
1005        }
1006        if let Some(fingerprint) = failure_fingerprint(text, *is_error) {
1007            repeated_failure |= failure_fingerprints.contains(&fingerprint);
1008            failure_fingerprints.push(fingerprint);
1009        }
1010    }
1011
1012    let no_error_streak = compute_no_error_streak(&tool_texts);
1013
1014    // Single pass: cumulative + sliding-window counters together. Also tracks
1015    // the trailing pure-bash streak (consecutive `Unknown` calls back
1016    // from the end) — the build-pit proxy.
1017    let recent_start = tool_calls.len().saturating_sub(recent_window);
1018    let mut write_count = 0u32;
1019    let mut edit_count = 0u32;
1020    let mut read_count = 0u32;
1021    let mut todowrite_count = 0u32;
1022    let mut recent_write_count = 0u32;
1023    let mut recent_edit_count = 0u32;
1024    let mut recent_read_count = 0u32;
1025    let mut recent_todowrite_count = 0u32;
1026    let mut new_count = 0u32;
1027    let mut recent_new_count = 0u32;
1028    let mut pure_bash_streak = 0u32;
1029    let mut streak_open = true;
1030    for (i, tc) in tool_calls.iter().enumerate().rev() {
1031        // The joined name wins, so configs that list it keep working.
1032        let mut cat = classify_tool_call_with_semantics(&tc.name, tc.command.as_deref(), semantics);
1033        if matches!(cat, ToolSemantic::Unknown)
1034            && let Some(bare_name) = tc.bare_name
1035        {
1036            cat = classify_tool_call_with_semantics(bare_name, tc.command.as_deref(), semantics);
1037        }
1038        if streak_open {
1039            if matches!(cat, ToolSemantic::Unknown) {
1040                pure_bash_streak += 1;
1041            } else {
1042                streak_open = false;
1043            }
1044        }
1045        match cat {
1046            ToolSemantic::Mutate(MutationKind::Write) => {
1047                write_count += 1;
1048                if i >= recent_start {
1049                    recent_write_count += 1;
1050                }
1051            }
1052            ToolSemantic::Mutate(MutationKind::Edit) => {
1053                edit_count += 1;
1054                if i >= recent_start {
1055                    recent_edit_count += 1;
1056                }
1057            }
1058            ToolSemantic::Observe => {
1059                read_count += 1;
1060                if i >= recent_start {
1061                    recent_read_count += 1;
1062                }
1063            }
1064            ToolSemantic::Plan => {
1065                todowrite_count += 1;
1066                if i >= recent_start {
1067                    recent_todowrite_count += 1;
1068                }
1069            }
1070            ToolSemantic::New => {
1071                new_count += 1;
1072                if i >= recent_start {
1073                    recent_new_count += 1;
1074                }
1075            }
1076            ToolSemantic::Unknown => {}
1077        }
1078    }
1079
1080    let tests_passed = detect_tests_passed(&tool_texts, recent_window);
1081
1082    ToolSignals {
1083        severity,
1084        repeated_failure,
1085        no_error_streak,
1086        edit_count,
1087        write_count,
1088        read_count,
1089        todowrite_count,
1090        recent_edit_count,
1091        recent_write_count,
1092        recent_read_count,
1093        recent_todowrite_count,
1094        new_count,
1095        recent_new_count,
1096        pure_bash_streak,
1097        tests_passed,
1098        turn_depth,
1099        // Set by extract_tool_signals_with_window after the format-specific extract,
1100        // which scans all message contents for the compaction marker and tallies
1101        // the raw conversation-shape counts.
1102        tool_result_count: 0,
1103        assistant_turn_count: 0,
1104        compacted: false,
1105    }
1106}
1107
1108// ─── pure helpers ─────────────────────────────────────────────────────────────
1109
1110/// Normalise a JSON tool-result content value to a plain string.
1111fn content_to_text(content: Option<&Value>) -> Option<String> {
1112    match content? {
1113        Value::String(s) => Some(s.clone()),
1114        Value::Array(blocks) => {
1115            let parts: Vec<&str> = blocks
1116                .iter()
1117                .filter_map(|b| {
1118                    b.as_object()
1119                        .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
1120                        .and_then(|o| o.get("text"))
1121                        .and_then(Value::as_str)
1122                })
1123                .collect();
1124            if parts.is_empty() {
1125                None
1126            } else {
1127                Some(parts.join("\n"))
1128            }
1129        }
1130        _ => None,
1131    }
1132}
1133
1134/// Match tool text and structured result fields against error patterns.
1135///
1136/// Returns `(max_severity, matched_pattern_names)`.
1137pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
1138    let lower = text.to_lowercase();
1139    let mut patterns = Vec::new();
1140    let mut severity: f32 = 0.0;
1141    for (name, sev, substrings) in ERROR_PATTERNS {
1142        if substrings.iter().any(|sub| lower.contains(sub)) {
1143            patterns.push(name.to_string());
1144            severity = severity.max(*sev);
1145        }
1146    }
1147    // Hermes can append a loop warning after the JSON result.
1148    let result = serde_json::Deserializer::from_str(text)
1149        .into_iter::<Value>()
1150        .next()
1151        .and_then(|result| result.ok())
1152        .unwrap_or_default();
1153    let nonzero_exit = result["exit_code"].as_i64().is_some_and(|code| code != 0);
1154    let tool_error = result["success"].as_bool() == Some(false)
1155        || result["error"]
1156            .as_str()
1157            .is_some_and(|error| !error.trim().is_empty());
1158    if (nonzero_exit || has_nonzero_exit_status(&lower))
1159        && !patterns.iter().any(|p| p == "exit_nonzero")
1160    {
1161        patterns.push("exit_nonzero".to_string());
1162        severity = severity.max(SOFT);
1163    }
1164    for (name, matched) in [
1165        ("tool_error", tool_error),
1166        ("compile_error", has_compiler_diagnostic(&lower)),
1167        ("runtime_exception", has_runtime_exception(&lower)),
1168        ("runtime_panic", has_runtime_panic(&lower)),
1169        ("patch_error", has_patch_failure(&lower)),
1170    ] {
1171        if matched && !patterns.iter().any(|pattern| pattern == name) {
1172            patterns.push(name.to_string());
1173            severity = severity.max(HARD);
1174        }
1175    }
1176    (severity, patterns)
1177}
1178
1179/// Stable identity for a material failure. Soft non-zero exits need an explicit
1180/// failure flag to count as a repeated mistake.
1181fn failure_fingerprint(text: &str, is_error: bool) -> Option<String> {
1182    let (severity, patterns) = classify_text(text);
1183    if severity < HARD && !is_error {
1184        return None;
1185    }
1186
1187    let lower = text.to_lowercase();
1188    let diagnostic = lower
1189        .lines()
1190        .find(|line| is_failure_diagnostic(line))
1191        .or_else(|| lower.lines().find(|line| !line.trim().is_empty()))
1192        .unwrap_or_default();
1193    let normalized = normalize_failure_text(diagnostic);
1194    Some(format!("{}|{normalized}", patterns.join(",")))
1195}
1196
1197fn is_failure_diagnostic(line: &str) -> bool {
1198    let line = line.trim();
1199    [
1200        "error",
1201        "exception",
1202        "panic",
1203        "failed",
1204        "timed out",
1205        "timeout",
1206        "connection refused",
1207        "cannot allocate memory",
1208        "out of memory",
1209        "not found",
1210    ]
1211    .iter()
1212    .any(|marker| line.contains(marker))
1213}
1214
1215/// Removes values that normally change between retries while retaining the
1216/// diagnostic wording that distinguishes one failure from another.
1217fn normalize_failure_text(text: &str) -> String {
1218    let mut normalized = String::new();
1219    for word in text.split_whitespace() {
1220        if !normalized.is_empty() {
1221            normalized.push(' ');
1222        }
1223        let mut in_digits = false;
1224        if word.starts_with('/') || word.contains("/src/") || word.contains("/tmp/") {
1225            normalized.push_str("<path>");
1226            continue;
1227        }
1228        for character in word.chars() {
1229            if character.is_ascii_digit() {
1230                if !in_digits {
1231                    normalized.push('#');
1232                    in_digits = true;
1233                }
1234            } else {
1235                normalized.push(character);
1236                in_digits = false;
1237            }
1238        }
1239    }
1240    normalized.chars().take(240).collect()
1241}
1242
1243fn has_compiler_diagnostic(lower: &str) -> bool {
1244    lower.lines().any(|line| {
1245        let line = line.trim_start();
1246        if matches!(
1247            line,
1248            "compilation failed" | "error: compilation failed" | "error: could not compile"
1249        ) || line.starts_with("error: could not compile ")
1250        {
1251            return true;
1252        }
1253
1254        let Some(rest) = line.strip_prefix("error[e") else {
1255            return false;
1256        };
1257        let Some((code, _)) = rest.split_once("]:") else {
1258            return false;
1259        };
1260        !code.is_empty() && code.chars().all(|character| character.is_ascii_digit())
1261    })
1262}
1263
1264fn has_runtime_exception(lower: &str) -> bool {
1265    let has_exception_line = lower.lines().any(|line| {
1266        let line = line.trim_start();
1267        [
1268            "typeerror:",
1269            "referenceerror:",
1270            "rangeerror:",
1271            "runtimeerror:",
1272            "keyerror:",
1273            "attributeerror:",
1274        ]
1275        .iter()
1276        .any(|prefix| line.starts_with(prefix))
1277    });
1278    has_exception_line && (lower.contains("\n    at ") || lower.contains("\n  at "))
1279}
1280
1281fn has_runtime_panic(lower: &str) -> bool {
1282    lower
1283        .lines()
1284        .any(|line| line.trim_start().starts_with("panic: runtime error:"))
1285        && (lower.contains("\ngoroutine ") || lower.contains("[signal sig"))
1286}
1287
1288fn has_patch_failure(lower: &str) -> bool {
1289    lower.lines().any(|line| {
1290        let line = line.trim_start();
1291        line.starts_with("error: patch failed:")
1292            || line.starts_with("patch failed:")
1293            || line.contains(": patch does not apply")
1294            || line.starts_with("invalid context")
1295    })
1296}
1297
1298/// Detects `exit_nonzero` only when a supported exit phrase is followed by a
1299/// nonzero decimal status.
1300///
1301/// Codex includes "Process exited with code 0" on clean tool results, so exit
1302/// phrases must parse their numeric status instead of matching the phrase alone.
1303fn has_nonzero_exit_status(lower: &str) -> bool {
1304    NONZERO_EXIT_PHRASES
1305        .iter()
1306        .any(|phrase| phrase_followed_by_nonzero_integer(lower, phrase))
1307}
1308
1309/// Matches common "exit code/status N" spellings after optional separators.
1310fn phrase_followed_by_nonzero_integer(lower: &str, phrase: &str) -> bool {
1311    let mut cursor = 0usize;
1312    while let Some(rel) = lower[cursor..].find(phrase) {
1313        let value_start = cursor + rel + phrase.len();
1314        let rest = lower[value_start..].trim_start_matches(|c: char| {
1315            c.is_ascii_whitespace() || matches!(c, ':' | '=' | '\'' | '"' | '`')
1316        });
1317        let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
1318        if !digits.is_empty() && digits.chars().any(|d| d != '0') {
1319            return true;
1320        }
1321        cursor = value_start;
1322    }
1323    false
1324}
1325
1326fn compute_no_error_streak(tool_texts: &[(String, bool)]) -> u32 {
1327    let mut streak = 0u32;
1328    for (text, is_error) in tool_texts.iter().rev() {
1329        let (sev, _) = classify_text(text);
1330        if *is_error || sev > 0.0 {
1331            break;
1332        }
1333        streak += 1;
1334    }
1335    streak
1336}
1337
1338fn detect_tests_passed(tool_texts: &[(String, bool)], recent_window: usize) -> bool {
1339    let start = tool_texts.len().saturating_sub(recent_window.max(1));
1340    let recent = &tool_texts[start..];
1341    let after_latest_failure = recent
1342        .iter()
1343        .rposition(|(text, is_error)| *is_error || classify_text(text).0 > 0.0)
1344        .map_or(recent, |index| &recent[index + 1..]);
1345    after_latest_failure.iter().any(|(text, _)| {
1346        let lower = text.to_lowercase();
1347        TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
1348            && !TEST_FAILURE_LITERAL.iter().any(|p| lower.contains(p))
1349            && !has_nonzero_failure_count(&lower)
1350    })
1351}
1352
1353// True iff `lower` contains a `NUMERIC_FAILURE_KEYWORDS` token preceded
1354// (modulo whitespace) by a nonzero integer. The "modulo whitespace" lets
1355// "1 failed", "1\nfailed", and "1  failed" all trip; the nonzero guard
1356// keeps cargo's "0 failed" / go's "0 errors" / pytest's "0 errors in"
1357// summaries from being misread as failures on a clean run.
1358fn has_nonzero_failure_count(lower: &str) -> bool {
1359    for kw in NUMERIC_FAILURE_KEYWORDS {
1360        let mut cursor = 0usize;
1361        while let Some(rel) = lower[cursor..].find(kw) {
1362            let kw_start = cursor + rel;
1363            let kw_end = kw_start + kw.len();
1364            // Word boundary AFTER the keyword — "errors" mid-word (e.g.
1365            // "errored") shouldn't count as a failure-count site.
1366            let boundary_after = lower[kw_end..]
1367                .chars()
1368                .next()
1369                .is_none_or(|c| !c.is_ascii_alphanumeric());
1370            if boundary_after {
1371                let prefix = &lower[..kw_start];
1372                let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
1373                let digits_rev: String = trimmed
1374                    .chars()
1375                    .rev()
1376                    .take_while(|c| c.is_ascii_digit())
1377                    .collect();
1378                if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
1379                    return true;
1380                }
1381            }
1382            cursor = kw_start + kw.len();
1383        }
1384    }
1385    false
1386}
1387
1388// ─── tests ───────────────────────────────────────────────────────────────────
1389
1390#[cfg(test)]
1391mod tests {
1392    use super::*;
1393    use crate::algorithms::util::stage::score_signal;
1394    use serde_json::json;
1395    use switchyard_protocol::codex_namespaces::TOOL_NAMESPACES_KEY;
1396    use switchyard_protocol::{
1397        ContentBlock, LlmRequest, Message, Metadata, Role, ToolCall, ToolResult,
1398    };
1399
1400    fn with_messages(messages: Vec<Message>) -> Request {
1401        Request {
1402            llm_request: LlmRequest {
1403                messages,
1404                ..LlmRequest::default()
1405            },
1406            raw_request: None,
1407            metadata: None,
1408        }
1409    }
1410
1411    // assistant message with a single named tool call
1412    fn tc(name: &str) -> Message {
1413        Message {
1414            role: Role::Assistant,
1415            content: vec![ContentBlock::ToolCall(ToolCall {
1416                id: String::new(),
1417                name: name.to_string(),
1418                arguments: json!({}),
1419            })],
1420        }
1421    }
1422
1423    // assistant Bash message carrying `command`
1424    fn bash(command: &str) -> Message {
1425        Message {
1426            role: Role::Assistant,
1427            content: vec![ContentBlock::ToolCall(ToolCall {
1428                id: String::new(),
1429                name: "Bash".to_string(),
1430                arguments: json!({"command": command}),
1431            })],
1432        }
1433    }
1434
1435    // a tool result message (goes in a user-role message, as in Anthropic's normalised form)
1436    fn tr(text: &str) -> Message {
1437        Message {
1438            role: Role::User,
1439            content: vec![ContentBlock::ToolResult(ToolResult {
1440                tool_call_id: String::new(),
1441                content: vec![ContentBlock::Text {
1442                    text: text.to_string(),
1443                }],
1444                is_error: None,
1445            })],
1446        }
1447    }
1448
1449    #[test]
1450    fn clean_text_has_zero_severity() {
1451        let (sev, patterns) = classify_text("everything went fine");
1452        assert_eq!(sev, 0.0);
1453        assert!(patterns.is_empty());
1454    }
1455
1456    #[test]
1457    fn structured_tool_failures_affect_recovery_signals() {
1458        for (text, severity) in [
1459            (r#"{"output":"","exit_code":7,"error":null}"#, SOFT),
1460            (r#"{"output":"","exit_code":-1,"error":null}"#, SOFT),
1461            (r#"{"success":false,"error":"No matching text"}"#, HARD),
1462            (r#"{"success":false,"error":null}"#, HARD),
1463            (
1464                r#"{"error":"Overwrite refused","stale_write_blocked":true}"#,
1465                HARD,
1466            ),
1467            (r#"{"output":"out of memory","exit_code":1}"#, CRITICAL),
1468            (r#"{"output":"done","exit_code":0,"error":null}"#, 0.0),
1469            (r#"{"success":true,"error":"  "}"#, 0.0),
1470            (r#"{"output":"running","exit_code":null,"error":null}"#, 0.0),
1471            (r#"{"output":{"success":false},"exit_code":0}"#, 0.0),
1472        ] {
1473            let warned = format!("{text}\n\n[Tool loop warning: repeated identical call]");
1474            let request = with_messages(vec![tr("5 passed in 0.12s"), tr(text), tr(&warned)]);
1475            let signal = ToolSignals::from_request(&request, None);
1476            let clean = severity == 0.0;
1477            assert_eq!(signal.severity, severity, "{text}");
1478            assert_eq!(signal.no_error_streak, if clean { 3 } else { 0 }, "{text}");
1479            assert_eq!(signal.repeated_failure, severity >= HARD, "{text}");
1480            assert_eq!(signal.tests_passed, clean, "{text}");
1481        }
1482    }
1483
1484    #[test]
1485    fn traceback_is_hard() {
1486        let (sev, patterns) = classify_text("Traceback (most recent call last):\n  ValueError");
1487        assert_eq!(sev, HARD);
1488        assert!(patterns.contains(&"traceback".to_string()));
1489    }
1490
1491    #[test]
1492    fn oom_is_critical() {
1493        let (sev, _) = classify_text("Out of memory: kill process 1234");
1494        assert_eq!(sev, CRITICAL);
1495    }
1496
1497    #[test]
1498    fn connection_refused_is_hard() {
1499        let (severity, _) = classify_text("Connection refused on port 8000");
1500        assert_eq!(severity, HARD);
1501    }
1502
1503    #[test]
1504    fn repeated_failure_ignores_volatile_paths_and_numbers() {
1505        let request = with_messages(vec![
1506            tr("error[E0308]: mismatched types at /tmp/a/src/lib.rs:12"),
1507            tr("error[E0308]: mismatched types at /tmp/b/src/lib.rs:47"),
1508        ]);
1509        assert!(ToolSignals::from_request(&request, None).repeated_failure);
1510    }
1511
1512    #[test]
1513    fn different_failures_are_not_repeated() {
1514        let request = with_messages(vec![
1515            tr("error[E0308]: mismatched types"),
1516            tr("error[E0509]: cannot move out"),
1517        ]);
1518        assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1519    }
1520
1521    #[test]
1522    fn one_material_failure_is_not_repeated() {
1523        let request = with_messages(vec![tr("Connection refused on port 8000")]);
1524        assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1525    }
1526
1527    /// Explicit failures count even without diagnostic text and cannot signal recovery.
1528    #[test]
1529    fn structured_tool_failures_feed_error_and_recovery_signals() {
1530        for text in ["Dependency unavailable", "", "5 passed in 0.12s"] {
1531            let mut failed = tr(text);
1532            let ContentBlock::ToolResult(result) = &mut failed.content[0] else {
1533                panic!("expected tool result");
1534            };
1535            result.is_error = Some(true);
1536            let mut request = with_messages(vec![tr("5 passed in 0.12s"), failed.clone()]);
1537            let signals = ToolSignals::from_request(&request, Some(3));
1538            assert_eq!(signals.severity, HARD);
1539            assert!(!signals.repeated_failure);
1540            assert_eq!(signals.no_error_streak, 0);
1541            assert!(!signals.tests_passed);
1542
1543            request.llm_request.messages.push(failed);
1544            assert!(ToolSignals::from_request(&request, Some(3)).repeated_failure);
1545            request.llm_request.messages.push(tr("5 passed in 0.12s"));
1546            let recovered = ToolSignals::from_request(&request, Some(1));
1547            assert_eq!(recovered.severity, 0.0);
1548            assert!(!recovered.repeated_failure);
1549            assert_eq!(recovered.no_error_streak, 1);
1550            assert!(recovered.tests_passed);
1551        }
1552    }
1553
1554    #[test]
1555    fn severity_is_max_across_patterns() {
1556        // exit_nonzero (SOFT) + traceback (HARD) → HARD.
1557        let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
1558        assert_eq!(sev, HARD);
1559    }
1560
1561    #[test]
1562    fn codex_process_exit_zero_stays_clean() {
1563        let (sev, patterns) =
1564            classify_text("Chunk ID: abc\nProcess exited with code 0\nOutput:\nok");
1565        assert_eq!(sev, 0.0);
1566        assert!(!patterns.contains(&"exit_nonzero".to_string()));
1567    }
1568
1569    #[test]
1570    fn nonzero_exit_codes_are_soft_errors() {
1571        let cases = [
1572            "Process exited with code 1",
1573            "Process exited with code 127",
1574            "exit code: 2",
1575            "exit status 3",
1576            "exited with status 9",
1577        ];
1578        for case in cases {
1579            let (sev, patterns) = classify_text(case);
1580            assert_eq!(sev, SOFT, "expected soft severity for {case}");
1581            assert!(patterns.contains(&"exit_nonzero".to_string()));
1582        }
1583    }
1584
1585    #[test]
1586    fn partial_process_failures_are_hard_errors() {
1587        let cases = [
1588            (
1589                "Process running with session ID 12\nOutput:\nerror[E0509]: cannot move out",
1590                "compile_error",
1591            ),
1592            (
1593                "Process exited with code 0\nOutput:\nTypeError: value is undefined\n    at main.js:1:2",
1594                "runtime_exception",
1595            ),
1596            (
1597                "Process running with session ID 13\nOutput:\npanic: runtime error: index out of range\n\ngoroutine 6 [running]:",
1598                "runtime_panic",
1599            ),
1600            (
1601                "Process exited with code 0\nOutput:\nerror: patch failed: src/lib.rs:4\nerror: src/lib.rs: patch does not apply",
1602                "patch_error",
1603            ),
1604        ];
1605        for (text, expected_pattern) in cases {
1606            let (severity, patterns) = classify_text(text);
1607            assert_eq!(severity, HARD, "expected hard severity for {text}");
1608            assert!(patterns.iter().any(|pattern| pattern == expected_pattern));
1609        }
1610    }
1611
1612    #[test]
1613    fn source_text_that_names_exceptions_stays_clean() {
1614        let text =
1615            "pub enum TypeError: this is documentation\nlet sample = 'panic: runtime error:';";
1616        assert_eq!(classify_text(text).0, 0.0);
1617    }
1618
1619    #[test]
1620    fn file_does_not_exist_is_hard() {
1621        // Claude Code Read-tool miss. Trace-mined addition (22 true / 2 false positives).
1622        let (sev, patterns) =
1623            classify_text("Error: File does not exist. Note: current working directory is /app.");
1624        assert_eq!(sev, HARD);
1625        assert!(patterns.contains(&"no_such_file".to_string()));
1626    }
1627
1628    #[test]
1629    fn bare_does_not_exist_stays_clean() {
1630        // Precision guard: only the anchored "file does not exist" fires, so a bare
1631        // "does not exist" in prose or directory output must not trip a false error.
1632        let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
1633        assert_eq!(sev, 0.0);
1634    }
1635
1636    #[test]
1637    fn no_error_streak_all_clean() {
1638        let texts = vec![("ok".to_string(), false), ("all good".to_string(), false)];
1639        assert_eq!(compute_no_error_streak(&texts), 2);
1640    }
1641
1642    #[test]
1643    fn no_error_streak_stops_at_error() {
1644        let texts = vec![
1645            ("Traceback (most recent call last):".to_string(), false),
1646            ("ok".to_string(), false),
1647            ("ok".to_string(), false),
1648        ];
1649        assert_eq!(compute_no_error_streak(&texts), 2);
1650    }
1651
1652    #[test]
1653    fn tests_passed_detects_pytest_output() {
1654        assert!(detect_tests_passed(
1655            &[("====== 5 passed in 0.12s ======".to_string(), false)],
1656            DEFAULT_RECENT_WINDOW
1657        ));
1658    }
1659
1660    #[test]
1661    fn tests_passed_ignores_partial_failures() {
1662        assert!(!detect_tests_passed(
1663            &[("2 failed, 5 passed in 0.56s".to_string(), false)],
1664            DEFAULT_RECENT_WINDOW
1665        ));
1666    }
1667
1668    #[test]
1669    fn tests_passed_must_follow_the_latest_failure() {
1670        assert!(!detect_tests_passed(
1671            &[
1672                ("5 passed in 0.12s".to_string(), false),
1673                (
1674                    "Traceback (most recent call last):\nValueError".to_string(),
1675                    false
1676                ),
1677                ("edit applied".to_string(), false),
1678            ],
1679            DEFAULT_RECENT_WINDOW
1680        ));
1681        assert!(detect_tests_passed(
1682            &[
1683                (
1684                    "Traceback (most recent call last):\nValueError".to_string(),
1685                    false
1686                ),
1687                ("5 passed in 0.12s".to_string(), false),
1688            ],
1689            DEFAULT_RECENT_WINDOW
1690        ));
1691    }
1692
1693    #[test]
1694    fn retrieved_file_contents_are_ignored() {
1695        let call = |id: &str, name: &str, arguments: Value| Message {
1696            role: Role::Assistant,
1697            content: vec![ContentBlock::ToolCall(ToolCall {
1698                id: id.to_string(),
1699                name: name.to_string(),
1700                arguments,
1701            })],
1702        };
1703        let result = |id: &str, text: &str| Message {
1704            role: Role::User,
1705            content: vec![ContentBlock::ToolResult(ToolResult {
1706                tool_call_id: id.to_string(),
1707                content: vec![ContentBlock::Text {
1708                    text: text.to_string(),
1709                }],
1710                is_error: None,
1711            })],
1712        };
1713        let signal = extract_tool_signals_with_window(
1714            &with_messages(vec![
1715                call("a", "Bash", json!({"command": "pytest"})),
1716                result("a", "Traceback (most recent call last):\nValueError"),
1717                call("b", "Read", json!({"file_path": "notes.md"})),
1718                result(
1719                    "b",
1720                    r#"{"success":false,"error":"out of memory","exit_code":7}"#,
1721                ),
1722                call("c", "Grep", json!({"pattern": "passed"})),
1723                result("c", "CHANGELOG.md: all tests passed"),
1724            ]),
1725            DEFAULT_RECENT_WINDOW,
1726        );
1727        // Only the real pytest run counts.
1728        assert_eq!(signal.severity, HARD);
1729        assert!(!signal.tests_passed);
1730        assert_eq!(signal.tool_result_count, 3);
1731
1732        for (command, text, severity) in [
1733            ("cat config.json", r#"{"error":"fixture data"}"#, 0.0),
1734            (
1735                "cat missing.json",
1736                r#"{"output":"","exit_code":7,"error":null}"#,
1737                SOFT,
1738            ),
1739            (
1740                "cat config.json && python check.py",
1741                r#"{"error":"check rejected"}"#,
1742                HARD,
1743            ),
1744        ] {
1745            let request = with_messages(vec![
1746                call("shell", "Bash", json!({"command": command})),
1747                result("shell", text),
1748            ]);
1749            assert_eq!(
1750                ToolSignals::from_request(&request, None).severity,
1751                severity,
1752                "{text}"
1753            );
1754        }
1755    }
1756
1757    #[test]
1758    fn severity_is_windowed_over_recent_results() {
1759        // An error two results back, then two clean results.
1760        let request = with_messages(vec![
1761            tr("Traceback (most recent call last):\n  ValueError"),
1762            tr("ok"),
1763            tr("ok"),
1764        ]);
1765        // window covers the error → severity persists (max over the window)
1766        assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
1767        // window of 1 sees only the last (clean) result → severity has decayed out
1768        assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
1769    }
1770
1771    #[test]
1772    fn extract_openai_chat_tool_results() {
1773        let request = with_messages(vec![
1774            Message::text(Role::User, "do something"),
1775            tc("Edit"),
1776            tr("Traceback (most recent call last):\n  ValueError"),
1777        ]);
1778        let sig = ToolSignals::from_request(&request, None);
1779        assert_eq!(sig.severity, HARD);
1780        assert_eq!(sig.edit_count, 1);
1781        assert_eq!(sig.turn_depth, 3);
1782    }
1783
1784    #[test]
1785    fn extract_anthropic_tool_results() {
1786        let request = with_messages(vec![tr("Traceback (most recent call last):\n  ValueError")]);
1787        let sig = ToolSignals::from_request(&request, None);
1788        assert_eq!(sig.severity, HARD);
1789    }
1790
1791    #[test]
1792    fn extract_responses_api_tool_results() {
1793        let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
1794        let sig = ToolSignals::from_request(&request, None);
1795        assert_eq!(sig.severity, 0.0);
1796        assert_eq!(sig.write_count, 1);
1797    }
1798
1799    #[test]
1800    fn responses_builtin_tool_failures_escalate() {
1801        use crate::algorithms::util::stage::{PickOutcome, PickerMode, Tier, pick_tier};
1802
1803        let mut cases = Vec::new();
1804        for (status, output) in [
1805            (
1806                "failed",
1807                "Synthetic dependency unavailable; retry with the recovery path.",
1808            ),
1809            (
1810                "completed",
1811                "Synthetic dependency unavailable; retry with the recovery path.",
1812            ),
1813            ("failed", ""),
1814        ] {
1815            cases.push((
1816                json!({
1817                    "type": "apply_patch_call_output",
1818                    "status": status,
1819                    "output": output,
1820                }),
1821                if status == "failed" { HARD } else { 0.0 },
1822            ));
1823        }
1824        for (outcome, stdout, stderr, severity) in [
1825            (json!({"type": "exit", "exit_code": 1}), "", "", SOFT),
1826            (json!({"type": "exit", "exit_code": 1}), "", " \n", SOFT),
1827            (
1828                json!({"type": "exit", "exit_code": 1}),
1829                "",
1830                "command failed",
1831                HARD,
1832            ),
1833            (json!({"type": "timeout"}), "", "", HARD),
1834            (json!({"type": "exit", "exit_code": 0}), "done", "", 0.0),
1835            (
1836                json!({"type": "exit", "exit_code": 0}),
1837                "Traceback (most recent call last):",
1838                "",
1839                HARD,
1840            ),
1841            (
1842                json!({"type": "exit", "exit_code": 0}),
1843                "",
1844                "Traceback (most recent call last):",
1845                HARD,
1846            ),
1847        ] {
1848            cases.push((
1849                json!({
1850                    "type": "shell_call_output",
1851                    "output": [
1852                        {"stdout": stdout, "stderr": stderr, "outcome": outcome},
1853                        {"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}
1854                    ],
1855                }),
1856                severity,
1857            ));
1858        }
1859        for (raw, severity) in cases {
1860            let is_error = severity >= HARD;
1861            let mut request = with_messages(
1862                ["call_1", "call_2"]
1863                    .into_iter()
1864                    .map(|call_id| {
1865                        let mut raw = raw.clone();
1866                        raw["call_id"] = json!(call_id);
1867                        Message {
1868                            role: Role::User,
1869                            content: vec![ContentBlock::Unknown {
1870                                provider: WireFormat::OpenAiResponses.into(),
1871                                raw,
1872                            }],
1873                        }
1874                    })
1875                    .collect(),
1876            );
1877            let signal = ToolSignals::from_request(&request, Some(3));
1878            assert_eq!(signal.severity, severity, "{raw}");
1879            assert_eq!(signal.repeated_failure, is_error, "{raw}");
1880            assert_eq!(signal.tool_result_count, 2);
1881            assert_eq!(
1882                matches!(
1883                    pick_tier(&signal, PickerMode::EfficientFirst, 0.5),
1884                    PickOutcome::Resolved {
1885                        tier: Tier::Capable,
1886                        ..
1887                    }
1888                ),
1889                is_error,
1890                "{raw}"
1891            );
1892
1893            let mut success = match raw["type"].as_str() {
1894                Some("apply_patch_call_output") => json!({
1895                    "type": "apply_patch_call_output", "status": "completed", "output": ""
1896                }),
1897                Some("shell_call_output") => json!({
1898                    "type": "shell_call_output",
1899                    "output": [{"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}]
1900                }),
1901                _ => unreachable!(),
1902            };
1903            for index in 0..3 {
1904                success["call_id"] = json!(format!("success_{index}"));
1905                request.llm_request.messages.push(Message {
1906                    role: Role::User,
1907                    content: vec![ContentBlock::Unknown {
1908                        provider: WireFormat::OpenAiResponses.into(),
1909                        raw: success.clone(),
1910                    }],
1911                });
1912            }
1913            let recovered = ToolSignals::from_request(&request, Some(3));
1914            assert_eq!(recovered.severity, 0.0, "{raw}");
1915            assert!(!recovered.repeated_failure, "{raw}");
1916            assert!(recovered.no_error_streak >= 3, "{raw}");
1917            assert_eq!(recovered.tool_result_count, 5);
1918        }
1919    }
1920
1921    #[test]
1922    fn conversation_counts_are_per_block_and_role_aware() {
1923        // A batched user message (Anthropic shape) contributes one count per
1924        // ToolResult block, empty-content results included. assistant_turn_count
1925        // tracks Role::Assistant only, while turn_depth counts every message.
1926        let result = |content: Vec<ContentBlock>| {
1927            ContentBlock::ToolResult(ToolResult {
1928                tool_call_id: String::new(),
1929                content,
1930                is_error: None,
1931            })
1932        };
1933        let request = with_messages(vec![
1934            Message::text(Role::User, "do something"),
1935            Message::text(Role::Assistant, "working"),
1936            Message {
1937                role: Role::User,
1938                content: vec![
1939                    result(vec![ContentBlock::Text {
1940                        text: "ok".to_string(),
1941                    }]),
1942                    result(Vec::new()),
1943                ],
1944            },
1945            tc("Bash"),
1946        ]);
1947        let sig = ToolSignals::from_request(&request, None);
1948        assert_eq!(sig.tool_result_count, 2);
1949        assert_eq!(sig.assistant_turn_count, 2);
1950        assert_eq!(sig.turn_depth, 4);
1951    }
1952
1953    #[test]
1954    fn recent_window_counts_only_last_default_window_tool_calls() {
1955        // 5 writes + 1 edit at the end → the default window (3) should see
1956        // the last 3 calls: 1 edit + 2 writes (not all 6 calls).
1957        let request = with_messages(vec![
1958            tc("Write"),
1959            tr("ok"),
1960            tc("Write"),
1961            tr("ok"),
1962            tc("Write"),
1963            tr("ok"),
1964            tc("Write"),
1965            tr("ok"),
1966            tc("Write"),
1967            tr("ok"),
1968            tc("Edit"),
1969            tr("ok"),
1970        ]);
1971        let sig = ToolSignals::from_request(&request, None);
1972        assert_eq!(sig.write_count, 5);
1973        assert_eq!(sig.edit_count, 1);
1974        assert_eq!(sig.recent_write_count, 2);
1975        assert_eq!(sig.recent_edit_count, 1);
1976    }
1977
1978    #[test]
1979    fn codex_apply_patch_counts_as_an_edit() {
1980        let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
1981        let sig = ToolSignals::from_request(&request, None);
1982        assert_eq!(sig.edit_count, 1);
1983        assert_eq!(sig.recent_edit_count, 1);
1984    }
1985
1986    fn exec_command(cmd: Value) -> Message {
1987        Message {
1988            role: Role::Assistant,
1989            content: vec![ContentBlock::ToolCall(ToolCall {
1990                id: String::new(),
1991                name: "exec_command".to_string(),
1992                arguments: cmd,
1993            })],
1994        }
1995    }
1996
1997    #[test]
1998    fn codex_exec_command_is_classified() {
1999        // arguments arrive as a JSON string, with the command under `cmd`
2000        let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
2001        let request = with_messages(vec![exec_command(args), tr("ok")]);
2002        assert_eq!(
2003            ToolSignals::from_request(&request, None).recent_edit_count,
2004            1
2005        );
2006    }
2007
2008    #[test]
2009    fn python_write_expressions_need_a_python_command() {
2010        let write = with_messages(vec![
2011            exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
2012            tr("ok"),
2013        ]);
2014        assert_eq!(
2015            ToolSignals::from_request(&write, None).recent_write_count,
2016            1
2017        );
2018
2019        let search = with_messages(vec![
2020            exec_command(json!({"cmd": "grep -R '.write(' src"})),
2021            tr("ok"),
2022        ]);
2023        assert_eq!(
2024            ToolSignals::from_request(&search, None).recent_write_count,
2025            0
2026        );
2027    }
2028
2029    #[test]
2030    fn recent_window_size_is_caller_overridable() {
2031        // Same six tool calls (1 edit at the end, 5 writes before).
2032        // With recent_window=3 → recent_writes=2, recent_edits=1.
2033        // With recent_window=6 → recent_writes=5, recent_edits=1 (all calls).
2034        let request = with_messages(vec![
2035            tc("Write"),
2036            tr("ok"),
2037            tc("Write"),
2038            tr("ok"),
2039            tc("Write"),
2040            tr("ok"),
2041            tc("Write"),
2042            tr("ok"),
2043            tc("Write"),
2044            tr("ok"),
2045            tc("Edit"),
2046            tr("ok"),
2047        ]);
2048        let narrow = extract_tool_signals_with_window(&request, 3);
2049        assert_eq!(narrow.recent_write_count, 2);
2050        assert_eq!(narrow.recent_edit_count, 1);
2051
2052        let wide = extract_tool_signals_with_window(&request, 6);
2053        assert_eq!(wide.recent_write_count, 5);
2054        assert_eq!(wide.recent_edit_count, 1);
2055    }
2056
2057    #[test]
2058    fn compaction_marker_sets_compacted() {
2059        // The compaction summary is a user message carrying Claude Code's preamble.
2060        let request = with_messages(vec![
2061            Message::text(
2062                Role::User,
2063                "This session is being continued from a previous conversation that ran out of context.",
2064            ),
2065            bash("ls"),
2066        ]);
2067        assert!(ToolSignals::from_request(&request, None).compacted);
2068    }
2069
2070    #[test]
2071    fn hermes_compaction_headers_set_compacted() {
2072        for header in [
2073            "[CONTEXT COMPACTION \u{2014} REFERENCE ONLY] Earlier turns were compacted into the summary below.",
2074            "[STILL IN PROGRESS \u{2014} this is the active request, restated after the compaction boundary because it was not finished yet. Continue it\u{3b} do not start over.]",
2075        ] {
2076            for role in [Role::User, Role::Assistant] {
2077                let request = with_messages(vec![
2078                    Message::text(role, format!("{header}\nContinue the task.")),
2079                    bash("ls"),
2080                ]);
2081                assert!(ToolSignals::from_request(&request, None).compacted);
2082            }
2083            let request = with_messages(vec![Message::text(
2084                Role::User,
2085                format!("Prior context.\n\n  {header}\nContinue the task."),
2086            )]);
2087            assert!(ToolSignals::from_request(&request, None).compacted);
2088            let request = with_messages(vec![tr(header)]);
2089            assert!(!ToolSignals::from_request(&request, None).compacted);
2090        }
2091    }
2092
2093    #[test]
2094    fn ordinary_compaction_text_stays_uncompacted() {
2095        for text in [
2096            "The task is still in progress after the compaction boundary.",
2097            "Explain [CONTEXT COMPACTION \u{2014} REFERENCE ONLY] in the docs.",
2098            "[STILL IN PROGRESS] Continue the task.",
2099        ] {
2100            let request = with_messages(vec![Message::text(Role::User, text)]);
2101            assert!(!ToolSignals::from_request(&request, None).compacted);
2102        }
2103    }
2104
2105    #[test]
2106    fn codex_compaction_metadata_stays_on_parent_route() {
2107        let mut request = with_messages(vec![bash("ls")]);
2108        request.metadata = Some(Metadata {
2109            is_subagent: true,
2110            agent_kind: Some("compact".to_string()),
2111            ..Default::default()
2112        });
2113        assert!(!ToolSignals::from_request(&request, None).compacted);
2114    }
2115
2116    #[test]
2117    fn no_compaction_marker_stays_uncompacted() {
2118        let request = with_messages(vec![
2119            Message::text(Role::User, "Write a script that parses the log file."),
2120            bash("ls"),
2121        ]);
2122        assert!(!ToolSignals::from_request(&request, None).compacted);
2123    }
2124
2125    #[test]
2126    fn bash_heredoc_counts_as_write() {
2127        // Claude Code's pattern on TB 2.0 — write a scratch file via heredoc.
2128        let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
2129        let sig = ToolSignals::from_request(&request, None);
2130        assert_eq!(
2131            sig.write_count, 1,
2132            "Bash heredoc should bucket into write_count"
2133        );
2134        assert_eq!(sig.edit_count, 0);
2135    }
2136
2137    #[test]
2138    fn bash_sed_inplace_counts_as_edit() {
2139        let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
2140        let sig = ToolSignals::from_request(&request, None);
2141        assert_eq!(
2142            sig.edit_count, 1,
2143            "Bash sed -i should bucket into edit_count"
2144        );
2145        assert_eq!(sig.write_count, 0);
2146    }
2147
2148    #[test]
2149    fn bash_non_mutating_does_not_count() {
2150        // ls, cat, grep — should not increment either counter.
2151        let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
2152        let sig = ToolSignals::from_request(&request, None);
2153        assert_eq!(sig.write_count, 0);
2154        assert_eq!(sig.edit_count, 0);
2155    }
2156
2157    #[test]
2158    fn tests_passed_detects_pytest_with_failure_block() {
2159        // Mixed pytest run: 2 failed + 5 passed → NOT considered tests_passed.
2160        assert!(!detect_tests_passed(
2161            &[("2 failed, 5 passed in 0.56s".to_string(), false)],
2162            DEFAULT_RECENT_WINDOW
2163        ));
2164    }
2165
2166    #[test]
2167    fn tests_passed_accepts_cargo_clean_summary() {
2168        // Cargo's clean-run summary contains "0 failed" — must not trip the
2169        // failure list (regression: previously substring-matched "failed").
2170        assert!(detect_tests_passed(
2171            &[(
2172                "running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string(),
2173                false
2174            )],
2175            DEFAULT_RECENT_WINDOW
2176        ));
2177    }
2178
2179    #[test]
2180    fn tests_passed_rejects_cargo_real_failure() {
2181        // Cargo's actual-failure summary: nonzero count before "failed".
2182        assert!(!detect_tests_passed(
2183            &[(
2184                "running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string(),
2185                false
2186            )],
2187            DEFAULT_RECENT_WINDOW
2188        ));
2189    }
2190
2191    #[test]
2192    fn tests_passed_accepts_go_clean_summary() {
2193        // Go test's clean-run "0 errors" must not trip (regression).
2194        assert!(detect_tests_passed(
2195            &[(
2196                "ok  github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string(),
2197                false
2198            )],
2199            DEFAULT_RECENT_WINDOW
2200        ));
2201    }
2202
2203    #[test]
2204    fn tests_passed_accepts_pytest_zero_errors() {
2205        // Pytest long-form: "0 errors in 0.3s" on a clean run.
2206        assert!(detect_tests_passed(
2207            &[("5 passed, 0 errors in 0.30s".to_string(), false)],
2208            DEFAULT_RECENT_WINDOW
2209        ));
2210    }
2211
2212    #[test]
2213    fn tests_passed_detects_diy_checkmark() {
2214        assert!(detect_tests_passed(
2215            &[("✓ all checks passed".to_string(), false)],
2216            DEFAULT_RECENT_WINDOW
2217        ));
2218    }
2219
2220    #[test]
2221    fn anthropic_bash_heredoc_extracts_command() {
2222        // Anthropic format: tool_use.input is an object, not a JSON string.
2223        let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
2224        let sig = ToolSignals::from_request(&request, None);
2225        assert_eq!(
2226            sig.write_count, 1,
2227            "Anthropic Bash heredoc must also be detected"
2228        );
2229    }
2230
2231    #[test]
2232    fn recent_window_falls_back_to_full_history_when_short() {
2233        let request = with_messages(vec![tc("Write")]);
2234        let sig = ToolSignals::from_request(&request, None);
2235        assert_eq!(sig.recent_write_count, 1);
2236        assert_eq!(sig.recent_edit_count, 0);
2237    }
2238
2239    #[test]
2240    fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
2241        let request = with_messages(vec![tr("output ok"), tr("another ok")]);
2242        let sig = ToolSignals::from_request(&request, None);
2243        assert_eq!(sig.severity, 0.0);
2244        assert_eq!(sig.no_error_streak, 2);
2245    }
2246
2247    // ─── asymmetric-signal extensions ────────────────────────────────────
2248
2249    #[test]
2250    fn todowrite_classifies_as_plan() {
2251        assert_eq!(classify_tool_call("TodoWrite", None), ToolSemantic::Plan);
2252        assert_eq!(classify_tool_call("todo_write", None), ToolSemantic::Plan);
2253    }
2254
2255    #[test]
2256    fn codex_update_plan_classifies_as_plan() {
2257        assert_eq!(classify_tool_call("update_plan", None), ToolSemantic::Plan);
2258    }
2259
2260    #[test]
2261    fn codex_shell_command_runs_bash_pattern_match() {
2262        // shell_command + heredoc -> Write.
2263        assert_eq!(
2264            classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
2265            ToolSemantic::Mutate(MutationKind::Write),
2266        );
2267        // shell_command + read-like inspection -> Read.
2268        assert_eq!(
2269            classify_tool_call("shell_command", Some("ls /app")),
2270            ToolSemantic::Observe,
2271        );
2272        // shell_command without matching patterns -> Unknown.
2273        assert_eq!(
2274            classify_tool_call("shell_command", Some("./run_tests.sh")),
2275            ToolSemantic::Unknown,
2276        );
2277    }
2278
2279    #[test]
2280    fn text_editor_view_is_a_read() {
2281        for name in ["str_replace_based_edit_tool", "text_editor"] {
2282            assert_eq!(
2283                classify_tool_call(name, Some("view")),
2284                ToolSemantic::Observe
2285            );
2286            for command in [
2287                Some("create"),
2288                Some("insert"),
2289                Some("str_replace"),
2290                Some("undo_edit"),
2291                None,
2292            ] {
2293                assert_eq!(
2294                    classify_tool_call(name, command),
2295                    ToolSemantic::Mutate(MutationKind::Edit),
2296                );
2297            }
2298        }
2299
2300        let arguments = [
2301            json!({"command": "view", "path": "/app/main.py"}),
2302            // the Responses wire format sends arguments as a JSON string
2303            json!(r#"{"command":"view","path":"/app/main.py"}"#),
2304        ];
2305        for arguments in arguments {
2306            let call = Message {
2307                role: Role::Assistant,
2308                content: vec![ContentBlock::ToolCall(ToolCall {
2309                    id: String::new(),
2310                    name: "str_replace_based_edit_tool".to_string(),
2311                    arguments,
2312                })],
2313            };
2314            let request = with_messages(vec![call, tr("print('hi')")]);
2315            let sig = ToolSignals::from_request(&request, None);
2316            assert_eq!(sig.read_count, 1);
2317            assert_eq!(sig.recent_read_count, 1);
2318            assert_eq!(sig.edit_count, 0);
2319        }
2320    }
2321
2322    #[test]
2323    fn read_tool_classifies_as_read() {
2324        assert_eq!(classify_tool_call("Read", None), ToolSemantic::Observe);
2325        assert_eq!(classify_tool_call("View", None), ToolSemantic::Observe);
2326    }
2327
2328    #[test]
2329    fn hermes_tool_names_classify() {
2330        // Hermes (NousResearch) file tools route by name.
2331        assert_eq!(
2332            classify_tool_call("write_file", None),
2333            ToolSemantic::Mutate(MutationKind::Write)
2334        );
2335        assert_eq!(
2336            classify_tool_call("patch", None),
2337            ToolSemantic::Mutate(MutationKind::Edit)
2338        );
2339        assert_eq!(classify_tool_call("read_file", None), ToolSemantic::Observe);
2340        assert_eq!(
2341            classify_tool_call("search_files", None),
2342            ToolSemantic::Observe
2343        );
2344        // Hermes runs shell through `terminal`, which carries a `command` arg,
2345        // so its intent comes from the Bash-pattern match like codex's shell_command.
2346        assert_eq!(
2347            classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
2348            ToolSemantic::Mutate(MutationKind::Edit),
2349        );
2350        assert_eq!(
2351            classify_tool_call("terminal", Some("grep foo /app")),
2352            ToolSemantic::Observe,
2353        );
2354        assert_eq!(
2355            classify_tool_call("terminal", Some("./run_tests.sh")),
2356            ToolSemantic::Unknown,
2357        );
2358    }
2359
2360    #[test]
2361    fn bash_read_patterns_classify_as_read() {
2362        let cases = [
2363            "cat /etc/passwd",
2364            "grep foo bar.txt",
2365            "ls /app",
2366            "find . -name '*.py'",
2367        ];
2368        for cmd in cases {
2369            assert_eq!(
2370                classify_tool_call("Bash", Some(cmd)),
2371                ToolSemantic::Observe,
2372                "expected Read for {cmd}"
2373            );
2374        }
2375    }
2376
2377    #[test]
2378    fn codex_inspection_commands_classify_as_read() {
2379        let cases = [
2380            "sed -n '1,80p' src/lib.rs",
2381            "rg -n 'needle' src",
2382            "nl -ba src/lib.rs",
2383            "cat package.json",
2384            "jq '.scripts' package.json",
2385            "git status --short",
2386            "git log --oneline -5",
2387            "git show HEAD:src/lib.rs",
2388            "git branch --show-current",
2389            "git remote -v",
2390            "git config --get remote.origin.url",
2391        ];
2392        for command in cases {
2393            assert_eq!(
2394                classify_tool_call("exec_command", Some(command)),
2395                ToolSemantic::Observe,
2396                "expected Read for {command}"
2397            );
2398        }
2399    }
2400
2401    #[test]
2402    fn quoted_shell_separators_do_not_create_commands() {
2403        for command in ["rg 'foo|rm obsolete.rs'", "rg \"foo; rm obsolete.rs\""] {
2404            assert_eq!(
2405                classify_tool_call("exec_command", Some(command)),
2406                ToolSemantic::Observe,
2407                "quoted text must not be parsed as a command: {command}"
2408            );
2409        }
2410    }
2411
2412    #[test]
2413    fn codex_shell_mutations_classify_as_production() {
2414        let writes = [
2415            "cp source.rs destination.rs",
2416            "mkdir -p src/generated",
2417            "touch src/generated/mod.rs",
2418            "git show HEAD:file.rs > file.rs",
2419            "node <<'node'\nfs.writefilesync('file.js', text)\nnode",
2420        ];
2421        for command in writes {
2422            assert_eq!(
2423                classify_tool_call("exec_command", Some(command)),
2424                ToolSemantic::Mutate(MutationKind::Write),
2425                "expected Write for {command}"
2426            );
2427        }
2428
2429        let edits = [
2430            "mv old.rs new.rs",
2431            "rm obsolete.rs",
2432            "gofmt -w main.go",
2433            "cargo fmt",
2434            "ruff check --fix src",
2435            "perl -0pi -e 's/old/new/' src/lib.rs",
2436            "npx prettier --write src/lib.ts",
2437            "uv run ruff format src",
2438            "git apply fix.patch",
2439        ];
2440        for command in edits {
2441            assert_eq!(
2442                classify_tool_call("exec_command", Some(command)),
2443                ToolSemantic::Mutate(MutationKind::Edit),
2444                "expected Edit for {command}"
2445            );
2446        }
2447    }
2448
2449    #[test]
2450    fn formatter_checks_are_not_edits() {
2451        for command in [
2452            "cargo fmt --check",
2453            "ruff format --check src",
2454            "black --check src",
2455        ] {
2456            assert_ne!(
2457                classify_tool_call("exec_command", Some(command)),
2458                ToolSemantic::Mutate(MutationKind::Edit),
2459                "read-only formatter check must not be Edit: {command}"
2460            );
2461        }
2462    }
2463
2464    #[test]
2465    fn embedded_comparison_is_not_a_shell_write() {
2466        let command = "node <<'node'\nif (index > 0) console.log(index)\nnode";
2467        assert_eq!(
2468            classify_tool_call("exec_command", Some(command)),
2469            ToolSemantic::Unknown
2470        );
2471    }
2472
2473    #[test]
2474    fn bash_write_precedence_over_read() {
2475        // `cat /file > out` contains both `cat /` (read) and ` > ` (write);
2476        // write redirection must win.
2477        assert_eq!(
2478            classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
2479            ToolSemantic::Mutate(MutationKind::Write),
2480        );
2481    }
2482
2483    #[test]
2484    fn pure_bash_streak_counts_trailing_other() {
2485        // 5 trailing non-classified Bash calls → streak == 5.
2486        let request = with_messages(vec![
2487            bash("make"),
2488            tr("ok"),
2489            bash("./configure"),
2490            tr("ok"),
2491            bash("make install"),
2492            tr("ok"),
2493            bash("./run.sh"),
2494            tr("ok"),
2495            bash("./test"),
2496            tr("ok"),
2497        ]);
2498        let sig = ToolSignals::from_request(&request, None);
2499        assert_eq!(sig.pure_bash_streak, 5);
2500        assert_eq!(sig.write_count, 0);
2501        assert_eq!(sig.read_count, 0);
2502    }
2503
2504    #[test]
2505    fn pure_bash_streak_resets_on_write() {
2506        let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
2507        let sig = ToolSignals::from_request(&request, None);
2508        assert_eq!(sig.pure_bash_streak, 0);
2509        assert_eq!(sig.write_count, 1);
2510    }
2511
2512    #[test]
2513    fn recent_window_tracks_todowrite_and_read() {
2514        // Final 3 tool calls: TodoWrite, Read, TodoWrite.
2515        let request = with_messages(vec![
2516            bash("make"),
2517            tr("ok"),
2518            tc("TodoWrite"),
2519            tr("ok"),
2520            tc("Read"),
2521            tr("ok"),
2522            tc("TodoWrite"),
2523            tr("ok"),
2524        ]);
2525        let sig = ToolSignals::from_request(&request, None);
2526        assert_eq!(sig.todowrite_count, 2);
2527        assert_eq!(sig.recent_todowrite_count, 2);
2528        assert_eq!(sig.read_count, 1);
2529        assert_eq!(sig.recent_read_count, 1);
2530    }
2531
2532    #[test]
2533    fn configured_tool_semantics_extend_the_builtin_vocabulary() {
2534        let semantics = ToolSemantics {
2535            observe: vec!["KB_search".to_string()],
2536            mutate: vec!["send_payment_request".to_string()],
2537            plan: vec!["create_research_plan".to_string()],
2538            new: vec!["send_message_to_user".to_string()],
2539        };
2540        semantics.validate().expect("valid additive semantics");
2541        let request = with_messages(vec![
2542            tc("Read"),
2543            tc("Write"),
2544            tc("TodoWrite"),
2545            tc("kb_SEARCH"),
2546            tc("send_payment_request"),
2547            tc("create_research_plan"),
2548            tc("send_message_to_user"),
2549            tc("unlisted_tool"),
2550        ]);
2551
2552        let signal = ToolSignals::from_request_with_semantics(&request, None, &semantics);
2553
2554        assert_eq!(signal.read_count, 2);
2555        assert_eq!(signal.write_count, 2);
2556        assert_eq!(signal.todowrite_count, 2);
2557        assert_eq!(signal.new_count, 1);
2558        assert_eq!(signal.recent_new_count, 1);
2559        assert_eq!(signal.pure_bash_streak, 1);
2560    }
2561
2562    #[test]
2563    fn configured_tool_semantics_match_namespaced_and_mcp_tools() {
2564        // The Responses decoder flattens namespaced tools and records the mapping.
2565        let mut request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
2566        request.llm_request.extensions.fields.insert(
2567            TOOL_NAMESPACES_KEY.to_string(),
2568            json!({"mcp__billing__send_payment_request": "mcp__billing"}),
2569        );
2570
2571        // Claude Code sends MCP tools flat, with no namespace mapping.
2572        let claude_request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
2573
2574        for request in [&request, &claude_request] {
2575            for name in ["send_payment_request", "mcp__billing__send_payment_request"] {
2576                let semantics = ToolSemantics {
2577                    mutate: vec![name.to_string()],
2578                    ..Default::default()
2579                };
2580                let signal = ToolSignals::from_request_with_semantics(request, None, &semantics);
2581                assert_eq!(signal.write_count, 1, "{name}");
2582            }
2583        }
2584    }
2585
2586    #[test]
2587    fn configured_tool_semantics_only_fold_ascii_case() {
2588        let semantics = ToolSemantics {
2589            observe: vec!["kb_search".to_string()],
2590            ..Default::default()
2591        };
2592
2593        assert_eq!(
2594            classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
2595            ToolSemantic::Observe
2596        );
2597        // U+212A lowercases to ASCII `k` under Unicode rules, but custom names
2598        // intentionally ignore only ASCII case.
2599        assert_eq!(
2600            classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
2601            ToolSemantic::Unknown
2602        );
2603    }
2604
2605    #[test]
2606    fn custom_semantics_preserve_builtin_unicode_lowercasing() {
2607        let semantics = ToolSemantics {
2608            observe: vec!["lookup_customer".to_string()],
2609            ..Default::default()
2610        };
2611
2612        // This matched the built-in `notebookedit` before custom semantics existed.
2613        assert_eq!(
2614            classify_tool_call_with_semantics("notebooKedit", None, &semantics),
2615            ToolSemantic::Mutate(MutationKind::Edit)
2616        );
2617    }
2618
2619    #[test]
2620    fn configured_semantics_never_replace_builtin_classifications() {
2621        let semantics = ToolSemantics {
2622            observe: vec!["lookup_customer".to_string()],
2623            mutate: vec!["send_payment".to_string()],
2624            plan: vec!["create_workflow".to_string()],
2625            new: vec!["send_message".to_string()],
2626        };
2627
2628        for name in WRITE_TOOL_NAMES {
2629            assert_eq!(
2630                classify_tool_call_with_semantics(name, None, &semantics),
2631                ToolSemantic::Mutate(MutationKind::Write),
2632                "write tool {name:?} changed classification"
2633            );
2634        }
2635        for name in EDIT_TOOL_NAMES {
2636            assert_eq!(
2637                classify_tool_call_with_semantics(name, None, &semantics),
2638                ToolSemantic::Mutate(MutationKind::Edit),
2639                "edit tool {name:?} changed classification"
2640            );
2641        }
2642        for name in READ_TOOL_NAMES {
2643            assert_eq!(
2644                classify_tool_call_with_semantics(name, None, &semantics),
2645                ToolSemantic::Observe,
2646                "read tool {name:?} changed classification"
2647            );
2648        }
2649        for name in PLAN_TOOL_NAMES {
2650            assert_eq!(
2651                classify_tool_call_with_semantics(name, None, &semantics),
2652                ToolSemantic::Plan,
2653                "plan tool {name:?} changed classification"
2654            );
2655        }
2656
2657        for (command, expected) in [
2658            ("cat /tmp/input", ToolSemantic::Observe),
2659            (
2660                "cat /tmp/input > /tmp/output",
2661                ToolSemantic::Mutate(MutationKind::Write),
2662            ),
2663            (
2664                "sed -i 's/a/b/' /tmp/file",
2665                ToolSemantic::Mutate(MutationKind::Edit),
2666            ),
2667            ("./run_tests.sh", ToolSemantic::Unknown),
2668        ] {
2669            assert_eq!(
2670                classify_tool_call_with_semantics("BASH", Some(command), &semantics),
2671                expected,
2672                "bash command {command:?} changed classification"
2673            );
2674        }
2675    }
2676
2677    #[test]
2678    fn configured_semantics_score_like_their_builtin_equivalents() {
2679        let semantics = ToolSemantics {
2680            observe: vec!["lookup_customer".to_string()],
2681            mutate: vec!["send_payment".to_string()],
2682            plan: vec!["create_workflow".to_string()],
2683            ..Default::default()
2684        };
2685
2686        for (builtin, configured) in [
2687            ("Read", "lookup_customer"),
2688            ("Write", "send_payment"),
2689            ("TodoWrite", "create_workflow"),
2690        ] {
2691            let messages_before_tool = || {
2692                vec![
2693                    Message::text(Role::User, "start"),
2694                    Message::text(Role::Assistant, "working"),
2695                    Message::text(Role::User, "continue"),
2696                    Message::text(Role::Assistant, "working"),
2697                    Message::text(Role::User, "continue"),
2698                    Message::text(Role::Assistant, "working"),
2699                    Message::text(Role::User, "continue"),
2700                ]
2701            };
2702            let mut builtin_messages = messages_before_tool();
2703            builtin_messages.push(tc(builtin));
2704            let mut configured_messages = messages_before_tool();
2705            configured_messages.push(tc(configured));
2706
2707            let builtin_score = score_signal(&ToolSignals::from_request(
2708                &with_messages(builtin_messages),
2709                None,
2710            ));
2711            let configured_score = score_signal(&ToolSignals::from_request_with_semantics(
2712                &with_messages(configured_messages),
2713                None,
2714                &semantics,
2715            ));
2716
2717            assert_ne!(
2718                builtin_score.score, 0.0,
2719                "the {builtin:?} control must exercise a scoring dimension"
2720            );
2721            assert_eq!(
2722                configured_score, builtin_score,
2723                "configured tool {configured:?} must score exactly like {builtin:?}"
2724            );
2725        }
2726    }
2727
2728    #[test]
2729    fn tool_semantics_reject_duplicates_and_builtin_reclassification() {
2730        let duplicate = ToolSemantics {
2731            observe: vec!["lookup".to_string()],
2732            mutate: vec!["LOOKUP".to_string()],
2733            ..Default::default()
2734        };
2735        assert!(
2736            duplicate
2737                .validate()
2738                .expect_err("duplicate should fail")
2739                .to_string()
2740                .contains("appears in both")
2741        );
2742
2743        let builtin = ToolSemantics {
2744            new: vec!["write_file".to_string()],
2745            ..Default::default()
2746        };
2747        assert!(
2748            builtin
2749                .validate()
2750                .expect_err("built-in should fail")
2751                .to_string()
2752                .contains("built-in semantics")
2753        );
2754
2755        let empty = ToolSemantics {
2756            observe: vec![" \t".to_string()],
2757            ..Default::default()
2758        };
2759        assert!(
2760            empty
2761                .validate()
2762                .expect_err("empty name should fail")
2763                .to_string()
2764                .contains("empty tool name")
2765        );
2766    }
2767}