diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 10a27e28a..6e730f077 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -1586,6 +1586,41 @@ fn non_yolo_mode_retains_default_defer_policy() { assert!(should_default_defer_tool("git_blame", &always_load)); } +#[test] +fn default_defer_lookup_matches_linear_scan_over_active_native_tools() { + // Parity guard for #4152: `should_default_defer_tool` now consults an O(1) + // side set built from DEFAULT_ACTIVE_NATIVE_TOOLS instead of a linear + // `.iter().any(...)` scan. Assert the set returns the SAME hit/miss as an + // explicit linear scan over the ordered array — every array member is a hit + // (not deferred); names outside the array miss (deferred by default). + let always_load = HashSet::new(); + let active = default_active_native_tool_names(); + + for name in active { + // Reference linear scan == what the converted lookup must agree with. + let linear_hit = active.iter().any(|core| core == name); + assert!(linear_hit, "reference scan should find array member {name}"); + assert!( + !should_default_defer_tool(name, &always_load), + "array member {name} must stay active (not deferred)" + ); + } + + for name in [ + "git_blame", + "task_shell_start", + REQUEST_USER_INPUT_NAME, + "definitely_not_a_tool", + ] { + let linear_hit = active.iter().any(|core| *core == name); + assert!(!linear_hit, "non-member {name} should be absent from array"); + assert!( + should_default_defer_tool(name, &always_load), + "non-member {name} must default to deferred" + ); + } +} + #[test] fn model_tool_catalog_applies_native_and_mcp_deferral() { let always_load = HashSet::new(); diff --git a/crates/tui/src/core/engine/tool_catalog.rs b/crates/tui/src/core/engine/tool_catalog.rs index 3d5e3c33e..72f591ebc 100644 --- a/crates/tui/src/core/engine/tool_catalog.rs +++ b/crates/tui/src/core/engine/tool_catalog.rs @@ -125,6 +125,21 @@ fn cached_fallbacks() -> &'static [CachedFallback] { }) } +/// Membership index over [`DEFAULT_ACTIVE_NATIVE_TOOLS`], built once for the +/// process lifetime. The array stays the source of truth for *ordered* +/// iteration (see [`tool_catalog_consistency_issues`] and +/// `engine::default_active_native_tool_names`); this set only accelerates the +/// hot membership check in [`should_default_defer_tool`], which runs once per +/// catalog tool on every catalog rebuild (i.e. per turn) — an O(n·m) linear +/// scan over the array collapses to O(1) hashed lookups. +static DEFAULT_ACTIVE_NATIVE_TOOLS_SET: std::sync::OnceLock> = + std::sync::OnceLock::new(); + +fn default_active_native_tools_set() -> &'static HashSet<&'static str> { + DEFAULT_ACTIVE_NATIVE_TOOLS_SET + .get_or_init(|| DEFAULT_ACTIVE_NATIVE_TOOLS.iter().copied().collect()) +} + pub(super) fn should_default_defer_tool(name: &str, always_load: &HashSet) -> bool { if always_load.contains(name) { return false; @@ -134,9 +149,10 @@ pub(super) fn should_default_defer_tool(name: &str, always_load: &HashSet) {