| 1 | use super::{ |
| 2 | CODE_EXECUTION_DESCRIPTION, DEFAULT_ACTIVE_NATIVE_TOOLS, ToolMode, |
| 3 | allowlist_is_native_file_and_shell_only, apply_mcp_tool_deferral, apply_native_tool_deferral, |
| 4 | build_model_tool_catalog_with_surface, default_synthetic_catalog_tool_names, |
| 5 | ensure_advanced_tooling, execute_tool_search_with_cache, initial_active_tools, |
| 6 | is_synthetic_catalog_tool, remove_evicted_cache_activations, requested_tool_mode, |
| 7 | tool_matches_any_rule, touch_cached_tool_after_execution, |
| 8 | }; |
| 9 | use crate::core::session::ToolActivationCache; |
| 10 | use codewhale_config::AppMode; |
| 11 | use codewhale_models::Tool; |
| 12 | use serde_json::json; |
| 13 | use std::collections::{BTreeSet, HashSet}; |
| 14 | |
| 15 | fn tool(name: &str) -> Tool { |
| 16 | Tool { |
| 17 | tool_type: None, |
| 18 | name: name.to_string(), |
| 19 | description: format!("{name} test tool"), |
| 20 | input_schema: json!({"type": "object", "properties": {}}), |
| 21 | allowed_callers: None, |
| 22 | defer_loading: None, |
| 23 | input_examples: None, |
| 24 | strict: None, |
| 25 | cache_control: None, |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | /// `code_execution` writes the script to a tempdir and runs it as a plain |
| 30 | /// child process in the workspace — no seccomp, no jail, no container. The |
| 31 | /// description is model-facing, so calling it a sandbox would tell the model |
| 32 | /// it has isolation the runtime never provides. |
| 33 | #[test] |
| 34 | fn code_execution_description_does_not_claim_process_sandboxing() { |
| 35 | assert!(CODE_EXECUTION_DESCRIPTION.contains("local Python interpreter")); |
| 36 | assert!(!CODE_EXECUTION_DESCRIPTION.contains("sandbox")); |
| 37 | } |
| 38 | |
| 39 | /// The published synthetic-name list and the predicate that classifies a |
| 40 | /// catalog entry as synthetic must agree. A name that appears in the list |
| 41 | /// but is not classified synthetic would let the request projection report |
| 42 | /// a provenance the engine itself disputes. |
| 43 | #[test] |
| 44 | fn published_synthetic_names_agree_with_the_synthetic_predicate() { |
| 45 | let names = default_synthetic_catalog_tool_names(); |
| 46 | assert!(!names.is_empty()); |
| 47 | for name in &names { |
| 48 | assert!( |
| 49 | is_synthetic_catalog_tool(name), |
| 50 | "'{name}' is published as synthetic but the predicate disagrees" |
| 51 | ); |
| 52 | } |
| 53 | let mut sorted = names.clone(); |
| 54 | sorted.sort(); |
| 55 | sorted.dedup(); |
| 56 | assert_eq!(names, sorted, "the list must be sorted and deduplicated"); |
| 57 | |
| 58 | // MCP names resolve through the real pool, so they are deliberately |
| 59 | // absent here even though the predicate accepts them. |
| 60 | assert!(!names.iter().any(|name| name.starts_with("mcp_"))); |
| 61 | |
| 62 | // `multi_tool_use.parallel` is a call name, never a catalog entry, so |
| 63 | // it has no catalog provenance and must not be published as synthetic. |
| 64 | assert!( |
| 65 | !names |
| 66 | .iter() |
| 67 | .any(|name| name == super::MULTI_TOOL_PARALLEL_NAME) |
| 68 | ); |
| 69 | } |
| 70 | |
| 71 | #[test] |
| 72 | fn first_turn_surface_is_stable_across_plan_work_and_operate() { |
| 73 | assert_eq!( |
| 74 | DEFAULT_ACTIVE_NATIVE_TOOLS, |
| 75 | &[ |
| 76 | "read", |
| 77 | "write", |
| 78 | "edit", |
| 79 | "bash", |
| 80 | "agent", |
| 81 | "workflow", |
| 82 | "todo_write", |
| 83 | "create_goal", |
| 84 | "get_goal", |
| 85 | "update_goal" |
| 86 | ] |
| 87 | ); |
| 88 | let expected = [ |
| 89 | "agent", |
| 90 | "bash", |
| 91 | "create_goal", |
| 92 | "get_goal", |
| 93 | "update_goal", |
| 94 | "edit", |
| 95 | "read", |
| 96 | "todo_write", |
| 97 | "tool_search", |
| 98 | "workflow", |
| 99 | "write", |
| 100 | ] |
| 101 | .into_iter() |
| 102 | .map(str::to_string) |
| 103 | .collect::<BTreeSet<_>>(); |
| 104 | let mut expected_prefix = None; |
| 105 | for mode in [AppMode::Plan, AppMode::Agent, AppMode::Operate] { |
| 106 | let mut catalog = [ |
| 107 | "read", |
| 108 | "write", |
| 109 | "edit", |
| 110 | "bash", |
| 111 | "agent", |
| 112 | "workflow", |
| 113 | "todo_write", |
| 114 | "create_goal", |
| 115 | "get_goal", |
| 116 | "update_goal", |
| 117 | "Git", |
| 118 | "Run", |
| 119 | "tasks", |
| 120 | "load_skill", |
| 121 | ] |
| 122 | .into_iter() |
| 123 | .map(tool) |
| 124 | .collect::<Vec<_>>(); |
| 125 | let always_load = HashSet::new(); |
| 126 | apply_native_tool_deferral(&mut catalog, &always_load); |
| 127 | ensure_advanced_tooling(&mut catalog, mode, &always_load, ToolMode::Direct); |
| 128 | let active_names = initial_active_tools(&catalog); |
| 129 | let active = active_names.iter().cloned().collect::<BTreeSet<_>>(); |
| 130 | assert_eq!(active, expected, "{mode:?}"); |
| 131 | let prefix = serde_json::to_string(&super::active_tools_for_step(&catalog, &active_names)) |
| 132 | .expect("serialize first-turn tool prefix"); |
| 133 | if let Some(expected) = &expected_prefix { |
| 134 | assert_eq!(&prefix, expected, "{mode:?} must preserve schema bytes"); |
| 135 | } else { |
| 136 | expected_prefix = Some(prefix); |
| 137 | } |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | #[test] |
| 142 | fn eager_workflow_still_respects_command_allow_and_deny_gates() { |
| 143 | for mode in [AppMode::Plan, AppMode::Agent, AppMode::Operate] { |
| 144 | for (allow, deny, expected) in [ |
| 145 | (None, None, true), |
| 146 | (Some("read"), None, false), |
| 147 | (Some("workflow"), None, true), |
| 148 | (None, Some("workflow"), false), |
| 149 | (Some("workflow"), Some("workflow"), false), |
| 150 | ] { |
| 151 | let catalog = build_model_tool_catalog_with_surface( |
| 152 | ["read", "agent", "workflow"] |
| 153 | .into_iter() |
| 154 | .map(tool) |
| 155 | .collect(), |
| 156 | Vec::new(), |
| 157 | mode, |
| 158 | &HashSet::new(), |
| 159 | crate::model_profile::ToolSurfaceBudget::Standard, |
| 160 | ); |
| 161 | let policy = super::ToolSurfacePolicy::new( |
| 162 | crate::tools::ToolRegistry::new(crate::tools::ToolContext::for_empty_registry()), |
| 163 | Some(catalog), |
| 164 | mode, |
| 165 | &HashSet::new(), |
| 166 | &["workflow"], // Cached activation cannot restore a denied tool. |
| 167 | false, |
| 168 | allow.map(|name| vec![name.to_string()]), |
| 169 | deny.map(|name| vec![name.to_string()]), |
| 170 | None, |
| 171 | codewhale_execpolicy::ApprovalMode::Suggest, |
| 172 | ToolMode::Direct, |
| 173 | ); |
| 174 | assert_eq!(policy.allows_tool("workflow"), expected); |
| 175 | assert_eq!(policy.active_names.contains("workflow"), expected); |
| 176 | assert_eq!( |
| 177 | policy.catalog.iter().any(|tool| tool.name == "workflow"), |
| 178 | expected |
| 179 | ); |
| 180 | } |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | #[test] |
| 185 | fn mcp_tools_are_searchable_not_eager_in_every_mode() { |
| 186 | for mode in [AppMode::Plan, AppMode::Agent] { |
| 187 | let mut catalog = vec![tool("read_mcp_resource"), tool("mcp_acme_lookup")]; |
| 188 | apply_mcp_tool_deferral(&mut catalog, mode, &HashSet::new()); |
| 189 | assert!( |
| 190 | catalog |
| 191 | .iter() |
| 192 | .all(|definition| definition.defer_loading == Some(true)), |
| 193 | "{mode:?}: {catalog:?}" |
| 194 | ); |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | #[test] |
| 199 | fn cache_eviction_does_not_hide_a_tool_that_became_eager() { |
| 200 | let mut catalog = vec![tool("promoted")]; |
| 201 | catalog[0].defer_loading = Some(true); |
| 202 | let mut cache = ToolActivationCache::default(); |
| 203 | cache.activate(&catalog, &["promoted".to_string()]); |
| 204 | |
| 205 | catalog[0].defer_loading = Some(false); |
| 206 | let mut active = initial_active_tools(&catalog); |
| 207 | let evicted = cache.revalidate(&catalog); |
| 208 | remove_evicted_cache_activations(&catalog, &mut active, evicted); |
| 209 | |
| 210 | assert!(active.contains("promoted")); |
| 211 | assert_eq!(cache.names().count(), 0); |
| 212 | } |
| 213 | |
| 214 | #[test] |
| 215 | fn successful_cached_execution_updates_lru_without_granting_uncached_names() { |
| 216 | let catalog = (0..=8) |
| 217 | .map(|index| { |
| 218 | let mut definition = tool(&format!("deferred-{index}")); |
| 219 | definition.defer_loading = Some(true); |
| 220 | definition |
| 221 | }) |
| 222 | .collect::<Vec<_>>(); |
| 223 | let mut cache = ToolActivationCache::default(); |
| 224 | let first = (0..8) |
| 225 | .map(|index| format!("deferred-{index}")) |
| 226 | .collect::<Vec<_>>(); |
| 227 | let mut active = HashSet::new(); |
| 228 | let delta = cache.activate(&catalog, &first); |
| 229 | active.extend(delta.admitted); |
| 230 | |
| 231 | assert!(touch_cached_tool_after_execution( |
| 232 | &catalog, |
| 233 | &mut active, |
| 234 | &mut cache, |
| 235 | "deferred-0" |
| 236 | )); |
| 237 | let delta = cache.activate(&catalog, &["deferred-8".to_string()]); |
| 238 | remove_evicted_cache_activations(&catalog, &mut active, delta.evicted); |
| 239 | active.extend(delta.admitted); |
| 240 | assert!(cache.names().any(|name| name == "deferred-0")); |
| 241 | assert!(!cache.names().any(|name| name == "deferred-1")); |
| 242 | |
| 243 | assert!(!touch_cached_tool_after_execution( |
| 244 | &catalog, |
| 245 | &mut active, |
| 246 | &mut cache, |
| 247 | "never-activated" |
| 248 | )); |
| 249 | assert!(!active.contains("never-activated")); |
| 250 | } |
| 251 | |
| 252 | #[test] |
| 253 | fn searching_for_an_eager_tool_is_not_reported_as_cache_rejected() { |
| 254 | let mut catalog = vec![tool("read")]; |
| 255 | catalog[0].defer_loading = Some(false); |
| 256 | let mut active = initial_active_tools(&catalog); |
| 257 | let mut cache = ToolActivationCache::default(); |
| 258 | |
| 259 | let result = execute_tool_search_with_cache( |
| 260 | super::TOOL_SEARCH_NAME, |
| 261 | &json!({"query": "read"}), |
| 262 | &catalog, |
| 263 | &mut active, |
| 264 | &mut cache, |
| 265 | ) |
| 266 | .expect("tool search should succeed"); |
| 267 | |
| 268 | let metadata = result.metadata.expect("search metadata"); |
| 269 | assert_eq!(metadata["tool_references"], json!([])); |
| 270 | assert_eq!(metadata["unavailable_tool_references"], json!([])); |
| 271 | assert!(active.contains("read")); |
| 272 | assert_eq!(cache.names().count(), 0); |
| 273 | } |
| 274 | |
| 275 | #[test] |
| 276 | fn allow_and_deny_rules_cover_visible_and_hidden_compat_aliases_symmetrically() { |
| 277 | for family in [ |
| 278 | &["read", "read_file"][..], |
| 279 | &["write", "write_file"][..], |
| 280 | &["edit", "edit_file"][..], |
| 281 | &["bash", "Bash", "exec_shell"][..], |
| 282 | ] { |
| 283 | for rule in family { |
| 284 | let rules = vec![(*rule).to_string()]; |
| 285 | for tool_name in family { |
| 286 | assert!( |
| 287 | tool_matches_any_rule(&rules, tool_name), |
| 288 | "rule {rule:?} should cover alias {tool_name:?}" |
| 289 | ); |
| 290 | } |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | assert!(tool_matches_any_rule(&["exec_shell*".to_string()], "bash")); |
| 295 | assert!(tool_matches_any_rule( |
| 296 | &["exec_shell*".to_string()], |
| 297 | "exec_shell_wait" |
| 298 | )); |
| 299 | for primitive in ["read", "write", "edit"] { |
| 300 | assert!(tool_matches_any_rule(&["File".to_string()], primitive)); |
| 301 | } |
| 302 | assert!(!tool_matches_any_rule(&["read".to_string()], "write")); |
| 303 | } |
| 304 | |
| 305 | #[test] |
| 306 | fn native_file_and_shell_allowlist_can_skip_mcp_startup() { |
| 307 | let native = ["bash", "read", "write", "edit"].map(str::to_string); |
| 308 | assert!(allowlist_is_native_file_and_shell_only(Some(&native))); |
| 309 | assert!(!allowlist_is_native_file_and_shell_only(None)); |
| 310 | } |
| 311 | |
| 312 | #[test] |
| 313 | fn unknown_and_wildcard_allowlists_keep_mcp_startup() { |
| 314 | for rule in ["mcp_github_list_prs", "mcp_*", "m*", "*", "other_tool"] { |
| 315 | assert!( |
| 316 | !allowlist_is_native_file_and_shell_only(Some(&[rule.to_string()])), |
| 317 | "{rule} may admit an MCP-backed tool" |
| 318 | ); |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | #[test] |
| 323 | fn compact_surface_keeps_agent_and_workflow_eager() { |
| 324 | let catalog = build_model_tool_catalog_with_surface( |
| 325 | [ |
| 326 | "read", |
| 327 | "write", |
| 328 | "edit", |
| 329 | "bash", |
| 330 | "agent", |
| 331 | "workflow", |
| 332 | "todo_write", |
| 333 | ] |
| 334 | .into_iter() |
| 335 | .map(tool) |
| 336 | .collect(), |
| 337 | Vec::new(), |
| 338 | AppMode::Agent, |
| 339 | &HashSet::new(), |
| 340 | crate::model_profile::ToolSurfaceBudget::Compact, |
| 341 | ); |
| 342 | |
| 343 | for name in ["agent", "workflow"] { |
| 344 | assert_eq!( |
| 345 | catalog |
| 346 | .iter() |
| 347 | .find(|definition| definition.name == name) |
| 348 | .and_then(|definition| definition.defer_loading), |
| 349 | Some(false), |
| 350 | "{name}" |
| 351 | ); |
| 352 | } |
| 353 | } |
| 354 | |
| 355 | /// The per-tool Registry paragraph is gone; the catalog builder must leave the |
| 356 | /// shell tool's description exactly as the registry produced it, so the KV |
| 357 | /// prefix stays byte-stable and there is one Registry authority (the prompt). |
| 358 | #[test] |
| 359 | fn catalog_build_does_not_append_registry_guidance_to_the_shell_tool() { |
| 360 | let described = tool("bash"); |
| 361 | let catalog = build_model_tool_catalog_with_surface( |
| 362 | vec![described.clone()], |
| 363 | Vec::new(), |
| 364 | AppMode::Agent, |
| 365 | &HashSet::new(), |
| 366 | crate::model_profile::ToolSurfaceBudget::Standard, |
| 367 | ); |
| 368 | |
| 369 | let shell = catalog |
| 370 | .iter() |
| 371 | .find(|definition| definition.name == "bash") |
| 372 | .expect("shell tool"); |
| 373 | assert_eq!(shell.description, described.description); |
| 374 | assert!(!shell.description.contains("registry_sync")); |
| 375 | } |
| 376 | |
| 377 | #[test] |
| 378 | fn requested_tool_mode_prefers_model_hint_then_flag_then_direct() { |
| 379 | use crate::features::{Feature, Features}; |
| 380 | |
| 381 | let off = Features::with_defaults(); |
| 382 | assert_eq!(requested_tool_mode(None, &off), ToolMode::Direct); |
| 383 | |
| 384 | let mut on = Features::with_defaults(); |
| 385 | on.enable(Feature::CodeMode); |
| 386 | assert_eq!(requested_tool_mode(None, &on), ToolMode::CodeMode); |
| 387 | |
| 388 | // Model metadata wins over config, in both directions (Codex parity). |
| 389 | assert_eq!( |
| 390 | requested_tool_mode(Some(ToolMode::Direct), &on), |
| 391 | ToolMode::Direct |
| 392 | ); |
| 393 | assert_eq!( |
| 394 | requested_tool_mode(Some(ToolMode::CodeMode), &off), |
| 395 | ToolMode::CodeMode |
| 396 | ); |
| 397 | } |
| 398 | |
| 399 | #[test] |
| 400 | fn execute_tools_is_eager_in_code_mode_and_deferred_in_direct() { |
| 401 | for (mode, expected_defer) in [(ToolMode::Direct, true), (ToolMode::CodeMode, false)] { |
| 402 | let mut catalog = vec![tool("read")]; |
| 403 | ensure_advanced_tooling(&mut catalog, AppMode::Agent, &HashSet::new(), mode); |
| 404 | let injected = catalog |
| 405 | .iter() |
| 406 | .find(|definition| definition.name == "execute_tools") |
| 407 | .expect("execute_tools is injected outside Plan"); |
| 408 | assert_eq!(injected.defer_loading, Some(expected_defer), "{mode:?}"); |
| 409 | } |
| 410 | |
| 411 | // Plan hides the surface under every tool mode. |
| 412 | let mut catalog = vec![tool("read")]; |
| 413 | ensure_advanced_tooling( |
| 414 | &mut catalog, |
| 415 | AppMode::Plan, |
| 416 | &HashSet::new(), |
| 417 | ToolMode::CodeMode, |
| 418 | ); |
| 419 | assert!( |
| 420 | catalog |
| 421 | .iter() |
| 422 | .all(|definition| definition.name != "execute_tools") |
| 423 | ); |
| 424 | } |
| 425 |