Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions crates/tui/src/core/engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
22 changes: 19 additions & 3 deletions crates/tui/src/core/engine/tool_catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HashSet<&'static str>> =
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<String>) -> bool {
if always_load.contains(name) {
return false;
Expand All @@ -134,9 +149,10 @@ pub(super) fn should_default_defer_tool(name: &str, always_load: &HashSet<String
return false;
}

!DEFAULT_ACTIVE_NATIVE_TOOLS
.iter()
.any(|core_tool| core_tool == &name)
// Membership-only test (no ordering dependency): the side set built from
// DEFAULT_ACTIVE_NATIVE_TOOLS returns identical hit/miss results as the
// former `.iter().any(...)` linear scan.
!default_active_native_tools_set().contains(name)
}

pub(super) fn apply_native_tool_deferral(catalog: &mut [Tool], always_load: &HashSet<String>) {
Expand Down
Loading