返回 CodeWhale
test_cases_10.rs
根目录 / crates / tui / src / core / engine / tests / test_cases_10.rs
1
2
3 #[test]
4 fn default_active_contract_keeps_discovery_and_core_tools_eager() {
5 const EXPECTED_NATIVE: [&str; 11] = [
6 "read",
7 "write",
8 "edit",
9 "bash",
10 "agent",
11 "workflow",
12 "todo_write",
13 "create_goal",
14 "get_goal",
15 "update_goal",
16 "load_skill",
17 ];
18 assert_eq!(
19 default_active_native_tool_names(),
20 EXPECTED_NATIVE.as_slice()
21 );
22
23 let always_load = HashSet::new();
24 let mut catalog = build_model_tool_catalog(
25 EXPECTED_NATIVE.into_iter().map(api_tool).collect(),
26 Vec::new(),
27 AppMode::Agent,
28 &always_load,
29 );
30 ensure_advanced_tooling(
31 &mut catalog,
32 AppMode::Agent,
33 &always_load,
34 crate::core::engine::tool_catalog::ToolMode::Direct,
35 );
36 let active = initial_active_tools(&catalog);
37 let expected = EXPECTED_NATIVE
38 .into_iter()
39 .chain([TOOL_SEARCH_NAME])
40 .map(str::to_string)
41 .collect::<HashSet<_>>();
42
43 assert_eq!(active, expected);
44 assert_eq!(
45 catalog
46 .iter()
47 .find(|tool| tool.name == TOOL_SEARCH_NAME)
48 .and_then(|tool| tool.defer_loading),
49 Some(false)
50 );
51 }
52
53 #[test]
54 fn non_yolo_mode_retains_default_defer_policy() {
55 let always_load = HashSet::new();
56 for core in [
57 "read",
58 "write",
59 "edit",
60 "bash",
61 "agent",
62 "todo_write",
63 "load_skill",
64 ] {
65 assert!(!should_default_defer_tool(core, &always_load));
66 }
67 for searchable in [
68 "Bash",
69 "File",
70 "Git",
71 "Run",
72 "remember",
73 REQUEST_USER_INPUT_NAME,
74 "read_file",
75 "edit_file",
76 "apply_patch",
77 "git_status",
78 "git_blame",
79 "run_tests",
80 "web_search",
81 ] {
82 assert!(should_default_defer_tool(searchable, &always_load));
83 }
84 }
85
86 #[test]
87 fn default_defer_lookup_matches_linear_scan_over_active_native_tools() {
88 // Parity guard for #4152: `should_default_defer_tool` now consults an O(1)
89 // side set built from DEFAULT_ACTIVE_NATIVE_TOOLS instead of a linear
90 // `.iter().any(...)` scan. Assert the set returns the SAME hit/miss as an
91 // explicit linear scan over the ordered array — every array member is a hit
92 // (not deferred); names outside the array miss (deferred by default).
93 let always_load = HashSet::new();
94 let active = default_active_native_tool_names();
95
96 for name in active {
97 // Reference linear scan == what the converted lookup must agree with.
98 let linear_hit = active.iter().any(|core| core == name);
99 assert!(linear_hit, "reference scan should find array member {name}");
100 assert!(
101 !should_default_defer_tool(name, &always_load),
102 "array member {name} must stay active (not deferred)"
103 );
104 }
105
106 for name in [
107 "git_blame",
108 "task_shell_start",
109 REQUEST_USER_INPUT_NAME,
110 "definitely_not_a_tool",
111 ] {
112 let linear_hit = active.contains(&name);
113 assert!(!linear_hit, "non-member {name} should be absent from array");
114 assert!(
115 should_default_defer_tool(name, &always_load),
116 "non-member {name} must default to deferred"
117 );
118 }
119 }
120
121 #[test]
122 fn model_tool_catalog_applies_native_and_mcp_deferral() {
123 let always_load = HashSet::new();
124 let catalog = build_model_tool_catalog(
125 vec![
126 api_tool("read"),
127 api_tool("write"),
128 api_tool("edit"),
129 api_tool("bash"),
130 api_tool("agent"),
131 api_tool("Git"),
132 api_tool("Run"),
133 api_tool("remember"),
134 api_tool("project_map"),
135 ],
136 vec![api_tool("list_mcp_resources"), api_tool("mcp_server_write")],
137 AppMode::Agent,
138 &always_load,
139 );
140
141 let defer_loading = |name: &str| {
142 catalog
143 .iter()
144 .find(|tool| tool.name == name)
145 .and_then(|tool| tool.defer_loading)
146 };
147
148 for core in ["read", "write", "edit", "bash", "agent"] {
149 assert_eq!(defer_loading(core), Some(false));
150 }
151 assert_eq!(defer_loading("Git"), Some(true));
152 assert_eq!(defer_loading("Run"), Some(true));
153 assert_eq!(defer_loading("remember"), Some(true));
154 assert_eq!(defer_loading("project_map"), Some(true));
155 assert_eq!(defer_loading("list_mcp_resources"), Some(true));
156 assert_eq!(defer_loading("mcp_server_write"), Some(true));
157 }
158
159 #[test]
160 fn registry_sync_results_are_bounded_like_every_other_tool() {
161 // The full-catalog bypass is gone: an oversized registry payload flows
162 // through the same route budget as any other tool result.
163 let budget = crate::route_budget::route_inline_char_budget_for_route(
164 ProviderKind::Deepseek,
165 "small-context-model",
166 None,
167 );
168 let raw = format!(
169 "{{\"instruction\":\"compare all\",\"servers\":[{{\"name\":\"{}\"}}]}}",
170 "a".repeat(budget + 1_000),
171 );
172 let output = ToolResult::success(raw.clone());
173
174 let context = compact_tool_result_for_route(
175 ProviderKind::Deepseek,
176 "small-context-model",
177 None,
178 "registry_sync",
179 &output,
180 );
181
182 assert_ne!(context, raw);
183 assert!(context.chars().count() <= budget);
184 assert!(context.contains(crate::tools::truncate::SPILLOVER_RECOVERY_HINT));
185 }
186
187 #[test]
188 fn capability_compact_surface_defers_nonessential_core_tools() {
189 let always_load = HashSet::new();
190 let catalog = build_model_tool_catalog_with_surface(
191 vec![
192 api_tool("read"),
193 api_tool("write"),
194 api_tool("edit"),
195 api_tool("bash"),
196 api_tool("agent"),
197 api_tool("Git"),
198 api_tool("Run"),
199 api_tool(TOOL_SEARCH_NAME),
200 api_tool("update_plan"),
201 api_tool("Web"),
202 ],
203 vec![api_tool("list_mcp_resources"), api_tool("mcp_server_write")],
204 AppMode::Agent,
205 &always_load,
206 crate::model_profile::ToolSurfaceBudget::Compact,
207 );
208
209 let defer_loading = |name: &str| {
210 catalog
211 .iter()
212 .find(|tool| tool.name == name)
213 .and_then(|tool| tool.defer_loading)
214 };
215
216 for core in ["read", "write", "edit", "bash", "agent"] {
217 assert_eq!(defer_loading(core), Some(false));
218 }
219 assert_eq!(defer_loading("Git"), Some(true));
220 assert_eq!(defer_loading("update_plan"), Some(true));
221 assert_eq!(defer_loading(TOOL_SEARCH_NAME), Some(false));
222 assert_eq!(defer_loading("list_mcp_resources"), Some(true));
223 assert_eq!(defer_loading("Run"), Some(true));
224 assert_eq!(defer_loading("Web"), Some(true));
225 assert_eq!(defer_loading("mcp_server_write"), Some(true));
226 }
227
228 #[test]
229 fn capability_full_surface_preserves_small_default_head() {
230 let always_load = HashSet::new();
231 let catalog = build_model_tool_catalog_with_surface(
232 vec![
233 api_tool("read"),
234 api_tool("write"),
235 api_tool("edit"),
236 api_tool("bash"),
237 api_tool("agent"),
238 api_tool("Run"),
239 ],
240 Vec::new(),
241 AppMode::Agent,
242 &always_load,
243 crate::model_profile::ToolSurfaceBudget::Full,
244 );
245
246 for name in ["read", "write", "edit", "bash", "agent"] {
247 assert_eq!(
248 catalog
249 .iter()
250 .find(|tool| tool.name == name)
251 .and_then(|tool| tool.defer_loading),
252 Some(false),
253 "{name} should stay eager on full tool surfaces"
254 );
255 }
256 assert_eq!(
257 catalog
258 .iter()
259 .find(|tool| tool.name == "Run")
260 .and_then(|tool| tool.defer_loading),
261 Some(true)
262 );
263 }
264
265 #[test]
266 fn plugin_or_benchmark_tools_remain_searchable_not_eager() {
267 let always_load = HashSet::new();
268 let mut catalog = build_model_tool_catalog(
269 vec![api_tool("KB_search"), api_tool("read")],
270 Vec::new(),
271 AppMode::Agent,
272 &always_load,
273 );
274
275 ensure_advanced_tooling(
276 &mut catalog,
277 AppMode::Agent,
278 &always_load,
279 crate::core::engine::tool_catalog::ToolMode::Direct,
280 );
281
282 let active = initial_active_tools(&catalog);
283 assert!(!active.contains("KB_search"));
284 assert!(active.contains("read"));
285 assert_eq!(
286 catalog
287 .iter()
288 .find(|tool| tool.name == "KB_search")
289 .and_then(|tool| tool.defer_loading),
290 Some(true)
291 );
292 }
293
294 #[tokio::test]
295 async fn registry_discovery_and_start_handlers_exist_in_agent_and_plan_modes() {
296 let (mut engine, _handle) = Engine::new(EngineConfig::default(), &Config::default());
297 engine.ensure_mcp_pool().await.expect("initialize MCP pool");
298
299 for mode in [AppMode::Agent, AppMode::Plan] {
300 let registry = engine
301 .build_turn_tool_registry_builder(
302 mode,
303 engine.config.todos.clone(),
304 engine.config.plan_state.clone(),
305 )
306 .build(engine.build_tool_context(mode, false));
307 assert!(registry.contains("registry_sync"), "missing in {mode:?}");
308 assert!(registry.contains("read_media"), "missing in {mode:?}");
309 let media_tools = registry
310 .to_api_tools_with_cache(true)
311 .into_iter()
312 .filter(|tool| tool.name == "read_media")
313 .collect::<Vec<_>>();
314 assert_eq!(
315 media_tools.len(),
316 1,
317 "read_media must be registered exactly once in {mode:?}"
318 );
319 assert_eq!(
320 media_tools[0].defer_loading,
321 Some(true),
322 "read_media must remain default-off/deferred in {mode:?}"
323 );
324 assert!(
325 registry.contains("start_registry_mcp_server"),
326 "missing in {mode:?}"
327 );
328 assert!(!registry.contains("registry_install_run_info"));
329 }
330 }
331
332 #[test]
333 fn catalog_consistency_self_check_flags_registered_core_tool_missing_from_catalog() {
334 let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default());
335 let registry = engine
336 .build_turn_tool_registry_builder(
337 AppMode::Agent,
338 engine.config.todos.clone(),
339 engine.config.plan_state.clone(),
340 )
341 .build(engine.build_tool_context(AppMode::Agent, false));
342 let always_load = HashSet::new();
343 let mut catalog = build_model_tool_catalog(
344 registry.to_api_tools_with_cache(true),
345 vec![],
346 AppMode::Agent,
347 &always_load,
348 );
349 catalog.retain(|tool| tool.name != "read");
350
351 let issues = tool_catalog_consistency_issues(&catalog, &registry);
352 assert!(
353 issues
354 .iter()
355 .any(|issue| issue.contains("registered core tool 'read'")),
356 "missing registered read should be reported: {issues:?}"
357 );
358 }
359
360 fn assert_exec_shell_is_not_discoverable(match_kind: &str) {
361 let catalog = vec![api_tool("read_file")];
362 let mut active = initial_active_tools(&catalog);
363
364 let result = execute_tool_search(
365 TOOL_SEARCH_NAME,
366 &json!({ "query": "exec_shell", "match": match_kind }),
367 &catalog,
368 &mut active,
369 )
370 .expect("tool search succeeds");
371
372 assert!(!active.contains("exec_shell"));
373 let metadata = result.metadata.as_ref().expect("search metadata");
374 let references = metadata["tool_references"]
375 .as_array()
376 .expect("tool references are an array");
377 assert!(
378 references
379 .iter()
380 .all(|reference| reference.as_str() != Some("exec_shell")),
381 "legacy shell alias must not surface via {match_kind}: {references:?}"
382 );
383 let unavailable = metadata["unavailable_tool_references"]
384 .as_array()
385 .expect("unavailable references are an array");
386 assert!(
387 unavailable
388 .iter()
389 .all(|reference| reference["tool_name"].as_str() != Some("exec_shell")),
390 "legacy shell alias must not surface as an unavailable fallback via {match_kind}: {unavailable:?}"
391 );
392 }
393
394 #[test]
395 fn regex_tool_search_does_not_discover_hidden_exec_shell_alias() {
396 assert_exec_shell_is_not_discoverable("regex");
397 }
398
399 #[test]
400 fn bm25_tool_search_does_not_discover_hidden_exec_shell_alias() {
401 assert_exec_shell_is_not_discoverable("bm25");
402 }
403
404 #[test]
405 fn tools_always_scenario() {
406 // Scenario consolidation of: tools_always_load_overrides_mcp_deferral, tools_always_load_overrides_default_native_deferral
407 // from tools_always_load_overrides_mcp_deferral
408 {
409 let always_load = HashSet::from(["mcp_server_write".to_string()]);
410 let catalog = build_model_tool_catalog(
411 vec![api_tool("read_file")],
412 vec![api_tool("mcp_server_write")],
413 AppMode::Agent,
414 &always_load,
415 );
416 let mcp = catalog
417 .iter()
418 .find(|tool| tool.name == "mcp_server_write")
419 .expect("mcp tool");
420 assert_eq!(mcp.defer_loading, Some(false));
421 }
422 // from tools_always_load_overrides_default_native_deferral
423 {
424 let always_load = HashSet::from(["git_blame".to_string()]);
425 assert!(!should_default_defer_tool("git_blame", &always_load));
426 }
427 }
428
429 fn tool_catalog_surface_metrics(catalog: &[Tool]) -> serde_json::Value {
430 let serialized = serde_json::to_vec(catalog).expect("serialize canonical tool catalog");
431 let mut tool_names = catalog
432 .iter()
433 .map(|tool| tool.name.clone())
434 .collect::<Vec<_>>();
435 tool_names.sort();
436 let identity_sha256 = crate::hashing::sha256_hex(tool_names.join("\0").as_bytes());
437 serde_json::json!({
438 "tools": catalog.len(),
439 "bytes": serialized.len(),
440 "tokens_est": serialized.len().div_ceil(4),
441 "tool_names": tool_names,
442 "identity_sha256": identity_sha256,
443 })
444 }
445
446 async fn measure_production_mode_tool_catalogs() -> serde_json::Value {
447 let _env_lock = lock_test_env();
448 let tmp = tempdir().expect("tempdir");
449 let home = tmp.path().join("home");
450 fs::create_dir_all(&home).expect("create isolated home");
451 let _home = EnvVarGuard::set("HOME", &home);
452 let _userprofile = EnvVarGuard::set("USERPROFILE", &home);
453 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
454 // Interpreter-backed advanced tools are intentionally excluded from this
455 // cross-platform built-in profile. The production planner still owns that
456 // decision; a deliberately nonexistent PATH root makes its real dependency
457 // probes return absent without inheriting the developer or CI host.
458 let _path = EnvVarGuard::set("PATH", tmp.path().join("no-host-interpreters"));
459 // Shell syntax is part of the tool schema. A bare name plus the empty
460 // PATH resolves to the same `bash` spelling on Unix and Windows without
461 // executing or requiring that interpreter. Do not inherit the host shell.
462 let _shell = EnvVarGuard::set("SHELL", "bash");
463 // The macOS Vision OCR probe is a framework check that ignores PATH, so
464 // the profile neutralizes it explicitly: every host presents no local OCR
465 // capability here, matching the no-host-interpreters PATH pin above.
466 let _ocr = EnvVarGuard::set("CODEWHALE_LOCAL_OCR_UNAVAILABLE", "1");
467
468 let api_config = Config {
469 default_text_model: Some(DEFAULT_TEXT_MODEL.to_string()),
470 ..Config::default()
471 }
472 .with_legacy_root(Some("local-runtime-contract-fixture".to_string()), None);
473 let mut mode_metrics = serde_json::Map::new();
474 for (mode_name, mode) in [
475 ("plan", AppMode::Plan),
476 ("act", AppMode::Agent),
477 ("operate", AppMode::Operate),
478 ] {
479 let workspace = tmp.path().join(mode_name);
480 fs::create_dir_all(&workspace).expect("create isolated mode workspace");
481 let engine_config = EngineConfig {
482 workspace,
483 allow_shell: true,
484 ..EngineConfig::default()
485 };
486 let (mut engine, _handle) = Engine::new(engine_config, &api_config);
487 // MCP catalogs depend on configured external servers. This receipt owns
488 // the canonical provider-free built-in profile and exercises the same
489 // production builder/planner with MCP explicitly disabled.
490 engine.config.features.disable(Feature::Mcp);
491 let route = TurnRouteContext {
492 provider: ProviderKind::Deepseek,
493 model: DEFAULT_TEXT_MODEL.to_string(),
494 capabilities: codewhale_config::route::RouteCapabilities::default(),
495 limits: None,
496 client: engine.codewhale_client.clone(),
497 api_config: Box::new(api_config.clone()),
498 locale_tag: engine.config.locale_tag.clone(),
499 role_models: engine.subagent_role_models(),
500 auto_model: false,
501 reasoning_effort: None,
502 reasoning_effort_auto: false,
503 };
504 let policy = crate::core::authority::TurnAuthority::from_effective_fields(
505 mode,
506 true,
507 false,
508 false,
509 ApprovalMode::Suggest,
510 );
511 let build = engine
512 .build_turn_tool_registry_and_catalog(
513 &policy,
514 &[],
515 None,
516 SubAgentWiring::Inert,
517 McpAccess::PassiveSnapshot,
518 route,
519 "",
520 )
521 .await;
522 let active = build.surface.active.clone().unwrap_or_default();
523 mode_metrics.insert(
524 mode_name.to_string(),
525 serde_json::json!({
526 "full": tool_catalog_surface_metrics(&build.surface.catalog),
527 "active": tool_catalog_surface_metrics(&active),
528 }),
529 );
530 }
531
532 serde_json::json!({
533 "surface_profile": "production-default-builtins-no-mcp-no-host-interpreters-bash-v2",
534 "execution_shell": crate::shell_dispatcher::global_dispatcher().kind().binary(),
535 "modes": mode_metrics,
536 })
537 }
538
539 fn metric_tool_names<'a>(
540 payload: &'a serde_json::Value,
541 mode: &str,
542 surface: &str,
543 ) -> HashSet<&'a str> {
544 payload["modes"][mode][surface]["tool_names"]
545 .as_array()
546 .expect("tool names array")
547 .iter()
548 .map(|name| name.as_str().expect("tool name string"))
549 .collect()
550 }
551
552 #[tokio::test]
553 #[allow(clippy::await_holding_lock)]
554 async fn runtime_contract_tool_metric_uses_canonical_mode_surfaces() {
555 let payload = measure_production_mode_tool_catalogs().await;
556 let expected_active = HashSet::from([
557 "agent",
558 "bash",
559 "create_goal",
560 "get_goal",
561 "update_goal",
562 "edit",
563 "read",
564 "todo_write",
565 "tool_search",
566 "workflow",
567 "write",
568 "load_skill",
569 ]);
570
571 for mode in ["plan", "act", "operate"] {
572 let full = metric_tool_names(&payload, mode, "full");
573 for required in [
574 "read",
575 "write",
576 "edit",
577 "bash",
578 "agent",
579 "workflow",
580 "tool_search",
581 "create_goal",
582 "get_goal",
583 "update_goal",
584 ] {
585 assert!(full.contains(required), "{mode} must include {required}");
586 }
587 for hidden in ["File", "Bash", "read_file", "write_file", "edit_file"] {
588 assert!(!full.contains(hidden), "{mode} must hide {hidden}");
589 }
590 // #6562: `[features] code_mode` defaults on, so Act/Operate promote
591 // `execute_tools` into the request head. Plan hides it entirely.
592 let mut expected_mode_active = expected_active.clone();
593 if mode != "plan" {
594 expected_mode_active.insert("execute_tools");
595 }
596 assert_eq!(
597 metric_tool_names(&payload, mode, "active"),
598 expected_mode_active,
599 "{mode} must keep the same request head including goal controls"
600 );
601 }
602
603 let plan = metric_tool_names(&payload, "plan", "full");
604 for forbidden in ["Run", "fim_edit", "verify", "execute_tools"] {
605 assert!(!plan.contains(forbidden), "Plan must exclude {forbidden}");
606 }
607
608 for mode in ["act", "operate"] {
609 let full = metric_tool_names(&payload, mode, "full");
610 for required in ["Run", "verify", "fim_edit"] {
611 assert!(full.contains(required), "{mode} must include {required}");
612 }
613 }
614 }
615
616 #[tokio::test]
617 #[ignore = "one-shot metric for scripts/measure-tool-catalog.py"]
618 #[allow(clippy::await_holding_lock)]
619 #[allow(clippy::print_stdout)]
620 async fn print_mode_tool_catalog_metrics() {
621 let metrics = measure_production_mode_tool_catalogs().await;
622 assert_eq!(
623 metrics["execution_shell"], "bash",
624 "run this exact metric in a fresh process so its shell fixture owns dispatcher initialization"
625 );
626 println!("TOOL_CATALOG_METRICS {metrics}");
627 }
628
629 #[test]
630 fn runtime_contract_tool_metric_is_independent_of_inherited_shell() {
631 let metric = "core::engine::tests::print_mode_tool_catalog_metrics";
632 let samples: Vec<serde_json::Value> = ["/bin/zsh", "pwsh"]
633 .into_iter()
634 .map(|shell| {
635 let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
636 .args([
637 metric,
638 "--exact",
639 "--ignored",
640 "--nocapture",
641 "--test-threads=1",
642 ])
643 .env("SHELL", shell)
644 .output()
645 .expect("run exact metric in a fresh process");
646 assert!(
647 output.status.success(),
648 "metric failed: {}{}",
649 String::from_utf8_lossy(&output.stdout),
650 String::from_utf8_lossy(&output.stderr)
651 );
652 String::from_utf8(output.stdout)
653 .expect("metric UTF-8")
654 .lines()
655 .find_map(|line| {
656 line.split_once("TOOL_CATALOG_METRICS ")
657 .map(|(_, payload)| serde_json::from_str(payload).expect("metric JSON"))
658 })
659 .expect("metric marker")
660 })
661 .collect();
662 assert_eq!(samples[0], samples[1], "host shell changed the fixture");
663 assert_eq!(samples[0]["execution_shell"], "bash");
664 }
665
666 #[test]
667 #[ignore = "one-shot metric for scripts/measure-runtime-contract.py"]
668 #[allow(clippy::print_stdout)]
669 fn print_mode_runtime_contract_metrics() {
670 let _env_lock = lock_test_env();
671 let tmp = tempdir().expect("tempdir");
672 let workspace = tmp.path().join("workspace");
673 let home = tmp.path().join("home");
674 fs::create_dir_all(&workspace).expect("create isolated workspace");
675 fs::create_dir_all(&home).expect("create isolated home");
676 let _home = EnvVarGuard::set("HOME", &home);
677 let _userprofile = EnvVarGuard::set("USERPROFILE", &home);
678 let codewhale_home = home.join(".codewhale");
679 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
680 // Keep the model-visible shell fact stable across developer and CI hosts
681 // while exercising the exact-path contract used at runtime.
682 let _shell = EnvVarGuard::set("SHELL", "/bin/bash");
683 let mut mode_metrics = serde_json::Map::new();
684 for (mode_name, mode) in [
685 ("plan", AppMode::Plan),
686 ("act", AppMode::Agent),
687 ("operate", AppMode::Operate),
688 ] {
689 let prompt = system_prompt_for_mode_with_context_skills_and_session(
690 &workspace,
691 None,
692 None,
693 None,
694 PromptSessionContext {
695 mode,
696 ..PromptSessionContext::default()
697 },
698 );
699 let prompt_bytes = system_prompt_flat_text(&prompt).len();
700 let prompt_blocks = match &prompt {
701 codewhale_models::SystemPrompt::Blocks(blocks) => blocks.len(),
702 _ => 1,
703 };
704 mode_metrics.insert(
705 mode_name.to_string(),
706 serde_json::json!({
707 "system_prompt_bytes": prompt_bytes,
708 "system_prompt_tokens_est": prompt_bytes.div_ceil(4),
709 "system_prompt_blocks": prompt_blocks,
710 "mode_instructions_bytes": 0,
711 "mode_instructions_tokens_est": 0,
712 }),
713 );
714 }
715
716 println!(
717 "RUNTIME_CONTRACT_METRICS {}",
718 serde_json::json!({
719 "modes": mode_metrics,
720 })
721 );
722 }
723
724 fn representative_prompt(
725 workspace: &Path,
726 skills_dir: &Path,
727 instructions: Option<&[InstructionSource]>,
728 user_memory_block: Option<&str>,
729 goal_objective: Option<&str>,
730 ) -> codewhale_models::SystemPrompt {
731 system_prompt_for_mode_with_context_skills_and_session(
732 workspace,
733 None,
734 Some(skills_dir),
735 instructions,
736 PromptSessionContext {
737 user_memory_block,
738 goal_objective,
739 skills_discovery_mode: crate::skills::SkillDiscoveryMode::CodeWhaleOnly,
740 mode: AppMode::Agent,
741 ..PromptSessionContext::default()
742 },
743 )
744 }
745
746 fn prompt_block_count(prompt: &codewhale_models::SystemPrompt) -> usize {
747 match prompt {
748 codewhale_models::SystemPrompt::Blocks(blocks) => blocks.len(),
749 codewhale_models::SystemPrompt::Text(_) => 1,
750 }
751 }
752
753 #[derive(Debug)]
754 struct RepresentativePromptStage {
755 name: &'static str,
756 flat: String,
757 normalized: String,
758 }
759
760 fn normalize_representative_prompt(text: &str, workspace: &Path, home: &Path) -> String {
761 let mut replacements = Vec::new();
762 for (path, replacement) in [(workspace, "<WORKSPACE>"), (home, "<HOME>")] {
763 replacements.push((path.to_path_buf(), replacement));
764 if let Ok(canonical) = path.canonicalize() {
765 replacements.push((canonical, replacement));
766 }
767 }
768 replacements.sort_by(|(left, _), (right, _)| {
769 right
770 .to_string_lossy()
771 .len()
772 .cmp(&left.to_string_lossy().len())
773 .then_with(|| left.cmp(right))
774 });
775 replacements.dedup_by(|(left, _), (right, _)| left == right);
776
777 let normalized =
778 replacements
779 .into_iter()
780 .fold(text.to_string(), |normalized, (path, replacement)| {
781 normalized.replace(path.to_string_lossy().as_ref(), replacement)
782 });
783 // Platform remains an actionable host fact in the stable environment
784 // block. Pin it so this fixture measures prompt structure across hosts.
785 normalized.replace(
786 &format!("- platform: {}", std::env::consts::OS),
787 "- platform: <PLATFORM>",
788 )
789 }
790
791 fn representative_stage(
792 name: &'static str,
793 prompt: codewhale_models::SystemPrompt,
794 workspace: &Path,
795 home: &Path,
796 ) -> RepresentativePromptStage {
797 let flat = system_prompt_flat_text(&prompt);
798 let normalized = normalize_representative_prompt(&flat, workspace, home);
799 RepresentativePromptStage {
800 name,
801 flat,
802 normalized,
803 }
804 }
805
806 fn measure_representative_runtime_context()
807 -> (serde_json::Value, Vec<RepresentativePromptStage>, String) {
808 let _env_lock = lock_test_env();
809 let tmp = tempdir().expect("tempdir");
810 let workspace = tmp.path().join("workspace");
811 let home = tmp.path().join("home");
812 let skills_dir = workspace.join(".codewhale").join("skills");
813 fs::create_dir_all(&workspace).expect("create isolated workspace");
814 fs::create_dir_all(&home).expect("create isolated home");
815
816 let _home = EnvVarGuard::set("HOME", &home);
817 let _userprofile = EnvVarGuard::set("USERPROFILE", &home);
818 let codewhale_home = home.join(".codewhale");
819 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
820 // Keep the model-visible shell fact stable across developer and CI hosts
821 // while exercising the exact-path contract used at runtime.
822 let _shell = EnvVarGuard::set("SHELL", "/bin/bash");
823 // The fixture measures a trusted repository: project skills load only in
824 // a trusted workspace, and the skill stage exists to measure one.
825 crate::test_support::trust_workspace(&workspace);
826
827 let mut stages = vec![representative_stage(
828 "base",
829 representative_prompt(&workspace, &skills_dir, None, None, None),
830 &workspace,
831 &home,
832 )];
833
834 fs::write(
835 workspace.join("AGENTS.md"),
836 REPRESENTATIVE_PROJECT_AUTHORITY_BODY,
837 )
838 .expect("write representative project authority");
839 stages.push(representative_stage(
840 "project",
841 representative_prompt(&workspace, &skills_dir, None, None, None),
842 &workspace,
843 &home,
844 ));
845
846 let instructions = [InstructionSource::Inline {
847 name: "embedded:representative-v1".to_string(),
848 content: REPRESENTATIVE_INLINE_INSTRUCTIONS.to_string(),
849 }];
850 stages.push(representative_stage(
851 "instructions",
852 representative_prompt(&workspace, &skills_dir, Some(&instructions), None, None),
853 &workspace,
854 &home,
855 ));
856
857 let skill_dir = skills_dir.join("representative-skill");
858 fs::create_dir_all(&skill_dir).expect("create representative skill directory");
859 fs::write(
860 skill_dir.join("SKILL.md"),
861 format!(
862 "---\nname: representative-skill\ndescription: {REPRESENTATIVE_SKILL_DESCRIPTION}\n---\nExercise the deterministic runtime-contract fixture.\n"
863 ),
864 )
865 .expect("write representative skill");
866 stages.push(representative_stage(
867 "skill",
868 representative_prompt(&workspace, &skills_dir, Some(&instructions), None, None),
869 &workspace,
870 &home,
871 ));
872
873 let memory_block = format!("## Memory\n\n- {REPRESENTATIVE_MEMORY_CHECKPOINT}");
874 stages.push(representative_stage(
875 "memory",
876 representative_prompt(
877 &workspace,
878 &skills_dir,
879 Some(&instructions),
880 Some(&memory_block),
881 None,
882 ),
883 &workspace,
884 &home,
885 ));
886 stages.push(representative_stage(
887 "goal",
888 representative_prompt(
889 &workspace,
890 &skills_dir,
891 Some(&instructions),
892 Some(&memory_block),
893 Some(REPRESENTATIVE_GOAL_OBJECTIVE),
894 ),
895 &workspace,
896 &home,
897 ));
898
899 fs::write(
900 workspace.join(crate::prompts::HANDOFF_RELATIVE_PATH),
901 format!("# Representative Relay\n\n{REPRESENTATIVE_HANDOFF_RELAY}\n"),
902 )
903 .expect("write representative handoff");
904 let final_prompt = representative_prompt(
905 &workspace,
906 &skills_dir,
907 Some(&instructions),
908 Some(&memory_block),
909 Some(REPRESENTATIVE_GOAL_OBJECTIVE),
910 );
911 let final_blocks = prompt_block_count(&final_prompt);
912 stages.push(representative_stage(
913 "handoff",
914 final_prompt,
915 &workspace,
916 &home,
917 ));
918 let repeated_flat = system_prompt_flat_text(&representative_prompt(
919 &workspace,
920 &skills_dir,
921 Some(&instructions),
922 Some(&memory_block),
923 Some(REPRESENTATIVE_GOAL_OBJECTIVE),
924 ));
925
926 let mut stage_metrics = serde_json::Map::new();
927 for (index, stage) in stages.iter().enumerate() {
928 let mut metrics = serde_json::json!({
929 // Keep byte ceilings host-independent just like the structural
930 // identity: temporary workspace/home paths vary across runners.
931 "bytes": stage.normalized.len(),
932 "identity_sha256": crate::hashing::sha256_hex(stage.normalized.as_bytes()),
933 });
934 if let Some(previous) = index.checked_sub(1).and_then(|i| stages.get(i)) {
935 metrics["delta_bytes"] = serde_json::json!(
936 stage
937 .normalized
938 .len()
939 .checked_sub(previous.normalized.len())
940 .unwrap_or_else(|| panic!(
941 "representative {} stage unexpectedly shrank prompt",
942 stage.name
943 ))
944 );
945 }
946 stage_metrics.insert(stage.name.to_string(), metrics);
947 }
948 let final_stage = stages.last().expect("handoff stage");
949 let payload = serde_json::json!({
950 "fixture_id": REPRESENTATIVE_FIXTURE_ID,
951 "stages": stage_metrics,
952 "total_bytes": final_stage.normalized.len(),
953 "total_tokens_est": final_stage.normalized.len().div_ceil(4),
954 "system_prompt_blocks": final_blocks,
955 "prompts_byte_identical": final_stage.flat == repeated_flat,
956 });
957
958 (payload, stages, repeated_flat)
959 }
960
961 #[test]
962 fn representative_runtime_context_fixture_is_stable_and_contains_expected_markers() {
963 let (payload, stages, repeated_prompt) = measure_representative_runtime_context();
964 let (second_payload, second_stages, _) = measure_representative_runtime_context();
965 let final_stage = stages.last().expect("handoff stage");
966 assert_eq!(payload["fixture_id"], REPRESENTATIVE_FIXTURE_ID);
967 assert_eq!(final_stage.flat, repeated_prompt);
968 assert_eq!(payload["prompts_byte_identical"], true);
969 for (first, second) in stages.iter().zip(&second_stages) {
970 assert_eq!(first.name, second.name);
971 assert_eq!(first.normalized, second.normalized);
972 assert_eq!(
973 payload["stages"][first.name]["identity_sha256"],
974 second_payload["stages"][second.name]["identity_sha256"],
975 "representative {} digest must be stable across temp roots",
976 first.name
977 );
978 }
979 for pair in stages.windows(2) {
980 let [previous, current] = pair else {
981 unreachable!("stage windows always contain two entries")
982 };
983 assert_eq!(
984 payload["stages"][current.name]["delta_bytes"],
985 current.normalized.len() - previous.normalized.len(),
986 "representative {} delta must be computed from its adjacent stages",
987 current.name
988 );
989 }
990 let markers = [
991 REPRESENTATIVE_PROJECT_AUTHORITY,
992 REPRESENTATIVE_INLINE_INSTRUCTIONS,
993 REPRESENTATIVE_SKILL_DESCRIPTION,
994 REPRESENTATIVE_MEMORY_CHECKPOINT,
995 REPRESENTATIVE_GOAL_OBJECTIVE,
996 REPRESENTATIVE_HANDOFF_RELAY,
997 ];
998 for (stage_index, stage) in stages.iter().enumerate() {
999 for (marker_index, marker) in markers.iter().enumerate() {
1000 let expected = usize::from(marker_index < stage_index);
1001 assert_eq!(
1002 stage.flat.matches(marker).count(),
1003 expected,
1004 "representative {} stage has the wrong count for {marker}",
1005 stage.name
1006 );
1007 }
1008 }
1009 let fixture_sources = [
1010 REPRESENTATIVE_PROJECT_AUTHORITY,
1011 REPRESENTATIVE_INLINE_INSTRUCTIONS,
1012 REPRESENTATIVE_SKILL_DESCRIPTION,
1013 REPRESENTATIVE_MEMORY_CHECKPOINT,
1014 REPRESENTATIVE_GOAL_OBJECTIVE,
1015 REPRESENTATIVE_HANDOFF_RELAY,
1016 ]
1017 .join("\n")
1018 .to_ascii_lowercase();
1019 for secret_shape in ["sk-", "api_key=", "password=", "bearer "] {
1020 assert!(
1021 !fixture_sources.contains(secret_shape),
1022 "representative fixture must not contain secret-shaped values"
1023 );
1024 }
1025 }
1026
1027 #[test]
1028 #[ignore = "one-shot metric for scripts/measure-runtime-contract.py"]
1029 #[allow(clippy::print_stdout)]
1030 fn print_representative_runtime_context_metrics() {
1031 let (payload, _, _) = measure_representative_runtime_context();
1032 println!("REPRESENTATIVE_CONTEXT_METRICS {payload}");
1033 }
1034
1035 fn measure_unchanged_prompt_skill_discovery() -> (
1036 crate::skills::SkillDiscoveryMetrics,
1037 crate::skills::SkillDiscoveryMetrics,
1038 bool,
1039 ) {
1040 let _env_lock = lock_test_env();
1041 let tmp = tempdir().expect("tempdir");
1042 let workspace = tmp.path().join("workspace");
1043 let home = tmp.path().join("home");
1044 let skills_dir = workspace.join(".codewhale").join("skills");
1045 let skill = skills_dir.join("receipt-demo");
1046 fs::create_dir_all(&skill).expect("create skill directory");
1047 fs::create_dir_all(&home).expect("create isolated home");
1048 fs::write(
1049 skill.join("SKILL.md"),
1050 "---\nname: receipt-demo\ndescription: Hermetic measurement skill\n---\nMeasure discovery.\n",
1051 )
1052 .expect("write skill");
1053
1054 let _home = EnvVarGuard::set("HOME", &home);
1055 let _userprofile = EnvVarGuard::set("USERPROFILE", &home);
1056 let codewhale_home = home.join(".codewhale");
1057 let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
1058 crate::test_support::trust_workspace(&workspace);
1059
1060 crate::skills::clear_skill_discovery_cache();
1061 crate::skills::reset_discovery_metrics();
1062 let start = crate::skills::discovery_metrics_snapshot();
1063 let first_prompt = system_prompt_for_mode_with_context_skills_and_session(
1064 &workspace,
1065 None,
1066 Some(&skills_dir),
1067 None,
1068 PromptSessionContext::default(),
1069 );
1070 let after_first = crate::skills::discovery_metrics_snapshot();
1071 let second_prompt = system_prompt_for_mode_with_context_skills_and_session(
1072 &workspace,
1073 None,
1074 Some(&skills_dir),
1075 None,
1076 PromptSessionContext::default(),
1077 );
1078 let after_second = crate::skills::discovery_metrics_snapshot();
1079
1080 let first = after_first.delta_since(start);
1081 let second = after_second.delta_since(after_first);
1082 let first_flat = system_prompt_flat_text(&first_prompt);
1083 let second_flat = system_prompt_flat_text(&second_prompt);
1084 (first, second, first_flat == second_flat)
1085 }
1086
1087 #[test]
1088 fn unchanged_prompt_skill_discovery_baseline_caches_the_second_turn() {
1089 let (first, second, prompts_byte_identical) = measure_unchanged_prompt_skill_discovery();
1090 let first_expected = crate::skills::SkillDiscoveryMetrics {
1091 root_discovery_calls: 1,
1092 directories_visited: 1,
1093 skill_md_read_attempts: 1,
1094 };
1095 assert_eq!(first, first_expected);
1096 assert_eq!(second, crate::skills::SkillDiscoveryMetrics::default());
1097 assert!(prompts_byte_identical);
1098 }
1099
1100 fn skill_discovery_metric_payload(
1101 first: crate::skills::SkillDiscoveryMetrics,
1102 second: crate::skills::SkillDiscoveryMetrics,
1103 prompts_byte_identical: bool,
1104 ) -> serde_json::Value {
1105 serde_json::json!({
1106 "first_delta": {
1107 "root_discovery_calls": first.root_discovery_calls,
1108 "directories_visited": first.directories_visited,
1109 "skill_md_read_attempts": first.skill_md_read_attempts,
1110 },
1111 "second_delta": {
1112 "root_discovery_calls": second.root_discovery_calls,
1113 "directories_visited": second.directories_visited,
1114 "skill_md_read_attempts": second.skill_md_read_attempts,
1115 },
1116 "prompts_byte_identical": prompts_byte_identical,
1117 })
1118 }
1119
1120 #[test]
1121 fn skill_discovery_metric_payload_accepts_cached_second_turn() {
1122 let first = crate::skills::SkillDiscoveryMetrics {
1123 root_discovery_calls: 1,
1124 directories_visited: 1,
1125 skill_md_read_attempts: 1,
1126 };
1127 let payload = skill_discovery_metric_payload(
1128 first,
1129 crate::skills::SkillDiscoveryMetrics::default(),
1130 true,
1131 );
1132 assert_eq!(payload["first_delta"]["root_discovery_calls"], 1);
1133 assert_eq!(payload["second_delta"]["root_discovery_calls"], 0);
1134 assert_eq!(payload["second_delta"]["directories_visited"], 0);
1135 assert_eq!(payload["second_delta"]["skill_md_read_attempts"], 0);
1136 }
1137
1138 #[test]
1139 #[ignore = "one-shot metric for scripts/measure-runtime-contract.py"]
1140 #[allow(clippy::print_stdout)]
1141 fn print_skill_discovery_turn_metrics() {
1142 let (first, second, prompts_byte_identical) = measure_unchanged_prompt_skill_discovery();
1143
1144 println!(
1145 "SKILL_DISCOVERY_METRICS {}",
1146 skill_discovery_metric_payload(first, second, prompts_byte_identical)
1147 );
1148 }
1149
1150 #[test]
1151 fn deferred_first_use_executes_well_formed_calls_and_hydrates_malformed_ones() {
1152 let mut apply_patch = api_tool("apply_patch");
1153 apply_patch.defer_loading = Some(true);
1154 apply_patch.input_schema = json!({
1155 "type": "object",
1156 "properties": {
1157 "patch": { "type": "string" }
1158 },
1159 "required": ["patch"]
1160 });
1161
1162 let catalog = vec![apply_patch];
1163 let active_at_batch_start = HashSet::new();
1164 let mut hydrated_this_batch = HashSet::new();
1165 // A call already shaped like the unseen schema must not lose its turn.
1166 assert!(
1167 maybe_hydrate_requested_deferred_tool(
1168 "apply_patch",
1169 &json!({"patch": "*** Begin Patch\n*** End Patch"}),
1170 &catalog,
1171 &active_at_batch_start,
1172 &mut hydrated_this_batch,
1173 )
1174 .is_none(),
1175 "a well-formed first call executes"
1176 );
1177 assert!(
1178 hydrated_this_batch.contains("apply_patch"),
1179 "the executed tool still activates for later requests"
1180 );
1181
1182 for malformed in [
1183 json!({}),
1184 json!({"diff": "*** Begin Patch\n*** End Patch"}),
1185 json!({"patch": "x", "path": "src/lib.rs"}),
1186 json!("*** Begin Patch"),
1187 ] {
1188 let mut hydrated = HashSet::new();
1189 let result = maybe_hydrate_requested_deferred_tool(
1190 "apply_patch",
1191 &malformed,
1192 &catalog,
1193 &active_at_batch_start,
1194 &mut hydrated,
1195 )
1196 .unwrap_or_else(|| panic!("{malformed} must return the schema instead of executing"));
1197 assert!(hydrated.contains("apply_patch"));
1198 assert!(result.success);
1199 assert!(result.content.contains("Tool `apply_patch` was deferred"));
1200 assert!(result.content.contains("patch: string"));
1201 assert!(result.content.contains("The tool was not executed"));
1202 let metadata = result.metadata.expect("metadata");
1203 assert_eq!(metadata["event"], "tool.schema_hydrated");
1204 assert_eq!(metadata["executed"], false);
1205 assert_eq!(metadata["retry_required"], true);
1206 }
1207
1208 let mut active_next_batch = active_at_batch_start.clone();
1209 active_next_batch.extend(hydrated_this_batch);
1210 let mut hydrated_next_batch = HashSet::new();
1211 assert!(
1212 maybe_hydrate_requested_deferred_tool(
1213 "apply_patch",
1214 &json!({}),
1215 &catalog,
1216 &active_next_batch,
1217 &mut hydrated_next_batch,
1218 )
1219 .is_none(),
1220 "tools hydrated in a previous batch execute normally, even malformed"
1221 );
1222 }
1223
1224 /// E3: the first call to a deferred tool hydrates its schema and tells the
1225 /// model to retry. That hint is model-facing; it reaches the model in the
1226 /// tool result and must not surface as a user status line.
1227 #[tokio::test]
1228 #[allow(clippy::await_holding_lock)]
1229 async fn deferred_tool_first_use_does_not_emit_a_retry_status() {
1230 use wiremock::matchers::{body_string_contains, method, path};
1231 use wiremock::{Mock, MockServer, ResponseTemplate};
1232
1233 let _lock = lock_test_env();
1234 let workspace = tempdir().expect("tempdir");
1235 let server = MockServer::start().await;
1236 let tool_call_sse = concat!(
1237 "data: {\"id\":\"chatcmpl-e3\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[",
1238 "{\"index\":0,\"id\":\"call_e3_map\",\"type\":\"function\",\"function\":{\"name\":\"project_map\",",
1239 // Malformed on purpose: a well-formed first call now executes, and
1240 // this test covers the schema hint returned for a malformed one.
1241 "\"arguments\":\"{\\\"not_a_project_map_field\\\":true}\"}}",
1242 "]},\"finish_reason\":null}]}\n\n",
1243 "data: {\"id\":\"chatcmpl-e3\",\"choices\":[{\"index\":0,\"delta\":{},",
1244 "\"finish_reason\":\"tool_calls\"}]}\n\n",
1245 "data: [DONE]\n\n",
1246 );
1247 let done_sse = concat!(
1248 "data: {\"id\":\"chatcmpl-e3-done\",\"choices\":[{\"index\":0,",
1249 "\"delta\":{\"content\":\"done\"},\"finish_reason\":null}]}\n\n",
1250 "data: {\"id\":\"chatcmpl-e3-done\",\"choices\":[{\"index\":0,\"delta\":{},",
1251 "\"finish_reason\":\"stop\"}]}\n\n",
1252 "data: [DONE]\n\n",
1253 );
1254 Mock::given(method("POST"))
1255 .and(path("/v1/chat/completions"))
1256 .and(body_string_contains("call_e3_map"))
1257 .respond_with(
1258 ResponseTemplate::new(200)
1259 .insert_header("content-type", "text/event-stream")
1260 .set_body_string(done_sse),
1261 )
1262 .with_priority(1)
1263 .mount(&server)
1264 .await;
1265 Mock::given(method("POST"))
1266 .and(path("/v1/chat/completions"))
1267 .respond_with(
1268 ResponseTemplate::new(200)
1269 .insert_header("content-type", "text/event-stream")
1270 .set_body_string(tool_call_sse),
1271 )
1272 .expect(1)
1273 .with_priority(2)
1274 .mount(&server)
1275 .await;
1276
1277 let api_config = Config {
1278 ..Config::default()
1279 }
1280 .with_legacy_root(Some("test-key".to_string()), Some(server.uri()));
1281 let (engine, handle) = Engine::new(
1282 EngineConfig {
1283 model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
1284 workspace: workspace.path().to_path_buf(),
1285 snapshots_enabled: false,
1286 subagents_enabled: false,
1287 terminal_chrome_enabled: false,
1288 ..EngineConfig::default()
1289 },
1290 &api_config,
1291 );
1292 let run_task = tokio::spawn(engine.run());
1293 handle
1294 .send(Op::SendMessage(TurnSpec {
1295 max_output_tokens: None,
1296 content: "Map this project".to_string(),
1297 images: Vec::new(),
1298 mode: AppMode::Agent,
1299 route: resolved_route_for_test(&api_config, crate::config::DEFAULT_TEXT_MODEL),
1300 compaction: Box::new(CompactionConfig::default()),
1301 initial_routed_usage: Box::default(),
1302 goal_objective: None,
1303 goal_token_budget: None,
1304 goal_status: crate::tools::goal::GoalStatus::Active,
1305 reasoning_effort: None,
1306 reasoning_effort_auto: false,
1307 auto_model: false,
1308 allow_shell: true,
1309 trust_mode: false,
1310 auto_approve: false,
1311 approval_mode: ApprovalMode::Suggest,
1312 translation_enabled: false,
1313 allowed_tools: None,
1314 dynamic_tools: Vec::new(),
1315 hook_executor: None,
1316 verbosity: None,
1317 provenance: UserInputProvenance::ExternalUser,
1318 submission_id: None,
1319 }))
1320 .await
1321 .expect("send model turn");
1322
1323 let mut hydration_result = None;
1324 let mut statuses = Vec::new();
1325 let mut rx = handle.rx_event.write().await;
1326 while let Some(event) = tokio::time::timeout(model_turn_event_timeout(), rx.recv())
1327 .await
1328 .expect("timed out waiting for turn event")
1329 {
1330 match event {
1331 Event::Status { message, .. } => statuses.push(message),
1332 Event::ToolCallComplete { name, result, .. } if name == "project_map" => {
1333 hydration_result = Some(result);
1334 }
1335 Event::TurnComplete { .. } => break,
1336 _ => {}
1337 }
1338 }
1339 drop(rx);
1340 handle.send(Op::Shutdown).await.expect("shutdown engine");
1341 run_task.await.expect("engine task");
1342
1343 let hydration = hydration_result
1344 .expect("the deferred call completes")
1345 .expect("hydration result");
1346 assert!(
1347 hydration.content.contains("was deferred"),
1348 "the model still gets the retry hint: {}",
1349 hydration.content
1350 );
1351 assert!(
1352 statuses
1353 .iter()
1354 .all(|status| !status.contains("Loaded deferred tool")),
1355 "{statuses:?}"
1356 );
1357 }
1358
1359 #[test]
1360 fn model_tool_catalog_defers_non_core_native_tools_in_act_mode() {
1361 let always_load = HashSet::new();
1362 let catalog = build_model_tool_catalog(
1363 vec![api_tool("read"), api_tool("project_map")],
1364 vec![api_tool("mcp_server_write")],
1365 AppMode::Agent,
1366 &always_load,
1367 );
1368
1369 let defer_loading = |name: &str| {
1370 catalog
1371 .iter()
1372 .find(|tool| tool.name == name)
1373 .and_then(|tool| tool.defer_loading)
1374 };
1375
1376 assert_eq!(defer_loading("read"), Some(false));
1377 assert_eq!(defer_loading("project_map"), Some(true));
1378 assert_eq!(defer_loading("mcp_server_write"), Some(true));
1379 }
1380
1381 #[test]
1382 fn request_user_input_stays_deferred_but_can_be_dynamically_activated() {
1383 let always_load = HashSet::new();
1384 let catalog = build_model_tool_catalog(
1385 vec![api_tool("read_file"), api_tool(REQUEST_USER_INPUT_NAME)],
1386 Vec::new(),
1387 AppMode::Agent,
1388 &always_load,
1389 );
1390
1391 assert_eq!(
1392 catalog
1393 .iter()
1394 .find(|tool| tool.name == REQUEST_USER_INPUT_NAME)
1395 .and_then(|tool| tool.defer_loading),
1396 Some(true)
1397 );
1398
1399 let mut active = initial_active_tools(&catalog);
1400 assert!(!active.contains(REQUEST_USER_INPUT_NAME));
1401 active.insert(REQUEST_USER_INPUT_NAME.to_string());
1402
1403 let active_tools = active_tools_for_step(&catalog, &active);
1404 assert!(
1405 active_tools
1406 .iter()
1407 .any(|tool| tool.name == REQUEST_USER_INPUT_NAME),
1408 "dynamic active tools should expose the question modal without making it eager by default"
1409 );
1410 }
1411
1412 #[test]
1413 fn question_tool_survives_the_tool_surface_in_every_posture() {
1414 // Questions are separate from approval posture: the surface takes no
1415 // posture input, so Auto-Review offers `request_user_input` exactly like
1416 // Suggest, Never and Full Access. Only an explicit deny (headless exec's
1417 // default) withholds it.
1418 let surface = policy_for_catalog(
1419 vec![api_tool("read_file"), api_tool(REQUEST_USER_INPUT_NAME)],
1420 None,
1421 None,
1422 );
1423 assert!(
1424 surface
1425 .catalog
1426 .iter()
1427 .any(|tool| tool.name == REQUEST_USER_INPUT_NAME)
1428 );
1429
1430 // Headless exec's default deny list; `exec_agent` tests pin that
1431 // `exec_disallowed_tools(None)` produces it.
1432 let headless = policy_for_catalog(
1433 vec![api_tool("read_file"), api_tool(REQUEST_USER_INPUT_NAME)],
1434 None,
1435 Some(vec![REQUEST_USER_INPUT_NAME.to_string()]),
1436 );
1437 assert!(
1438 !headless
1439 .catalog
1440 .iter()
1441 .any(|tool| tool.name == REQUEST_USER_INPUT_NAME)
1442 );
1443 assert!(headless.catalog.iter().any(|tool| tool.name == "read_file"));
1444 }
1445
1446 #[test]
1447 fn model_tool_catalog_sorts_each_partition_for_prefix_cache_stability() {
1448 // Regression for #263: deterministic byte order of the tools array is a
1449 // hard requirement for DeepSeek's KV prefix cache. Built-ins stay as a
1450 // contiguous prefix; MCP tools follow. Within each partition: alphabetical.
1451 let always_load = HashSet::new();
1452 let catalog = build_model_tool_catalog(
1453 vec![
1454 api_tool("read_file"),
1455 api_tool("apply_patch"),
1456 api_tool("exec_shell"),
1457 ],
1458 vec![api_tool("mcp_zoo_b"), api_tool("mcp_aardvark_a")],
1459 AppMode::Agent,
1460 &always_load,
1461 );
1462
1463 let names: Vec<&str> = catalog.iter().map(|t| t.name.as_str()).collect();
1464 assert_eq!(
1465 names,
1466 vec![
1467 "apply_patch",
1468 "exec_shell",
1469 "read_file",
1470 "mcp_aardvark_a",
1471 "mcp_zoo_b",
1472 ],
1473 "built-ins must be alphabetical and contiguous; MCP tools follow, alphabetical",
1474 );
1475 }
1476
1477 #[test]
1478 fn active_tool_list_pushes_deferred_activations_to_the_tail() {
1479 // Regression for #263: when ToolSearch activates a deferred tool mid-
1480 // session, it must NOT be inserted at its catalog index — that would
1481 // shift every later tool's byte offset and bust the cached prefix.
1482 // Deferred-but-now-active tools belong at the tail.
1483 let mut a = api_tool("a_load_now");
1484 a.defer_loading = Some(false);
1485 let mut search = api_tool("search_via_toolsearch");
1486 search.defer_loading = Some(true);
1487 let mut b = api_tool("b_load_now");
1488 b.defer_loading = Some(false);
1489
1490 let catalog = vec![a, search, b];
1491 let active: HashSet<String> = ["a_load_now", "search_via_toolsearch", "b_load_now"]
1492 .into_iter()
1493 .map(String::from)
1494 .collect();
1495
1496 let listed = active_tools_for_step(&catalog, &active);
1497 let names: Vec<&str> = listed.iter().map(|t| t.name.as_str()).collect();
1498 assert_eq!(
1499 names,
1500 vec!["a_load_now", "b_load_now", "search_via_toolsearch"],
1501 "deferred-but-active tools must come after always-loaded tools",
1502 );
1503 }
1504
1505 #[test]
1506 fn legacy_rlm_actions_are_not_advertised_to_new_model_turns() {
1507 let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default());
1508 let registry = engine
1509 .build_turn_tool_registry_builder(
1510 AppMode::Agent,
1511 engine.config.todos.clone(),
1512 engine.config.plan_state.clone(),
1513 )
1514 .build(engine.build_tool_context(AppMode::Agent, false));
1515 let always_load = HashSet::new();
1516 let catalog = build_model_tool_catalog(
1517 registry.to_api_tools_with_cache(true),
1518 vec![],
1519 AppMode::Agent,
1520 &always_load,
1521 );
1522 // The session-persistent kernel is now the normal RLM path. These tools
1523 // stay registered solely for saved-transcript replay and an explicitly
1524 // named compatibility call; letting a fresh model discover them would
1525 // recreate the old action-by-action workflow.
1526 for legacy_name in [
1527 "rlm",
1528 "rlm_session_objects",
1529 "rlm_open",
1530 "rlm_eval",
1531 "rlm_configure",
1532 "rlm_close",
1533 ] {
1534 assert!(
1535 !catalog.iter().any(|tool| tool.name == legacy_name),
1536 "{legacy_name} must remain outside the new-turn model catalog"
1537 );
1538 }
1539 }
1539 lines RUST