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