Skip to main content

switchyard_libsy/algorithms/util/
tool_signals.rs

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