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