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 ("exit_nonzero", SOFT, &["returned non-zero"]),
86];
87
88static NONZERO_EXIT_PHRASES: &[&str] = &[
89 "exit code",
90 "exit status",
91 "exited with code",
92 "exited with status",
93];
94
95static EDIT_TOOL_NAMES: &[&str] = &[
96 "edit",
97 "multiedit",
98 "notebookedit",
99 "str_replace",
100 "str_replace_based_edit_tool",
101 "apply_patch", "text_editor",
103 "patch", ];
105
106static WRITE_TOOL_NAMES: &[&str] = &["write", "create_file", "new_file", "write_file"];
107
108static BASH_WRITE_PATTERNS: &[&str] = &[
112 "cat >",
113 "cat >>",
114 "echo >",
115 "echo >>",
116 "tee ",
117 "printf >",
118 "printf >>",
119 "> /",
120 ">> /",
121 "<< 'eof'",
122 "<<eof",
123 "<<'eof'",
124 "<< eof",
125];
126
127static PYTHON_WRITE_PATTERNS: &[&str] = &["write_text(", "writelines(", ".write("];
130
131static JAVASCRIPT_WRITE_PATTERNS: &[&str] = &[
132 "writefilesync(",
133 "writefile(",
134 "appendfilesync(",
135 "appendfile(",
136];
137
138static BASH_EDIT_PATTERNS: &[&str] = &[
139 "sed -i",
140 "sed --in-place",
141 "awk -i inplace",
142 "awk 'inplace=1'",
143 "patch ",
144 "patch -p",
145 "perl -i",
146 "perl -p -i",
147 "perl -pi",
148];
149
150static BASH_READ_PATTERNS: &[&str] = &[
153 "cat /", "cat ./", "cat ../", "grep ", "ls ", "ls -", "find ", "head ", "tail ", "wc ",
154 "diff ", "which ", "ps ", "df ", "du ", "stat ", "file ", "less ", "more ",
155];
156
157static BASH_READ_COMMANDS: &[&str] = &[
160 "cat", "rg", "nl", "jq", "pwd", "tree", "sed", "grep", "ls", "find", "head", "tail", "wc",
161 "diff", "which", "ps", "df", "du", "stat", "file", "less", "more", "readlink", "realpath",
162 "basename", "dirname", "printenv",
163];
164
165static GIT_READ_SUBCOMMANDS: &[&str] = &[
166 "status",
167 "diff",
168 "log",
169 "show",
170 "show-ref",
171 "rev-parse",
172 "ls-files",
173 "ls-remote",
174 "ls-tree",
175 "grep",
176 "blame",
177 "merge-base",
178 "check-ignore",
179 "tag",
180];
181
182static READ_TOOL_NAMES: &[&str] = &["read", "view", "read_file", "search_files"];
183
184static PLAN_TOOL_NAMES: &[&str] = &["todowrite", "todo_write", "todo", "update_plan"];
187
188static BASH_TOOL_NAMES: &[&str] = &[
193 "bash",
194 "shell_command",
195 "shell",
196 "local_shell_call",
197 "terminal",
198 "exec_command", ];
200
201static TEST_PASS_PHRASES: &[&str] = &[
204 " passed",
205 "passed in",
206 "tests passed",
207 "all tests passed",
208 "test ok",
209 "test result: ok",
210 "passed.\n",
211 "tests pass",
212 "\nok ", "✓ ",
214];
215
216static TEST_FAILURE_LITERAL: &[&str] = &["✗ ", "fatal:", "assertionerror", "error:"];
221
222static NUMERIC_FAILURE_KEYWORDS: &[&str] = &["failed", "failure", "failures", "errors", "error"];
226
227pub const DEFAULT_RECENT_WINDOW: usize = 3;
234
235#[derive(Clone, Debug, Default)]
244pub struct ToolSignals {
245 pub severity: f32,
250 pub no_error_streak: u32,
252 pub edit_count: u32,
254 pub write_count: u32,
256 pub read_count: u32,
258 pub todowrite_count: u32,
261 pub recent_edit_count: u32,
263 pub recent_write_count: u32,
265 pub recent_read_count: u32,
267 pub recent_todowrite_count: u32,
269 pub pure_bash_streak: u32,
272 pub tests_passed: bool,
274 pub tool_result_count: u32,
277 pub assistant_turn_count: u32,
280 pub turn_depth: u32,
284 pub compacted: bool,
290}
291
292impl ToolSignals {
293 pub fn from_request(request: &Request, window_size: Option<usize>) -> Self {
298 extract_tool_signals_with_window(request, window_size.unwrap_or(DEFAULT_RECENT_WINDOW))
299 }
300}
301
302#[derive(Debug, Clone)]
304struct ObservedToolCall {
305 name: String,
306 command: Option<String>,
307}
308
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310enum ToolCategory {
311 Write,
312 Edit,
313 Read,
314 Plan,
315 Other,
316}
317
318#[derive(Debug, Clone)]
321pub struct ToolSignalProcessor {
322 pub recent_window: usize,
325}
326
327impl Default for ToolSignalProcessor {
328 fn default() -> Self {
329 Self {
330 recent_window: DEFAULT_RECENT_WINDOW,
331 }
332 }
333}
334
335#[async_trait]
336impl Processor<State> for ToolSignalProcessor {
337 async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
338 if let Event::Request { request: req, .. } = event {
339 let tool_signal = ToolSignals::from_request(req, Some(self.recent_window));
340 state.tool_signals = Some(tool_signal);
341 }
342 Ok(())
343 }
344}
345
346fn classify_tool_call(name: &str, command: Option<&str>) -> ToolCategory {
347 let lower = name.to_lowercase();
348 if WRITE_TOOL_NAMES.contains(&lower.as_str()) {
349 return ToolCategory::Write;
350 }
351 if EDIT_TOOL_NAMES.contains(&lower.as_str()) {
352 return ToolCategory::Edit;
353 }
354 if READ_TOOL_NAMES.contains(&lower.as_str()) {
355 return ToolCategory::Read;
356 }
357 if PLAN_TOOL_NAMES.contains(&lower.as_str()) {
358 return ToolCategory::Plan;
359 }
360 if BASH_TOOL_NAMES.contains(&lower.as_str())
361 && let Some(cmd) = command
362 {
363 if BASH_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_write(cmd) {
365 return ToolCategory::Write;
366 }
367 if cmd.contains("python") && PYTHON_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
368 return ToolCategory::Write;
369 }
370 if shell_invokes_program(cmd, "node")
371 && JAVASCRIPT_WRITE_PATTERNS
372 .iter()
373 .any(|pattern| cmd.contains(pattern))
374 {
375 return ToolCategory::Write;
376 }
377 if BASH_EDIT_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_edit(cmd) {
378 return ToolCategory::Edit;
379 }
380 if BASH_READ_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_read(cmd) {
381 return ToolCategory::Read;
382 }
383 }
384 ToolCategory::Other
385}
386
387fn shell_segments(command: &str) -> impl Iterator<Item = &str> {
391 command
392 .split(['\n', ';', '|', '&'])
393 .map(str::trim)
394 .filter(|segment| !segment.is_empty())
395}
396
397fn shell_words(segment: &str) -> Vec<&str> {
398 let words: Vec<&str> = segment.split_ascii_whitespace().collect();
399 let mut start = 0usize;
400
401 if words.first().is_some_and(|word| *word == "env") {
402 start += 1;
403 while words.get(start).is_some_and(|word| word.starts_with('-')) {
404 start += 1;
405 }
406 }
407 while words
408 .get(start)
409 .is_some_and(|word| word.contains('=') && !word.starts_with('='))
410 {
411 start += 1;
412 }
413
414 words[start..].to_vec()
415}
416
417fn program_name(word: &str) -> &str {
418 word.rsplit('/').next().unwrap_or(word)
419}
420
421fn shell_invokes_program(command: &str, expected: &str) -> bool {
422 shell_segments(command).any(|segment| {
423 shell_words(segment)
424 .first()
425 .is_some_and(|word| program_name(word) == expected)
426 })
427}
428
429fn shell_command_is_write(command: &str) -> bool {
430 shell_segments(command).any(|segment| {
431 let words = shell_words(segment);
432 let Some(program) = words.first().map(|word| program_name(word)) else {
433 return false;
434 };
435 if matches!(program, "cp" | "mkdir" | "touch" | "install") {
436 return true;
437 }
438
439 let redirects_output = words.iter().skip(1).any(|word| matches!(*word, ">" | ">>"));
440 redirects_output
441 && (matches!(program, "echo" | "printf" | "git")
442 || BASH_READ_COMMANDS.contains(&program))
443 })
444}
445
446fn shell_command_is_edit(command: &str) -> bool {
447 shell_segments(command).any(|segment| {
448 let words = shell_words(segment);
449 let Some(program) = words.first().map(|word| program_name(word)) else {
450 return false;
451 };
452 let has_arg = |arg: &str| words.iter().skip(1).any(|word| *word == arg);
453
454 match program {
455 "mv" | "rm" => true,
456 "perl" => words
457 .iter()
458 .skip(1)
459 .take_while(|word| word.starts_with('-'))
460 .any(|option| {
461 option
462 .trim_start_matches('-')
463 .chars()
464 .any(|flag| flag == 'i')
465 }),
466 "git" => words
467 .get(1)
468 .is_some_and(|subcommand| matches!(*subcommand, "apply" | "am" | "restore")),
469 "gofmt" => has_arg("-w"),
470 "cargo" => words.get(1) == Some(&"fmt") && !has_arg("--check"),
471 "ruff" => {
472 (words.get(1) == Some(&"format") && !has_arg("--check"))
473 || (words.get(1) == Some(&"check") && has_arg("--fix"))
474 }
475 "prettier" => has_arg("--write"),
476 "black" => !has_arg("--check"),
477 _ => {
478 (words.iter().any(|word| program_name(word) == "prettier") && has_arg("--write"))
479 || (words.iter().any(|word| program_name(word) == "ruff")
480 && ((words.contains(&"format") && !has_arg("--check"))
481 || (words.contains(&"check") && has_arg("--fix"))))
482 }
483 }
484 })
485}
486
487fn shell_command_is_read(command: &str) -> bool {
488 shell_segments(command).any(|segment| {
489 if segment == "env" {
490 return true;
491 }
492 let words = shell_words(segment);
493 let Some(program) = words.first().map(|word| program_name(word)) else {
494 return false;
495 };
496
497 if BASH_READ_COMMANDS.contains(&program) {
498 return true;
499 }
500 if program == "command" && words.get(1) == Some(&"-v") {
501 return true;
502 }
503 if program == "type" {
504 return true;
505 }
506 if program != "git" {
507 return false;
508 }
509
510 match words.get(1).copied() {
511 Some("branch") => words.get(2).is_none_or(|arg| arg.starts_with('-')),
512 Some("remote") => words
513 .get(2)
514 .is_none_or(|arg| arg.starts_with('-') || *arg == "get-url"),
515 Some("config") => words
516 .get(2)
517 .is_some_and(|arg| matches!(*arg, "--get" | "--get-all" | "--list" | "-l")),
518 Some(subcommand) => GIT_READ_SUBCOMMANDS.contains(&subcommand),
519 None => false,
520 }
521 })
522}
523
524fn extract_tool_signals_with_window(request: &Request, recent_window: usize) -> ToolSignals {
531 let messages = &request.llm_request.messages;
535 let mut tool_texts: Vec<String> = Vec::new();
536 let mut tool_calls: Vec<ObservedToolCall> = Vec::new();
537 let mut compacted = false;
538 let mut tool_result_count = 0usize;
539 let mut assistant_turn_count = 0usize;
540
541 for message in messages {
542 if message.role == Role::Assistant {
543 assistant_turn_count += 1;
544 }
545 for block in &message.content {
546 match block {
547 ContentBlock::ToolCall(call) => {
548 tool_calls.push(ObservedToolCall {
549 name: call.name.clone(),
550 command: command_of(&call.arguments),
551 });
552 }
553 ContentBlock::ToolResult(result) => {
554 tool_result_count += 1;
556 let text = result
557 .content
558 .iter()
559 .filter_map(text_of)
560 .collect::<Vec<_>>()
561 .join("\n");
562 if !text.is_empty() {
563 tool_texts.push(text);
564 }
565 }
566 ContentBlock::Text { text } => {
570 compacted |= text.to_lowercase().contains(COMPACTION_MARKER);
571 }
572 _ => {}
573 }
574 }
575 }
576
577 let mut signal = build_signal(tool_texts, tool_calls, messages.len() as u32, recent_window);
578 signal.compacted = compacted;
579 signal.tool_result_count = u32::try_from(tool_result_count).unwrap_or(u32::MAX);
580 signal.assistant_turn_count = u32::try_from(assistant_turn_count).unwrap_or(u32::MAX);
581 signal
582}
583
584const COMPACTION_MARKER: &str = "session is being continued";
587
588fn command_of(arguments: &Value) -> Option<String> {
591 let decoded = arguments
593 .as_str()
594 .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
595 let object = decoded.as_ref().unwrap_or(arguments);
596
597 ["command", "cmd", "input"]
598 .iter()
599 .filter_map(|key| object.get(*key))
600 .find_map(command_text)
601}
602
603fn command_text(value: &Value) -> Option<String> {
605 match value {
606 Value::String(text) => Some(text.to_lowercase()),
607 Value::Array(parts) => {
608 let joined = parts
609 .iter()
610 .filter_map(Value::as_str)
611 .collect::<Vec<_>>()
612 .join(" ");
613 (!joined.is_empty()).then(|| joined.to_lowercase())
614 }
615 _ => None,
616 }
617}
618
619fn text_of(block: &ContentBlock) -> Option<&str> {
621 match block {
622 ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
623 _ => None,
624 }
625}
626
627fn build_signal(
628 tool_texts: Vec<String>,
629 tool_calls: Vec<ObservedToolCall>,
630 turn_depth: u32,
631 recent_window: usize,
632) -> ToolSignals {
633 let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
639 let mut severity = 0.0f32;
640 for text in &tool_texts[sev_start..] {
641 let (sev, _patterns) = classify_text(text);
642 if sev > severity {
643 severity = sev;
644 }
645 }
646
647 let no_error_streak = compute_no_error_streak(&tool_texts);
648
649 let recent_start = tool_calls.len().saturating_sub(recent_window);
653 let mut write_count = 0u32;
654 let mut edit_count = 0u32;
655 let mut read_count = 0u32;
656 let mut todowrite_count = 0u32;
657 let mut recent_write_count = 0u32;
658 let mut recent_edit_count = 0u32;
659 let mut recent_read_count = 0u32;
660 let mut recent_todowrite_count = 0u32;
661 let mut pure_bash_streak = 0u32;
662 let mut streak_open = true;
663 for (i, tc) in tool_calls.iter().enumerate().rev() {
664 let cat = classify_tool_call(&tc.name, tc.command.as_deref());
665 if streak_open {
666 if matches!(cat, ToolCategory::Other) {
667 pure_bash_streak += 1;
668 } else {
669 streak_open = false;
670 }
671 }
672 match cat {
673 ToolCategory::Write => {
674 write_count += 1;
675 if i >= recent_start {
676 recent_write_count += 1;
677 }
678 }
679 ToolCategory::Edit => {
680 edit_count += 1;
681 if i >= recent_start {
682 recent_edit_count += 1;
683 }
684 }
685 ToolCategory::Read => {
686 read_count += 1;
687 if i >= recent_start {
688 recent_read_count += 1;
689 }
690 }
691 ToolCategory::Plan => {
692 todowrite_count += 1;
693 if i >= recent_start {
694 recent_todowrite_count += 1;
695 }
696 }
697 ToolCategory::Other => {}
698 }
699 }
700
701 let tests_passed = detect_tests_passed(&tool_texts, recent_window);
702
703 ToolSignals {
704 severity,
705 no_error_streak,
706 edit_count,
707 write_count,
708 read_count,
709 todowrite_count,
710 recent_edit_count,
711 recent_write_count,
712 recent_read_count,
713 recent_todowrite_count,
714 pure_bash_streak,
715 tests_passed,
716 turn_depth,
717 tool_result_count: 0,
721 assistant_turn_count: 0,
722 compacted: false,
723 }
724}
725
726fn content_to_text(content: Option<&Value>) -> Option<String> {
730 match content? {
731 Value::String(s) => Some(s.clone()),
732 Value::Array(blocks) => {
733 let parts: Vec<&str> = blocks
734 .iter()
735 .filter_map(|b| {
736 b.as_object()
737 .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
738 .and_then(|o| o.get("text"))
739 .and_then(Value::as_str)
740 })
741 .collect();
742 if parts.is_empty() {
743 None
744 } else {
745 Some(parts.join("\n"))
746 }
747 }
748 _ => None,
749 }
750}
751
752pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
756 let lower = text.to_lowercase();
757 let mut patterns = Vec::new();
758 let mut severity: f32 = 0.0;
759 for (name, sev, substrings) in ERROR_PATTERNS {
760 if substrings.iter().any(|sub| lower.contains(sub)) {
761 patterns.push(name.to_string());
762 severity = severity.max(*sev);
763 }
764 }
765 if has_nonzero_exit_status(&lower) && !patterns.iter().any(|p| p == "exit_nonzero") {
766 patterns.push("exit_nonzero".to_string());
767 severity = severity.max(SOFT);
768 }
769 for (name, matched) in [
770 ("compile_error", has_compiler_diagnostic(&lower)),
771 ("runtime_exception", has_runtime_exception(&lower)),
772 ("runtime_panic", has_runtime_panic(&lower)),
773 ("patch_error", has_patch_failure(&lower)),
774 ] {
775 if matched && !patterns.iter().any(|pattern| pattern == name) {
776 patterns.push(name.to_string());
777 severity = severity.max(HARD);
778 }
779 }
780 (severity, patterns)
781}
782
783fn has_compiler_diagnostic(lower: &str) -> bool {
784 lower.lines().any(|line| {
785 let line = line.trim_start();
786 if matches!(
787 line,
788 "compilation failed" | "error: compilation failed" | "error: could not compile"
789 ) || line.starts_with("error: could not compile ")
790 {
791 return true;
792 }
793
794 let Some(rest) = line.strip_prefix("error[e") else {
795 return false;
796 };
797 let Some((code, _)) = rest.split_once("]:") else {
798 return false;
799 };
800 !code.is_empty() && code.chars().all(|character| character.is_ascii_digit())
801 })
802}
803
804fn has_runtime_exception(lower: &str) -> bool {
805 let has_exception_line = lower.lines().any(|line| {
806 let line = line.trim_start();
807 [
808 "typeerror:",
809 "referenceerror:",
810 "rangeerror:",
811 "runtimeerror:",
812 "keyerror:",
813 "attributeerror:",
814 ]
815 .iter()
816 .any(|prefix| line.starts_with(prefix))
817 });
818 has_exception_line && (lower.contains("\n at ") || lower.contains("\n at "))
819}
820
821fn has_runtime_panic(lower: &str) -> bool {
822 lower
823 .lines()
824 .any(|line| line.trim_start().starts_with("panic: runtime error:"))
825 && (lower.contains("\ngoroutine ") || lower.contains("[signal sig"))
826}
827
828fn has_patch_failure(lower: &str) -> bool {
829 lower.lines().any(|line| {
830 let line = line.trim_start();
831 line.starts_with("error: patch failed:")
832 || line.starts_with("patch failed:")
833 || line.contains(": patch does not apply")
834 || line.starts_with("invalid context")
835 })
836}
837
838fn has_nonzero_exit_status(lower: &str) -> bool {
844 NONZERO_EXIT_PHRASES
845 .iter()
846 .any(|phrase| phrase_followed_by_nonzero_integer(lower, phrase))
847}
848
849fn phrase_followed_by_nonzero_integer(lower: &str, phrase: &str) -> bool {
851 let mut cursor = 0usize;
852 while let Some(rel) = lower[cursor..].find(phrase) {
853 let value_start = cursor + rel + phrase.len();
854 let rest = lower[value_start..].trim_start_matches(|c: char| {
855 c.is_ascii_whitespace() || matches!(c, ':' | '=' | '\'' | '"' | '`')
856 });
857 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
858 if !digits.is_empty() && digits.chars().any(|d| d != '0') {
859 return true;
860 }
861 cursor = value_start;
862 }
863 false
864}
865
866fn compute_no_error_streak(tool_texts: &[String]) -> u32 {
867 let mut streak = 0u32;
868 for text in tool_texts.iter().rev() {
869 let (sev, _) = classify_text(text);
870 if sev > 0.0 {
871 break;
872 }
873 streak += 1;
874 }
875 streak
876}
877
878fn detect_tests_passed(tool_texts: &[String], recent_window: usize) -> bool {
879 let start = tool_texts.len().saturating_sub(recent_window.max(1));
880 tool_texts[start..].iter().any(|text| {
881 let lower = text.to_lowercase();
882 TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
883 && !TEST_FAILURE_LITERAL.iter().any(|p| lower.contains(p))
884 && !has_nonzero_failure_count(&lower)
885 })
886}
887
888fn has_nonzero_failure_count(lower: &str) -> bool {
894 for kw in NUMERIC_FAILURE_KEYWORDS {
895 let mut cursor = 0usize;
896 while let Some(rel) = lower[cursor..].find(kw) {
897 let kw_start = cursor + rel;
898 let kw_end = kw_start + kw.len();
899 let boundary_after = lower[kw_end..]
902 .chars()
903 .next()
904 .is_none_or(|c| !c.is_ascii_alphanumeric());
905 if boundary_after {
906 let prefix = &lower[..kw_start];
907 let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
908 let digits_rev: String = trimmed
909 .chars()
910 .rev()
911 .take_while(|c| c.is_ascii_digit())
912 .collect();
913 if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
914 return true;
915 }
916 }
917 cursor = kw_start + kw.len();
918 }
919 }
920 false
921}
922
923#[cfg(test)]
926mod tests {
927 use super::*;
928 use serde_json::json;
929 use switchyard_protocol::{ContentBlock, LlmRequest, Message, Role, ToolCall, ToolResult};
930
931 fn with_messages(messages: Vec<Message>) -> Request {
932 Request {
933 llm_request: LlmRequest {
934 messages,
935 ..LlmRequest::default()
936 },
937 raw_request: None,
938 metadata: None,
939 }
940 }
941
942 fn tc(name: &str) -> Message {
944 Message {
945 role: Role::Assistant,
946 content: vec![ContentBlock::ToolCall(ToolCall {
947 id: String::new(),
948 name: name.to_string(),
949 arguments: json!({}),
950 })],
951 }
952 }
953
954 fn bash(command: &str) -> Message {
956 Message {
957 role: Role::Assistant,
958 content: vec![ContentBlock::ToolCall(ToolCall {
959 id: String::new(),
960 name: "Bash".to_string(),
961 arguments: json!({"command": command}),
962 })],
963 }
964 }
965
966 fn tr(text: &str) -> Message {
968 Message {
969 role: Role::User,
970 content: vec![ContentBlock::ToolResult(ToolResult {
971 tool_call_id: String::new(),
972 content: vec![ContentBlock::Text {
973 text: text.to_string(),
974 }],
975 is_error: None,
976 })],
977 }
978 }
979
980 #[test]
981 fn clean_text_has_zero_severity() {
982 let (sev, patterns) = classify_text("everything went fine");
983 assert_eq!(sev, 0.0);
984 assert!(patterns.is_empty());
985 }
986
987 #[test]
988 fn traceback_is_hard() {
989 let (sev, patterns) = classify_text("Traceback (most recent call last):\n ValueError");
990 assert_eq!(sev, HARD);
991 assert!(patterns.contains(&"traceback".to_string()));
992 }
993
994 #[test]
995 fn oom_is_critical() {
996 let (sev, _) = classify_text("Out of memory: kill process 1234");
997 assert_eq!(sev, CRITICAL);
998 }
999
1000 #[test]
1001 fn severity_is_max_across_patterns() {
1002 let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
1004 assert_eq!(sev, HARD);
1005 }
1006
1007 #[test]
1008 fn codex_process_exit_zero_stays_clean() {
1009 let (sev, patterns) =
1010 classify_text("Chunk ID: abc\nProcess exited with code 0\nOutput:\nok");
1011 assert_eq!(sev, 0.0);
1012 assert!(!patterns.contains(&"exit_nonzero".to_string()));
1013 }
1014
1015 #[test]
1016 fn nonzero_exit_codes_are_soft_errors() {
1017 let cases = [
1018 "Process exited with code 1",
1019 "Process exited with code 127",
1020 "exit code: 2",
1021 "exit status 3",
1022 "exited with status 9",
1023 ];
1024 for case in cases {
1025 let (sev, patterns) = classify_text(case);
1026 assert_eq!(sev, SOFT, "expected soft severity for {case}");
1027 assert!(patterns.contains(&"exit_nonzero".to_string()));
1028 }
1029 }
1030
1031 #[test]
1032 fn partial_process_failures_are_hard_errors() {
1033 let cases = [
1034 (
1035 "Process running with session ID 12\nOutput:\nerror[E0509]: cannot move out",
1036 "compile_error",
1037 ),
1038 (
1039 "Process exited with code 0\nOutput:\nTypeError: value is undefined\n at main.js:1:2",
1040 "runtime_exception",
1041 ),
1042 (
1043 "Process running with session ID 13\nOutput:\npanic: runtime error: index out of range\n\ngoroutine 6 [running]:",
1044 "runtime_panic",
1045 ),
1046 (
1047 "Process exited with code 0\nOutput:\nerror: patch failed: src/lib.rs:4\nerror: src/lib.rs: patch does not apply",
1048 "patch_error",
1049 ),
1050 ];
1051 for (text, expected_pattern) in cases {
1052 let (severity, patterns) = classify_text(text);
1053 assert_eq!(severity, HARD, "expected hard severity for {text}");
1054 assert!(patterns.iter().any(|pattern| pattern == expected_pattern));
1055 }
1056 }
1057
1058 #[test]
1059 fn source_text_that_names_exceptions_stays_clean() {
1060 let text =
1061 "pub enum TypeError: this is documentation\nlet sample = 'panic: runtime error:';";
1062 assert_eq!(classify_text(text).0, 0.0);
1063 }
1064
1065 #[test]
1066 fn file_does_not_exist_is_hard() {
1067 let (sev, patterns) =
1069 classify_text("Error: File does not exist. Note: current working directory is /app.");
1070 assert_eq!(sev, HARD);
1071 assert!(patterns.contains(&"no_such_file".to_string()));
1072 }
1073
1074 #[test]
1075 fn bare_does_not_exist_stays_clean() {
1076 let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
1079 assert_eq!(sev, 0.0);
1080 }
1081
1082 #[test]
1083 fn no_error_streak_all_clean() {
1084 let texts = vec!["ok".to_string(), "all good".to_string()];
1085 assert_eq!(compute_no_error_streak(&texts), 2);
1086 }
1087
1088 #[test]
1089 fn no_error_streak_stops_at_error() {
1090 let texts = vec![
1091 "Traceback (most recent call last):".to_string(),
1092 "ok".to_string(),
1093 "ok".to_string(),
1094 ];
1095 assert_eq!(compute_no_error_streak(&texts), 2);
1096 }
1097
1098 #[test]
1099 fn tests_passed_detects_pytest_output() {
1100 assert!(detect_tests_passed(
1101 &["====== 5 passed in 0.12s ======".to_string()],
1102 DEFAULT_RECENT_WINDOW
1103 ));
1104 }
1105
1106 #[test]
1107 fn tests_passed_ignores_partial_failures() {
1108 assert!(!detect_tests_passed(
1109 &["2 failed, 5 passed in 0.56s".to_string()],
1110 DEFAULT_RECENT_WINDOW
1111 ));
1112 }
1113
1114 #[test]
1115 fn severity_is_windowed_over_recent_results() {
1116 let request = with_messages(vec![
1118 tr("Traceback (most recent call last):\n ValueError"),
1119 tr("ok"),
1120 tr("ok"),
1121 ]);
1122 assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
1124 assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
1126 }
1127
1128 #[test]
1129 fn extract_openai_chat_tool_results() {
1130 let request = with_messages(vec![
1131 Message::text(Role::User, "do something"),
1132 tc("Edit"),
1133 tr("Traceback (most recent call last):\n ValueError"),
1134 ]);
1135 let sig = ToolSignals::from_request(&request, None);
1136 assert_eq!(sig.severity, HARD);
1137 assert_eq!(sig.edit_count, 1);
1138 assert_eq!(sig.turn_depth, 3);
1139 }
1140
1141 #[test]
1142 fn extract_anthropic_tool_results() {
1143 let request = with_messages(vec![tr("Traceback (most recent call last):\n ValueError")]);
1144 let sig = ToolSignals::from_request(&request, None);
1145 assert_eq!(sig.severity, HARD);
1146 }
1147
1148 #[test]
1149 fn extract_responses_api_tool_results() {
1150 let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
1151 let sig = ToolSignals::from_request(&request, None);
1152 assert_eq!(sig.severity, 0.0);
1153 assert_eq!(sig.write_count, 1);
1154 }
1155
1156 #[test]
1157 fn conversation_counts_are_per_block_and_role_aware() {
1158 let result = |content: Vec<ContentBlock>| {
1162 ContentBlock::ToolResult(ToolResult {
1163 tool_call_id: String::new(),
1164 content,
1165 is_error: None,
1166 })
1167 };
1168 let request = with_messages(vec![
1169 Message::text(Role::User, "do something"),
1170 Message::text(Role::Assistant, "working"),
1171 Message {
1172 role: Role::User,
1173 content: vec![
1174 result(vec![ContentBlock::Text {
1175 text: "ok".to_string(),
1176 }]),
1177 result(Vec::new()),
1178 ],
1179 },
1180 tc("Bash"),
1181 ]);
1182 let sig = ToolSignals::from_request(&request, None);
1183 assert_eq!(sig.tool_result_count, 2);
1184 assert_eq!(sig.assistant_turn_count, 2);
1185 assert_eq!(sig.turn_depth, 4);
1186 }
1187
1188 #[test]
1189 fn recent_window_counts_only_last_default_window_tool_calls() {
1190 let request = with_messages(vec![
1193 tc("Write"),
1194 tr("ok"),
1195 tc("Write"),
1196 tr("ok"),
1197 tc("Write"),
1198 tr("ok"),
1199 tc("Write"),
1200 tr("ok"),
1201 tc("Write"),
1202 tr("ok"),
1203 tc("Edit"),
1204 tr("ok"),
1205 ]);
1206 let sig = ToolSignals::from_request(&request, None);
1207 assert_eq!(sig.write_count, 5);
1208 assert_eq!(sig.edit_count, 1);
1209 assert_eq!(sig.recent_write_count, 2);
1210 assert_eq!(sig.recent_edit_count, 1);
1211 }
1212
1213 #[test]
1214 fn codex_apply_patch_counts_as_an_edit() {
1215 let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
1216 let sig = ToolSignals::from_request(&request, None);
1217 assert_eq!(sig.edit_count, 1);
1218 assert_eq!(sig.recent_edit_count, 1);
1219 }
1220
1221 fn exec_command(cmd: Value) -> Message {
1222 Message {
1223 role: Role::Assistant,
1224 content: vec![ContentBlock::ToolCall(ToolCall {
1225 id: String::new(),
1226 name: "exec_command".to_string(),
1227 arguments: cmd,
1228 })],
1229 }
1230 }
1231
1232 #[test]
1233 fn codex_exec_command_is_classified() {
1234 let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
1236 let request = with_messages(vec![exec_command(args), tr("ok")]);
1237 assert_eq!(
1238 ToolSignals::from_request(&request, None).recent_edit_count,
1239 1
1240 );
1241 }
1242
1243 #[test]
1244 fn python_write_expressions_need_a_python_command() {
1245 let write = with_messages(vec![
1246 exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
1247 tr("ok"),
1248 ]);
1249 assert_eq!(
1250 ToolSignals::from_request(&write, None).recent_write_count,
1251 1
1252 );
1253
1254 let search = with_messages(vec![
1255 exec_command(json!({"cmd": "grep -R '.write(' src"})),
1256 tr("ok"),
1257 ]);
1258 assert_eq!(
1259 ToolSignals::from_request(&search, None).recent_write_count,
1260 0
1261 );
1262 }
1263
1264 #[test]
1265 fn recent_window_size_is_caller_overridable() {
1266 let request = with_messages(vec![
1270 tc("Write"),
1271 tr("ok"),
1272 tc("Write"),
1273 tr("ok"),
1274 tc("Write"),
1275 tr("ok"),
1276 tc("Write"),
1277 tr("ok"),
1278 tc("Write"),
1279 tr("ok"),
1280 tc("Edit"),
1281 tr("ok"),
1282 ]);
1283 let narrow = extract_tool_signals_with_window(&request, 3);
1284 assert_eq!(narrow.recent_write_count, 2);
1285 assert_eq!(narrow.recent_edit_count, 1);
1286
1287 let wide = extract_tool_signals_with_window(&request, 6);
1288 assert_eq!(wide.recent_write_count, 5);
1289 assert_eq!(wide.recent_edit_count, 1);
1290 }
1291
1292 #[test]
1293 fn compaction_marker_sets_compacted() {
1294 let request = with_messages(vec![
1296 Message::text(
1297 Role::User,
1298 "This session is being continued from a previous conversation that ran out of context.",
1299 ),
1300 bash("ls"),
1301 ]);
1302 assert!(ToolSignals::from_request(&request, None).compacted);
1303 }
1304
1305 #[test]
1306 fn no_compaction_marker_stays_uncompacted() {
1307 let request = with_messages(vec![
1308 Message::text(Role::User, "Write a script that parses the log file."),
1309 bash("ls"),
1310 ]);
1311 assert!(!ToolSignals::from_request(&request, None).compacted);
1312 }
1313
1314 #[test]
1315 fn bash_heredoc_counts_as_write() {
1316 let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
1318 let sig = ToolSignals::from_request(&request, None);
1319 assert_eq!(
1320 sig.write_count, 1,
1321 "Bash heredoc should bucket into write_count"
1322 );
1323 assert_eq!(sig.edit_count, 0);
1324 }
1325
1326 #[test]
1327 fn bash_sed_inplace_counts_as_edit() {
1328 let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
1329 let sig = ToolSignals::from_request(&request, None);
1330 assert_eq!(
1331 sig.edit_count, 1,
1332 "Bash sed -i should bucket into edit_count"
1333 );
1334 assert_eq!(sig.write_count, 0);
1335 }
1336
1337 #[test]
1338 fn bash_non_mutating_does_not_count() {
1339 let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
1341 let sig = ToolSignals::from_request(&request, None);
1342 assert_eq!(sig.write_count, 0);
1343 assert_eq!(sig.edit_count, 0);
1344 }
1345
1346 #[test]
1347 fn tests_passed_detects_pytest_with_failure_block() {
1348 assert!(!detect_tests_passed(
1350 &["2 failed, 5 passed in 0.56s".to_string()],
1351 DEFAULT_RECENT_WINDOW
1352 ));
1353 }
1354
1355 #[test]
1356 fn tests_passed_accepts_cargo_clean_summary() {
1357 assert!(detect_tests_passed(
1360 &["running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string()],
1361 DEFAULT_RECENT_WINDOW
1362 ));
1363 }
1364
1365 #[test]
1366 fn tests_passed_rejects_cargo_real_failure() {
1367 assert!(!detect_tests_passed(
1369 &["running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string()],
1370 DEFAULT_RECENT_WINDOW
1371 ));
1372 }
1373
1374 #[test]
1375 fn tests_passed_accepts_go_clean_summary() {
1376 assert!(detect_tests_passed(
1378 &["ok github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string()],
1379 DEFAULT_RECENT_WINDOW
1380 ));
1381 }
1382
1383 #[test]
1384 fn tests_passed_accepts_pytest_zero_errors() {
1385 assert!(detect_tests_passed(
1387 &["5 passed, 0 errors in 0.30s".to_string()],
1388 DEFAULT_RECENT_WINDOW
1389 ));
1390 }
1391
1392 #[test]
1393 fn tests_passed_detects_diy_checkmark() {
1394 assert!(detect_tests_passed(
1395 &["✓ all checks passed".to_string()],
1396 DEFAULT_RECENT_WINDOW
1397 ));
1398 }
1399
1400 #[test]
1401 fn anthropic_bash_heredoc_extracts_command() {
1402 let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
1404 let sig = ToolSignals::from_request(&request, None);
1405 assert_eq!(
1406 sig.write_count, 1,
1407 "Anthropic Bash heredoc must also be detected"
1408 );
1409 }
1410
1411 #[test]
1412 fn recent_window_falls_back_to_full_history_when_short() {
1413 let request = with_messages(vec![tc("Write")]);
1414 let sig = ToolSignals::from_request(&request, None);
1415 assert_eq!(sig.recent_write_count, 1);
1416 assert_eq!(sig.recent_edit_count, 0);
1417 }
1418
1419 #[test]
1420 fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
1421 let request = with_messages(vec![tr("output ok"), tr("another ok")]);
1422 let sig = ToolSignals::from_request(&request, None);
1423 assert_eq!(sig.severity, 0.0);
1424 assert_eq!(sig.no_error_streak, 2);
1425 }
1426
1427 #[test]
1430 fn todowrite_classifies_as_plan() {
1431 assert_eq!(classify_tool_call("TodoWrite", None), ToolCategory::Plan);
1432 assert_eq!(classify_tool_call("todo_write", None), ToolCategory::Plan);
1433 }
1434
1435 #[test]
1436 fn codex_update_plan_classifies_as_plan() {
1437 assert_eq!(classify_tool_call("update_plan", None), ToolCategory::Plan);
1438 }
1439
1440 #[test]
1441 fn codex_shell_command_runs_bash_pattern_match() {
1442 assert_eq!(
1444 classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
1445 ToolCategory::Write,
1446 );
1447 assert_eq!(
1449 classify_tool_call("shell_command", Some("ls /app")),
1450 ToolCategory::Read,
1451 );
1452 assert_eq!(
1454 classify_tool_call("shell_command", Some("./run_tests.sh")),
1455 ToolCategory::Other,
1456 );
1457 }
1458
1459 #[test]
1460 fn read_tool_classifies_as_read() {
1461 assert_eq!(classify_tool_call("Read", None), ToolCategory::Read);
1462 assert_eq!(classify_tool_call("View", None), ToolCategory::Read);
1463 }
1464
1465 #[test]
1466 fn hermes_tool_names_classify() {
1467 assert_eq!(classify_tool_call("write_file", None), ToolCategory::Write);
1469 assert_eq!(classify_tool_call("patch", None), ToolCategory::Edit);
1470 assert_eq!(classify_tool_call("read_file", None), ToolCategory::Read);
1471 assert_eq!(classify_tool_call("search_files", None), ToolCategory::Read);
1472 assert_eq!(
1475 classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
1476 ToolCategory::Edit,
1477 );
1478 assert_eq!(
1479 classify_tool_call("terminal", Some("grep foo /app")),
1480 ToolCategory::Read,
1481 );
1482 assert_eq!(
1483 classify_tool_call("terminal", Some("./run_tests.sh")),
1484 ToolCategory::Other,
1485 );
1486 }
1487
1488 #[test]
1489 fn bash_read_patterns_classify_as_read() {
1490 let cases = [
1491 "cat /etc/passwd",
1492 "grep foo bar.txt",
1493 "ls /app",
1494 "find . -name '*.py'",
1495 ];
1496 for cmd in cases {
1497 assert_eq!(
1498 classify_tool_call("Bash", Some(cmd)),
1499 ToolCategory::Read,
1500 "expected Read for {cmd}"
1501 );
1502 }
1503 }
1504
1505 #[test]
1506 fn codex_inspection_commands_classify_as_read() {
1507 let cases = [
1508 "sed -n '1,80p' src/lib.rs",
1509 "rg -n 'needle' src",
1510 "nl -ba src/lib.rs",
1511 "cat package.json",
1512 "jq '.scripts' package.json",
1513 "git status --short",
1514 "git log --oneline -5",
1515 "git show HEAD:src/lib.rs",
1516 "git branch --show-current",
1517 "git remote -v",
1518 "git config --get remote.origin.url",
1519 ];
1520 for command in cases {
1521 assert_eq!(
1522 classify_tool_call("exec_command", Some(command)),
1523 ToolCategory::Read,
1524 "expected Read for {command}"
1525 );
1526 }
1527 }
1528
1529 #[test]
1530 fn codex_shell_mutations_classify_as_production() {
1531 let writes = [
1532 "cp source.rs destination.rs",
1533 "mkdir -p src/generated",
1534 "touch src/generated/mod.rs",
1535 "git show HEAD:file.rs > file.rs",
1536 "node <<'node'\nfs.writefilesync('file.js', text)\nnode",
1537 ];
1538 for command in writes {
1539 assert_eq!(
1540 classify_tool_call("exec_command", Some(command)),
1541 ToolCategory::Write,
1542 "expected Write for {command}"
1543 );
1544 }
1545
1546 let edits = [
1547 "mv old.rs new.rs",
1548 "rm obsolete.rs",
1549 "gofmt -w main.go",
1550 "cargo fmt",
1551 "ruff check --fix src",
1552 "perl -0pi -e 's/old/new/' src/lib.rs",
1553 "npx prettier --write src/lib.ts",
1554 "uv run ruff format src",
1555 "git apply fix.patch",
1556 ];
1557 for command in edits {
1558 assert_eq!(
1559 classify_tool_call("exec_command", Some(command)),
1560 ToolCategory::Edit,
1561 "expected Edit for {command}"
1562 );
1563 }
1564 }
1565
1566 #[test]
1567 fn formatter_checks_are_not_edits() {
1568 for command in [
1569 "cargo fmt --check",
1570 "ruff format --check src",
1571 "black --check src",
1572 ] {
1573 assert_ne!(
1574 classify_tool_call("exec_command", Some(command)),
1575 ToolCategory::Edit,
1576 "read-only formatter check must not be Edit: {command}"
1577 );
1578 }
1579 }
1580
1581 #[test]
1582 fn embedded_comparison_is_not_a_shell_write() {
1583 let command = "node <<'node'\nif (index > 0) console.log(index)\nnode";
1584 assert_eq!(
1585 classify_tool_call("exec_command", Some(command)),
1586 ToolCategory::Other
1587 );
1588 }
1589
1590 #[test]
1591 fn bash_write_precedence_over_read() {
1592 assert_eq!(
1595 classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
1596 ToolCategory::Write,
1597 );
1598 }
1599
1600 #[test]
1601 fn pure_bash_streak_counts_trailing_other() {
1602 let request = with_messages(vec![
1604 bash("make"),
1605 tr("ok"),
1606 bash("./configure"),
1607 tr("ok"),
1608 bash("make install"),
1609 tr("ok"),
1610 bash("./run.sh"),
1611 tr("ok"),
1612 bash("./test"),
1613 tr("ok"),
1614 ]);
1615 let sig = ToolSignals::from_request(&request, None);
1616 assert_eq!(sig.pure_bash_streak, 5);
1617 assert_eq!(sig.write_count, 0);
1618 assert_eq!(sig.read_count, 0);
1619 }
1620
1621 #[test]
1622 fn pure_bash_streak_resets_on_write() {
1623 let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
1624 let sig = ToolSignals::from_request(&request, None);
1625 assert_eq!(sig.pure_bash_streak, 0);
1626 assert_eq!(sig.write_count, 1);
1627 }
1628
1629 #[test]
1630 fn recent_window_tracks_todowrite_and_read() {
1631 let request = with_messages(vec![
1633 bash("make"),
1634 tr("ok"),
1635 tc("TodoWrite"),
1636 tr("ok"),
1637 tc("Read"),
1638 tr("ok"),
1639 tc("TodoWrite"),
1640 tr("ok"),
1641 ]);
1642 let sig = ToolSignals::from_request(&request, None);
1643 assert_eq!(sig.todowrite_count, 2);
1644 assert_eq!(sig.recent_todowrite_count, 2);
1645 assert_eq!(sig.read_count, 1);
1646 assert_eq!(sig.recent_read_count, 1);
1647 }
1648}