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