返回 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 /// The shared launcher applies policy only where enforcement is available.
30 /// The model-facing description must not promise unconditional isolation.
31 #[test]
32 fn code_execution_description_does_not_claim_process_sandboxing() {
33 assert!(CODE_EXECUTION_DESCRIPTION.contains("local Python interpreter"));
34 assert!(!CODE_EXECUTION_DESCRIPTION.contains("sandbox"));
35 }
36
37 /// Python output must match our UTF-8 decoder even with non-UTF-8 parent stdio.
38 #[tokio::test]
39 async fn code_execution_returns_utf8_stdout_and_stderr() {
40 use crate::dependencies::ExternalTool as _;
41 use crate::test_support::{EnvVarGuard, lock_test_env};
42
43 let _env_lock = lock_test_env();
44 if !crate::dependencies::Python::available() {
45 return;
46 }
47 let _encoding = EnvVarGuard::set("PYTHONIOENCODING", "gbk");
48 let tmp = tempfile::tempdir().expect("tempdir");
49 let result = super::execute_code_execution_tool(
50 &json!({"code": r#"import sys; print("\u4e2d\u6587"); print("\u9519\u8bef", file=sys.stderr)"#}),
51 tmp.path(),
52 &crate::tools::spec::ToolContext::new(tmp.path()),
53 )
54 .await
55 .expect("code execution should run");
56 let payload = result.metadata.expect("payload");
57 assert_eq!(
58 (
59 payload["stdout"].as_str().map(str::trim_end),
60 payload["stderr"].as_str().map(str::trim_end),
61 ),
62 (Some("中文"), Some("错误")),
63 );
64 }
65
66 /// The published synthetic-name list and the predicate that classifies a
67 /// catalog entry as synthetic must agree. A name that appears in the list
68 /// but is not classified synthetic would let the request projection report
69 /// a provenance the engine itself disputes.
70 #[test]
71 fn published_synthetic_names_agree_with_the_synthetic_predicate() {
72 let names = default_synthetic_catalog_tool_names();
73 assert!(!names.is_empty());
74 for name in &names {
75 assert!(
76 is_synthetic_catalog_tool(name),
77 "'{name}' is published as synthetic but the predicate disagrees"
78 );
79 }
80 let mut sorted = names.clone();
81 sorted.sort();
82 sorted.dedup();
83 assert_eq!(names, sorted, "the list must be sorted and deduplicated");
84
85 // MCP names resolve through the real pool, so they are deliberately
86 // absent here even though the predicate accepts them.
87 assert!(!names.iter().any(|name| name.starts_with("mcp_")));
88
89 // `multi_tool_use.parallel` is a call name, never a catalog entry, so
90 // it has no catalog provenance and must not be published as synthetic.
91 assert!(
92 !names
93 .iter()
94 .any(|name| name == super::MULTI_TOOL_PARALLEL_NAME)
95 );
96 }
97
98 #[test]
99 fn first_turn_surface_is_stable_across_plan_work_and_operate() {
100 assert_eq!(
101 DEFAULT_ACTIVE_NATIVE_TOOLS,
102 &[
103 "read",
104 "write",
105 "edit",
106 "bash",
107 "agent",
108 "workflow",
109 "todo_write",
110 "create_goal",
111 "get_goal",
112 "update_goal",
113 "load_skill"
114 ]
115 );
116 let expected = [
117 "agent",
118 "bash",
119 "create_goal",
120 "get_goal",
121 "update_goal",
122 "edit",
123 "load_skill",
124 "read",
125 "todo_write",
126 "tool_search",
127 "workflow",
128 "write",
129 ]
130 .into_iter()
131 .map(str::to_string)
132 .collect::<BTreeSet<_>>();
133 let mut expected_prefix = None;
134 for mode in [AppMode::Plan, AppMode::Agent, AppMode::Operate] {
135 let mut catalog = [
136 "read",
137 "write",
138 "edit",
139 "bash",
140 "agent",
141 "workflow",
142 "todo_write",
143 "create_goal",
144 "get_goal",
145 "update_goal",
146 "Git",
147 "Run",
148 "tasks",
149 "load_skill",
150 ]
151 .into_iter()
152 .map(tool)
153 .collect::<Vec<_>>();
154 let always_load = HashSet::new();
155 apply_native_tool_deferral(&mut catalog, &always_load);
156 ensure_advanced_tooling(&mut catalog, mode, &always_load, ToolMode::Direct);
157 let active_names = initial_active_tools(&catalog);
158 let active = active_names.iter().cloned().collect::<BTreeSet<_>>();
159 assert_eq!(active, expected, "{mode:?}");
160 let prefix = serde_json::to_string(&super::active_tools_for_step(&catalog, &active_names))
161 .expect("serialize first-turn tool prefix");
162 if let Some(expected) = &expected_prefix {
163 assert_eq!(&prefix, expected, "{mode:?} must preserve schema bytes");
164 } else {
165 expected_prefix = Some(prefix);
166 }
167 }
168 }
169
170 #[test]
171 fn eager_workflow_still_respects_command_allow_and_deny_gates() {
172 for mode in [AppMode::Plan, AppMode::Agent, AppMode::Operate] {
173 for (allow, deny, expected) in [
174 (None, None, true),
175 (Some("read"), None, false),
176 (Some("workflow"), None, true),
177 (None, Some("workflow"), false),
178 (Some("workflow"), Some("workflow"), false),
179 ] {
180 let catalog = build_model_tool_catalog_with_surface(
181 ["read", "agent", "workflow"]
182 .into_iter()
183 .map(tool)
184 .collect(),
185 Vec::new(),
186 mode,
187 &HashSet::new(),
188 crate::model_profile::ToolSurfaceBudget::Standard,
189 );
190 let policy = super::ToolSurfacePolicy::new(
191 crate::tools::ToolRegistry::new(crate::tools::ToolContext::for_empty_registry()),
192 Some(catalog),
193 mode,
194 &HashSet::new(),
195 &["workflow"], // Cached activation cannot restore a denied tool.
196 false,
197 allow.map(|name| vec![name.to_string()]),
198 deny.map(|name| vec![name.to_string()]),
199 None,
200 ToolMode::Direct,
201 );
202 assert_eq!(policy.allows_tool("workflow"), expected);
203 assert_eq!(policy.active_names.contains("workflow"), expected);
204 assert_eq!(
205 policy.catalog.iter().any(|tool| tool.name == "workflow"),
206 expected
207 );
208 }
209 }
210 }
211
212 #[test]
213 fn mcp_tools_are_searchable_not_eager_in_every_mode() {
214 for mode in [AppMode::Plan, AppMode::Agent] {
215 let mut catalog = vec![tool("read_mcp_resource"), tool("mcp_acme_lookup")];
216 apply_mcp_tool_deferral(&mut catalog, mode, &HashSet::new());
217 assert!(
218 catalog
219 .iter()
220 .all(|definition| definition.defer_loading == Some(true)),
221 "{mode:?}: {catalog:?}"
222 );
223 }
224 }
225
226 #[test]
227 fn cache_eviction_does_not_hide_a_tool_that_became_eager() {
228 let mut catalog = vec![tool("promoted")];
229 catalog[0].defer_loading = Some(true);
230 let mut cache = ToolActivationCache::default();
231 cache.activate(&catalog, &["promoted".to_string()]);
232
233 catalog[0].defer_loading = Some(false);
234 let mut active = initial_active_tools(&catalog);
235 let evicted = cache.revalidate(&catalog);
236 remove_evicted_cache_activations(&catalog, &mut active, evicted);
237
238 assert!(active.contains("promoted"));
239 assert_eq!(cache.names().count(), 0);
240 }
241
242 #[test]
243 fn successful_cached_execution_updates_lru_without_granting_uncached_names() {
244 let catalog = (0..=8)
245 .map(|index| {
246 let mut definition = tool(&format!("deferred-{index}"));
247 definition.defer_loading = Some(true);
248 definition
249 })
250 .collect::<Vec<_>>();
251 let mut cache = ToolActivationCache::default();
252 let first = (0..8)
253 .map(|index| format!("deferred-{index}"))
254 .collect::<Vec<_>>();
255 let mut active = HashSet::new();
256 let delta = cache.activate(&catalog, &first);
257 active.extend(delta.admitted);
258
259 assert!(touch_cached_tool_after_execution(
260 &catalog,
261 &mut active,
262 &mut cache,
263 "deferred-0"
264 ));
265 let delta = cache.activate(&catalog, &["deferred-8".to_string()]);
266 remove_evicted_cache_activations(&catalog, &mut active, delta.evicted);
267 active.extend(delta.admitted);
268 assert!(cache.names().any(|name| name == "deferred-0"));
269 assert!(!cache.names().any(|name| name == "deferred-1"));
270
271 assert!(!touch_cached_tool_after_execution(
272 &catalog,
273 &mut active,
274 &mut cache,
275 "never-activated"
276 ));
277 assert!(!active.contains("never-activated"));
278 }
279
280 #[test]
281 fn searching_for_an_eager_tool_is_not_reported_as_cache_rejected() {
282 let mut catalog = vec![tool("read")];
283 catalog[0].defer_loading = Some(false);
284 let mut active = initial_active_tools(&catalog);
285 let mut cache = ToolActivationCache::default();
286
287 let result = execute_tool_search_with_cache(
288 super::TOOL_SEARCH_NAME,
289 &json!({"query": "read"}),
290 &catalog,
291 &mut active,
292 &mut cache,
293 )
294 .expect("tool search should succeed");
295
296 let metadata = result.metadata.expect("search metadata");
297 assert_eq!(metadata["tool_references"], json!([]));
298 assert_eq!(metadata["unavailable_tool_references"], json!([]));
299 assert!(active.contains("read"));
300 assert_eq!(cache.names().count(), 0);
301 }
302
303 #[test]
304 fn allow_and_deny_rules_cover_visible_and_hidden_compat_aliases_symmetrically() {
305 for family in [
306 &["read", "read_file"][..],
307 &["write", "write_file"][..],
308 &["edit", "edit_file"][..],
309 &["bash", "Bash", "exec_shell"][..],
310 ] {
311 for rule in family {
312 let rules = vec![(*rule).to_string()];
313 for tool_name in family {
314 assert!(
315 tool_matches_any_rule(&rules, tool_name),
316 "rule {rule:?} should cover alias {tool_name:?}"
317 );
318 }
319 }
320 }
321
322 assert!(tool_matches_any_rule(&["exec_shell*".to_string()], "bash"));
323 assert!(tool_matches_any_rule(
324 &["exec_shell*".to_string()],
325 "exec_shell_wait"
326 ));
327 for primitive in ["read", "write", "edit"] {
328 assert!(tool_matches_any_rule(&["File".to_string()], primitive));
329 }
330 assert!(!tool_matches_any_rule(&["read".to_string()], "write"));
331 }
332
333 #[test]
334 fn native_file_and_shell_allowlist_can_skip_mcp_startup() {
335 let native = ["bash", "read", "write", "edit"].map(str::to_string);
336 assert!(allowlist_is_native_file_and_shell_only(Some(&native)));
337 assert!(!allowlist_is_native_file_and_shell_only(None));
338 }
339
340 #[test]
341 fn unknown_and_wildcard_allowlists_keep_mcp_startup() {
342 for rule in ["mcp_github_list_prs", "mcp_*", "m*", "*", "other_tool"] {
343 assert!(
344 !allowlist_is_native_file_and_shell_only(Some(&[rule.to_string()])),
345 "{rule} may admit an MCP-backed tool"
346 );
347 }
348 }
349
350 #[test]
351 fn compact_surface_keeps_agent_and_workflow_eager() {
352 let catalog = build_model_tool_catalog_with_surface(
353 [
354 "read",
355 "write",
356 "edit",
357 "bash",
358 "agent",
359 "workflow",
360 "todo_write",
361 ]
362 .into_iter()
363 .map(tool)
364 .collect(),
365 Vec::new(),
366 AppMode::Agent,
367 &HashSet::new(),
368 crate::model_profile::ToolSurfaceBudget::Compact,
369 );
370
371 for name in ["agent", "workflow"] {
372 assert_eq!(
373 catalog
374 .iter()
375 .find(|definition| definition.name == name)
376 .and_then(|definition| definition.defer_loading),
377 Some(false),
378 "{name}"
379 );
380 }
381 }
382
383 /// The per-tool Registry paragraph is gone; the catalog builder must leave the
384 /// shell tool's description exactly as the registry produced it, so the KV
385 /// prefix stays byte-stable and there is one Registry authority (the prompt).
386 #[test]
387 fn catalog_build_does_not_append_registry_guidance_to_the_shell_tool() {
388 let described = tool("bash");
389 let catalog = build_model_tool_catalog_with_surface(
390 vec![described.clone()],
391 Vec::new(),
392 AppMode::Agent,
393 &HashSet::new(),
394 crate::model_profile::ToolSurfaceBudget::Standard,
395 );
396
397 let shell = catalog
398 .iter()
399 .find(|definition| definition.name == "bash")
400 .expect("shell tool");
401 assert_eq!(shell.description, described.description);
402 assert!(!shell.description.contains("registry_sync"));
403 }
404
405 #[test]
406 fn requested_tool_mode_prefers_model_hint_then_flag_then_direct() {
407 use crate::features::{Feature, Features};
408
409 // #6562: code mode is the default; `[features] code_mode = false` is the
410 // escape hatch back to Direct.
411 let on = Features::with_defaults();
412 assert_eq!(requested_tool_mode(None, &on), ToolMode::CodeMode);
413
414 let mut off = Features::with_defaults();
415 off.disable(Feature::CodeMode);
416 assert_eq!(requested_tool_mode(None, &off), ToolMode::Direct);
417
418 // Model metadata wins over config, in both directions (Codex parity).
419 assert_eq!(
420 requested_tool_mode(Some(ToolMode::Direct), &on),
421 ToolMode::Direct
422 );
423 assert_eq!(
424 requested_tool_mode(Some(ToolMode::CodeMode), &off),
425 ToolMode::CodeMode
426 );
427 }
428
429 #[test]
430 fn execute_tools_is_eager_in_code_mode_and_deferred_in_direct() {
431 for (mode, expected_defer) in [(ToolMode::Direct, true), (ToolMode::CodeMode, false)] {
432 let mut catalog = vec![tool("read")];
433 ensure_advanced_tooling(&mut catalog, AppMode::Agent, &HashSet::new(), mode);
434 let injected = catalog
435 .iter()
436 .find(|definition| definition.name == "execute_tools")
437 .expect("execute_tools is injected outside Plan");
438 assert_eq!(injected.defer_loading, Some(expected_defer), "{mode:?}");
439 }
440
441 // Plan hides the surface under every tool mode.
442 let mut catalog = vec![tool("read")];
443 ensure_advanced_tooling(
444 &mut catalog,
445 AppMode::Plan,
446 &HashSet::new(),
447 ToolMode::CodeMode,
448 );
449 assert!(
450 catalog
451 .iter()
452 .all(|definition| definition.name != "execute_tools")
453 );
454 }
455
456 /// A timed-out or cancelled `code_execution` kills the interpreter and what the
457 /// script started; a bare `output()` left both running.
458 #[cfg(unix)]
459 #[tokio::test]
460 async fn dropped_code_execution_kills_the_interpreter_tree() {
461 if crate::dependencies::resolve_python_interpreter().is_none() {
462 return;
463 }
464 let tmp = tempfile::tempdir().expect("tempdir");
465 let pid_file = tmp.path().join("grandchild.pid");
466 let code = format!(
467 "import subprocess\np = subprocess.Popen(['sleep', '300'])\nopen({:?}, 'w').write(str(p.pid))\np.wait()\n",
468 pid_file.display().to_string()
469 );
470 let input = json!({ "code": code });
471 let context = crate::tools::spec::ToolContext::new(tmp.path());
472 let run = super::execute_code_execution_tool(&input, tmp.path(), &context);
473 let grandchild = crate::process_tree::drop_once_pid_written(run, &pid_file).await;
474 assert!(
475 crate::process_tree::wait_for_pid_exit(grandchild, std::time::Duration::from_secs(5)),
476 "a process the script started outlived the dropped code_execution call"
477 );
478 }
479
479 lines RUST