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 "write_text(",
128 "writelines(",
129 ".write(",
130];
131
132static BASH_EDIT_PATTERNS: &[&str] = &[
133 "sed -i",
134 "sed --in-place",
135 "awk -i inplace",
136 "awk 'inplace=1'",
137 "patch ",
138 "patch -p",
139 "perl -i",
140 "perl -p -i",
141 "perl -pi",
142];
143
144static BASH_READ_PATTERNS: &[&str] = &[
147 "cat /", "cat ./", "cat ../", "grep ", "ls ", "ls -", "find ", "head ", "tail ", "wc ",
148 "diff ", "which ", "ps ", "df ", "du ", "stat ", "file ", "less ", "more ",
149];
150
151static READ_TOOL_NAMES: &[&str] = &["read", "view", "read_file", "search_files"];
152
153static PLAN_TOOL_NAMES: &[&str] = &["todowrite", "todo_write", "todo", "update_plan"];
156
157static BASH_TOOL_NAMES: &[&str] = &[
162 "bash",
163 "shell_command",
164 "shell",
165 "local_shell_call",
166 "terminal",
167 "exec_command", ];
169
170static TEST_PASS_PHRASES: &[&str] = &[
173 " passed",
174 "passed in",
175 "tests passed",
176 "all tests passed",
177 "test ok",
178 "test result: ok",
179 "passed.\n",
180 "tests pass",
181 "\nok ", "✓ ",
183];
184
185static TEST_FAILURE_LITERAL: &[&str] = &["✗ ", "fatal:", "assertionerror", "error:"];
190
191static NUMERIC_FAILURE_KEYWORDS: &[&str] = &["failed", "failure", "failures", "errors", "error"];
195
196pub const DEFAULT_RECENT_WINDOW: usize = 3;
203
204#[derive(Clone, Debug, Default)]
211pub struct ToolSignals {
212 pub severity: f32,
217 pub no_error_streak: u32,
219 pub edit_count: u32,
221 pub write_count: u32,
223 pub read_count: u32,
225 pub todowrite_count: u32,
228 pub recent_edit_count: u32,
230 pub recent_write_count: u32,
232 pub recent_read_count: u32,
234 pub recent_todowrite_count: u32,
236 pub pure_bash_streak: u32,
239 pub tests_passed: bool,
241 pub turn_depth: u32,
245 pub compacted: bool,
251}
252
253impl ToolSignals {
254 pub fn from_request(request: &Request, window_size: Option<usize>) -> Self {
259 extract_tool_signals_with_window(request, window_size.unwrap_or(DEFAULT_RECENT_WINDOW))
260 }
261}
262
263#[derive(Debug, Clone)]
265struct ObservedToolCall {
266 name: String,
267 command: Option<String>,
268}
269
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271enum ToolCategory {
272 Write,
273 Edit,
274 Read,
275 Plan,
276 Other,
277}
278
279#[derive(Debug, Clone)]
282pub struct ToolSignalProcessor {
283 pub recent_window: usize,
286}
287
288impl Default for ToolSignalProcessor {
289 fn default() -> Self {
290 Self {
291 recent_window: DEFAULT_RECENT_WINDOW,
292 }
293 }
294}
295
296#[async_trait]
297impl Processor<State> for ToolSignalProcessor {
298 async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
299 if let Event::Request { request: req, .. } = event {
300 let tool_signal = ToolSignals::from_request(req, Some(self.recent_window));
301 state.tool_signals = Some(tool_signal);
302 }
303 Ok(())
304 }
305}
306
307fn classify_tool_call(name: &str, command: Option<&str>) -> ToolCategory {
308 let lower = name.to_lowercase();
309 if WRITE_TOOL_NAMES.contains(&lower.as_str()) {
310 return ToolCategory::Write;
311 }
312 if EDIT_TOOL_NAMES.contains(&lower.as_str()) {
313 return ToolCategory::Edit;
314 }
315 if READ_TOOL_NAMES.contains(&lower.as_str()) {
316 return ToolCategory::Read;
317 }
318 if PLAN_TOOL_NAMES.contains(&lower.as_str()) {
319 return ToolCategory::Plan;
320 }
321 if BASH_TOOL_NAMES.contains(&lower.as_str())
322 && let Some(cmd) = command
323 {
324 if BASH_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
326 return ToolCategory::Write;
327 }
328 if BASH_EDIT_PATTERNS.iter().any(|p| cmd.contains(p)) {
329 return ToolCategory::Edit;
330 }
331 if BASH_READ_PATTERNS.iter().any(|p| cmd.contains(p)) {
332 return ToolCategory::Read;
333 }
334 }
335 ToolCategory::Other
336}
337
338fn extract_tool_signals_with_window(request: &Request, recent_window: usize) -> ToolSignals {
345 let messages = &request.llm_request.messages;
349 let mut tool_texts: Vec<String> = Vec::new();
350 let mut tool_calls: Vec<ObservedToolCall> = Vec::new();
351 let mut compacted = false;
352
353 for message in messages {
354 for block in &message.content {
355 match block {
356 ContentBlock::ToolCall(call) => {
357 tool_calls.push(ObservedToolCall {
358 name: call.name.clone(),
359 command: command_of(&call.arguments),
360 });
361 }
362 ContentBlock::ToolResult(result) => {
363 let text = result
364 .content
365 .iter()
366 .filter_map(text_of)
367 .collect::<Vec<_>>()
368 .join("\n");
369 if !text.is_empty() {
370 tool_texts.push(text);
371 }
372 }
373 ContentBlock::Text { text } => {
377 compacted |= text.to_lowercase().contains(COMPACTION_MARKER);
378 }
379 _ => {}
380 }
381 }
382 }
383
384 let mut signal = build_signal(tool_texts, tool_calls, messages.len() as u32, recent_window);
385 signal.compacted = compacted;
386 signal
387}
388
389const COMPACTION_MARKER: &str = "session is being continued";
392
393fn command_of(arguments: &Value) -> Option<String> {
396 let decoded = arguments
397 .as_str()
398 .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
399 let object = decoded.as_ref().unwrap_or(arguments);
400
401 ["command", "cmd", "input"]
402 .iter()
403 .filter_map(|key| object.get(*key))
404 .find_map(command_text)
405}
406
407fn command_text(value: &Value) -> Option<String> {
409 match value {
410 Value::String(text) => Some(text.to_lowercase()),
411 Value::Array(parts) => {
412 let joined = parts
413 .iter()
414 .filter_map(Value::as_str)
415 .collect::<Vec<_>>()
416 .join(" ");
417 (!joined.is_empty()).then(|| joined.to_lowercase())
418 }
419 _ => None,
420 }
421}
422
423fn text_of(block: &ContentBlock) -> Option<&str> {
425 match block {
426 ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
427 _ => None,
428 }
429}
430
431fn reports_no_failure(text: &str) -> bool {
435 let lower = text.to_lowercase();
436 if lower.contains("process running with session id") {
437 return true;
438 }
439 let mut saw_zero = false;
440 for marker in ["exited with code ", "exit code: "] {
441 for tail in lower.split(marker).skip(1) {
442 let code: String = tail.chars().take_while(char::is_ascii_digit).collect();
443 match code.as_str() {
444 "" => {}
445 "0" => saw_zero = true,
446 _ => return false,
447 }
448 }
449 }
450 saw_zero
451}
452
453fn build_signal(
454 tool_texts: Vec<String>,
455 tool_calls: Vec<ObservedToolCall>,
456 turn_depth: u32,
457 recent_window: usize,
458) -> ToolSignals {
459 let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
465 let mut severity = 0.0f32;
466 for text in &tool_texts[sev_start..] {
467 if reports_no_failure(text) {
468 continue;
469 }
470 let (sev, _patterns) = classify_text(text);
471 if sev > severity {
472 severity = sev;
473 }
474 }
475
476 let no_error_streak = compute_no_error_streak(&tool_texts);
477
478 let recent_start = tool_calls.len().saturating_sub(recent_window);
482 let mut write_count = 0u32;
483 let mut edit_count = 0u32;
484 let mut read_count = 0u32;
485 let mut todowrite_count = 0u32;
486 let mut recent_write_count = 0u32;
487 let mut recent_edit_count = 0u32;
488 let mut recent_read_count = 0u32;
489 let mut recent_todowrite_count = 0u32;
490 let mut pure_bash_streak = 0u32;
491 let mut streak_open = true;
492 for (i, tc) in tool_calls.iter().enumerate().rev() {
493 let cat = classify_tool_call(&tc.name, tc.command.as_deref());
494 if streak_open {
495 if matches!(cat, ToolCategory::Other) {
496 pure_bash_streak += 1;
497 } else {
498 streak_open = false;
499 }
500 }
501 match cat {
502 ToolCategory::Write => {
503 write_count += 1;
504 if i >= recent_start {
505 recent_write_count += 1;
506 }
507 }
508 ToolCategory::Edit => {
509 edit_count += 1;
510 if i >= recent_start {
511 recent_edit_count += 1;
512 }
513 }
514 ToolCategory::Read => {
515 read_count += 1;
516 if i >= recent_start {
517 recent_read_count += 1;
518 }
519 }
520 ToolCategory::Plan => {
521 todowrite_count += 1;
522 if i >= recent_start {
523 recent_todowrite_count += 1;
524 }
525 }
526 ToolCategory::Other => {}
527 }
528 }
529
530 let tests_passed = detect_tests_passed(&tool_texts, recent_window);
531
532 ToolSignals {
533 severity,
534 no_error_streak,
535 edit_count,
536 write_count,
537 read_count,
538 todowrite_count,
539 recent_edit_count,
540 recent_write_count,
541 recent_read_count,
542 recent_todowrite_count,
543 pure_bash_streak,
544 tests_passed,
545 turn_depth,
546 compacted: false,
549 }
550}
551
552fn content_to_text(content: Option<&Value>) -> Option<String> {
556 match content? {
557 Value::String(s) => Some(s.clone()),
558 Value::Array(blocks) => {
559 let parts: Vec<&str> = blocks
560 .iter()
561 .filter_map(|b| {
562 b.as_object()
563 .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
564 .and_then(|o| o.get("text"))
565 .and_then(Value::as_str)
566 })
567 .collect();
568 if parts.is_empty() {
569 None
570 } else {
571 Some(parts.join("\n"))
572 }
573 }
574 _ => None,
575 }
576}
577
578pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
582 let lower = text.to_lowercase();
583 let mut patterns = Vec::new();
584 let mut severity: f32 = 0.0;
585 for (name, sev, substrings) in ERROR_PATTERNS {
586 if substrings.iter().any(|sub| lower.contains(sub)) {
587 patterns.push(name.to_string());
588 severity = severity.max(*sev);
589 }
590 }
591 (severity, patterns)
592}
593
594fn compute_no_error_streak(tool_texts: &[String]) -> u32 {
595 let mut streak = 0u32;
596 for text in tool_texts.iter().rev() {
597 let (sev, _) = classify_text(text);
598 if sev > 0.0 {
599 break;
600 }
601 streak += 1;
602 }
603 streak
604}
605
606fn contains_failure_literal(lower: &str) -> bool {
609 TEST_FAILURE_LITERAL.iter().any(|literal| {
610 let mut cursor = 0usize;
611 while let Some(rel) = lower[cursor..].find(literal) {
612 let end = cursor + rel + literal.len();
613 if !lower[end..].starts_with(':') {
614 return true;
615 }
616 cursor = end;
617 }
618 false
619 })
620}
621
622fn detect_tests_passed(tool_texts: &[String], recent_window: usize) -> bool {
623 let start = tool_texts.len().saturating_sub(recent_window.max(1));
624 tool_texts[start..].iter().any(|text| {
625 let lower = text.to_lowercase();
626 TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
627 && !contains_failure_literal(&lower)
628 && !has_nonzero_failure_count(&lower)
629 })
630}
631
632fn has_nonzero_failure_count(lower: &str) -> bool {
638 for kw in NUMERIC_FAILURE_KEYWORDS {
639 let mut cursor = 0usize;
640 while let Some(rel) = lower[cursor..].find(kw) {
641 let kw_start = cursor + rel;
642 let kw_end = kw_start + kw.len();
643 let boundary_after = lower[kw_end..]
646 .chars()
647 .next()
648 .is_none_or(|c| !c.is_ascii_alphanumeric());
649 if boundary_after {
650 let prefix = &lower[..kw_start];
651 let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
652 let digits_rev: String = trimmed
653 .chars()
654 .rev()
655 .take_while(|c| c.is_ascii_digit())
656 .collect();
657 if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
658 return true;
659 }
660 }
661 cursor = kw_start + kw.len();
662 }
663 }
664 false
665}
666
667#[cfg(test)]
670mod tests {
671 use super::*;
672 use serde_json::json;
673 use switchyard_protocol::{ContentBlock, LlmRequest, Message, Role, ToolCall, ToolResult};
674
675 fn with_messages(messages: Vec<Message>) -> Request {
676 Request {
677 llm_request: LlmRequest {
678 messages,
679 ..LlmRequest::default()
680 },
681 raw_request: None,
682 metadata: None,
683 }
684 }
685
686 fn tc(name: &str) -> Message {
688 Message {
689 role: Role::Assistant,
690 content: vec![ContentBlock::ToolCall(ToolCall {
691 id: String::new(),
692 name: name.to_string(),
693 arguments: json!({}),
694 })],
695 }
696 }
697
698 fn bash(command: &str) -> Message {
700 Message {
701 role: Role::Assistant,
702 content: vec![ContentBlock::ToolCall(ToolCall {
703 id: String::new(),
704 name: "Bash".to_string(),
705 arguments: json!({"command": command}),
706 })],
707 }
708 }
709
710 fn tr(text: &str) -> Message {
712 Message {
713 role: Role::User,
714 content: vec![ContentBlock::ToolResult(ToolResult {
715 tool_call_id: String::new(),
716 content: vec![ContentBlock::Text {
717 text: text.to_string(),
718 }],
719 is_error: None,
720 })],
721 }
722 }
723
724 #[test]
725 fn clean_text_has_zero_severity() {
726 let (sev, patterns) = classify_text("everything went fine");
727 assert_eq!(sev, 0.0);
728 assert!(patterns.is_empty());
729 }
730
731 #[test]
732 fn traceback_is_hard() {
733 let (sev, patterns) = classify_text("Traceback (most recent call last):\n ValueError");
734 assert_eq!(sev, HARD);
735 assert!(patterns.contains(&"traceback".to_string()));
736 }
737
738 #[test]
739 fn oom_is_critical() {
740 let (sev, _) = classify_text("Out of memory: kill process 1234");
741 assert_eq!(sev, CRITICAL);
742 }
743
744 #[test]
745 fn severity_is_max_across_patterns() {
746 let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
748 assert_eq!(sev, HARD);
749 }
750
751 #[test]
752 fn file_does_not_exist_is_hard() {
753 let (sev, patterns) =
755 classify_text("Error: File does not exist. Note: current working directory is /app.");
756 assert_eq!(sev, HARD);
757 assert!(patterns.contains(&"no_such_file".to_string()));
758 }
759
760 #[test]
761 fn bare_does_not_exist_stays_clean() {
762 let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
765 assert_eq!(sev, 0.0);
766 }
767
768 #[test]
769 fn no_error_streak_all_clean() {
770 let texts = vec!["ok".to_string(), "all good".to_string()];
771 assert_eq!(compute_no_error_streak(&texts), 2);
772 }
773
774 #[test]
775 fn no_error_streak_stops_at_error() {
776 let texts = vec![
777 "Traceback (most recent call last):".to_string(),
778 "ok".to_string(),
779 "ok".to_string(),
780 ];
781 assert_eq!(compute_no_error_streak(&texts), 2);
782 }
783
784 #[test]
785 fn tests_passed_detects_pytest_output() {
786 assert!(detect_tests_passed(
787 &["====== 5 passed in 0.12s ======".to_string()],
788 DEFAULT_RECENT_WINDOW
789 ));
790 }
791
792 #[test]
793 fn tests_passed_ignores_partial_failures() {
794 assert!(!detect_tests_passed(
795 &["2 failed, 5 passed in 0.56s".to_string()],
796 DEFAULT_RECENT_WINDOW
797 ));
798 }
799
800 #[test]
801 fn severity_is_windowed_over_recent_results() {
802 let request = with_messages(vec![
804 tr("Traceback (most recent call last):\n ValueError"),
805 tr("ok"),
806 tr("ok"),
807 ]);
808 assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
810 assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
812 }
813
814 #[test]
815 fn extract_openai_chat_tool_results() {
816 let request = with_messages(vec![
817 Message::text(Role::User, "do something"),
818 tc("Edit"),
819 tr("Traceback (most recent call last):\n ValueError"),
820 ]);
821 let sig = ToolSignals::from_request(&request, None);
822 assert_eq!(sig.severity, HARD);
823 assert_eq!(sig.edit_count, 1);
824 assert_eq!(sig.turn_depth, 3);
825 }
826
827 #[test]
828 fn extract_anthropic_tool_results() {
829 let request = with_messages(vec![tr("Traceback (most recent call last):\n ValueError")]);
830 let sig = ToolSignals::from_request(&request, None);
831 assert_eq!(sig.severity, HARD);
832 }
833
834 #[test]
835 fn extract_responses_api_tool_results() {
836 let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
837 let sig = ToolSignals::from_request(&request, None);
838 assert_eq!(sig.severity, 0.0);
839 assert_eq!(sig.write_count, 1);
840 }
841
842 #[test]
843 fn recent_window_counts_only_last_default_window_tool_calls() {
844 let request = with_messages(vec![
847 tc("Write"),
848 tr("ok"),
849 tc("Write"),
850 tr("ok"),
851 tc("Write"),
852 tr("ok"),
853 tc("Write"),
854 tr("ok"),
855 tc("Write"),
856 tr("ok"),
857 tc("Edit"),
858 tr("ok"),
859 ]);
860 let sig = ToolSignals::from_request(&request, None);
861 assert_eq!(sig.write_count, 5);
862 assert_eq!(sig.edit_count, 1);
863 assert_eq!(sig.recent_write_count, 2);
864 assert_eq!(sig.recent_edit_count, 1);
865 }
866
867 fn exec_command(arguments: Value) -> Message {
868 Message {
869 role: Role::Assistant,
870 content: vec![ContentBlock::ToolCall(ToolCall {
871 id: String::new(),
872 name: "exec_command".to_string(),
873 arguments,
874 })],
875 }
876 }
877
878 #[test]
879 fn exit_zero_is_not_an_error() {
880 let source = "static CRITICAL: &[&str] = &[\"out of memory\", \"connection refused\"];";
881 let request = with_messages(vec![
882 exec_command(json!({"cmd": "sed -n 1,40p tool_signals.rs"})),
883 tr(&format!("Process exited with code 0\nOutput: {source}")),
884 ]);
885 assert_eq!(ToolSignals::from_request(&request, None).severity, 0.0);
886 }
887
888 #[test]
889 fn streaming_chunk_is_not_a_failure() {
890 let request = with_messages(vec![
892 exec_command(json!({"cmd": "python3 - <<'PY'"})),
893 tr(
894 "Process running with session ID 14028\nOutput:\n+ tr(\"Traceback (most recent call last)\"),",
895 ),
896 ]);
897 assert_eq!(ToolSignals::from_request(&request, None).severity, 0.0);
898 }
899
900 #[test]
901 fn rust_path_is_not_a_failure() {
902 let passing = "test error::tests::preserves_source ... ok\n\
903 test result: ok. 268 passed; 0 failed; 0 ignored";
904 let request = with_messages(vec![
905 exec_command(json!({"cmd": "cargo test"})),
906 tr(passing),
907 ]);
908 assert!(ToolSignals::from_request(&request, None).tests_passed);
909 }
910
911 #[test]
912 fn exec_command_arg_shapes() {
913 for arguments in [
914 json!({"command": "sed -i s/a/b/ src/lib.rs"}),
915 json!({"cmd": "sed -i s/a/b/ src/lib.rs"}),
916 json!({"cmd": ["bash", "-lc", "sed -i s/a/b/ src/lib.rs"]}),
917 json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#),
918 ] {
919 let request = with_messages(vec![exec_command(arguments.clone()), tr("ok")]);
920 let signal = ToolSignals::from_request(&request, None);
921 assert_eq!(signal.recent_edit_count, 1, "edit count for {arguments}");
922 }
923 }
924
925 #[test]
926 fn heredoc_script_is_a_write() {
927 let request = with_messages(vec![
928 exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
929 tr("ok"),
930 ]);
931 assert_eq!(
932 ToolSignals::from_request(&request, None).recent_write_count,
933 1
934 );
935 }
936
937 #[test]
938 fn codex_apply_patch_counts_as_an_edit() {
939 let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
940 let sig = ToolSignals::from_request(&request, None);
941 assert_eq!(sig.edit_count, 1);
942 assert_eq!(sig.recent_edit_count, 1);
943 }
944
945 #[test]
946 fn recent_window_size_is_caller_overridable() {
947 let request = with_messages(vec![
951 tc("Write"),
952 tr("ok"),
953 tc("Write"),
954 tr("ok"),
955 tc("Write"),
956 tr("ok"),
957 tc("Write"),
958 tr("ok"),
959 tc("Write"),
960 tr("ok"),
961 tc("Edit"),
962 tr("ok"),
963 ]);
964 let narrow = extract_tool_signals_with_window(&request, 3);
965 assert_eq!(narrow.recent_write_count, 2);
966 assert_eq!(narrow.recent_edit_count, 1);
967
968 let wide = extract_tool_signals_with_window(&request, 6);
969 assert_eq!(wide.recent_write_count, 5);
970 assert_eq!(wide.recent_edit_count, 1);
971 }
972
973 #[test]
974 fn compaction_marker_sets_compacted() {
975 let request = with_messages(vec![
977 Message::text(
978 Role::User,
979 "This session is being continued from a previous conversation that ran out of context.",
980 ),
981 bash("ls"),
982 ]);
983 assert!(ToolSignals::from_request(&request, None).compacted);
984 }
985
986 #[test]
987 fn no_compaction_marker_stays_uncompacted() {
988 let request = with_messages(vec![
989 Message::text(Role::User, "Write a script that parses the log file."),
990 bash("ls"),
991 ]);
992 assert!(!ToolSignals::from_request(&request, None).compacted);
993 }
994
995 #[test]
996 fn bash_heredoc_counts_as_write() {
997 let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
999 let sig = ToolSignals::from_request(&request, None);
1000 assert_eq!(
1001 sig.write_count, 1,
1002 "Bash heredoc should bucket into write_count"
1003 );
1004 assert_eq!(sig.edit_count, 0);
1005 }
1006
1007 #[test]
1008 fn bash_sed_inplace_counts_as_edit() {
1009 let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
1010 let sig = ToolSignals::from_request(&request, None);
1011 assert_eq!(
1012 sig.edit_count, 1,
1013 "Bash sed -i should bucket into edit_count"
1014 );
1015 assert_eq!(sig.write_count, 0);
1016 }
1017
1018 #[test]
1019 fn bash_non_mutating_does_not_count() {
1020 let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
1022 let sig = ToolSignals::from_request(&request, None);
1023 assert_eq!(sig.write_count, 0);
1024 assert_eq!(sig.edit_count, 0);
1025 }
1026
1027 #[test]
1028 fn tests_passed_detects_pytest_with_failure_block() {
1029 assert!(!detect_tests_passed(
1031 &["2 failed, 5 passed in 0.56s".to_string()],
1032 DEFAULT_RECENT_WINDOW
1033 ));
1034 }
1035
1036 #[test]
1037 fn tests_passed_accepts_cargo_clean_summary() {
1038 assert!(detect_tests_passed(
1041 &["running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string()],
1042 DEFAULT_RECENT_WINDOW
1043 ));
1044 }
1045
1046 #[test]
1047 fn tests_passed_rejects_cargo_real_failure() {
1048 assert!(!detect_tests_passed(
1050 &["running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string()],
1051 DEFAULT_RECENT_WINDOW
1052 ));
1053 }
1054
1055 #[test]
1056 fn tests_passed_accepts_go_clean_summary() {
1057 assert!(detect_tests_passed(
1059 &["ok github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string()],
1060 DEFAULT_RECENT_WINDOW
1061 ));
1062 }
1063
1064 #[test]
1065 fn tests_passed_accepts_pytest_zero_errors() {
1066 assert!(detect_tests_passed(
1068 &["5 passed, 0 errors in 0.30s".to_string()],
1069 DEFAULT_RECENT_WINDOW
1070 ));
1071 }
1072
1073 #[test]
1074 fn tests_passed_detects_diy_checkmark() {
1075 assert!(detect_tests_passed(
1076 &["✓ all checks passed".to_string()],
1077 DEFAULT_RECENT_WINDOW
1078 ));
1079 }
1080
1081 #[test]
1082 fn anthropic_bash_heredoc_extracts_command() {
1083 let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
1085 let sig = ToolSignals::from_request(&request, None);
1086 assert_eq!(
1087 sig.write_count, 1,
1088 "Anthropic Bash heredoc must also be detected"
1089 );
1090 }
1091
1092 #[test]
1093 fn recent_window_falls_back_to_full_history_when_short() {
1094 let request = with_messages(vec![tc("Write")]);
1095 let sig = ToolSignals::from_request(&request, None);
1096 assert_eq!(sig.recent_write_count, 1);
1097 assert_eq!(sig.recent_edit_count, 0);
1098 }
1099
1100 #[test]
1101 fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
1102 let request = with_messages(vec![tr("output ok"), tr("another ok")]);
1103 let sig = ToolSignals::from_request(&request, None);
1104 assert_eq!(sig.severity, 0.0);
1105 assert_eq!(sig.no_error_streak, 2);
1106 }
1107
1108 #[test]
1111 fn todowrite_classifies_as_plan() {
1112 assert_eq!(classify_tool_call("TodoWrite", None), ToolCategory::Plan);
1113 assert_eq!(classify_tool_call("todo_write", None), ToolCategory::Plan);
1114 }
1115
1116 #[test]
1117 fn codex_update_plan_classifies_as_plan() {
1118 assert_eq!(classify_tool_call("update_plan", None), ToolCategory::Plan);
1119 }
1120
1121 #[test]
1122 fn codex_shell_command_runs_bash_pattern_match() {
1123 assert_eq!(
1125 classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
1126 ToolCategory::Write,
1127 );
1128 assert_eq!(
1130 classify_tool_call("shell_command", Some("ls /app")),
1131 ToolCategory::Read,
1132 );
1133 assert_eq!(
1135 classify_tool_call("shell_command", Some("./run_tests.sh")),
1136 ToolCategory::Other,
1137 );
1138 }
1139
1140 #[test]
1141 fn read_tool_classifies_as_read() {
1142 assert_eq!(classify_tool_call("Read", None), ToolCategory::Read);
1143 assert_eq!(classify_tool_call("View", None), ToolCategory::Read);
1144 }
1145
1146 #[test]
1147 fn hermes_tool_names_classify() {
1148 assert_eq!(classify_tool_call("write_file", None), ToolCategory::Write);
1150 assert_eq!(classify_tool_call("patch", None), ToolCategory::Edit);
1151 assert_eq!(classify_tool_call("read_file", None), ToolCategory::Read);
1152 assert_eq!(classify_tool_call("search_files", None), ToolCategory::Read);
1153 assert_eq!(
1156 classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
1157 ToolCategory::Edit,
1158 );
1159 assert_eq!(
1160 classify_tool_call("terminal", Some("grep foo /app")),
1161 ToolCategory::Read,
1162 );
1163 assert_eq!(
1164 classify_tool_call("terminal", Some("./run_tests.sh")),
1165 ToolCategory::Other,
1166 );
1167 }
1168
1169 #[test]
1170 fn bash_read_patterns_classify_as_read() {
1171 let cases = [
1172 "cat /etc/passwd",
1173 "grep foo bar.txt",
1174 "ls /app",
1175 "find . -name '*.py'",
1176 ];
1177 for cmd in cases {
1178 assert_eq!(
1179 classify_tool_call("Bash", Some(cmd)),
1180 ToolCategory::Read,
1181 "expected Read for {cmd}"
1182 );
1183 }
1184 }
1185
1186 #[test]
1187 fn bash_write_precedence_over_read() {
1188 assert_eq!(
1191 classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
1192 ToolCategory::Write,
1193 );
1194 }
1195
1196 #[test]
1197 fn pure_bash_streak_counts_trailing_other() {
1198 let request = with_messages(vec![
1200 bash("make"),
1201 tr("ok"),
1202 bash("./configure"),
1203 tr("ok"),
1204 bash("make install"),
1205 tr("ok"),
1206 bash("./run.sh"),
1207 tr("ok"),
1208 bash("./test"),
1209 tr("ok"),
1210 ]);
1211 let sig = ToolSignals::from_request(&request, None);
1212 assert_eq!(sig.pure_bash_streak, 5);
1213 assert_eq!(sig.write_count, 0);
1214 assert_eq!(sig.read_count, 0);
1215 }
1216
1217 #[test]
1218 fn pure_bash_streak_resets_on_write() {
1219 let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
1220 let sig = ToolSignals::from_request(&request, None);
1221 assert_eq!(sig.pure_bash_streak, 0);
1222 assert_eq!(sig.write_count, 1);
1223 }
1224
1225 #[test]
1226 fn recent_window_tracks_todowrite_and_read() {
1227 let request = with_messages(vec![
1229 bash("make"),
1230 tr("ok"),
1231 tc("TodoWrite"),
1232 tr("ok"),
1233 tc("Read"),
1234 tr("ok"),
1235 tc("TodoWrite"),
1236 tr("ok"),
1237 ]);
1238 let sig = ToolSignals::from_request(&request, None);
1239 assert_eq!(sig.todowrite_count, 2);
1240 assert_eq!(sig.recent_todowrite_count, 2);
1241 assert_eq!(sig.read_count, 1);
1242 assert_eq!(sig.recent_read_count, 1);
1243 }
1244}