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
406const PENDING_MARKER: &str = "process running with session id";
408
409fn reports_no_failure(text: &str) -> bool {
412 let lower = text.to_lowercase();
413 if lower.contains(PENDING_MARKER) {
414 return true;
415 }
416 let mut saw_zero = false;
417 for marker in ["exited with code ", "exit code: "] {
418 for tail in lower.split(marker).skip(1) {
419 let code: String = tail.chars().take_while(char::is_ascii_digit).collect();
420 match code.as_str() {
421 "" => {}
422 "0" => saw_zero = true,
423 _ => return false,
424 }
425 }
426 }
427 saw_zero
428}
429
430fn build_signal(
431 tool_texts: Vec<String>,
432 tool_calls: Vec<ObservedToolCall>,
433 turn_depth: u32,
434 recent_window: usize,
435) -> ToolSignals {
436 let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
442 let mut severity = 0.0f32;
443 for text in &tool_texts[sev_start..] {
444 if reports_no_failure(text) {
445 continue;
446 }
447 let (sev, _patterns) = classify_text(text);
448 if sev > severity {
449 severity = sev;
450 }
451 }
452
453 let no_error_streak = compute_no_error_streak(&tool_texts);
454
455 let recent_start = tool_calls.len().saturating_sub(recent_window);
459 let mut write_count = 0u32;
460 let mut edit_count = 0u32;
461 let mut read_count = 0u32;
462 let mut todowrite_count = 0u32;
463 let mut recent_write_count = 0u32;
464 let mut recent_edit_count = 0u32;
465 let mut recent_read_count = 0u32;
466 let mut recent_todowrite_count = 0u32;
467 let mut pure_bash_streak = 0u32;
468 let mut streak_open = true;
469 for (i, tc) in tool_calls.iter().enumerate().rev() {
470 let cat = classify_tool_call(&tc.name, tc.command.as_deref());
471 if streak_open {
472 if matches!(cat, ToolCategory::Other) {
473 pure_bash_streak += 1;
474 } else {
475 streak_open = false;
476 }
477 }
478 match cat {
479 ToolCategory::Write => {
480 write_count += 1;
481 if i >= recent_start {
482 recent_write_count += 1;
483 }
484 }
485 ToolCategory::Edit => {
486 edit_count += 1;
487 if i >= recent_start {
488 recent_edit_count += 1;
489 }
490 }
491 ToolCategory::Read => {
492 read_count += 1;
493 if i >= recent_start {
494 recent_read_count += 1;
495 }
496 }
497 ToolCategory::Plan => {
498 todowrite_count += 1;
499 if i >= recent_start {
500 recent_todowrite_count += 1;
501 }
502 }
503 ToolCategory::Other => {}
504 }
505 }
506
507 let tests_passed = detect_tests_passed(&tool_texts, recent_window);
508
509 ToolSignals {
510 severity,
511 no_error_streak,
512 edit_count,
513 write_count,
514 read_count,
515 todowrite_count,
516 recent_edit_count,
517 recent_write_count,
518 recent_read_count,
519 recent_todowrite_count,
520 pure_bash_streak,
521 tests_passed,
522 turn_depth,
523 compacted: false,
526 }
527}
528
529fn content_to_text(content: Option<&Value>) -> Option<String> {
533 match content? {
534 Value::String(s) => Some(s.clone()),
535 Value::Array(blocks) => {
536 let parts: Vec<&str> = blocks
537 .iter()
538 .filter_map(|b| {
539 b.as_object()
540 .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
541 .and_then(|o| o.get("text"))
542 .and_then(Value::as_str)
543 })
544 .collect();
545 if parts.is_empty() {
546 None
547 } else {
548 Some(parts.join("\n"))
549 }
550 }
551 _ => None,
552 }
553}
554
555pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
559 let lower = text.to_lowercase();
560 let mut patterns = Vec::new();
561 let mut severity: f32 = 0.0;
562 for (name, sev, substrings) in ERROR_PATTERNS {
563 if substrings.iter().any(|sub| lower.contains(sub)) {
564 patterns.push(name.to_string());
565 severity = severity.max(*sev);
566 }
567 }
568 (severity, patterns)
569}
570
571fn compute_no_error_streak(tool_texts: &[String]) -> u32 {
572 let mut streak = 0u32;
573 for text in tool_texts.iter().rev() {
574 let (sev, _) = classify_text(text);
575 if sev > 0.0 {
576 break;
577 }
578 streak += 1;
579 }
580 streak
581}
582
583fn contains_failure_literal(lower: &str) -> bool {
586 TEST_FAILURE_LITERAL.iter().any(|literal| {
587 let mut cursor = 0usize;
588 while let Some(rel) = lower[cursor..].find(literal) {
589 let end = cursor + rel + literal.len();
590 if !lower[end..].starts_with(':') {
591 return true;
592 }
593 cursor = end;
594 }
595 false
596 })
597}
598
599fn detect_tests_passed(tool_texts: &[String], recent_window: usize) -> bool {
600 let start = tool_texts.len().saturating_sub(recent_window.max(1));
601 tool_texts[start..].iter().any(|text| {
602 let lower = text.to_lowercase();
603 TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
604 && !contains_failure_literal(&lower)
605 && !has_nonzero_failure_count(&lower)
606 })
607}
608
609fn has_nonzero_failure_count(lower: &str) -> bool {
615 for kw in NUMERIC_FAILURE_KEYWORDS {
616 let mut cursor = 0usize;
617 while let Some(rel) = lower[cursor..].find(kw) {
618 let kw_start = cursor + rel;
619 let kw_end = kw_start + kw.len();
620 let boundary_after = lower[kw_end..]
623 .chars()
624 .next()
625 .is_none_or(|c| !c.is_ascii_alphanumeric());
626 if boundary_after {
627 let prefix = &lower[..kw_start];
628 let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
629 let digits_rev: String = trimmed
630 .chars()
631 .rev()
632 .take_while(|c| c.is_ascii_digit())
633 .collect();
634 if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
635 return true;
636 }
637 }
638 cursor = kw_start + kw.len();
639 }
640 }
641 false
642}
643
644#[cfg(test)]
647mod tests {
648 use super::*;
649 use serde_json::json;
650 use switchyard_protocol::{ContentBlock, LlmRequest, Message, Role, ToolCall, ToolResult};
651
652 fn with_messages(messages: Vec<Message>) -> Request {
653 Request {
654 llm_request: LlmRequest {
655 messages,
656 ..LlmRequest::default()
657 },
658 raw_request: None,
659 metadata: None,
660 }
661 }
662
663 fn tc(name: &str) -> Message {
665 Message {
666 role: Role::Assistant,
667 content: vec![ContentBlock::ToolCall(ToolCall {
668 id: String::new(),
669 name: name.to_string(),
670 arguments: json!({}),
671 })],
672 }
673 }
674
675 fn bash(command: &str) -> Message {
677 Message {
678 role: Role::Assistant,
679 content: vec![ContentBlock::ToolCall(ToolCall {
680 id: String::new(),
681 name: "Bash".to_string(),
682 arguments: json!({"command": command}),
683 })],
684 }
685 }
686
687 fn tr(text: &str) -> Message {
689 Message {
690 role: Role::User,
691 content: vec![ContentBlock::ToolResult(ToolResult {
692 tool_call_id: String::new(),
693 content: vec![ContentBlock::Text {
694 text: text.to_string(),
695 }],
696 is_error: None,
697 })],
698 }
699 }
700
701 #[test]
702 fn clean_text_has_zero_severity() {
703 let (sev, patterns) = classify_text("everything went fine");
704 assert_eq!(sev, 0.0);
705 assert!(patterns.is_empty());
706 }
707
708 #[test]
709 fn traceback_is_hard() {
710 let (sev, patterns) = classify_text("Traceback (most recent call last):\n ValueError");
711 assert_eq!(sev, HARD);
712 assert!(patterns.contains(&"traceback".to_string()));
713 }
714
715 #[test]
716 fn oom_is_critical() {
717 let (sev, _) = classify_text("Out of memory: kill process 1234");
718 assert_eq!(sev, CRITICAL);
719 }
720
721 #[test]
722 fn severity_is_max_across_patterns() {
723 let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
725 assert_eq!(sev, HARD);
726 }
727
728 #[test]
729 fn file_does_not_exist_is_hard() {
730 let (sev, patterns) =
732 classify_text("Error: File does not exist. Note: current working directory is /app.");
733 assert_eq!(sev, HARD);
734 assert!(patterns.contains(&"no_such_file".to_string()));
735 }
736
737 #[test]
738 fn bare_does_not_exist_stays_clean() {
739 let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
742 assert_eq!(sev, 0.0);
743 }
744
745 #[test]
746 fn no_error_streak_all_clean() {
747 let texts = vec!["ok".to_string(), "all good".to_string()];
748 assert_eq!(compute_no_error_streak(&texts), 2);
749 }
750
751 #[test]
752 fn no_error_streak_stops_at_error() {
753 let texts = vec![
754 "Traceback (most recent call last):".to_string(),
755 "ok".to_string(),
756 "ok".to_string(),
757 ];
758 assert_eq!(compute_no_error_streak(&texts), 2);
759 }
760
761 #[test]
762 fn tests_passed_detects_pytest_output() {
763 assert!(detect_tests_passed(
764 &["====== 5 passed in 0.12s ======".to_string()],
765 DEFAULT_RECENT_WINDOW
766 ));
767 }
768
769 #[test]
770 fn tests_passed_ignores_partial_failures() {
771 assert!(!detect_tests_passed(
772 &["2 failed, 5 passed in 0.56s".to_string()],
773 DEFAULT_RECENT_WINDOW
774 ));
775 }
776
777 #[test]
778 fn severity_is_windowed_over_recent_results() {
779 let request = with_messages(vec![
781 tr("Traceback (most recent call last):\n ValueError"),
782 tr("ok"),
783 tr("ok"),
784 ]);
785 assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
787 assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
789 }
790
791 #[test]
792 fn extract_openai_chat_tool_results() {
793 let request = with_messages(vec![
794 Message::text(Role::User, "do something"),
795 tc("Edit"),
796 tr("Traceback (most recent call last):\n ValueError"),
797 ]);
798 let sig = ToolSignals::from_request(&request, None);
799 assert_eq!(sig.severity, HARD);
800 assert_eq!(sig.edit_count, 1);
801 assert_eq!(sig.turn_depth, 3);
802 }
803
804 #[test]
805 fn extract_anthropic_tool_results() {
806 let request = with_messages(vec![tr("Traceback (most recent call last):\n ValueError")]);
807 let sig = ToolSignals::from_request(&request, None);
808 assert_eq!(sig.severity, HARD);
809 }
810
811 #[test]
812 fn extract_responses_api_tool_results() {
813 let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
814 let sig = ToolSignals::from_request(&request, None);
815 assert_eq!(sig.severity, 0.0);
816 assert_eq!(sig.write_count, 1);
817 }
818
819 #[test]
820 fn recent_window_counts_only_last_default_window_tool_calls() {
821 let request = with_messages(vec![
824 tc("Write"),
825 tr("ok"),
826 tc("Write"),
827 tr("ok"),
828 tc("Write"),
829 tr("ok"),
830 tc("Write"),
831 tr("ok"),
832 tc("Write"),
833 tr("ok"),
834 tc("Edit"),
835 tr("ok"),
836 ]);
837 let sig = ToolSignals::from_request(&request, None);
838 assert_eq!(sig.write_count, 5);
839 assert_eq!(sig.edit_count, 1);
840 assert_eq!(sig.recent_write_count, 2);
841 assert_eq!(sig.recent_edit_count, 1);
842 }
843
844 #[test]
845 fn codex_apply_patch_counts_as_an_edit() {
846 let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
847 let sig = ToolSignals::from_request(&request, None);
848 assert_eq!(sig.edit_count, 1);
849 assert_eq!(sig.recent_edit_count, 1);
850 }
851
852 #[test]
853 fn exit_zero_is_not_an_error() {
854 let source = "static CRITICAL: &[&str] = &[\"out of memory\", \"connection refused\"];";
855 let request = with_messages(vec![
856 tc("Bash"),
857 tr(&format!("Process exited with code 0\nOutput: {source}")),
858 ]);
859 assert_eq!(ToolSignals::from_request(&request, None).severity, 0.0);
860 }
861
862 #[test]
863 fn streaming_chunk_is_not_a_failure() {
864 let request = with_messages(vec![
865 tc("Bash"),
866 tr(
867 "Process running with session ID 14028\nOutput:\n+ tr(\"Traceback (most recent call last)\"),",
868 ),
869 ]);
870 assert_eq!(ToolSignals::from_request(&request, None).severity, 0.0);
871 }
872
873 #[test]
875 fn rust_path_is_not_a_failure() {
876 let passing = "test error::tests::preserves_source ... ok\n\
877 test result: ok. 268 passed; 0 failed; 0 ignored";
878 let request = with_messages(vec![tc("Bash"), tr(passing)]);
879 assert!(ToolSignals::from_request(&request, None).tests_passed);
880 }
881
882 #[test]
883 fn recent_window_size_is_caller_overridable() {
884 let request = with_messages(vec![
888 tc("Write"),
889 tr("ok"),
890 tc("Write"),
891 tr("ok"),
892 tc("Write"),
893 tr("ok"),
894 tc("Write"),
895 tr("ok"),
896 tc("Write"),
897 tr("ok"),
898 tc("Edit"),
899 tr("ok"),
900 ]);
901 let narrow = extract_tool_signals_with_window(&request, 3);
902 assert_eq!(narrow.recent_write_count, 2);
903 assert_eq!(narrow.recent_edit_count, 1);
904
905 let wide = extract_tool_signals_with_window(&request, 6);
906 assert_eq!(wide.recent_write_count, 5);
907 assert_eq!(wide.recent_edit_count, 1);
908 }
909
910 #[test]
911 fn compaction_marker_sets_compacted() {
912 let request = with_messages(vec![
914 Message::text(
915 Role::User,
916 "This session is being continued from a previous conversation that ran out of context.",
917 ),
918 bash("ls"),
919 ]);
920 assert!(ToolSignals::from_request(&request, None).compacted);
921 }
922
923 #[test]
924 fn no_compaction_marker_stays_uncompacted() {
925 let request = with_messages(vec![
926 Message::text(Role::User, "Write a script that parses the log file."),
927 bash("ls"),
928 ]);
929 assert!(!ToolSignals::from_request(&request, None).compacted);
930 }
931
932 #[test]
933 fn bash_heredoc_counts_as_write() {
934 let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
936 let sig = ToolSignals::from_request(&request, None);
937 assert_eq!(
938 sig.write_count, 1,
939 "Bash heredoc should bucket into write_count"
940 );
941 assert_eq!(sig.edit_count, 0);
942 }
943
944 #[test]
945 fn bash_sed_inplace_counts_as_edit() {
946 let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
947 let sig = ToolSignals::from_request(&request, None);
948 assert_eq!(
949 sig.edit_count, 1,
950 "Bash sed -i should bucket into edit_count"
951 );
952 assert_eq!(sig.write_count, 0);
953 }
954
955 #[test]
956 fn bash_non_mutating_does_not_count() {
957 let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
959 let sig = ToolSignals::from_request(&request, None);
960 assert_eq!(sig.write_count, 0);
961 assert_eq!(sig.edit_count, 0);
962 }
963
964 #[test]
965 fn tests_passed_detects_pytest_with_failure_block() {
966 assert!(!detect_tests_passed(
968 &["2 failed, 5 passed in 0.56s".to_string()],
969 DEFAULT_RECENT_WINDOW
970 ));
971 }
972
973 #[test]
974 fn tests_passed_accepts_cargo_clean_summary() {
975 assert!(detect_tests_passed(
978 &["running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string()],
979 DEFAULT_RECENT_WINDOW
980 ));
981 }
982
983 #[test]
984 fn tests_passed_rejects_cargo_real_failure() {
985 assert!(!detect_tests_passed(
987 &["running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string()],
988 DEFAULT_RECENT_WINDOW
989 ));
990 }
991
992 #[test]
993 fn tests_passed_accepts_go_clean_summary() {
994 assert!(detect_tests_passed(
996 &["ok github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string()],
997 DEFAULT_RECENT_WINDOW
998 ));
999 }
1000
1001 #[test]
1002 fn tests_passed_accepts_pytest_zero_errors() {
1003 assert!(detect_tests_passed(
1005 &["5 passed, 0 errors in 0.30s".to_string()],
1006 DEFAULT_RECENT_WINDOW
1007 ));
1008 }
1009
1010 #[test]
1011 fn tests_passed_detects_diy_checkmark() {
1012 assert!(detect_tests_passed(
1013 &["✓ all checks passed".to_string()],
1014 DEFAULT_RECENT_WINDOW
1015 ));
1016 }
1017
1018 #[test]
1019 fn anthropic_bash_heredoc_extracts_command() {
1020 let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
1022 let sig = ToolSignals::from_request(&request, None);
1023 assert_eq!(
1024 sig.write_count, 1,
1025 "Anthropic Bash heredoc must also be detected"
1026 );
1027 }
1028
1029 #[test]
1030 fn recent_window_falls_back_to_full_history_when_short() {
1031 let request = with_messages(vec![tc("Write")]);
1032 let sig = ToolSignals::from_request(&request, None);
1033 assert_eq!(sig.recent_write_count, 1);
1034 assert_eq!(sig.recent_edit_count, 0);
1035 }
1036
1037 #[test]
1038 fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
1039 let request = with_messages(vec![tr("output ok"), tr("another ok")]);
1040 let sig = ToolSignals::from_request(&request, None);
1041 assert_eq!(sig.severity, 0.0);
1042 assert_eq!(sig.no_error_streak, 2);
1043 }
1044
1045 #[test]
1048 fn todowrite_classifies_as_plan() {
1049 assert_eq!(classify_tool_call("TodoWrite", None), ToolCategory::Plan);
1050 assert_eq!(classify_tool_call("todo_write", None), ToolCategory::Plan);
1051 }
1052
1053 #[test]
1054 fn codex_update_plan_classifies_as_plan() {
1055 assert_eq!(classify_tool_call("update_plan", None), ToolCategory::Plan);
1056 }
1057
1058 #[test]
1059 fn codex_shell_command_runs_bash_pattern_match() {
1060 assert_eq!(
1062 classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
1063 ToolCategory::Write,
1064 );
1065 assert_eq!(
1067 classify_tool_call("shell_command", Some("ls /app")),
1068 ToolCategory::Read,
1069 );
1070 assert_eq!(
1072 classify_tool_call("shell_command", Some("./run_tests.sh")),
1073 ToolCategory::Other,
1074 );
1075 }
1076
1077 #[test]
1078 fn read_tool_classifies_as_read() {
1079 assert_eq!(classify_tool_call("Read", None), ToolCategory::Read);
1080 assert_eq!(classify_tool_call("View", None), ToolCategory::Read);
1081 }
1082
1083 #[test]
1084 fn hermes_tool_names_classify() {
1085 assert_eq!(classify_tool_call("write_file", None), ToolCategory::Write);
1087 assert_eq!(classify_tool_call("patch", None), ToolCategory::Edit);
1088 assert_eq!(classify_tool_call("read_file", None), ToolCategory::Read);
1089 assert_eq!(classify_tool_call("search_files", None), ToolCategory::Read);
1090 assert_eq!(
1093 classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
1094 ToolCategory::Edit,
1095 );
1096 assert_eq!(
1097 classify_tool_call("terminal", Some("grep foo /app")),
1098 ToolCategory::Read,
1099 );
1100 assert_eq!(
1101 classify_tool_call("terminal", Some("./run_tests.sh")),
1102 ToolCategory::Other,
1103 );
1104 }
1105
1106 #[test]
1107 fn bash_read_patterns_classify_as_read() {
1108 let cases = [
1109 "cat /etc/passwd",
1110 "grep foo bar.txt",
1111 "ls /app",
1112 "find . -name '*.py'",
1113 ];
1114 for cmd in cases {
1115 assert_eq!(
1116 classify_tool_call("Bash", Some(cmd)),
1117 ToolCategory::Read,
1118 "expected Read for {cmd}"
1119 );
1120 }
1121 }
1122
1123 #[test]
1124 fn bash_write_precedence_over_read() {
1125 assert_eq!(
1128 classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
1129 ToolCategory::Write,
1130 );
1131 }
1132
1133 #[test]
1134 fn pure_bash_streak_counts_trailing_other() {
1135 let request = with_messages(vec![
1137 bash("make"),
1138 tr("ok"),
1139 bash("./configure"),
1140 tr("ok"),
1141 bash("make install"),
1142 tr("ok"),
1143 bash("./run.sh"),
1144 tr("ok"),
1145 bash("./test"),
1146 tr("ok"),
1147 ]);
1148 let sig = ToolSignals::from_request(&request, None);
1149 assert_eq!(sig.pure_bash_streak, 5);
1150 assert_eq!(sig.write_count, 0);
1151 assert_eq!(sig.read_count, 0);
1152 }
1153
1154 #[test]
1155 fn pure_bash_streak_resets_on_write() {
1156 let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
1157 let sig = ToolSignals::from_request(&request, None);
1158 assert_eq!(sig.pure_bash_streak, 0);
1159 assert_eq!(sig.write_count, 1);
1160 }
1161
1162 #[test]
1163 fn recent_window_tracks_todowrite_and_read() {
1164 let request = with_messages(vec![
1166 bash("make"),
1167 tr("ok"),
1168 tc("TodoWrite"),
1169 tr("ok"),
1170 tc("Read"),
1171 tr("ok"),
1172 tc("TodoWrite"),
1173 tr("ok"),
1174 ]);
1175 let sig = ToolSignals::from_request(&request, None);
1176 assert_eq!(sig.todowrite_count, 2);
1177 assert_eq!(sig.recent_todowrite_count, 2);
1178 assert_eq!(sig.read_count, 1);
1179 assert_eq!(sig.recent_read_count, 1);
1180 }
1181}