#![allow(dead_code)]
use async_trait::async_trait;
use serde_json::Value;
use switchyard_protocol::{ContentBlock, Request};
use crate::Result;
use crate::core::processor::{Event, Processor};
use crate::core::state::State;
const SOFT: f32 = 0.3;
const HARD: f32 = 0.7;
const CRITICAL: f32 = 1.0;
static ERROR_PATTERNS: &[(&str, f32, &[&str])] = &[
(
"oom",
CRITICAL,
&["out of memory", "memoryerror", "cannot allocate memory"],
),
(
"connection_refused",
CRITICAL,
&[
"connection refused",
"connectionrefusederror",
"econnrefused",
],
),
("traceback", HARD, &["traceback (most recent call last)"]),
(
"import_error",
HARD,
&["modulenotfounderror:", "importerror:", "no module named "],
),
(
"cmd_not_found",
HARD,
&["command not found", "not found\n", "/usr/bin/env: "],
),
("assertion", HARD, &["assertionerror"]),
("value_error", HARD, &["valueerror:"]),
("syntax_error", HARD, &["syntaxerror:"]),
(
"timeout",
HARD,
&[
"timed out",
"timeouterror",
"timeout expired",
"deadline exceeded",
],
),
(
"no_such_file",
HARD,
&[
"filenotfounderror:",
"no such file or directory",
"file does not exist",
],
),
(
"exit_nonzero",
SOFT,
&[
"exit code 1",
"exit code 2",
"exit status 1",
"returned non-zero",
"exited with code",
],
),
];
static EDIT_TOOL_NAMES: &[&str] = &[
"edit",
"multiedit",
"notebookedit",
"str_replace",
"str_replace_based_edit_tool",
"text_editor",
"patch", ];
static WRITE_TOOL_NAMES: &[&str] = &["write", "create_file", "new_file", "write_file"];
static BASH_WRITE_PATTERNS: &[&str] = &[
"cat >",
"cat >>",
"echo >",
"echo >>",
"tee ",
"printf >",
"printf >>",
"> /",
">> /",
"<< 'eof'",
"<<eof",
"<<'eof'",
"<< eof",
];
static BASH_EDIT_PATTERNS: &[&str] = &[
"sed -i",
"sed --in-place",
"awk -i inplace",
"awk 'inplace=1'",
"patch ",
"patch -p",
"perl -i",
"perl -p -i",
"perl -pi",
];
static BASH_READ_PATTERNS: &[&str] = &[
"cat /", "cat ./", "cat ../", "grep ", "ls ", "ls -", "find ", "head ", "tail ", "wc ",
"diff ", "which ", "ps ", "df ", "du ", "stat ", "file ", "less ", "more ",
];
static READ_TOOL_NAMES: &[&str] = &["read", "view", "read_file", "search_files"];
static PLAN_TOOL_NAMES: &[&str] = &["todowrite", "todo_write", "todo", "update_plan"];
static BASH_TOOL_NAMES: &[&str] = &[
"bash",
"shell_command",
"shell",
"local_shell_call",
"terminal",
];
static TEST_PASS_PHRASES: &[&str] = &[
" passed",
"passed in",
"tests passed",
"all tests passed",
"test ok",
"test result: ok",
"passed.\n",
"tests pass",
"\nok ", "✓ ",
];
static TEST_FAILURE_LITERAL: &[&str] = &["✗ ", "fatal:", "assertionerror", "error:"];
static NUMERIC_FAILURE_KEYWORDS: &[&str] = &["failed", "failure", "failures", "errors", "error"];
pub const DEFAULT_RECENT_WINDOW: usize = 3;
#[derive(Clone, Debug, Default)]
pub struct ToolSignals {
pub severity: f32,
pub no_error_streak: u32,
pub edit_count: u32,
pub write_count: u32,
pub read_count: u32,
pub todowrite_count: u32,
pub recent_edit_count: u32,
pub recent_write_count: u32,
pub recent_read_count: u32,
pub recent_todowrite_count: u32,
pub pure_bash_streak: u32,
pub tests_passed: bool,
pub turn_depth: u32,
pub compacted: bool,
}
impl ToolSignals {
pub fn from_request(request: &Request, window_size: Option<usize>) -> Self {
extract_tool_signals_with_window(request, window_size.unwrap_or(DEFAULT_RECENT_WINDOW))
}
}
#[derive(Debug, Clone)]
struct ObservedToolCall {
name: String,
command: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ToolCategory {
Write,
Edit,
Read,
Plan,
Other,
}
#[derive(Debug, Clone)]
pub struct ToolSignalProcessor {
pub recent_window: usize,
}
impl Default for ToolSignalProcessor {
fn default() -> Self {
Self {
recent_window: DEFAULT_RECENT_WINDOW,
}
}
}
#[async_trait]
impl Processor<State> for ToolSignalProcessor {
async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
if let Event::Request(req) = event {
let tool_signal = ToolSignals::from_request(req, Some(self.recent_window));
state.tool_signals = Some(tool_signal);
}
Ok(())
}
}
fn classify_tool_call(name: &str, command: Option<&str>) -> ToolCategory {
let lower = name.to_lowercase();
if WRITE_TOOL_NAMES.contains(&lower.as_str()) {
return ToolCategory::Write;
}
if EDIT_TOOL_NAMES.contains(&lower.as_str()) {
return ToolCategory::Edit;
}
if READ_TOOL_NAMES.contains(&lower.as_str()) {
return ToolCategory::Read;
}
if PLAN_TOOL_NAMES.contains(&lower.as_str()) {
return ToolCategory::Plan;
}
if BASH_TOOL_NAMES.contains(&lower.as_str())
&& let Some(cmd) = command
{
if BASH_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
return ToolCategory::Write;
}
if BASH_EDIT_PATTERNS.iter().any(|p| cmd.contains(p)) {
return ToolCategory::Edit;
}
if BASH_READ_PATTERNS.iter().any(|p| cmd.contains(p)) {
return ToolCategory::Read;
}
}
ToolCategory::Other
}
fn extract_tool_signals_with_window(request: &Request, recent_window: usize) -> ToolSignals {
let messages = &request.llm_request.messages;
let mut tool_texts: Vec<String> = Vec::new();
let mut tool_calls: Vec<ObservedToolCall> = Vec::new();
let mut compacted = false;
for message in messages {
for block in &message.content {
match block {
ContentBlock::ToolCall(call) => {
tool_calls.push(ObservedToolCall {
name: call.name.clone(),
command: command_of(&call.arguments),
});
}
ContentBlock::ToolResult(result) => {
let text = result
.content
.iter()
.filter_map(text_of)
.collect::<Vec<_>>()
.join("\n");
if !text.is_empty() {
tool_texts.push(text);
}
}
ContentBlock::Text { text } => {
compacted |= text.to_lowercase().contains(COMPACTION_MARKER);
}
_ => {}
}
}
}
let mut signal = build_signal(tool_texts, tool_calls, messages.len() as u32, recent_window);
signal.compacted = compacted;
signal
}
const COMPACTION_MARKER: &str = "session is being continued";
fn command_of(arguments: &Value) -> Option<String> {
arguments
.get("command")
.and_then(Value::as_str)
.map(str::to_lowercase)
}
fn text_of(block: &ContentBlock) -> Option<&str> {
match block {
ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
_ => None,
}
}
fn build_signal(
tool_texts: Vec<String>,
tool_calls: Vec<ObservedToolCall>,
turn_depth: u32,
recent_window: usize,
) -> ToolSignals {
let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
let mut severity = 0.0f32;
for text in &tool_texts[sev_start..] {
let (sev, _patterns) = classify_text(text);
if sev > severity {
severity = sev;
}
}
let no_error_streak = compute_no_error_streak(&tool_texts);
let recent_start = tool_calls.len().saturating_sub(recent_window);
let mut write_count = 0u32;
let mut edit_count = 0u32;
let mut read_count = 0u32;
let mut todowrite_count = 0u32;
let mut recent_write_count = 0u32;
let mut recent_edit_count = 0u32;
let mut recent_read_count = 0u32;
let mut recent_todowrite_count = 0u32;
let mut pure_bash_streak = 0u32;
let mut streak_open = true;
for (i, tc) in tool_calls.iter().enumerate().rev() {
let cat = classify_tool_call(&tc.name, tc.command.as_deref());
if streak_open {
if matches!(cat, ToolCategory::Other) {
pure_bash_streak += 1;
} else {
streak_open = false;
}
}
match cat {
ToolCategory::Write => {
write_count += 1;
if i >= recent_start {
recent_write_count += 1;
}
}
ToolCategory::Edit => {
edit_count += 1;
if i >= recent_start {
recent_edit_count += 1;
}
}
ToolCategory::Read => {
read_count += 1;
if i >= recent_start {
recent_read_count += 1;
}
}
ToolCategory::Plan => {
todowrite_count += 1;
if i >= recent_start {
recent_todowrite_count += 1;
}
}
ToolCategory::Other => {}
}
}
let tests_passed = detect_tests_passed(&tool_texts, recent_window);
ToolSignals {
severity,
no_error_streak,
edit_count,
write_count,
read_count,
todowrite_count,
recent_edit_count,
recent_write_count,
recent_read_count,
recent_todowrite_count,
pure_bash_streak,
tests_passed,
turn_depth,
compacted: false,
}
}
fn content_to_text(content: Option<&Value>) -> Option<String> {
match content? {
Value::String(s) => Some(s.clone()),
Value::Array(blocks) => {
let parts: Vec<&str> = blocks
.iter()
.filter_map(|b| {
b.as_object()
.filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
.and_then(|o| o.get("text"))
.and_then(Value::as_str)
})
.collect();
if parts.is_empty() {
None
} else {
Some(parts.join("\n"))
}
}
_ => None,
}
}
pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
let lower = text.to_lowercase();
let mut patterns = Vec::new();
let mut severity: f32 = 0.0;
for (name, sev, substrings) in ERROR_PATTERNS {
if substrings.iter().any(|sub| lower.contains(sub)) {
patterns.push(name.to_string());
severity = severity.max(*sev);
}
}
(severity, patterns)
}
fn compute_no_error_streak(tool_texts: &[String]) -> u32 {
let mut streak = 0u32;
for text in tool_texts.iter().rev() {
let (sev, _) = classify_text(text);
if sev > 0.0 {
break;
}
streak += 1;
}
streak
}
fn detect_tests_passed(tool_texts: &[String], recent_window: usize) -> bool {
let start = tool_texts.len().saturating_sub(recent_window.max(1));
tool_texts[start..].iter().any(|text| {
let lower = text.to_lowercase();
TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
&& !TEST_FAILURE_LITERAL.iter().any(|p| lower.contains(p))
&& !has_nonzero_failure_count(&lower)
})
}
fn has_nonzero_failure_count(lower: &str) -> bool {
for kw in NUMERIC_FAILURE_KEYWORDS {
let mut cursor = 0usize;
while let Some(rel) = lower[cursor..].find(kw) {
let kw_start = cursor + rel;
let kw_end = kw_start + kw.len();
let boundary_after = lower[kw_end..]
.chars()
.next()
.is_none_or(|c| !c.is_ascii_alphanumeric());
if boundary_after {
let prefix = &lower[..kw_start];
let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
let digits_rev: String = trimmed
.chars()
.rev()
.take_while(|c| c.is_ascii_digit())
.collect();
if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
return true;
}
}
cursor = kw_start + kw.len();
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use switchyard_protocol::{ContentBlock, LlmRequest, Message, Role, ToolCall, ToolResult};
fn with_messages(messages: Vec<Message>) -> Request {
Request {
llm_request: LlmRequest {
messages,
..LlmRequest::default()
},
raw_request: None,
metadata: None,
}
}
fn tc(name: &str) -> Message {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolCall(ToolCall {
id: String::new(),
name: name.to_string(),
arguments: json!({}),
})],
}
}
fn bash(command: &str) -> Message {
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolCall(ToolCall {
id: String::new(),
name: "Bash".to_string(),
arguments: json!({"command": command}),
})],
}
}
fn tr(text: &str) -> Message {
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult(ToolResult {
tool_call_id: String::new(),
content: vec![ContentBlock::Text {
text: text.to_string(),
}],
is_error: None,
})],
}
}
#[test]
fn clean_text_has_zero_severity() {
let (sev, patterns) = classify_text("everything went fine");
assert_eq!(sev, 0.0);
assert!(patterns.is_empty());
}
#[test]
fn traceback_is_hard() {
let (sev, patterns) = classify_text("Traceback (most recent call last):\n ValueError");
assert_eq!(sev, HARD);
assert!(patterns.contains(&"traceback".to_string()));
}
#[test]
fn oom_is_critical() {
let (sev, _) = classify_text("Out of memory: kill process 1234");
assert_eq!(sev, CRITICAL);
}
#[test]
fn severity_is_max_across_patterns() {
let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
assert_eq!(sev, HARD);
}
#[test]
fn file_does_not_exist_is_hard() {
let (sev, patterns) =
classify_text("Error: File does not exist. Note: current working directory is /app.");
assert_eq!(sev, HARD);
assert!(patterns.contains(&"no_such_file".to_string()));
}
#[test]
fn bare_does_not_exist_stays_clean() {
let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
assert_eq!(sev, 0.0);
}
#[test]
fn no_error_streak_all_clean() {
let texts = vec!["ok".to_string(), "all good".to_string()];
assert_eq!(compute_no_error_streak(&texts), 2);
}
#[test]
fn no_error_streak_stops_at_error() {
let texts = vec![
"Traceback (most recent call last):".to_string(),
"ok".to_string(),
"ok".to_string(),
];
assert_eq!(compute_no_error_streak(&texts), 2);
}
#[test]
fn tests_passed_detects_pytest_output() {
assert!(detect_tests_passed(
&["====== 5 passed in 0.12s ======".to_string()],
DEFAULT_RECENT_WINDOW
));
}
#[test]
fn tests_passed_ignores_partial_failures() {
assert!(!detect_tests_passed(
&["2 failed, 5 passed in 0.56s".to_string()],
DEFAULT_RECENT_WINDOW
));
}
#[test]
fn severity_is_windowed_over_recent_results() {
let request = with_messages(vec![
tr("Traceback (most recent call last):\n ValueError"),
tr("ok"),
tr("ok"),
]);
assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
}
#[test]
fn extract_openai_chat_tool_results() {
let request = with_messages(vec![
Message::text(Role::User, "do something"),
tc("Edit"),
tr("Traceback (most recent call last):\n ValueError"),
]);
let sig = ToolSignals::from_request(&request, None);
assert_eq!(sig.severity, HARD);
assert_eq!(sig.edit_count, 1);
assert_eq!(sig.turn_depth, 3);
}
#[test]
fn extract_anthropic_tool_results() {
let request = with_messages(vec![tr("Traceback (most recent call last):\n ValueError")]);
let sig = ToolSignals::from_request(&request, None);
assert_eq!(sig.severity, HARD);
}
#[test]
fn extract_responses_api_tool_results() {
let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
let sig = ToolSignals::from_request(&request, None);
assert_eq!(sig.severity, 0.0);
assert_eq!(sig.write_count, 1);
}
#[test]
fn recent_window_counts_only_last_default_window_tool_calls() {
let request = with_messages(vec![
tc("Write"),
tr("ok"),
tc("Write"),
tr("ok"),
tc("Write"),
tr("ok"),
tc("Write"),
tr("ok"),
tc("Write"),
tr("ok"),
tc("Edit"),
tr("ok"),
]);
let sig = ToolSignals::from_request(&request, None);
assert_eq!(sig.write_count, 5);
assert_eq!(sig.edit_count, 1);
assert_eq!(sig.recent_write_count, 2);
assert_eq!(sig.recent_edit_count, 1);
}
#[test]
fn recent_window_size_is_caller_overridable() {
let request = with_messages(vec![
tc("Write"),
tr("ok"),
tc("Write"),
tr("ok"),
tc("Write"),
tr("ok"),
tc("Write"),
tr("ok"),
tc("Write"),
tr("ok"),
tc("Edit"),
tr("ok"),
]);
let narrow = extract_tool_signals_with_window(&request, 3);
assert_eq!(narrow.recent_write_count, 2);
assert_eq!(narrow.recent_edit_count, 1);
let wide = extract_tool_signals_with_window(&request, 6);
assert_eq!(wide.recent_write_count, 5);
assert_eq!(wide.recent_edit_count, 1);
}
#[test]
fn compaction_marker_sets_compacted() {
let request = with_messages(vec![
Message::text(
Role::User,
"This session is being continued from a previous conversation that ran out of context.",
),
bash("ls"),
]);
assert!(ToolSignals::from_request(&request, None).compacted);
}
#[test]
fn no_compaction_marker_stays_uncompacted() {
let request = with_messages(vec![
Message::text(Role::User, "Write a script that parses the log file."),
bash("ls"),
]);
assert!(!ToolSignals::from_request(&request, None).compacted);
}
#[test]
fn bash_heredoc_counts_as_write() {
let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
let sig = ToolSignals::from_request(&request, None);
assert_eq!(
sig.write_count, 1,
"Bash heredoc should bucket into write_count"
);
assert_eq!(sig.edit_count, 0);
}
#[test]
fn bash_sed_inplace_counts_as_edit() {
let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
let sig = ToolSignals::from_request(&request, None);
assert_eq!(
sig.edit_count, 1,
"Bash sed -i should bucket into edit_count"
);
assert_eq!(sig.write_count, 0);
}
#[test]
fn bash_non_mutating_does_not_count() {
let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
let sig = ToolSignals::from_request(&request, None);
assert_eq!(sig.write_count, 0);
assert_eq!(sig.edit_count, 0);
}
#[test]
fn tests_passed_detects_pytest_with_failure_block() {
assert!(!detect_tests_passed(
&["2 failed, 5 passed in 0.56s".to_string()],
DEFAULT_RECENT_WINDOW
));
}
#[test]
fn tests_passed_accepts_cargo_clean_summary() {
assert!(detect_tests_passed(
&["running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string()],
DEFAULT_RECENT_WINDOW
));
}
#[test]
fn tests_passed_rejects_cargo_real_failure() {
assert!(!detect_tests_passed(
&["running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string()],
DEFAULT_RECENT_WINDOW
));
}
#[test]
fn tests_passed_accepts_go_clean_summary() {
assert!(detect_tests_passed(
&["ok github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string()],
DEFAULT_RECENT_WINDOW
));
}
#[test]
fn tests_passed_accepts_pytest_zero_errors() {
assert!(detect_tests_passed(
&["5 passed, 0 errors in 0.30s".to_string()],
DEFAULT_RECENT_WINDOW
));
}
#[test]
fn tests_passed_detects_diy_checkmark() {
assert!(detect_tests_passed(
&["✓ all checks passed".to_string()],
DEFAULT_RECENT_WINDOW
));
}
#[test]
fn anthropic_bash_heredoc_extracts_command() {
let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
let sig = ToolSignals::from_request(&request, None);
assert_eq!(
sig.write_count, 1,
"Anthropic Bash heredoc must also be detected"
);
}
#[test]
fn recent_window_falls_back_to_full_history_when_short() {
let request = with_messages(vec![tc("Write")]);
let sig = ToolSignals::from_request(&request, None);
assert_eq!(sig.recent_write_count, 1);
assert_eq!(sig.recent_edit_count, 0);
}
#[test]
fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
let request = with_messages(vec![tr("output ok"), tr("another ok")]);
let sig = ToolSignals::from_request(&request, None);
assert_eq!(sig.severity, 0.0);
assert_eq!(sig.no_error_streak, 2);
}
#[test]
fn todowrite_classifies_as_plan() {
assert_eq!(classify_tool_call("TodoWrite", None), ToolCategory::Plan);
assert_eq!(classify_tool_call("todo_write", None), ToolCategory::Plan);
}
#[test]
fn codex_update_plan_classifies_as_plan() {
assert_eq!(classify_tool_call("update_plan", None), ToolCategory::Plan);
}
#[test]
fn codex_shell_command_runs_bash_pattern_match() {
assert_eq!(
classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
ToolCategory::Write,
);
assert_eq!(
classify_tool_call("shell_command", Some("ls /app")),
ToolCategory::Read,
);
assert_eq!(
classify_tool_call("shell_command", Some("./run_tests.sh")),
ToolCategory::Other,
);
}
#[test]
fn read_tool_classifies_as_read() {
assert_eq!(classify_tool_call("Read", None), ToolCategory::Read);
assert_eq!(classify_tool_call("View", None), ToolCategory::Read);
}
#[test]
fn hermes_tool_names_classify() {
assert_eq!(classify_tool_call("write_file", None), ToolCategory::Write);
assert_eq!(classify_tool_call("patch", None), ToolCategory::Edit);
assert_eq!(classify_tool_call("read_file", None), ToolCategory::Read);
assert_eq!(classify_tool_call("search_files", None), ToolCategory::Read);
assert_eq!(
classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
ToolCategory::Edit,
);
assert_eq!(
classify_tool_call("terminal", Some("grep foo /app")),
ToolCategory::Read,
);
assert_eq!(
classify_tool_call("terminal", Some("./run_tests.sh")),
ToolCategory::Other,
);
}
#[test]
fn bash_read_patterns_classify_as_read() {
let cases = [
"cat /etc/passwd",
"grep foo bar.txt",
"ls /app",
"find . -name '*.py'",
];
for cmd in cases {
assert_eq!(
classify_tool_call("Bash", Some(cmd)),
ToolCategory::Read,
"expected Read for {cmd}"
);
}
}
#[test]
fn bash_write_precedence_over_read() {
assert_eq!(
classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
ToolCategory::Write,
);
}
#[test]
fn pure_bash_streak_counts_trailing_other() {
let request = with_messages(vec![
bash("make"),
tr("ok"),
bash("./configure"),
tr("ok"),
bash("make install"),
tr("ok"),
bash("./run.sh"),
tr("ok"),
bash("./test"),
tr("ok"),
]);
let sig = ToolSignals::from_request(&request, None);
assert_eq!(sig.pure_bash_streak, 5);
assert_eq!(sig.write_count, 0);
assert_eq!(sig.read_count, 0);
}
#[test]
fn pure_bash_streak_resets_on_write() {
let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
let sig = ToolSignals::from_request(&request, None);
assert_eq!(sig.pure_bash_streak, 0);
assert_eq!(sig.write_count, 1);
}
#[test]
fn recent_window_tracks_todowrite_and_read() {
let request = with_messages(vec![
bash("make"),
tr("ok"),
tc("TodoWrite"),
tr("ok"),
tc("Read"),
tr("ok"),
tc("TodoWrite"),
tr("ok"),
]);
let sig = ToolSignals::from_request(&request, None);
assert_eq!(sig.todowrite_count, 2);
assert_eq!(sig.recent_todowrite_count, 2);
assert_eq!(sig.read_count, 1);
assert_eq!(sig.recent_read_count, 1);
}
}