Skip to main content

switchyard_libsy/algorithms/util/
tool_signals.rs

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