Skip to main content

switchyard_libsy/algorithms/util/
tool_signals.rs

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