1#![allow(dead_code)]
14
15use async_trait::async_trait;
16use serde_json::Value;
17use switchyard_protocol::{ContentBlock, Request, Role};
18
19use crate::Result;
20
21use crate::core::processor::{Event, Processor};
22use crate::core::state::State;
23
24const SOFT: f32 = 0.3;
27const HARD: f32 = 0.7;
28const CRITICAL: f32 = 1.0;
29
30static ERROR_PATTERNS: &[(&str, f32, &[&str])] = &[
34 (
35 "oom",
36 CRITICAL,
37 &["out of memory", "memoryerror", "cannot allocate memory"],
38 ),
39 (
40 "connection_refused",
41 CRITICAL,
42 &[
43 "connection refused",
44 "connectionrefusederror",
45 "econnrefused",
46 ],
47 ),
48 ("traceback", HARD, &["traceback (most recent call last)"]),
49 (
50 "import_error",
51 HARD,
52 &["modulenotfounderror:", "importerror:", "no module named "],
53 ),
54 (
55 "cmd_not_found",
56 HARD,
57 &["command not found", "not found\n", "/usr/bin/env: "],
58 ),
59 ("assertion", HARD, &["assertionerror"]),
60 ("value_error", HARD, &["valueerror:"]),
61 ("syntax_error", HARD, &["syntaxerror:"]),
62 (
63 "timeout",
64 HARD,
65 &[
66 "timed out",
67 "timeouterror",
68 "timeout expired",
69 "deadline exceeded",
70 ],
71 ),
72 (
73 "no_such_file",
74 HARD,
75 &[
76 "filenotfounderror:",
77 "no such file or directory",
78 "file does not exist",
82 ],
83 ),
84 (
86 "exit_nonzero",
87 SOFT,
88 &[
89 "exit code 1",
90 "exit code 2",
91 "exit status 1",
92 "returned non-zero",
93 "exited with code",
94 ],
95 ),
96];
97
98static EDIT_TOOL_NAMES: &[&str] = &[
99 "edit",
100 "multiedit",
101 "notebookedit",
102 "str_replace",
103 "str_replace_based_edit_tool",
104 "apply_patch", "text_editor",
106 "patch", ];
108
109static WRITE_TOOL_NAMES: &[&str] = &["write", "create_file", "new_file", "write_file"];
110
111static BASH_WRITE_PATTERNS: &[&str] = &[
115 "cat >",
116 "cat >>",
117 "echo >",
118 "echo >>",
119 "tee ",
120 "printf >",
121 "printf >>",
122 "> /",
123 ">> /",
124 "<< 'eof'",
125 "<<eof",
126 "<<'eof'",
127 "<< eof",
128];
129
130static PYTHON_WRITE_PATTERNS: &[&str] = &["write_text(", "writelines(", ".write("];
133
134static BASH_EDIT_PATTERNS: &[&str] = &[
135 "sed -i",
136 "sed --in-place",
137 "awk -i inplace",
138 "awk 'inplace=1'",
139 "patch ",
140 "patch -p",
141 "perl -i",
142 "perl -p -i",
143 "perl -pi",
144];
145
146static BASH_READ_PATTERNS: &[&str] = &[
149 "cat /", "cat ./", "cat ../", "grep ", "ls ", "ls -", "find ", "head ", "tail ", "wc ",
150 "diff ", "which ", "ps ", "df ", "du ", "stat ", "file ", "less ", "more ",
151];
152
153static READ_TOOL_NAMES: &[&str] = &["read", "view", "read_file", "search_files"];
154
155static PLAN_TOOL_NAMES: &[&str] = &["todowrite", "todo_write", "todo", "update_plan"];
158
159static BASH_TOOL_NAMES: &[&str] = &[
164 "bash",
165 "shell_command",
166 "shell",
167 "local_shell_call",
168 "terminal",
169 "exec_command", ];
171
172static TEST_PASS_PHRASES: &[&str] = &[
175 " passed",
176 "passed in",
177 "tests passed",
178 "all tests passed",
179 "test ok",
180 "test result: ok",
181 "passed.\n",
182 "tests pass",
183 "\nok ", "✓ ",
185];
186
187static TEST_FAILURE_LITERAL: &[&str] = &["✗ ", "fatal:", "assertionerror", "error:"];
192
193static NUMERIC_FAILURE_KEYWORDS: &[&str] = &["failed", "failure", "failures", "errors", "error"];
197
198pub const DEFAULT_RECENT_WINDOW: usize = 3;
205
206#[derive(Clone, Debug, Default)]
215pub struct ToolSignals {
216 pub severity: f32,
221 pub no_error_streak: u32,
223 pub edit_count: u32,
225 pub write_count: u32,
227 pub read_count: u32,
229 pub todowrite_count: u32,
232 pub recent_edit_count: u32,
234 pub recent_write_count: u32,
236 pub recent_read_count: u32,
238 pub recent_todowrite_count: u32,
240 pub pure_bash_streak: u32,
243 pub tests_passed: bool,
245 pub tool_result_count: u32,
248 pub assistant_turn_count: u32,
251 pub turn_depth: u32,
255 pub compacted: bool,
261}
262
263impl ToolSignals {
264 pub fn from_request(request: &Request, window_size: Option<usize>) -> Self {
269 extract_tool_signals_with_window(request, window_size.unwrap_or(DEFAULT_RECENT_WINDOW))
270 }
271}
272
273#[derive(Debug, Clone)]
275struct ObservedToolCall {
276 name: String,
277 command: Option<String>,
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281enum ToolCategory {
282 Write,
283 Edit,
284 Read,
285 Plan,
286 Other,
287}
288
289#[derive(Debug, Clone)]
292pub struct ToolSignalProcessor {
293 pub recent_window: usize,
296}
297
298impl Default for ToolSignalProcessor {
299 fn default() -> Self {
300 Self {
301 recent_window: DEFAULT_RECENT_WINDOW,
302 }
303 }
304}
305
306#[async_trait]
307impl Processor<State> for ToolSignalProcessor {
308 async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
309 if let Event::Request { request: req, .. } = event {
310 let tool_signal = ToolSignals::from_request(req, Some(self.recent_window));
311 state.tool_signals = Some(tool_signal);
312 }
313 Ok(())
314 }
315}
316
317fn classify_tool_call(name: &str, command: Option<&str>) -> ToolCategory {
318 let lower = name.to_lowercase();
319 if WRITE_TOOL_NAMES.contains(&lower.as_str()) {
320 return ToolCategory::Write;
321 }
322 if EDIT_TOOL_NAMES.contains(&lower.as_str()) {
323 return ToolCategory::Edit;
324 }
325 if READ_TOOL_NAMES.contains(&lower.as_str()) {
326 return ToolCategory::Read;
327 }
328 if PLAN_TOOL_NAMES.contains(&lower.as_str()) {
329 return ToolCategory::Plan;
330 }
331 if BASH_TOOL_NAMES.contains(&lower.as_str())
332 && let Some(cmd) = command
333 {
334 if BASH_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
336 return ToolCategory::Write;
337 }
338 if cmd.contains("python") && PYTHON_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
339 return ToolCategory::Write;
340 }
341 if BASH_EDIT_PATTERNS.iter().any(|p| cmd.contains(p)) {
342 return ToolCategory::Edit;
343 }
344 if BASH_READ_PATTERNS.iter().any(|p| cmd.contains(p)) {
345 return ToolCategory::Read;
346 }
347 }
348 ToolCategory::Other
349}
350
351fn extract_tool_signals_with_window(request: &Request, recent_window: usize) -> ToolSignals {
358 let messages = &request.llm_request.messages;
362 let mut tool_texts: Vec<String> = Vec::new();
363 let mut tool_calls: Vec<ObservedToolCall> = Vec::new();
364 let mut compacted = false;
365 let mut tool_result_count = 0usize;
366 let mut assistant_turn_count = 0usize;
367
368 for message in messages {
369 if message.role == Role::Assistant {
370 assistant_turn_count += 1;
371 }
372 for block in &message.content {
373 match block {
374 ContentBlock::ToolCall(call) => {
375 tool_calls.push(ObservedToolCall {
376 name: call.name.clone(),
377 command: command_of(&call.arguments),
378 });
379 }
380 ContentBlock::ToolResult(result) => {
381 tool_result_count += 1;
383 let text = result
384 .content
385 .iter()
386 .filter_map(text_of)
387 .collect::<Vec<_>>()
388 .join("\n");
389 if !text.is_empty() {
390 tool_texts.push(text);
391 }
392 }
393 ContentBlock::Text { text } => {
397 compacted |= text.to_lowercase().contains(COMPACTION_MARKER);
398 }
399 _ => {}
400 }
401 }
402 }
403
404 let mut signal = build_signal(tool_texts, tool_calls, messages.len() as u32, recent_window);
405 signal.compacted = compacted;
406 signal.tool_result_count = u32::try_from(tool_result_count).unwrap_or(u32::MAX);
407 signal.assistant_turn_count = u32::try_from(assistant_turn_count).unwrap_or(u32::MAX);
408 signal
409}
410
411const COMPACTION_MARKER: &str = "session is being continued";
414
415fn command_of(arguments: &Value) -> Option<String> {
418 let decoded = arguments
420 .as_str()
421 .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
422 let object = decoded.as_ref().unwrap_or(arguments);
423
424 ["command", "cmd", "input"]
425 .iter()
426 .filter_map(|key| object.get(*key))
427 .find_map(command_text)
428}
429
430fn command_text(value: &Value) -> Option<String> {
432 match value {
433 Value::String(text) => Some(text.to_lowercase()),
434 Value::Array(parts) => {
435 let joined = parts
436 .iter()
437 .filter_map(Value::as_str)
438 .collect::<Vec<_>>()
439 .join(" ");
440 (!joined.is_empty()).then(|| joined.to_lowercase())
441 }
442 _ => None,
443 }
444}
445
446fn text_of(block: &ContentBlock) -> Option<&str> {
448 match block {
449 ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
450 _ => None,
451 }
452}
453
454fn build_signal(
455 tool_texts: Vec<String>,
456 tool_calls: Vec<ObservedToolCall>,
457 turn_depth: u32,
458 recent_window: usize,
459) -> ToolSignals {
460 let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
466 let mut severity = 0.0f32;
467 for text in &tool_texts[sev_start..] {
468 let (sev, _patterns) = classify_text(text);
469 if sev > severity {
470 severity = sev;
471 }
472 }
473
474 let no_error_streak = compute_no_error_streak(&tool_texts);
475
476 let recent_start = tool_calls.len().saturating_sub(recent_window);
480 let mut write_count = 0u32;
481 let mut edit_count = 0u32;
482 let mut read_count = 0u32;
483 let mut todowrite_count = 0u32;
484 let mut recent_write_count = 0u32;
485 let mut recent_edit_count = 0u32;
486 let mut recent_read_count = 0u32;
487 let mut recent_todowrite_count = 0u32;
488 let mut pure_bash_streak = 0u32;
489 let mut streak_open = true;
490 for (i, tc) in tool_calls.iter().enumerate().rev() {
491 let cat = classify_tool_call(&tc.name, tc.command.as_deref());
492 if streak_open {
493 if matches!(cat, ToolCategory::Other) {
494 pure_bash_streak += 1;
495 } else {
496 streak_open = false;
497 }
498 }
499 match cat {
500 ToolCategory::Write => {
501 write_count += 1;
502 if i >= recent_start {
503 recent_write_count += 1;
504 }
505 }
506 ToolCategory::Edit => {
507 edit_count += 1;
508 if i >= recent_start {
509 recent_edit_count += 1;
510 }
511 }
512 ToolCategory::Read => {
513 read_count += 1;
514 if i >= recent_start {
515 recent_read_count += 1;
516 }
517 }
518 ToolCategory::Plan => {
519 todowrite_count += 1;
520 if i >= recent_start {
521 recent_todowrite_count += 1;
522 }
523 }
524 ToolCategory::Other => {}
525 }
526 }
527
528 let tests_passed = detect_tests_passed(&tool_texts, recent_window);
529
530 ToolSignals {
531 severity,
532 no_error_streak,
533 edit_count,
534 write_count,
535 read_count,
536 todowrite_count,
537 recent_edit_count,
538 recent_write_count,
539 recent_read_count,
540 recent_todowrite_count,
541 pure_bash_streak,
542 tests_passed,
543 turn_depth,
544 tool_result_count: 0,
548 assistant_turn_count: 0,
549 compacted: false,
550 }
551}
552
553fn content_to_text(content: Option<&Value>) -> Option<String> {
557 match content? {
558 Value::String(s) => Some(s.clone()),
559 Value::Array(blocks) => {
560 let parts: Vec<&str> = blocks
561 .iter()
562 .filter_map(|b| {
563 b.as_object()
564 .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
565 .and_then(|o| o.get("text"))
566 .and_then(Value::as_str)
567 })
568 .collect();
569 if parts.is_empty() {
570 None
571 } else {
572 Some(parts.join("\n"))
573 }
574 }
575 _ => None,
576 }
577}
578
579pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
583 let lower = text.to_lowercase();
584 let mut patterns = Vec::new();
585 let mut severity: f32 = 0.0;
586 for (name, sev, substrings) in ERROR_PATTERNS {
587 if substrings.iter().any(|sub| lower.contains(sub)) {
588 patterns.push(name.to_string());
589 severity = severity.max(*sev);
590 }
591 }
592 (severity, patterns)
593}
594
595fn compute_no_error_streak(tool_texts: &[String]) -> u32 {
596 let mut streak = 0u32;
597 for text in tool_texts.iter().rev() {
598 let (sev, _) = classify_text(text);
599 if sev > 0.0 {
600 break;
601 }
602 streak += 1;
603 }
604 streak
605}
606
607fn detect_tests_passed(tool_texts: &[String], recent_window: usize) -> bool {
608 let start = tool_texts.len().saturating_sub(recent_window.max(1));
609 tool_texts[start..].iter().any(|text| {
610 let lower = text.to_lowercase();
611 TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
612 && !TEST_FAILURE_LITERAL.iter().any(|p| lower.contains(p))
613 && !has_nonzero_failure_count(&lower)
614 })
615}
616
617fn has_nonzero_failure_count(lower: &str) -> bool {
623 for kw in NUMERIC_FAILURE_KEYWORDS {
624 let mut cursor = 0usize;
625 while let Some(rel) = lower[cursor..].find(kw) {
626 let kw_start = cursor + rel;
627 let kw_end = kw_start + kw.len();
628 let boundary_after = lower[kw_end..]
631 .chars()
632 .next()
633 .is_none_or(|c| !c.is_ascii_alphanumeric());
634 if boundary_after {
635 let prefix = &lower[..kw_start];
636 let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
637 let digits_rev: String = trimmed
638 .chars()
639 .rev()
640 .take_while(|c| c.is_ascii_digit())
641 .collect();
642 if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
643 return true;
644 }
645 }
646 cursor = kw_start + kw.len();
647 }
648 }
649 false
650}
651
652#[cfg(test)]
655mod tests {
656 use super::*;
657 use serde_json::json;
658 use switchyard_protocol::{ContentBlock, LlmRequest, Message, Role, ToolCall, ToolResult};
659
660 fn with_messages(messages: Vec<Message>) -> Request {
661 Request {
662 llm_request: LlmRequest {
663 messages,
664 ..LlmRequest::default()
665 },
666 raw_request: None,
667 metadata: None,
668 }
669 }
670
671 fn tc(name: &str) -> Message {
673 Message {
674 role: Role::Assistant,
675 content: vec![ContentBlock::ToolCall(ToolCall {
676 id: String::new(),
677 name: name.to_string(),
678 arguments: json!({}),
679 })],
680 }
681 }
682
683 fn bash(command: &str) -> Message {
685 Message {
686 role: Role::Assistant,
687 content: vec![ContentBlock::ToolCall(ToolCall {
688 id: String::new(),
689 name: "Bash".to_string(),
690 arguments: json!({"command": command}),
691 })],
692 }
693 }
694
695 fn tr(text: &str) -> Message {
697 Message {
698 role: Role::User,
699 content: vec![ContentBlock::ToolResult(ToolResult {
700 tool_call_id: String::new(),
701 content: vec![ContentBlock::Text {
702 text: text.to_string(),
703 }],
704 is_error: None,
705 })],
706 }
707 }
708
709 #[test]
710 fn clean_text_has_zero_severity() {
711 let (sev, patterns) = classify_text("everything went fine");
712 assert_eq!(sev, 0.0);
713 assert!(patterns.is_empty());
714 }
715
716 #[test]
717 fn traceback_is_hard() {
718 let (sev, patterns) = classify_text("Traceback (most recent call last):\n ValueError");
719 assert_eq!(sev, HARD);
720 assert!(patterns.contains(&"traceback".to_string()));
721 }
722
723 #[test]
724 fn oom_is_critical() {
725 let (sev, _) = classify_text("Out of memory: kill process 1234");
726 assert_eq!(sev, CRITICAL);
727 }
728
729 #[test]
730 fn severity_is_max_across_patterns() {
731 let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
733 assert_eq!(sev, HARD);
734 }
735
736 #[test]
737 fn file_does_not_exist_is_hard() {
738 let (sev, patterns) =
740 classify_text("Error: File does not exist. Note: current working directory is /app.");
741 assert_eq!(sev, HARD);
742 assert!(patterns.contains(&"no_such_file".to_string()));
743 }
744
745 #[test]
746 fn bare_does_not_exist_stays_clean() {
747 let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
750 assert_eq!(sev, 0.0);
751 }
752
753 #[test]
754 fn no_error_streak_all_clean() {
755 let texts = vec!["ok".to_string(), "all good".to_string()];
756 assert_eq!(compute_no_error_streak(&texts), 2);
757 }
758
759 #[test]
760 fn no_error_streak_stops_at_error() {
761 let texts = vec![
762 "Traceback (most recent call last):".to_string(),
763 "ok".to_string(),
764 "ok".to_string(),
765 ];
766 assert_eq!(compute_no_error_streak(&texts), 2);
767 }
768
769 #[test]
770 fn tests_passed_detects_pytest_output() {
771 assert!(detect_tests_passed(
772 &["====== 5 passed in 0.12s ======".to_string()],
773 DEFAULT_RECENT_WINDOW
774 ));
775 }
776
777 #[test]
778 fn tests_passed_ignores_partial_failures() {
779 assert!(!detect_tests_passed(
780 &["2 failed, 5 passed in 0.56s".to_string()],
781 DEFAULT_RECENT_WINDOW
782 ));
783 }
784
785 #[test]
786 fn severity_is_windowed_over_recent_results() {
787 let request = with_messages(vec![
789 tr("Traceback (most recent call last):\n ValueError"),
790 tr("ok"),
791 tr("ok"),
792 ]);
793 assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
795 assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
797 }
798
799 #[test]
800 fn extract_openai_chat_tool_results() {
801 let request = with_messages(vec![
802 Message::text(Role::User, "do something"),
803 tc("Edit"),
804 tr("Traceback (most recent call last):\n ValueError"),
805 ]);
806 let sig = ToolSignals::from_request(&request, None);
807 assert_eq!(sig.severity, HARD);
808 assert_eq!(sig.edit_count, 1);
809 assert_eq!(sig.turn_depth, 3);
810 }
811
812 #[test]
813 fn extract_anthropic_tool_results() {
814 let request = with_messages(vec![tr("Traceback (most recent call last):\n ValueError")]);
815 let sig = ToolSignals::from_request(&request, None);
816 assert_eq!(sig.severity, HARD);
817 }
818
819 #[test]
820 fn extract_responses_api_tool_results() {
821 let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
822 let sig = ToolSignals::from_request(&request, None);
823 assert_eq!(sig.severity, 0.0);
824 assert_eq!(sig.write_count, 1);
825 }
826
827 #[test]
828 fn conversation_counts_are_per_block_and_role_aware() {
829 let result = |content: Vec<ContentBlock>| {
833 ContentBlock::ToolResult(ToolResult {
834 tool_call_id: String::new(),
835 content,
836 is_error: None,
837 })
838 };
839 let request = with_messages(vec![
840 Message::text(Role::User, "do something"),
841 Message::text(Role::Assistant, "working"),
842 Message {
843 role: Role::User,
844 content: vec![
845 result(vec![ContentBlock::Text {
846 text: "ok".to_string(),
847 }]),
848 result(Vec::new()),
849 ],
850 },
851 tc("Bash"),
852 ]);
853 let sig = ToolSignals::from_request(&request, None);
854 assert_eq!(sig.tool_result_count, 2);
855 assert_eq!(sig.assistant_turn_count, 2);
856 assert_eq!(sig.turn_depth, 4);
857 }
858
859 #[test]
860 fn recent_window_counts_only_last_default_window_tool_calls() {
861 let request = with_messages(vec![
864 tc("Write"),
865 tr("ok"),
866 tc("Write"),
867 tr("ok"),
868 tc("Write"),
869 tr("ok"),
870 tc("Write"),
871 tr("ok"),
872 tc("Write"),
873 tr("ok"),
874 tc("Edit"),
875 tr("ok"),
876 ]);
877 let sig = ToolSignals::from_request(&request, None);
878 assert_eq!(sig.write_count, 5);
879 assert_eq!(sig.edit_count, 1);
880 assert_eq!(sig.recent_write_count, 2);
881 assert_eq!(sig.recent_edit_count, 1);
882 }
883
884 #[test]
885 fn codex_apply_patch_counts_as_an_edit() {
886 let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
887 let sig = ToolSignals::from_request(&request, None);
888 assert_eq!(sig.edit_count, 1);
889 assert_eq!(sig.recent_edit_count, 1);
890 }
891
892 fn exec_command(cmd: Value) -> Message {
893 Message {
894 role: Role::Assistant,
895 content: vec![ContentBlock::ToolCall(ToolCall {
896 id: String::new(),
897 name: "exec_command".to_string(),
898 arguments: cmd,
899 })],
900 }
901 }
902
903 #[test]
904 fn codex_exec_command_is_classified() {
905 let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
907 let request = with_messages(vec![exec_command(args), tr("ok")]);
908 assert_eq!(
909 ToolSignals::from_request(&request, None).recent_edit_count,
910 1
911 );
912 }
913
914 #[test]
915 fn python_write_expressions_need_a_python_command() {
916 let write = with_messages(vec![
917 exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
918 tr("ok"),
919 ]);
920 assert_eq!(
921 ToolSignals::from_request(&write, None).recent_write_count,
922 1
923 );
924
925 let search = with_messages(vec![
926 exec_command(json!({"cmd": "grep -R '.write(' src"})),
927 tr("ok"),
928 ]);
929 assert_eq!(
930 ToolSignals::from_request(&search, None).recent_write_count,
931 0
932 );
933 }
934
935 #[test]
936 fn recent_window_size_is_caller_overridable() {
937 let request = with_messages(vec![
941 tc("Write"),
942 tr("ok"),
943 tc("Write"),
944 tr("ok"),
945 tc("Write"),
946 tr("ok"),
947 tc("Write"),
948 tr("ok"),
949 tc("Write"),
950 tr("ok"),
951 tc("Edit"),
952 tr("ok"),
953 ]);
954 let narrow = extract_tool_signals_with_window(&request, 3);
955 assert_eq!(narrow.recent_write_count, 2);
956 assert_eq!(narrow.recent_edit_count, 1);
957
958 let wide = extract_tool_signals_with_window(&request, 6);
959 assert_eq!(wide.recent_write_count, 5);
960 assert_eq!(wide.recent_edit_count, 1);
961 }
962
963 #[test]
964 fn compaction_marker_sets_compacted() {
965 let request = with_messages(vec![
967 Message::text(
968 Role::User,
969 "This session is being continued from a previous conversation that ran out of context.",
970 ),
971 bash("ls"),
972 ]);
973 assert!(ToolSignals::from_request(&request, None).compacted);
974 }
975
976 #[test]
977 fn no_compaction_marker_stays_uncompacted() {
978 let request = with_messages(vec![
979 Message::text(Role::User, "Write a script that parses the log file."),
980 bash("ls"),
981 ]);
982 assert!(!ToolSignals::from_request(&request, None).compacted);
983 }
984
985 #[test]
986 fn bash_heredoc_counts_as_write() {
987 let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
989 let sig = ToolSignals::from_request(&request, None);
990 assert_eq!(
991 sig.write_count, 1,
992 "Bash heredoc should bucket into write_count"
993 );
994 assert_eq!(sig.edit_count, 0);
995 }
996
997 #[test]
998 fn bash_sed_inplace_counts_as_edit() {
999 let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
1000 let sig = ToolSignals::from_request(&request, None);
1001 assert_eq!(
1002 sig.edit_count, 1,
1003 "Bash sed -i should bucket into edit_count"
1004 );
1005 assert_eq!(sig.write_count, 0);
1006 }
1007
1008 #[test]
1009 fn bash_non_mutating_does_not_count() {
1010 let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
1012 let sig = ToolSignals::from_request(&request, None);
1013 assert_eq!(sig.write_count, 0);
1014 assert_eq!(sig.edit_count, 0);
1015 }
1016
1017 #[test]
1018 fn tests_passed_detects_pytest_with_failure_block() {
1019 assert!(!detect_tests_passed(
1021 &["2 failed, 5 passed in 0.56s".to_string()],
1022 DEFAULT_RECENT_WINDOW
1023 ));
1024 }
1025
1026 #[test]
1027 fn tests_passed_accepts_cargo_clean_summary() {
1028 assert!(detect_tests_passed(
1031 &["running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string()],
1032 DEFAULT_RECENT_WINDOW
1033 ));
1034 }
1035
1036 #[test]
1037 fn tests_passed_rejects_cargo_real_failure() {
1038 assert!(!detect_tests_passed(
1040 &["running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string()],
1041 DEFAULT_RECENT_WINDOW
1042 ));
1043 }
1044
1045 #[test]
1046 fn tests_passed_accepts_go_clean_summary() {
1047 assert!(detect_tests_passed(
1049 &["ok github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string()],
1050 DEFAULT_RECENT_WINDOW
1051 ));
1052 }
1053
1054 #[test]
1055 fn tests_passed_accepts_pytest_zero_errors() {
1056 assert!(detect_tests_passed(
1058 &["5 passed, 0 errors in 0.30s".to_string()],
1059 DEFAULT_RECENT_WINDOW
1060 ));
1061 }
1062
1063 #[test]
1064 fn tests_passed_detects_diy_checkmark() {
1065 assert!(detect_tests_passed(
1066 &["✓ all checks passed".to_string()],
1067 DEFAULT_RECENT_WINDOW
1068 ));
1069 }
1070
1071 #[test]
1072 fn anthropic_bash_heredoc_extracts_command() {
1073 let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
1075 let sig = ToolSignals::from_request(&request, None);
1076 assert_eq!(
1077 sig.write_count, 1,
1078 "Anthropic Bash heredoc must also be detected"
1079 );
1080 }
1081
1082 #[test]
1083 fn recent_window_falls_back_to_full_history_when_short() {
1084 let request = with_messages(vec![tc("Write")]);
1085 let sig = ToolSignals::from_request(&request, None);
1086 assert_eq!(sig.recent_write_count, 1);
1087 assert_eq!(sig.recent_edit_count, 0);
1088 }
1089
1090 #[test]
1091 fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
1092 let request = with_messages(vec![tr("output ok"), tr("another ok")]);
1093 let sig = ToolSignals::from_request(&request, None);
1094 assert_eq!(sig.severity, 0.0);
1095 assert_eq!(sig.no_error_streak, 2);
1096 }
1097
1098 #[test]
1101 fn todowrite_classifies_as_plan() {
1102 assert_eq!(classify_tool_call("TodoWrite", None), ToolCategory::Plan);
1103 assert_eq!(classify_tool_call("todo_write", None), ToolCategory::Plan);
1104 }
1105
1106 #[test]
1107 fn codex_update_plan_classifies_as_plan() {
1108 assert_eq!(classify_tool_call("update_plan", None), ToolCategory::Plan);
1109 }
1110
1111 #[test]
1112 fn codex_shell_command_runs_bash_pattern_match() {
1113 assert_eq!(
1115 classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
1116 ToolCategory::Write,
1117 );
1118 assert_eq!(
1120 classify_tool_call("shell_command", Some("ls /app")),
1121 ToolCategory::Read,
1122 );
1123 assert_eq!(
1125 classify_tool_call("shell_command", Some("./run_tests.sh")),
1126 ToolCategory::Other,
1127 );
1128 }
1129
1130 #[test]
1131 fn read_tool_classifies_as_read() {
1132 assert_eq!(classify_tool_call("Read", None), ToolCategory::Read);
1133 assert_eq!(classify_tool_call("View", None), ToolCategory::Read);
1134 }
1135
1136 #[test]
1137 fn hermes_tool_names_classify() {
1138 assert_eq!(classify_tool_call("write_file", None), ToolCategory::Write);
1140 assert_eq!(classify_tool_call("patch", None), ToolCategory::Edit);
1141 assert_eq!(classify_tool_call("read_file", None), ToolCategory::Read);
1142 assert_eq!(classify_tool_call("search_files", None), ToolCategory::Read);
1143 assert_eq!(
1146 classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
1147 ToolCategory::Edit,
1148 );
1149 assert_eq!(
1150 classify_tool_call("terminal", Some("grep foo /app")),
1151 ToolCategory::Read,
1152 );
1153 assert_eq!(
1154 classify_tool_call("terminal", Some("./run_tests.sh")),
1155 ToolCategory::Other,
1156 );
1157 }
1158
1159 #[test]
1160 fn bash_read_patterns_classify_as_read() {
1161 let cases = [
1162 "cat /etc/passwd",
1163 "grep foo bar.txt",
1164 "ls /app",
1165 "find . -name '*.py'",
1166 ];
1167 for cmd in cases {
1168 assert_eq!(
1169 classify_tool_call("Bash", Some(cmd)),
1170 ToolCategory::Read,
1171 "expected Read for {cmd}"
1172 );
1173 }
1174 }
1175
1176 #[test]
1177 fn bash_write_precedence_over_read() {
1178 assert_eq!(
1181 classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
1182 ToolCategory::Write,
1183 );
1184 }
1185
1186 #[test]
1187 fn pure_bash_streak_counts_trailing_other() {
1188 let request = with_messages(vec![
1190 bash("make"),
1191 tr("ok"),
1192 bash("./configure"),
1193 tr("ok"),
1194 bash("make install"),
1195 tr("ok"),
1196 bash("./run.sh"),
1197 tr("ok"),
1198 bash("./test"),
1199 tr("ok"),
1200 ]);
1201 let sig = ToolSignals::from_request(&request, None);
1202 assert_eq!(sig.pure_bash_streak, 5);
1203 assert_eq!(sig.write_count, 0);
1204 assert_eq!(sig.read_count, 0);
1205 }
1206
1207 #[test]
1208 fn pure_bash_streak_resets_on_write() {
1209 let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
1210 let sig = ToolSignals::from_request(&request, None);
1211 assert_eq!(sig.pure_bash_streak, 0);
1212 assert_eq!(sig.write_count, 1);
1213 }
1214
1215 #[test]
1216 fn recent_window_tracks_todowrite_and_read() {
1217 let request = with_messages(vec![
1219 bash("make"),
1220 tr("ok"),
1221 tc("TodoWrite"),
1222 tr("ok"),
1223 tc("Read"),
1224 tr("ok"),
1225 tc("TodoWrite"),
1226 tr("ok"),
1227 ]);
1228 let sig = ToolSignals::from_request(&request, None);
1229 assert_eq!(sig.todowrite_count, 2);
1230 assert_eq!(sig.recent_todowrite_count, 2);
1231 assert_eq!(sig.read_count, 1);
1232 assert_eq!(sig.recent_read_count, 1);
1233 }
1234}