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