diff --git a/tests/tools/test_budget_config.py b/tests/tools/test_budget_config.py new file mode 100644 index 000000000..aeacc6219 --- /dev/null +++ b/tests/tools/test_budget_config.py @@ -0,0 +1,176 @@ +"""Unit tests for tools/budget_config.py. + +Covers default values, resolve_threshold() priority chain +(pinned > tool_overrides > registry > default), immutability, +and the PINNED_THRESHOLDS escape-hatch for read_file. +""" + +import dataclasses +import math +from unittest.mock import patch + +import pytest + +from tools.budget_config import ( + DEFAULT_BUDGET, + DEFAULT_PREVIEW_SIZE_CHARS, + DEFAULT_RESULT_SIZE_CHARS, + DEFAULT_TURN_BUDGET_CHARS, + PINNED_THRESHOLDS, + BudgetConfig, +) + + +# --------------------------------------------------------------------------- +# Module-level constants +# --------------------------------------------------------------------------- + + +class TestModuleConstants: + """Verify documented default values haven't drifted.""" + + def test_default_result_size(self): + assert DEFAULT_RESULT_SIZE_CHARS == 100_000 + + def test_default_turn_budget(self): + assert DEFAULT_TURN_BUDGET_CHARS == 200_000 + + def test_default_preview_size(self): + assert DEFAULT_PREVIEW_SIZE_CHARS == 1_500 + + +class TestPinnedThresholds: + """PINNED_THRESHOLDS – tools whose values must never be overridden.""" + + def test_read_file_is_inf(self): + assert PINNED_THRESHOLDS["read_file"] == float("inf") + assert math.isinf(PINNED_THRESHOLDS["read_file"]) + + def test_pinned_is_not_empty(self): + assert len(PINNED_THRESHOLDS) >= 1 + + +# --------------------------------------------------------------------------- +# BudgetConfig defaults +# --------------------------------------------------------------------------- + + +class TestBudgetConfigDefaults: + """BudgetConfig() should match the module-level defaults exactly.""" + + def test_default_result_size(self): + cfg = BudgetConfig() + assert cfg.default_result_size == DEFAULT_RESULT_SIZE_CHARS + + def test_default_turn_budget(self): + cfg = BudgetConfig() + assert cfg.turn_budget == DEFAULT_TURN_BUDGET_CHARS + + def test_default_preview_size(self): + cfg = BudgetConfig() + assert cfg.preview_size == DEFAULT_PREVIEW_SIZE_CHARS + + def test_default_tool_overrides_empty(self): + cfg = BudgetConfig() + assert cfg.tool_overrides == {} + + def test_default_budget_singleton_matches(self): + """DEFAULT_BUDGET should equal a freshly constructed BudgetConfig.""" + assert DEFAULT_BUDGET == BudgetConfig() + + +# --------------------------------------------------------------------------- +# Immutability (frozen=True) +# --------------------------------------------------------------------------- + + +class TestBudgetConfigFrozen: + """Frozen dataclass must reject attribute mutation.""" + + def test_cannot_set_default_result_size(self): + cfg = BudgetConfig() + with pytest.raises(dataclasses.FrozenInstanceError): + cfg.default_result_size = 999 + + def test_cannot_set_turn_budget(self): + cfg = BudgetConfig() + with pytest.raises(dataclasses.FrozenInstanceError): + cfg.turn_budget = 999 + + def test_cannot_set_preview_size(self): + cfg = BudgetConfig() + with pytest.raises(dataclasses.FrozenInstanceError): + cfg.preview_size = 999 + + def test_cannot_set_tool_overrides(self): + cfg = BudgetConfig() + with pytest.raises(dataclasses.FrozenInstanceError): + cfg.tool_overrides = {"foo": 1} + + +# --------------------------------------------------------------------------- +# Custom construction +# --------------------------------------------------------------------------- + + +class TestBudgetConfigCustom: + """BudgetConfig can be created with non-default values.""" + + def test_custom_values(self): + cfg = BudgetConfig( + default_result_size=50_000, + turn_budget=100_000, + preview_size=500, + tool_overrides={"my_tool": 42}, + ) + assert cfg.default_result_size == 50_000 + assert cfg.turn_budget == 100_000 + assert cfg.preview_size == 500 + assert cfg.tool_overrides == {"my_tool": 42} + + +# --------------------------------------------------------------------------- +# resolve_threshold() priority chain +# --------------------------------------------------------------------------- + + +class TestResolveThreshold: + """Priority: pinned > tool_overrides > registry > default.""" + + def test_pinned_wins_over_override(self): + """Even if tool_overrides contains read_file, pinned value wins.""" + cfg = BudgetConfig(tool_overrides={"read_file": 1}) + result = cfg.resolve_threshold("read_file") + assert result == float("inf") + + def test_tool_override_wins_over_default(self): + """tool_overrides should be returned before falling back to registry.""" + cfg = BudgetConfig(tool_overrides={"my_tool": 42}) + result = cfg.resolve_threshold("my_tool") + assert result == 42 + + @patch("tools.registry.registry") + def test_falls_back_to_registry(self, mock_registry): + """When not pinned and not in overrides, delegate to registry.""" + mock_registry.get_max_result_size.return_value = 77_777 + cfg = BudgetConfig() + result = cfg.resolve_threshold("some_tool") + mock_registry.get_max_result_size.assert_called_once_with( + "some_tool", default=DEFAULT_RESULT_SIZE_CHARS + ) + assert result == 77_777 + + @patch("tools.registry.registry") + def test_registry_receives_custom_default(self, mock_registry): + """Custom default_result_size flows through to registry call.""" + mock_registry.get_max_result_size.return_value = 50_000 + cfg = BudgetConfig(default_result_size=50_000) + cfg.resolve_threshold("unknown_tool") + mock_registry.get_max_result_size.assert_called_once_with( + "unknown_tool", default=50_000 + ) + + def test_pinned_read_file_returns_inf(self): + """Canonical case: read_file must always return inf.""" + cfg = BudgetConfig() + assert cfg.resolve_threshold("read_file") == float("inf")