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