From d6993fe8b6c851aa728fca9ede2cea6137b55b1a Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 21 Jul 2026 12:04:43 +0200 Subject: [PATCH 1/5] Fix docstring change being applied to the wrong 'find references' tool --- src/serena/tools/jetbrains_tools.py | 3 ++- src/serena/tools/symbol_tools.py | 2 -- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index a46736ba..543811e9 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -259,7 +259,6 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark symbol_dict_grouper = JetBrainsSymbolDictGrouper(["relative_path", "type"], ["type"], collapse_singleton=True) - # TODO: (maybe) - add content snippets showing the references like in LS based version? def apply( self, name_path: str, @@ -273,6 +272,8 @@ class JetBrainsFindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead, ToolMark :param name_path: name path of the symbol for which to find references :param relative_path: the relative path to the file containing the symbol (must be a file, not a directory) + Note: for external dependencies, this must be an identifier starting with ` Date: Tue, 21 Jul 2026 12:33:23 +0200 Subject: [PATCH 2/5] SymbolDictGrouper: Allow grouper to be temporarily disabled via disabled_context --- src/serena/symbol.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index 8eb00595..6a17813a 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -5,6 +5,7 @@ import os from abc import ABC, abstractmethod from collections import OrderedDict from collections.abc import Callable, Iterable, Iterator, Sequence +from contextlib import contextmanager from dataclasses import asdict, dataclass from time import perf_counter from typing import Any, Generic, Literal, NotRequired, Self, TypedDict, TypeVar @@ -1261,6 +1262,7 @@ class SymbolDictGrouper(Generic[TSymbolDict], ABC): self._group_keys = group_keys self._group_children_keys = group_children_keys self._collapse_singleton = collapse_singleton + self._is_enabled = True def _group_by(self, l: list[dict], keys: list[str], children_keys: list[str], is_children: bool) -> dict[str, Any] | list[Any]: """ @@ -1321,8 +1323,24 @@ class SymbolDictGrouper(Generic[TSymbolDict], ABC): """ # avoid side effects by working on a deep-copy symbols_copy = copy.deepcopy(symbols) + + if not self._is_enabled: + return symbols_copy + return self._group_by(symbols_copy, self._group_keys, self._group_children_keys, is_children=False) + @contextmanager + def disabled_context(self) -> Iterator[None]: + """ + A context manager that temporarily disables grouping. + """ + original_state = self._is_enabled + self._is_enabled = False + try: + yield + finally: + self._is_enabled = original_state + class LanguageServerSymbolDictGrouper(SymbolDictGrouper[LanguageServerSymbol.OutputDict]): def __init__( From ba7cdffc025fc030ba7582e8fb1ef790e412005a Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 21 Jul 2026 12:33:41 +0200 Subject: [PATCH 3/5] FindReferencingSymbolsTool: Limit file system sync to the necessary case (i.e. no full symbol search), adding tests that specifically test the tool path --- src/serena/tools/symbol_tools.py | 4 +- test/conftest.py | 17 ++- test/serena/test_ls_file_sync.py | 172 ++++++++++++++++++++----------- 3 files changed, 131 insertions(+), 62 deletions(-) diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 308ab0cb..69d24d81 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -272,7 +272,9 @@ class FindReferencingSymbolsTool(Tool, ToolMarkerSymbolicRead): :param max_answer_chars: max result length; -1 for default :return: a list of JSON objects with the symbols referencing the requested symbol """ - self.project.ls_sync_file_system_changes() + # file system sync needed for case where symbol finder does not perform a global search, updating everything + if relative_path: + self.project.ls_sync_file_system_changes() include_body = False # It is probably never a good idea to include the body of the referencing symbols parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None diff --git a/test/conftest.py b/test/conftest.py index 6b25dec3..626cdb78 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -13,6 +13,7 @@ import pytest from _pytest.mark import Mark, MarkDecorator from sensai.util.logging import configure +from serena.agent import SerenaAgent from serena.config.serena_config import SerenaConfig, SerenaPaths from serena.constants import SERENA_MANAGED_DIR_NAME from serena.project import Project @@ -129,7 +130,7 @@ def start_default_ls_context(language: Language) -> Iterator[SolidLanguageServer def create_default_serena_config(): - return SerenaConfig(gui_log_window=False, web_dashboard=False) + return SerenaConfig().with_headless_mode_overrides() def _create_default_project(language: Language, repo_root_override: str | None = None) -> Project: @@ -238,6 +239,20 @@ def project_with_ls_context(language: Language, repo_root_override: str | None = yield project +@contextmanager +def agent_for_project_context(language: Language, repo_root_override: str | None = None) -> Iterator[SerenaAgent]: + project_root = str(get_repo_path(language)) if repo_root_override is None else repo_root_override + agent = SerenaAgent(project=project_root, serena_config=create_default_serena_config()) + + # wait for agent to be ready + agent.execute_task(lambda: None) + + try: + yield agent + finally: + agent.on_shutdown() + + @pytest.fixture(scope="module") def project_with_ls(request: LanguageParamRequest) -> Iterator[Project]: if not hasattr(request, "param"): diff --git a/test/serena/test_ls_file_sync.py b/test/serena/test_ls_file_sync.py index babe4bab..feeacc8c 100644 --- a/test/serena/test_ls_file_sync.py +++ b/test/serena/test_ls_file_sync.py @@ -5,84 +5,136 @@ Exercises all three ``FileChangeType`` branches against a real pyright backend: Created (new caller file), Changed (append a second caller), Deleted (remove the caller file). """ +import json import os import shutil import pytest -from solidlsp import SolidLanguageServer +from serena.agent import SerenaAgent +from serena.project import Project +from serena.tools import FindReferencingSymbolsTool from solidlsp.ls_config import Language -from test.conftest import get_repo_path, project_with_ls_context +from test.conftest import agent_for_project_context, get_repo_path pytestmark = pytest.mark.python -# A method that already has a call site in the fixture, so its baseline reference set is non-empty. -_TARGET_FILE = os.path.join("test_repo", "services.py") -_TARGET_SYMBOL = "create_user" -_CALLER_REL_PATH = os.path.join("test_repo", "external_caller.py") -_CALLER_ONE = "external_caller_one" -_CALLER_TWO = "external_caller_two" +class FileSystemSyncTestCase: + """ + Tests the language server file system synchronisation behaviour, using a reference search + as an example of a feature that generally depends on the synchronisation. + + It supports two modes: + * using Serena's tools (where an explicit sync is not needed because it builds on find_symbol) + * a low-level mode that uses LS methods directly and therefore needs the sync + """ + + # A method that already has a call site in the fixture, so its baseline reference set is non-empty. + _TARGET_FILE = os.path.join("test_repo", "services.py") + _TARGET_SYMBOL = "create_user" + + _CALLER_REL_PATH = os.path.join("test_repo", "external_caller.py") + _CALLER_ONE = "external_caller_one" + _CALLER_TWO = "external_caller_two" + + def __init__(self, use_serena_tool: bool, tool_use_relative_path: bool = True): + """ + :param use_serena_tool: whether to use the Serena tool, which does not need an explicit FS sync + """ + self._use_serena_tool = use_serena_tool + self._tool_use_relative_path = tool_use_relative_path + + def _caller_source(self, *function_names: str) -> str: + body = "\n\n".join( + f"def {name}() -> User:\n return UserService().{self._TARGET_SYMBOL}('x', 'y', 'z@example.com')" for name in function_names + ) + return "from test_repo.models import User\nfrom test_repo.services import UserService\n\n\n" + body + "\n" + + def _referencing_symbol_names(self, agent: SerenaAgent) -> list[str]: + """Names of the symbols that reference ``_TARGET_SYMBOL``, per the warm language server.""" + if self._use_serena_tool: + tool = agent.get_tool(FindReferencingSymbolsTool) + with tool.symbol_dict_grouper.disabled_context(): + relative_path = self._TARGET_FILE if self._tool_use_relative_path else "" + response = tool.apply(name_path=self._TARGET_SYMBOL, relative_path=relative_path) + ref_symbols = json.loads(response) + symbol_names = [ref["name_path"].split("/")[-1] for ref in ref_symbols] + return symbol_names + else: + ls = next(iter(agent.get_active_project_or_raise().language_server_manager.iter_language_servers())) + document_symbols = ls.request_document_symbols(self._TARGET_FILE).get_all_symbols_and_roots() + target = next((s for s in document_symbols[0] if s.get("name") == self._TARGET_SYMBOL), None) + assert target is not None and "selectionRange" in target, f"{self._TARGET_SYMBOL} not found in {self._TARGET_FILE}" + start = target["selectionRange"]["start"] + references = ls.request_referencing_symbols(self._TARGET_FILE, start["line"], start["character"]) + return [ref.symbol["name"] for ref in references] + + def run(self, tmp_path): + # Work on an isolated copy so we can freely create/edit/delete files under the project root. + repo_root = tmp_path / "repo" + shutil.copytree(get_repo_path(Language.PYTHON), repo_root) + caller_abs = repo_root / self._CALLER_REL_PATH + + with agent_for_project_context(Language.PYTHON, str(repo_root)) as agent: + project = agent.get_active_project_or_raise() + + # Warm the reference index, then establish the freshness baseline (first poll never notifies). + self._referencing_symbol_names(agent) + self._sync_fs(project, 0, "") + + # --- Created ------------------------------------------------------------------------------- + caller_abs.write_text(self._caller_source(self._CALLER_ONE), encoding="utf-8") + + if not self._use_serena_tool: + # Adversarial: without the poll, the warm server has never seen the new file -> stale. + assert self._CALLER_ONE not in self._referencing_symbol_names(agent), "expected the new caller to be invisible before poll" + + self._sync_fs(project, 1, "Created") + assert self._CALLER_ONE in self._referencing_symbol_names(agent), "new caller must be visible after poll" + + # --- Changed ------------------------------------------------------------------------------- + caller_abs.write_text(self._caller_source(self._CALLER_ONE, self._CALLER_TWO), encoding="utf-8") + self._sync_fs(project, 1, "Changed") + names_after_change = self._referencing_symbol_names(agent) + assert self._CALLER_TWO in names_after_change, "appended caller must be visible after poll" + assert self._CALLER_ONE in names_after_change + + # --- Deleted ------------------------------------------------------------------------------- + caller_abs.unlink() + self._sync_fs(project, 1, "Deleted") + names_after_delete = self._referencing_symbol_names(agent) + assert self._CALLER_ONE not in names_after_delete, "deleted caller must disappear after poll" + assert self._CALLER_TWO not in names_after_delete + + def _sync_fs(self, project: Project, num_expected_events: int, event_type: str): + # apply sync only if Serena's tool is not being used + if not self._use_serena_tool: + assert project.ls_sync_file_system_changes() == num_expected_events, f"expected {num_expected_events} {event_type} event(s)" -def _caller_source(*function_names: str) -> str: - body = "\n\n".join( - f"def {name}() -> User:\n return UserService().{_TARGET_SYMBOL}('x', 'y', 'z@example.com')" for name in function_names - ) - return "from test_repo.models import User\nfrom test_repo.services import UserService\n\n\n" + body + "\n" +def test_find_referencing_symbol_tool_handles_sync_with_relative_path(tmp_path): + """ + Tests that the FindReferencingSymbolsTool handles file system synchronisation correctly when passed a relative path. + """ + FileSystemSyncTestCase(use_serena_tool=True, tool_use_relative_path=True).run(tmp_path) -def _referencing_symbol_names(ls: SolidLanguageServer) -> list[str]: - """Names of the symbols that reference ``_TARGET_SYMBOL``, per the warm language server.""" - document_symbols = ls.request_document_symbols(_TARGET_FILE).get_all_symbols_and_roots() - target = next((s for s in document_symbols[0] if s.get("name") == _TARGET_SYMBOL), None) - assert target is not None and "selectionRange" in target, f"{_TARGET_SYMBOL} not found in {_TARGET_FILE}" - start = target["selectionRange"]["start"] - references = ls.request_referencing_symbols(_TARGET_FILE, start["line"], start["character"]) - return [ref.symbol["name"] for ref in references] +def test_find_referencing_symbol_tool_handles_sync_without_relative_path(tmp_path): + """ + Tests that the FindReferencingSymbolsTool handles file system synchronisation correctly when no relative path is provided. + """ + FileSystemSyncTestCase(use_serena_tool=True, tool_use_relative_path=False).run(tmp_path) -def test_external_changes_become_visible_after_sync(tmp_path): - # Work on an isolated copy so we can freely create/edit/delete files under the project root. - repo_root = tmp_path / "repo" - shutil.copytree(get_repo_path(Language.PYTHON), repo_root) - caller_abs = repo_root / _CALLER_REL_PATH - - with project_with_ls_context(Language.PYTHON, str(repo_root)) as project: - language_server = next(iter(project.language_server_manager.iter_language_servers())) - - def sync_fs() -> int: - return project.language_server_manager.sync_file_system_changes() - - # Warm the reference index, then establish the freshness baseline (first poll never notifies). - _referencing_symbol_names(language_server) - assert sync_fs() == 0, "first poll must only establish the baseline" - - # --- Created ------------------------------------------------------------------------------- - caller_abs.write_text(_caller_source(_CALLER_ONE), encoding="utf-8") - - # Adversarial: without the poll, the warm server has never seen the new file -> stale. - assert _CALLER_ONE not in _referencing_symbol_names(language_server), "expected the new caller to be invisible before poll" - - assert sync_fs() == 1, "one Created event expected" - assert _CALLER_ONE in _referencing_symbol_names(language_server), "new caller must be visible after poll" - - # --- Changed ------------------------------------------------------------------------------- - caller_abs.write_text(_caller_source(_CALLER_ONE, _CALLER_TWO), encoding="utf-8") - assert sync_fs() == 1, "one Changed event expected" - names_after_change = _referencing_symbol_names(language_server) - assert _CALLER_TWO in names_after_change, "appended caller must be visible after poll" - assert _CALLER_ONE in names_after_change - - # --- Deleted ------------------------------------------------------------------------------- - caller_abs.unlink() - assert sync_fs() == 1, "one Deleted event expected" - names_after_delete = _referencing_symbol_names(language_server) - assert _CALLER_ONE not in names_after_delete, "deleted caller must disappear after poll" - assert _CALLER_TWO not in names_after_delete +def test_ls_low_level_find_references_with_explicit_sync(tmp_path): + """ + Tests that requesting references directly from the LS works if synchronisation is explicitly requested after external file changes. + """ + FileSystemSyncTestCase(use_serena_tool=False).run(tmp_path) -def test_first_poll_is_baseline_only(tmp_path): +def test_sync_first_poll_is_baseline_only(tmp_path): """A newly-loaded project must not report its entire pre-existing file set as changed.""" repo_root = tmp_path / "repo" shutil.copytree(get_repo_path(Language.PYTHON), repo_root) From 574110fc286ddcaac8d15c6a256be59e853d76ce Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 21 Jul 2026 12:38:38 +0200 Subject: [PATCH 4/5] Initialise file change notifier (establishing baseline) upon construction --- src/serena/ls_manager.py | 7 ++++++- test/serena/test_ls_file_sync.py | 10 ---------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/serena/ls_manager.py b/src/serena/ls_manager.py index 27218ded..45405962 100644 --- a/src/serena/ls_manager.py +++ b/src/serena/ls_manager.py @@ -280,12 +280,17 @@ class LanguageServerFileChangeNotifier: Detects changes to source files on disk and notifies language servers of those changes. """ - def __init__(self, project: "Project", language_server_manager: LanguageServerManager) -> None: + def __init__(self, project: "Project", language_server_manager: LanguageServerManager, initial_poll: bool = True) -> None: self._project = project self._language_server_manager = language_server_manager self._freshness_last_seen_mtimes: dict[str, float] | None = None self._freshness_lock = threading.Lock() + if initial_poll: + # Establish the baseline for the first poll; no notifications are sent on the first call. + with LogTime("Initialising file change notifier (polling for baseline)"): + self.poll_and_notify() + def poll_and_notify(self) -> int: """ Detects source files that were changed, created or deleted on disk since the last call diff --git a/test/serena/test_ls_file_sync.py b/test/serena/test_ls_file_sync.py index feeacc8c..874dbcc7 100644 --- a/test/serena/test_ls_file_sync.py +++ b/test/serena/test_ls_file_sync.py @@ -132,13 +132,3 @@ def test_ls_low_level_find_references_with_explicit_sync(tmp_path): Tests that requesting references directly from the LS works if synchronisation is explicitly requested after external file changes. """ FileSystemSyncTestCase(use_serena_tool=False).run(tmp_path) - - -def test_sync_first_poll_is_baseline_only(tmp_path): - """A newly-loaded project must not report its entire pre-existing file set as changed.""" - repo_root = tmp_path / "repo" - shutil.copytree(get_repo_path(Language.PYTHON), repo_root) - with project_with_ls_context(Language.PYTHON, str(repo_root)) as project: - assert project.ls_sync_file_system_changes() == 0 - # No external edits between the two calls -> still nothing to report. - assert project.ls_sync_file_system_changes() == 0 From 4cf2bf04e48694cfedce0393aa764cbaef75cc10 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 21 Jul 2026 12:42:38 +0200 Subject: [PATCH 5/5] Document tool file sync requirements, adding missing sync call in RenameSymbolTool --- src/serena/tools/symbol_tools.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 69d24d81..c4ed5bfc 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -54,6 +54,8 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead): Don't adjust unless there is really no other way to get the content required for the task. :return: a JSON object containing symbols grouped by kind in a compact format. """ + # Note: file system sync not required (relevant file is opened in the language server explicitly) + if depth == -1: if relative_path.endswith((".java", ".kt")): depth = 1 @@ -189,6 +191,8 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): :param max_answer_chars: max result length; -1 for default :return: symbols (with locations) matching the name. """ + # Note: file system sync not required; the symbol finder opens all relevant source files explicitly in the case of changes + if include_body: depth = 0 # ignore user-specified depth if include_body is True assert max_matches != 0, "max_matches must be > 0 or equal to -1." @@ -685,6 +689,7 @@ class RenameSymbolTool(Tool, ToolMarkerSymbolicEdit): :param new_name: the new name for the symbol :return: result summary indicating success or failure """ + self.project.ls_sync_file_system_changes() code_editor = self.create_ls_code_editor() status_message = code_editor.rename_symbol(name_path, relative_path=relative_path, new_name=new_name) return status_message