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`].
9//!
10//! All logic is pure and deterministic — no I/O, no shared state.
11
12#![allow(dead_code)]
13
14use async_trait::async_trait;
15use serde_json::Value;
16use switchyard_protocol::{ContentBlock, Request};
17
18use crate::Result;
19
20use crate::core::processor::{Event, Processor};
21use crate::core::state::State;
22
23// ─── severity constants ───────────────────────────────────────────────────────
24
25const SOFT: f32 = 0.3;
26const HARD: f32 = 0.7;
27const CRITICAL: f32 = 1.0;
28
29// ─── pattern table ────────────────────────────────────────────────────────────
30
31/// (name, severity, lower-cased substrings — any hit fires the pattern)
32static ERROR_PATTERNS: &[(&str, f32, &[&str])] = &[
33    (
34        "oom",
35        CRITICAL,
36        &["out of memory", "memoryerror", "cannot allocate memory"],
37    ),
38    (
39        "connection_refused",
40        CRITICAL,
41        &[
42            "connection refused",
43            "connectionrefusederror",
44            "econnrefused",
45        ],
46    ),
47    ("traceback", HARD, &["traceback (most recent call last)"]),
48    (
49        "import_error",
50        HARD,
51        &["modulenotfounderror:", "importerror:", "no module named "],
52    ),
53    (
54        "cmd_not_found",
55        HARD,
56        &["command not found", "not found\n", "/usr/bin/env: "],
57    ),
58    ("assertion", HARD, &["assertionerror"]),
59    ("value_error", HARD, &["valueerror:"]),
60    ("syntax_error", HARD, &["syntaxerror:"]),
61    (
62        "timeout",
63        HARD,
64        &[
65            "timed out",
66            "timeouterror",
67            "timeout expired",
68            "deadline exceeded",
69        ],
70    ),
71    (
72        "no_such_file",
73        HARD,
74        &[
75            "filenotfounderror:",
76            "no such file or directory",
77            // Claude Code Read-tool miss. Anchored as "file does not exist" (not a
78            // bare "does not exist", which fires on `ls` output and prose) — trace-
79            // mined across 1006 local trajectories at 22 true / 2 false positives.
80            "file does not exist",
81        ],
82    ),
83    // SOFT: plain non-zero exit without a recognisable exception traceback.
84    (
85        "exit_nonzero",
86        SOFT,
87        &[
88            "exit code 1",
89            "exit code 2",
90            "exit status 1",
91            "returned non-zero",
92            "exited with code",
93        ],
94    ),
95];
96
97static EDIT_TOOL_NAMES: &[&str] = &[
98    "edit",
99    "multiedit",
100    "notebookedit",
101    "str_replace",
102    "str_replace_based_edit_tool",
103    "apply_patch", // codex's edit tool
104    "text_editor",
105    "patch", // hermes's str_replace-style edit tool
106];
107
108static WRITE_TOOL_NAMES: &[&str] = &["write", "create_file", "new_file", "write_file"];
109
110// Bash subcommand patterns. Lowercased; callers must lowercase the command
111// before matching. Bucketed into write_count / edit_count alongside the
112// dedicated `Write` / `Edit` tools.
113static BASH_WRITE_PATTERNS: &[&str] = &[
114    "cat >",
115    "cat >>",
116    "echo >",
117    "echo >>",
118    "tee ",
119    "printf >",
120    "printf >>",
121    "> /",
122    ">> /",
123    "<< 'eof'",
124    "<<eof",
125    "<<'eof'",
126    "<< eof",
127    "write_text(",
128    "writelines(",
129    ".write(",
130];
131
132static BASH_EDIT_PATTERNS: &[&str] = &[
133    "sed -i",
134    "sed --in-place",
135    "awk -i inplace",
136    "awk 'inplace=1'",
137    "patch ",
138    "patch -p",
139    "perl -i",
140    "perl -p -i",
141    "perl -pi",
142];
143
144// Read-like Bash inspections. Match only when none of the write/edit patterns
145// fire (redirection / in-place edit trumps the read intent of the command).
146static BASH_READ_PATTERNS: &[&str] = &[
147    "cat /", "cat ./", "cat ../", "grep ", "ls ", "ls -", "find ", "head ", "tail ", "wc ",
148    "diff ", "which ", "ps ", "df ", "du ", "stat ", "file ", "less ", "more ",
149];
150
151static READ_TOOL_NAMES: &[&str] = &["read", "view", "read_file", "search_files"];
152
153// Planning / scratchpad tool calls — investigative (non-producing) activity.
154// `update_plan` is codex's equivalent of `todowrite`.
155static PLAN_TOOL_NAMES: &[&str] = &["todowrite", "todo_write", "todo", "update_plan"];
156
157// Tool names that route through Bash-command pattern matching. `bash` is
158// claude-code's name; `shell_command` is codex's; `shell` / `local_shell_call`
159// are seen on some OpenAI-derived harnesses; `terminal` is hermes's (it carries
160// a `command` arg like the others, so its intent comes from the pattern match).
161static BASH_TOOL_NAMES: &[&str] = &[
162    "bash",
163    "shell_command",
164    "shell",
165    "local_shell_call",
166    "terminal",
167    "exec_command", // codex
168];
169
170// Prefer false negatives: tests_passed routes the picker to EFFICIENT, so a false
171// positive would drop tier on an unfinished task.
172static TEST_PASS_PHRASES: &[&str] = &[
173    " passed",
174    "passed in",
175    "tests passed",
176    "all tests passed",
177    "test ok",
178    "test result: ok",
179    "passed.\n",
180    "tests pass",
181    "\nok ", // go test; newline-anchored to avoid "...lookup..." mid-text
182    "✓ ",
183];
184
185// Literal failure phrases that cannot appear inside a clean run. Substring
186// matched as-is. Patterns that pair with a count (e.g. "failed", "errors")
187// are handled separately by `has_nonzero_failure_count` so "0 failed" /
188// "0 errors" do not trigger a false negative.
189static TEST_FAILURE_LITERAL: &[&str] = &["✗ ", "fatal:", "assertionerror", "error:"];
190
191// Count-prefixed failure keywords. Trip only when a nonzero integer precedes
192// the keyword (modulo whitespace), so cargo's "0 failed" and go's
193// "0 errors" summaries on a clean run are not misread as failures.
194static NUMERIC_FAILURE_KEYWORDS: &[&str] = &["failed", "failure", "failures", "errors", "error"];
195
196/// Default sliding-window size for `recent_*` counts and windowed severity.
197///
198/// A short horizon captures "what is the agent doing right now" while keeping
199/// signals sticky — an error or stall persists a few recovery turns instead of
200/// flickering off the moment one clean result lands. Override per request by
201/// passing a window to [`ToolSignals::from_request`].
202pub const DEFAULT_RECENT_WINDOW: usize = 3;
203
204// ─── output type ─────────────────────────────────────────────────────────────
205
206/// Tool-execution signals extracted from a normalized [`Request`].
207///
208/// A request-side processor stores these signals in [`State`](crate::State) for
209/// [`crate::StageRouter`] and its classifier to consume.
210#[derive(Clone, Debug, Default)]
211pub struct ToolSignals {
212    /// Max severity across the recent window (last `recent_window` tool results):
213    /// `0.0` clean · `0.3` soft (exit_nonzero) · `0.7` hard · `1.0` critical.
214    /// Windowed so an error persists through the recovery turns instead of clearing
215    /// the instant the next result is clean.
216    pub severity: f32,
217    /// Consecutive clean tool results back from the most recent. `0` if the last failed.
218    pub no_error_streak: u32,
219    /// Total edit-style tool calls in the request.
220    pub edit_count: u32,
221    /// Total write-style tool calls in the request.
222    pub write_count: u32,
223    /// Read-type calls (Read tool + read-like Bash). Used by the build-pit gate.
224    pub read_count: u32,
225    /// TodoWrite / planning tool calls. Investigative (non-producing) activity —
226    /// recent todowrites distinguish `exploring` from `spinning` in the scorer.
227    pub todowrite_count: u32,
228    /// Edit-type calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
229    pub recent_edit_count: u32,
230    /// Write-type calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
231    pub recent_write_count: u32,
232    /// Read-type calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
233    pub recent_read_count: u32,
234    /// TodoWrite calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
235    pub recent_todowrite_count: u32,
236    /// Consecutive trailing tool calls in the `Other` category (no Write/Edit/Read/
237    /// Plan match). Surfaced in the classifier state summary; not scored directly.
238    pub pure_bash_streak: u32,
239    /// At least one of the last three tool results matched a test-pass pattern.
240    pub tests_passed: bool,
241    /// Message-count proxy for turn depth. Wire-format dependent (Anthropic batches
242    /// tool results into fewer messages than OpenAI-chat), so gates keyed on it are
243    /// approximate across request origins.
244    pub turn_depth: u32,
245    /// The request carries a context-compaction summary (the agent's context was
246    /// summarised after overflowing). Compaction resets the router's accumulated
247    /// signals, so a task that was on the strong tier de-escalates back to weak — the
248    /// picker uses this to force + hold the strong tier. Self-latching: the summary
249    /// stays in the context prefix on every subsequent turn.
250    pub compacted: bool,
251}
252
253impl ToolSignals {
254    /// Extracts tool and progress signals from `request`.
255    ///
256    /// `window_size` limits recent counters to the newest tool results. `None`
257    /// uses [`DEFAULT_RECENT_WINDOW`].
258    pub fn from_request(request: &Request, window_size: Option<usize>) -> Self {
259        extract_tool_signals_with_window(request, window_size.unwrap_or(DEFAULT_RECENT_WINDOW))
260    }
261}
262
263// `command` is the lowercased Bash command line; None for non-Bash tools.
264#[derive(Debug, Clone)]
265struct ObservedToolCall {
266    name: String,
267    command: Option<String>,
268}
269
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271enum ToolCategory {
272    Write,
273    Edit,
274    Read,
275    Plan,
276    Other,
277}
278
279/// Request-side processor that extracts tool-result signals from each request
280/// and stores them on the request `State` for downstream routing.
281#[derive(Debug, Clone)]
282pub struct ToolSignalProcessor {
283    /// Number of trailing tool results the `recent_*` counts and windowed
284    /// severity are computed over.
285    pub recent_window: usize,
286}
287
288impl Default for ToolSignalProcessor {
289    fn default() -> Self {
290        Self {
291            recent_window: DEFAULT_RECENT_WINDOW,
292        }
293    }
294}
295
296#[async_trait]
297impl Processor<State> for ToolSignalProcessor {
298    async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
299        if let Event::Request { request: req, .. } = event {
300            let tool_signal = ToolSignals::from_request(req, Some(self.recent_window));
301            state.tool_signals = Some(tool_signal);
302        }
303        Ok(())
304    }
305}
306
307fn classify_tool_call(name: &str, command: Option<&str>) -> ToolCategory {
308    let lower = name.to_lowercase();
309    if WRITE_TOOL_NAMES.contains(&lower.as_str()) {
310        return ToolCategory::Write;
311    }
312    if EDIT_TOOL_NAMES.contains(&lower.as_str()) {
313        return ToolCategory::Edit;
314    }
315    if READ_TOOL_NAMES.contains(&lower.as_str()) {
316        return ToolCategory::Read;
317    }
318    if PLAN_TOOL_NAMES.contains(&lower.as_str()) {
319        return ToolCategory::Plan;
320    }
321    if BASH_TOOL_NAMES.contains(&lower.as_str())
322        && let Some(cmd) = command
323    {
324        // Write/edit redirection trumps read-like operands.
325        if BASH_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
326            return ToolCategory::Write;
327        }
328        if BASH_EDIT_PATTERNS.iter().any(|p| cmd.contains(p)) {
329            return ToolCategory::Edit;
330        }
331        if BASH_READ_PATTERNS.iter().any(|p| cmd.contains(p)) {
332            return ToolCategory::Read;
333        }
334    }
335    ToolCategory::Other
336}
337
338// ─── extraction entry point ───────────────────────────────────────────────────
339
340/// Extract all tool-execution signals from a normalized [`Request`].
341///
342/// Returns [`ToolSignals::default()`] when the message history contains no tool
343/// activity, so callers can always inspect the signal fields.
344fn extract_tool_signals_with_window(request: &Request, recent_window: usize) -> ToolSignals {
345    // Read the decoded conversation, not the raw body: every inbound format lands
346    // in the same shape here, so the signals do not depend on knowing which one it
347    // arrived as.
348    let messages = &request.llm_request.messages;
349    let mut tool_texts: Vec<String> = Vec::new();
350    let mut tool_calls: Vec<ObservedToolCall> = Vec::new();
351    let mut compacted = false;
352
353    for message in messages {
354        for block in &message.content {
355            match block {
356                ContentBlock::ToolCall(call) => {
357                    tool_calls.push(ObservedToolCall {
358                        name: call.name.clone(),
359                        command: command_of(&call.arguments),
360                    });
361                }
362                ContentBlock::ToolResult(result) => {
363                    let text = result
364                        .content
365                        .iter()
366                        .filter_map(text_of)
367                        .collect::<Vec<_>>()
368                        .join("\n");
369                    if !text.is_empty() {
370                        tool_texts.push(text);
371                    }
372                }
373                // Compaction is detected anywhere in the conversation: the summary
374                // stays in the prefix on every later turn, so this self-latches
375                // once it fires.
376                ContentBlock::Text { text } => {
377                    compacted |= text.to_lowercase().contains(COMPACTION_MARKER);
378                }
379                _ => {}
380            }
381        }
382    }
383
384    let mut signal = build_signal(tool_texts, tool_calls, messages.len() as u32, recent_window);
385    signal.compacted = compacted;
386    signal
387}
388
389/// Distinctive preamble Claude Code injects as a user message when it compacts an
390/// overflowed context. Matched case-insensitively; normal task text never contains it.
391const COMPACTION_MARKER: &str = "session is being continued";
392
393/// The shell command a tool call carries, when it has one. Harnesses name the
394/// field `command`; anything else is a tool whose category comes from its name.
395fn command_of(arguments: &Value) -> Option<String> {
396    let decoded = arguments
397        .as_str()
398        .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
399    let object = decoded.as_ref().unwrap_or(arguments);
400
401    ["command", "cmd", "input"]
402        .iter()
403        .filter_map(|key| object.get(*key))
404        .find_map(command_text)
405}
406
407/// A command field as lowercase text, from a string or an argv array.
408fn command_text(value: &Value) -> Option<String> {
409    match value {
410        Value::String(text) => Some(text.to_lowercase()),
411        Value::Array(parts) => {
412            let joined = parts
413                .iter()
414                .filter_map(Value::as_str)
415                .collect::<Vec<_>>()
416                .join(" ");
417            (!joined.is_empty()).then(|| joined.to_lowercase())
418        }
419        _ => None,
420    }
421}
422
423/// Text carried by a content block, ignoring the non-textual kinds.
424fn text_of(block: &ContentBlock) -> Option<&str> {
425    match block {
426        ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
427        _ => None,
428    }
429}
430
431/// Whether a tool result reports a status that rules out failure: a zero exit, or
432/// a command still running. A successful `grep` prints whatever the file holds,
433/// error words included, and a command that has not finished has not failed yet.
434fn reports_no_failure(text: &str) -> bool {
435    let lower = text.to_lowercase();
436    if lower.contains("process running with session id") {
437        return true;
438    }
439    let mut saw_zero = false;
440    for marker in ["exited with code ", "exit code: "] {
441        for tail in lower.split(marker).skip(1) {
442            let code: String = tail.chars().take_while(char::is_ascii_digit).collect();
443            match code.as_str() {
444                "" => {}
445                "0" => saw_zero = true,
446                _ => return false,
447            }
448        }
449    }
450    saw_zero
451}
452
453fn build_signal(
454    tool_texts: Vec<String>,
455    tool_calls: Vec<ObservedToolCall>,
456    turn_depth: u32,
457    recent_window: usize,
458) -> ToolSignals {
459    // Windowed severity: take the MAX severity across the last `recent_window` tool
460    // results rather than only the last one. An error's severity then persists for
461    // the recent window and decays out of it — parallel to the windowed `recent_*`
462    // counts — so a fix written a couple of turns after an error still routes on the
463    // error signal instead of the router flapping straight back to the weak tier.
464    let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
465    let mut severity = 0.0f32;
466    for text in &tool_texts[sev_start..] {
467        if reports_no_failure(text) {
468            continue;
469        }
470        let (sev, _patterns) = classify_text(text);
471        if sev > severity {
472            severity = sev;
473        }
474    }
475
476    let no_error_streak = compute_no_error_streak(&tool_texts);
477
478    // Single pass: cumulative + sliding-window counters together. Also tracks
479    // the trailing pure-bash streak (consecutive `Other`-category calls back
480    // from the end) — the build-pit proxy.
481    let recent_start = tool_calls.len().saturating_sub(recent_window);
482    let mut write_count = 0u32;
483    let mut edit_count = 0u32;
484    let mut read_count = 0u32;
485    let mut todowrite_count = 0u32;
486    let mut recent_write_count = 0u32;
487    let mut recent_edit_count = 0u32;
488    let mut recent_read_count = 0u32;
489    let mut recent_todowrite_count = 0u32;
490    let mut pure_bash_streak = 0u32;
491    let mut streak_open = true;
492    for (i, tc) in tool_calls.iter().enumerate().rev() {
493        let cat = classify_tool_call(&tc.name, tc.command.as_deref());
494        if streak_open {
495            if matches!(cat, ToolCategory::Other) {
496                pure_bash_streak += 1;
497            } else {
498                streak_open = false;
499            }
500        }
501        match cat {
502            ToolCategory::Write => {
503                write_count += 1;
504                if i >= recent_start {
505                    recent_write_count += 1;
506                }
507            }
508            ToolCategory::Edit => {
509                edit_count += 1;
510                if i >= recent_start {
511                    recent_edit_count += 1;
512                }
513            }
514            ToolCategory::Read => {
515                read_count += 1;
516                if i >= recent_start {
517                    recent_read_count += 1;
518                }
519            }
520            ToolCategory::Plan => {
521                todowrite_count += 1;
522                if i >= recent_start {
523                    recent_todowrite_count += 1;
524                }
525            }
526            ToolCategory::Other => {}
527        }
528    }
529
530    let tests_passed = detect_tests_passed(&tool_texts, recent_window);
531
532    ToolSignals {
533        severity,
534        no_error_streak,
535        edit_count,
536        write_count,
537        read_count,
538        todowrite_count,
539        recent_edit_count,
540        recent_write_count,
541        recent_read_count,
542        recent_todowrite_count,
543        pure_bash_streak,
544        tests_passed,
545        turn_depth,
546        // Set by extract_tool_signals_with_window after the format-specific extract,
547        // which scans all message contents for the compaction marker.
548        compacted: false,
549    }
550}
551
552// ─── pure helpers ─────────────────────────────────────────────────────────────
553
554/// Normalise a JSON tool-result content value to a plain string.
555fn content_to_text(content: Option<&Value>) -> Option<String> {
556    match content? {
557        Value::String(s) => Some(s.clone()),
558        Value::Array(blocks) => {
559            let parts: Vec<&str> = blocks
560                .iter()
561                .filter_map(|b| {
562                    b.as_object()
563                        .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
564                        .and_then(|o| o.get("text"))
565                        .and_then(Value::as_str)
566                })
567                .collect();
568            if parts.is_empty() {
569                None
570            } else {
571                Some(parts.join("\n"))
572            }
573        }
574        _ => None,
575    }
576}
577
578/// Match `text` against the error pattern table.
579///
580/// Returns `(max_severity, matched_pattern_names)`.
581pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
582    let lower = text.to_lowercase();
583    let mut patterns = Vec::new();
584    let mut severity: f32 = 0.0;
585    for (name, sev, substrings) in ERROR_PATTERNS {
586        if substrings.iter().any(|sub| lower.contains(sub)) {
587            patterns.push(name.to_string());
588            severity = severity.max(*sev);
589        }
590    }
591    (severity, patterns)
592}
593
594fn compute_no_error_streak(tool_texts: &[String]) -> u32 {
595    let mut streak = 0u32;
596    for text in tool_texts.iter().rev() {
597        let (sev, _) = classify_text(text);
598        if sev > 0.0 {
599            break;
600        }
601        streak += 1;
602    }
603    streak
604}
605
606/// Whether a tool result carries a failure marker. `error:` must not match the
607/// path `error::`, which passing tests print.
608fn contains_failure_literal(lower: &str) -> bool {
609    TEST_FAILURE_LITERAL.iter().any(|literal| {
610        let mut cursor = 0usize;
611        while let Some(rel) = lower[cursor..].find(literal) {
612            let end = cursor + rel + literal.len();
613            if !lower[end..].starts_with(':') {
614                return true;
615            }
616            cursor = end;
617        }
618        false
619    })
620}
621
622fn detect_tests_passed(tool_texts: &[String], recent_window: usize) -> bool {
623    let start = tool_texts.len().saturating_sub(recent_window.max(1));
624    tool_texts[start..].iter().any(|text| {
625        let lower = text.to_lowercase();
626        TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
627            && !contains_failure_literal(&lower)
628            && !has_nonzero_failure_count(&lower)
629    })
630}
631
632// True iff `lower` contains a `NUMERIC_FAILURE_KEYWORDS` token preceded
633// (modulo whitespace) by a nonzero integer. The "modulo whitespace" lets
634// "1 failed", "1\nfailed", and "1  failed" all trip; the nonzero guard
635// keeps cargo's "0 failed" / go's "0 errors" / pytest's "0 errors in"
636// summaries from being misread as failures on a clean run.
637fn has_nonzero_failure_count(lower: &str) -> bool {
638    for kw in NUMERIC_FAILURE_KEYWORDS {
639        let mut cursor = 0usize;
640        while let Some(rel) = lower[cursor..].find(kw) {
641            let kw_start = cursor + rel;
642            let kw_end = kw_start + kw.len();
643            // Word boundary AFTER the keyword — "errors" mid-word (e.g.
644            // "errored") shouldn't count as a failure-count site.
645            let boundary_after = lower[kw_end..]
646                .chars()
647                .next()
648                .is_none_or(|c| !c.is_ascii_alphanumeric());
649            if boundary_after {
650                let prefix = &lower[..kw_start];
651                let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
652                let digits_rev: String = trimmed
653                    .chars()
654                    .rev()
655                    .take_while(|c| c.is_ascii_digit())
656                    .collect();
657                if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
658                    return true;
659                }
660            }
661            cursor = kw_start + kw.len();
662        }
663    }
664    false
665}
666
667// ─── tests ───────────────────────────────────────────────────────────────────
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use serde_json::json;
673    use switchyard_protocol::{ContentBlock, LlmRequest, Message, Role, ToolCall, ToolResult};
674
675    fn with_messages(messages: Vec<Message>) -> Request {
676        Request {
677            llm_request: LlmRequest {
678                messages,
679                ..LlmRequest::default()
680            },
681            raw_request: None,
682            metadata: None,
683        }
684    }
685
686    // assistant message with a single named tool call
687    fn tc(name: &str) -> Message {
688        Message {
689            role: Role::Assistant,
690            content: vec![ContentBlock::ToolCall(ToolCall {
691                id: String::new(),
692                name: name.to_string(),
693                arguments: json!({}),
694            })],
695        }
696    }
697
698    // assistant Bash message carrying `command`
699    fn bash(command: &str) -> Message {
700        Message {
701            role: Role::Assistant,
702            content: vec![ContentBlock::ToolCall(ToolCall {
703                id: String::new(),
704                name: "Bash".to_string(),
705                arguments: json!({"command": command}),
706            })],
707        }
708    }
709
710    // a tool result message (goes in a user-role message, as in Anthropic's normalised form)
711    fn tr(text: &str) -> Message {
712        Message {
713            role: Role::User,
714            content: vec![ContentBlock::ToolResult(ToolResult {
715                tool_call_id: String::new(),
716                content: vec![ContentBlock::Text {
717                    text: text.to_string(),
718                }],
719                is_error: None,
720            })],
721        }
722    }
723
724    #[test]
725    fn clean_text_has_zero_severity() {
726        let (sev, patterns) = classify_text("everything went fine");
727        assert_eq!(sev, 0.0);
728        assert!(patterns.is_empty());
729    }
730
731    #[test]
732    fn traceback_is_hard() {
733        let (sev, patterns) = classify_text("Traceback (most recent call last):\n  ValueError");
734        assert_eq!(sev, HARD);
735        assert!(patterns.contains(&"traceback".to_string()));
736    }
737
738    #[test]
739    fn oom_is_critical() {
740        let (sev, _) = classify_text("Out of memory: kill process 1234");
741        assert_eq!(sev, CRITICAL);
742    }
743
744    #[test]
745    fn severity_is_max_across_patterns() {
746        // exit_nonzero (SOFT) + traceback (HARD) → HARD.
747        let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
748        assert_eq!(sev, HARD);
749    }
750
751    #[test]
752    fn file_does_not_exist_is_hard() {
753        // Claude Code Read-tool miss. Trace-mined addition (22 true / 2 false positives).
754        let (sev, patterns) =
755            classify_text("Error: File does not exist. Note: current working directory is /app.");
756        assert_eq!(sev, HARD);
757        assert!(patterns.contains(&"no_such_file".to_string()));
758    }
759
760    #[test]
761    fn bare_does_not_exist_stays_clean() {
762        // Precision guard: only the anchored "file does not exist" fires, so a bare
763        // "does not exist" in prose or directory output must not trip a false error.
764        let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
765        assert_eq!(sev, 0.0);
766    }
767
768    #[test]
769    fn no_error_streak_all_clean() {
770        let texts = vec!["ok".to_string(), "all good".to_string()];
771        assert_eq!(compute_no_error_streak(&texts), 2);
772    }
773
774    #[test]
775    fn no_error_streak_stops_at_error() {
776        let texts = vec![
777            "Traceback (most recent call last):".to_string(),
778            "ok".to_string(),
779            "ok".to_string(),
780        ];
781        assert_eq!(compute_no_error_streak(&texts), 2);
782    }
783
784    #[test]
785    fn tests_passed_detects_pytest_output() {
786        assert!(detect_tests_passed(
787            &["====== 5 passed in 0.12s ======".to_string()],
788            DEFAULT_RECENT_WINDOW
789        ));
790    }
791
792    #[test]
793    fn tests_passed_ignores_partial_failures() {
794        assert!(!detect_tests_passed(
795            &["2 failed, 5 passed in 0.56s".to_string()],
796            DEFAULT_RECENT_WINDOW
797        ));
798    }
799
800    #[test]
801    fn severity_is_windowed_over_recent_results() {
802        // An error two results back, then two clean results.
803        let request = with_messages(vec![
804            tr("Traceback (most recent call last):\n  ValueError"),
805            tr("ok"),
806            tr("ok"),
807        ]);
808        // window covers the error → severity persists (max over the window)
809        assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
810        // window of 1 sees only the last (clean) result → severity has decayed out
811        assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
812    }
813
814    #[test]
815    fn extract_openai_chat_tool_results() {
816        let request = with_messages(vec![
817            Message::text(Role::User, "do something"),
818            tc("Edit"),
819            tr("Traceback (most recent call last):\n  ValueError"),
820        ]);
821        let sig = ToolSignals::from_request(&request, None);
822        assert_eq!(sig.severity, HARD);
823        assert_eq!(sig.edit_count, 1);
824        assert_eq!(sig.turn_depth, 3);
825    }
826
827    #[test]
828    fn extract_anthropic_tool_results() {
829        let request = with_messages(vec![tr("Traceback (most recent call last):\n  ValueError")]);
830        let sig = ToolSignals::from_request(&request, None);
831        assert_eq!(sig.severity, HARD);
832    }
833
834    #[test]
835    fn extract_responses_api_tool_results() {
836        let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
837        let sig = ToolSignals::from_request(&request, None);
838        assert_eq!(sig.severity, 0.0);
839        assert_eq!(sig.write_count, 1);
840    }
841
842    #[test]
843    fn recent_window_counts_only_last_default_window_tool_calls() {
844        // 5 writes + 1 edit at the end → the default window (3) should see
845        // the last 3 calls: 1 edit + 2 writes (not all 6 calls).
846        let request = with_messages(vec![
847            tc("Write"),
848            tr("ok"),
849            tc("Write"),
850            tr("ok"),
851            tc("Write"),
852            tr("ok"),
853            tc("Write"),
854            tr("ok"),
855            tc("Write"),
856            tr("ok"),
857            tc("Edit"),
858            tr("ok"),
859        ]);
860        let sig = ToolSignals::from_request(&request, None);
861        assert_eq!(sig.write_count, 5);
862        assert_eq!(sig.edit_count, 1);
863        assert_eq!(sig.recent_write_count, 2);
864        assert_eq!(sig.recent_edit_count, 1);
865    }
866
867    fn exec_command(arguments: Value) -> Message {
868        Message {
869            role: Role::Assistant,
870            content: vec![ContentBlock::ToolCall(ToolCall {
871                id: String::new(),
872                name: "exec_command".to_string(),
873                arguments,
874            })],
875        }
876    }
877
878    #[test]
879    fn exit_zero_is_not_an_error() {
880        let source = "static CRITICAL: &[&str] = &[\"out of memory\", \"connection refused\"];";
881        let request = with_messages(vec![
882            exec_command(json!({"cmd": "sed -n 1,40p tool_signals.rs"})),
883            tr(&format!("Process exited with code 0\nOutput: {source}")),
884        ]);
885        assert_eq!(ToolSignals::from_request(&request, None).severity, 0.0);
886    }
887
888    #[test]
889    fn streaming_chunk_is_not_a_failure() {
890        // a chunk from a command still running echoes the diff being written
891        let request = with_messages(vec![
892            exec_command(json!({"cmd": "python3 - <<'PY'"})),
893            tr(
894                "Process running with session ID 14028\nOutput:\n+ tr(\"Traceback (most recent call last)\"),",
895            ),
896        ]);
897        assert_eq!(ToolSignals::from_request(&request, None).severity, 0.0);
898    }
899
900    #[test]
901    fn rust_path_is_not_a_failure() {
902        let passing = "test error::tests::preserves_source ... ok\n\
903                       test result: ok. 268 passed; 0 failed; 0 ignored";
904        let request = with_messages(vec![
905            exec_command(json!({"cmd": "cargo test"})),
906            tr(passing),
907        ]);
908        assert!(ToolSignals::from_request(&request, None).tests_passed);
909    }
910
911    #[test]
912    fn exec_command_arg_shapes() {
913        for arguments in [
914            json!({"command": "sed -i s/a/b/ src/lib.rs"}),
915            json!({"cmd": "sed -i s/a/b/ src/lib.rs"}),
916            json!({"cmd": ["bash", "-lc", "sed -i s/a/b/ src/lib.rs"]}),
917            json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#),
918        ] {
919            let request = with_messages(vec![exec_command(arguments.clone()), tr("ok")]);
920            let signal = ToolSignals::from_request(&request, None);
921            assert_eq!(signal.recent_edit_count, 1, "edit count for {arguments}");
922        }
923    }
924
925    #[test]
926    fn heredoc_script_is_a_write() {
927        let request = with_messages(vec![
928            exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
929            tr("ok"),
930        ]);
931        assert_eq!(
932            ToolSignals::from_request(&request, None).recent_write_count,
933            1
934        );
935    }
936
937    #[test]
938    fn codex_apply_patch_counts_as_an_edit() {
939        let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
940        let sig = ToolSignals::from_request(&request, None);
941        assert_eq!(sig.edit_count, 1);
942        assert_eq!(sig.recent_edit_count, 1);
943    }
944
945    #[test]
946    fn recent_window_size_is_caller_overridable() {
947        // Same six tool calls (1 edit at the end, 5 writes before).
948        // With recent_window=3 → recent_writes=2, recent_edits=1.
949        // With recent_window=6 → recent_writes=5, recent_edits=1 (all calls).
950        let request = with_messages(vec![
951            tc("Write"),
952            tr("ok"),
953            tc("Write"),
954            tr("ok"),
955            tc("Write"),
956            tr("ok"),
957            tc("Write"),
958            tr("ok"),
959            tc("Write"),
960            tr("ok"),
961            tc("Edit"),
962            tr("ok"),
963        ]);
964        let narrow = extract_tool_signals_with_window(&request, 3);
965        assert_eq!(narrow.recent_write_count, 2);
966        assert_eq!(narrow.recent_edit_count, 1);
967
968        let wide = extract_tool_signals_with_window(&request, 6);
969        assert_eq!(wide.recent_write_count, 5);
970        assert_eq!(wide.recent_edit_count, 1);
971    }
972
973    #[test]
974    fn compaction_marker_sets_compacted() {
975        // The compaction summary is a user message carrying Claude Code's preamble.
976        let request = with_messages(vec![
977            Message::text(
978                Role::User,
979                "This session is being continued from a previous conversation that ran out of context.",
980            ),
981            bash("ls"),
982        ]);
983        assert!(ToolSignals::from_request(&request, None).compacted);
984    }
985
986    #[test]
987    fn no_compaction_marker_stays_uncompacted() {
988        let request = with_messages(vec![
989            Message::text(Role::User, "Write a script that parses the log file."),
990            bash("ls"),
991        ]);
992        assert!(!ToolSignals::from_request(&request, None).compacted);
993    }
994
995    #[test]
996    fn bash_heredoc_counts_as_write() {
997        // Claude Code's pattern on TB 2.0 — write a scratch file via heredoc.
998        let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
999        let sig = ToolSignals::from_request(&request, None);
1000        assert_eq!(
1001            sig.write_count, 1,
1002            "Bash heredoc should bucket into write_count"
1003        );
1004        assert_eq!(sig.edit_count, 0);
1005    }
1006
1007    #[test]
1008    fn bash_sed_inplace_counts_as_edit() {
1009        let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
1010        let sig = ToolSignals::from_request(&request, None);
1011        assert_eq!(
1012            sig.edit_count, 1,
1013            "Bash sed -i should bucket into edit_count"
1014        );
1015        assert_eq!(sig.write_count, 0);
1016    }
1017
1018    #[test]
1019    fn bash_non_mutating_does_not_count() {
1020        // ls, cat, grep — should not increment either counter.
1021        let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
1022        let sig = ToolSignals::from_request(&request, None);
1023        assert_eq!(sig.write_count, 0);
1024        assert_eq!(sig.edit_count, 0);
1025    }
1026
1027    #[test]
1028    fn tests_passed_detects_pytest_with_failure_block() {
1029        // Mixed pytest run: 2 failed + 5 passed → NOT considered tests_passed.
1030        assert!(!detect_tests_passed(
1031            &["2 failed, 5 passed in 0.56s".to_string()],
1032            DEFAULT_RECENT_WINDOW
1033        ));
1034    }
1035
1036    #[test]
1037    fn tests_passed_accepts_cargo_clean_summary() {
1038        // Cargo's clean-run summary contains "0 failed" — must not trip the
1039        // failure list (regression: previously substring-matched "failed").
1040        assert!(detect_tests_passed(
1041            &["running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string()],
1042            DEFAULT_RECENT_WINDOW
1043        ));
1044    }
1045
1046    #[test]
1047    fn tests_passed_rejects_cargo_real_failure() {
1048        // Cargo's actual-failure summary: nonzero count before "failed".
1049        assert!(!detect_tests_passed(
1050            &["running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string()],
1051            DEFAULT_RECENT_WINDOW
1052        ));
1053    }
1054
1055    #[test]
1056    fn tests_passed_accepts_go_clean_summary() {
1057        // Go test's clean-run "0 errors" must not trip (regression).
1058        assert!(detect_tests_passed(
1059            &["ok  github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string()],
1060            DEFAULT_RECENT_WINDOW
1061        ));
1062    }
1063
1064    #[test]
1065    fn tests_passed_accepts_pytest_zero_errors() {
1066        // Pytest long-form: "0 errors in 0.3s" on a clean run.
1067        assert!(detect_tests_passed(
1068            &["5 passed, 0 errors in 0.30s".to_string()],
1069            DEFAULT_RECENT_WINDOW
1070        ));
1071    }
1072
1073    #[test]
1074    fn tests_passed_detects_diy_checkmark() {
1075        assert!(detect_tests_passed(
1076            &["✓ all checks passed".to_string()],
1077            DEFAULT_RECENT_WINDOW
1078        ));
1079    }
1080
1081    #[test]
1082    fn anthropic_bash_heredoc_extracts_command() {
1083        // Anthropic format: tool_use.input is an object, not a JSON string.
1084        let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
1085        let sig = ToolSignals::from_request(&request, None);
1086        assert_eq!(
1087            sig.write_count, 1,
1088            "Anthropic Bash heredoc must also be detected"
1089        );
1090    }
1091
1092    #[test]
1093    fn recent_window_falls_back_to_full_history_when_short() {
1094        let request = with_messages(vec![tc("Write")]);
1095        let sig = ToolSignals::from_request(&request, None);
1096        assert_eq!(sig.recent_write_count, 1);
1097        assert_eq!(sig.recent_edit_count, 0);
1098    }
1099
1100    #[test]
1101    fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
1102        let request = with_messages(vec![tr("output ok"), tr("another ok")]);
1103        let sig = ToolSignals::from_request(&request, None);
1104        assert_eq!(sig.severity, 0.0);
1105        assert_eq!(sig.no_error_streak, 2);
1106    }
1107
1108    // ─── asymmetric-signal extensions ────────────────────────────────────
1109
1110    #[test]
1111    fn todowrite_classifies_as_plan() {
1112        assert_eq!(classify_tool_call("TodoWrite", None), ToolCategory::Plan);
1113        assert_eq!(classify_tool_call("todo_write", None), ToolCategory::Plan);
1114    }
1115
1116    #[test]
1117    fn codex_update_plan_classifies_as_plan() {
1118        assert_eq!(classify_tool_call("update_plan", None), ToolCategory::Plan);
1119    }
1120
1121    #[test]
1122    fn codex_shell_command_runs_bash_pattern_match() {
1123        // shell_command + heredoc -> Write.
1124        assert_eq!(
1125            classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
1126            ToolCategory::Write,
1127        );
1128        // shell_command + read-like inspection -> Read.
1129        assert_eq!(
1130            classify_tool_call("shell_command", Some("ls /app")),
1131            ToolCategory::Read,
1132        );
1133        // shell_command without matching patterns -> Other.
1134        assert_eq!(
1135            classify_tool_call("shell_command", Some("./run_tests.sh")),
1136            ToolCategory::Other,
1137        );
1138    }
1139
1140    #[test]
1141    fn read_tool_classifies_as_read() {
1142        assert_eq!(classify_tool_call("Read", None), ToolCategory::Read);
1143        assert_eq!(classify_tool_call("View", None), ToolCategory::Read);
1144    }
1145
1146    #[test]
1147    fn hermes_tool_names_classify() {
1148        // Hermes (NousResearch) file tools route by name.
1149        assert_eq!(classify_tool_call("write_file", None), ToolCategory::Write);
1150        assert_eq!(classify_tool_call("patch", None), ToolCategory::Edit);
1151        assert_eq!(classify_tool_call("read_file", None), ToolCategory::Read);
1152        assert_eq!(classify_tool_call("search_files", None), ToolCategory::Read);
1153        // Hermes runs shell through `terminal`, which carries a `command` arg,
1154        // so its intent comes from the Bash-pattern match like codex's shell_command.
1155        assert_eq!(
1156            classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
1157            ToolCategory::Edit,
1158        );
1159        assert_eq!(
1160            classify_tool_call("terminal", Some("grep foo /app")),
1161            ToolCategory::Read,
1162        );
1163        assert_eq!(
1164            classify_tool_call("terminal", Some("./run_tests.sh")),
1165            ToolCategory::Other,
1166        );
1167    }
1168
1169    #[test]
1170    fn bash_read_patterns_classify_as_read() {
1171        let cases = [
1172            "cat /etc/passwd",
1173            "grep foo bar.txt",
1174            "ls /app",
1175            "find . -name '*.py'",
1176        ];
1177        for cmd in cases {
1178            assert_eq!(
1179                classify_tool_call("Bash", Some(cmd)),
1180                ToolCategory::Read,
1181                "expected Read for {cmd}"
1182            );
1183        }
1184    }
1185
1186    #[test]
1187    fn bash_write_precedence_over_read() {
1188        // `cat /file > out` contains both `cat /` (read) and ` > ` (write);
1189        // write redirection must win.
1190        assert_eq!(
1191            classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
1192            ToolCategory::Write,
1193        );
1194    }
1195
1196    #[test]
1197    fn pure_bash_streak_counts_trailing_other() {
1198        // 5 trailing non-classified Bash calls → streak == 5.
1199        let request = with_messages(vec![
1200            bash("make"),
1201            tr("ok"),
1202            bash("./configure"),
1203            tr("ok"),
1204            bash("make install"),
1205            tr("ok"),
1206            bash("./run.sh"),
1207            tr("ok"),
1208            bash("./test"),
1209            tr("ok"),
1210        ]);
1211        let sig = ToolSignals::from_request(&request, None);
1212        assert_eq!(sig.pure_bash_streak, 5);
1213        assert_eq!(sig.write_count, 0);
1214        assert_eq!(sig.read_count, 0);
1215    }
1216
1217    #[test]
1218    fn pure_bash_streak_resets_on_write() {
1219        let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
1220        let sig = ToolSignals::from_request(&request, None);
1221        assert_eq!(sig.pure_bash_streak, 0);
1222        assert_eq!(sig.write_count, 1);
1223    }
1224
1225    #[test]
1226    fn recent_window_tracks_todowrite_and_read() {
1227        // Final 3 tool calls: TodoWrite, Read, TodoWrite.
1228        let request = with_messages(vec![
1229            bash("make"),
1230            tr("ok"),
1231            tc("TodoWrite"),
1232            tr("ok"),
1233            tc("Read"),
1234            tr("ok"),
1235            tc("TodoWrite"),
1236            tr("ok"),
1237        ]);
1238        let sig = ToolSignals::from_request(&request, None);
1239        assert_eq!(sig.todowrite_count, 2);
1240        assert_eq!(sig.recent_todowrite_count, 2);
1241        assert_eq!(sig.read_count, 1);
1242        assert_eq!(sig.recent_read_count, 1);
1243    }
1244}