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
This commit is contained in:
Roman Nikulenkov committed 2026-01-19 12:45:25 +03:00
1 parent 3748eb8b7c
commit 3768cb6e31
8 files changed
+531 -17

No files matched your search

+38
View File
@@ -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")
@@ -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"
@@ -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: []
+132
View File
@@ -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()
+10 -3
View File
@@ -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
+57 -14
View File
@@ -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(
+61
View File
@@ -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
+223
View File
@@ -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())