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