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 "text_editor",
104 "patch", ];
106
107static WRITE_TOOL_NAMES: &[&str] = &["write", "create_file", "new_file", "write_file"];
108
109static BASH_WRITE_PATTERNS: &[&str] = &[
113 "cat >",
114 "cat >>",
115 "echo >",
116 "echo >>",
117 "tee ",
118 "printf >",
119 "printf >>",
120 "> /",
121 ">> /",
122 "<< 'eof'",
123 "<<eof",
124 "<<'eof'",
125 "<< eof",
126];
127
128static BASH_EDIT_PATTERNS: &[&str] = &[
129 "sed -i",
130 "sed --in-place",
131 "awk -i inplace",
132 "awk 'inplace=1'",
133 "patch ",
134 "patch -p",
135 "perl -i",
136 "perl -p -i",
137 "perl -pi",
138];
139
140static BASH_READ_PATTERNS: &[&str] = &[
143 "cat /", "cat ./", "cat ../", "grep ", "ls ", "ls -", "find ", "head ", "tail ", "wc ",
144 "diff ", "which ", "ps ", "df ", "du ", "stat ", "file ", "less ", "more ",
145];
146
147static READ_TOOL_NAMES: &[&str] = &["read", "view", "read_file", "search_files"];
148
149static PLAN_TOOL_NAMES: &[&str] = &["todowrite", "todo_write", "todo", "update_plan"];
152
153static BASH_TOOL_NAMES: &[&str] = &[
158 "bash",
159 "shell_command",
160 "shell",
161 "local_shell_call",
162 "terminal",
163];
164
165static TEST_PASS_PHRASES: &[&str] = &[
168 " passed",
169 "passed in",
170 "tests passed",
171 "all tests passed",
172 "test ok",
173 "test result: ok",
174 "passed.\n",
175 "tests pass",
176 "\nok ", "✓ ",
178];
179
180static TEST_FAILURE_LITERAL: &[&str] = &["✗ ", "fatal:", "assertionerror", "error:"];
185
186static NUMERIC_FAILURE_KEYWORDS: &[&str] = &["failed", "failure", "failures", "errors", "error"];
190
191pub const DEFAULT_RECENT_WINDOW: usize = 3;
198
199#[derive(Clone, Debug, Default)]
206pub struct ToolSignals {
207 pub severity: f32,
212 pub no_error_streak: u32,
214 pub edit_count: u32,
216 pub write_count: u32,
218 pub read_count: u32,
220 pub todowrite_count: u32,
223 pub recent_edit_count: u32,
225 pub recent_write_count: u32,
227 pub recent_read_count: u32,
229 pub recent_todowrite_count: u32,
231 pub pure_bash_streak: u32,
234 pub tests_passed: bool,
236 pub turn_depth: u32,
240 pub compacted: bool,
246}
247
248impl ToolSignals {
249 pub fn from_request(request: &Request, window_size: Option<usize>) -> Self {
254 extract_tool_signals_with_window(request, window_size.unwrap_or(DEFAULT_RECENT_WINDOW))
255 }
256}
257
258#[derive(Debug, Clone)]
260struct ObservedToolCall {
261 name: String,
262 command: Option<String>,
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266enum ToolCategory {
267 Write,
268 Edit,
269 Read,
270 Plan,
271 Other,
272}
273
274#[derive(Debug, Clone)]
277pub struct ToolSignalProcessor {
278 pub recent_window: usize,
281}
282
283impl Default for ToolSignalProcessor {
284 fn default() -> Self {
285 Self {
286 recent_window: DEFAULT_RECENT_WINDOW,
287 }
288 }
289}
290
291#[async_trait]
292impl Processor<State> for ToolSignalProcessor {
293 async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
294 if let Event::Request(req) = event {
295 let tool_signal = ToolSignals::from_request(req, Some(self.recent_window));
296 state.tool_signals = Some(tool_signal);
297 }
298 Ok(())
299 }
300}
301
302fn classify_tool_call(name: &str, command: Option<&str>) -> ToolCategory {
303 let lower = name.to_lowercase();
304 if WRITE_TOOL_NAMES.contains(&lower.as_str()) {
305 return ToolCategory::Write;
306 }
307 if EDIT_TOOL_NAMES.contains(&lower.as_str()) {
308 return ToolCategory::Edit;
309 }
310 if READ_TOOL_NAMES.contains(&lower.as_str()) {
311 return ToolCategory::Read;
312 }
313 if PLAN_TOOL_NAMES.contains(&lower.as_str()) {
314 return ToolCategory::Plan;
315 }
316 if BASH_TOOL_NAMES.contains(&lower.as_str())
317 && let Some(cmd) = command
318 {
319 if BASH_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
321 return ToolCategory::Write;
322 }
323 if BASH_EDIT_PATTERNS.iter().any(|p| cmd.contains(p)) {
324 return ToolCategory::Edit;
325 }
326 if BASH_READ_PATTERNS.iter().any(|p| cmd.contains(p)) {
327 return ToolCategory::Read;
328 }
329 }
330 ToolCategory::Other
331}
332
333fn extract_tool_signals_with_window(request: &Request, recent_window: usize) -> ToolSignals {
340 let messages = &request.llm_request.messages;
344 let mut tool_texts: Vec<String> = Vec::new();
345 let mut tool_calls: Vec<ObservedToolCall> = Vec::new();
346 let mut compacted = false;
347
348 for message in messages {
349 for block in &message.content {
350 match block {
351 ContentBlock::ToolCall(call) => {
352 tool_calls.push(ObservedToolCall {
353 name: call.name.clone(),
354 command: command_of(&call.arguments),
355 });
356 }
357 ContentBlock::ToolResult(result) => {
358 let text = result
359 .content
360 .iter()
361 .filter_map(text_of)
362 .collect::<Vec<_>>()
363 .join("\n");
364 if !text.is_empty() {
365 tool_texts.push(text);
366 }
367 }
368 ContentBlock::Text { text } => {
372 compacted |= text.to_lowercase().contains(COMPACTION_MARKER);
373 }
374 _ => {}
375 }
376 }
377 }
378
379 let mut signal = build_signal(tool_texts, tool_calls, messages.len() as u32, recent_window);
380 signal.compacted = compacted;
381 signal
382}
383
384const COMPACTION_MARKER: &str = "session is being continued";
387
388fn command_of(arguments: &Value) -> Option<String> {
391 arguments
392 .get("command")
393 .and_then(Value::as_str)
394 .map(str::to_lowercase)
395}
396
397fn text_of(block: &ContentBlock) -> Option<&str> {
399 match block {
400 ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
401 _ => None,
402 }
403}
404
405fn build_signal(
406 tool_texts: Vec<String>,
407 tool_calls: Vec<ObservedToolCall>,
408 turn_depth: u32,
409 recent_window: usize,
410) -> ToolSignals {
411 let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
417 let mut severity = 0.0f32;
418 for text in &tool_texts[sev_start..] {
419 let (sev, _patterns) = classify_text(text);
420 if sev > severity {
421 severity = sev;
422 }
423 }
424
425 let no_error_streak = compute_no_error_streak(&tool_texts);
426
427 let recent_start = tool_calls.len().saturating_sub(recent_window);
431 let mut write_count = 0u32;
432 let mut edit_count = 0u32;
433 let mut read_count = 0u32;
434 let mut todowrite_count = 0u32;
435 let mut recent_write_count = 0u32;
436 let mut recent_edit_count = 0u32;
437 let mut recent_read_count = 0u32;
438 let mut recent_todowrite_count = 0u32;
439 let mut pure_bash_streak = 0u32;
440 let mut streak_open = true;
441 for (i, tc) in tool_calls.iter().enumerate().rev() {
442 let cat = classify_tool_call(&tc.name, tc.command.as_deref());
443 if streak_open {
444 if matches!(cat, ToolCategory::Other) {
445 pure_bash_streak += 1;
446 } else {
447 streak_open = false;
448 }
449 }
450 match cat {
451 ToolCategory::Write => {
452 write_count += 1;
453 if i >= recent_start {
454 recent_write_count += 1;
455 }
456 }
457 ToolCategory::Edit => {
458 edit_count += 1;
459 if i >= recent_start {
460 recent_edit_count += 1;
461 }
462 }
463 ToolCategory::Read => {
464 read_count += 1;
465 if i >= recent_start {
466 recent_read_count += 1;
467 }
468 }
469 ToolCategory::Plan => {
470 todowrite_count += 1;
471 if i >= recent_start {
472 recent_todowrite_count += 1;
473 }
474 }
475 ToolCategory::Other => {}
476 }
477 }
478
479 let tests_passed = detect_tests_passed(&tool_texts, recent_window);
480
481 ToolSignals {
482 severity,
483 no_error_streak,
484 edit_count,
485 write_count,
486 read_count,
487 todowrite_count,
488 recent_edit_count,
489 recent_write_count,
490 recent_read_count,
491 recent_todowrite_count,
492 pure_bash_streak,
493 tests_passed,
494 turn_depth,
495 compacted: false,
498 }
499}
500
501fn content_to_text(content: Option<&Value>) -> Option<String> {
505 match content? {
506 Value::String(s) => Some(s.clone()),
507 Value::Array(blocks) => {
508 let parts: Vec<&str> = blocks
509 .iter()
510 .filter_map(|b| {
511 b.as_object()
512 .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
513 .and_then(|o| o.get("text"))
514 .and_then(Value::as_str)
515 })
516 .collect();
517 if parts.is_empty() {
518 None
519 } else {
520 Some(parts.join("\n"))
521 }
522 }
523 _ => None,
524 }
525}
526
527pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
531 let lower = text.to_lowercase();
532 let mut patterns = Vec::new();
533 let mut severity: f32 = 0.0;
534 for (name, sev, substrings) in ERROR_PATTERNS {
535 if substrings.iter().any(|sub| lower.contains(sub)) {
536 patterns.push(name.to_string());
537 severity = severity.max(*sev);
538 }
539 }
540 (severity, patterns)
541}
542
543fn compute_no_error_streak(tool_texts: &[String]) -> u32 {
544 let mut streak = 0u32;
545 for text in tool_texts.iter().rev() {
546 let (sev, _) = classify_text(text);
547 if sev > 0.0 {
548 break;
549 }
550 streak += 1;
551 }
552 streak
553}
554
555fn detect_tests_passed(tool_texts: &[String], recent_window: usize) -> bool {
556 let start = tool_texts.len().saturating_sub(recent_window.max(1));
557 tool_texts[start..].iter().any(|text| {
558 let lower = text.to_lowercase();
559 TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
560 && !TEST_FAILURE_LITERAL.iter().any(|p| lower.contains(p))
561 && !has_nonzero_failure_count(&lower)
562 })
563}
564
565fn has_nonzero_failure_count(lower: &str) -> bool {
571 for kw in NUMERIC_FAILURE_KEYWORDS {
572 let mut cursor = 0usize;
573 while let Some(rel) = lower[cursor..].find(kw) {
574 let kw_start = cursor + rel;
575 let kw_end = kw_start + kw.len();
576 let boundary_after = lower[kw_end..]
579 .chars()
580 .next()
581 .is_none_or(|c| !c.is_ascii_alphanumeric());
582 if boundary_after {
583 let prefix = &lower[..kw_start];
584 let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
585 let digits_rev: String = trimmed
586 .chars()
587 .rev()
588 .take_while(|c| c.is_ascii_digit())
589 .collect();
590 if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
591 return true;
592 }
593 }
594 cursor = kw_start + kw.len();
595 }
596 }
597 false
598}
599
600#[cfg(test)]
603mod tests {
604 use super::*;
605 use serde_json::json;
606 use switchyard_protocol::{ContentBlock, LlmRequest, Message, Role, ToolCall, ToolResult};
607
608 fn with_messages(messages: Vec<Message>) -> Request {
609 Request {
610 llm_request: LlmRequest {
611 messages,
612 ..LlmRequest::default()
613 },
614 raw_request: None,
615 metadata: None,
616 }
617 }
618
619 fn tc(name: &str) -> Message {
621 Message {
622 role: Role::Assistant,
623 content: vec![ContentBlock::ToolCall(ToolCall {
624 id: String::new(),
625 name: name.to_string(),
626 arguments: json!({}),
627 })],
628 }
629 }
630
631 fn bash(command: &str) -> Message {
633 Message {
634 role: Role::Assistant,
635 content: vec![ContentBlock::ToolCall(ToolCall {
636 id: String::new(),
637 name: "Bash".to_string(),
638 arguments: json!({"command": command}),
639 })],
640 }
641 }
642
643 fn tr(text: &str) -> Message {
645 Message {
646 role: Role::User,
647 content: vec![ContentBlock::ToolResult(ToolResult {
648 tool_call_id: String::new(),
649 content: vec![ContentBlock::Text {
650 text: text.to_string(),
651 }],
652 is_error: None,
653 })],
654 }
655 }
656
657 #[test]
658 fn clean_text_has_zero_severity() {
659 let (sev, patterns) = classify_text("everything went fine");
660 assert_eq!(sev, 0.0);
661 assert!(patterns.is_empty());
662 }
663
664 #[test]
665 fn traceback_is_hard() {
666 let (sev, patterns) = classify_text("Traceback (most recent call last):\n ValueError");
667 assert_eq!(sev, HARD);
668 assert!(patterns.contains(&"traceback".to_string()));
669 }
670
671 #[test]
672 fn oom_is_critical() {
673 let (sev, _) = classify_text("Out of memory: kill process 1234");
674 assert_eq!(sev, CRITICAL);
675 }
676
677 #[test]
678 fn severity_is_max_across_patterns() {
679 let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
681 assert_eq!(sev, HARD);
682 }
683
684 #[test]
685 fn file_does_not_exist_is_hard() {
686 let (sev, patterns) =
688 classify_text("Error: File does not exist. Note: current working directory is /app.");
689 assert_eq!(sev, HARD);
690 assert!(patterns.contains(&"no_such_file".to_string()));
691 }
692
693 #[test]
694 fn bare_does_not_exist_stays_clean() {
695 let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
698 assert_eq!(sev, 0.0);
699 }
700
701 #[test]
702 fn no_error_streak_all_clean() {
703 let texts = vec!["ok".to_string(), "all good".to_string()];
704 assert_eq!(compute_no_error_streak(&texts), 2);
705 }
706
707 #[test]
708 fn no_error_streak_stops_at_error() {
709 let texts = vec![
710 "Traceback (most recent call last):".to_string(),
711 "ok".to_string(),
712 "ok".to_string(),
713 ];
714 assert_eq!(compute_no_error_streak(&texts), 2);
715 }
716
717 #[test]
718 fn tests_passed_detects_pytest_output() {
719 assert!(detect_tests_passed(
720 &["====== 5 passed in 0.12s ======".to_string()],
721 DEFAULT_RECENT_WINDOW
722 ));
723 }
724
725 #[test]
726 fn tests_passed_ignores_partial_failures() {
727 assert!(!detect_tests_passed(
728 &["2 failed, 5 passed in 0.56s".to_string()],
729 DEFAULT_RECENT_WINDOW
730 ));
731 }
732
733 #[test]
734 fn severity_is_windowed_over_recent_results() {
735 let request = with_messages(vec![
737 tr("Traceback (most recent call last):\n ValueError"),
738 tr("ok"),
739 tr("ok"),
740 ]);
741 assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
743 assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
745 }
746
747 #[test]
748 fn extract_openai_chat_tool_results() {
749 let request = with_messages(vec![
750 Message::text(Role::User, "do something"),
751 tc("Edit"),
752 tr("Traceback (most recent call last):\n ValueError"),
753 ]);
754 let sig = ToolSignals::from_request(&request, None);
755 assert_eq!(sig.severity, HARD);
756 assert_eq!(sig.edit_count, 1);
757 assert_eq!(sig.turn_depth, 3);
758 }
759
760 #[test]
761 fn extract_anthropic_tool_results() {
762 let request = with_messages(vec![tr("Traceback (most recent call last):\n ValueError")]);
763 let sig = ToolSignals::from_request(&request, None);
764 assert_eq!(sig.severity, HARD);
765 }
766
767 #[test]
768 fn extract_responses_api_tool_results() {
769 let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
770 let sig = ToolSignals::from_request(&request, None);
771 assert_eq!(sig.severity, 0.0);
772 assert_eq!(sig.write_count, 1);
773 }
774
775 #[test]
776 fn recent_window_counts_only_last_default_window_tool_calls() {
777 let request = with_messages(vec![
780 tc("Write"),
781 tr("ok"),
782 tc("Write"),
783 tr("ok"),
784 tc("Write"),
785 tr("ok"),
786 tc("Write"),
787 tr("ok"),
788 tc("Write"),
789 tr("ok"),
790 tc("Edit"),
791 tr("ok"),
792 ]);
793 let sig = ToolSignals::from_request(&request, None);
794 assert_eq!(sig.write_count, 5);
795 assert_eq!(sig.edit_count, 1);
796 assert_eq!(sig.recent_write_count, 2);
797 assert_eq!(sig.recent_edit_count, 1);
798 }
799
800 #[test]
801 fn recent_window_size_is_caller_overridable() {
802 let request = with_messages(vec![
806 tc("Write"),
807 tr("ok"),
808 tc("Write"),
809 tr("ok"),
810 tc("Write"),
811 tr("ok"),
812 tc("Write"),
813 tr("ok"),
814 tc("Write"),
815 tr("ok"),
816 tc("Edit"),
817 tr("ok"),
818 ]);
819 let narrow = extract_tool_signals_with_window(&request, 3);
820 assert_eq!(narrow.recent_write_count, 2);
821 assert_eq!(narrow.recent_edit_count, 1);
822
823 let wide = extract_tool_signals_with_window(&request, 6);
824 assert_eq!(wide.recent_write_count, 5);
825 assert_eq!(wide.recent_edit_count, 1);
826 }
827
828 #[test]
829 fn compaction_marker_sets_compacted() {
830 let request = with_messages(vec![
832 Message::text(
833 Role::User,
834 "This session is being continued from a previous conversation that ran out of context.",
835 ),
836 bash("ls"),
837 ]);
838 assert!(ToolSignals::from_request(&request, None).compacted);
839 }
840
841 #[test]
842 fn no_compaction_marker_stays_uncompacted() {
843 let request = with_messages(vec![
844 Message::text(Role::User, "Write a script that parses the log file."),
845 bash("ls"),
846 ]);
847 assert!(!ToolSignals::from_request(&request, None).compacted);
848 }
849
850 #[test]
851 fn bash_heredoc_counts_as_write() {
852 let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
854 let sig = ToolSignals::from_request(&request, None);
855 assert_eq!(
856 sig.write_count, 1,
857 "Bash heredoc should bucket into write_count"
858 );
859 assert_eq!(sig.edit_count, 0);
860 }
861
862 #[test]
863 fn bash_sed_inplace_counts_as_edit() {
864 let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
865 let sig = ToolSignals::from_request(&request, None);
866 assert_eq!(
867 sig.edit_count, 1,
868 "Bash sed -i should bucket into edit_count"
869 );
870 assert_eq!(sig.write_count, 0);
871 }
872
873 #[test]
874 fn bash_non_mutating_does_not_count() {
875 let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
877 let sig = ToolSignals::from_request(&request, None);
878 assert_eq!(sig.write_count, 0);
879 assert_eq!(sig.edit_count, 0);
880 }
881
882 #[test]
883 fn tests_passed_detects_pytest_with_failure_block() {
884 assert!(!detect_tests_passed(
886 &["2 failed, 5 passed in 0.56s".to_string()],
887 DEFAULT_RECENT_WINDOW
888 ));
889 }
890
891 #[test]
892 fn tests_passed_accepts_cargo_clean_summary() {
893 assert!(detect_tests_passed(
896 &["running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string()],
897 DEFAULT_RECENT_WINDOW
898 ));
899 }
900
901 #[test]
902 fn tests_passed_rejects_cargo_real_failure() {
903 assert!(!detect_tests_passed(
905 &["running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string()],
906 DEFAULT_RECENT_WINDOW
907 ));
908 }
909
910 #[test]
911 fn tests_passed_accepts_go_clean_summary() {
912 assert!(detect_tests_passed(
914 &["ok github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string()],
915 DEFAULT_RECENT_WINDOW
916 ));
917 }
918
919 #[test]
920 fn tests_passed_accepts_pytest_zero_errors() {
921 assert!(detect_tests_passed(
923 &["5 passed, 0 errors in 0.30s".to_string()],
924 DEFAULT_RECENT_WINDOW
925 ));
926 }
927
928 #[test]
929 fn tests_passed_detects_diy_checkmark() {
930 assert!(detect_tests_passed(
931 &["✓ all checks passed".to_string()],
932 DEFAULT_RECENT_WINDOW
933 ));
934 }
935
936 #[test]
937 fn anthropic_bash_heredoc_extracts_command() {
938 let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
940 let sig = ToolSignals::from_request(&request, None);
941 assert_eq!(
942 sig.write_count, 1,
943 "Anthropic Bash heredoc must also be detected"
944 );
945 }
946
947 #[test]
948 fn recent_window_falls_back_to_full_history_when_short() {
949 let request = with_messages(vec![tc("Write")]);
950 let sig = ToolSignals::from_request(&request, None);
951 assert_eq!(sig.recent_write_count, 1);
952 assert_eq!(sig.recent_edit_count, 0);
953 }
954
955 #[test]
956 fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
957 let request = with_messages(vec![tr("output ok"), tr("another ok")]);
958 let sig = ToolSignals::from_request(&request, None);
959 assert_eq!(sig.severity, 0.0);
960 assert_eq!(sig.no_error_streak, 2);
961 }
962
963 #[test]
966 fn todowrite_classifies_as_plan() {
967 assert_eq!(classify_tool_call("TodoWrite", None), ToolCategory::Plan);
968 assert_eq!(classify_tool_call("todo_write", None), ToolCategory::Plan);
969 }
970
971 #[test]
972 fn codex_update_plan_classifies_as_plan() {
973 assert_eq!(classify_tool_call("update_plan", None), ToolCategory::Plan);
974 }
975
976 #[test]
977 fn codex_shell_command_runs_bash_pattern_match() {
978 assert_eq!(
980 classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
981 ToolCategory::Write,
982 );
983 assert_eq!(
985 classify_tool_call("shell_command", Some("ls /app")),
986 ToolCategory::Read,
987 );
988 assert_eq!(
990 classify_tool_call("shell_command", Some("./run_tests.sh")),
991 ToolCategory::Other,
992 );
993 }
994
995 #[test]
996 fn read_tool_classifies_as_read() {
997 assert_eq!(classify_tool_call("Read", None), ToolCategory::Read);
998 assert_eq!(classify_tool_call("View", None), ToolCategory::Read);
999 }
1000
1001 #[test]
1002 fn hermes_tool_names_classify() {
1003 assert_eq!(classify_tool_call("write_file", None), ToolCategory::Write);
1005 assert_eq!(classify_tool_call("patch", None), ToolCategory::Edit);
1006 assert_eq!(classify_tool_call("read_file", None), ToolCategory::Read);
1007 assert_eq!(classify_tool_call("search_files", None), ToolCategory::Read);
1008 assert_eq!(
1011 classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
1012 ToolCategory::Edit,
1013 );
1014 assert_eq!(
1015 classify_tool_call("terminal", Some("grep foo /app")),
1016 ToolCategory::Read,
1017 );
1018 assert_eq!(
1019 classify_tool_call("terminal", Some("./run_tests.sh")),
1020 ToolCategory::Other,
1021 );
1022 }
1023
1024 #[test]
1025 fn bash_read_patterns_classify_as_read() {
1026 let cases = [
1027 "cat /etc/passwd",
1028 "grep foo bar.txt",
1029 "ls /app",
1030 "find . -name '*.py'",
1031 ];
1032 for cmd in cases {
1033 assert_eq!(
1034 classify_tool_call("Bash", Some(cmd)),
1035 ToolCategory::Read,
1036 "expected Read for {cmd}"
1037 );
1038 }
1039 }
1040
1041 #[test]
1042 fn bash_write_precedence_over_read() {
1043 assert_eq!(
1046 classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
1047 ToolCategory::Write,
1048 );
1049 }
1050
1051 #[test]
1052 fn pure_bash_streak_counts_trailing_other() {
1053 let request = with_messages(vec![
1055 bash("make"),
1056 tr("ok"),
1057 bash("./configure"),
1058 tr("ok"),
1059 bash("make install"),
1060 tr("ok"),
1061 bash("./run.sh"),
1062 tr("ok"),
1063 bash("./test"),
1064 tr("ok"),
1065 ]);
1066 let sig = ToolSignals::from_request(&request, None);
1067 assert_eq!(sig.pure_bash_streak, 5);
1068 assert_eq!(sig.write_count, 0);
1069 assert_eq!(sig.read_count, 0);
1070 }
1071
1072 #[test]
1073 fn pure_bash_streak_resets_on_write() {
1074 let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
1075 let sig = ToolSignals::from_request(&request, None);
1076 assert_eq!(sig.pure_bash_streak, 0);
1077 assert_eq!(sig.write_count, 1);
1078 }
1079
1080 #[test]
1081 fn recent_window_tracks_todowrite_and_read() {
1082 let request = with_messages(vec![
1084 bash("make"),
1085 tr("ok"),
1086 tc("TodoWrite"),
1087 tr("ok"),
1088 tc("Read"),
1089 tr("ok"),
1090 tc("TodoWrite"),
1091 tr("ok"),
1092 ]);
1093 let sig = ToolSignals::from_request(&request, None);
1094 assert_eq!(sig.todowrite_count, 2);
1095 assert_eq!(sig.recent_todowrite_count, 2);
1096 assert_eq!(sig.read_count, 1);
1097 assert_eq!(sig.recent_read_count, 1);
1098 }
1099}