From 3768cb6e3192ef47d8ac0b60b82da5dea82c84b3 Mon Sep 17 00:00:00 2001 From: Roman Nikulenkov Date: Mon, 19 Jan 2026 12:39:19 +0300 Subject: [PATCH] Add include_info hover budget configuration and validation - Introduced `include_info_hover_budget_seconds` to ProjectConfig and SerenaConfig for per-project and global hover request time limits. - Updated YAML templates to include hover budget settings with appropriate documentation. - Enhanced LanguageServerSymbolRetriever to respect hover budget during symbol info requests, allowing for partial results if the budget is exceeded. - Added unit tests to validate hover budget behavior and ensure correct handling of various budget scenarios. Change log level from error to debug for performance logging in LanguageServerSymbolRetriever and SolidLanguageServer Change log level from debug to error for performance logging in LanguageServerSymbolRetriever and SolidLanguageServer Enhance symbol retrieval by adding batch request method and optimize hover info fetching + DEBUG --- src/serena/config/serena_config.py | 38 +++ src/serena/resources/project.template.yml | 5 + .../resources/serena_config.template.yml | 5 + src/serena/symbol.py | 132 +++++++++++ src/serena/tools/symbol_tools.py | 13 +- src/solidlsp/ls.py | 71 ++++-- test/serena/config/test_serena_config.py | 61 +++++ test/serena/test_symbol.py | 223 ++++++++++++++++++ 8 files changed, 531 insertions(+), 17 deletions(-) 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())