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//! pattern-matches their text against a curated 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 async_trait::async_trait;
16use serde_json::Value;
17use switchyard_protocol::{ContentBlock, Request, Role};
18
19use crate::Result;
20
21use crate::core::processor::{Event, Processor};
22use crate::core::state::State;
23
24// ─── severity constants ───────────────────────────────────────────────────────
25
26const SOFT: f32 = 0.3;
27const HARD: f32 = 0.7;
28const CRITICAL: f32 = 1.0;
29
30// ─── pattern table ────────────────────────────────────────────────────────────
31
32/// (name, severity, lower-cased substrings — any hit fires the pattern)
33static ERROR_PATTERNS: &[(&str, f32, &[&str])] = &[
34    (
35        "oom",
36        CRITICAL,
37        &["out of memory", "memoryerror", "cannot allocate memory"],
38    ),
39    (
40        "connection_refused",
41        CRITICAL,
42        &[
43            "connection refused",
44            "connectionrefusederror",
45            "econnrefused",
46        ],
47    ),
48    ("traceback", HARD, &["traceback (most recent call last)"]),
49    (
50        "import_error",
51        HARD,
52        &["modulenotfounderror:", "importerror:", "no module named "],
53    ),
54    (
55        "cmd_not_found",
56        HARD,
57        &["command not found", "not found\n", "/usr/bin/env: "],
58    ),
59    ("assertion", HARD, &["assertionerror"]),
60    ("value_error", HARD, &["valueerror:"]),
61    ("syntax_error", HARD, &["syntaxerror:"]),
62    (
63        "timeout",
64        HARD,
65        &[
66            "timed out",
67            "timeouterror",
68            "timeout expired",
69            "deadline exceeded",
70        ],
71    ),
72    (
73        "no_such_file",
74        HARD,
75        &[
76            "filenotfounderror:",
77            "no such file or directory",
78            // Claude Code Read-tool miss. Anchored as "file does not exist" (not a
79            // bare "does not exist", which fires on `ls` output and prose) — trace-
80            // mined across 1006 local trajectories at 22 true / 2 false positives.
81            "file does not exist",
82        ],
83    ),
84    // SOFT: plain non-zero exit without a recognisable exception traceback.
85    ("exit_nonzero", SOFT, &["returned non-zero"]),
86];
87
88static NONZERO_EXIT_PHRASES: &[&str] = &[
89    "exit code",
90    "exit status",
91    "exited with code",
92    "exited with status",
93];
94
95static EDIT_TOOL_NAMES: &[&str] = &[
96    "edit",
97    "multiedit",
98    "notebookedit",
99    "str_replace",
100    "str_replace_based_edit_tool",
101    "apply_patch", // codex's edit tool
102    "text_editor",
103    "patch", // hermes's str_replace-style edit tool
104];
105
106static WRITE_TOOL_NAMES: &[&str] = &["write", "create_file", "new_file", "write_file"];
107
108// Bash subcommand patterns. Lowercased; callers must lowercase the command
109// before matching. Bucketed into write_count / edit_count alongside the
110// dedicated `Write` / `Edit` tools.
111static BASH_WRITE_PATTERNS: &[&str] = &[
112    "cat >",
113    "cat >>",
114    "echo >",
115    "echo >>",
116    "tee ",
117    "printf >",
118    "printf >>",
119    "> /",
120    ">> /",
121    "<< 'eof'",
122    "<<eof",
123    "<<'eof'",
124    "<< eof",
125];
126
127/// Python file-write expressions, which only indicate a write when an
128/// interpreter is running them rather than a search looking for them.
129static PYTHON_WRITE_PATTERNS: &[&str] = &["write_text(", "writelines(", ".write("];
130
131static JAVASCRIPT_WRITE_PATTERNS: &[&str] = &[
132    "writefilesync(",
133    "writefile(",
134    "appendfilesync(",
135    "appendfile(",
136];
137
138static BASH_EDIT_PATTERNS: &[&str] = &[
139    "sed -i",
140    "sed --in-place",
141    "awk -i inplace",
142    "awk 'inplace=1'",
143    "patch ",
144    "patch -p",
145    "perl -i",
146    "perl -p -i",
147    "perl -pi",
148];
149
150// Read-like Bash inspections. Match only when none of the write/edit patterns
151// fire (redirection / in-place edit trumps the read intent of the command).
152static BASH_READ_PATTERNS: &[&str] = &[
153    "cat /", "cat ./", "cat ../", "grep ", "ls ", "ls -", "find ", "head ", "tail ", "wc ",
154    "diff ", "which ", "ps ", "df ", "du ", "stat ", "file ", "less ", "more ",
155];
156
157/// Read-only shell programs seen in Codex trajectories. Matching is limited to
158/// command-segment starts so prose and arguments do not masquerade as actions.
159static BASH_READ_COMMANDS: &[&str] = &[
160    "cat", "rg", "nl", "jq", "pwd", "tree", "sed", "grep", "ls", "find", "head", "tail", "wc",
161    "diff", "which", "ps", "df", "du", "stat", "file", "less", "more", "readlink", "realpath",
162    "basename", "dirname", "printenv",
163];
164
165static GIT_READ_SUBCOMMANDS: &[&str] = &[
166    "status",
167    "diff",
168    "log",
169    "show",
170    "show-ref",
171    "rev-parse",
172    "ls-files",
173    "ls-remote",
174    "ls-tree",
175    "grep",
176    "blame",
177    "merge-base",
178    "check-ignore",
179    "tag",
180];
181
182static READ_TOOL_NAMES: &[&str] = &["read", "view", "read_file", "search_files"];
183
184// Planning / scratchpad tool calls — investigative (non-producing) activity.
185// `update_plan` is codex's equivalent of `todowrite`.
186static PLAN_TOOL_NAMES: &[&str] = &["todowrite", "todo_write", "todo", "update_plan"];
187
188// Tool names that route through Bash-command pattern matching. `bash` is
189// claude-code's name; `shell_command` is codex's; `shell` / `local_shell_call`
190// are seen on some OpenAI-derived harnesses; `terminal` is hermes's (it carries
191// a `command` arg like the others, so its intent comes from the pattern match).
192static BASH_TOOL_NAMES: &[&str] = &[
193    "bash",
194    "shell_command",
195    "shell",
196    "local_shell_call",
197    "terminal",
198    "exec_command", // codex
199];
200
201// Prefer false negatives: tests_passed routes the picker to EFFICIENT, so a false
202// positive would drop tier on an unfinished task.
203static TEST_PASS_PHRASES: &[&str] = &[
204    " passed",
205    "passed in",
206    "tests passed",
207    "all tests passed",
208    "test ok",
209    "test result: ok",
210    "passed.\n",
211    "tests pass",
212    "\nok ", // go test; newline-anchored to avoid "...lookup..." mid-text
213    "✓ ",
214];
215
216// Literal failure phrases that cannot appear inside a clean run. Substring
217// matched as-is. Patterns that pair with a count (e.g. "failed", "errors")
218// are handled separately by `has_nonzero_failure_count` so "0 failed" /
219// "0 errors" do not trigger a false negative.
220static TEST_FAILURE_LITERAL: &[&str] = &["✗ ", "fatal:", "assertionerror", "error:"];
221
222// Count-prefixed failure keywords. Trip only when a nonzero integer precedes
223// the keyword (modulo whitespace), so cargo's "0 failed" and go's
224// "0 errors" summaries on a clean run are not misread as failures.
225static NUMERIC_FAILURE_KEYWORDS: &[&str] = &["failed", "failure", "failures", "errors", "error"];
226
227/// Default sliding-window size for `recent_*` counts and windowed severity.
228///
229/// A short horizon captures "what is the agent doing right now" while keeping
230/// signals sticky — an error or stall persists a few recovery turns instead of
231/// flickering off the moment one clean result lands. Override per request by
232/// passing a window to [`ToolSignals::from_request`].
233pub const DEFAULT_RECENT_WINDOW: usize = 3;
234
235// ─── output type ─────────────────────────────────────────────────────────────
236
237/// Tool-execution signals extracted from a normalized [`Request`].
238///
239/// A request-side processor stores these signals in [`State`](crate::State) for
240/// [`crate::StageRouter`] and its classifier to consume. The advisor gate's
241/// request-side guards read the conversation-shape counts directly via
242/// [`ToolSignals::from_request`].
243#[derive(Clone, Debug, Default)]
244pub struct ToolSignals {
245    /// Max severity across the recent window (last `recent_window` tool results):
246    /// `0.0` clean · `0.3` soft (exit_nonzero) · `0.7` hard · `1.0` critical.
247    /// Windowed so an error persists through the recovery turns instead of clearing
248    /// the instant the next result is clean.
249    pub severity: f32,
250    /// Consecutive clean tool results back from the most recent. `0` if the last failed.
251    pub no_error_streak: u32,
252    /// Total edit-style tool calls in the request.
253    pub edit_count: u32,
254    /// Total write-style tool calls in the request.
255    pub write_count: u32,
256    /// Read-type calls (Read tool + read-like Bash). Used by the build-pit gate.
257    pub read_count: u32,
258    /// TodoWrite / planning tool calls. Investigative (non-producing) activity —
259    /// recent todowrites distinguish `exploring` from `spinning` in the scorer.
260    pub todowrite_count: u32,
261    /// Edit-type calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
262    pub recent_edit_count: u32,
263    /// Write-type calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
264    pub recent_write_count: u32,
265    /// Read-type calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
266    pub recent_read_count: u32,
267    /// TodoWrite calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
268    pub recent_todowrite_count: u32,
269    /// Consecutive trailing tool calls in the `Other` category (no Write/Edit/Read/
270    /// Plan match). Surfaced in the classifier state summary; not scored directly.
271    pub pure_bash_streak: u32,
272    /// At least one of the last three tool results matched a test-pass pattern.
273    pub tests_passed: bool,
274    /// Total `ToolResult` blocks, counted per block (a message batching N
275    /// results contributes N) and including empty-content results.
276    pub tool_result_count: u32,
277    /// Messages with `Role::Assistant`, unlike [`ToolSignals::turn_depth`],
278    /// which counts every message regardless of role.
279    pub assistant_turn_count: u32,
280    /// Message-count proxy for turn depth. Wire-format dependent (Anthropic batches
281    /// tool results into fewer messages than OpenAI-chat), so gates keyed on it are
282    /// approximate across request origins.
283    pub turn_depth: u32,
284    /// The request carries a context-compaction summary (the agent's context was
285    /// summarised after overflowing). Compaction resets the router's accumulated
286    /// signals, so a task that was on the strong tier de-escalates back to weak — the
287    /// picker uses this to force + hold the strong tier. Self-latching: the summary
288    /// stays in the context prefix on every subsequent turn.
289    pub compacted: bool,
290}
291
292impl ToolSignals {
293    /// Extracts tool and progress signals from `request`.
294    ///
295    /// `window_size` limits recent counters to the newest tool results. `None`
296    /// uses [`DEFAULT_RECENT_WINDOW`].
297    pub fn from_request(request: &Request, window_size: Option<usize>) -> Self {
298        extract_tool_signals_with_window(request, window_size.unwrap_or(DEFAULT_RECENT_WINDOW))
299    }
300}
301
302// `command` is the lowercased Bash command line; None for non-Bash tools.
303#[derive(Debug, Clone)]
304struct ObservedToolCall {
305    name: String,
306    command: Option<String>,
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310enum ToolCategory {
311    Write,
312    Edit,
313    Read,
314    Plan,
315    Other,
316}
317
318/// Request-side processor that extracts tool-result signals from each request
319/// and stores them on the request `State` for downstream routing.
320#[derive(Debug, Clone)]
321pub struct ToolSignalProcessor {
322    /// Number of trailing tool results the `recent_*` counts and windowed
323    /// severity are computed over.
324    pub recent_window: usize,
325}
326
327impl Default for ToolSignalProcessor {
328    fn default() -> Self {
329        Self {
330            recent_window: DEFAULT_RECENT_WINDOW,
331        }
332    }
333}
334
335#[async_trait]
336impl Processor<State> for ToolSignalProcessor {
337    async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
338        if let Event::Request { request: req, .. } = event {
339            let tool_signal = ToolSignals::from_request(req, Some(self.recent_window));
340            state.tool_signals = Some(tool_signal);
341        }
342        Ok(())
343    }
344}
345
346fn classify_tool_call(name: &str, command: Option<&str>) -> ToolCategory {
347    let lower = name.to_lowercase();
348    if WRITE_TOOL_NAMES.contains(&lower.as_str()) {
349        return ToolCategory::Write;
350    }
351    if EDIT_TOOL_NAMES.contains(&lower.as_str()) {
352        return ToolCategory::Edit;
353    }
354    if READ_TOOL_NAMES.contains(&lower.as_str()) {
355        return ToolCategory::Read;
356    }
357    if PLAN_TOOL_NAMES.contains(&lower.as_str()) {
358        return ToolCategory::Plan;
359    }
360    if BASH_TOOL_NAMES.contains(&lower.as_str())
361        && let Some(cmd) = command
362    {
363        // Write/edit redirection trumps read-like operands.
364        if BASH_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_write(cmd) {
365            return ToolCategory::Write;
366        }
367        if cmd.contains("python") && PYTHON_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
368            return ToolCategory::Write;
369        }
370        if shell_invokes_program(cmd, "node")
371            && JAVASCRIPT_WRITE_PATTERNS
372                .iter()
373                .any(|pattern| cmd.contains(pattern))
374        {
375            return ToolCategory::Write;
376        }
377        if BASH_EDIT_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_edit(cmd) {
378            return ToolCategory::Edit;
379        }
380        if BASH_READ_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_read(cmd) {
381            return ToolCategory::Read;
382        }
383    }
384    ToolCategory::Other
385}
386
387/// Split a shell line at common command separators. This intentionally avoids
388/// pretending to be a full shell parser; only the leading program and flags of
389/// each segment are inspected below.
390fn shell_segments(command: &str) -> impl Iterator<Item = &str> {
391    command
392        .split(['\n', ';', '|', '&'])
393        .map(str::trim)
394        .filter(|segment| !segment.is_empty())
395}
396
397fn shell_words(segment: &str) -> Vec<&str> {
398    let words: Vec<&str> = segment.split_ascii_whitespace().collect();
399    let mut start = 0usize;
400
401    if words.first().is_some_and(|word| *word == "env") {
402        start += 1;
403        while words.get(start).is_some_and(|word| word.starts_with('-')) {
404            start += 1;
405        }
406    }
407    while words
408        .get(start)
409        .is_some_and(|word| word.contains('=') && !word.starts_with('='))
410    {
411        start += 1;
412    }
413
414    words[start..].to_vec()
415}
416
417fn program_name(word: &str) -> &str {
418    word.rsplit('/').next().unwrap_or(word)
419}
420
421fn shell_invokes_program(command: &str, expected: &str) -> bool {
422    shell_segments(command).any(|segment| {
423        shell_words(segment)
424            .first()
425            .is_some_and(|word| program_name(word) == expected)
426    })
427}
428
429fn shell_command_is_write(command: &str) -> bool {
430    shell_segments(command).any(|segment| {
431        let words = shell_words(segment);
432        let Some(program) = words.first().map(|word| program_name(word)) else {
433            return false;
434        };
435        if matches!(program, "cp" | "mkdir" | "touch" | "install") {
436            return true;
437        }
438
439        let redirects_output = words.iter().skip(1).any(|word| matches!(*word, ">" | ">>"));
440        redirects_output
441            && (matches!(program, "echo" | "printf" | "git")
442                || BASH_READ_COMMANDS.contains(&program))
443    })
444}
445
446fn shell_command_is_edit(command: &str) -> bool {
447    shell_segments(command).any(|segment| {
448        let words = shell_words(segment);
449        let Some(program) = words.first().map(|word| program_name(word)) else {
450            return false;
451        };
452        let has_arg = |arg: &str| words.iter().skip(1).any(|word| *word == arg);
453
454        match program {
455            "mv" | "rm" => true,
456            "perl" => words
457                .iter()
458                .skip(1)
459                .take_while(|word| word.starts_with('-'))
460                .any(|option| {
461                    option
462                        .trim_start_matches('-')
463                        .chars()
464                        .any(|flag| flag == 'i')
465                }),
466            "git" => words
467                .get(1)
468                .is_some_and(|subcommand| matches!(*subcommand, "apply" | "am" | "restore")),
469            "gofmt" => has_arg("-w"),
470            "cargo" => words.get(1) == Some(&"fmt") && !has_arg("--check"),
471            "ruff" => {
472                (words.get(1) == Some(&"format") && !has_arg("--check"))
473                    || (words.get(1) == Some(&"check") && has_arg("--fix"))
474            }
475            "prettier" => has_arg("--write"),
476            "black" => !has_arg("--check"),
477            _ => {
478                (words.iter().any(|word| program_name(word) == "prettier") && has_arg("--write"))
479                    || (words.iter().any(|word| program_name(word) == "ruff")
480                        && ((words.contains(&"format") && !has_arg("--check"))
481                            || (words.contains(&"check") && has_arg("--fix"))))
482            }
483        }
484    })
485}
486
487fn shell_command_is_read(command: &str) -> bool {
488    shell_segments(command).any(|segment| {
489        if segment == "env" {
490            return true;
491        }
492        let words = shell_words(segment);
493        let Some(program) = words.first().map(|word| program_name(word)) else {
494            return false;
495        };
496
497        if BASH_READ_COMMANDS.contains(&program) {
498            return true;
499        }
500        if program == "command" && words.get(1) == Some(&"-v") {
501            return true;
502        }
503        if program == "type" {
504            return true;
505        }
506        if program != "git" {
507            return false;
508        }
509
510        match words.get(1).copied() {
511            Some("branch") => words.get(2).is_none_or(|arg| arg.starts_with('-')),
512            Some("remote") => words
513                .get(2)
514                .is_none_or(|arg| arg.starts_with('-') || *arg == "get-url"),
515            Some("config") => words
516                .get(2)
517                .is_some_and(|arg| matches!(*arg, "--get" | "--get-all" | "--list" | "-l")),
518            Some(subcommand) => GIT_READ_SUBCOMMANDS.contains(&subcommand),
519            None => false,
520        }
521    })
522}
523
524// ─── extraction entry point ───────────────────────────────────────────────────
525
526/// Extract all tool-execution signals from a normalized [`Request`].
527///
528/// Returns [`ToolSignals::default()`] when the message history contains no tool
529/// activity, so callers can always inspect the signal fields.
530fn extract_tool_signals_with_window(request: &Request, recent_window: usize) -> ToolSignals {
531    // Read the decoded conversation, not the raw body: every inbound format lands
532    // in the same shape here, so the signals do not depend on knowing which one it
533    // arrived as.
534    let messages = &request.llm_request.messages;
535    let mut tool_texts: Vec<String> = Vec::new();
536    let mut tool_calls: Vec<ObservedToolCall> = Vec::new();
537    let mut compacted = false;
538    let mut tool_result_count = 0usize;
539    let mut assistant_turn_count = 0usize;
540
541    for message in messages {
542        if message.role == Role::Assistant {
543            assistant_turn_count += 1;
544        }
545        for block in &message.content {
546            match block {
547                ContentBlock::ToolCall(call) => {
548                    tool_calls.push(ObservedToolCall {
549                        name: call.name.clone(),
550                        command: command_of(&call.arguments),
551                    });
552                }
553                ContentBlock::ToolResult(result) => {
554                    // Before the empty-text filter: empty results still count.
555                    tool_result_count += 1;
556                    let text = result
557                        .content
558                        .iter()
559                        .filter_map(text_of)
560                        .collect::<Vec<_>>()
561                        .join("\n");
562                    if !text.is_empty() {
563                        tool_texts.push(text);
564                    }
565                }
566                // Compaction is detected anywhere in the conversation: the summary
567                // stays in the prefix on every later turn, so this self-latches
568                // once it fires.
569                ContentBlock::Text { text } => {
570                    compacted |= text.to_lowercase().contains(COMPACTION_MARKER);
571                }
572                _ => {}
573            }
574        }
575    }
576
577    let mut signal = build_signal(tool_texts, tool_calls, messages.len() as u32, recent_window);
578    signal.compacted = compacted;
579    signal.tool_result_count = u32::try_from(tool_result_count).unwrap_or(u32::MAX);
580    signal.assistant_turn_count = u32::try_from(assistant_turn_count).unwrap_or(u32::MAX);
581    signal
582}
583
584/// Distinctive preamble Claude Code injects as a user message when it compacts an
585/// overflowed context. Matched case-insensitively; normal task text never contains it.
586const COMPACTION_MARKER: &str = "session is being continued";
587
588/// The shell command a tool call carries, when it has one. Harnesses name the
589/// field `command`; anything else is a tool whose category comes from its name.
590fn command_of(arguments: &Value) -> Option<String> {
591    // the Responses wire format sends arguments as a JSON string, not an object
592    let decoded = arguments
593        .as_str()
594        .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
595    let object = decoded.as_ref().unwrap_or(arguments);
596
597    ["command", "cmd", "input"]
598        .iter()
599        .filter_map(|key| object.get(*key))
600        .find_map(command_text)
601}
602
603/// A command field as lowercase text, from a string or an argv array.
604fn command_text(value: &Value) -> Option<String> {
605    match value {
606        Value::String(text) => Some(text.to_lowercase()),
607        Value::Array(parts) => {
608            let joined = parts
609                .iter()
610                .filter_map(Value::as_str)
611                .collect::<Vec<_>>()
612                .join(" ");
613            (!joined.is_empty()).then(|| joined.to_lowercase())
614        }
615        _ => None,
616    }
617}
618
619/// Text carried by a content block, ignoring the non-textual kinds.
620fn text_of(block: &ContentBlock) -> Option<&str> {
621    match block {
622        ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
623        _ => None,
624    }
625}
626
627fn build_signal(
628    tool_texts: Vec<String>,
629    tool_calls: Vec<ObservedToolCall>,
630    turn_depth: u32,
631    recent_window: usize,
632) -> ToolSignals {
633    // Windowed severity: take the MAX severity across the last `recent_window` tool
634    // results rather than only the last one. An error's severity then persists for
635    // the recent window and decays out of it — parallel to the windowed `recent_*`
636    // counts — so a fix written a couple of turns after an error still routes on the
637    // error signal instead of the router flapping straight back to the weak tier.
638    let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
639    let mut severity = 0.0f32;
640    for text in &tool_texts[sev_start..] {
641        let (sev, _patterns) = classify_text(text);
642        if sev > severity {
643            severity = sev;
644        }
645    }
646
647    let no_error_streak = compute_no_error_streak(&tool_texts);
648
649    // Single pass: cumulative + sliding-window counters together. Also tracks
650    // the trailing pure-bash streak (consecutive `Other`-category calls back
651    // from the end) — the build-pit proxy.
652    let recent_start = tool_calls.len().saturating_sub(recent_window);
653    let mut write_count = 0u32;
654    let mut edit_count = 0u32;
655    let mut read_count = 0u32;
656    let mut todowrite_count = 0u32;
657    let mut recent_write_count = 0u32;
658    let mut recent_edit_count = 0u32;
659    let mut recent_read_count = 0u32;
660    let mut recent_todowrite_count = 0u32;
661    let mut pure_bash_streak = 0u32;
662    let mut streak_open = true;
663    for (i, tc) in tool_calls.iter().enumerate().rev() {
664        let cat = classify_tool_call(&tc.name, tc.command.as_deref());
665        if streak_open {
666            if matches!(cat, ToolCategory::Other) {
667                pure_bash_streak += 1;
668            } else {
669                streak_open = false;
670            }
671        }
672        match cat {
673            ToolCategory::Write => {
674                write_count += 1;
675                if i >= recent_start {
676                    recent_write_count += 1;
677                }
678            }
679            ToolCategory::Edit => {
680                edit_count += 1;
681                if i >= recent_start {
682                    recent_edit_count += 1;
683                }
684            }
685            ToolCategory::Read => {
686                read_count += 1;
687                if i >= recent_start {
688                    recent_read_count += 1;
689                }
690            }
691            ToolCategory::Plan => {
692                todowrite_count += 1;
693                if i >= recent_start {
694                    recent_todowrite_count += 1;
695                }
696            }
697            ToolCategory::Other => {}
698        }
699    }
700
701    let tests_passed = detect_tests_passed(&tool_texts, recent_window);
702
703    ToolSignals {
704        severity,
705        no_error_streak,
706        edit_count,
707        write_count,
708        read_count,
709        todowrite_count,
710        recent_edit_count,
711        recent_write_count,
712        recent_read_count,
713        recent_todowrite_count,
714        pure_bash_streak,
715        tests_passed,
716        turn_depth,
717        // Set by extract_tool_signals_with_window after the format-specific extract,
718        // which scans all message contents for the compaction marker and tallies
719        // the raw conversation-shape counts.
720        tool_result_count: 0,
721        assistant_turn_count: 0,
722        compacted: false,
723    }
724}
725
726// ─── pure helpers ─────────────────────────────────────────────────────────────
727
728/// Normalise a JSON tool-result content value to a plain string.
729fn content_to_text(content: Option<&Value>) -> Option<String> {
730    match content? {
731        Value::String(s) => Some(s.clone()),
732        Value::Array(blocks) => {
733            let parts: Vec<&str> = blocks
734                .iter()
735                .filter_map(|b| {
736                    b.as_object()
737                        .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
738                        .and_then(|o| o.get("text"))
739                        .and_then(Value::as_str)
740                })
741                .collect();
742            if parts.is_empty() {
743                None
744            } else {
745                Some(parts.join("\n"))
746            }
747        }
748        _ => None,
749    }
750}
751
752/// Match `text` against the error pattern table.
753///
754/// Returns `(max_severity, matched_pattern_names)`.
755pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
756    let lower = text.to_lowercase();
757    let mut patterns = Vec::new();
758    let mut severity: f32 = 0.0;
759    for (name, sev, substrings) in ERROR_PATTERNS {
760        if substrings.iter().any(|sub| lower.contains(sub)) {
761            patterns.push(name.to_string());
762            severity = severity.max(*sev);
763        }
764    }
765    if has_nonzero_exit_status(&lower) && !patterns.iter().any(|p| p == "exit_nonzero") {
766        patterns.push("exit_nonzero".to_string());
767        severity = severity.max(SOFT);
768    }
769    for (name, matched) in [
770        ("compile_error", has_compiler_diagnostic(&lower)),
771        ("runtime_exception", has_runtime_exception(&lower)),
772        ("runtime_panic", has_runtime_panic(&lower)),
773        ("patch_error", has_patch_failure(&lower)),
774    ] {
775        if matched && !patterns.iter().any(|pattern| pattern == name) {
776            patterns.push(name.to_string());
777            severity = severity.max(HARD);
778        }
779    }
780    (severity, patterns)
781}
782
783fn has_compiler_diagnostic(lower: &str) -> bool {
784    lower.lines().any(|line| {
785        let line = line.trim_start();
786        if matches!(
787            line,
788            "compilation failed" | "error: compilation failed" | "error: could not compile"
789        ) || line.starts_with("error: could not compile ")
790        {
791            return true;
792        }
793
794        let Some(rest) = line.strip_prefix("error[e") else {
795            return false;
796        };
797        let Some((code, _)) = rest.split_once("]:") else {
798            return false;
799        };
800        !code.is_empty() && code.chars().all(|character| character.is_ascii_digit())
801    })
802}
803
804fn has_runtime_exception(lower: &str) -> bool {
805    let has_exception_line = lower.lines().any(|line| {
806        let line = line.trim_start();
807        [
808            "typeerror:",
809            "referenceerror:",
810            "rangeerror:",
811            "runtimeerror:",
812            "keyerror:",
813            "attributeerror:",
814        ]
815        .iter()
816        .any(|prefix| line.starts_with(prefix))
817    });
818    has_exception_line && (lower.contains("\n    at ") || lower.contains("\n  at "))
819}
820
821fn has_runtime_panic(lower: &str) -> bool {
822    lower
823        .lines()
824        .any(|line| line.trim_start().starts_with("panic: runtime error:"))
825        && (lower.contains("\ngoroutine ") || lower.contains("[signal sig"))
826}
827
828fn has_patch_failure(lower: &str) -> bool {
829    lower.lines().any(|line| {
830        let line = line.trim_start();
831        line.starts_with("error: patch failed:")
832            || line.starts_with("patch failed:")
833            || line.contains(": patch does not apply")
834            || line.starts_with("invalid context")
835    })
836}
837
838/// Detects `exit_nonzero` only when a supported exit phrase is followed by a
839/// nonzero decimal status.
840///
841/// Codex includes "Process exited with code 0" on clean tool results, so exit
842/// phrases must parse their numeric status instead of matching the phrase alone.
843fn has_nonzero_exit_status(lower: &str) -> bool {
844    NONZERO_EXIT_PHRASES
845        .iter()
846        .any(|phrase| phrase_followed_by_nonzero_integer(lower, phrase))
847}
848
849/// Matches common "exit code/status N" spellings after optional separators.
850fn phrase_followed_by_nonzero_integer(lower: &str, phrase: &str) -> bool {
851    let mut cursor = 0usize;
852    while let Some(rel) = lower[cursor..].find(phrase) {
853        let value_start = cursor + rel + phrase.len();
854        let rest = lower[value_start..].trim_start_matches(|c: char| {
855            c.is_ascii_whitespace() || matches!(c, ':' | '=' | '\'' | '"' | '`')
856        });
857        let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
858        if !digits.is_empty() && digits.chars().any(|d| d != '0') {
859            return true;
860        }
861        cursor = value_start;
862    }
863    false
864}
865
866fn compute_no_error_streak(tool_texts: &[String]) -> u32 {
867    let mut streak = 0u32;
868    for text in tool_texts.iter().rev() {
869        let (sev, _) = classify_text(text);
870        if sev > 0.0 {
871            break;
872        }
873        streak += 1;
874    }
875    streak
876}
877
878fn detect_tests_passed(tool_texts: &[String], recent_window: usize) -> bool {
879    let start = tool_texts.len().saturating_sub(recent_window.max(1));
880    tool_texts[start..].iter().any(|text| {
881        let lower = text.to_lowercase();
882        TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
883            && !TEST_FAILURE_LITERAL.iter().any(|p| lower.contains(p))
884            && !has_nonzero_failure_count(&lower)
885    })
886}
887
888// True iff `lower` contains a `NUMERIC_FAILURE_KEYWORDS` token preceded
889// (modulo whitespace) by a nonzero integer. The "modulo whitespace" lets
890// "1 failed", "1\nfailed", and "1  failed" all trip; the nonzero guard
891// keeps cargo's "0 failed" / go's "0 errors" / pytest's "0 errors in"
892// summaries from being misread as failures on a clean run.
893fn has_nonzero_failure_count(lower: &str) -> bool {
894    for kw in NUMERIC_FAILURE_KEYWORDS {
895        let mut cursor = 0usize;
896        while let Some(rel) = lower[cursor..].find(kw) {
897            let kw_start = cursor + rel;
898            let kw_end = kw_start + kw.len();
899            // Word boundary AFTER the keyword — "errors" mid-word (e.g.
900            // "errored") shouldn't count as a failure-count site.
901            let boundary_after = lower[kw_end..]
902                .chars()
903                .next()
904                .is_none_or(|c| !c.is_ascii_alphanumeric());
905            if boundary_after {
906                let prefix = &lower[..kw_start];
907                let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
908                let digits_rev: String = trimmed
909                    .chars()
910                    .rev()
911                    .take_while(|c| c.is_ascii_digit())
912                    .collect();
913                if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
914                    return true;
915                }
916            }
917            cursor = kw_start + kw.len();
918        }
919    }
920    false
921}
922
923// ─── tests ───────────────────────────────────────────────────────────────────
924
925#[cfg(test)]
926mod tests {
927    use super::*;
928    use serde_json::json;
929    use switchyard_protocol::{ContentBlock, LlmRequest, Message, Role, ToolCall, ToolResult};
930
931    fn with_messages(messages: Vec<Message>) -> Request {
932        Request {
933            llm_request: LlmRequest {
934                messages,
935                ..LlmRequest::default()
936            },
937            raw_request: None,
938            metadata: None,
939        }
940    }
941
942    // assistant message with a single named tool call
943    fn tc(name: &str) -> Message {
944        Message {
945            role: Role::Assistant,
946            content: vec![ContentBlock::ToolCall(ToolCall {
947                id: String::new(),
948                name: name.to_string(),
949                arguments: json!({}),
950            })],
951        }
952    }
953
954    // assistant Bash message carrying `command`
955    fn bash(command: &str) -> Message {
956        Message {
957            role: Role::Assistant,
958            content: vec![ContentBlock::ToolCall(ToolCall {
959                id: String::new(),
960                name: "Bash".to_string(),
961                arguments: json!({"command": command}),
962            })],
963        }
964    }
965
966    // a tool result message (goes in a user-role message, as in Anthropic's normalised form)
967    fn tr(text: &str) -> Message {
968        Message {
969            role: Role::User,
970            content: vec![ContentBlock::ToolResult(ToolResult {
971                tool_call_id: String::new(),
972                content: vec![ContentBlock::Text {
973                    text: text.to_string(),
974                }],
975                is_error: None,
976            })],
977        }
978    }
979
980    #[test]
981    fn clean_text_has_zero_severity() {
982        let (sev, patterns) = classify_text("everything went fine");
983        assert_eq!(sev, 0.0);
984        assert!(patterns.is_empty());
985    }
986
987    #[test]
988    fn traceback_is_hard() {
989        let (sev, patterns) = classify_text("Traceback (most recent call last):\n  ValueError");
990        assert_eq!(sev, HARD);
991        assert!(patterns.contains(&"traceback".to_string()));
992    }
993
994    #[test]
995    fn oom_is_critical() {
996        let (sev, _) = classify_text("Out of memory: kill process 1234");
997        assert_eq!(sev, CRITICAL);
998    }
999
1000    #[test]
1001    fn severity_is_max_across_patterns() {
1002        // exit_nonzero (SOFT) + traceback (HARD) → HARD.
1003        let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
1004        assert_eq!(sev, HARD);
1005    }
1006
1007    #[test]
1008    fn codex_process_exit_zero_stays_clean() {
1009        let (sev, patterns) =
1010            classify_text("Chunk ID: abc\nProcess exited with code 0\nOutput:\nok");
1011        assert_eq!(sev, 0.0);
1012        assert!(!patterns.contains(&"exit_nonzero".to_string()));
1013    }
1014
1015    #[test]
1016    fn nonzero_exit_codes_are_soft_errors() {
1017        let cases = [
1018            "Process exited with code 1",
1019            "Process exited with code 127",
1020            "exit code: 2",
1021            "exit status 3",
1022            "exited with status 9",
1023        ];
1024        for case in cases {
1025            let (sev, patterns) = classify_text(case);
1026            assert_eq!(sev, SOFT, "expected soft severity for {case}");
1027            assert!(patterns.contains(&"exit_nonzero".to_string()));
1028        }
1029    }
1030
1031    #[test]
1032    fn partial_process_failures_are_hard_errors() {
1033        let cases = [
1034            (
1035                "Process running with session ID 12\nOutput:\nerror[E0509]: cannot move out",
1036                "compile_error",
1037            ),
1038            (
1039                "Process exited with code 0\nOutput:\nTypeError: value is undefined\n    at main.js:1:2",
1040                "runtime_exception",
1041            ),
1042            (
1043                "Process running with session ID 13\nOutput:\npanic: runtime error: index out of range\n\ngoroutine 6 [running]:",
1044                "runtime_panic",
1045            ),
1046            (
1047                "Process exited with code 0\nOutput:\nerror: patch failed: src/lib.rs:4\nerror: src/lib.rs: patch does not apply",
1048                "patch_error",
1049            ),
1050        ];
1051        for (text, expected_pattern) in cases {
1052            let (severity, patterns) = classify_text(text);
1053            assert_eq!(severity, HARD, "expected hard severity for {text}");
1054            assert!(patterns.iter().any(|pattern| pattern == expected_pattern));
1055        }
1056    }
1057
1058    #[test]
1059    fn source_text_that_names_exceptions_stays_clean() {
1060        let text =
1061            "pub enum TypeError: this is documentation\nlet sample = 'panic: runtime error:';";
1062        assert_eq!(classify_text(text).0, 0.0);
1063    }
1064
1065    #[test]
1066    fn file_does_not_exist_is_hard() {
1067        // Claude Code Read-tool miss. Trace-mined addition (22 true / 2 false positives).
1068        let (sev, patterns) =
1069            classify_text("Error: File does not exist. Note: current working directory is /app.");
1070        assert_eq!(sev, HARD);
1071        assert!(patterns.contains(&"no_such_file".to_string()));
1072    }
1073
1074    #[test]
1075    fn bare_does_not_exist_stays_clean() {
1076        // Precision guard: only the anchored "file does not exist" fires, so a bare
1077        // "does not exist" in prose or directory output must not trip a false error.
1078        let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
1079        assert_eq!(sev, 0.0);
1080    }
1081
1082    #[test]
1083    fn no_error_streak_all_clean() {
1084        let texts = vec!["ok".to_string(), "all good".to_string()];
1085        assert_eq!(compute_no_error_streak(&texts), 2);
1086    }
1087
1088    #[test]
1089    fn no_error_streak_stops_at_error() {
1090        let texts = vec![
1091            "Traceback (most recent call last):".to_string(),
1092            "ok".to_string(),
1093            "ok".to_string(),
1094        ];
1095        assert_eq!(compute_no_error_streak(&texts), 2);
1096    }
1097
1098    #[test]
1099    fn tests_passed_detects_pytest_output() {
1100        assert!(detect_tests_passed(
1101            &["====== 5 passed in 0.12s ======".to_string()],
1102            DEFAULT_RECENT_WINDOW
1103        ));
1104    }
1105
1106    #[test]
1107    fn tests_passed_ignores_partial_failures() {
1108        assert!(!detect_tests_passed(
1109            &["2 failed, 5 passed in 0.56s".to_string()],
1110            DEFAULT_RECENT_WINDOW
1111        ));
1112    }
1113
1114    #[test]
1115    fn severity_is_windowed_over_recent_results() {
1116        // An error two results back, then two clean results.
1117        let request = with_messages(vec![
1118            tr("Traceback (most recent call last):\n  ValueError"),
1119            tr("ok"),
1120            tr("ok"),
1121        ]);
1122        // window covers the error → severity persists (max over the window)
1123        assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
1124        // window of 1 sees only the last (clean) result → severity has decayed out
1125        assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
1126    }
1127
1128    #[test]
1129    fn extract_openai_chat_tool_results() {
1130        let request = with_messages(vec![
1131            Message::text(Role::User, "do something"),
1132            tc("Edit"),
1133            tr("Traceback (most recent call last):\n  ValueError"),
1134        ]);
1135        let sig = ToolSignals::from_request(&request, None);
1136        assert_eq!(sig.severity, HARD);
1137        assert_eq!(sig.edit_count, 1);
1138        assert_eq!(sig.turn_depth, 3);
1139    }
1140
1141    #[test]
1142    fn extract_anthropic_tool_results() {
1143        let request = with_messages(vec![tr("Traceback (most recent call last):\n  ValueError")]);
1144        let sig = ToolSignals::from_request(&request, None);
1145        assert_eq!(sig.severity, HARD);
1146    }
1147
1148    #[test]
1149    fn extract_responses_api_tool_results() {
1150        let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
1151        let sig = ToolSignals::from_request(&request, None);
1152        assert_eq!(sig.severity, 0.0);
1153        assert_eq!(sig.write_count, 1);
1154    }
1155
1156    #[test]
1157    fn conversation_counts_are_per_block_and_role_aware() {
1158        // A batched user message (Anthropic shape) contributes one count per
1159        // ToolResult block, empty-content results included. assistant_turn_count
1160        // tracks Role::Assistant only, while turn_depth counts every message.
1161        let result = |content: Vec<ContentBlock>| {
1162            ContentBlock::ToolResult(ToolResult {
1163                tool_call_id: String::new(),
1164                content,
1165                is_error: None,
1166            })
1167        };
1168        let request = with_messages(vec![
1169            Message::text(Role::User, "do something"),
1170            Message::text(Role::Assistant, "working"),
1171            Message {
1172                role: Role::User,
1173                content: vec![
1174                    result(vec![ContentBlock::Text {
1175                        text: "ok".to_string(),
1176                    }]),
1177                    result(Vec::new()),
1178                ],
1179            },
1180            tc("Bash"),
1181        ]);
1182        let sig = ToolSignals::from_request(&request, None);
1183        assert_eq!(sig.tool_result_count, 2);
1184        assert_eq!(sig.assistant_turn_count, 2);
1185        assert_eq!(sig.turn_depth, 4);
1186    }
1187
1188    #[test]
1189    fn recent_window_counts_only_last_default_window_tool_calls() {
1190        // 5 writes + 1 edit at the end → the default window (3) should see
1191        // the last 3 calls: 1 edit + 2 writes (not all 6 calls).
1192        let request = with_messages(vec![
1193            tc("Write"),
1194            tr("ok"),
1195            tc("Write"),
1196            tr("ok"),
1197            tc("Write"),
1198            tr("ok"),
1199            tc("Write"),
1200            tr("ok"),
1201            tc("Write"),
1202            tr("ok"),
1203            tc("Edit"),
1204            tr("ok"),
1205        ]);
1206        let sig = ToolSignals::from_request(&request, None);
1207        assert_eq!(sig.write_count, 5);
1208        assert_eq!(sig.edit_count, 1);
1209        assert_eq!(sig.recent_write_count, 2);
1210        assert_eq!(sig.recent_edit_count, 1);
1211    }
1212
1213    #[test]
1214    fn codex_apply_patch_counts_as_an_edit() {
1215        let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
1216        let sig = ToolSignals::from_request(&request, None);
1217        assert_eq!(sig.edit_count, 1);
1218        assert_eq!(sig.recent_edit_count, 1);
1219    }
1220
1221    fn exec_command(cmd: Value) -> Message {
1222        Message {
1223            role: Role::Assistant,
1224            content: vec![ContentBlock::ToolCall(ToolCall {
1225                id: String::new(),
1226                name: "exec_command".to_string(),
1227                arguments: cmd,
1228            })],
1229        }
1230    }
1231
1232    #[test]
1233    fn codex_exec_command_is_classified() {
1234        // arguments arrive as a JSON string, with the command under `cmd`
1235        let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
1236        let request = with_messages(vec![exec_command(args), tr("ok")]);
1237        assert_eq!(
1238            ToolSignals::from_request(&request, None).recent_edit_count,
1239            1
1240        );
1241    }
1242
1243    #[test]
1244    fn python_write_expressions_need_a_python_command() {
1245        let write = with_messages(vec![
1246            exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
1247            tr("ok"),
1248        ]);
1249        assert_eq!(
1250            ToolSignals::from_request(&write, None).recent_write_count,
1251            1
1252        );
1253
1254        let search = with_messages(vec![
1255            exec_command(json!({"cmd": "grep -R '.write(' src"})),
1256            tr("ok"),
1257        ]);
1258        assert_eq!(
1259            ToolSignals::from_request(&search, None).recent_write_count,
1260            0
1261        );
1262    }
1263
1264    #[test]
1265    fn recent_window_size_is_caller_overridable() {
1266        // Same six tool calls (1 edit at the end, 5 writes before).
1267        // With recent_window=3 → recent_writes=2, recent_edits=1.
1268        // With recent_window=6 → recent_writes=5, recent_edits=1 (all calls).
1269        let request = with_messages(vec![
1270            tc("Write"),
1271            tr("ok"),
1272            tc("Write"),
1273            tr("ok"),
1274            tc("Write"),
1275            tr("ok"),
1276            tc("Write"),
1277            tr("ok"),
1278            tc("Write"),
1279            tr("ok"),
1280            tc("Edit"),
1281            tr("ok"),
1282        ]);
1283        let narrow = extract_tool_signals_with_window(&request, 3);
1284        assert_eq!(narrow.recent_write_count, 2);
1285        assert_eq!(narrow.recent_edit_count, 1);
1286
1287        let wide = extract_tool_signals_with_window(&request, 6);
1288        assert_eq!(wide.recent_write_count, 5);
1289        assert_eq!(wide.recent_edit_count, 1);
1290    }
1291
1292    #[test]
1293    fn compaction_marker_sets_compacted() {
1294        // The compaction summary is a user message carrying Claude Code's preamble.
1295        let request = with_messages(vec![
1296            Message::text(
1297                Role::User,
1298                "This session is being continued from a previous conversation that ran out of context.",
1299            ),
1300            bash("ls"),
1301        ]);
1302        assert!(ToolSignals::from_request(&request, None).compacted);
1303    }
1304
1305    #[test]
1306    fn no_compaction_marker_stays_uncompacted() {
1307        let request = with_messages(vec![
1308            Message::text(Role::User, "Write a script that parses the log file."),
1309            bash("ls"),
1310        ]);
1311        assert!(!ToolSignals::from_request(&request, None).compacted);
1312    }
1313
1314    #[test]
1315    fn bash_heredoc_counts_as_write() {
1316        // Claude Code's pattern on TB 2.0 — write a scratch file via heredoc.
1317        let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
1318        let sig = ToolSignals::from_request(&request, None);
1319        assert_eq!(
1320            sig.write_count, 1,
1321            "Bash heredoc should bucket into write_count"
1322        );
1323        assert_eq!(sig.edit_count, 0);
1324    }
1325
1326    #[test]
1327    fn bash_sed_inplace_counts_as_edit() {
1328        let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
1329        let sig = ToolSignals::from_request(&request, None);
1330        assert_eq!(
1331            sig.edit_count, 1,
1332            "Bash sed -i should bucket into edit_count"
1333        );
1334        assert_eq!(sig.write_count, 0);
1335    }
1336
1337    #[test]
1338    fn bash_non_mutating_does_not_count() {
1339        // ls, cat, grep — should not increment either counter.
1340        let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
1341        let sig = ToolSignals::from_request(&request, None);
1342        assert_eq!(sig.write_count, 0);
1343        assert_eq!(sig.edit_count, 0);
1344    }
1345
1346    #[test]
1347    fn tests_passed_detects_pytest_with_failure_block() {
1348        // Mixed pytest run: 2 failed + 5 passed → NOT considered tests_passed.
1349        assert!(!detect_tests_passed(
1350            &["2 failed, 5 passed in 0.56s".to_string()],
1351            DEFAULT_RECENT_WINDOW
1352        ));
1353    }
1354
1355    #[test]
1356    fn tests_passed_accepts_cargo_clean_summary() {
1357        // Cargo's clean-run summary contains "0 failed" — must not trip the
1358        // failure list (regression: previously substring-matched "failed").
1359        assert!(detect_tests_passed(
1360            &["running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string()],
1361            DEFAULT_RECENT_WINDOW
1362        ));
1363    }
1364
1365    #[test]
1366    fn tests_passed_rejects_cargo_real_failure() {
1367        // Cargo's actual-failure summary: nonzero count before "failed".
1368        assert!(!detect_tests_passed(
1369            &["running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string()],
1370            DEFAULT_RECENT_WINDOW
1371        ));
1372    }
1373
1374    #[test]
1375    fn tests_passed_accepts_go_clean_summary() {
1376        // Go test's clean-run "0 errors" must not trip (regression).
1377        assert!(detect_tests_passed(
1378            &["ok  github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string()],
1379            DEFAULT_RECENT_WINDOW
1380        ));
1381    }
1382
1383    #[test]
1384    fn tests_passed_accepts_pytest_zero_errors() {
1385        // Pytest long-form: "0 errors in 0.3s" on a clean run.
1386        assert!(detect_tests_passed(
1387            &["5 passed, 0 errors in 0.30s".to_string()],
1388            DEFAULT_RECENT_WINDOW
1389        ));
1390    }
1391
1392    #[test]
1393    fn tests_passed_detects_diy_checkmark() {
1394        assert!(detect_tests_passed(
1395            &["✓ all checks passed".to_string()],
1396            DEFAULT_RECENT_WINDOW
1397        ));
1398    }
1399
1400    #[test]
1401    fn anthropic_bash_heredoc_extracts_command() {
1402        // Anthropic format: tool_use.input is an object, not a JSON string.
1403        let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
1404        let sig = ToolSignals::from_request(&request, None);
1405        assert_eq!(
1406            sig.write_count, 1,
1407            "Anthropic Bash heredoc must also be detected"
1408        );
1409    }
1410
1411    #[test]
1412    fn recent_window_falls_back_to_full_history_when_short() {
1413        let request = with_messages(vec![tc("Write")]);
1414        let sig = ToolSignals::from_request(&request, None);
1415        assert_eq!(sig.recent_write_count, 1);
1416        assert_eq!(sig.recent_edit_count, 0);
1417    }
1418
1419    #[test]
1420    fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
1421        let request = with_messages(vec![tr("output ok"), tr("another ok")]);
1422        let sig = ToolSignals::from_request(&request, None);
1423        assert_eq!(sig.severity, 0.0);
1424        assert_eq!(sig.no_error_streak, 2);
1425    }
1426
1427    // ─── asymmetric-signal extensions ────────────────────────────────────
1428
1429    #[test]
1430    fn todowrite_classifies_as_plan() {
1431        assert_eq!(classify_tool_call("TodoWrite", None), ToolCategory::Plan);
1432        assert_eq!(classify_tool_call("todo_write", None), ToolCategory::Plan);
1433    }
1434
1435    #[test]
1436    fn codex_update_plan_classifies_as_plan() {
1437        assert_eq!(classify_tool_call("update_plan", None), ToolCategory::Plan);
1438    }
1439
1440    #[test]
1441    fn codex_shell_command_runs_bash_pattern_match() {
1442        // shell_command + heredoc -> Write.
1443        assert_eq!(
1444            classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
1445            ToolCategory::Write,
1446        );
1447        // shell_command + read-like inspection -> Read.
1448        assert_eq!(
1449            classify_tool_call("shell_command", Some("ls /app")),
1450            ToolCategory::Read,
1451        );
1452        // shell_command without matching patterns -> Other.
1453        assert_eq!(
1454            classify_tool_call("shell_command", Some("./run_tests.sh")),
1455            ToolCategory::Other,
1456        );
1457    }
1458
1459    #[test]
1460    fn read_tool_classifies_as_read() {
1461        assert_eq!(classify_tool_call("Read", None), ToolCategory::Read);
1462        assert_eq!(classify_tool_call("View", None), ToolCategory::Read);
1463    }
1464
1465    #[test]
1466    fn hermes_tool_names_classify() {
1467        // Hermes (NousResearch) file tools route by name.
1468        assert_eq!(classify_tool_call("write_file", None), ToolCategory::Write);
1469        assert_eq!(classify_tool_call("patch", None), ToolCategory::Edit);
1470        assert_eq!(classify_tool_call("read_file", None), ToolCategory::Read);
1471        assert_eq!(classify_tool_call("search_files", None), ToolCategory::Read);
1472        // Hermes runs shell through `terminal`, which carries a `command` arg,
1473        // so its intent comes from the Bash-pattern match like codex's shell_command.
1474        assert_eq!(
1475            classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
1476            ToolCategory::Edit,
1477        );
1478        assert_eq!(
1479            classify_tool_call("terminal", Some("grep foo /app")),
1480            ToolCategory::Read,
1481        );
1482        assert_eq!(
1483            classify_tool_call("terminal", Some("./run_tests.sh")),
1484            ToolCategory::Other,
1485        );
1486    }
1487
1488    #[test]
1489    fn bash_read_patterns_classify_as_read() {
1490        let cases = [
1491            "cat /etc/passwd",
1492            "grep foo bar.txt",
1493            "ls /app",
1494            "find . -name '*.py'",
1495        ];
1496        for cmd in cases {
1497            assert_eq!(
1498                classify_tool_call("Bash", Some(cmd)),
1499                ToolCategory::Read,
1500                "expected Read for {cmd}"
1501            );
1502        }
1503    }
1504
1505    #[test]
1506    fn codex_inspection_commands_classify_as_read() {
1507        let cases = [
1508            "sed -n '1,80p' src/lib.rs",
1509            "rg -n 'needle' src",
1510            "nl -ba src/lib.rs",
1511            "cat package.json",
1512            "jq '.scripts' package.json",
1513            "git status --short",
1514            "git log --oneline -5",
1515            "git show HEAD:src/lib.rs",
1516            "git branch --show-current",
1517            "git remote -v",
1518            "git config --get remote.origin.url",
1519        ];
1520        for command in cases {
1521            assert_eq!(
1522                classify_tool_call("exec_command", Some(command)),
1523                ToolCategory::Read,
1524                "expected Read for {command}"
1525            );
1526        }
1527    }
1528
1529    #[test]
1530    fn codex_shell_mutations_classify_as_production() {
1531        let writes = [
1532            "cp source.rs destination.rs",
1533            "mkdir -p src/generated",
1534            "touch src/generated/mod.rs",
1535            "git show HEAD:file.rs > file.rs",
1536            "node <<'node'\nfs.writefilesync('file.js', text)\nnode",
1537        ];
1538        for command in writes {
1539            assert_eq!(
1540                classify_tool_call("exec_command", Some(command)),
1541                ToolCategory::Write,
1542                "expected Write for {command}"
1543            );
1544        }
1545
1546        let edits = [
1547            "mv old.rs new.rs",
1548            "rm obsolete.rs",
1549            "gofmt -w main.go",
1550            "cargo fmt",
1551            "ruff check --fix src",
1552            "perl -0pi -e 's/old/new/' src/lib.rs",
1553            "npx prettier --write src/lib.ts",
1554            "uv run ruff format src",
1555            "git apply fix.patch",
1556        ];
1557        for command in edits {
1558            assert_eq!(
1559                classify_tool_call("exec_command", Some(command)),
1560                ToolCategory::Edit,
1561                "expected Edit for {command}"
1562            );
1563        }
1564    }
1565
1566    #[test]
1567    fn formatter_checks_are_not_edits() {
1568        for command in [
1569            "cargo fmt --check",
1570            "ruff format --check src",
1571            "black --check src",
1572        ] {
1573            assert_ne!(
1574                classify_tool_call("exec_command", Some(command)),
1575                ToolCategory::Edit,
1576                "read-only formatter check must not be Edit: {command}"
1577            );
1578        }
1579    }
1580
1581    #[test]
1582    fn embedded_comparison_is_not_a_shell_write() {
1583        let command = "node <<'node'\nif (index > 0) console.log(index)\nnode";
1584        assert_eq!(
1585            classify_tool_call("exec_command", Some(command)),
1586            ToolCategory::Other
1587        );
1588    }
1589
1590    #[test]
1591    fn bash_write_precedence_over_read() {
1592        // `cat /file > out` contains both `cat /` (read) and ` > ` (write);
1593        // write redirection must win.
1594        assert_eq!(
1595            classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
1596            ToolCategory::Write,
1597        );
1598    }
1599
1600    #[test]
1601    fn pure_bash_streak_counts_trailing_other() {
1602        // 5 trailing non-classified Bash calls → streak == 5.
1603        let request = with_messages(vec![
1604            bash("make"),
1605            tr("ok"),
1606            bash("./configure"),
1607            tr("ok"),
1608            bash("make install"),
1609            tr("ok"),
1610            bash("./run.sh"),
1611            tr("ok"),
1612            bash("./test"),
1613            tr("ok"),
1614        ]);
1615        let sig = ToolSignals::from_request(&request, None);
1616        assert_eq!(sig.pure_bash_streak, 5);
1617        assert_eq!(sig.write_count, 0);
1618        assert_eq!(sig.read_count, 0);
1619    }
1620
1621    #[test]
1622    fn pure_bash_streak_resets_on_write() {
1623        let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
1624        let sig = ToolSignals::from_request(&request, None);
1625        assert_eq!(sig.pure_bash_streak, 0);
1626        assert_eq!(sig.write_count, 1);
1627    }
1628
1629    #[test]
1630    fn recent_window_tracks_todowrite_and_read() {
1631        // Final 3 tool calls: TodoWrite, Read, TodoWrite.
1632        let request = with_messages(vec![
1633            bash("make"),
1634            tr("ok"),
1635            tc("TodoWrite"),
1636            tr("ok"),
1637            tc("Read"),
1638            tr("ok"),
1639            tc("TodoWrite"),
1640            tr("ok"),
1641        ]);
1642        let sig = ToolSignals::from_request(&request, None);
1643        assert_eq!(sig.todowrite_count, 2);
1644        assert_eq!(sig.recent_todowrite_count, 2);
1645        assert_eq!(sig.read_count, 1);
1646        assert_eq!(sig.recent_read_count, 1);
1647    }
1648}