1#![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
23const SOFT: f32 = 0.3;
26const HARD: f32 = 0.7;
27const CRITICAL: f32 = 1.0;
28
29static 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 "file does not exist",
81 ],
82 ),
83 (
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", "text_editor",
105 "patch", ];
107
108static WRITE_TOOL_NAMES: &[&str] = &["write", "create_file", "new_file", "write_file"];
109
110static 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
141static 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
150static PLAN_TOOL_NAMES: &[&str] = &["todowrite", "todo_write", "todo", "update_plan"];
153
154static BASH_TOOL_NAMES: &[&str] = &[
159 "bash",
160 "shell_command",
161 "shell",
162 "local_shell_call",
163 "terminal",
164];
165
166static 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 ", "✓ ",
179];
180
181static TEST_FAILURE_LITERAL: &[&str] = &["✗ ", "fatal:", "assertionerror", "error:"];
186
187static NUMERIC_FAILURE_KEYWORDS: &[&str] = &["failed", "failure", "failures", "errors", "error"];
191
192pub const DEFAULT_RECENT_WINDOW: usize = 3;
199
200#[derive(Clone, Debug, Default)]
207pub struct ToolSignals {
208 pub severity: f32,
213 pub no_error_streak: u32,
215 pub edit_count: u32,
217 pub write_count: u32,
219 pub read_count: u32,
221 pub todowrite_count: u32,
224 pub recent_edit_count: u32,
226 pub recent_write_count: u32,
228 pub recent_read_count: u32,
230 pub recent_todowrite_count: u32,
232 pub pure_bash_streak: u32,
235 pub tests_passed: bool,
237 pub turn_depth: u32,
241 pub compacted: bool,
247}
248
249impl ToolSignals {
250 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#[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#[derive(Debug, Clone)]
278pub struct ToolSignalProcessor {
279 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 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
334fn extract_tool_signals_with_window(request: &Request, recent_window: usize) -> ToolSignals {
341 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 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
385const COMPACTION_MARKER: &str = "session is being continued";
388
389fn command_of(arguments: &Value) -> Option<String> {
392 arguments
393 .get("command")
394 .and_then(Value::as_str)
395 .map(str::to_lowercase)
396}
397
398fn 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 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 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 compacted: false,
499 }
500}
501
502fn 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
528pub(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
566fn 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 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#[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 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 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 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 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 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 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 let request = with_messages(vec![
738 tr("Traceback (most recent call last):\n ValueError"),
739 tr("ok"),
740 tr("ok"),
741 ]);
742 assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
744 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 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 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 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 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 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 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 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 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 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 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 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 #[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 assert_eq!(
989 classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
990 ToolCategory::Write,
991 );
992 assert_eq!(
994 classify_tool_call("shell_command", Some("ls /app")),
995 ToolCategory::Read,
996 );
997 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 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 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 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 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 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}