Skip to main content

switchyard_libsy/algorithms/util/
tool_signals.rs

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