Skip to main content

switchyard_libsy/algorithms/util/
tool_signals.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Tool-result context signals extracted from the conversation history.
5//!
6//! The extractor walks normalized messages, finds tool calls and results,
7//! reads explicit failure flags, matches text against an error table, and aggregates
8//! conversation-history metrics used by [`crate::StageRouter`] and the
9//! advisor gate's request-side guards.
10//!
11//! All logic is pure and deterministic — no I/O, no shared state.
12
13#![allow(dead_code)]
14
15use std::collections::HashSet;
16use std::path::Path;
17use std::sync::LazyLock;
18
19use async_trait::async_trait;
20use regex::Regex;
21use serde::Deserialize;
22use serde_json::Value;
23use switchyard_protocol::codex_namespaces::{split_qualified_name, tool_namespaces};
24use switchyard_protocol::{ContentBlock, Request, Role, WireFormat};
25
26use super::affinity::is_user_turn;
27use crate::{LibsyError, Result};
28
29use crate::core::processor::{Event, Processor};
30use crate::core::state::State;
31
32// ─── severity constants ───────────────────────────────────────────────────────
33
34const SOFT: f32 = 0.3;
35const HARD: f32 = 0.7;
36const CRITICAL: f32 = 1.0;
37
38// ─── pattern table ────────────────────────────────────────────────────────────
39
40/// (name, severity, lower-cased substrings — any hit fires the pattern)
41static ERROR_PATTERNS: &[(&str, f32, &[&str])] = &[
42    (
43        "oom",
44        CRITICAL,
45        &["out of memory", "memoryerror", "cannot allocate memory"],
46    ),
47    (
48        "connection_refused",
49        HARD,
50        &[
51            "connection refused",
52            "connectionrefusederror",
53            "econnrefused",
54        ],
55    ),
56    ("traceback", HARD, &["traceback (most recent call last)"]),
57    (
58        "import_error",
59        HARD,
60        &["modulenotfounderror:", "importerror:", "no module named "],
61    ),
62    (
63        "cmd_not_found",
64        HARD,
65        &["command not found", "/usr/bin/env: "],
66    ),
67    ("assertion", HARD, &["assertionerror"]),
68    ("value_error", HARD, &["valueerror:"]),
69    ("syntax_error", HARD, &["syntaxerror:"]),
70    (
71        "timeout",
72        HARD,
73        &[
74            "timed out",
75            "timeouterror",
76            "timeout expired",
77            "deadline exceeded",
78        ],
79    ),
80    (
81        "no_such_file",
82        HARD,
83        &[
84            "filenotfounderror:",
85            "no such file or directory",
86            // Claude Code's missing-file message. Reads are exempt (`is_missing_file`).
87            // Anchored as "file does not exist" (not a bare "does not exist", which
88            // fires on `ls` output and prose) — trace-mined across 1006 local
89            // trajectories at 22 true / 2 false positives.
90            "file does not exist",
91        ],
92    ),
93    // SOFT: plain non-zero exit without a recognisable exception traceback.
94    ("exit_nonzero", SOFT, &["returned non-zero"]),
95];
96
97static NONZERO_EXIT_PHRASES: &[&str] = &[
98    "exit code",
99    "exit status",
100    "exited with code",
101    "exited with status",
102];
103
104static EDIT_TOOL_NAMES: &[&str] = &[
105    "edit",
106    "multiedit",
107    "notebookedit",
108    "str_replace",
109    "str_replace_based_edit_tool",
110    "apply_patch", // codex's edit tool
111    "text_editor",
112    "patch", // hermes's str_replace-style edit tool
113];
114
115/// Editor tools whose `command` argument picks the action. `view` only reads.
116static EDITOR_TOOL_NAMES: &[&str] = &["str_replace_based_edit_tool", "text_editor"];
117
118static WRITE_TOOL_NAMES: &[&str] = &["write", "create_file", "new_file", "write_file"];
119
120// Bash subcommand patterns. Lowercased; callers must lowercase the command
121// before matching. Bucketed into write_count / edit_count alongside the
122// dedicated `Write` / `Edit` tools.
123static BASH_WRITE_PATTERNS: &[&str] = &[
124    "cat >",
125    "cat >>",
126    "echo >",
127    "echo >>",
128    "tee ",
129    "printf >",
130    "printf >>",
131    "> /",
132    ">> /",
133    "<< 'eof'",
134    "<<eof",
135    "<<'eof'",
136    "<< eof",
137];
138
139/// Python file-write expressions, which only indicate a write when an
140/// interpreter is running them rather than a search looking for them.
141static PYTHON_WRITE_PATTERNS: &[&str] = &["write_text(", "writelines(", ".write("];
142
143static JAVASCRIPT_WRITE_PATTERNS: &[&str] = &[
144    "writefilesync(",
145    "writefile(",
146    "appendfilesync(",
147    "appendfile(",
148];
149
150static BASH_EDIT_PATTERNS: &[&str] = &[
151    "sed -i",
152    "sed --in-place",
153    "awk -i inplace",
154    "awk 'inplace=1'",
155    "perl -i",
156    "perl -p -i",
157    "perl -pi",
158];
159
160// Read-like Bash inspections. Match only when none of the write/edit patterns
161// fire (redirection / in-place edit trumps the read intent of the command).
162static BASH_READ_PATTERNS: &[&str] = &[
163    "cat /", "cat ./", "cat ../", "grep ", "ls ", "ls -", "find ", "head ", "tail ", "wc ",
164    "diff ", "which ", "ps ", "df ", "du ", "stat ", "file ", "less ", "more ",
165];
166
167/// Read-only shell programs seen in Codex trajectories. Matching is limited to
168/// command-segment starts so prose and arguments do not masquerade as actions.
169static BASH_READ_COMMANDS: &[&str] = &[
170    "cat", "rg", "nl", "jq", "pwd", "tree", "sed", "grep", "ls", "find", "head", "tail", "wc",
171    "diff", "which", "ps", "df", "du", "stat", "file", "less", "more", "readlink", "realpath",
172    "basename", "dirname", "printenv",
173];
174
175static GIT_READ_SUBCOMMANDS: &[&str] = &[
176    "status",
177    "diff",
178    "log",
179    "show",
180    "show-ref",
181    "rev-parse",
182    "ls-files",
183    "ls-remote",
184    "ls-tree",
185    "grep",
186    "blame",
187    "merge-base",
188    "check-ignore",
189    "tag",
190];
191
192static READ_TOOL_NAMES: &[&str] = &[
193    "read",
194    "view",
195    "read_file",
196    "search_files",
197    "glob",
198    "grep",
199    "find",
200    "ls",
201];
202
203// Planning / scratchpad tool calls — investigative (non-producing) activity.
204// `update_plan` is codex's equivalent of `todowrite`.
205static PLAN_TOOL_NAMES: &[&str] = &[
206    "todowrite",
207    "todo_write",
208    "todo",
209    "update_plan",
210    "todo_list",
211];
212
213// Tool names that route through Bash-command pattern matching. `bash` is
214// claude-code's name; `shell_command` is codex's; `shell` / `local_shell_call`
215// are seen on some OpenAI-derived harnesses; `terminal` is hermes's (it carries
216// a `command` arg like the others, so its intent comes from the pattern match).
217static BASH_TOOL_NAMES: &[&str] = &[
218    "bash",
219    "shell_command",
220    "shell",
221    "local_shell_call",
222    "terminal",
223    "exec_command", // codex
224    "exec",         // openclaw
225    "powershell",   // pi on Windows
226];
227
228// Prefer false negatives: tests_passed clears a capable hold, so a false positive
229// could hand an unfinished task back too early.
230static TEST_PASS_PHRASES: &[&str] = &[
231    " passed",
232    "passed in",
233    "tests passed",
234    "all tests passed",
235    "test ok",
236    "test result: ok",
237    "passed.\n",
238    "tests pass",
239    "\nok ", // go test; newline-anchored to avoid "...lookup..." mid-text
240    "✓ ",
241];
242
243// Literal failure phrases that cannot appear inside a clean run. Substring
244// matched as-is. Patterns that pair with a count (e.g. "failed", "errors")
245// are handled separately by `has_nonzero_failure_count` so "0 failed" /
246// "0 errors" do not trigger a false negative.
247static TEST_FAILURE_LITERAL: &[&str] = &["✗ ", "fatal:", "assertionerror", "error:"];
248
249// Count-prefixed failure keywords. Trip only when a nonzero integer precedes
250// the keyword (modulo whitespace), so cargo's "0 failed" and go's
251// "0 errors" summaries on a clean run are not misread as failures.
252static NUMERIC_FAILURE_KEYWORDS: &[&str] = &["failed", "failure", "failures", "errors", "error"];
253
254/// Default sliding-window size for `recent_*` counts and windowed severity.
255///
256/// A short horizon captures "what is the agent doing right now" while keeping
257/// signals sticky — an error or stall persists a few recovery turns instead of
258/// flickering off the moment one clean result lands. Override per request by
259/// passing a window to [`ToolSignals::from_request`].
260pub const DEFAULT_RECENT_WINDOW: usize = 3;
261
262/// Exact tool-name semantics added to the built-in vocabulary.
263///
264/// Matching is ASCII case-insensitive. An MCP or Codex namespaced tool also
265/// matches by its bare tool name. These lists are additive: built-in tool names
266/// cannot be reclassified.
267#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
268#[serde(default, deny_unknown_fields)]
269pub struct ToolSemantics {
270    /// Read-only lookup or inspection tools.
271    pub observe: Vec<String>,
272    /// Tools that change task or external state.
273    pub mutate: Vec<String>,
274    /// Explicit planning or task-decomposition tools.
275    pub plan: Vec<String>,
276    /// Tools that demonstrate new forward activity without favoring either tier.
277    pub new: Vec<String>,
278}
279
280impl ToolSemantics {
281    /// Rejects ambiguous mappings and attempts to reclassify built-in tools.
282    pub fn validate(&self) -> Result<()> {
283        let mut seen: Vec<(String, &'static str)> = Vec::new();
284        for (category, names) in [
285            ("observe", &self.observe),
286            ("mutate", &self.mutate),
287            ("plan", &self.plan),
288            ("new", &self.new),
289        ] {
290            for name in names {
291                if name.trim().is_empty() {
292                    return Err(tool_semantics_error(format!(
293                        "tool_semantics.{category} contains an empty tool name"
294                    )));
295                }
296                let normalized = name.to_ascii_lowercase();
297                if is_builtin_tool_name(&name.to_lowercase()) {
298                    return Err(tool_semantics_error(format!(
299                        "tool {name:?} already has built-in semantics and cannot be reclassified"
300                    )));
301                }
302                if let Some((_, previous)) = seen.iter().find(|(seen, _)| seen == &normalized) {
303                    return Err(tool_semantics_error(format!(
304                        "tool {name:?} appears in both tool_semantics.{previous} and tool_semantics.{category}"
305                    )));
306                }
307                seen.push((normalized, category));
308            }
309        }
310        Ok(())
311    }
312
313    fn classify(&self, name: &str) -> Option<ToolSemantic> {
314        if contains_name(&self.observe, name) {
315            Some(ToolSemantic::Observe)
316        } else if contains_name(&self.mutate, name) {
317            // The stage scorer treats writes and edits identically. Custom
318            // mutations use the write counter to preserve the public signal shape.
319            Some(ToolSemantic::Mutate(MutationKind::Write))
320        } else if contains_name(&self.plan, name) {
321            Some(ToolSemantic::Plan)
322        } else if contains_name(&self.new, name) {
323            Some(ToolSemantic::New)
324        } else {
325            None
326        }
327    }
328}
329
330fn contains_name(names: &[String], candidate: &str) -> bool {
331    names
332        .iter()
333        .any(|name| name.eq_ignore_ascii_case(candidate))
334}
335
336fn tool_semantics_error(message: String) -> LibsyError {
337    LibsyError::AlgorithmError { message }
338}
339
340// ─── output type ─────────────────────────────────────────────────────────────
341
342/// Tool-execution signals extracted from a normalized [`Request`].
343///
344/// A request-side processor stores these signals in [`State`](crate::State) for
345/// [`crate::StageRouter`] and its classifier to consume. The advisor gate's
346/// request-side guards read the conversation-shape counts directly via
347/// [`ToolSignals::from_request`].
348#[derive(Clone, Debug, Default)]
349pub struct ToolSignals {
350    /// Max severity across the recent window (last `recent_window` tool results):
351    /// `0.0` clean · `0.3` soft (exit_nonzero) · `0.7` hard · `1.0` critical.
352    /// Windowed so an error persists through the recovery turns instead of clearing
353    /// the instant the next result is clean.
354    pub severity: f32,
355    /// The same hard-or-critical failure appeared at least twice in the recent
356    /// tool-result window.
357    pub repeated_failure: bool,
358    /// Consecutive clean tool results back from the most recent. `0` if the last failed.
359    pub no_error_streak: u32,
360    /// Total edit-style tool calls in the request.
361    pub edit_count: u32,
362    /// Total write-style tool calls in the request.
363    pub write_count: u32,
364    /// Read-type calls (Read tool + read-like Bash). Used by the build-pit gate.
365    pub read_count: u32,
366    /// TodoWrite / planning tool calls. Investigative (non-producing) activity —
367    /// recent todowrites distinguish `exploring` from `spinning` in the scorer.
368    pub todowrite_count: u32,
369    /// Edit-type calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
370    pub recent_edit_count: u32,
371    /// Write-type calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
372    pub recent_write_count: u32,
373    /// Read-type calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
374    pub recent_read_count: u32,
375    /// TodoWrite calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
376    pub recent_todowrite_count: u32,
377    /// Configured `new` tool calls across the full request history.
378    pub new_count: u32,
379    /// Configured `new` tool calls within the recent window.
380    pub recent_new_count: u32,
381    /// Consecutive trailing tool calls in the `Unknown` category (no Write/Edit/Read/
382    /// Plan match). Surfaced in the classifier state summary; not scored directly.
383    pub pure_bash_streak: u32,
384    /// A tool result after the latest recent failure matched a test-pass pattern.
385    pub tests_passed: bool,
386    /// Total `ToolResult` blocks, counted per block (a message batching N
387    /// results contributes N) and including empty-content results.
388    pub tool_result_count: u32,
389    /// Messages with `Role::Assistant`, unlike [`ToolSignals::turn_depth`],
390    /// which counts every message regardless of role.
391    pub assistant_turn_count: u32,
392    /// Message-count proxy for turn depth. Wire-format dependent (Anthropic batches
393    /// tool results into fewer messages than OpenAI-chat), so gates keyed on it are
394    /// approximate across request origins.
395    pub turn_depth: u32,
396    /// The request carries a context-compaction summary (the agent's context was
397    /// summarised after overflowing). Compaction resets the router's accumulated
398    /// signals, so a task that was on the strong tier de-escalates back to weak — the
399    /// picker uses this to force + hold the strong tier. Self-latching: the summary
400    /// stays in the context prefix on every subsequent turn.
401    pub compacted: bool,
402}
403
404impl ToolSignals {
405    /// Extracts tool and activity signals from `request`.
406    ///
407    /// `window_size` limits recent counters to the newest tool results. `None`
408    /// uses [`DEFAULT_RECENT_WINDOW`].
409    pub fn from_request(request: &Request, window_size: Option<usize>) -> Self {
410        Self::from_request_with_semantics(request, window_size, &ToolSemantics::default())
411    }
412
413    /// Extracts signals using the built-in vocabulary plus additive semantics.
414    pub fn from_request_with_semantics(
415        request: &Request,
416        window_size: Option<usize>,
417        semantics: &ToolSemantics,
418    ) -> Self {
419        extract_tool_signals_with_window_and_semantics(
420            request,
421            window_size.unwrap_or(DEFAULT_RECENT_WINDOW),
422            semantics,
423            false,
424        )
425    }
426}
427
428#[derive(Debug, Clone, Copy, PartialEq, Eq)]
429enum MutationKind {
430    Write,
431    Edit,
432}
433
434/// Domain-neutral meaning assigned to an observed tool call.
435#[derive(Debug, Clone, Copy, PartialEq, Eq)]
436enum ToolSemantic {
437    Mutate(MutationKind),
438    Observe,
439    Plan,
440    New,
441    Unknown,
442}
443
444/// Request-side processor that extracts tool-result signals from each request
445/// and stores them on the request `State` for downstream routing.
446#[derive(Debug, Clone)]
447pub struct ToolSignalProcessor {
448    /// Number of trailing tool results the `recent_*` counts and windowed
449    /// severity are computed over.
450    pub recent_window: usize,
451    /// Route-scoped additions to the built-in tool vocabulary.
452    pub tool_semantics: ToolSemantics,
453}
454
455impl Default for ToolSignalProcessor {
456    fn default() -> Self {
457        Self {
458            recent_window: DEFAULT_RECENT_WINDOW,
459            tool_semantics: ToolSemantics::default(),
460        }
461    }
462}
463
464impl ToolSignalProcessor {
465    pub(crate) fn for_user_turn(&self, request: &Request) -> ToolSignals {
466        extract_tool_signals_with_window_and_semantics(
467            request,
468            self.recent_window,
469            &self.tool_semantics,
470            true,
471        )
472    }
473}
474
475#[async_trait]
476impl Processor for ToolSignalProcessor {
477    async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
478        if let Event::Request { request: req, .. } = event {
479            let tool_signal = ToolSignals::from_request_with_semantics(
480                req,
481                Some(self.recent_window),
482                &self.tool_semantics,
483            );
484            state.tool_signals = Some(tool_signal);
485        }
486        Ok(())
487    }
488}
489
490fn classify_tool_call(name: &str, command: Option<&str>) -> ToolSemantic {
491    classify_tool_call_with_semantics(
492        name,
493        command
494            .map(|command| Value::String(command.to_owned()))
495            .as_ref(),
496        &ToolSemantics::default(),
497    )
498}
499
500/// Recognize straight-line Codex patch calls, with optional literal bindings and
501/// `text(...)` wrappers. Other JavaScript stays unknown, including control flow.
502fn code_mode_has_patch(mut input: &str) -> bool {
503    static STATEMENT: LazyLock<Regex> = LazyLock::new(|| {
504        let identifier = r"[a-zA-Z_$][a-zA-Z0-9_$]*";
505        let literal = r#"(?:"(?:[^"\\\r\n]|\\[^\r\n])*"|'(?:[^'\\\r\n]|\\[^\r\n])*'|`(?:[^`\\$]|\\[^\r\n])*`)"#;
506        let argument = format!(r"(?:{literal}|{identifier})");
507        let patch = format!(r"await\s+tools\s*\.\s*apply_patch\s*\(\s*{argument}\s*\)");
508        // The pattern uses only constant fragments.
509        Regex::new(&format!(
510            r"(?x)\A(?://[^\r\n]*|(?s:/\*.*?\*/)|(?:
511                (?:const|let)\s+(?P<binding>{identifier})\s*=\s*{literal}|
512                (?P<patch>{patch}|text\s*\(\s*{patch}\s*\))|
513                text\s*\(\s*{argument}\s*\)
514            )[\t\x20]*(?:;|\r?\n|\z))"
515        ))
516        .expect("constant code-mode statement pattern")
517    });
518    let mut has_patch = false;
519    while !input.trim_start().is_empty() {
520        input = input.trim_start();
521        let Some(statement) = STATEMENT.captures(input) else {
522            return false;
523        };
524        if statement
525            .name("binding")
526            .is_some_and(|binding| matches!(binding.as_str(), "tools" | "text"))
527        {
528            return false;
529        }
530        has_patch |= statement.name("patch").is_some();
531        input = &input[statement[0].len()..];
532    }
533    has_patch
534}
535
536fn classify_tool_call_with_semantics(
537    name: &str,
538    command: Option<&Value>,
539    semantics: &ToolSemantics,
540) -> ToolSemantic {
541    // Built-in names and Bash command inference take precedence over route-scoped mappings.
542    let lower = name.to_lowercase();
543    if WRITE_TOOL_NAMES.contains(&lower.as_str()) {
544        return ToolSemantic::Mutate(MutationKind::Write);
545    }
546    if EDITOR_TOOL_NAMES.contains(&lower.as_str())
547        && command
548            .and_then(Value::as_str)
549            .is_some_and(|command| command.eq_ignore_ascii_case("view"))
550    {
551        return ToolSemantic::Observe;
552    }
553    if EDIT_TOOL_NAMES.contains(&lower.as_str()) {
554        return ToolSemantic::Mutate(MutationKind::Edit);
555    }
556    if READ_TOOL_NAMES.contains(&lower.as_str()) {
557        return ToolSemantic::Observe;
558    }
559    if PLAN_TOOL_NAMES.contains(&lower.as_str()) {
560        return ToolSemantic::Plan;
561    }
562    if BASH_TOOL_NAMES.contains(&lower.as_str())
563        && let Some(command) = command
564        && let Some(cmd) = command_text(command)
565    {
566        // Write/edit redirection trumps read-like operands.
567        if BASH_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_write(&cmd) {
568            return ToolSemantic::Mutate(MutationKind::Write);
569        }
570        if cmd.contains("python") && PYTHON_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
571            return ToolSemantic::Mutate(MutationKind::Write);
572        }
573        if shell_invokes_program(&cmd, "node")
574            && JAVASCRIPT_WRITE_PATTERNS
575                .iter()
576                .any(|pattern| cmd.contains(pattern))
577        {
578            return ToolSemantic::Mutate(MutationKind::Write);
579        }
580        if BASH_EDIT_PATTERNS.iter().any(|p| cmd.contains(p))
581            || shell_command_is_edit(&cmd)
582            || command_invokes_patch(command)
583        {
584            return ToolSemantic::Mutate(MutationKind::Edit);
585        }
586        if BASH_READ_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_read(&cmd) {
587            return ToolSemantic::Observe;
588        }
589    }
590    semantics.classify(name).unwrap_or(ToolSemantic::Unknown)
591}
592
593/// Tools whose successful output is retrieved content or inspection data.
594fn is_retrieval_tool(name: &str, command: Option<&Value>) -> bool {
595    let lower = name.to_lowercase();
596    READ_TOOL_NAMES.contains(&lower.as_str())
597        || (EDITOR_TOOL_NAMES.contains(&lower.as_str())
598            && command
599                .and_then(Value::as_str)
600                .is_some_and(|command| command.eq_ignore_ascii_case("view")))
601        // PowerShell quoting and escapes do not follow the POSIX rules below.
602        || (lower != "powershell"
603            && BASH_TOOL_NAMES.contains(&lower.as_str())
604            && command.is_some_and(command_is_retrieval))
605}
606
607/// A shell command field given as one line or as argv words.
608fn command_is_retrieval(command: &Value) -> bool {
609    match command {
610        Value::String(line) => shell_is_retrieval(line, 0),
611        Value::Array(argv) => {
612            // Charge each word a separator byte, as in the one-line form, so the
613            // budget also bounds the argument count.
614            argv.iter()
615                .try_fold(0, |size, word| {
616                    let size = size + word.as_str()?.len() + 1;
617                    (size <= MAX_RETRIEVAL_COMMAND_BYTES).then_some(size)
618                })
619                .is_some()
620                && retrieval_command(
621                    &argv.iter().filter_map(Value::as_str).collect::<Vec<_>>(),
622                    0,
623                )
624        }
625        _ => false,
626    }
627}
628
629// Keep shell parsing bounded on the request thread. Larger commands retain
630// ordinary error scanning, just like unrecognized shell syntax.
631const MAX_RETRIEVAL_COMMAND_BYTES: usize = 16 * 1024;
632
633/// Only suppress output when every shell segment is a recognized inspection.
634/// Observation counters can count mixed commands; suppressing errors needs all
635/// commands to qualify. Substitutions and redirects keep their error signals.
636fn shell_is_retrieval(command: &str, depth: usize) -> bool {
637    if command.len() > MAX_RETRIEVAL_COMMAND_BYTES {
638        return false;
639    }
640    if quoted_chars(command).any(|(_, c, quote)| {
641        (quote != Some('\'') && matches!(c, '$' | '`'))
642            || (quote.is_none() && matches!(c, '<' | '>' | '(' | ')' | '{' | '}' | '#'))
643    }) {
644        return false;
645    }
646    let mut segments = shell_segments(command).peekable();
647    segments.peek().is_some()
648        && segments.all(|segment| {
649            // `split` also rejects an unclosed quote or a trailing backslash.
650            shlex::split(segment).is_some_and(|words| {
651                retrieval_command(&words.iter().map(String::as_str).collect::<Vec<_>>(), depth)
652            })
653        })
654}
655
656fn skip_shell_options<'a>(mut args: &'a [&'a str], takes_value: &[&str]) -> &'a [&'a str] {
657    while let Some((option, rest)) = args.split_first() {
658        if !option.starts_with('-') || *option == "-" {
659            break;
660        }
661        args = rest;
662        if *option == "--" {
663            break;
664        }
665        if takes_value.contains(option) {
666            args = args.get(1..).unwrap_or_default();
667        }
668    }
669    args
670}
671
672/// A short-option word such as `-Hx` that sets any of `flags`.
673fn has_short_flag(word: &str, flags: &[char]) -> bool {
674    word.starts_with('-') && !word.starts_with("--") && word.contains(flags)
675}
676
677// Bounds recursion through nested wrappers and `sh -c` scripts.
678const MAX_COMMAND_DEPTH: usize = 20;
679
680/// This helper skips leading assignments whose names are valid shell identifiers.
681fn skip_shell_assignments<'a>(mut words: &'a [&'a str]) -> &'a [&'a str] {
682    while words.first().is_some_and(|word| {
683        word.split_once('=').is_some_and(|(name, _)| {
684            name.starts_with(|c: char| c == '_' || c.is_ascii_alphabetic())
685                && name.bytes().all(|c| c == b'_' || c.is_ascii_alphanumeric())
686        })
687    }) {
688        words = &words[1..];
689    }
690    words
691}
692
693/// This helper returns the command words after a supported launcher's options and values.
694/// It leaves `env -S` unrecognized because that option splits command text into arguments.
695fn shell_wrapper_arguments<'a>(program: &str, args: &'a [&'a str]) -> Option<&'a [&'a str]> {
696    let options: &[&str] = match program {
697        "sudo" => &[
698            "-u", "-g", "-h", "-p", "-C", "-T", "--user", "--group", "--host",
699        ],
700        "env" => &["-u", "--unset", "-C", "--chdir"],
701        "command" | "nohup" => &[],
702        "exec" => &["-a"],
703        "time" => &["-f", "--format", "-o", "--output"],
704        "timeout" => &["-s", "--signal", "-k", "--kill-after"],
705        "xargs" => &[
706            "-n",
707            "--max-args",
708            "-P",
709            "--max-procs",
710            "-I",
711            "--replace",
712            "-d",
713            "--delimiter",
714            "-L",
715            "--max-lines",
716            "-s",
717            "--max-chars",
718        ],
719        _ => return None,
720    };
721    // `env -S` splits its argument into a new command line.
722    if program == "env"
723        && args
724            .iter()
725            .any(|arg| arg.starts_with("--split-string") || has_short_flag(arg, &['S']))
726    {
727        return None;
728    }
729    let mut rest = skip_shell_options(args, options);
730    if program == "timeout" {
731        rest = rest.get(1..).unwrap_or_default();
732    }
733    Some(rest)
734}
735
736fn retrieval_command(words: &[&str], depth: usize) -> bool {
737    if depth > MAX_COMMAND_DEPTH {
738        return false;
739    }
740    let Some((&program, args)) = skip_shell_assignments(words).split_first() else {
741        return false;
742    };
743    // The standard bin dirs hold the real programs. Any other path, such as
744    // `./test`, names a local script.
745    let program = [
746        "/bin/",
747        "/sbin/",
748        "/usr/bin/",
749        "/usr/sbin/",
750        "/usr/local/bin/",
751        "/opt/homebrew/bin/",
752    ]
753    .iter()
754    .find_map(|dir| program.strip_prefix(dir))
755    .unwrap_or(program);
756    // Unwrap launchers before inspecting the actual command and its flags.
757    if let Some(rest) = shell_wrapper_arguments(program, args) {
758        return (program == "env" && rest.is_empty()) || retrieval_command(rest, depth + 1);
759    }
760    if matches!(program, "bash" | "sh" | "dash" | "zsh" | "ksh") {
761        return args
762            .first()
763            .is_some_and(|option| has_short_flag(option, &['c']))
764            && args
765                .get(1)
766                .is_some_and(|command| shell_is_retrieval(command, depth + 1));
767    }
768    if program == "git" {
769        return git_is_retrieval(args);
770    }
771    if matches!(args, ["--help" | "--version"]) {
772        return true;
773    }
774    match program {
775        "find" => !args.iter().any(|arg| {
776            matches!(
777                *arg,
778                "-delete"
779                    | "-exec"
780                    | "-execdir"
781                    | "-ok"
782                    | "-okdir"
783                    | "-fprint"
784                    | "-fprint0"
785                    | "-fprintf"
786            )
787        }),
788        // fd joins short flags, so `-Hx` runs a command too.
789        "fd" => !args
790            .iter()
791            .any(|arg| arg.starts_with("--exec") || has_short_flag(arg, &['x', 'X'])),
792        // Only the common line-range printing form; sed scripts can execute commands.
793        "sed" => {
794            args.len() >= 2
795                && args[0] == "-n"
796                && args[2..].iter().all(|arg| !arg.starts_with('-'))
797                && args[1].strip_suffix('p').is_some_and(|range| {
798                    !range.is_empty()
799                        && range
800                            .bytes()
801                            .all(|c| c.is_ascii_digit() || matches!(c, b',' | b'$'))
802                })
803        }
804        "sort" => !args.iter().any(|arg| {
805            arg.starts_with("--compress-program")
806                || arg.starts_with("--output")
807                || arg.starts_with("-o")
808        }),
809        "rg" => !args.iter().any(|arg| arg.starts_with("--pre")),
810        "xxd" => !args.contains(&"-r") && !args.contains(&"-revert"),
811        "go" => {
812            matches!(args.first(), Some(&"list" | &"doc" | &"env" | &"version"))
813                && !args.iter().any(|arg| matches!(*arg, "-w" | "-u"))
814        }
815        "docker" | "podman" => matches!(args.first(), Some(&"ps" | &"version")),
816        "cat" | "grep" | "ls" | "nl" | "head" | "tail" | "wc" | "pwd" | "stat" | "file" | "du"
817        | "df" | "which" | "type" | "diff" | "cmp" | "jq" | "uniq" | "cut" | "readlink"
818        | "realpath" | "tree" | "basename" | "dirname" | "printenv" | "echo" | "printf"
819        | "less" | "more" | "test" | "[" | "ps" | "pgrep" | "pkg-config" | "strings" | "uname"
820        | "od" | "true" | ":" | "cd" | "pstree" | "sha256sum" | "sha1sum" | "md5sum" | "lsof"
821        | "tr" | "free" | "id" | "namei" | "whoami" | "paste" | "ss" | "getent" => true,
822        _ => false,
823    }
824}
825
826fn git_is_retrieval(args: &[&str]) -> bool {
827    let args = skip_shell_options(
828        args,
829        &["-C", "-c", "--git-dir", "--work-tree", "--namespace"],
830    );
831    let Some((subcommand, rest)) = args.split_first() else {
832        return false;
833    };
834    match *subcommand {
835        "diff" => !rest.contains(&"--check"),
836        "branch" => {
837            rest.is_empty()
838                || rest.iter().all(|arg| {
839                    matches!(
840                        *arg,
841                        "-a" | "-r"
842                            | "--all"
843                            | "--list"
844                            | "--show-current"
845                            | "-v"
846                            | "-vv"
847                            | "--verbose"
848                    )
849                })
850        }
851        "remote" => {
852            rest.is_empty()
853                || matches!(rest, ["-v" | "--verbose"])
854                || rest.first() == Some(&"get-url")
855        }
856        "tag" => rest.is_empty() || matches!(rest[0], "-l" | "--list"),
857        "config" => matches!(
858            rest.first(),
859            Some(&"--get" | &"--get-all" | &"--list" | &"-l")
860        ),
861        "worktree" => rest.first() == Some(&"list"),
862        "submodule" => rest.first() == Some(&"status"),
863        "status" | "log" | "show" | "blame" | "ls-files" | "ls-remote" | "rev-parse"
864        | "merge-base" | "grep" | "describe" | "show-ref" | "check-ignore" | "rev-list"
865        | "ls-tree" | "diff-tree" | "version" => true,
866        _ => false,
867    }
868}
869
870fn is_builtin_tool_name(lower: &str) -> bool {
871    WRITE_TOOL_NAMES.contains(&lower)
872        || EDIT_TOOL_NAMES.contains(&lower)
873        || READ_TOOL_NAMES.contains(&lower)
874        || PLAN_TOOL_NAMES.contains(&lower)
875        || BASH_TOOL_NAMES.contains(&lower)
876}
877
878/// Split a shell line at unquoted command separators. This intentionally avoids
879/// pretending to be a full shell parser; only the leading program and flags of
880/// each segment are inspected below.
881fn shell_segments(command: &str) -> impl Iterator<Item = &str> {
882    let mut start = 0;
883    quoted_chars(command)
884        .filter(|&(_, c, quote)| quote.is_none() && matches!(c, '\n' | ';' | '|' | '&'))
885        .map(|(index, _, _)| index)
886        .chain([command.len()])
887        .filter_map(move |end| {
888            let segment = command[start..end].trim();
889            // Separators are one byte.
890            start = end + 1;
891            (!segment.is_empty()).then_some(segment)
892        })
893}
894
895/// Unescaped characters with their byte index and the quote around them.
896fn quoted_chars(command: &str) -> impl Iterator<Item = (usize, char, Option<char>)> {
897    let mut quote = None;
898    let mut escaped = false;
899    command.char_indices().filter_map(move |(index, c)| {
900        if escaped {
901            escaped = false;
902            return None;
903        }
904        if c == '\\' && quote != Some('\'') {
905            escaped = true;
906            return None;
907        }
908        let around = quote;
909        if quote == Some(c) {
910            quote = None;
911        } else if quote.is_none() && matches!(c, '\'' | '"') {
912            quote = Some(c);
913        }
914        Some((index, c, around))
915    })
916}
917
918fn shell_words(segment: &str) -> std::iter::Peekable<std::str::SplitAsciiWhitespace<'_>> {
919    let mut words = segment.split_ascii_whitespace().peekable();
920
921    if words.peek().copied() == Some("env") {
922        words.next();
923        while words.peek().is_some_and(|word| word.starts_with('-')) {
924            words.next();
925        }
926    }
927    while words
928        .peek()
929        .is_some_and(|word| word.contains('=') && !word.starts_with('='))
930    {
931        words.next();
932    }
933
934    words
935}
936
937fn program_name(word: &str) -> &str {
938    Path::new(word)
939        .file_name()
940        .and_then(|name| name.to_str())
941        .unwrap_or(word)
942}
943
944fn shell_invokes_program(command: &str, expected: &str) -> bool {
945    shell_segments(command).any(|segment| {
946        shell_words(segment)
947            .next()
948            .is_some_and(|word| program_name(word) == expected)
949    })
950}
951
952/// This recognizer preserves argv boundaries.
953/// Shell syntax within an argument stays data until a shell wrapper interprets it.
954fn command_invokes_patch(command: &Value) -> bool {
955    match command {
956        Value::String(command) => shell_invokes_patch(command, 0),
957        Value::Array(argv) => argv
958            .iter()
959            .map(Value::as_str)
960            .collect::<Option<Vec<_>>>()
961            .is_some_and(|words| patch_command(&words, 0)),
962        _ => false,
963    }
964}
965
966/// This recognizer finds patch programs in a limited set of shell syntax.
967/// It skips comments and stops at heredoc input.
968fn shell_invokes_patch(mut command: &str, depth: usize) -> bool {
969    if depth > MAX_COMMAND_DEPTH {
970        return false;
971    }
972    while !command.is_empty() {
973        let marker = quoted_chars(command).find(|&(index, c, quote)| {
974            quote.is_none()
975                && (command[index..].starts_with("<<")
976                    || (c == '#'
977                        && command[..index].chars().next_back().is_none_or(|previous| {
978                            previous.is_whitespace() || ";|&()".contains(previous)
979                        })))
980        });
981        let end = marker.map_or(command.len(), |(index, _, _)| index);
982        if shell_segments(&command[..end]).any(|segment| {
983            shlex::split(segment).is_some_and(|words| {
984                patch_command(&words.iter().map(String::as_str).collect::<Vec<_>>(), depth)
985            })
986        }) {
987            return true;
988        }
989        // A comment ends at the next newline. Heredoc input can contain command
990        // text, so scanning stops there without interpreting the body or delimiter.
991        let Some((index, '#', _)) = marker else {
992            return false;
993        };
994        command = command[index..]
995            .split_once('\n')
996            .map_or("", |(_, remaining)| remaining);
997    }
998    false
999}
1000
1001/// This recognizer follows supported launchers and shell `-c` scripts to find patch programs.
1002/// It limits nesting and excludes known shell modes that do not execute commands.
1003fn patch_command(words: &[&str], depth: usize) -> bool {
1004    if depth > MAX_COMMAND_DEPTH {
1005        return false;
1006    }
1007    let Some((&program, args)) = skip_shell_assignments(words).split_first() else {
1008        return false;
1009    };
1010    let program = program_name(program);
1011    if matches!(program, "patch" | "apply_patch") {
1012        return true;
1013    }
1014    // `command -v` and `command -V` inspect a program without executing it.
1015    if program == "command"
1016        && args
1017            .iter()
1018            .take_while(|arg| arg.starts_with('-') && **arg != "--")
1019            .any(|arg| has_short_flag(arg, &['v', 'V']))
1020    {
1021        return false;
1022    }
1023    if let Some(rest) = shell_wrapper_arguments(program, args) {
1024        let options = &args[..args.len() - rest.len()];
1025        return !options
1026            .iter()
1027            .any(|option| matches!(*option, "--help" | "--version"))
1028            && patch_command(rest, depth + 1);
1029    }
1030    if matches!(program, "bash" | "sh" | "dash" | "zsh" | "ksh") {
1031        // Only leading shell options can select a script with `-c`; later words
1032        // belong to that script. Syntax-check mode (`-n`) does not execute it.
1033        let mut options = args.iter();
1034        while let Some(option) = options.next() {
1035            if !option.starts_with('-') || matches!(*option, "-" | "--") {
1036                break;
1037            }
1038            if has_short_flag(option, &['n']) {
1039                return false;
1040            }
1041            if option.starts_with("--")
1042                && !matches!(
1043                    *option,
1044                    "--login" | "--noprofile" | "--norc" | "--rcfile" | "--init-file"
1045                )
1046            {
1047                return false;
1048            }
1049            if has_short_flag(option, &['o', 'O']) || matches!(*option, "--rcfile" | "--init-file")
1050            {
1051                let Some(value) = options.next() else {
1052                    return false;
1053                };
1054                if has_short_flag(option, &['o']) && *value == "noexec" {
1055                    return false;
1056                }
1057            }
1058            if has_short_flag(option, &['c']) {
1059                return options
1060                    .next()
1061                    .is_some_and(|command| shell_invokes_patch(command, depth + 1));
1062            }
1063        }
1064    }
1065    false
1066}
1067
1068fn shell_command_is_write(command: &str) -> bool {
1069    shell_segments(command).any(|segment| {
1070        let mut words = shell_words(segment);
1071        let Some(program) = words.next().map(program_name) else {
1072            return false;
1073        };
1074        if matches!(program, "cp" | "mkdir" | "touch" | "install") {
1075            return true;
1076        }
1077
1078        let redirects_output = words.any(|word| matches!(word, ">" | ">>"));
1079        redirects_output
1080            && (matches!(program, "echo" | "printf" | "git")
1081                || BASH_READ_COMMANDS.contains(&program))
1082    })
1083}
1084
1085fn shell_command_is_edit(command: &str) -> bool {
1086    shell_segments(command).any(|segment| {
1087        let mut words = shell_words(segment);
1088        let Some(program) = words.next().map(program_name) else {
1089            return false;
1090        };
1091        let has_arg = |arg: &str| words.clone().any(|word| word == arg);
1092
1093        match program {
1094            "mv" | "rm" => true,
1095            "perl" => words
1096                .take_while(|word| word.starts_with('-'))
1097                .any(|option| {
1098                    option
1099                        .trim_start_matches('-')
1100                        .chars()
1101                        .any(|flag| flag == 'i')
1102                }),
1103            "git" => words
1104                .next()
1105                .is_some_and(|subcommand| matches!(subcommand, "apply" | "am" | "restore")),
1106            "gofmt" => has_arg("-w"),
1107            "cargo" => words.clone().next() == Some("fmt") && !has_arg("--check"),
1108            "ruff" => {
1109                let subcommand = words.clone().next();
1110                (subcommand == Some("format") && !has_arg("--check"))
1111                    || (subcommand == Some("check") && has_arg("--fix"))
1112            }
1113            "prettier" => has_arg("--write"),
1114            "black" => !has_arg("--check"),
1115            _ => {
1116                (words.clone().any(|word| program_name(word) == "prettier") && has_arg("--write"))
1117                    || (words.clone().any(|word| program_name(word) == "ruff")
1118                        && ((has_arg("format") && !has_arg("--check"))
1119                            || (has_arg("check") && has_arg("--fix"))))
1120            }
1121        }
1122    })
1123}
1124
1125fn shell_command_is_read(command: &str) -> bool {
1126    shell_segments(command).any(|segment| {
1127        if segment == "env" {
1128            return true;
1129        }
1130        let mut words = shell_words(segment);
1131        let Some(program) = words.next().map(program_name) else {
1132            return false;
1133        };
1134
1135        if BASH_READ_COMMANDS.contains(&program) {
1136            return true;
1137        }
1138        if program == "command" && words.next() == Some("-v") {
1139            return true;
1140        }
1141        if program == "type" {
1142            return true;
1143        }
1144        if program != "git" {
1145            return false;
1146        }
1147
1148        match words.next() {
1149            Some("branch") => words.next().is_none_or(|arg| arg.starts_with('-')),
1150            Some("remote") => words
1151                .next()
1152                .is_none_or(|arg| arg.starts_with('-') || arg == "get-url"),
1153            Some("config") => words
1154                .next()
1155                .is_some_and(|arg| matches!(arg, "--get" | "--get-all" | "--list" | "-l")),
1156            Some(subcommand) => GIT_READ_SUBCOMMANDS.contains(&subcommand),
1157            None => false,
1158        }
1159    })
1160}
1161
1162// ─── extraction entry point ───────────────────────────────────────────────────
1163
1164/// Extract all tool-execution signals from a normalized [`Request`].
1165///
1166/// Returns [`ToolSignals::default()`] when the message history contains no tool
1167/// activity, so callers can always inspect the signal fields.
1168fn extract_tool_signals_with_window(request: &Request, recent_window: usize) -> ToolSignals {
1169    extract_tool_signals_with_window_and_semantics(
1170        request,
1171        recent_window,
1172        &ToolSemantics::default(),
1173        false,
1174    )
1175}
1176
1177fn extract_tool_signals_with_window_and_semantics(
1178    request: &Request,
1179    recent_window: usize,
1180    semantics: &ToolSemantics,
1181    current_user_turn: bool,
1182) -> ToolSignals {
1183    // Read the decoded conversation, including preserved built-in tool outputs.
1184    let messages = &request.llm_request.messages;
1185    let boundary = if current_user_turn {
1186        messages.iter().rposition(is_user_turn)
1187    } else {
1188        None
1189    };
1190    let start = boundary.map_or(0, |index| index + 1);
1191    let namespaces = tool_namespaces(&request.llm_request.extensions);
1192    let mut tool_texts: Vec<(String, bool)> = Vec::new();
1193    let mut tool_calls: Vec<ToolSemantic> = Vec::new();
1194    // IDs whose latest call is a retrieval tool.
1195    let mut retrieval_calls: HashSet<&str> = HashSet::new();
1196    let mut tool_result_count = 0usize;
1197    let mut assistant_turn_count = 0usize;
1198
1199    for (index, message) in messages.iter().enumerate() {
1200        if index >= start && message.role == Role::Assistant {
1201            assistant_turn_count += 1;
1202        }
1203        for block in &message.content {
1204            match block {
1205                _ if index < start => continue,
1206                ContentBlock::ToolCall(call) => {
1207                    // Responses namespaced tools arrive as `<namespace>__<tool>`.
1208                    let bare_name = namespaces
1209                        .and_then(|namespaces| split_qualified_name(namespaces, &call.name))
1210                        .map(|(tool, _)| tool)
1211                        .or_else(|| mcp_tool_name(&call.name));
1212                    // The Responses wire format sends arguments as a JSON string.
1213                    let decoded = call
1214                        .arguments
1215                        .as_str()
1216                        .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
1217                    let arguments = decoded.as_ref().unwrap_or(&call.arguments);
1218                    let command_field = command_of(arguments);
1219                    // Codex uses exec.input for JavaScript. OpenClaw's exec.command
1220                    // remains a shell call, even when the names are identical.
1221                    let code_semantic = (bare_name.unwrap_or(&call.name) == "exec"
1222                        && arguments.get("command").is_none()
1223                        && arguments.get("cmd").is_none())
1224                    .then(|| arguments.get("input").and_then(Value::as_str))
1225                    .flatten()
1226                    .map(|input| {
1227                        if code_mode_has_patch(input) {
1228                            ToolSemantic::Mutate(MutationKind::Edit)
1229                        } else {
1230                            ToolSemantic::Unknown
1231                        }
1232                    });
1233                    // The joined name takes precedence. A joined name configured
1234                    // as observe still counts as retrieval when its bare name is
1235                    // a retrieval tool, such as `mcp__files__read`.
1236                    let full =
1237                        classify_tool_call_with_semantics(&call.name, command_field, semantics);
1238                    let name = match (full, bare_name) {
1239                        (ToolSemantic::Unknown | ToolSemantic::Observe, Some(bare_name)) => {
1240                            bare_name
1241                        }
1242                        _ => call.name.as_str(),
1243                    };
1244                    let is_retrieval =
1245                        code_semantic.is_none() && is_retrieval_tool(name, command_field);
1246                    if !call.id.is_empty() {
1247                        // A reused ID links to its latest call.
1248                        if is_retrieval {
1249                            retrieval_calls.insert(call.id.as_str());
1250                        } else {
1251                            retrieval_calls.remove(call.id.as_str());
1252                        }
1253                    }
1254                    let semantic = if let Some(semantic) = code_semantic {
1255                        semantic
1256                    } else if is_retrieval {
1257                        ToolSemantic::Observe
1258                    } else if full == ToolSemantic::Unknown {
1259                        bare_name.map_or(full, |name| {
1260                            classify_tool_call_with_semantics(name, command_field, semantics)
1261                        })
1262                    } else {
1263                        full
1264                    };
1265                    tool_calls.push(semantic);
1266                }
1267                ContentBlock::ToolResult(result) => {
1268                    // Before the empty-text filter: empty results still count.
1269                    tool_result_count += 1;
1270                    let is_error = result.is_error == Some(true);
1271                    let text = result
1272                        .content
1273                        .iter()
1274                        .filter_map(text_of)
1275                        .collect::<Vec<_>>()
1276                        .join("\n");
1277                    let is_retrieval_result =
1278                        retrieval_calls.contains(result.tool_call_id.as_str());
1279                    if is_retrieval_result {
1280                        // Shell reads can return bare JSON from a file. Hermes wraps
1281                        // terminal output with output and exit_code fields.
1282                        let shell_result = serde_json::Deserializer::from_str(&text)
1283                            .into_iter::<Value>()
1284                            .next()
1285                            .and_then(|result| result.ok())
1286                            .filter(|value| {
1287                                value["output"].is_string() && value.get("exit_code").is_some()
1288                            });
1289                        // Codex reports the exit status in a header before `Output:`.
1290                        // An exit line after it is part of what was read.
1291                        let status = text
1292                            .split_once("\nOutput:\n")
1293                            .map_or(text.as_str(), |(header, _)| header);
1294                        // Hermes reports it in fields, so its output is not scanned.
1295                        let shell_failed = match &shell_result {
1296                            Some(value) => {
1297                                value["exit_code"].as_i64().is_some_and(|code| code != 0)
1298                                    || value["success"].as_bool() == Some(false)
1299                                    || value["error"]
1300                                        .as_str()
1301                                        .is_some_and(|error| !error.trim().is_empty())
1302                            }
1303                            None => has_nonzero_exit_status(&status.to_lowercase()),
1304                        };
1305                        if is_missing_file(&text) || (!is_error && !shell_failed) {
1306                            // Keep the window slot after dropping retrieved content.
1307                            if !text.is_empty() {
1308                                tool_texts.push((String::new(), false));
1309                            }
1310                            continue;
1311                        }
1312                    }
1313                    // An explicit failure remains a signal even without text.
1314                    if !text.is_empty() || is_error {
1315                        tool_texts.push((text, is_error));
1316                    }
1317                }
1318                // Built-in tool history stays opaque so it can be replayed unchanged.
1319                ContentBlock::Unknown { provider, raw }
1320                    if provider.as_str() == WireFormat::OpenAiResponses.as_str()
1321                        && raw.get("type").and_then(Value::as_str)
1322                            == Some("apply_patch_call_output") =>
1323                {
1324                    tool_result_count += 1;
1325                    let text = raw
1326                        .get("output")
1327                        .and_then(Value::as_str)
1328                        .unwrap_or_default();
1329                    let is_error = raw.get("status").and_then(Value::as_str) == Some("failed");
1330                    tool_texts.push((text.to_owned(), is_error));
1331                }
1332                ContentBlock::Unknown { provider, raw }
1333                    if provider.as_str() == WireFormat::OpenAiResponses.as_str()
1334                        && raw.get("type").and_then(Value::as_str) == Some("shell_call_output") =>
1335                {
1336                    tool_result_count += 1;
1337                    let mut texts = Vec::new();
1338                    let mut is_error = false;
1339                    if let Some(outputs) = raw.get("output").and_then(Value::as_array) {
1340                        for output in outputs {
1341                            for field in ["stdout", "stderr"] {
1342                                if let Some(text) = output.get(field).and_then(Value::as_str)
1343                                    && !text.is_empty()
1344                                {
1345                                    texts.push(text);
1346                                }
1347                            }
1348                            if let Some(outcome) = output.get("outcome") {
1349                                is_error |= match outcome.get("type").and_then(Value::as_str) {
1350                                    Some("timeout") => true,
1351                                    Some("exit")
1352                                        if outcome
1353                                            .get("exit_code")
1354                                            .and_then(Value::as_i64)
1355                                            .is_some_and(|code| code != 0) =>
1356                                    {
1357                                        // Keep plain nonzero exits SOFT, including empty output.
1358                                        texts.push("returned non-zero");
1359                                        output
1360                                            .get("stderr")
1361                                            .and_then(Value::as_str)
1362                                            .is_some_and(|text| !text.trim().is_empty())
1363                                    }
1364                                    _ => false,
1365                                };
1366                            }
1367                        }
1368                    }
1369                    let text = texts.join("\n");
1370                    tool_texts.push((text, is_error));
1371                }
1372                _ => {}
1373            }
1374        }
1375    }
1376
1377    let mut signal = build_signal(
1378        tool_texts,
1379        tool_calls,
1380        (messages.len() - boundary.unwrap_or(0)) as u32,
1381        recent_window,
1382    );
1383    signal.compacted = request.llm_request.has_compaction;
1384    signal.tool_result_count = u32::try_from(tool_result_count).unwrap_or(u32::MAX);
1385    signal.assistant_turn_count = u32::try_from(assistant_turn_count).unwrap_or(u32::MAX);
1386    signal
1387}
1388
1389/// The tool part of an `mcp__<server>__<tool>` name, the form Claude Code uses
1390/// for MCP tools. The server name is assumed not to contain `__`; the tool name
1391/// may.
1392fn mcp_tool_name(name: &str) -> Option<&str> {
1393    let (_server, tool) = name.strip_prefix("mcp__")?.split_once("__")?;
1394    (!tool.is_empty()).then_some(tool)
1395}
1396
1397/// The command field a tool call carries, when it has one. Harnesses name it
1398/// `command`, `cmd` or `input`; anything else is a tool whose category comes
1399/// from its name.
1400fn command_of(arguments: &Value) -> Option<&Value> {
1401    ["command", "cmd", "input"]
1402        .iter()
1403        .find_map(|key| arguments.get(*key))
1404}
1405
1406/// A command field as lowercase text, from a string or an argv array.
1407fn command_text(value: &Value) -> Option<String> {
1408    match value {
1409        Value::String(text) => Some(text.to_lowercase()),
1410        Value::Array(parts) => {
1411            let joined = parts
1412                .iter()
1413                .filter_map(Value::as_str)
1414                .collect::<Vec<_>>()
1415                .join(" ");
1416            (!joined.is_empty()).then(|| joined.to_lowercase())
1417        }
1418        _ => None,
1419    }
1420}
1421
1422/// Text carried by a content block, ignoring the non-textual kinds.
1423fn text_of(block: &ContentBlock) -> Option<&str> {
1424    match block {
1425        ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
1426        _ => None,
1427    }
1428}
1429
1430/// Reads of missing paths happen as often on tasks that succeed as on tasks
1431/// that fail, so they are not a failure signal.
1432fn is_missing_file(text: &str) -> bool {
1433    let lower = text.to_lowercase();
1434    // The OS error from shell tools, and Claude Code's Read tool message.
1435    lower.contains("no such file or directory") || lower.contains("file does not exist")
1436}
1437
1438fn build_signal(
1439    tool_texts: Vec<(String, bool)>,
1440    tool_calls: Vec<ToolSemantic>,
1441    turn_depth: u32,
1442    recent_window: usize,
1443) -> ToolSignals {
1444    // Windowed severity: take the MAX severity across the last `recent_window` tool
1445    // results rather than only the last one. An error's severity then persists for
1446    // the recent window and decays out of it — parallel to the windowed `recent_*`
1447    // counts — so a fix written a couple of turns after an error still routes on the
1448    // error signal instead of the router flapping straight back to the weak tier.
1449    let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
1450    let mut severity = 0.0f32;
1451    let mut failure_fingerprints = Vec::new();
1452    let mut repeated_failure = false;
1453    for (text, is_error) in &tool_texts[sev_start..] {
1454        let (sev, _patterns) = classify_text(text);
1455        // Explicit failure is at least hard; retain stronger text diagnostics.
1456        let sev = if *is_error { sev.max(HARD) } else { sev };
1457        if sev > severity {
1458            severity = sev;
1459        }
1460        if let Some(fingerprint) = failure_fingerprint(text, *is_error) {
1461            repeated_failure |= failure_fingerprints.contains(&fingerprint);
1462            failure_fingerprints.push(fingerprint);
1463        }
1464    }
1465
1466    let no_error_streak = compute_no_error_streak(&tool_texts);
1467
1468    // Single pass: cumulative + sliding-window counters together. Also tracks
1469    // the trailing pure-bash streak (consecutive `Unknown` calls back
1470    // from the end) — the build-pit proxy.
1471    let recent_start = tool_calls.len().saturating_sub(recent_window);
1472    let mut write_count = 0u32;
1473    let mut edit_count = 0u32;
1474    let mut read_count = 0u32;
1475    let mut todowrite_count = 0u32;
1476    let mut recent_write_count = 0u32;
1477    let mut recent_edit_count = 0u32;
1478    let mut recent_read_count = 0u32;
1479    let mut recent_todowrite_count = 0u32;
1480    let mut new_count = 0u32;
1481    let mut recent_new_count = 0u32;
1482    let mut pure_bash_streak = 0u32;
1483    let mut streak_open = true;
1484    for (i, cat) in tool_calls.iter().enumerate().rev() {
1485        if streak_open {
1486            if matches!(cat, ToolSemantic::Unknown) {
1487                pure_bash_streak += 1;
1488            } else {
1489                streak_open = false;
1490            }
1491        }
1492        match cat {
1493            ToolSemantic::Mutate(MutationKind::Write) => {
1494                write_count += 1;
1495                if i >= recent_start {
1496                    recent_write_count += 1;
1497                }
1498            }
1499            ToolSemantic::Mutate(MutationKind::Edit) => {
1500                edit_count += 1;
1501                if i >= recent_start {
1502                    recent_edit_count += 1;
1503                }
1504            }
1505            ToolSemantic::Observe => {
1506                read_count += 1;
1507                if i >= recent_start {
1508                    recent_read_count += 1;
1509                }
1510            }
1511            ToolSemantic::Plan => {
1512                todowrite_count += 1;
1513                if i >= recent_start {
1514                    recent_todowrite_count += 1;
1515                }
1516            }
1517            ToolSemantic::New => {
1518                new_count += 1;
1519                if i >= recent_start {
1520                    recent_new_count += 1;
1521                }
1522            }
1523            ToolSemantic::Unknown => {}
1524        }
1525    }
1526
1527    let tests_passed = detect_tests_passed(&tool_texts, recent_window);
1528
1529    ToolSignals {
1530        severity,
1531        repeated_failure,
1532        no_error_streak,
1533        edit_count,
1534        write_count,
1535        read_count,
1536        todowrite_count,
1537        recent_edit_count,
1538        recent_write_count,
1539        recent_read_count,
1540        recent_todowrite_count,
1541        new_count,
1542        recent_new_count,
1543        pure_bash_streak,
1544        tests_passed,
1545        turn_depth,
1546        // Set by extract_tool_signals_with_window after the format-specific extract,
1547        // which scans all message contents for the compaction marker and tallies
1548        // the raw conversation-shape counts.
1549        tool_result_count: 0,
1550        assistant_turn_count: 0,
1551        compacted: false,
1552    }
1553}
1554
1555// ─── pure helpers ─────────────────────────────────────────────────────────────
1556
1557/// Normalise a JSON tool-result content value to a plain string.
1558fn content_to_text(content: Option<&Value>) -> Option<String> {
1559    match content? {
1560        Value::String(s) => Some(s.clone()),
1561        Value::Array(blocks) => {
1562            let parts: Vec<&str> = blocks
1563                .iter()
1564                .filter_map(|b| {
1565                    b.as_object()
1566                        .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
1567                        .and_then(|o| o.get("text"))
1568                        .and_then(Value::as_str)
1569                })
1570                .collect();
1571            if parts.is_empty() {
1572                None
1573            } else {
1574                Some(parts.join("\n"))
1575            }
1576        }
1577        _ => None,
1578    }
1579}
1580
1581/// Match tool text and structured result fields against error patterns.
1582///
1583/// Returns `(max_severity, matched_pattern_names)`.
1584pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
1585    let lower = text.to_lowercase();
1586    let mut patterns = Vec::new();
1587    let mut severity: f32 = 0.0;
1588    for (name, sev, substrings) in ERROR_PATTERNS {
1589        if substrings.iter().any(|sub| lower.contains(sub)) {
1590            patterns.push(name.to_string());
1591            severity = severity.max(*sev);
1592        }
1593    }
1594    // Hermes can append a loop warning after the JSON result.
1595    let result = serde_json::Deserializer::from_str(text)
1596        .into_iter::<Value>()
1597        .next()
1598        .and_then(|result| result.ok())
1599        .unwrap_or_default();
1600    let nonzero_exit = result["exit_code"].as_i64().is_some_and(|code| code != 0);
1601    let tool_error = result["success"].as_bool() == Some(false)
1602        || result["error"]
1603            .as_str()
1604            .is_some_and(|error| !error.trim().is_empty());
1605    if (nonzero_exit || has_nonzero_exit_status(&lower))
1606        && !patterns.iter().any(|p| p == "exit_nonzero")
1607    {
1608        patterns.push("exit_nonzero".to_string());
1609        severity = severity.max(SOFT);
1610    }
1611    for (name, matched) in [
1612        ("tool_error", tool_error),
1613        ("compile_error", has_compiler_diagnostic(&lower)),
1614        ("runtime_exception", has_runtime_exception(&lower)),
1615        ("runtime_panic", has_runtime_panic(&lower)),
1616        ("patch_error", has_patch_failure(&lower)),
1617        ("cmd_not_found", ends_with_not_found(&lower)),
1618    ] {
1619        if matched && !patterns.iter().any(|pattern| pattern == name) {
1620            patterns.push(name.to_string());
1621            severity = severity.max(HARD);
1622        }
1623    }
1624    (severity, patterns)
1625}
1626
1627/// Stable identity for a material failure. Soft non-zero exits need an explicit
1628/// failure flag to count as a repeated mistake.
1629fn failure_fingerprint(text: &str, is_error: bool) -> Option<String> {
1630    let (severity, patterns) = classify_text(text);
1631    if severity < HARD && !is_error {
1632        return None;
1633    }
1634
1635    let lower = text.to_lowercase();
1636    let diagnostic = lower
1637        .lines()
1638        .find(|line| is_failure_diagnostic(line))
1639        .or_else(|| lower.lines().find(|line| !line.trim().is_empty()))
1640        .unwrap_or_default();
1641    let normalized = normalize_failure_text(diagnostic);
1642    Some(format!("{}|{normalized}", patterns.join(",")))
1643}
1644
1645fn is_failure_diagnostic(line: &str) -> bool {
1646    let line = line.trim();
1647    [
1648        "error",
1649        "exception",
1650        "panic",
1651        "failed",
1652        "timed out",
1653        "timeout",
1654        "connection refused",
1655        "cannot allocate memory",
1656        "out of memory",
1657        "not found",
1658    ]
1659    .iter()
1660    .any(|marker| line.contains(marker))
1661}
1662
1663/// Removes values that normally change between retries while retaining the
1664/// diagnostic wording that distinguishes one failure from another.
1665fn normalize_failure_text(text: &str) -> String {
1666    let mut normalized = String::new();
1667    for word in text.split_whitespace() {
1668        if !normalized.is_empty() {
1669            normalized.push(' ');
1670        }
1671        let mut in_digits = false;
1672        if word.starts_with('/') || word.contains("/src/") || word.contains("/tmp/") {
1673            normalized.push_str("<path>");
1674            continue;
1675        }
1676        for character in word.chars() {
1677            if character.is_ascii_digit() {
1678                if !in_digits {
1679                    normalized.push('#');
1680                    in_digits = true;
1681                }
1682            } else {
1683                normalized.push(character);
1684                in_digits = false;
1685            }
1686        }
1687    }
1688    normalized.chars().take(240).collect()
1689}
1690
1691fn has_compiler_diagnostic(lower: &str) -> bool {
1692    lower.lines().any(|line| {
1693        let line = line.trim_start();
1694        if matches!(
1695            line,
1696            "compilation failed" | "error: compilation failed" | "error: could not compile"
1697        ) || line.starts_with("error: could not compile ")
1698        {
1699            return true;
1700        }
1701
1702        let Some(rest) = line.strip_prefix("error[e") else {
1703            return false;
1704        };
1705        let Some((code, _)) = rest.split_once("]:") else {
1706            return false;
1707        };
1708        !code.is_empty() && code.chars().all(|character| character.is_ascii_digit())
1709    })
1710}
1711
1712fn has_runtime_exception(lower: &str) -> bool {
1713    let has_exception_line = lower.lines().any(|line| {
1714        let line = line.trim_start();
1715        [
1716            "typeerror:",
1717            "referenceerror:",
1718            "rangeerror:",
1719            "runtimeerror:",
1720            "keyerror:",
1721            "attributeerror:",
1722        ]
1723        .iter()
1724        .any(|prefix| line.starts_with(prefix))
1725    });
1726    has_exception_line && (lower.contains("\n    at ") || lower.contains("\n  at "))
1727}
1728
1729fn has_runtime_panic(lower: &str) -> bool {
1730    lower
1731        .lines()
1732        .any(|line| line.trim_start().starts_with("panic: runtime error:"))
1733        && (lower.contains("\ngoroutine ") || lower.contains("[signal sig"))
1734}
1735
1736fn has_patch_failure(lower: &str) -> bool {
1737    lower.lines().any(|line| {
1738        let line = line.trim_start();
1739        line.starts_with("error: patch failed:")
1740            || line.starts_with("patch failed:")
1741            || line.contains(": patch does not apply")
1742            || line.starts_with("invalid context")
1743    })
1744}
1745
1746/// Shell "not found" line (e.g. `git: not found`, `sh: 1: git: not found`)
1747/// anchored to end-of-line so it still fires when the caller has trimmed the
1748/// tool result's trailing newline, without matching prose that merely mentions
1749/// "not found" mid-sentence.
1750///
1751/// The `: ` separator is required because `classify_text` lowercases its input,
1752/// so a bare end-of-line match would also claim successful output such as
1753/// `HTTP/1.1 404 Not Found`.
1754fn ends_with_not_found(lower: &str) -> bool {
1755    lower
1756        .lines()
1757        .any(|line| line.trim_end().ends_with(": not found"))
1758}
1759
1760/// Detects `exit_nonzero` only when a supported exit phrase is followed by a
1761/// nonzero decimal status.
1762///
1763/// Codex includes "Process exited with code 0" on clean tool results, so exit
1764/// phrases must parse their numeric status instead of matching the phrase alone.
1765fn has_nonzero_exit_status(lower: &str) -> bool {
1766    NONZERO_EXIT_PHRASES
1767        .iter()
1768        .any(|phrase| phrase_followed_by_nonzero_integer(lower, phrase))
1769}
1770
1771/// Matches common "exit code/status N" spellings after optional separators.
1772fn phrase_followed_by_nonzero_integer(lower: &str, phrase: &str) -> bool {
1773    let mut cursor = 0usize;
1774    while let Some(rel) = lower[cursor..].find(phrase) {
1775        let value_start = cursor + rel + phrase.len();
1776        let rest = lower[value_start..].trim_start_matches(|c: char| {
1777            c.is_ascii_whitespace() || matches!(c, ':' | '=' | '\'' | '"' | '`')
1778        });
1779        let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
1780        if !digits.is_empty() && digits.chars().any(|d| d != '0') {
1781            return true;
1782        }
1783        cursor = value_start;
1784    }
1785    false
1786}
1787
1788fn compute_no_error_streak(tool_texts: &[(String, bool)]) -> u32 {
1789    let mut streak = 0u32;
1790    for (text, is_error) in tool_texts.iter().rev() {
1791        let (sev, _) = classify_text(text);
1792        if *is_error || sev > 0.0 {
1793            break;
1794        }
1795        streak += 1;
1796    }
1797    streak
1798}
1799
1800fn detect_tests_passed(tool_texts: &[(String, bool)], recent_window: usize) -> bool {
1801    let start = tool_texts.len().saturating_sub(recent_window.max(1));
1802    let recent = &tool_texts[start..];
1803    let after_latest_failure = recent
1804        .iter()
1805        .rposition(|(text, is_error)| *is_error || classify_text(text).0 > 0.0)
1806        .map_or(recent, |index| &recent[index + 1..]);
1807    after_latest_failure.iter().any(|(text, _)| {
1808        let lower = text.to_lowercase();
1809        TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
1810            && !TEST_FAILURE_LITERAL.iter().any(|p| lower.contains(p))
1811            && !has_nonzero_failure_count(&lower)
1812    })
1813}
1814
1815// True iff `lower` contains a `NUMERIC_FAILURE_KEYWORDS` token preceded
1816// (modulo whitespace) by a nonzero integer. The "modulo whitespace" lets
1817// "1 failed", "1\nfailed", and "1  failed" all trip; the nonzero guard
1818// keeps cargo's "0 failed" / go's "0 errors" / pytest's "0 errors in"
1819// summaries from being misread as failures on a clean run.
1820fn has_nonzero_failure_count(lower: &str) -> bool {
1821    for kw in NUMERIC_FAILURE_KEYWORDS {
1822        let mut cursor = 0usize;
1823        while let Some(rel) = lower[cursor..].find(kw) {
1824            let kw_start = cursor + rel;
1825            let kw_end = kw_start + kw.len();
1826            // Word boundary AFTER the keyword — "errors" mid-word (e.g.
1827            // "errored") shouldn't count as a failure-count site.
1828            let boundary_after = lower[kw_end..]
1829                .chars()
1830                .next()
1831                .is_none_or(|c| !c.is_ascii_alphanumeric());
1832            if boundary_after {
1833                let prefix = &lower[..kw_start];
1834                let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
1835                let digits_rev: String = trimmed
1836                    .chars()
1837                    .rev()
1838                    .take_while(|c| c.is_ascii_digit())
1839                    .collect();
1840                if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
1841                    return true;
1842                }
1843            }
1844            cursor = kw_start + kw.len();
1845        }
1846    }
1847    false
1848}
1849
1850// ─── tests ───────────────────────────────────────────────────────────────────
1851
1852#[cfg(test)]
1853mod tests {
1854    use super::*;
1855    use crate::algorithms::util::stage::score_signal;
1856    use serde_json::json;
1857    use switchyard_protocol::codex_namespaces::TOOL_NAMESPACES_KEY;
1858    use switchyard_protocol::{
1859        ContentBlock, LlmRequest, Message, Metadata, Role, ToolCall, ToolResult,
1860    };
1861
1862    fn with_messages(messages: Vec<Message>) -> Request {
1863        let mut request = Request {
1864            llm_request: LlmRequest {
1865                messages,
1866                ..LlmRequest::default()
1867            },
1868            raw_request: None,
1869            metadata: None,
1870        };
1871        switchyard_translation::normalize_compaction(&mut request.llm_request, None);
1872        request
1873    }
1874
1875    // assistant message with a single named tool call
1876    fn tc(name: &str) -> Message {
1877        Message::new(
1878            Role::Assistant,
1879            vec![ContentBlock::ToolCall(ToolCall {
1880                id: String::new(),
1881                name: name.to_string(),
1882                arguments: json!({}),
1883            })],
1884        )
1885    }
1886
1887    // assistant Bash message carrying `command`
1888    fn bash(command: &str) -> Message {
1889        Message::new(
1890            Role::Assistant,
1891            vec![ContentBlock::ToolCall(ToolCall {
1892                id: String::new(),
1893                name: "Bash".to_string(),
1894                arguments: json!({"command": command}),
1895            })],
1896        )
1897    }
1898
1899    // a tool result message (goes in a user-role message, as in Anthropic's normalised form)
1900    fn tr(text: &str) -> Message {
1901        Message::new(
1902            Role::User,
1903            vec![ContentBlock::ToolResult(ToolResult {
1904                tool_call_id: String::new(),
1905                content: vec![ContentBlock::Text {
1906                    text: text.to_string(),
1907                }],
1908                is_error: None,
1909            })],
1910        )
1911    }
1912
1913    #[test]
1914    fn user_turn_signals_exclude_old_errors_and_keep_user_role_tool_results() {
1915        let mut user = Message::text(Role::User, "next");
1916        user.content.extend(tr("out of memory").content);
1917        let request = with_messages(vec![tr("out of memory"), user, tr("ok")]);
1918        let signals = ToolSignalProcessor::default().for_user_turn(&request);
1919        assert_eq!(signals.tool_result_count, 1);
1920        assert_eq!(signals.severity, 0.0);
1921        assert_eq!(signals.turn_depth, 2);
1922    }
1923
1924    #[test]
1925    fn clean_text_has_zero_severity() {
1926        let (sev, patterns) = classify_text("everything went fine");
1927        assert_eq!(sev, 0.0);
1928        assert!(patterns.is_empty());
1929    }
1930
1931    #[test]
1932    fn structured_tool_failures_affect_recovery_signals() {
1933        for (text, severity) in [
1934            (r#"{"output":"","exit_code":7,"error":null}"#, SOFT),
1935            (r#"{"output":"","exit_code":-1,"error":null}"#, SOFT),
1936            (r#"{"success":false,"error":"No matching text"}"#, HARD),
1937            (r#"{"success":false,"error":null}"#, HARD),
1938            (
1939                r#"{"error":"Overwrite refused","stale_write_blocked":true}"#,
1940                HARD,
1941            ),
1942            (r#"{"output":"out of memory","exit_code":1}"#, CRITICAL),
1943            (r#"{"output":"done","exit_code":0,"error":null}"#, 0.0),
1944            (r#"{"success":true,"error":"  "}"#, 0.0),
1945            (r#"{"output":"running","exit_code":null,"error":null}"#, 0.0),
1946            (r#"{"output":{"success":false},"exit_code":0}"#, 0.0),
1947        ] {
1948            let warned = format!("{text}\n\n[Tool loop warning: repeated identical call]");
1949            let request = with_messages(vec![tr("5 passed in 0.12s"), tr(text), tr(&warned)]);
1950            let signal = ToolSignals::from_request(&request, None);
1951            let clean = severity == 0.0;
1952            assert_eq!(signal.severity, severity, "{text}");
1953            assert_eq!(signal.no_error_streak, if clean { 3 } else { 0 }, "{text}");
1954            assert_eq!(signal.repeated_failure, severity >= HARD, "{text}");
1955            assert_eq!(signal.tests_passed, clean, "{text}");
1956        }
1957    }
1958
1959    #[test]
1960    fn traceback_is_hard() {
1961        let (sev, patterns) = classify_text("Traceback (most recent call last):\n  ValueError");
1962        assert_eq!(sev, HARD);
1963        assert!(patterns.contains(&"traceback".to_string()));
1964    }
1965
1966    #[test]
1967    fn oom_is_critical() {
1968        let (sev, _) = classify_text("Out of memory: kill process 1234");
1969        assert_eq!(sev, CRITICAL);
1970    }
1971
1972    #[test]
1973    fn connection_refused_is_hard() {
1974        let (severity, _) = classify_text("Connection refused on port 8000");
1975        assert_eq!(severity, HARD);
1976    }
1977
1978    #[test]
1979    fn repeated_failure_ignores_volatile_paths_and_numbers() {
1980        let request = with_messages(vec![
1981            tr("error[E0308]: mismatched types at /tmp/a/src/lib.rs:12"),
1982            tr("error[E0308]: mismatched types at /tmp/b/src/lib.rs:47"),
1983        ]);
1984        assert!(ToolSignals::from_request(&request, None).repeated_failure);
1985    }
1986
1987    #[test]
1988    fn different_failures_are_not_repeated() {
1989        let request = with_messages(vec![
1990            tr("error[E0308]: mismatched types"),
1991            tr("error[E0509]: cannot move out"),
1992        ]);
1993        assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1994    }
1995
1996    #[test]
1997    fn one_material_failure_is_not_repeated() {
1998        let request = with_messages(vec![tr("Connection refused on port 8000")]);
1999        assert!(!ToolSignals::from_request(&request, None).repeated_failure);
2000    }
2001
2002    /// Explicit failures count even without diagnostic text and cannot signal recovery.
2003    #[test]
2004    fn structured_tool_failures_feed_error_and_recovery_signals() {
2005        for text in ["Dependency unavailable", "", "5 passed in 0.12s"] {
2006            let mut failed = tr(text);
2007            let ContentBlock::ToolResult(result) = &mut failed.content[0] else {
2008                panic!("expected tool result");
2009            };
2010            result.is_error = Some(true);
2011            let mut request = with_messages(vec![tr("5 passed in 0.12s"), failed.clone()]);
2012            let signals = ToolSignals::from_request(&request, Some(3));
2013            assert_eq!(signals.severity, HARD);
2014            assert!(!signals.repeated_failure);
2015            assert_eq!(signals.no_error_streak, 0);
2016            assert!(!signals.tests_passed);
2017
2018            request.llm_request.messages.push(failed);
2019            assert!(ToolSignals::from_request(&request, Some(3)).repeated_failure);
2020            request.llm_request.messages.push(tr("5 passed in 0.12s"));
2021            let recovered = ToolSignals::from_request(&request, Some(1));
2022            assert_eq!(recovered.severity, 0.0);
2023            assert!(!recovered.repeated_failure);
2024            assert_eq!(recovered.no_error_streak, 1);
2025            assert!(recovered.tests_passed);
2026        }
2027    }
2028
2029    #[test]
2030    fn severity_is_max_across_patterns() {
2031        // exit_nonzero (SOFT) + traceback (HARD) → HARD.
2032        let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
2033        assert_eq!(sev, HARD);
2034    }
2035
2036    #[test]
2037    fn codex_process_exit_zero_stays_clean() {
2038        let (sev, patterns) =
2039            classify_text("Chunk ID: abc\nProcess exited with code 0\nOutput:\nok");
2040        assert_eq!(sev, 0.0);
2041        assert!(!patterns.contains(&"exit_nonzero".to_string()));
2042    }
2043
2044    #[test]
2045    fn nonzero_exit_codes_are_soft_errors() {
2046        let cases = [
2047            "Process exited with code 1",
2048            "Process exited with code 127",
2049            "exit code: 2",
2050            "exit status 3",
2051            "exited with status 9",
2052        ];
2053        for case in cases {
2054            let (sev, patterns) = classify_text(case);
2055            assert_eq!(sev, SOFT, "expected soft severity for {case}");
2056            assert!(patterns.contains(&"exit_nonzero".to_string()));
2057        }
2058    }
2059
2060    #[test]
2061    fn partial_process_failures_are_hard_errors() {
2062        let cases = [
2063            (
2064                "Process running with session ID 12\nOutput:\nerror[E0509]: cannot move out",
2065                "compile_error",
2066            ),
2067            (
2068                "Process exited with code 0\nOutput:\nTypeError: value is undefined\n    at main.js:1:2",
2069                "runtime_exception",
2070            ),
2071            (
2072                "Process running with session ID 13\nOutput:\npanic: runtime error: index out of range\n\ngoroutine 6 [running]:",
2073                "runtime_panic",
2074            ),
2075            (
2076                "Process exited with code 0\nOutput:\nerror: patch failed: src/lib.rs:4\nerror: src/lib.rs: patch does not apply",
2077                "patch_error",
2078            ),
2079        ];
2080        for (text, expected_pattern) in cases {
2081            let (severity, patterns) = classify_text(text);
2082            assert_eq!(severity, HARD, "expected hard severity for {text}");
2083            assert!(patterns.iter().any(|pattern| pattern == expected_pattern));
2084        }
2085    }
2086
2087    /// A harness that strips the trailing newline from captured output must not
2088    /// silently drop a shell "not found" error to severity 0.0.
2089    #[test]
2090    fn trimmed_command_not_found_is_still_hard() {
2091        let cases = [
2092            "git: not found",
2093            "sh: 1: git: not found",
2094            "/bin/sh: 1: git: not found",
2095        ];
2096        for text in cases {
2097            let (sev, patterns) = classify_text(text);
2098            assert_eq!(sev, HARD, "expected hard severity for {text}");
2099            assert!(patterns.contains(&"cmd_not_found".to_string()));
2100        }
2101    }
2102
2103    /// Precision guard: "not found" must end the line to count as a shell error,
2104    /// so prose that merely mentions it does not trip a false error.
2105    #[test]
2106    fn not_found_mid_sentence_stays_clean() {
2107        let (sev, _) =
2108            classify_text("Looking for config key, not found in environment, using default.");
2109        assert_eq!(sev, 0.0);
2110    }
2111
2112    /// Precision guard: a line may end in "not found" and still report success.
2113    /// Only the shell's `<name>: not found` shape counts.
2114    #[test]
2115    fn not_found_without_shell_separator_stays_clean() {
2116        for text in [
2117            "HTTP/1.1 404 Not Found",
2118            "lookup finished, key not found",
2119            "GET /healthz -> 404 Not Found",
2120        ] {
2121            let (sev, patterns) = classify_text(text);
2122            assert_eq!(sev, 0.0, "expected clean classification for {text}");
2123            assert!(!patterns.contains(&"cmd_not_found".to_string()));
2124        }
2125    }
2126
2127    #[test]
2128    fn source_text_that_names_exceptions_stays_clean() {
2129        let text =
2130            "pub enum TypeError: this is documentation\nlet sample = 'panic: runtime error:';";
2131        assert_eq!(classify_text(text).0, 0.0);
2132    }
2133
2134    #[test]
2135    fn file_does_not_exist_is_hard() {
2136        // Claude Code Read-tool miss. Trace-mined addition (22 true / 2 false positives).
2137        let (sev, patterns) =
2138            classify_text("Error: File does not exist. Note: current working directory is /app.");
2139        assert_eq!(sev, HARD);
2140        assert!(patterns.contains(&"no_such_file".to_string()));
2141    }
2142
2143    #[test]
2144    fn bare_does_not_exist_stays_clean() {
2145        // Precision guard: only the anchored "file does not exist" fires, so a bare
2146        // "does not exist" in prose or directory output must not trip a false error.
2147        let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
2148        assert_eq!(sev, 0.0);
2149    }
2150
2151    #[test]
2152    fn no_error_streak_all_clean() {
2153        let texts = vec![("ok".to_string(), false), ("all good".to_string(), false)];
2154        assert_eq!(compute_no_error_streak(&texts), 2);
2155    }
2156
2157    #[test]
2158    fn no_error_streak_stops_at_error() {
2159        let texts = vec![
2160            ("Traceback (most recent call last):".to_string(), false),
2161            ("ok".to_string(), false),
2162            ("ok".to_string(), false),
2163        ];
2164        assert_eq!(compute_no_error_streak(&texts), 2);
2165    }
2166
2167    #[test]
2168    fn tests_passed_detects_pytest_output() {
2169        assert!(detect_tests_passed(
2170            &[("====== 5 passed in 0.12s ======".to_string(), false)],
2171            DEFAULT_RECENT_WINDOW
2172        ));
2173    }
2174
2175    #[test]
2176    fn tests_passed_ignores_partial_failures() {
2177        assert!(!detect_tests_passed(
2178            &[("2 failed, 5 passed in 0.56s".to_string(), false)],
2179            DEFAULT_RECENT_WINDOW
2180        ));
2181    }
2182
2183    #[test]
2184    fn tests_passed_must_follow_the_latest_failure() {
2185        assert!(!detect_tests_passed(
2186            &[
2187                ("5 passed in 0.12s".to_string(), false),
2188                (
2189                    "Traceback (most recent call last):\nValueError".to_string(),
2190                    false
2191                ),
2192                ("edit applied".to_string(), false),
2193            ],
2194            DEFAULT_RECENT_WINDOW
2195        ));
2196        assert!(detect_tests_passed(
2197            &[
2198                (
2199                    "Traceback (most recent call last):\nValueError".to_string(),
2200                    false
2201                ),
2202                ("5 passed in 0.12s".to_string(), false),
2203            ],
2204            DEFAULT_RECENT_WINDOW
2205        ));
2206    }
2207
2208    #[test]
2209    fn retrieved_file_contents_are_ignored() {
2210        let call = |id: &str, name: &str, arguments: Value| {
2211            Message::new(
2212                Role::Assistant,
2213                vec![ContentBlock::ToolCall(ToolCall {
2214                    id: id.to_string(),
2215                    name: name.to_string(),
2216                    arguments,
2217                })],
2218            )
2219        };
2220        let result = |id: &str, text: &str| {
2221            Message::new(
2222                Role::User,
2223                vec![ContentBlock::ToolResult(ToolResult {
2224                    tool_call_id: id.to_string(),
2225                    content: vec![ContentBlock::Text {
2226                        text: text.to_string(),
2227                    }],
2228                    is_error: None,
2229                })],
2230            )
2231        };
2232        let signal = extract_tool_signals_with_window(
2233            &with_messages(vec![
2234                call("a", "Bash", json!({"command": "pytest"})),
2235                result("a", "Traceback (most recent call last):\nValueError"),
2236                call("b", "Read", json!({"file_path": "notes.md"})),
2237                result(
2238                    "b",
2239                    r#"{"success":false,"error":"out of memory","exit_code":7}"#,
2240                ),
2241                call("c", "Grep", json!({"pattern": "passed"})),
2242                result("c", "CHANGELOG.md: all tests passed"),
2243            ]),
2244            DEFAULT_RECENT_WINDOW,
2245        );
2246        // Only the real pytest run counts.
2247        assert_eq!(signal.severity, HARD);
2248        assert!(!signal.tests_passed);
2249        assert_eq!(signal.tool_result_count, 3);
2250
2251        for (command, text, severity) in [
2252            ("cat config.json", r#"{"error":"fixture data"}"#, 0.0),
2253            (
2254                "cat missing.json",
2255                r#"{"output":"","exit_code":7,"error":null}"#,
2256                SOFT,
2257            ),
2258            (
2259                "cat config.json && python check.py",
2260                r#"{"error":"check rejected"}"#,
2261                HARD,
2262            ),
2263        ] {
2264            let request = with_messages(vec![
2265                call("shell", "Bash", json!({"command": command})),
2266                result("shell", text),
2267            ]);
2268            assert_eq!(
2269                ToolSignals::from_request(&request, None).severity,
2270                severity,
2271                "{text}"
2272            );
2273        }
2274    }
2275
2276    // A request whose tool result answers the tool call.
2277    fn call_with_result(mut call: Message, mut result: Message) -> Request {
2278        if let ContentBlock::ToolCall(call) = &mut call.content[0] {
2279            call.id = "call-1".into();
2280        }
2281        if let ContentBlock::ToolResult(result) = &mut result.content[0] {
2282            result.tool_call_id = "call-1".into();
2283        }
2284        with_messages(vec![call, result])
2285    }
2286
2287    #[test]
2288    fn shell_reads_are_ignored_but_keep_their_window_slot() {
2289        for command in [
2290            "/usr/bin/cat logfile.txt",
2291            "cd /repo && tail -n 100 logfile.txt | grep MemoryError",
2292            "env MODE=debug timeout 5s rg 'MemoryError' .",
2293        ] {
2294            let mut request = call_with_result(bash(command), tr("MemoryError\nall tests passed"));
2295            request.llm_request.messages.splice(
2296                0..0,
2297                [bash("pytest"), tr("Traceback (most recent call last):")],
2298            );
2299            // The read adds no error and no test pass, so the earlier failure stands.
2300            let signal = ToolSignals::from_request(&request, Some(2));
2301            assert_eq!(signal.severity, HARD, "{command}");
2302            assert!(!signal.tests_passed, "{command}");
2303            // The read still fills a window slot, so the failure can age out.
2304            let signal = ToolSignals::from_request(&request, Some(1));
2305            assert_eq!(signal.severity, 0.0, "{command}");
2306        }
2307    }
2308
2309    #[test]
2310    fn shell_commands_that_run_code_keep_signals() {
2311        let oversized = format!("cat {}", "a".repeat(MAX_RETRIEVAL_COMMAND_BYTES));
2312        for command in [
2313            "cat logfile.txt; pytest",
2314            "echo $(pytest)",
2315            "find . -name '*.py' -exec pytest ';'",
2316            oversized.as_str(),
2317        ] {
2318            let request = call_with_result(bash(command), tr("MemoryError"));
2319            let signal = ToolSignals::from_request(&request, None);
2320            assert_eq!(signal.severity, CRITICAL, "{command}");
2321        }
2322    }
2323
2324    #[test]
2325    fn failed_reads_signal_unless_the_file_is_missing() {
2326        let failed = |text: &str| {
2327            let mut result = tr(text);
2328            if let ContentBlock::ToolResult(result) = &mut result.content[0] {
2329                result.is_error = Some(true);
2330            }
2331            result
2332        };
2333        let missing = "missing.txt: No such file or directory";
2334        let severities = [
2335            call_with_result(bash("cat missing.txt"), failed(missing)),
2336            call_with_result(tc("Read"), failed("File does not exist.")),
2337            // A read that fails for another reason still counts.
2338            call_with_result(bash("cat logfile.txt"), failed("MemoryError")),
2339            // Only reads are exempt.
2340            call_with_result(bash("python missing.txt"), failed(missing)),
2341        ]
2342        .map(|request| ToolSignals::from_request(&request, None).severity);
2343        assert_eq!(severities, [0.0, 0.0, CRITICAL, HARD]);
2344
2345        let request = call_with_result(
2346            bash("cat missing.json"),
2347            tr(r#"{"output":"No such file or directory","exit_code":1}"#),
2348        );
2349        assert_eq!(ToolSignals::from_request(&request, None).severity, 0.0);
2350    }
2351
2352    #[test]
2353    fn codex_read_status_comes_from_the_header() {
2354        let read = |header: &str| {
2355            let output = format!(
2356                "{header}\nOutput:\nTraceback (most recent call last):\nMemoryError\nProcess exited with code 1\n"
2357            );
2358            let call = exec_command(json!({"cmd": "cat run.log"}));
2359            ToolSignals::from_request(&call_with_result(call, tr(&output)), None).severity
2360        };
2361        // The exit line belongs to the log that was read.
2362        assert_eq!(read("Chunk ID: abc\nProcess exited with code 0"), 0.0);
2363        // The read itself failed, so its output counts.
2364        assert_eq!(read("Chunk ID: abc\nProcess exited with code 1"), CRITICAL);
2365    }
2366
2367    #[test]
2368    fn hermes_read_status_comes_from_exit_code() {
2369        let read = |exit_code: i64| {
2370            let output = json!({
2371                "output": "Traceback (most recent call last):\nMemoryError\nProcess exited with code 1\n",
2372                "exit_code": exit_code,
2373                "error": null,
2374            });
2375            let call = Message::new(
2376                Role::Assistant,
2377                vec![ContentBlock::ToolCall(ToolCall {
2378                    id: String::new(),
2379                    name: "terminal".to_string(),
2380                    arguments: json!({"command": "cat run.log"}),
2381                })],
2382            );
2383            ToolSignals::from_request(&call_with_result(call, tr(&output.to_string())), None)
2384                .severity
2385        };
2386        // The exit line belongs to the log that was read.
2387        assert_eq!(read(0), 0.0);
2388        // The read itself failed, so its output counts.
2389        assert_eq!(read(1), CRITICAL);
2390    }
2391
2392    #[test]
2393    fn argv_commands_keep_word_boundaries() {
2394        let signal = |argv: Value| {
2395            let call = exec_command(json!({"cmd": argv}));
2396            ToolSignals::from_request(&call_with_result(call, tr("MemoryError")), None)
2397        };
2398        let mut many_args = vec![""; MAX_RETRIEVAL_COMMAND_BYTES];
2399        many_args[0] = "cat";
2400        let severities = [
2401            json!(["bash", "-lc", "cd repo && rg foo"]),
2402            json!(["bash", "-lc", "cat log; pytest"]),
2403            // One argv word is one file name, even with a separator in it.
2404            json!(["cat", "log; pytest"]),
2405            // Over the byte budget, by size and by argument count.
2406            json!(["cat", "a".repeat(MAX_RETRIEVAL_COMMAND_BYTES)]),
2407            json!(many_args),
2408        ]
2409        .map(|argv| signal(argv).severity);
2410        assert_eq!(severities, [0.0, CRITICAL, 0.0, CRITICAL, CRITICAL]);
2411        // A read is an observation, even as a whole `-lc` script.
2412        let read = signal(json!(["bash", "-lc", "sed -n 1,200p x"]));
2413        assert_eq!(read.read_count, 1);
2414        // Other commands are counted inside the script too.
2415        let write = signal(json!(["bash", "-lc", "cd repo && mkdir x"]));
2416        assert_eq!(write.write_count, 1);
2417    }
2418
2419    #[test]
2420    fn severity_is_windowed_over_recent_results() {
2421        // An error two results back, then two clean results.
2422        let request = with_messages(vec![
2423            tr("Traceback (most recent call last):\n  ValueError"),
2424            tr("ok"),
2425            tr("ok"),
2426        ]);
2427        // window covers the error → severity persists (max over the window)
2428        assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
2429        // window of 1 sees only the last (clean) result → severity has decayed out
2430        assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
2431    }
2432
2433    #[test]
2434    fn extract_openai_chat_tool_results() {
2435        let request = with_messages(vec![
2436            Message::text(Role::User, "do something"),
2437            tc("Edit"),
2438            tr("Traceback (most recent call last):\n  ValueError"),
2439        ]);
2440        let sig = ToolSignals::from_request(&request, None);
2441        assert_eq!(sig.severity, HARD);
2442        assert_eq!(sig.edit_count, 1);
2443        assert_eq!(sig.turn_depth, 3);
2444    }
2445
2446    #[test]
2447    fn extract_anthropic_tool_results() {
2448        let request = with_messages(vec![tr("Traceback (most recent call last):\n  ValueError")]);
2449        let sig = ToolSignals::from_request(&request, None);
2450        assert_eq!(sig.severity, HARD);
2451    }
2452
2453    #[test]
2454    fn extract_responses_api_tool_results() {
2455        let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
2456        let sig = ToolSignals::from_request(&request, None);
2457        assert_eq!(sig.severity, 0.0);
2458        assert_eq!(sig.write_count, 1);
2459    }
2460
2461    #[test]
2462    fn responses_builtin_tool_failures_escalate() {
2463        use crate::algorithms::util::stage::{PickOutcome, PickerMode, Tier, pick_tier};
2464
2465        let mut cases = Vec::new();
2466        for (status, output) in [
2467            (
2468                "failed",
2469                "Synthetic dependency unavailable; retry with the recovery path.",
2470            ),
2471            (
2472                "completed",
2473                "Synthetic dependency unavailable; retry with the recovery path.",
2474            ),
2475            ("failed", ""),
2476        ] {
2477            cases.push((
2478                json!({
2479                    "type": "apply_patch_call_output",
2480                    "status": status,
2481                    "output": output,
2482                }),
2483                if status == "failed" { HARD } else { 0.0 },
2484            ));
2485        }
2486        for (outcome, stdout, stderr, severity) in [
2487            (json!({"type": "exit", "exit_code": 1}), "", "", SOFT),
2488            (json!({"type": "exit", "exit_code": 1}), "", " \n", SOFT),
2489            (
2490                json!({"type": "exit", "exit_code": 1}),
2491                "",
2492                "command failed",
2493                HARD,
2494            ),
2495            (json!({"type": "timeout"}), "", "", HARD),
2496            (json!({"type": "exit", "exit_code": 0}), "done", "", 0.0),
2497            (
2498                json!({"type": "exit", "exit_code": 0}),
2499                "Traceback (most recent call last):",
2500                "",
2501                HARD,
2502            ),
2503            (
2504                json!({"type": "exit", "exit_code": 0}),
2505                "",
2506                "Traceback (most recent call last):",
2507                HARD,
2508            ),
2509        ] {
2510            cases.push((
2511                json!({
2512                    "type": "shell_call_output",
2513                    "output": [
2514                        {"stdout": stdout, "stderr": stderr, "outcome": outcome},
2515                        {"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}
2516                    ],
2517                }),
2518                severity,
2519            ));
2520        }
2521        for (raw, severity) in cases {
2522            let is_error = severity >= HARD;
2523            let mut request = with_messages(
2524                ["call_1", "call_2"]
2525                    .into_iter()
2526                    .map(|call_id| {
2527                        let mut raw = raw.clone();
2528                        raw["call_id"] = json!(call_id);
2529                        Message::new(
2530                            Role::User,
2531                            vec![ContentBlock::Unknown {
2532                                provider: WireFormat::OpenAiResponses.into(),
2533                                raw,
2534                            }],
2535                        )
2536                    })
2537                    .collect(),
2538            );
2539            let signal = ToolSignals::from_request(&request, Some(3));
2540            assert_eq!(signal.severity, severity, "{raw}");
2541            assert_eq!(signal.repeated_failure, is_error, "{raw}");
2542            assert_eq!(signal.tool_result_count, 2);
2543            assert_eq!(
2544                matches!(
2545                    pick_tier(&signal, PickerMode::EfficientFirst, 0.5),
2546                    PickOutcome::Resolved {
2547                        tier: Tier::Capable,
2548                        ..
2549                    }
2550                ),
2551                is_error,
2552                "{raw}"
2553            );
2554
2555            let mut success = match raw["type"].as_str() {
2556                Some("apply_patch_call_output") => json!({
2557                    "type": "apply_patch_call_output", "status": "completed", "output": ""
2558                }),
2559                Some("shell_call_output") => json!({
2560                    "type": "shell_call_output",
2561                    "output": [{"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}]
2562                }),
2563                _ => unreachable!(),
2564            };
2565            for index in 0..3 {
2566                success["call_id"] = json!(format!("success_{index}"));
2567                request.llm_request.messages.push(Message::new(
2568                    Role::User,
2569                    vec![ContentBlock::Unknown {
2570                        provider: WireFormat::OpenAiResponses.into(),
2571                        raw: success.clone(),
2572                    }],
2573                ));
2574            }
2575            let recovered = ToolSignals::from_request(&request, Some(3));
2576            assert_eq!(recovered.severity, 0.0, "{raw}");
2577            assert!(!recovered.repeated_failure, "{raw}");
2578            assert!(recovered.no_error_streak >= 3, "{raw}");
2579            assert_eq!(recovered.tool_result_count, 5);
2580        }
2581    }
2582
2583    #[test]
2584    fn conversation_counts_are_per_block_and_role_aware() {
2585        // A batched user message (Anthropic shape) contributes one count per
2586        // ToolResult block, empty-content results included. assistant_turn_count
2587        // tracks Role::Assistant only, while turn_depth counts every message.
2588        let result = |content: Vec<ContentBlock>| {
2589            ContentBlock::ToolResult(ToolResult {
2590                tool_call_id: String::new(),
2591                content,
2592                is_error: None,
2593            })
2594        };
2595        let request = with_messages(vec![
2596            Message::text(Role::User, "do something"),
2597            Message::text(Role::Assistant, "working"),
2598            Message::new(
2599                Role::User,
2600                vec![
2601                    result(vec![ContentBlock::Text {
2602                        text: "ok".to_string(),
2603                    }]),
2604                    result(Vec::new()),
2605                ],
2606            ),
2607            tc("Bash"),
2608        ]);
2609        let sig = ToolSignals::from_request(&request, None);
2610        assert_eq!(sig.tool_result_count, 2);
2611        assert_eq!(sig.assistant_turn_count, 2);
2612        assert_eq!(sig.turn_depth, 4);
2613    }
2614
2615    #[test]
2616    fn recent_window_counts_only_last_default_window_tool_calls() {
2617        // 5 writes + 1 edit at the end → the default window (3) should see
2618        // the last 3 calls: 1 edit + 2 writes (not all 6 calls).
2619        let request = with_messages(vec![
2620            tc("Write"),
2621            tr("ok"),
2622            tc("Write"),
2623            tr("ok"),
2624            tc("Write"),
2625            tr("ok"),
2626            tc("Write"),
2627            tr("ok"),
2628            tc("Write"),
2629            tr("ok"),
2630            tc("Edit"),
2631            tr("ok"),
2632        ]);
2633        let sig = ToolSignals::from_request(&request, None);
2634        assert_eq!(sig.write_count, 5);
2635        assert_eq!(sig.edit_count, 1);
2636        assert_eq!(sig.recent_write_count, 2);
2637        assert_eq!(sig.recent_edit_count, 1);
2638    }
2639
2640    #[test]
2641    fn codex_apply_patch_counts_as_an_edit() {
2642        let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
2643        let sig = ToolSignals::from_request(&request, None);
2644        assert_eq!(sig.edit_count, 1);
2645        assert_eq!(sig.recent_edit_count, 1);
2646    }
2647
2648    fn exec_command(cmd: Value) -> Message {
2649        Message::new(
2650            Role::Assistant,
2651            vec![ContentBlock::ToolCall(ToolCall {
2652                id: String::new(),
2653                name: "exec_command".to_string(),
2654                arguments: cmd,
2655            })],
2656        )
2657    }
2658
2659    #[test]
2660    fn codex_exec_command_is_classified() {
2661        // arguments arrive as a JSON string, with the command under `cmd`
2662        let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
2663        let request = with_messages(vec![exec_command(args), tr("ok")]);
2664        assert_eq!(
2665            ToolSignals::from_request(&request, None).recent_edit_count,
2666            1
2667        );
2668    }
2669
2670    #[test]
2671    fn shell_patch_mentions_do_not_count_as_edits() {
2672        for arguments in [
2673            json!({"cmd": "printf 'worker dispatch locations\\n'; rg worker src 2>/dev/null"}),
2674            json!(r#"{"cmd":"find /usr /opt -name apply_patch -o -name patch 2>/dev/null"}"#),
2675            json!({"command": ["bash", "-lc", "find . -name apply_patch -o -name patch 2>/dev/null"]}),
2676            json!({"input": "printf 'patch utility\\n'; command -v apply_patch 2>/dev/null"}),
2677            json!({"cmd": "rg 'patch -p1|apply_patch fix.diff' src 2>/dev/null"}),
2678            json!({"cmd": "bash -lc \"printf 'patch -p1' 2>/dev/null\""}),
2679            json!({"cmd": ["inspect", "patch -p1; apply_patch fix.diff"]}),
2680            json!({"cmd": "printf 'patch -p1"}),
2681            json!({"cmd": "cat <<'TEXT'\npatch -p1\nTEXT"}),
2682            json!({"cmd": "printf x # ; patch -p1"}),
2683            json!({"cmd": ["bash", "script.sh", "-c", "patch -p1"]}),
2684            json!({"cmd": "env --help patch"}),
2685            json!({"cmd": "bash -nc 'patch -p1'"}),
2686            json!({"cmd": "bash -o noexec -c 'patch -p1'"}),
2687            json!({"cmd": "bash --norc -n -c 'patch -p1'"}),
2688            json!({"cmd": "bash --help -c 'patch -p1'"}),
2689            json!({"cmd": "bash --version -c 'patch -p1'"}),
2690            json!({"cmd": "bash --dump-strings -c 'patch -p1'"}),
2691            json!({"cmd": "bash --dump-po-strings -c 'patch -p1'"}),
2692            json!({"cmd": "zsh --noexec -c 'patch -p1'"}),
2693            json!({"cmd": "ksh --no-exec -c 'patch -p1'"}),
2694            json!({"cmd": "zsh --NO_EXEC -c 'patch -p1'"}),
2695            json!({"command": ["bash", "--rcfile", "-c", "script.sh", "patch -p1"]}),
2696            json!({"cmd": "bash -- -c 'patch -p1'"}),
2697            json!({"cmd": "bash -o pipefail -c \"printf 'patch -p1' 2>/dev/null\""}),
2698        ] {
2699            let request = call_with_result(exec_command(arguments.clone()), tr("MemoryError"));
2700            let signal = ToolSignals::from_request(&request, None);
2701            assert_eq!(signal.recent_edit_count, 0, "{arguments}");
2702            assert_eq!(signal.recent_write_count, 0, "{arguments}");
2703            assert_eq!(signal.severity, CRITICAL, "{arguments}");
2704        }
2705    }
2706
2707    #[test]
2708    fn shell_patch_invocations_count_as_edits() {
2709        for arguments in [
2710            json!({"cmd": "patch -p1 < fix.diff"}),
2711            json!({"cmd": "apply_patch <<'PATCH'\n*** Begin Patch\n*** End Patch\nPATCH"}),
2712            json!({"cmd": "env -u MODE MODE=debug /usr/bin/patch -p1 < fix.diff"}),
2713            json!({"cmd": "cat fix.diff | command patch -p1"}),
2714            json!({"cmd": "command patch -v -p1 < fix.diff"}),
2715            json!({"cmd": "sudo timeout 5 /usr/local/bin/apply_patch fix.diff"}),
2716            json!({"cmd": "bash -lc 'patch -p1 < fix.diff'"}),
2717            json!({"cmd": "bash -l -c 'patch -p1 < fix.diff'"}),
2718            json!({"cmd": "bash -o pipefail -c 'patch -p1 < fix.diff'"}),
2719            json!({"cmd": "bash -oc pipefail 'patch -p1 < fix.diff'"}),
2720            json!({"cmd": "bash -O extglob -c 'patch -p1 < fix.diff'"}),
2721            json!({"cmd": "bash --login -c 'patch -p1 < fix.diff'"}),
2722            json!({"cmd": "bash --noprofile --norc -c 'apply_patch fix.diff'"}),
2723            json!({"command": ["bash", "--rcfile", "/dev/null", "-c", "apply_patch fix.diff"]}),
2724            json!({"cmd": ["bash", "--init-file", "/dev/null", "-c", "apply_patch fix.diff"]}),
2725            json!(r#"{"cmd":["bash","-o","pipefail","-c","apply_patch fix.diff"]}"#),
2726            json!({"cmd": "printf x # note\npatch -p1 < fix.diff"}),
2727            json!({"cmd": "printf '%s' name#suffix; patch -p1 < fix.diff"}),
2728            json!({"command": ["bash", "-lc", "apply_patch fix.diff"]}),
2729            json!(r#"{"cmd":["env","MODE=debug","patch","-p1"]}"#),
2730        ] {
2731            let request = with_messages(vec![exec_command(arguments.clone()), tr("ok")]);
2732            let signal = ToolSignals::from_request(&request, None);
2733            assert_eq!(signal.recent_edit_count, 1, "{arguments}");
2734            assert_eq!(signal.recent_write_count, 0, "{arguments}");
2735        }
2736    }
2737
2738    #[test]
2739    fn python_write_expressions_need_a_python_command() {
2740        let write = with_messages(vec![
2741            exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
2742            tr("ok"),
2743        ]);
2744        assert_eq!(
2745            ToolSignals::from_request(&write, None).recent_write_count,
2746            1
2747        );
2748
2749        let search = with_messages(vec![
2750            exec_command(json!({"cmd": "grep -R '.write(' src"})),
2751            tr("ok"),
2752        ]);
2753        assert_eq!(
2754            ToolSignals::from_request(&search, None).recent_write_count,
2755            0
2756        );
2757    }
2758
2759    #[test]
2760    fn recent_window_size_is_caller_overridable() {
2761        // Same six tool calls (1 edit at the end, 5 writes before).
2762        // With recent_window=3 → recent_writes=2, recent_edits=1.
2763        // With recent_window=6 → recent_writes=5, recent_edits=1 (all calls).
2764        let request = with_messages(vec![
2765            tc("Write"),
2766            tr("ok"),
2767            tc("Write"),
2768            tr("ok"),
2769            tc("Write"),
2770            tr("ok"),
2771            tc("Write"),
2772            tr("ok"),
2773            tc("Write"),
2774            tr("ok"),
2775            tc("Edit"),
2776            tr("ok"),
2777        ]);
2778        let narrow = extract_tool_signals_with_window(&request, 3);
2779        assert_eq!(narrow.recent_write_count, 2);
2780        assert_eq!(narrow.recent_edit_count, 1);
2781
2782        let wide = extract_tool_signals_with_window(&request, 6);
2783        assert_eq!(wide.recent_write_count, 5);
2784        assert_eq!(wide.recent_edit_count, 1);
2785    }
2786
2787    #[test]
2788    fn compaction_marker_sets_compacted() {
2789        for summary in [
2790            "This session is being continued from a previous conversation that ran out of context.",
2791            "Another language model started to solve this problem and produced a summary of its thinking process.",
2792        ] {
2793            let request = with_messages(vec![
2794                Message::text(Role::User, summary),
2795                Message::text(Role::User, "Continue with the next task"),
2796                bash("ls"),
2797            ]);
2798            assert!(ToolSignals::from_request(&request, None).compacted);
2799            assert!(
2800                ToolSignalProcessor::default()
2801                    .for_user_turn(&request)
2802                    .compacted
2803            );
2804        }
2805    }
2806
2807    #[test]
2808    fn hermes_compaction_headers_set_compacted() {
2809        for header in [
2810            "[CONTEXT COMPACTION \u{2014} REFERENCE ONLY] Earlier turns were compacted into the summary below.",
2811            "[STILL IN PROGRESS \u{2014} this is the active request, restated after the compaction boundary because it was not finished yet. Continue it\u{3b} do not start over.]",
2812        ] {
2813            for role in [Role::User, Role::Assistant] {
2814                let request = with_messages(vec![
2815                    Message::text(role, format!("{header}\nContinue the task.")),
2816                    bash("ls"),
2817                ]);
2818                assert!(ToolSignals::from_request(&request, None).compacted);
2819            }
2820            let request = with_messages(vec![Message::text(
2821                Role::User,
2822                format!("Prior context.\n\n  {header}\nContinue the task."),
2823            )]);
2824            assert!(ToolSignals::from_request(&request, None).compacted);
2825            let request = with_messages(vec![tr(header)]);
2826            assert!(!ToolSignals::from_request(&request, None).compacted);
2827        }
2828    }
2829
2830    #[test]
2831    fn ordinary_compaction_text_stays_uncompacted() {
2832        for text in [
2833            "The task is still in progress after the compaction boundary.",
2834            "Explain [CONTEXT COMPACTION \u{2014} REFERENCE ONLY] in the docs.",
2835            "[STILL IN PROGRESS] Continue the task.",
2836        ] {
2837            let request = with_messages(vec![Message::text(Role::User, text)]);
2838            assert!(!ToolSignals::from_request(&request, None).compacted);
2839        }
2840    }
2841
2842    #[test]
2843    fn codex_compaction_metadata_stays_on_parent_route() {
2844        let mut request = with_messages(vec![bash("ls")]);
2845        request.metadata = Some(Metadata {
2846            is_subagent: true,
2847            agent_kind: Some("compact".to_string()),
2848            ..Default::default()
2849        });
2850        assert!(!ToolSignals::from_request(&request, None).compacted);
2851    }
2852
2853    #[test]
2854    fn no_compaction_marker_stays_uncompacted() {
2855        let request = with_messages(vec![
2856            Message::text(Role::User, "Write a script that parses the log file."),
2857            bash("ls"),
2858        ]);
2859        assert!(!ToolSignals::from_request(&request, None).compacted);
2860    }
2861
2862    #[test]
2863    fn bash_heredoc_counts_as_write() {
2864        // Claude Code's pattern on TB 2.0 — write a scratch file via heredoc.
2865        let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
2866        let sig = ToolSignals::from_request(&request, None);
2867        assert_eq!(
2868            sig.write_count, 1,
2869            "Bash heredoc should bucket into write_count"
2870        );
2871        assert_eq!(sig.edit_count, 0);
2872    }
2873
2874    #[test]
2875    fn bash_sed_inplace_counts_as_edit() {
2876        let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
2877        let sig = ToolSignals::from_request(&request, None);
2878        assert_eq!(
2879            sig.edit_count, 1,
2880            "Bash sed -i should bucket into edit_count"
2881        );
2882        assert_eq!(sig.write_count, 0);
2883    }
2884
2885    #[test]
2886    fn bash_non_mutating_does_not_count() {
2887        // ls, cat, grep — should not increment either counter.
2888        let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
2889        let sig = ToolSignals::from_request(&request, None);
2890        assert_eq!(sig.write_count, 0);
2891        assert_eq!(sig.edit_count, 0);
2892    }
2893
2894    #[test]
2895    fn tests_passed_detects_pytest_with_failure_block() {
2896        // Mixed pytest run: 2 failed + 5 passed → NOT considered tests_passed.
2897        assert!(!detect_tests_passed(
2898            &[("2 failed, 5 passed in 0.56s".to_string(), false)],
2899            DEFAULT_RECENT_WINDOW
2900        ));
2901    }
2902
2903    #[test]
2904    fn tests_passed_accepts_cargo_clean_summary() {
2905        // Cargo's clean-run summary contains "0 failed" — must not trip the
2906        // failure list (regression: previously substring-matched "failed").
2907        assert!(detect_tests_passed(
2908            &[(
2909                "running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string(),
2910                false
2911            )],
2912            DEFAULT_RECENT_WINDOW
2913        ));
2914    }
2915
2916    #[test]
2917    fn tests_passed_rejects_cargo_real_failure() {
2918        // Cargo's actual-failure summary: nonzero count before "failed".
2919        assert!(!detect_tests_passed(
2920            &[(
2921                "running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string(),
2922                false
2923            )],
2924            DEFAULT_RECENT_WINDOW
2925        ));
2926    }
2927
2928    #[test]
2929    fn tests_passed_accepts_go_clean_summary() {
2930        // Go test's clean-run "0 errors" must not trip (regression).
2931        assert!(detect_tests_passed(
2932            &[(
2933                "ok  github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string(),
2934                false
2935            )],
2936            DEFAULT_RECENT_WINDOW
2937        ));
2938    }
2939
2940    #[test]
2941    fn tests_passed_accepts_pytest_zero_errors() {
2942        // Pytest long-form: "0 errors in 0.3s" on a clean run.
2943        assert!(detect_tests_passed(
2944            &[("5 passed, 0 errors in 0.30s".to_string(), false)],
2945            DEFAULT_RECENT_WINDOW
2946        ));
2947    }
2948
2949    #[test]
2950    fn tests_passed_detects_diy_checkmark() {
2951        assert!(detect_tests_passed(
2952            &[("✓ all checks passed".to_string(), false)],
2953            DEFAULT_RECENT_WINDOW
2954        ));
2955    }
2956
2957    #[test]
2958    fn anthropic_bash_heredoc_extracts_command() {
2959        // Anthropic format: tool_use.input is an object, not a JSON string.
2960        let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
2961        let sig = ToolSignals::from_request(&request, None);
2962        assert_eq!(
2963            sig.write_count, 1,
2964            "Anthropic Bash heredoc must also be detected"
2965        );
2966    }
2967
2968    #[test]
2969    fn recent_window_falls_back_to_full_history_when_short() {
2970        let request = with_messages(vec![tc("Write")]);
2971        let sig = ToolSignals::from_request(&request, None);
2972        assert_eq!(sig.recent_write_count, 1);
2973        assert_eq!(sig.recent_edit_count, 0);
2974    }
2975
2976    #[test]
2977    fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
2978        let request = with_messages(vec![tr("output ok"), tr("another ok")]);
2979        let sig = ToolSignals::from_request(&request, None);
2980        assert_eq!(sig.severity, 0.0);
2981        assert_eq!(sig.no_error_streak, 2);
2982    }
2983
2984    // ─── asymmetric-signal extensions ────────────────────────────────────
2985
2986    #[test]
2987    fn todowrite_classifies_as_plan() {
2988        assert_eq!(classify_tool_call("TodoWrite", None), ToolSemantic::Plan);
2989        assert_eq!(classify_tool_call("todo_write", None), ToolSemantic::Plan);
2990    }
2991
2992    #[test]
2993    fn codex_update_plan_classifies_as_plan() {
2994        assert_eq!(classify_tool_call("update_plan", None), ToolSemantic::Plan);
2995    }
2996
2997    #[test]
2998    fn codex_shell_command_runs_bash_pattern_match() {
2999        // shell_command + heredoc -> Write.
3000        assert_eq!(
3001            classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
3002            ToolSemantic::Mutate(MutationKind::Write),
3003        );
3004        // shell_command + read-like inspection -> Read.
3005        assert_eq!(
3006            classify_tool_call("shell_command", Some("ls /app")),
3007            ToolSemantic::Observe,
3008        );
3009        // shell_command without matching patterns -> Unknown.
3010        assert_eq!(
3011            classify_tool_call("shell_command", Some("./run_tests.sh")),
3012            ToolSemantic::Unknown,
3013        );
3014    }
3015
3016    #[test]
3017    fn text_editor_view_is_a_read() {
3018        for name in ["str_replace_based_edit_tool", "text_editor"] {
3019            assert_eq!(
3020                classify_tool_call(name, Some("view")),
3021                ToolSemantic::Observe
3022            );
3023            for command in [
3024                Some("create"),
3025                Some("insert"),
3026                Some("str_replace"),
3027                Some("undo_edit"),
3028                None,
3029            ] {
3030                assert_eq!(
3031                    classify_tool_call(name, command),
3032                    ToolSemantic::Mutate(MutationKind::Edit),
3033                );
3034            }
3035        }
3036
3037        let arguments = [
3038            json!({"command": "view", "path": "/app/main.py"}),
3039            // the Responses wire format sends arguments as a JSON string
3040            json!(r#"{"command":"view","path":"/app/main.py"}"#),
3041        ];
3042        for arguments in arguments {
3043            let call = Message::new(
3044                Role::Assistant,
3045                vec![ContentBlock::ToolCall(ToolCall {
3046                    id: String::new(),
3047                    name: "str_replace_based_edit_tool".to_string(),
3048                    arguments,
3049                })],
3050            );
3051            let request = with_messages(vec![call, tr("print('hi')")]);
3052            let sig = ToolSignals::from_request(&request, None);
3053            assert_eq!(sig.read_count, 1);
3054            assert_eq!(sig.recent_read_count, 1);
3055            assert_eq!(sig.edit_count, 0);
3056        }
3057    }
3058
3059    #[test]
3060    fn read_tool_classifies_as_read() {
3061        assert_eq!(classify_tool_call("Read", None), ToolSemantic::Observe);
3062        assert_eq!(classify_tool_call("View", None), ToolSemantic::Observe);
3063    }
3064
3065    #[test]
3066    fn hermes_tool_names_classify() {
3067        // Hermes (NousResearch) file tools route by name.
3068        assert_eq!(
3069            classify_tool_call("write_file", None),
3070            ToolSemantic::Mutate(MutationKind::Write)
3071        );
3072        assert_eq!(
3073            classify_tool_call("patch", None),
3074            ToolSemantic::Mutate(MutationKind::Edit)
3075        );
3076        assert_eq!(classify_tool_call("read_file", None), ToolSemantic::Observe);
3077        assert_eq!(
3078            classify_tool_call("search_files", None),
3079            ToolSemantic::Observe
3080        );
3081        // Hermes runs shell through `terminal`, which carries a `command` arg,
3082        // so its intent comes from the Bash-pattern match like codex's shell_command.
3083        assert_eq!(
3084            classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
3085            ToolSemantic::Mutate(MutationKind::Edit),
3086        );
3087        assert_eq!(
3088            classify_tool_call("terminal", Some("grep foo /app")),
3089            ToolSemantic::Observe,
3090        );
3091        assert_eq!(
3092            classify_tool_call("terminal", Some("./run_tests.sh")),
3093            ToolSemantic::Unknown,
3094        );
3095    }
3096
3097    #[test]
3098    fn bash_read_patterns_classify_as_read() {
3099        let cases = [
3100            "cat /etc/passwd",
3101            "grep foo bar.txt",
3102            "ls /app",
3103            "find . -name '*.py'",
3104        ];
3105        for cmd in cases {
3106            assert_eq!(
3107                classify_tool_call("Bash", Some(cmd)),
3108                ToolSemantic::Observe,
3109                "expected Read for {cmd}"
3110            );
3111        }
3112    }
3113
3114    #[test]
3115    fn codex_inspection_commands_classify_as_read() {
3116        let cases = [
3117            "sed -n '1,80p' src/lib.rs",
3118            "rg -n 'needle' src",
3119            "nl -ba src/lib.rs",
3120            "cat package.json",
3121            "jq '.scripts' package.json",
3122            "git status --short",
3123            "git log --oneline -5",
3124            "git show HEAD:src/lib.rs",
3125            "git branch --show-current",
3126            "git remote -v",
3127            "git config --get remote.origin.url",
3128        ];
3129        for command in cases {
3130            assert_eq!(
3131                classify_tool_call("exec_command", Some(command)),
3132                ToolSemantic::Observe,
3133                "expected Read for {command}"
3134            );
3135        }
3136    }
3137
3138    #[test]
3139    fn quoted_shell_separators_do_not_create_commands() {
3140        for command in ["rg 'foo|rm obsolete.rs'", "rg \"foo; rm obsolete.rs\""] {
3141            assert_eq!(
3142                classify_tool_call("exec_command", Some(command)),
3143                ToolSemantic::Observe,
3144                "quoted text must not be parsed as a command: {command}"
3145            );
3146        }
3147    }
3148
3149    #[test]
3150    fn codex_shell_mutations_classify_as_production() {
3151        let writes = [
3152            "cp source.rs destination.rs",
3153            "mkdir -p src/generated",
3154            "touch src/generated/mod.rs",
3155            "git show HEAD:file.rs > file.rs",
3156            "node <<'node'\nfs.writefilesync('file.js', text)\nnode",
3157        ];
3158        for command in writes {
3159            assert_eq!(
3160                classify_tool_call("exec_command", Some(command)),
3161                ToolSemantic::Mutate(MutationKind::Write),
3162                "expected Write for {command}"
3163            );
3164        }
3165
3166        let edits = [
3167            "mv old.rs new.rs",
3168            "rm obsolete.rs",
3169            "gofmt -w main.go",
3170            "cargo fmt",
3171            "ruff check --fix src",
3172            "perl -0pi -e 's/old/new/' src/lib.rs",
3173            "npx prettier --write src/lib.ts",
3174            "uv run ruff format src",
3175            "git apply fix.patch",
3176        ];
3177        for command in edits {
3178            assert_eq!(
3179                classify_tool_call("exec_command", Some(command)),
3180                ToolSemantic::Mutate(MutationKind::Edit),
3181                "expected Edit for {command}"
3182            );
3183        }
3184    }
3185
3186    #[test]
3187    fn formatter_checks_are_not_edits() {
3188        for command in [
3189            "cargo fmt --check",
3190            "ruff format --check src",
3191            "black --check src",
3192        ] {
3193            assert_ne!(
3194                classify_tool_call("exec_command", Some(command)),
3195                ToolSemantic::Mutate(MutationKind::Edit),
3196                "read-only formatter check must not be Edit: {command}"
3197            );
3198        }
3199    }
3200
3201    #[test]
3202    fn embedded_comparison_is_not_a_shell_write() {
3203        let command = "node <<'node'\nif (index > 0) console.log(index)\nnode";
3204        assert_eq!(
3205            classify_tool_call("exec_command", Some(command)),
3206            ToolSemantic::Unknown
3207        );
3208    }
3209
3210    #[test]
3211    fn bash_write_precedence_over_read() {
3212        // `cat /file > out` contains both `cat /` (read) and ` > ` (write);
3213        // write redirection must win.
3214        assert_eq!(
3215            classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
3216            ToolSemantic::Mutate(MutationKind::Write),
3217        );
3218    }
3219
3220    #[test]
3221    fn pure_bash_streak_counts_trailing_other() {
3222        // 5 trailing non-classified Bash calls → streak == 5.
3223        let request = with_messages(vec![
3224            bash("make"),
3225            tr("ok"),
3226            bash("./configure"),
3227            tr("ok"),
3228            bash("make install"),
3229            tr("ok"),
3230            bash("./run.sh"),
3231            tr("ok"),
3232            bash("./test"),
3233            tr("ok"),
3234        ]);
3235        let sig = ToolSignals::from_request(&request, None);
3236        assert_eq!(sig.pure_bash_streak, 5);
3237        assert_eq!(sig.write_count, 0);
3238        assert_eq!(sig.read_count, 0);
3239    }
3240
3241    #[test]
3242    fn pure_bash_streak_resets_on_write() {
3243        let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
3244        let sig = ToolSignals::from_request(&request, None);
3245        assert_eq!(sig.pure_bash_streak, 0);
3246        assert_eq!(sig.write_count, 1);
3247    }
3248
3249    #[test]
3250    fn recent_window_tracks_todowrite_and_read() {
3251        // Final 3 tool calls: TodoWrite, Read, TodoWrite.
3252        let request = with_messages(vec![
3253            bash("make"),
3254            tr("ok"),
3255            tc("TodoWrite"),
3256            tr("ok"),
3257            tc("Read"),
3258            tr("ok"),
3259            tc("TodoWrite"),
3260            tr("ok"),
3261        ]);
3262        let sig = ToolSignals::from_request(&request, None);
3263        assert_eq!(sig.todowrite_count, 2);
3264        assert_eq!(sig.recent_todowrite_count, 2);
3265        assert_eq!(sig.read_count, 1);
3266        assert_eq!(sig.recent_read_count, 1);
3267    }
3268
3269    #[test]
3270    fn configured_tool_semantics_extend_the_builtin_vocabulary() {
3271        let semantics = ToolSemantics {
3272            observe: vec!["KB_search".to_string()],
3273            mutate: vec!["send_payment_request".to_string()],
3274            plan: vec!["create_research_plan".to_string()],
3275            new: vec!["send_message_to_user".to_string()],
3276        };
3277        semantics.validate().expect("valid additive semantics");
3278        let request = with_messages(vec![
3279            tc("Read"),
3280            tc("Write"),
3281            tc("TodoWrite"),
3282            tc("kb_SEARCH"),
3283            tc("send_payment_request"),
3284            tc("create_research_plan"),
3285            tc("send_message_to_user"),
3286            tc("unlisted_tool"),
3287        ]);
3288
3289        let signal = ToolSignals::from_request_with_semantics(&request, None, &semantics);
3290
3291        assert_eq!(signal.read_count, 2);
3292        assert_eq!(signal.write_count, 2);
3293        assert_eq!(signal.todowrite_count, 2);
3294        assert_eq!(signal.new_count, 1);
3295        assert_eq!(signal.recent_new_count, 1);
3296        assert_eq!(signal.pure_bash_streak, 1);
3297    }
3298
3299    #[test]
3300    fn configured_tool_semantics_match_namespaced_and_mcp_tools() {
3301        // The Responses decoder flattens namespaced tools and records the mapping.
3302        let mut request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
3303        request.llm_request.extensions.fields.insert(
3304            TOOL_NAMESPACES_KEY.to_string(),
3305            json!({"mcp__billing__send_payment_request": "mcp__billing"}),
3306        );
3307
3308        // Claude Code sends MCP tools flat, with no namespace mapping.
3309        let claude_request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
3310
3311        for request in [&request, &claude_request] {
3312            for name in ["send_payment_request", "mcp__billing__send_payment_request"] {
3313                let semantics = ToolSemantics {
3314                    mutate: vec![name.to_string()],
3315                    ..Default::default()
3316                };
3317                let signal = ToolSignals::from_request_with_semantics(request, None, &semantics);
3318                assert_eq!(signal.write_count, 1, "{name}");
3319            }
3320        }
3321    }
3322
3323    #[test]
3324    fn configured_tool_semantics_only_fold_ascii_case() {
3325        let semantics = ToolSemantics {
3326            observe: vec!["kb_search".to_string()],
3327            ..Default::default()
3328        };
3329
3330        assert_eq!(
3331            classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
3332            ToolSemantic::Observe
3333        );
3334        // U+212A lowercases to ASCII `k` under Unicode rules, but custom names
3335        // intentionally ignore only ASCII case.
3336        assert_eq!(
3337            classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
3338            ToolSemantic::Unknown
3339        );
3340    }
3341
3342    #[test]
3343    fn custom_semantics_preserve_builtin_unicode_lowercasing() {
3344        let semantics = ToolSemantics {
3345            observe: vec!["lookup_customer".to_string()],
3346            ..Default::default()
3347        };
3348
3349        // This matched the built-in `notebookedit` before custom semantics existed.
3350        assert_eq!(
3351            classify_tool_call_with_semantics("notebooKedit", None, &semantics),
3352            ToolSemantic::Mutate(MutationKind::Edit)
3353        );
3354    }
3355
3356    #[test]
3357    fn configured_semantics_never_replace_builtin_classifications() {
3358        let semantics = ToolSemantics {
3359            observe: vec!["lookup_customer".to_string()],
3360            mutate: vec!["send_payment".to_string()],
3361            plan: vec!["create_workflow".to_string()],
3362            new: vec!["send_message".to_string()],
3363        };
3364
3365        for name in WRITE_TOOL_NAMES {
3366            assert_eq!(
3367                classify_tool_call_with_semantics(name, None, &semantics),
3368                ToolSemantic::Mutate(MutationKind::Write),
3369                "write tool {name:?} changed classification"
3370            );
3371        }
3372        for name in EDIT_TOOL_NAMES {
3373            assert_eq!(
3374                classify_tool_call_with_semantics(name, None, &semantics),
3375                ToolSemantic::Mutate(MutationKind::Edit),
3376                "edit tool {name:?} changed classification"
3377            );
3378        }
3379        for name in READ_TOOL_NAMES {
3380            assert_eq!(
3381                classify_tool_call_with_semantics(name, None, &semantics),
3382                ToolSemantic::Observe,
3383                "read tool {name:?} changed classification"
3384            );
3385        }
3386        for name in PLAN_TOOL_NAMES {
3387            assert_eq!(
3388                classify_tool_call_with_semantics(name, None, &semantics),
3389                ToolSemantic::Plan,
3390                "plan tool {name:?} changed classification"
3391            );
3392        }
3393
3394        for (command, expected) in [
3395            ("cat /tmp/input", ToolSemantic::Observe),
3396            (
3397                "cat /tmp/input > /tmp/output",
3398                ToolSemantic::Mutate(MutationKind::Write),
3399            ),
3400            (
3401                "sed -i 's/a/b/' /tmp/file",
3402                ToolSemantic::Mutate(MutationKind::Edit),
3403            ),
3404            ("./run_tests.sh", ToolSemantic::Unknown),
3405        ] {
3406            assert_eq!(
3407                classify_tool_call_with_semantics("BASH", Some(&json!(command)), &semantics),
3408                expected,
3409                "bash command {command:?} changed classification"
3410            );
3411        }
3412    }
3413
3414    #[test]
3415    fn configured_semantics_score_like_their_builtin_equivalents() {
3416        let semantics = ToolSemantics {
3417            observe: vec!["lookup_customer".to_string()],
3418            mutate: vec!["send_payment".to_string()],
3419            plan: vec!["create_workflow".to_string()],
3420            ..Default::default()
3421        };
3422
3423        for (builtin, configured) in [
3424            ("Read", "lookup_customer"),
3425            ("Write", "send_payment"),
3426            ("TodoWrite", "create_workflow"),
3427        ] {
3428            let messages_before_tool = || {
3429                vec![
3430                    Message::text(Role::User, "start"),
3431                    Message::text(Role::Assistant, "working"),
3432                    Message::text(Role::User, "continue"),
3433                    Message::text(Role::Assistant, "working"),
3434                    Message::text(Role::User, "continue"),
3435                    Message::text(Role::Assistant, "working"),
3436                    Message::text(Role::User, "continue"),
3437                ]
3438            };
3439            let mut builtin_messages = messages_before_tool();
3440            builtin_messages.push(tc(builtin));
3441            let mut configured_messages = messages_before_tool();
3442            configured_messages.push(tc(configured));
3443
3444            let builtin_score = score_signal(&ToolSignals::from_request(
3445                &with_messages(builtin_messages),
3446                None,
3447            ));
3448            let configured_score = score_signal(&ToolSignals::from_request_with_semantics(
3449                &with_messages(configured_messages),
3450                None,
3451                &semantics,
3452            ));
3453
3454            assert_ne!(
3455                builtin_score.score, 0.0,
3456                "the {builtin:?} control must exercise a scoring dimension"
3457            );
3458            assert_eq!(
3459                configured_score, builtin_score,
3460                "configured tool {configured:?} must score exactly like {builtin:?}"
3461            );
3462        }
3463    }
3464
3465    #[test]
3466    fn tool_semantics_reject_duplicates_and_builtin_reclassification() {
3467        let duplicate = ToolSemantics {
3468            observe: vec!["lookup".to_string()],
3469            mutate: vec!["LOOKUP".to_string()],
3470            ..Default::default()
3471        };
3472        assert!(
3473            duplicate
3474                .validate()
3475                .expect_err("duplicate should fail")
3476                .to_string()
3477                .contains("appears in both")
3478        );
3479
3480        let builtin = ToolSemantics {
3481            new: vec!["write_file".to_string()],
3482            ..Default::default()
3483        };
3484        assert!(
3485            builtin
3486                .validate()
3487                .expect_err("built-in should fail")
3488                .to_string()
3489                .contains("built-in semantics")
3490        );
3491
3492        let empty = ToolSemantics {
3493            observe: vec![" \t".to_string()],
3494            ..Default::default()
3495        };
3496        assert!(
3497            empty
3498                .validate()
3499                .expect_err("empty name should fail")
3500                .to_string()
3501                .contains("empty tool name")
3502        );
3503    }
3504}