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 PYTHON_WRITE_PATTERNS: &[&str] = &["write_text(", "writelines(", ".write("];
132
133static BASH_EDIT_PATTERNS: &[&str] = &[
134 "sed -i",
135 "sed --in-place",
136 "awk -i inplace",
137 "awk 'inplace=1'",
138 "patch ",
139 "patch -p",
140 "perl -i",
141 "perl -p -i",
142 "perl -pi",
143];
144
145static BASH_READ_PATTERNS: &[&str] = &[
148 "cat /", "cat ./", "cat ../", "grep ", "ls ", "ls -", "find ", "head ", "tail ", "wc ",
149 "diff ", "which ", "ps ", "df ", "du ", "stat ", "file ", "less ", "more ",
150];
151
152static READ_TOOL_NAMES: &[&str] = &["read", "view", "read_file", "search_files"];
153
154static PLAN_TOOL_NAMES: &[&str] = &["todowrite", "todo_write", "todo", "update_plan"];
157
158static BASH_TOOL_NAMES: &[&str] = &[
163 "bash",
164 "shell_command",
165 "shell",
166 "local_shell_call",
167 "terminal",
168 "exec_command", ];
170
171static TEST_PASS_PHRASES: &[&str] = &[
174 " passed",
175 "passed in",
176 "tests passed",
177 "all tests passed",
178 "test ok",
179 "test result: ok",
180 "passed.\n",
181 "tests pass",
182 "\nok ", "✓ ",
184];
185
186static TEST_FAILURE_LITERAL: &[&str] = &["✗ ", "fatal:", "assertionerror", "error:"];
191
192static NUMERIC_FAILURE_KEYWORDS: &[&str] = &["failed", "failure", "failures", "errors", "error"];
196
197pub const DEFAULT_RECENT_WINDOW: usize = 3;
204
205#[derive(Clone, Debug, Default)]
212pub struct ToolSignals {
213 pub severity: f32,
218 pub no_error_streak: u32,
220 pub edit_count: u32,
222 pub write_count: u32,
224 pub read_count: u32,
226 pub todowrite_count: u32,
229 pub recent_edit_count: u32,
231 pub recent_write_count: u32,
233 pub recent_read_count: u32,
235 pub recent_todowrite_count: u32,
237 pub pure_bash_streak: u32,
240 pub tests_passed: bool,
242 pub turn_depth: u32,
246 pub compacted: bool,
252}
253
254impl ToolSignals {
255 pub fn from_request(request: &Request, window_size: Option<usize>) -> Self {
260 extract_tool_signals_with_window(request, window_size.unwrap_or(DEFAULT_RECENT_WINDOW))
261 }
262}
263
264#[derive(Debug, Clone)]
266struct ObservedToolCall {
267 name: String,
268 command: Option<String>,
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272enum ToolCategory {
273 Write,
274 Edit,
275 Read,
276 Plan,
277 Other,
278}
279
280#[derive(Debug, Clone)]
283pub struct ToolSignalProcessor {
284 pub recent_window: usize,
287}
288
289impl Default for ToolSignalProcessor {
290 fn default() -> Self {
291 Self {
292 recent_window: DEFAULT_RECENT_WINDOW,
293 }
294 }
295}
296
297#[async_trait]
298impl Processor<State> for ToolSignalProcessor {
299 async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
300 if let Event::Request { request: req, .. } = event {
301 let tool_signal = ToolSignals::from_request(req, Some(self.recent_window));
302 state.tool_signals = Some(tool_signal);
303 }
304 Ok(())
305 }
306}
307
308fn classify_tool_call(name: &str, command: Option<&str>) -> ToolCategory {
309 let lower = name.to_lowercase();
310 if WRITE_TOOL_NAMES.contains(&lower.as_str()) {
311 return ToolCategory::Write;
312 }
313 if EDIT_TOOL_NAMES.contains(&lower.as_str()) {
314 return ToolCategory::Edit;
315 }
316 if READ_TOOL_NAMES.contains(&lower.as_str()) {
317 return ToolCategory::Read;
318 }
319 if PLAN_TOOL_NAMES.contains(&lower.as_str()) {
320 return ToolCategory::Plan;
321 }
322 if BASH_TOOL_NAMES.contains(&lower.as_str())
323 && let Some(cmd) = command
324 {
325 if BASH_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
327 return ToolCategory::Write;
328 }
329 if cmd.contains("python") && PYTHON_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
330 return ToolCategory::Write;
331 }
332 if BASH_EDIT_PATTERNS.iter().any(|p| cmd.contains(p)) {
333 return ToolCategory::Edit;
334 }
335 if BASH_READ_PATTERNS.iter().any(|p| cmd.contains(p)) {
336 return ToolCategory::Read;
337 }
338 }
339 ToolCategory::Other
340}
341
342fn extract_tool_signals_with_window(request: &Request, recent_window: usize) -> ToolSignals {
349 let messages = &request.llm_request.messages;
353 let mut tool_texts: Vec<String> = Vec::new();
354 let mut tool_calls: Vec<ObservedToolCall> = Vec::new();
355 let mut compacted = false;
356
357 for message in messages {
358 for block in &message.content {
359 match block {
360 ContentBlock::ToolCall(call) => {
361 tool_calls.push(ObservedToolCall {
362 name: call.name.clone(),
363 command: command_of(&call.arguments),
364 });
365 }
366 ContentBlock::ToolResult(result) => {
367 let text = result
368 .content
369 .iter()
370 .filter_map(text_of)
371 .collect::<Vec<_>>()
372 .join("\n");
373 if !text.is_empty() {
374 tool_texts.push(text);
375 }
376 }
377 ContentBlock::Text { text } => {
381 compacted |= text.to_lowercase().contains(COMPACTION_MARKER);
382 }
383 _ => {}
384 }
385 }
386 }
387
388 let mut signal = build_signal(tool_texts, tool_calls, messages.len() as u32, recent_window);
389 signal.compacted = compacted;
390 signal
391}
392
393const COMPACTION_MARKER: &str = "session is being continued";
396
397fn command_of(arguments: &Value) -> Option<String> {
400 let decoded = arguments
402 .as_str()
403 .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
404 let object = decoded.as_ref().unwrap_or(arguments);
405
406 ["command", "cmd", "input"]
407 .iter()
408 .filter_map(|key| object.get(*key))
409 .find_map(command_text)
410}
411
412fn command_text(value: &Value) -> Option<String> {
414 match value {
415 Value::String(text) => Some(text.to_lowercase()),
416 Value::Array(parts) => {
417 let joined = parts
418 .iter()
419 .filter_map(Value::as_str)
420 .collect::<Vec<_>>()
421 .join(" ");
422 (!joined.is_empty()).then(|| joined.to_lowercase())
423 }
424 _ => None,
425 }
426}
427
428fn text_of(block: &ContentBlock) -> Option<&str> {
430 match block {
431 ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
432 _ => None,
433 }
434}
435
436fn build_signal(
437 tool_texts: Vec<String>,
438 tool_calls: Vec<ObservedToolCall>,
439 turn_depth: u32,
440 recent_window: usize,
441) -> ToolSignals {
442 let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
448 let mut severity = 0.0f32;
449 for text in &tool_texts[sev_start..] {
450 let (sev, _patterns) = classify_text(text);
451 if sev > severity {
452 severity = sev;
453 }
454 }
455
456 let no_error_streak = compute_no_error_streak(&tool_texts);
457
458 let recent_start = tool_calls.len().saturating_sub(recent_window);
462 let mut write_count = 0u32;
463 let mut edit_count = 0u32;
464 let mut read_count = 0u32;
465 let mut todowrite_count = 0u32;
466 let mut recent_write_count = 0u32;
467 let mut recent_edit_count = 0u32;
468 let mut recent_read_count = 0u32;
469 let mut recent_todowrite_count = 0u32;
470 let mut pure_bash_streak = 0u32;
471 let mut streak_open = true;
472 for (i, tc) in tool_calls.iter().enumerate().rev() {
473 let cat = classify_tool_call(&tc.name, tc.command.as_deref());
474 if streak_open {
475 if matches!(cat, ToolCategory::Other) {
476 pure_bash_streak += 1;
477 } else {
478 streak_open = false;
479 }
480 }
481 match cat {
482 ToolCategory::Write => {
483 write_count += 1;
484 if i >= recent_start {
485 recent_write_count += 1;
486 }
487 }
488 ToolCategory::Edit => {
489 edit_count += 1;
490 if i >= recent_start {
491 recent_edit_count += 1;
492 }
493 }
494 ToolCategory::Read => {
495 read_count += 1;
496 if i >= recent_start {
497 recent_read_count += 1;
498 }
499 }
500 ToolCategory::Plan => {
501 todowrite_count += 1;
502 if i >= recent_start {
503 recent_todowrite_count += 1;
504 }
505 }
506 ToolCategory::Other => {}
507 }
508 }
509
510 let tests_passed = detect_tests_passed(&tool_texts, recent_window);
511
512 ToolSignals {
513 severity,
514 no_error_streak,
515 edit_count,
516 write_count,
517 read_count,
518 todowrite_count,
519 recent_edit_count,
520 recent_write_count,
521 recent_read_count,
522 recent_todowrite_count,
523 pure_bash_streak,
524 tests_passed,
525 turn_depth,
526 compacted: false,
529 }
530}
531
532fn content_to_text(content: Option<&Value>) -> Option<String> {
536 match content? {
537 Value::String(s) => Some(s.clone()),
538 Value::Array(blocks) => {
539 let parts: Vec<&str> = blocks
540 .iter()
541 .filter_map(|b| {
542 b.as_object()
543 .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
544 .and_then(|o| o.get("text"))
545 .and_then(Value::as_str)
546 })
547 .collect();
548 if parts.is_empty() {
549 None
550 } else {
551 Some(parts.join("\n"))
552 }
553 }
554 _ => None,
555 }
556}
557
558pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
562 let lower = text.to_lowercase();
563 let mut patterns = Vec::new();
564 let mut severity: f32 = 0.0;
565 for (name, sev, substrings) in ERROR_PATTERNS {
566 if substrings.iter().any(|sub| lower.contains(sub)) {
567 patterns.push(name.to_string());
568 severity = severity.max(*sev);
569 }
570 }
571 (severity, patterns)
572}
573
574fn compute_no_error_streak(tool_texts: &[String]) -> u32 {
575 let mut streak = 0u32;
576 for text in tool_texts.iter().rev() {
577 let (sev, _) = classify_text(text);
578 if sev > 0.0 {
579 break;
580 }
581 streak += 1;
582 }
583 streak
584}
585
586fn detect_tests_passed(tool_texts: &[String], recent_window: usize) -> bool {
587 let start = tool_texts.len().saturating_sub(recent_window.max(1));
588 tool_texts[start..].iter().any(|text| {
589 let lower = text.to_lowercase();
590 TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
591 && !TEST_FAILURE_LITERAL.iter().any(|p| lower.contains(p))
592 && !has_nonzero_failure_count(&lower)
593 })
594}
595
596fn has_nonzero_failure_count(lower: &str) -> bool {
602 for kw in NUMERIC_FAILURE_KEYWORDS {
603 let mut cursor = 0usize;
604 while let Some(rel) = lower[cursor..].find(kw) {
605 let kw_start = cursor + rel;
606 let kw_end = kw_start + kw.len();
607 let boundary_after = lower[kw_end..]
610 .chars()
611 .next()
612 .is_none_or(|c| !c.is_ascii_alphanumeric());
613 if boundary_after {
614 let prefix = &lower[..kw_start];
615 let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
616 let digits_rev: String = trimmed
617 .chars()
618 .rev()
619 .take_while(|c| c.is_ascii_digit())
620 .collect();
621 if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
622 return true;
623 }
624 }
625 cursor = kw_start + kw.len();
626 }
627 }
628 false
629}
630
631#[cfg(test)]
634mod tests {
635 use super::*;
636 use serde_json::json;
637 use switchyard_protocol::{ContentBlock, LlmRequest, Message, Role, ToolCall, ToolResult};
638
639 fn with_messages(messages: Vec<Message>) -> Request {
640 Request {
641 llm_request: LlmRequest {
642 messages,
643 ..LlmRequest::default()
644 },
645 raw_request: None,
646 metadata: None,
647 }
648 }
649
650 fn tc(name: &str) -> Message {
652 Message {
653 role: Role::Assistant,
654 content: vec![ContentBlock::ToolCall(ToolCall {
655 id: String::new(),
656 name: name.to_string(),
657 arguments: json!({}),
658 })],
659 }
660 }
661
662 fn bash(command: &str) -> Message {
664 Message {
665 role: Role::Assistant,
666 content: vec![ContentBlock::ToolCall(ToolCall {
667 id: String::new(),
668 name: "Bash".to_string(),
669 arguments: json!({"command": command}),
670 })],
671 }
672 }
673
674 fn tr(text: &str) -> Message {
676 Message {
677 role: Role::User,
678 content: vec![ContentBlock::ToolResult(ToolResult {
679 tool_call_id: String::new(),
680 content: vec![ContentBlock::Text {
681 text: text.to_string(),
682 }],
683 is_error: None,
684 })],
685 }
686 }
687
688 #[test]
689 fn clean_text_has_zero_severity() {
690 let (sev, patterns) = classify_text("everything went fine");
691 assert_eq!(sev, 0.0);
692 assert!(patterns.is_empty());
693 }
694
695 #[test]
696 fn traceback_is_hard() {
697 let (sev, patterns) = classify_text("Traceback (most recent call last):\n ValueError");
698 assert_eq!(sev, HARD);
699 assert!(patterns.contains(&"traceback".to_string()));
700 }
701
702 #[test]
703 fn oom_is_critical() {
704 let (sev, _) = classify_text("Out of memory: kill process 1234");
705 assert_eq!(sev, CRITICAL);
706 }
707
708 #[test]
709 fn severity_is_max_across_patterns() {
710 let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
712 assert_eq!(sev, HARD);
713 }
714
715 #[test]
716 fn file_does_not_exist_is_hard() {
717 let (sev, patterns) =
719 classify_text("Error: File does not exist. Note: current working directory is /app.");
720 assert_eq!(sev, HARD);
721 assert!(patterns.contains(&"no_such_file".to_string()));
722 }
723
724 #[test]
725 fn bare_does_not_exist_stays_clean() {
726 let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
729 assert_eq!(sev, 0.0);
730 }
731
732 #[test]
733 fn no_error_streak_all_clean() {
734 let texts = vec!["ok".to_string(), "all good".to_string()];
735 assert_eq!(compute_no_error_streak(&texts), 2);
736 }
737
738 #[test]
739 fn no_error_streak_stops_at_error() {
740 let texts = vec![
741 "Traceback (most recent call last):".to_string(),
742 "ok".to_string(),
743 "ok".to_string(),
744 ];
745 assert_eq!(compute_no_error_streak(&texts), 2);
746 }
747
748 #[test]
749 fn tests_passed_detects_pytest_output() {
750 assert!(detect_tests_passed(
751 &["====== 5 passed in 0.12s ======".to_string()],
752 DEFAULT_RECENT_WINDOW
753 ));
754 }
755
756 #[test]
757 fn tests_passed_ignores_partial_failures() {
758 assert!(!detect_tests_passed(
759 &["2 failed, 5 passed in 0.56s".to_string()],
760 DEFAULT_RECENT_WINDOW
761 ));
762 }
763
764 #[test]
765 fn severity_is_windowed_over_recent_results() {
766 let request = with_messages(vec![
768 tr("Traceback (most recent call last):\n ValueError"),
769 tr("ok"),
770 tr("ok"),
771 ]);
772 assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
774 assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
776 }
777
778 #[test]
779 fn extract_openai_chat_tool_results() {
780 let request = with_messages(vec![
781 Message::text(Role::User, "do something"),
782 tc("Edit"),
783 tr("Traceback (most recent call last):\n ValueError"),
784 ]);
785 let sig = ToolSignals::from_request(&request, None);
786 assert_eq!(sig.severity, HARD);
787 assert_eq!(sig.edit_count, 1);
788 assert_eq!(sig.turn_depth, 3);
789 }
790
791 #[test]
792 fn extract_anthropic_tool_results() {
793 let request = with_messages(vec![tr("Traceback (most recent call last):\n ValueError")]);
794 let sig = ToolSignals::from_request(&request, None);
795 assert_eq!(sig.severity, HARD);
796 }
797
798 #[test]
799 fn extract_responses_api_tool_results() {
800 let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
801 let sig = ToolSignals::from_request(&request, None);
802 assert_eq!(sig.severity, 0.0);
803 assert_eq!(sig.write_count, 1);
804 }
805
806 #[test]
807 fn recent_window_counts_only_last_default_window_tool_calls() {
808 let request = with_messages(vec![
811 tc("Write"),
812 tr("ok"),
813 tc("Write"),
814 tr("ok"),
815 tc("Write"),
816 tr("ok"),
817 tc("Write"),
818 tr("ok"),
819 tc("Write"),
820 tr("ok"),
821 tc("Edit"),
822 tr("ok"),
823 ]);
824 let sig = ToolSignals::from_request(&request, None);
825 assert_eq!(sig.write_count, 5);
826 assert_eq!(sig.edit_count, 1);
827 assert_eq!(sig.recent_write_count, 2);
828 assert_eq!(sig.recent_edit_count, 1);
829 }
830
831 #[test]
832 fn codex_apply_patch_counts_as_an_edit() {
833 let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
834 let sig = ToolSignals::from_request(&request, None);
835 assert_eq!(sig.edit_count, 1);
836 assert_eq!(sig.recent_edit_count, 1);
837 }
838
839 fn exec_command(cmd: Value) -> Message {
840 Message {
841 role: Role::Assistant,
842 content: vec![ContentBlock::ToolCall(ToolCall {
843 id: String::new(),
844 name: "exec_command".to_string(),
845 arguments: cmd,
846 })],
847 }
848 }
849
850 #[test]
851 fn codex_exec_command_is_classified() {
852 let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
854 let request = with_messages(vec![exec_command(args), tr("ok")]);
855 assert_eq!(
856 ToolSignals::from_request(&request, None).recent_edit_count,
857 1
858 );
859 }
860
861 #[test]
862 fn python_write_expressions_need_a_python_command() {
863 let write = with_messages(vec![
864 exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
865 tr("ok"),
866 ]);
867 assert_eq!(
868 ToolSignals::from_request(&write, None).recent_write_count,
869 1
870 );
871
872 let search = with_messages(vec![
873 exec_command(json!({"cmd": "grep -R '.write(' src"})),
874 tr("ok"),
875 ]);
876 assert_eq!(
877 ToolSignals::from_request(&search, None).recent_write_count,
878 0
879 );
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}