返回 CodeWhale
tests.rs
根目录 / crates / tui / src / core / engine / tool_catalog / tests.rs
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
425 lines RUST