use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolCallSummary {
pub name: String,
pub kind: ToolCallKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolCallKind {
Bash {
command: String,
},
Path {
path: String,
},
Grep {
pattern: String,
dir: String,
},
Glob {
pattern: String,
base: Option<String>,
},
List {
dir: String,
},
WebFetch {
url: String,
},
Generic {
value: Option<String>,
},
}
impl ToolCallSummary {
pub fn from_call(name: &str, args: &Value) -> Self {
let kind = match name {
"Bash" => ToolCallKind::Bash {
command: first_string(args, &["command", "cmd"]).unwrap_or_default(),
},
"Read" | "Write" | "Edit" | "Delete" => ToolCallKind::Path {
path: first_string(args, &["file_path", "path"]).unwrap_or_default(),
},
"Grep" => ToolCallKind::Grep {
pattern: first_string(args, &["search_string", "pattern"]).unwrap_or_default(),
dir: first_string(args, &["file_path", "path", "directory"])
.unwrap_or_else(|| ".".to_string()),
},
"Glob" => ToolCallKind::Glob {
pattern: first_string(args, &["pattern"]).unwrap_or_default(),
base: first_string(args, &["file_path", "path", "directory"]),
},
"List" => ToolCallKind::List {
dir: first_string(args, &["file_path", "path", "directory"])
.unwrap_or_else(|| ".".to_string()),
},
"WebFetch" => ToolCallKind::WebFetch {
url: first_string(args, &["url"]).unwrap_or_default(),
},
_ => ToolCallKind::Generic {
value: first_string_in_object(args),
},
};
Self {
name: name.to_string(),
kind,
}
}
}
fn first_string(args: &Value, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|k| args.get(k).and_then(|v| v.as_str()).map(|s| s.to_string()))
}
fn first_string_in_object(args: &Value) -> Option<String> {
args.as_object()?
.iter()
.find_map(|(_, v)| v.as_str().map(|s| s.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn bash_reads_command_key() {
let s = ToolCallSummary::from_call("Bash", &json!({ "command": "ls -la" }));
assert_eq!(
s.kind,
ToolCallKind::Bash {
command: "ls -la".into()
}
);
}
#[test]
fn bash_falls_back_to_cmd_alias() {
let s = ToolCallSummary::from_call("Bash", &json!({ "cmd": "echo hi" }));
assert_eq!(
s.kind,
ToolCallKind::Bash {
command: "echo hi".into()
}
);
}
#[test]
fn bash_with_no_command_yields_empty() {
let s = ToolCallSummary::from_call("Bash", &json!({}));
assert_eq!(
s.kind,
ToolCallKind::Bash {
command: String::new()
}
);
}
#[test]
fn read_write_edit_delete_share_path_shape() {
for name in ["Read", "Write", "Edit", "Delete"] {
let s = ToolCallSummary::from_call(name, &json!({ "file_path": "src/foo.rs" }));
assert_eq!(
s.kind,
ToolCallKind::Path {
path: "src/foo.rs".into()
},
"{name} should produce ToolCallKind::Path"
);
}
}
#[test]
fn path_falls_back_to_path_alias() {
let s = ToolCallSummary::from_call("Read", &json!({ "path": "legacy.rs" }));
assert_eq!(
s.kind,
ToolCallKind::Path {
path: "legacy.rs".into()
}
);
}
#[test]
fn grep_reads_search_string_and_file_path() {
let s = ToolCallSummary::from_call(
"Grep",
&json!({ "search_string": "TODO", "file_path": "src/" }),
);
assert_eq!(
s.kind,
ToolCallKind::Grep {
pattern: "TODO".into(),
dir: "src/".into()
}
);
}
#[test]
fn grep_pattern_alias_works_for_legacy_callers() {
let s = ToolCallSummary::from_call(
"Grep",
&json!({ "pattern": "fn main", "directory": "src/" }),
);
assert_eq!(
s.kind,
ToolCallKind::Grep {
pattern: "fn main".into(),
dir: "src/".into()
}
);
}
#[test]
fn grep_default_directory_is_dot() {
let s = ToolCallSummary::from_call("Grep", &json!({ "search_string": "x" }));
assert_eq!(
s.kind,
ToolCallKind::Grep {
pattern: "x".into(),
dir: ".".into()
}
);
}
#[test]
fn glob_with_no_base_leaves_base_none() {
let s = ToolCallSummary::from_call("Glob", &json!({ "pattern": "*.rs" }));
assert_eq!(
s.kind,
ToolCallKind::Glob {
pattern: "*.rs".into(),
base: None
}
);
}
#[test]
fn glob_surfaces_file_path_as_base_when_present() {
let s = ToolCallSummary::from_call(
"Glob",
&json!({ "pattern": "*.toml", "file_path": "koda-cli/" }),
);
assert_eq!(
s.kind,
ToolCallKind::Glob {
pattern: "*.toml".into(),
base: Some("koda-cli/".into()),
}
);
}
#[test]
fn list_default_directory_is_dot() {
let s = ToolCallSummary::from_call("List", &json!({}));
assert_eq!(s.kind, ToolCallKind::List { dir: ".".into() });
}
#[test]
fn list_uses_file_path_key_from_schema() {
let s = ToolCallSummary::from_call("List", &json!({ "file_path": "koda-core/src/" }));
assert_eq!(
s.kind,
ToolCallKind::List {
dir: "koda-core/src/".into()
}
);
}
#[test]
fn webfetch_reads_url() {
let s = ToolCallSummary::from_call("WebFetch", &json!({ "url": "https://example.com" }));
assert_eq!(
s.kind,
ToolCallKind::WebFetch {
url: "https://example.com".into()
}
);
}
#[test]
fn generic_picks_first_string_value_in_object_order() {
let s = ToolCallSummary::from_call("UnknownTool", &json!({ "a": "first", "b": "second" }));
assert_eq!(
s.kind,
ToolCallKind::Generic {
value: Some("first".into())
}
);
}
#[test]
fn generic_with_no_string_values_yields_none() {
let s = ToolCallSummary::from_call("UnknownTool", &json!({ "n": 42 }));
assert_eq!(s.kind, ToolCallKind::Generic { value: None });
}
#[test]
fn generic_with_non_object_args_yields_none() {
let s = ToolCallSummary::from_call("UnknownTool", &json!("just a string"));
assert_eq!(s.kind, ToolCallKind::Generic { value: None });
}
#[test]
fn path_bearing_tools_honor_file_path_key() {
let cases = [
(
"List",
json!({ "file_path": "alpha", "path": "WRONG", "directory": "WRONG" }),
"alpha",
),
(
"Grep",
json!({
"search_string": "x",
"file_path": "bravo",
"path": "WRONG",
"directory": "WRONG",
}),
"bravo",
),
(
"Glob",
json!({ "pattern": "*", "file_path": "charlie", "path": "WRONG" }),
"charlie",
),
(
"Read",
json!({ "file_path": "delta", "path": "WRONG" }),
"delta",
),
];
for (name, args, expected) in cases {
let s = ToolCallSummary::from_call(name, &args);
let actual = match &s.kind {
ToolCallKind::List { dir } => dir.clone(),
ToolCallKind::Grep { dir, .. } => dir.clone(),
ToolCallKind::Glob { base, .. } => base.clone().unwrap_or_default(),
ToolCallKind::Path { path } => path.clone(),
other => panic!("{name} produced unexpected kind {other:?}"),
};
assert_eq!(
actual, expected,
"{name}: must read `file_path` first — that's the key the dispatcher reads. \
Pre-#1099, renderers checked obsolete keys first and silently rendered \
wrong paths for every call."
);
}
}
}