use lash_core::plugin::{PluginDirective, PluginError, ToolCallHookContext};
pub(super) fn normalize_projected_tool_args(
ctx: ToolCallHookContext,
) -> Result<Vec<PluginDirective>, PluginError> {
let original = ctx.args;
let normalized = crate::projection::normalize_tool_args_for_projection(
original.clone(),
&ctx.argument_projection,
);
if normalized == original {
Ok(Vec::new())
} else {
Ok(vec![PluginDirective::ReplaceToolArgs { args: normalized }])
}
}
#[cfg(test)]
mod tests {
use crate::projection::RlmSeed;
fn projected(value: serde_json::Value) -> serde_json::Value {
serde_json::json!({ "__projected__": value })
}
fn received_tool_args(
policy: lash_core::ToolArgumentProjectionPolicy,
args: serde_json::Value,
) -> serde_json::Value {
crate::projection::normalize_tool_args_for_projection(args, &policy)
}
fn materializing_args(args: serde_json::Value) -> serde_json::Value {
received_tool_args(
lash_core::ToolArgumentProjectionPolicy::MaterializeProjectedValues,
args,
)
}
fn seed_preserving_args(args: serde_json::Value) -> serde_json::Value {
received_tool_args(
lash_core::ToolArgumentProjectionPolicy::preserve_projected_refs_in_field("seed"),
args,
)
}
fn classify_received_seed(received: &serde_json::Value) -> RlmSeed {
RlmSeed::from_tool_args(received).expect("seed should classify")
}
#[test]
fn projected_tool_arg_normalization_materializes_ordinary_tools_recursively() {
let args = serde_json::json!({
"path": projected(serde_json::json!("/tmp/projected.txt")),
"nested": {
"items": [
projected(serde_json::json!("a")),
{ "plain": projected(serde_json::json!(true)) }
]
}
});
let normalized = materializing_args(args);
assert_eq!(
normalized,
serde_json::json!({
"path": "/tmp/projected.txt",
"nested": {
"items": [
"a",
{ "plain": true }
]
}
})
);
}
#[test]
fn projected_tool_arg_normalization_preserves_seed_roots_for_projection_aware_tools() {
let args = serde_json::json!({
"task": projected(serde_json::json!("inspect the file")),
"capability": "explore",
"seed": {
"projected_root": projected(serde_json::json!("carry-over")),
"computed_record": {
"field": projected(serde_json::json!("materialize me"))
}
}
});
let normalized = seed_preserving_args(args);
assert_eq!(
normalized,
serde_json::json!({
"task": "inspect the file",
"capability": "explore",
"seed": {
"projected_root": { "__projected__": "carry-over" },
"computed_record": {
"field": "materialize me"
}
}
})
);
}
#[test]
fn projected_tool_arg_normalization_preserves_continue_as_seed_roots() {
let args = serde_json::json!({
"task": projected(serde_json::json!("continue")),
"seed": {
"problem": projected(serde_json::json!({ "prompt": "large prompt" }))
}
});
let normalized = seed_preserving_args(args);
let seed = classify_received_seed(&normalized);
assert_eq!(
normalized.get("task").and_then(serde_json::Value::as_str),
Some("continue")
);
assert_eq!(
seed.projected.entries.as_slice(),
&[(
"problem".to_string(),
serde_json::json!({ "prompt": "large prompt" })
)]
);
assert!(seed.globals.is_empty());
}
#[test]
fn ordinary_tool_receives_non_projected_input_without_materialization() {
let args = serde_json::json!({
"query": "plain",
"options": { "limit": 3, "exact": true }
});
let received = materializing_args(args.clone());
assert_eq!(received, args);
}
#[test]
fn ordinary_tool_receives_projected_input_materialized_as_plain_json() {
let received = materializing_args(serde_json::json!({
"query": projected(serde_json::json!("lazy query")),
"options": {
"limit": projected(serde_json::json!(3)),
"filters": [
projected(serde_json::json!("rust")),
"tests"
]
}
}));
assert_eq!(
received,
serde_json::json!({
"query": "lazy query",
"options": {
"limit": 3,
"filters": ["rust", "tests"]
}
})
);
}
#[test]
fn projection_aware_tool_receives_non_projected_seed_as_plain_input() {
let received = seed_preserving_args(serde_json::json!({
"task": "continue from facts",
"capability": "explore",
"seed": {
"facts": { "count": 2 },
"label": "plain"
}
}));
let seed = classify_received_seed(&received);
assert!(seed.projected.is_empty());
assert_eq!(
seed.globals,
serde_json::Map::from_iter([
("facts".to_string(), serde_json::json!({ "count": 2 })),
("label".to_string(), serde_json::json!("plain")),
])
);
}
#[test]
fn projection_aware_tool_receives_projected_seed_roots_without_materializing_them() {
let received = seed_preserving_args(serde_json::json!({
"task": projected(serde_json::json!("continue from projected context")),
"capability": "explore",
"seed": {
"problem": projected(serde_json::json!("large parent context")),
"computed": {
"summary": projected(serde_json::json!("short summary"))
}
}
}));
let seed = classify_received_seed(&received);
assert_eq!(
received.get("task").and_then(serde_json::Value::as_str),
Some("continue from projected context")
);
assert_eq!(
seed.projected.entries.as_slice(),
&[(
"problem".to_string(),
serde_json::json!("large parent context")
)]
);
assert_eq!(
seed.globals,
serde_json::Map::from_iter([(
"computed".to_string(),
serde_json::json!({ "summary": "short summary" })
)])
);
}
#[test]
fn projection_policy_cutover_has_no_name_based_projection_checks() {
let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let registration_src =
std::fs::read_to_string(manifest_dir.join("src/plugin/registration.rs"))
.expect("read registration source");
let tool_args_src = std::fs::read_to_string(manifest_dir.join("src/plugin/tool_args.rs"))
.expect("read tool args source");
let executor_src = std::fs::read_to_string(manifest_dir.join("src/executor.rs"))
.expect("read executor source");
let host_bridge_src =
std::fs::read_to_string(manifest_dir.join("src/executor/host_bridge.rs"))
.expect("read host bridge source");
let projected_bindings_src =
std::fs::read_to_string(manifest_dir.join("src/projection/bindings.rs"))
.expect("read projected bindings source");
let old_hook_call = ["normalize_tool_args_for_projection", "(&ctx.tool_name"].concat();
let old_name_match = [
"matches!",
"(tool_name, ",
"\"continue_as\" | \"spawn_agent\")",
]
.concat();
let old_invalid_ref = [
"ProjectedBindingError::duplicate(",
"\"invalid_projection_ref\")",
]
.concat();
assert!(!registration_src.contains(&old_hook_call));
assert!(!tool_args_src.contains(&old_hook_call));
assert!(!executor_src.contains(&old_name_match));
assert!(!host_bridge_src.contains(&old_name_match));
assert!(!projected_bindings_src.contains(&old_invalid_ref));
}
}