Skip to main content

switchyard_libsy/algorithms/util/
tool_signals.rs

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