Skip to main content

switchyard_libsy/algorithms/util/
tool_signals.rs

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