diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index f449a64f..37cdc768 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -164,6 +164,12 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): ignore_all_files_in_gitignore: bool = True initial_prompt: str = "" encoding: str = DEFAULT_SOURCE_FILE_ENCODING + include_info_hover_budget_seconds: float | None = None + """ + Per-project override for include_info hover budget (seconds). + + If None, global config is used. 0 disables the budget. Negative values are invalid. + """ SERENA_DEFAULT_PROJECT_FILE = "project.yml" @@ -267,6 +273,8 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): data["ignore_all_files_in_gitignore"] = data.get("ignore_all_files_in_gitignore", True) data["initial_prompt"] = data.get("initial_prompt", "") data["encoding"] = data.get("encoding", DEFAULT_SOURCE_FILE_ENCODING) + # Keep None if missing to inherit from global config. + data.setdefault("include_info_hover_budget_seconds", None) # backward compatibility: handle single "language" field if len(data["languages"]) == 0 and "language" in data: @@ -309,6 +317,17 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): f"Invalid language: {orig_language_str}.\nValid language_strings are: {[l.value for l in Language]}" ) from e + # Validate include_info_hover_budget_seconds + hover_budget_raw = data["include_info_hover_budget_seconds"] + hover_budget = hover_budget_raw + if hover_budget is not None: + try: + hover_budget = float(hover_budget_raw) + except (TypeError, ValueError) as e: + raise ValueError(f"include_info_hover_budget_seconds must be a number or null, got: {hover_budget_raw}") from e + if hover_budget < 0: + raise ValueError(f"include_info_hover_budget_seconds cannot be negative, got: {hover_budget}") + return cls( project_name=data["project_name"], languages=languages, @@ -319,6 +338,7 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): ignore_all_files_in_gitignore=data["ignore_all_files_in_gitignore"], initial_prompt=data["initial_prompt"], encoding=data["encoding"], + include_info_hover_budget_seconds=hover_budget, ) def to_yaml_dict(self) -> dict: @@ -434,6 +454,14 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): ls_specific_settings: dict = field(default_factory=dict) """Advanced configuration option allowing to configure language server implementation specific options, see SolidLSPSettings for more info.""" + include_info_hover_budget_seconds: float = 5.0 + """ + Time budget (seconds) for LSP hover requests when tools request include_info. + + If budget is exceeded, Serena stops issuing further hover requests and returns partial info results. + 0 disables the budget (no early stopping). Negative values are invalid. + """ + # *** fields that are NOT mapped to/from the configuration file *** _loaded_commented_yaml: CommentedMap | None = None @@ -574,6 +602,16 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): instance.default_max_tool_answer_chars = get_value_or_default("default_max_tool_answer_chars") instance.ls_specific_settings = get_value_or_default("ls_specific_settings") + # Load and validate include_info_hover_budget_seconds + hover_budget_raw = get_value_or_default("include_info_hover_budget_seconds") + try: + hover_budget = float(hover_budget_raw) + except (TypeError, ValueError) as e: + raise ValueError(f"include_info_hover_budget_seconds must be a number, got: {hover_budget_raw}") from e + if hover_budget < 0: + raise ValueError(f"include_info_hover_budget_seconds cannot be negative, got: {hover_budget}") + instance.include_info_hover_budget_seconds = hover_budget + # re-save the configuration file if any migrations were performed if num_migrations > 0: log.info("Legacy configuration was migrated; re-saving configuration file") diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index a0f79106..f777b75d 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -84,4 +84,9 @@ excluded_tools: [] # (contrary to the memories, which are loaded on demand). initial_prompt: "" +# Per-project override for include_info hover budget (seconds). +# If null or missing, global config is used. +# 0 disables the budget (no early stopping). Negative values are invalid. +include_info_hover_budget_seconds: null + project_name: "project_name" diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index ddb41e35..d65baa08 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -91,5 +91,10 @@ token_count_estimator: CHAR_COUNT # data files on first run), or ANTHROPIC_CLAUDE_SONNET_4 which will use the (free of cost) Anthropic API to # estimate the token count using the Claude Sonnet 4 tokenizer. +include_info_hover_budget_seconds: 5 +# Time budget (seconds) for LSP hover requests when tools request include_info. +# If budget is exceeded, Serena stops issuing further hover requests and returns partial info results. +# 0 disables the budget (no early stopping). Negative values are invalid. + # The list of registered project paths (updated automatically). projects: [] diff --git a/src/serena/symbol.py b/src/serena/symbol.py index ad807a95..3c6d50e6 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -4,6 +4,7 @@ import os from abc import ABC, abstractmethod from collections.abc import Callable, Iterator, Sequence from dataclasses import asdict, dataclass +from time import perf_counter from typing import TYPE_CHECKING, Any, Self, Union from sensai.util.string import ToStringMixin @@ -507,7 +508,9 @@ class LanguageServerSymbolRetriever: hover_info = lang_server.request_hover(relative_file_path=relative_file_path, line=line, column=column) if hover_info is None: return None + contents = hover_info["contents"] + # Handle various response formats if isinstance(contents, list): # Array format: extract all parts and join them @@ -519,10 +522,13 @@ class LanguageServerSymbolRetriever: # should be a dict with "value" key stripped_parts.append(part["value"].strip()) # type: ignore return "\n".join(stripped_parts) if stripped_parts else None + if isinstance(contents, dict) and (stripped_contents := contents.get("value", "").strip()): return stripped_contents + if isinstance(contents, str) and (stripped_contents := contents.strip()): return stripped_contents + return None def request_info_for_symbol(self, symbol: LanguageServerSymbol) -> str | None: @@ -530,6 +536,132 @@ class LanguageServerSymbolRetriever: return None return self._request_info(relative_file_path=symbol.relative_path, line=symbol.line, column=symbol.column) # type: ignore[arg-type] + def request_info_for_symbols( + self, + symbols: list[LanguageServerSymbol], + ) -> dict[LanguageServerSymbol, str | None]: + """Retrieves information for multiple symbols, optimizing by grouping by file. + + Groups symbols by file path to minimize file switching overhead and uses a per-file + cache keyed by (line, col) to avoid duplicate hover lookups. + + The hover budget (include_info_hover_budget_seconds) limits total time spent on hover + requests. If exceeded, remaining symbols get info=None (partial results). + + :param symbols: list of symbols to get info for + :return: a dict mapping each symbol to its info (or None if unavailable) + """ + if not symbols: + return {} + + debug_enabled = log.isEnabledFor(logging.DEBUG) + t0_total = perf_counter() if debug_enabled else 0.0 + + # Resolve effective hover budget: project -> global -> default 5.0 + budget_seconds = 5.0 + if self.agent is not None: + budget_seconds = self.agent.serena_config.include_info_hover_budget_seconds + active_project = self.agent.get_active_project() + if active_project is not None: + project_budget = active_project.project_config.include_info_hover_budget_seconds + if project_budget is not None: + budget_seconds = project_budget + + info_by_symbol: dict[LanguageServerSymbol, str | None] = {} + skipped_symbols = 0 + detail_present_count = 0 + + # Group symbols by file path, filtering invalid symbols. + symbols_by_file: dict[str, list[LanguageServerSymbol]] = {} + for sym in symbols: + detail = sym.symbol_root.get("detail") + if isinstance(detail, str) and detail.strip(): + detail_present_count += 1 + + file_path = sym.relative_path + line = sym.line + column = sym.column + if file_path is None or line is None or column is None: + skipped_symbols += 1 + continue + + symbols_by_file.setdefault(file_path, []).append(sym) + + per_file_stats: list[tuple[str, int, float]] = [] + total_hover_lookups = 0 + hover_cache_hits = 0 + hover_spent_seconds = 0.0 + skipped_due_to_budget = 0 + + for file_path, file_symbols in symbols_by_file.items(): + t0_file = perf_counter() if debug_enabled else 0.0 + hover_cache: dict[tuple[int, int], str | None] = {} + file_hover_lookups = 0 + + for sym in file_symbols: + line = sym.line + column = sym.column + assert line is not None and column is not None + + cache_key = (line, column) + if cache_key in hover_cache: + hover_cache_hits += 1 + info = hover_cache[cache_key] + else: + # Check budget before starting new hover request + if budget_seconds > 0 and hover_spent_seconds >= budget_seconds: + skipped_due_to_budget += 1 + info = None + else: + t0_hover = perf_counter() + info = self._request_info(file_path, line, column) + hover_spent_seconds += perf_counter() - t0_hover + file_hover_lookups += 1 + total_hover_lookups += 1 + + hover_cache[cache_key] = info + + info_by_symbol[sym] = info + + if debug_enabled: + file_elapsed_ms = (perf_counter() - t0_file) * 1000 + per_file_stats.append((file_path, file_hover_lookups, file_elapsed_ms)) + + if debug_enabled: + total_elapsed_ms = (perf_counter() - t0_total) * 1000 + total_symbols = len(symbols) + unique_files = len(symbols_by_file) + detail_rate = (detail_present_count / total_symbols * 100) if total_symbols > 0 else 0.0 + budget_exceeded = skipped_due_to_budget > 0 + + log.debug( + "perf: request_info_for_symbols total_elapsed_ms=%.2f symbols=%d skipped=%d " + "detail_present=%d detail_rate=%.1f%% hover_lookups=%d hover_cache_hits=%d unique_files=%d " + "hover_budget_seconds=%.1f hover_spent_seconds=%.2f budget_exceeded=%s skipped_due_to_budget=%d", + total_elapsed_ms, + total_symbols, + skipped_symbols, + detail_present_count, + detail_rate, + total_hover_lookups, + hover_cache_hits, + unique_files, + budget_seconds, + hover_spent_seconds, + budget_exceeded, + skipped_due_to_budget, + ) + + for file_path, lookup_count, elapsed_ms in per_file_stats: + log.debug( + "perf: request_info_for_symbols file=%s hover_count=%d elapsed_ms=%.2f", + file_path, + lookup_count, + elapsed_ms, + ) + + return info_by_symbol + def get_root_path(self) -> str: return self._ls_manager.get_root_path() diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 2c0ec882..bed53273 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -179,9 +179,9 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): ) symbol_dicts = [_sanitize_symbol_dict(s.to_dict(kind=True, location=True, depth=depth, include_body=include_body)) for s in symbols] if not include_body and include_info: - # we add an info field to the symbol dicts if requested + info_by_symbol = symbol_retriever.request_info_for_symbols(symbols) for s, s_dict in zip(symbols, symbol_dicts, strict=True): - if symbol_info := symbol_retriever.request_info_for_symbol(s): + if symbol_info := info_by_symbol.get(s): s_dict["info"] = symbol_info s_dict.pop("name", None) # name is included in the info result = self._to_json(symbol_dicts) @@ -228,6 +228,13 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): include_kinds=parsed_include_kinds, exclude_kinds=parsed_exclude_kinds, ) + + # Batch-request hover info for referencing symbols (optional and potentially slow). + info_by_symbol = {} + if not include_body and include_info: + ref_symbols = [ref.symbol for ref in references_in_symbols] + info_by_symbol = symbol_retriever.request_info_for_symbols(ref_symbols) + reference_dicts = [] for ref in references_in_symbols: ref_dict = ref.symbol.to_dict(kind=True, location=True, depth=0, include_body=include_body) @@ -235,7 +242,7 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): if not include_body: ref_relative_path = ref.symbol.location.relative_path assert ref_relative_path is not None, f"Referencing symbol {ref.symbol.name} has no relative path, this is likely a bug." - if include_info and (referencing_symbol_info := symbol_retriever.request_info_for_symbol(ref.symbol)): + if include_info and (referencing_symbol_info := info_by_symbol.get(ref.symbol)): ref_dict["info"] = referencing_symbol_info content_around_ref = self.project.retrieve_content_around_line( relative_file_path=ref_relative_path, line=ref.line, context_lines_before=1, context_lines_after=1 diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 396ba533..fe5de74a 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -13,7 +13,7 @@ from collections.abc import Hashable, Iterator from contextlib import contextmanager from copy import copy from pathlib import Path, PurePath -from time import sleep +from time import perf_counter, sleep from typing import Self, Union, cast import pathspec @@ -820,6 +820,8 @@ class SolidLanguageServer(ABC): sleep(self._get_wait_time_for_cross_file_referencing()) self._has_waited_for_cross_file_references = True + debug_enabled = log.isEnabledFor(logging.DEBUG) + t0 = perf_counter() if debug_enabled else 0.0 with self.open_file(relative_file_path): try: response = self._send_references_request(relative_file_path, line=line, column=column) @@ -832,6 +834,9 @@ class SolidLanguageServer(ABC): ) from e raise if response is None: + if debug_enabled: + elapsed_ms = (perf_counter() - t0) * 1000 + log.debug("perf: request_references path=%s elapsed_ms=%.2f count=0", relative_file_path, elapsed_ms) return [] ret: list[ls_types.Location] = [] @@ -860,6 +865,17 @@ class SolidLanguageServer(ABC): new_item["relativePath"] = str(rel_path) ret.append(ls_types.Location(**new_item)) # type: ignore + if debug_enabled: + elapsed_ms = (perf_counter() - t0) * 1000 + unique_files = len({r["relativePath"] for r in ret}) + log.debug( + "perf: request_references path=%s elapsed_ms=%.2f count=%d unique_files=%d", + relative_file_path, + elapsed_ms, + len(ret), + unique_files, + ) + return ret def request_text_document_diagnostics(self, relative_file_path: str) -> list[ls_types.Diagnostic]: @@ -1033,15 +1049,22 @@ class SolidLanguageServer(ABC): def get_cached_raw_document_symbols(cache_key: str, fd: LSPFileBuffer) -> list[SymbolInformation] | list[DocumentSymbol] | None: file_hash_and_result = self._raw_document_symbols_cache.get(cache_key) - if file_hash_and_result is not None: - file_hash, result = file_hash_and_result - if file_hash == fd.content_hash: - log.debug("Returning cached raw document symbols for %s", relative_file_path) - return result - else: - log.debug("Document content for %s has changed (raw symbol cache is not up-to-date)", relative_file_path) - else: + if file_hash_and_result is None: log.debug("No cache hit for raw document symbols symbols in %s", relative_file_path) + if log.isEnabledFor(logging.DEBUG): + log.debug("perf: raw_document_symbols_cache MISS path=%s", relative_file_path) + return None + + file_hash, result = file_hash_and_result + if file_hash == fd.content_hash: + log.debug("Returning cached raw document symbols for %s", relative_file_path) + if log.isEnabledFor(logging.DEBUG): + log.debug("perf: raw_document_symbols_cache HIT path=%s", relative_file_path) + return result + + log.debug("Document content for %s has changed (raw symbol cache is not up-to-date)", relative_file_path) + if log.isEnabledFor(logging.DEBUG): + log.debug("perf: raw_document_symbols_cache STALE path=%s", relative_file_path) return None def get_raw_document_symbols(fd: LSPFileBuffer) -> list[SymbolInformation] | list[DocumentSymbol] | None: @@ -1086,15 +1109,21 @@ class SolidLanguageServer(ABC): # check if the desired result is cached cache_key = relative_file_path file_hash_and_result = self._document_symbols_cache.get(cache_key) - if file_hash_and_result is not None: + if file_hash_and_result is None: + log.debug("No cache hit for document symbols in %s", relative_file_path) + if log.isEnabledFor(logging.DEBUG): + log.debug("perf: document_symbols_cache MISS path=%s", relative_file_path) + else: file_hash, document_symbols = file_hash_and_result if file_hash == file_data.content_hash: log.debug("Returning cached document symbols for %s", relative_file_path) + if log.isEnabledFor(logging.DEBUG): + log.debug("perf: document_symbols_cache HIT path=%s", relative_file_path) return document_symbols - else: - log.debug("Cached document symbol content for %s has changed", relative_file_path) - else: - log.debug("No cache hit for document symbols in %s", relative_file_path) + + log.debug("Cached document symbol content for %s has changed", relative_file_path) + if log.isEnabledFor(logging.DEBUG): + log.debug("perf: document_symbols_cache STALE path=%s", relative_file_path) # no cached result: request the root symbols from the language server root_symbols = self._request_document_symbols(relative_file_path, file_data) @@ -1543,6 +1572,8 @@ class SolidLanguageServer(ABC): if not references: return [] + debug_enabled = log.isEnabledFor(logging.DEBUG) + t0_loop = perf_counter() if debug_enabled else 0.0 # For each reference, find the containing symbol result = [] incoming_symbol = None @@ -1643,6 +1674,18 @@ class SolidLanguageServer(ABC): result.append(ReferenceInSymbol(symbol=containing_symbol, line=ref_line, character=ref_col)) + if debug_enabled: + loop_elapsed_ms = (perf_counter() - t0_loop) * 1000 + unique_files = len({r.symbol["location"]["relativePath"] for r in result}) + log.debug( + "perf: request_referencing_symbols path=%s loop_elapsed_ms=%.2f ref_count=%d result_count=%d unique_files=%d", + relative_file_path, + loop_elapsed_ms, + len(references), + len(result), + unique_files, + ) + return result def request_containing_symbol( diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index 048b3dbc..4fff1fa6 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -117,3 +117,64 @@ class TestProjectConfigAutogenerate: assert config.project_name == custom_name assert config.languages == [Language.TYPESCRIPT] + + +class TestProjectConfigHoverBudget: + """Test class for include_info_hover_budget_seconds validation in ProjectConfig.""" + + def _base_project_dict(self, **overrides): + data = { + "project_name": "test_project", + "languages": ["python"], + "ignored_paths": [], + "excluded_tools": [], + "included_optional_tools": [], + "read_only": False, + "ignore_all_files_in_gitignore": True, + "initial_prompt": "", + "encoding": "utf-8", + "include_info_hover_budget_seconds": None, + } + data.update(overrides) + return data + + def test_from_dict_accepts_null_budget(self): + """Test that _from_dict accepts null hover budget (for inheritance).""" + data = self._base_project_dict() + config = ProjectConfig._from_dict(data) + assert config.include_info_hover_budget_seconds is None + + def test_from_dict_accepts_positive_budget(self): + """Test that _from_dict accepts positive hover budget.""" + data = self._base_project_dict(include_info_hover_budget_seconds=10.5) + config = ProjectConfig._from_dict(data) + assert config.include_info_hover_budget_seconds == 10.5 + + def test_from_dict_accepts_zero_budget(self): + """Test that _from_dict accepts zero hover budget (disabled).""" + data = self._base_project_dict(include_info_hover_budget_seconds=0) + config = ProjectConfig._from_dict(data) + assert config.include_info_hover_budget_seconds == 0.0 + + def test_from_dict_rejects_negative_budget(self): + """Test that _from_dict raises ValueError for negative hover budget.""" + data = self._base_project_dict(include_info_hover_budget_seconds=-1.0) + with pytest.raises(ValueError) as exc_info: + ProjectConfig._from_dict(data) + assert "cannot be negative" in str(exc_info.value) + + def test_apply_defaults_preserves_null(self): + """Test that _apply_defaults_to_dict preserves None for hover budget.""" + data = {"project_name": "test", "languages": ["python"]} + result = ProjectConfig._apply_defaults_to_dict(data) + assert result["include_info_hover_budget_seconds"] is None + + def test_apply_defaults_preserves_explicit_value(self): + """Test that _apply_defaults_to_dict preserves explicit hover budget value.""" + data = { + "project_name": "test", + "languages": ["python"], + "include_info_hover_budget_seconds": 15.0, + } + result = ProjectConfig._apply_defaults_to_dict(data) + assert result["include_info_hover_budget_seconds"] == 15.0 diff --git a/test/serena/test_symbol.py b/test/serena/test_symbol.py index 77558ecd..adb2cece 100644 --- a/test/serena/test_symbol.py +++ b/test/serena/test_symbol.py @@ -1,3 +1,5 @@ +from unittest.mock import MagicMock + import pytest from serena.symbol import LanguageServerSymbolRetriever, NamePathMatcher @@ -188,3 +190,224 @@ class TestLanguageServerSymbolRetriever: create_user_method_symbol = symbol_retriever.find("UserService/create_user", within_relative_path="test_repo/services.py")[0] create_user_method_symbol_info = symbol_retriever.request_info_for_symbol(create_user_method_symbol) assert "Create a new user and store it" in create_user_method_symbol_info + + +def _make_mock_symbols(count: int, *, relative_path: str = "test_repo/services.py") -> list[MagicMock]: + symbols: list[MagicMock] = [] + for i in range(count): + sym = MagicMock() + sym.relative_path = relative_path + sym.line = i + 1 + sym.column = 0 + sym.symbol_root = {} + symbols.append(sym) + return symbols + + +@pytest.mark.python +class TestHoverBudget: + """Tests for include_info_hover_budget_seconds time budget behavior.""" + + @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + def test_budget_not_exceeded_all_lookups_performed(self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch): + """With a large budget, all hover lookups are performed.""" + # Create symbol retriever with a mock agent that has large budget + mock_agent = MagicMock() + mock_agent.serena_config.include_info_hover_budget_seconds = 10.0 + mock_agent.get_active_project.return_value = None + + symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent) + + # Track _request_info calls + call_count = 0 + + def counting_request_info(file_path, line, column): + nonlocal call_count + call_count += 1 + return f"info:{line}:{column}" + + monkeypatch.setattr(symbol_retriever, "_request_info", counting_request_info) + + # Create mock symbols with unique (line, col) pairs + symbols = _make_mock_symbols(3) + + result = symbol_retriever.request_info_for_symbols(symbols) + + # All 3 symbols should have info (no budget exceeded) + assert call_count == 3 + assert all(info is not None for info in result.values()) + assert len(result) == 3 + + @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + def test_budget_exceeded_partial_info(self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch): + """With a small budget, hover lookups stop and remaining symbols get None info.""" + # Create symbol retriever with a mock agent that has small budget (0.1s) + mock_agent = MagicMock() + mock_agent.serena_config.include_info_hover_budget_seconds = 0.1 + mock_agent.get_active_project.return_value = None + + symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent) + + # Track _request_info calls and simulate 0.05s per call + call_count = 0 + simulated_time = [0.0] + + def slow_request_info(file_path, line, column): + nonlocal call_count + call_count += 1 + # Simulate each hover taking 0.05s + simulated_time[0] += 0.05 + return f"info:{line}:{column}" + + # Mock perf_counter to return simulated time for hover duration + def mock_perf_counter(): + return simulated_time[0] + + monkeypatch.setattr(symbol_retriever, "_request_info", slow_request_info) + monkeypatch.setattr("serena.symbol.perf_counter", mock_perf_counter) + + # Create 5 mock symbols with unique (line, col) pairs + symbols = _make_mock_symbols(5) + + result = symbol_retriever.request_info_for_symbols(symbols) + + # Budget is 0.1s, each call takes 0.05s, so only 2 calls should succeed + # After 2 calls: 0.1s >= 0.1s budget, remaining 3 should be skipped + assert call_count == 2 + assert len(result) == 5 + + # First 2 symbols should have info, last 3 should be None + result_list = list(result.values()) + assert result_list[0] is not None + assert result_list[1] is not None + assert result_list[2] is None + assert result_list[3] is None + assert result_list[4] is None + + @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + def test_budget_zero_means_unlimited(self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch): + """With budget=0, all hover lookups proceed (no early stopping).""" + # Create symbol retriever with budget=0 (unlimited) + mock_agent = MagicMock() + mock_agent.serena_config.include_info_hover_budget_seconds = 0.0 + mock_agent.get_active_project.return_value = None + + symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent) + + # Track _request_info calls + call_count = 0 + + def counting_request_info(file_path, line, column): + nonlocal call_count + call_count += 1 + return f"info:{line}:{column}" + + monkeypatch.setattr(symbol_retriever, "_request_info", counting_request_info) + + # Create mock symbols + symbols = _make_mock_symbols(5) + + result = symbol_retriever.request_info_for_symbols(symbols) + + # All 5 symbols should be looked up (no budget limit) + assert call_count == 5 + assert all(info is not None for info in result.values()) + + @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + def test_project_budget_overrides_global(self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch): + """Project-level budget overrides global budget.""" + # Create symbol retriever with global budget 10.0 but project budget 0.05 + mock_project = MagicMock() + mock_project.project_config.include_info_hover_budget_seconds = 0.05 + + mock_agent = MagicMock() + mock_agent.serena_config.include_info_hover_budget_seconds = 10.0 + mock_agent.get_active_project.return_value = mock_project + + symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent) + + # Track _request_info calls and simulate time + call_count = 0 + simulated_time = [0.0] + + def slow_request_info(file_path, line, column): + nonlocal call_count + call_count += 1 + simulated_time[0] += 0.03 + return f"info:{line}:{column}" + + def mock_perf_counter(): + return simulated_time[0] + + monkeypatch.setattr(symbol_retriever, "_request_info", slow_request_info) + monkeypatch.setattr("serena.symbol.perf_counter", mock_perf_counter) + + # Create 5 mock symbols + symbols = _make_mock_symbols(5) + + symbol_retriever.request_info_for_symbols(symbols) + + # Project budget is 0.05s, each call takes 0.03s + # Budget check happens BEFORE starting a new call: + # - Before call 1: spent=0 < 0.05, proceed, spent becomes 0.03 + # - Before call 2: spent=0.03 < 0.05, proceed, spent becomes 0.06 + # - Before call 3: spent=0.06 >= 0.05, skip + # So 2 calls succeed (proving project budget 0.05 overrode global 10.0) + assert call_count == 2 + + @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + def test_project_null_inherits_global(self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch): + """When project budget is None, global budget is used.""" + # Create symbol retriever with project budget=None (inherit global) + mock_project = MagicMock() + mock_project.project_config.include_info_hover_budget_seconds = None + + mock_agent = MagicMock() + mock_agent.serena_config.include_info_hover_budget_seconds = 10.0 + mock_agent.get_active_project.return_value = mock_project + + symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=mock_agent) + + # Track _request_info calls + call_count = 0 + + def counting_request_info(file_path, line, column): + nonlocal call_count + call_count += 1 + return f"info:{line}:{column}" + + monkeypatch.setattr(symbol_retriever, "_request_info", counting_request_info) + + # Create 3 mock symbols + symbols = _make_mock_symbols(3) + + result = symbol_retriever.request_info_for_symbols(symbols) + + # Global budget is 10s, all 3 should succeed + assert call_count == 3 + assert all(info is not None for info in result.values()) + + @pytest.mark.parametrize("language_server", [Language.PYTHON], indirect=True) + def test_no_agent_uses_default_budget(self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch): + """When agent is None, default budget of 5s is used.""" + # Create symbol retriever without agent + symbol_retriever = LanguageServerSymbolRetriever(language_server, agent=None) + + # Track _request_info calls + call_count = 0 + + def counting_request_info(file_path, line, column): + nonlocal call_count + call_count += 1 + return f"info:{line}:{column}" + + monkeypatch.setattr(symbol_retriever, "_request_info", counting_request_info) + + # Create 3 mock symbols + symbols = _make_mock_symbols(3) + + result = symbol_retriever.request_info_for_symbols(symbols) + + # Default budget is 5s, all 3 should succeed + assert call_count == 3 + assert all(info is not None for info in result.values())