From 5a06cec7d08aeb7f669e20ee76bfbe31fbfde4cd Mon Sep 17 00:00:00 2001 From: yhheolab-oss Date: Sat, 8 Aug 2026 23:15:30 +0900 Subject: [PATCH 1/9] Merge pull request #1820 from yhheolab-oss/fix/project-server-active-project-race fix(project_server): serialize access to the process-wide active project --- CHANGELOG.md | 2 +- src/serena/project_server.py | 44 +++++-- test/serena/test_project_server.py | 199 +++++++++++++++++++++++++++++ 3 files changed, 235 insertions(+), 10 deletions(-) create mode 100644 test/serena/test_project_server.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 256dd973..36d20301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. * General: - - Fix: the README, the Language Support docs page and the project template omitted several already-supported language servers + - Fix: Race conditions in ProjectServer when used by multiple clients in parallel - Fix: a tool call exceeding the timeout blocked the task executor indefinitely; the executor now recovers without user-induced cancellation - Add Grok Build support (context `grok`, setup CLI, hooks) diff --git a/src/serena/project_server.py b/src/serena/project_server.py index 12c5e907..c2cbcd65 100644 --- a/src/serena/project_server.py +++ b/src/serena/project_server.py @@ -1,5 +1,6 @@ import json import logging +import threading from typing import TYPE_CHECKING import requests as requests_lib @@ -61,6 +62,9 @@ class ProjectServer: self._agent = SerenaAgent(serena_config=serena_config) self._loaded_projects_by_root: dict[str, "Project"] = {} + self._project_load_locks_by_root: dict[str, threading.Lock] = {} + self._active_project_lock = threading.Lock() + self._loaded_projects_lock = threading.Lock() self._port = port self._host = host @@ -91,19 +95,41 @@ class ProjectServer: key = str(registered_project.project_root) - if key in self._loaded_projects_by_root: - return self._loaded_projects_by_root[key] + # find or publish the per-project load lock while holding the shared dictionaries + with self._loaded_projects_lock: + project = self._loaded_projects_by_root.get(key) + if project is not None: + return project + project_load_lock = self._project_load_locks_by_root.get(key) + if project_load_lock is None: + project_load_lock = threading.Lock() + self._project_load_locks_by_root[key] = project_load_lock - with LogTime(f"Loading project '{project_root_or_name}'"): - project = registered_project.get_project_instance(serena_config) - project.create_language_server_manager() - self._loaded_projects_by_root[key] = project - return project + # initialize only this project; another project's cached lookup or cold load can proceed + with project_load_lock: + with self._loaded_projects_lock: + project = self._loaded_projects_by_root.get(key) + if project is not None: + return project + + with LogTime(f"Loading project '{project_root_or_name}'"): + project = registered_project.get_project_instance(serena_config) + project.create_language_server_manager() + + with self._loaded_projects_lock: + self._loaded_projects_by_root[key] = project + return project def _query_project(self, req: QueryProjectRequest) -> str: - """Handle a /query_project request by invoking the agent on the specified project and tool.""" + """Handle a /query_project request by invoking the agent on the specified project and tool. + + The active project is process-wide state, whereas ``apply_ex`` runs the tool on the + agent's task executor thread. Without the lock, a second request entering + ``active_project_context`` while the first request's tool is still executing would + redirect that tool to the wrong project (and restore the wrong project afterwards). + """ project = self._get_project(req.project_name) - with self._agent.active_project_context(project): + with self._active_project_lock, self._agent.active_project_context(project): tool = self._agent.get_tool_by_name(req.tool_name) if not tool.is_readonly(): raise ValueError(f"Tool '{req.tool_name}' is not read-only and cannot be executed via the query_project route") diff --git a/test/serena/test_project_server.py b/test/serena/test_project_server.py new file mode 100644 index 00000000..1288e177 --- /dev/null +++ b/test/serena/test_project_server.py @@ -0,0 +1,199 @@ +import threading +from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import TimeoutError as FutureTimeoutError +from contextlib import contextmanager +from pathlib import Path +from typing import Any, cast +from unittest.mock import MagicMock + +import pytest + +from serena.project_server import ProjectServer, QueryProjectRequest + + +@pytest.fixture +def project_server() -> ProjectServer: + server = ProjectServer.__new__(ProjectServer) + server._agent = MagicMock() + server._loaded_projects_by_root = {} + server._project_load_locks_by_root = {} + server._active_project_lock = threading.Lock() + server._loaded_projects_lock = threading.Lock() + return server + + +def test_cached_project_lookup_is_not_blocked_by_unrelated_cold_load(project_server: ProjectServer) -> None: + cached_root = Path("/cached") + cold_root = Path("/cold") + cached_project = MagicMock() + cold_project = MagicMock() + cold_load_started = threading.Event() + allow_cold_load_to_finish = threading.Event() + + cached_registration = MagicMock(project_root=cached_root) + cold_registration = MagicMock(project_root=cold_root) + + def block_cold_load() -> None: + cold_load_started.set() + assert allow_cold_load_to_finish.wait(timeout=5) + + cold_registration.get_project_instance.return_value = cold_project + cold_project.create_language_server_manager.side_effect = block_cold_load + project_server._loaded_projects_by_root[str(cached_root)] = cached_project + serena_config = cast(Any, project_server._agent.serena_config) + serena_config.get_registered_project.side_effect = { + "cached": cached_registration, + "cold": cold_registration, + }.get + + with ThreadPoolExecutor(max_workers=2) as executor: + cold_future = executor.submit(project_server._get_project, "cold") + assert cold_load_started.wait(timeout=1) + + cached_future = executor.submit(project_server._get_project, "cached") + try: + assert cached_future.result(timeout=1) is cached_project + finally: + allow_cold_load_to_finish.set() + + assert cold_future.result(timeout=1) is cold_project + + +def test_cold_loads_for_different_projects_can_run_concurrently(project_server: ProjectServer) -> None: + first_root = Path("/first") + second_root = Path("/second") + first_project = MagicMock() + second_project = MagicMock() + first_load_started = threading.Event() + allow_first_load_to_finish = threading.Event() + + first_registration = MagicMock(project_root=first_root) + second_registration = MagicMock(project_root=second_root) + + def block_first_load() -> None: + first_load_started.set() + assert allow_first_load_to_finish.wait(timeout=5) + + first_registration.get_project_instance.return_value = first_project + first_project.create_language_server_manager.side_effect = block_first_load + second_registration.get_project_instance.return_value = second_project + serena_config = cast(Any, project_server._agent.serena_config) + serena_config.get_registered_project.side_effect = { + "first": first_registration, + "second": second_registration, + }.get + + with ThreadPoolExecutor(max_workers=2) as executor: + first_future = executor.submit(project_server._get_project, "first") + assert first_load_started.wait(timeout=1) + + second_future = executor.submit(project_server._get_project, "second") + try: + assert second_future.result(timeout=1) is second_project + finally: + allow_first_load_to_finish.set() + + assert first_future.result(timeout=1) is first_project + + +def test_concurrent_lookups_load_each_project_only_once(project_server: ProjectServer) -> None: + project_root = Path("/project") + project = MagicMock() + load_started = threading.Event() + second_lookup_started = threading.Event() + allow_load_to_finish = threading.Event() + registration = MagicMock(project_root=project_root) + lookup_count = 0 + lookup_count_lock = threading.Lock() + + def block_load() -> None: + load_started.set() + assert allow_load_to_finish.wait(timeout=5) + + registration.get_project_instance.return_value = project + project.create_language_server_manager.side_effect = block_load + serena_config = cast(Any, project_server._agent.serena_config) + + def get_registration(project_name: str) -> MagicMock: + nonlocal lookup_count + assert project_name == "project" + with lookup_count_lock: + lookup_count += 1 + if lookup_count == 2: + second_lookup_started.set() + return registration + + serena_config.get_registered_project.side_effect = get_registration + + with ThreadPoolExecutor(max_workers=2) as executor: + first_future = executor.submit(project_server._get_project, "project") + assert load_started.wait(timeout=1) + second_future = executor.submit(project_server._get_project, "project") + assert second_lookup_started.wait(timeout=1) + allow_load_to_finish.set() + + assert first_future.result(timeout=1) is project + assert second_future.result(timeout=1) is project + + registration.get_project_instance.assert_called_once_with(serena_config) + project.create_language_server_manager.assert_called_once_with() + + +def test_concurrent_queries_serialize_active_project_context(project_server: ProjectServer) -> None: + first_query_started = threading.Event() + second_lookup_finished = threading.Event() + allow_first_query_to_finish = threading.Event() + state_lock = threading.Lock() + state: dict[str, str | None] = {"active_project": None} + + def get_project(project_name: str) -> str: + if project_name == "second": + second_lookup_finished.set() + return project_name + + @contextmanager + def active_project_context(project: str) -> Iterator[None]: + with state_lock: + assert state["active_project"] is None + state["active_project"] = project + try: + yield + finally: + with state_lock: + state["active_project"] = None + + def apply_tool(hold: bool = False) -> str: + if hold: + first_query_started.set() + assert allow_first_query_to_finish.wait(timeout=5) + with state_lock: + active_project = state["active_project"] + assert active_project is not None + return active_project + + tool = MagicMock() + tool.is_readonly.return_value = True + tool.apply_ex.side_effect = apply_tool + server = cast(Any, project_server) + server._get_project = MagicMock(side_effect=get_project) + agent = cast(Any, project_server._agent) + agent.active_project_context.side_effect = active_project_context + agent.get_tool_by_name.return_value = tool + first_request = QueryProjectRequest(project_name="first", tool_name="read_file", tool_params_json='{"hold": true}') + second_request = QueryProjectRequest(project_name="second", tool_name="read_file", tool_params_json="{}") + + with ThreadPoolExecutor(max_workers=2) as executor: + first_future = executor.submit(project_server._query_project, first_request) + assert first_query_started.wait(timeout=1) + second_future = executor.submit(project_server._query_project, second_request) + assert second_lookup_finished.wait(timeout=1) + + try: + with pytest.raises(FutureTimeoutError): + second_future.result(timeout=0.1) + finally: + allow_first_query_to_finish.set() + + assert first_future.result(timeout=1) == "first" + assert second_future.result(timeout=1) == "second" From 8364fc554868fbacb580ba1441d2a518478abd30 Mon Sep 17 00:00:00 2001 From: Tyce Herrman Date: Fri, 7 Aug 2026 14:18:41 -0400 Subject: [PATCH 2/9] fix(java): remove unused IntelliCode integration --- CHANGELOG.md | 2 + docs/02-usage/050_configuration.md | 22 ++-- .../language_servers/eclipse_jdtls.py | 116 ++---------------- .../downloaded_dependency_hashes.json | 3 +- .../java/test_jdtls_path_resolution.py | 4 +- 5 files changed, 22 insertions(+), 125 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36d20301..312d9de5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ Status of the `main` branch. Changes prior to the next official version change w - Allow language server priorities to be configured in `serena_config.yml` (for auto-detection during project creation) - Add `python_basedpyright` as an alternative Python language server + - Java/JDTLS: stop downloading and loading the unused IntelliCode completion-ranking bundle; the retired + `intellicode_version`, `intellicode_xmx` and `intellicode_xms` settings remain accepted but are ignored #1821 - Nix/nixd: support custom `ls_path` launchers and external JSON settings through `config_path` #1737 - Fix: Nix/nixd diagnostics now use published diagnostics instead of the unsupported `textDocument/diagnostic` request, which terminated nixd #1802 diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 65b5e6cc..84facd85 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -666,8 +666,7 @@ Supported settings: Java support has two installation modes: 1. **Default vscode-java VSIX mode** (no extra config required): Serena downloads the platform-specific - vscode-java VSIX (~500 MB: JDTLS + bundled JRE 21 + Lombok + IntelliCode), Gradle distribution and - IntelliCode VSIX from public hosts on first use. + vscode-java VSIX (JDTLS + bundled JRE 21 + Lombok) and a Gradle distribution from public hosts on first use. 2. **Upstream JDTLS mode** (offline-friendly): Activated by setting both `jdtls_path` and `lombok_path`. Uses an existing JDTLS installation (~100 MB) and the system JDK 21+. Nothing is downloaded. Recommended for restricted-network/corporate environments. @@ -677,9 +676,8 @@ Java support has two installation modes: - **Default vscode-java VSIX mode** — recommended for most users. No setup required; Serena downloads everything on first use. - **Upstream JDTLS mode** — recommended when: - - you cannot reach `github.com`, `services.gradle.org` or `marketplace.visualstudio.com` - from the host (corporate proxy, air-gapped network); - - you want a smaller on-disk footprint (~100 MB vs ~500 MB); + - you cannot reach `github.com` or `services.gradle.org` from the host (corporate proxy, air-gapped network); + - you want a smaller on-disk footprint; - you already maintain a JDTLS installation (e.g. for `nvim-jdtls` or another editor); - your security policy prohibits per-project runtime downloads. @@ -702,23 +700,19 @@ The following settings are supported for the Java language server: | `runtimes` | `[]` | Extra JRE/JDK entries registered with JDT-LS via `java.configuration.runtimes`. Use this when a project's source/target level exceeds the JDK JDT-LS itself runs on (currently JDK 21 in default vscode-java VSIX mode). Each entry is a mapping with required `name` (e.g. `JavaSE-25`, matching the `JavaSE-NN` container the build tool requests) and `path` (JDK/JRE home directory; must exist), plus optional `default`, `sources`, and `javadoc` (passed through to JDT-LS). Entries extend rather than replace the bundled `JavaSE-21` runtime; an entry that reuses the `JavaSE-21` name overrides the bundled one. Changing this setting invalidates the JDTLS workspace hash so a fresh import is performed. | | `gradle_version` | `8.14.2` | (vscode-java mode only) Override the Gradle distribution version Serena downloads by default. | | `vscode_java_version` | `1.54.0-923` | (vscode-java mode only) Override the bundled `vscode-java` runtime bundle version Serena downloads by default. | -| `intellicode_version` | `1.2.30` | (vscode-java mode only) Override the IntelliCode VSIX version Serena downloads by default. | | `lombok_show_generated` | `true` | Show Lombok-generated methods (`getX/setX`, `builder()`, `equals/hashCode/toString`, `withX`, fluent accessors) in `find_symbol`, `get_symbols_overview` and the symbol-edit tools. Set to `false` to restore the previous JDTLS default and hide the synthetic methods (e.g. when `@Data` classes pollute the outline with too many getters/setters). Requires JDTLS commit `b2d8952` / `vscode-java >= 1.53.0`; the bundled default already meets this. | | `jdtls_xmx` | `3G` | Maximum heap size for the JDTLS server JVM. | | `jdtls_xms` | `100m` | Initial heap size for the JDTLS server JVM. | -| `intellicode_xmx` | `1G` | (vscode-java mode only) Maximum heap size for the IntelliCode embedded JVM. | -| `intellicode_xms` | `100m` | (vscode-java mode only) Initial heap size for the IntelliCode embedded JVM. | Notes: - When overriding `vscode_java_version`, Serena still assumes that the downloaded runtime bundle keeps the same internal directory layout and file names as the bundled default version. -- In upstream-jdtls mode, IntelliCode is not loaded (it's an ML completions ranker that is irrelevant to Serena's - symbol-tools workflow), and Serena does not ship a Gradle distribution. Maven projects work via JDTLS's bundled m2e. +- Serena does not download or load IntelliCode because its completion ranking is not used by Serena's tools. The retired + `intellicode_version`, `intellicode_xmx` and `intellicode_xms` keys remain accepted and ignored so existing + configurations continue to load; they can be removed. +- In upstream-jdtls mode Serena does not ship a Gradle distribution. Maven projects work via JDTLS's bundled m2e. Gradle projects must have `./gradlew` in the project, or rely on a system-installed Gradle through Buildship's - default discovery rules. -- In upstream-jdtls mode the `gradle_version`, `vscode_java_version`, `intellicode_version`, - `intellicode_xmx`, `intellicode_xms` settings are silently ignored — they only apply to the - vscode-java VSIX mode. + default discovery rules. The `gradle_version` and `vscode_java_version` settings are silently ignored in this mode. - Without `runtimes`, JDT-LS only knows about the bundled `JavaSE-21` JRE. Projects that request a newer container (e.g. `sourceCompatibility = JavaVersion.VERSION_25`) then fail to resolve JDK types such as `java.lang.Object`. Register the matching installed JDK via `runtimes` instead of symlinking over Serena's diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index 94531a9a..baa45de9 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -35,11 +35,6 @@ log = logging.getLogger(__name__) GRADLE_ALLOWED_HOSTS = ("services.gradle.org", "github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") VSCODE_JAVA_ALLOWED_HOSTS = ("github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com") -INTELLICODE_ALLOWED_HOSTS = ( - "visualstudioexptteam.gallery.vsassets.io", - "marketplace.visualstudio.com", - "download.visualstudio.microsoft.com", -) @dataclasses.dataclass(frozen=True) @@ -77,10 +72,9 @@ class RuntimeDependencyPaths: """ Stores the paths to the runtime dependencies of EclipseJDTLS. - In the default mode (vscode-java VSIX), all paths are populated. - In the upstream-jdtls mode (when ``jdtls_path`` and ``lombok_path`` are set), - fields that have no upstream equivalent (gradle distribution, IntelliCode bundle) - are set to None. + In the default mode (vscode-java VSIX), all paths are populated. In the + upstream-jdtls mode (when ``jdtls_path`` and ``lombok_path`` are set), the + Gradle distribution path is ``None`` because Serena does not download it. """ jre_path: str @@ -89,8 +83,6 @@ class RuntimeDependencyPaths: jdtls_readonly_config_path: str lombok_jar_path: str gradle_path: str | None = None - intellicode_jar_path: str | None = None - intellisense_members_path: str | None = None @dataclass @@ -113,8 +105,8 @@ class EclipseJDTLS(SolidLanguageServer): Two installation modes are supported: 1. **Default vscode-java VSIX mode** (no extra config required) — Serena downloads the platform-specific - vscode-java VSIX bundle (~500 MB: JDTLS + bundled JRE 21 + Lombok + IntelliCode), Gradle distribution - and IntelliCode VSIX from public hosts. Suitable when public network access is available. + vscode-java VSIX bundle (JDTLS + bundled JRE 21 + Lombok) and Gradle distribution from public hosts. + Suitable when public network access is available. 2. **Upstream JDTLS mode** (activated by setting both ``jdtls_path`` and ``lombok_path``) — uses an existing JDTLS installation and the system JDK. Nothing is downloaded. Suitable for restricted-network @@ -143,8 +135,6 @@ class EclipseJDTLS(SolidLanguageServer): gradle_java_home is unset, Gradle import (default: false) - jdtls_xmx: Maximum heap size for the JDTLS server JVM (default: "3G") - jdtls_xms: Initial heap size for the JDTLS server JVM (default: "100m") - - intellicode_xmx: Maximum heap size for the IntelliCode embedded JVM (default: "1G") - - intellicode_xms: Initial heap size for the IntelliCode embedded JVM (default: "100m") - lombok_show_generated: Show Lombok-generated methods (getX/setX/builder()/...) in document symbols by sending java.symbols.includeGeneratedCode=true to JDTLS (default: true). Set to false for @Data-heavy projects where the extra getters/setters are noise. @@ -155,7 +145,6 @@ class EclipseJDTLS(SolidLanguageServer): Pinned versions: "1.54.0-923" (default) and "1.42.0-561" (legacy / initial). Other versions are not supported in default VSIX mode (the resource paths inside the archive change between releases); use upstream-jdtls mode for arbitrary versions. - - intellicode_version: Override the pinned IntelliCode VSIX version downloaded by Serena - runtimes: Additional JRE/JDK entries to register with JDT-LS's ``java.configuration.runtimes``, for projects whose source/target level exceeds the JDK JDT-LS itself runs on (currently JDK 21 in default vscode-java VSIX mode). Each entry is a mapping with: @@ -190,12 +179,9 @@ class EclipseJDTLS(SolidLanguageServer): use_system_java_home: true # set to true to use system JAVA_HOME for JDTLS and Gradle fallback jdtls_xmx: "3G" # maximum heap size for the JDTLS server JVM jdtls_xms: "100m" # initial heap size for the JDTLS server JVM - intellicode_xmx: "1G" # maximum heap size for the IntelliCode embedded JVM - intellicode_xms: "100m" # initial heap size for the IntelliCode embedded JVM lombok_show_generated: true # show Lombok-generated methods in document symbols (default true) gradle_version: "8.14.2" vscode_java_version: "1.54.0-923" # also accepts pinned legacy "1.42.0-561" - intellicode_version: "1.2.30" runtimes: # register additional JDKs for projects targeting a newer Java version - name: "JavaSE-25" path: "/home/user/Java/jdk25" @@ -220,7 +206,6 @@ class EclipseJDTLS(SolidLanguageServer): self._service_ready_event = threading.Event() self._project_ready_event = threading.Event() - self._intellicode_enable_command_available = threading.Event() def _create_dependency_provider(self) -> LanguageServerDependencyProvider: ls_resources_dir = self.ls_resources_dir(self._solidlsp_settings) @@ -258,7 +243,6 @@ class EclipseJDTLS(SolidLanguageServer): # versions used initially (which use default paths without version suffix) INITIAL_VSCODE_JAVA_VERSION = "1.42.0-561" - INITIAL_INTELLICODE_VERSION = "1.2.30" INITIAL_VSCODE_JAVA_PATHS = VsixResourcePaths( jre_version="21.0.7", lombok_jar_basename="lombok-1.18.36.jar", @@ -270,7 +254,6 @@ class EclipseJDTLS(SolidLanguageServer): # scripts/update_downloaded_dependency_hashes.py and commit the resulting changes. DEFAULT_GRADLE_VERSION = "8.14.2" DEFAULT_VSCODE_JAVA_VERSION = "1.54.0-923" - DEFAULT_INTELLICODE_VERSION = "1.2.30" DEFAULT_VSCODE_JAVA_PATHS = VsixResourcePaths( jre_version="21.0.10", lombok_jar_basename="lombok-1.18.39-4050.jar", @@ -320,19 +303,9 @@ class EclipseJDTLS(SolidLanguageServer): ) return deps - @classmethod - def _create_dep_intellicode(cls, intellicode_version: str | None = None) -> DownloadedDependency: - intellicode_version = intellicode_version or cls.DEFAULT_INTELLICODE_VERSION - return DownloadedDependency( - url=f"https://VisualStudioExptTeam.gallery.vsassets.io/_apis/public/gallery/publisher/VisualStudioExptTeam/extension/vscodeintellicode/{intellicode_version}/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage", - archive_type="zip", - allowed_hosts=INTELLICODE_ALLOWED_HOSTS, - verified=intellicode_version == cls.DEFAULT_INTELLICODE_VERSION, - ) - @classmethod def update_dep_hashes(cls) -> None: - deps = [cls._create_dep_gradle(), cls._create_dep_intellicode()] + list(cls._create_deps_vscode_java().values()) + deps = [cls._create_dep_gradle()] + list(cls._create_deps_vscode_java().values()) with DownloadedDependencyHashDatabase.get_instance().update_context() as db: for dep in deps: db.update(dep) @@ -351,8 +324,8 @@ class EclipseJDTLS(SolidLanguageServer): ``brew install jdtls`` or extracted ``jdt-language-server-*.tar.gz``) and the system JDK. Nothing is downloaded by Serena. Suitable for restricted-network/corporate environments. * **Default vscode-java VSIX mode** (activated otherwise): downloads the platform-specific - vscode-java VSIX bundle (containing JDTLS, bundled JRE 21, Lombok, IntelliCode), Gradle - distribution and IntelliCode VSIX from public hosts. Original behaviour, unchanged. + vscode-java VSIX bundle (containing JDTLS, bundled JRE 21 and Lombok) and a Gradle + distribution from public hosts. """ jdtls_path = custom_settings.get("jdtls_path") lombok_path = custom_settings.get("lombok_path") @@ -369,16 +342,12 @@ class EclipseJDTLS(SolidLanguageServer): platformId = PlatformUtils.get_platform_id() gradle_version = custom_settings.get("gradle_version", cls.DEFAULT_GRADLE_VERSION) vscode_java_version = custom_settings.get("vscode_java_version", cls.DEFAULT_VSCODE_JAVA_VERSION) - intellicode_version = custom_settings.get("intellicode_version", cls.DEFAULT_INTELLICODE_VERSION) # install-dir name per the version-pinning convention (see module-level block): # INITIAL -> legacy unversioned dir; everything else -> "{name}-{resolved}" subdir vscode_java_dirname = ( "vscode-java" if vscode_java_version == cls.INITIAL_VSCODE_JAVA_VERSION else f"vscode-java-{vscode_java_version}" ) - intellicode_dirname = ( - "intellicode" if intellicode_version == cls.INITIAL_INTELLICODE_VERSION else f"intellicode-{intellicode_version}" - ) # Resolve internal VSIX paths (JRE / Lombok / launcher filenames). For pinned versions # these are known; for any other user-supplied version we bail out — guessing would @@ -478,26 +447,6 @@ class EclipseJDTLS(SolidLanguageServer): assert os.path.exists(jdtls_launcher_jar_path) assert os.path.exists(jdtls_readonly_config_path) - intellicode_dep = cls._create_dep_intellicode(intellicode_version) - intellicode_directory_path = str(PurePath(ls_resources_dir, intellicode_dirname)) - os.makedirs(intellicode_directory_path, exist_ok=True) - intellicode_jar_path = str( - PurePath(intellicode_directory_path, "extension/dist/com.microsoft.jdtls.intellicode.core-0.7.0.jar") - ) - intellisense_members_path = str(PurePath(intellicode_directory_path, "extension/dist/bundledModels/java_intellisense-members")) - if not all( - [ - os.path.exists(intellicode_directory_path), - os.path.exists(intellicode_jar_path), - os.path.exists(intellisense_members_path), - ] - ): - intellicode_dep.download_to(intellicode_directory_path) - - assert os.path.exists(intellicode_directory_path) - assert os.path.exists(intellicode_jar_path) - assert os.path.exists(intellisense_members_path) - return RuntimeDependencyPaths( gradle_path=gradle_path, lombok_jar_path=lombok_jar_path, @@ -505,8 +454,6 @@ class EclipseJDTLS(SolidLanguageServer): jre_home_path=jre_home_path, jdtls_launcher_jar_path=jdtls_launcher_jar_path, jdtls_readonly_config_path=jdtls_readonly_config_path, - intellicode_jar_path=intellicode_jar_path, - intellisense_members_path=intellisense_members_path, ) @staticmethod @@ -520,7 +467,7 @@ class EclipseJDTLS(SolidLanguageServer): :param jdtls_path: absolute path to the JDTLS root (containing ``plugins/`` and ``config_/``) :param lombok_path: absolute path to the Lombok jar (mandatory; agent is always attached) :param custom_settings: language-server-specific settings from ls_specific_settings.java - :return: populated RuntimeDependencyPaths with gradle/intellicode fields set to None + :return: populated RuntimeDependencyPaths with ``gradle_path`` set to ``None`` """ # validate jdtls_path structure (root + plugins dir) jdtls_root = Path(jdtls_path) @@ -569,8 +516,6 @@ class EclipseJDTLS(SolidLanguageServer): jdtls_readonly_config_path=str(config_dir), lombok_jar_path=lombok_path, gradle_path=None, - intellicode_jar_path=None, - intellisense_members_path=None, ) @staticmethod @@ -1006,10 +951,6 @@ class EclipseJDTLS(SolidLanguageServer): gradle_user_home = None log.info(f"Gradle user home not found at default location ({default_gradle_home}), will use JDTLS defaults") - # IntelliCode JVM settings (used in vmargs for the embedded JVM) - intellicode_xmx = self._custom_settings.get("intellicode_xmx", "1G") - intellicode_xms = self._custom_settings.get("intellicode_xms", "100m") - # Lombok-generated symbols (getX/setX/builder()/equals/hashCode/toString/...): JDTLS filters # these out of documentSymbol results by default. Without them, find_symbol/get_symbols_overview # return only user-written sources, which breaks navigation around @Data/@Builder/@Getter/@Setter @@ -1194,19 +1135,10 @@ class EclipseJDTLS(SolidLanguageServer): "notebookDocument": {"synchronization": {"dynamicRegistration": True, "executionSummarySupport": True}}, }, "initializationOptions": { - "bundles": ["intellicode-core.jar"], + "bundles": [], "settings": { "java": { "home": None, - "jdt": { - "ls": { - "java": {"home": None}, - "vmargs": f"-XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Dsun.zip.disableMemoryMapping=true -Xmx{intellicode_xmx} -Xms{intellicode_xms} -Xlog:disable", - "lombokSupport": {"enabled": True}, - "protobufSupport": {"enabled": True}, - "androidSupport": {"enabled": True}, - } - }, "errors": {"incompleteClasspath": {"severity": "error"}}, "configuration": { "checkProjectSettingsExclusions": False, @@ -1326,14 +1258,6 @@ class EclipseJDTLS(SolidLanguageServer): initialize_params["initializationOptions"]["workspaceFolders"] = [repo_uri] - # IntelliCode bundle: only attached in default vscode-java VSIX mode. - # In upstream-jdtls mode (jdtls_path set) we don't ship IntelliCode — agentic Serena workflows - # don't use completion ranking, so the bundle would be inert dead weight. - if self.runtime_dependency_paths.intellicode_jar_path is not None: - initialize_params["initializationOptions"]["bundles"] = [self.runtime_dependency_paths.intellicode_jar_path] - else: - initialize_params["initializationOptions"]["bundles"] = [] - # merge the bundled JRE with any additional runtimes configured via ls_specific_settings.java.runtimes # (e.g. so projects targeting a newer Java version than the bundled JRE resolve their JRE container)... default_runtime = {"name": "JavaSE-21", "path": self.runtime_dependency_paths.jre_home_path, "default": True} @@ -1378,9 +1302,6 @@ class EclipseJDTLS(SolidLanguageServer): "*", " ", ] - if registration["method"] == "workspace/executeCommand": - if "java.intellicode.enable" in registration["registerOptions"]["commands"]: - self._intellicode_enable_command_available.set() return def lang_status_handler(params: dict) -> None: @@ -1424,23 +1345,6 @@ class EclipseJDTLS(SolidLanguageServer): self.server.notify.workspace_did_change_configuration({"settings": initialize_params["initializationOptions"]["settings"]}) # type: ignore - # IntelliCode enablement is only relevant in the default vscode-java VSIX mode where the - # IntelliCode bundle is shipped. In upstream-jdtls mode it's absent and the - # 'java.intellicode.enable' command will never be registered, so we skip the wait/call. - if self.runtime_dependency_paths.intellicode_jar_path is not None: - self._intellicode_enable_command_available.wait() - - java_intellisense_members_path = self.runtime_dependency_paths.intellisense_members_path - assert java_intellisense_members_path is not None - assert os.path.exists(java_intellisense_members_path) - intellicode_enable_result = self.server.send.execute_command( - { - "command": "java.intellicode.enable", - "arguments": [True, java_intellisense_members_path], - } - ) - assert intellicode_enable_result - if not self._service_ready_event.is_set(): log.info("Waiting for service to be ready ...") self._service_ready_event.wait() diff --git a/src/solidlsp/resources/downloaded_dependency_hashes.json b/src/solidlsp/resources/downloaded_dependency_hashes.json index 7fc0bdd9..a02ce776 100644 --- a/src/solidlsp/resources/downloaded_dependency_hashes.json +++ b/src/solidlsp/resources/downloaded_dependency_hashes.json @@ -1,9 +1,8 @@ { "https://services.gradle.org/distributions/gradle-8.14.2-bin.zip": "7197a12f450794931532469d4ff21a59ea2c1cd59a3ec3f89c035c3c420a6999", - "https://VisualStudioExptTeam.gallery.vsassets.io/_apis/public/gallery/publisher/VisualStudioExptTeam/extension/vscodeintellicode/1.2.30/assetbyname/Microsoft.VisualStudio.Services.VSIXPackage": "7f61a7f96d101cdf230f96821be3fddd8f890ebfefb3695d18beee43004ae251", "https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-darwin-arm64-1.54.0-923.vsix": "c54c45cb0d2579d8e0a4ddeb24d4a9dd0b460d07d9366adea2b38a1da22a463c", "https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-darwin-x64-1.54.0-923.vsix": "dfc98abc4e54165a78372e280242a039671729b1b03420608df3b10c6b629fb6", "https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-linux-arm64-1.54.0-923.vsix": "e2bb22c427d90da8dbb1afff72ff1e2dce38d50b76deb02d7bc313a330a1330c", "https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-linux-x64-1.54.0-923.vsix": "9d4b15da54e25a0192f9bac073f086c015397d3676623b68dbf83a5dbaf5132b", "https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-win32-x64-1.54.0-923.vsix": "66f3914987edeccfee8a2558470e0fde4f8c4154232ff4baa5d73373ebc819d4" -} \ No newline at end of file +} diff --git a/test/solidlsp/java/test_jdtls_path_resolution.py b/test/solidlsp/java/test_jdtls_path_resolution.py index 3bf738b7..6ffd2ea4 100644 --- a/test/solidlsp/java/test_jdtls_path_resolution.py +++ b/test/solidlsp/java/test_jdtls_path_resolution.py @@ -455,7 +455,7 @@ class TestSetupFromExistingInstall: (jdk / "bin" / "java").touch() return jdk - def test_happy_path_returns_runtime_paths_with_no_gradle_and_no_intellicode( + def test_happy_path_returns_runtime_paths_with_no_gradle( self, tmp_path: Path, jdtls_root: Path, lombok_jar: Path, custom_settings: SolidLSPSettings.CustomLSSettings ) -> None: jdk = self._fake_jdk(tmp_path) @@ -465,8 +465,6 @@ class TestSetupFromExistingInstall: result = EclipseJDTLS.DependencyProvider._setup_from_existing_install(str(jdtls_root), str(lombok_jar), custom_settings) assert result.gradle_path is None - assert result.intellicode_jar_path is None - assert result.intellisense_members_path is None assert result.lombok_jar_path == str(lombok_jar) assert result.jre_home_path == str(jdk) assert Path(result.jdtls_launcher_jar_path).name.startswith("org.eclipse.equinox.launcher_") From 217517ecdfb299a2a1a4d680d8659fe272765718 Mon Sep 17 00:00:00 2001 From: Tyce Herrman Date: Sat, 8 Aug 2026 10:25:13 -0400 Subject: [PATCH 3/9] feat(kotlin): update language server to 262.9593.0 (#1811) * feat(kotlin): update language server to 262.9593.0 Note: download URL structure differs depending on version --------- Co-authored-by: Dominik Jain --- CHANGELOG.md | 2 + docs/02-usage/050_configuration.md | 9 +- .../update_downloaded_dependency_hashes.py | 2 + .../kotlin_language_server.py | 180 ++++++++---- .../downloaded_dependency_hashes.json | 13 +- test/solidlsp/kotlin/test_kotlin_basic.py | 2 +- .../kotlin/test_kotlin_dependency_provider.py | 275 ++++++++++++++++++ .../kotlin/test_kotlin_diagnostics.py | 2 + test/solidlsp/kotlin/test_kotlin_startup.py | 109 +++++++ 9 files changed, 537 insertions(+), 57 deletions(-) create mode 100644 test/solidlsp/kotlin/test_kotlin_dependency_provider.py create mode 100644 test/solidlsp/kotlin/test_kotlin_startup.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 312d9de5..316009cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ Status of the `main` branch. Changes prior to the next official version change w - Add `python_basedpyright` as an alternative Python language server - Java/JDTLS: stop downloading and loading the unused IntelliCode completion-ranking bundle; the retired `intellicode_version`, `intellicode_xmx` and `intellicode_xms` settings remain accepted but are ignored #1821 + - Kotlin: update the managed Kotlin LSP from `261.13587.0` to `262.9593.0`, including support for the + new platform-specific archive layout and Windows ARM64 builds - Nix/nixd: support custom `ls_path` launchers and external JSON settings through `config_path` #1737 - Fix: Nix/nixd diagnostics now use published diagnostics instead of the unsupported `textDocument/diagnostic` request, which terminated nixd #1802 diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 84facd85..659f73b8 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -759,15 +759,20 @@ Supported settings: | Setting | Default | Description | |---|---|---| | `ls_path` | managed download | Override the Kotlin Language Server executable path. | -| `kotlin_lsp_version` | `261.13587.0` | Override the Kotlin Language Server version Serena downloads when `ls_path` is not set. | +| `kotlin_lsp_version` | `262.9593.0` | Override the Kotlin Language Server version Serena downloads when `ls_path` is not set. | | `jvm_options` | `-Xmx2G` | Value assigned to `JAVA_TOOL_OPTIONS` for the Kotlin LS process. Set to `""` to disable JVM options entirely. | +The managed `262.9593.0` packages include a bundled JBR. For a custom `ls_path`, point directly to +`bin/intellij-server` (`bin/intellij-server.exe` on Windows). Serena also retains the legacy download +layout for custom Kotlin LSP versions older than `262.4739.0`. The pinned current and frozen initial +releases are checksum-verified; arbitrary custom versions are downloaded without checksum verification. + Example: ```yaml ls_specific_settings: kotlin: - kotlin_lsp_version: "261.13587.0" + kotlin_lsp_version: "262.9593.0" jvm_options: "-Xmx4G -XX:+UseG1GC" ``` diff --git a/scripts/update_downloaded_dependency_hashes.py b/scripts/update_downloaded_dependency_hashes.py index b82eab1d..02e56bda 100644 --- a/scripts/update_downloaded_dependency_hashes.py +++ b/scripts/update_downloaded_dependency_hashes.py @@ -1,7 +1,9 @@ from sensai.util import logging from solidlsp.language_servers.eclipse_jdtls import EclipseJDTLS +from solidlsp.language_servers.kotlin_language_server import KotlinLanguageServer if __name__ == "__main__": logging.configure() EclipseJDTLS.DependencyProvider.update_dep_hashes() + KotlinLanguageServer.DependencyProvider.update_dep_hashes() diff --git a/src/solidlsp/language_servers/kotlin_language_server.py b/src/solidlsp/language_servers/kotlin_language_server.py index 940758f6..df51dfd1 100644 --- a/src/solidlsp/language_servers/kotlin_language_server.py +++ b/src/solidlsp/language_servers/kotlin_language_server.py @@ -5,8 +5,8 @@ You can configure the following options in ls_specific_settings (in serena_confi ls_specific_settings: kotlin: - ls_path: '/path/to/kotlin-lsp.sh' # Custom path to Kotlin Language Server executable - kotlin_lsp_version: '261.13587.0' # Kotlin Language Server version (default: current bundled version) + ls_path: '/path/to/bin/intellij-server' # Custom path to Kotlin Language Server executable + kotlin_lsp_version: '262.9593.0' # Kotlin Language Server version (default: current bundled version) jvm_options: '-Xmx2G' # JVM options for Kotlin Language Server (default: -Xmx2G) Example configuration for large projects: @@ -21,16 +21,18 @@ import os import pathlib import stat import threading +from dataclasses import dataclass from overrides import override +from solidlsp.dependency_provider import DownloadedDependency, DownloadedDependencyHashDatabase from solidlsp.ls import ( LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer, ) from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_utils import FileUtils, PlatformUtils +from solidlsp.ls_utils import FileUtils, PlatformId, PlatformUtils from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -44,34 +46,21 @@ KOTLIN_LSP_ALLOWED_HOSTS = ("download-cdn.jetbrains.com",) # Version pinning convention (see eclipse_jdtls.py for the full spec): # INITIAL_* — frozen forever; legacy unversioned install dir is reserved for it. # DEFAULT_* — bumped on upgrades; goes into a versioned subdir. +# NOTE: After changing either pinned version, run scripts/update_downloaded_dependency_hashes.py. INITIAL_KOTLIN_LSP_VERSION = "261.13587.0" -INITIAL_KOTLIN_LSP_SHA256_BY_SUFFIX = { - "win-x64": "2806c2bd4810bd8e7ccc27d8c0ca4a5232a1c4f26ea1f4ba40e578b60860ccad", - "linux-x64": "dc0ed2e70cb0d61fdabb26aefce8299b7a75c0dcfffb9413715e92caec6e83ec", - "linux-aarch64": "d1dceb000fe06c5e2c30b95e7f4ab01d05101bd03ed448167feeb544a9f1d651", - "mac-x64": "a3972f27229eba2c226060e54baea1c958c82c326dfc971bf53f72a74d0564a3", - "mac-aarch64": "d4ea28b22b29cf906fe16d23698a8468f11646a6a66dcb15584f306aaefbee6c", -} -DEFAULT_KOTLIN_LSP_VERSION = "261.13587.0" -DEFAULT_KOTLIN_LSP_SHA256_BY_SUFFIX = { - "win-x64": "2806c2bd4810bd8e7ccc27d8c0ca4a5232a1c4f26ea1f4ba40e578b60860ccad", - "linux-x64": "dc0ed2e70cb0d61fdabb26aefce8299b7a75c0dcfffb9413715e92caec6e83ec", - "linux-aarch64": "d1dceb000fe06c5e2c30b95e7f4ab01d05101bd03ed448167feeb544a9f1d651", - "mac-x64": "a3972f27229eba2c226060e54baea1c958c82c326dfc971bf53f72a74d0564a3", - "mac-aarch64": "d4ea28b22b29cf906fe16d23698a8468f11646a6a66dcb15584f306aaefbee6c", -} +DEFAULT_KOTLIN_LSP_VERSION = "262.9593.0" +# Versions before this one use kotlin-lsp-{version}-{platform}.zip and a kotlin-lsp script. +# Starting with 262.4739.0, JetBrains publishes kotlin-server archives with platform-specific +# formats, a bundled JBR, and bin/intellij-server as the launcher. +KOTLIN_SERVER_PACKAGING_MIN_VERSION = (262, 4739, 0) -def _kotlin_lsp_sha(version: str, kotlin_suffix: str) -> str | None: - if version == INITIAL_KOTLIN_LSP_VERSION: - return INITIAL_KOTLIN_LSP_SHA256_BY_SUFFIX.get(kotlin_suffix) - if version == DEFAULT_KOTLIN_LSP_VERSION: - return DEFAULT_KOTLIN_LSP_SHA256_BY_SUFFIX.get(kotlin_suffix) - return None +# The first modern archives remained under the legacy /kotlin-lsp CDN path. +# Starting with 262.8190.0, JetBrains moved them to /language-server/kotlin-server. +KOTLIN_SERVER_CDN_PATH_MIN_VERSION = (262, 8190, 0) - -# Platform-specific Kotlin LSP download suffixes -PLATFORM_KOTLIN_SUFFIX = { +# Platform-specific suffixes used by legacy Kotlin LSP ZIP archives. +LEGACY_PLATFORM_KOTLIN_SUFFIX = { "win-x64": "win-x64", "linux-x64": "linux-x64", "linux-arm64": "linux-aarch64", @@ -79,6 +68,22 @@ PLATFORM_KOTLIN_SUFFIX = { "osx-arm64": "mac-aarch64", } +# Modern archive filename suffix, FileUtils archive type, and launcher path within the archive. +KOTLIN_SERVER_ARTIFACT_BY_PLATFORM: dict[str, tuple[str, FileUtils.ArchiveType, tuple[str, ...]]] = { + "win-x64": (".win.zip", "zip", ("bin", "intellij-server.exe")), + "win-arm64": ("-aarch64.win.zip", "zip", ("bin", "intellij-server.exe")), + "linux-x64": (".tar.gz", "gztar", ("kotlin-server-{version}", "bin", "intellij-server")), + "linux-arm64": ("-aarch64.tar.gz", "gztar", ("kotlin-server-{version}", "bin", "intellij-server")), + "osx-x64": (".sit", "zip", ("kotlin-server-{version}", "bin", "intellij-server")), + "osx-arm64": ("-aarch64.sit", "zip", ("kotlin-server-{version}", "bin", "intellij-server")), +} + + +@dataclass(frozen=True) +class KotlinLSPArtifact: + dependency: DownloadedDependency + launcher_parts: tuple[str, ...] + class KotlinLanguageServer(SolidLanguageServer): """ @@ -102,15 +107,78 @@ class KotlinLanguageServer(SolidLanguageServer): # set again once all progress tokens have ended. self._indexing_complete = threading.Event() self._indexing_complete.set() + self._intellij_server_ready = threading.Event() self._active_progress_tokens: set[str] = set() self._progress_lock = threading.Lock() def _create_dependency_provider(self) -> LanguageServerDependencyProvider: - return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir, str(self.cache_dir)) class DependencyProvider(LanguageServerDependencyProviderSinglePath): - def __init__(self, custom_settings: SolidLSPSettings.CustomLSSettings, ls_resources_dir: str): + def __init__(self, custom_settings: SolidLSPSettings.CustomLSSettings, ls_resources_dir: str, project_cache_dir: str): super().__init__(custom_settings, ls_resources_dir) + self._project_cache_dir = project_cache_dir + + @classmethod + def _create_artifact(cls, version: str, platform_id: PlatformId) -> KotlinLSPArtifact: + """Build download and launcher metadata for one Kotlin LSP release. + + JetBrains has three publishing layouts: legacy ZIPs before 262.4739.0, + modern platform archives under ``/kotlin-lsp`` through 262.7569.0, + and modern archives under ``/language-server/kotlin-server`` from + 262.8190.0 onward. Serena verifies the frozen initial and current default + releases; arbitrary user-selected versions are unverified by design. + """ + try: + version_parts = tuple(int(part) for part in version.split(".")) + except ValueError as exc: + raise ValueError(f"Kotlin LSP version must contain only dot-separated integers: {version!r}") from exc + + verified = version in {INITIAL_KOTLIN_LSP_VERSION, DEFAULT_KOTLIN_LSP_VERSION} + if version_parts >= KOTLIN_SERVER_PACKAGING_MIN_VERSION: + artifact_config = KOTLIN_SERVER_ARTIFACT_BY_PLATFORM.get(platform_id.value) + if artifact_config is None: + raise ValueError(f"Unsupported platform for Kotlin LSP {version}: {platform_id.value}") + + asset_suffix, archive_type, launcher_parts = artifact_config + asset_name = f"kotlin-server-{version}{asset_suffix}" + cdn_path = "language-server/kotlin-server" if version_parts >= KOTLIN_SERVER_CDN_PATH_MIN_VERSION else "kotlin-lsp" + return KotlinLSPArtifact( + dependency=DownloadedDependency( + url=f"https://download-cdn.jetbrains.com/{cdn_path}/{version}/{asset_name}", + archive_type=archive_type, + allowed_hosts=KOTLIN_LSP_ALLOWED_HOSTS, + verified=verified, + ), + launcher_parts=tuple(part.format(version=version) for part in launcher_parts), + ) + + kotlin_suffix = LEGACY_PLATFORM_KOTLIN_SUFFIX.get(platform_id.value) + if kotlin_suffix is None: + raise ValueError(f"Unsupported platform for Kotlin LSP {version}: {platform_id.value}") + + return KotlinLSPArtifact( + dependency=DownloadedDependency( + url=f"https://download-cdn.jetbrains.com/kotlin-lsp/{version}/kotlin-lsp-{version}-{kotlin_suffix}.zip", + archive_type="zip", + allowed_hosts=KOTLIN_LSP_ALLOWED_HOSTS, + verified=verified, + ), + launcher_parts=("kotlin-lsp.cmd",) if platform_id.is_windows() else ("kotlin-lsp.sh",), + ) + + @classmethod + def update_dep_hashes(cls) -> None: + pinned_artifacts = [ + *(cls._create_artifact(INITIAL_KOTLIN_LSP_VERSION, PlatformId(platform)) for platform in LEGACY_PLATFORM_KOTLIN_SUFFIX), + *( + cls._create_artifact(DEFAULT_KOTLIN_LSP_VERSION, PlatformId(platform)) + for platform in KOTLIN_SERVER_ARTIFACT_BY_PLATFORM + ), + ] + with DownloadedDependencyHashDatabase.get_instance().update_context() as database: + for artifact in pinned_artifacts: + database.update(artifact.dependency) def _get_or_install_core_dependency(self) -> str: """ @@ -118,16 +186,9 @@ class KotlinLanguageServer(SolidLanguageServer): """ platform_id = PlatformUtils.get_platform_id() - # Verify platform support - assert platform_id.value.startswith("win-") or platform_id.value.startswith("linux-") or platform_id.value.startswith("osx-"), ( - "Only Windows, Linux and macOS platforms are supported for Kotlin in multilspy at the moment" - ) - - kotlin_suffix = PLATFORM_KOTLIN_SUFFIX.get(platform_id.value) - assert kotlin_suffix, f"Unsupported platform for Kotlin LSP: {platform_id.value}" - # Setup paths for dependencies; legacy unversioned dir reserved for INITIAL only kotlin_lsp_version = self._custom_settings.get("kotlin_lsp_version", DEFAULT_KOTLIN_LSP_VERSION) + artifact = self._create_artifact(kotlin_lsp_version, platform_id) ls_dirname = ( "kotlin_language_server" if kotlin_lsp_version == INITIAL_KOTLIN_LSP_VERSION @@ -137,22 +198,13 @@ class KotlinLanguageServer(SolidLanguageServer): os.makedirs(static_dir, exist_ok=True) # Setup Kotlin Language Server - kotlin_script_name = "kotlin-lsp.cmd" if platform_id.value.startswith("win-") else "kotlin-lsp.sh" - kotlin_script = os.path.join(static_dir, kotlin_script_name) + kotlin_script = os.path.join(static_dir, *artifact.launcher_parts) if not os.path.exists(kotlin_script): - kotlin_url = f"https://download-cdn.jetbrains.com/kotlin-lsp/{kotlin_lsp_version}/kotlin-lsp-{kotlin_lsp_version}-{kotlin_suffix}.zip" - expected_sha256 = _kotlin_lsp_sha(kotlin_lsp_version, kotlin_suffix) log.info("Downloading Kotlin Language Server...") - FileUtils.download_and_extract_archive_verified( - kotlin_url, - static_dir, - "zip", - expected_sha256=expected_sha256, - allowed_hosts=KOTLIN_LSP_ALLOWED_HOSTS, - ) + artifact.dependency.download_to(static_dir) - if os.path.exists(kotlin_script) and not platform_id.value.startswith("win-"): + if os.path.exists(kotlin_script) and not platform_id.is_windows(): os.chmod( kotlin_script, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH, @@ -165,10 +217,18 @@ class KotlinLanguageServer(SolidLanguageServer): return kotlin_script def _create_launch_command(self, core_path: str) -> list[str]: - return [core_path, "--stdio"] + command = [core_path, "--stdio"] + # A custom ls_path is independent of Serena's managed version. Select + # arguments from the actual launcher so existing kotlin-lsp.sh/.cmd + # configurations do not inherit IntelliJ-server-only options. + platform_id = PlatformUtils.get_platform_id() + intellij_launcher_name = "intellij-server.exe" if platform_id.is_windows() else "intellij-server" + if os.path.basename(core_path).lower() == intellij_launcher_name: + command.extend(["--system-path", os.path.join(self._project_cache_dir, "kotlin-lsp-system")]) + return command def create_launch_command_env(self) -> dict[str, str]: - """Provides JAVA_HOME and JVM options for the Kotlin Language Server process.""" + """Provides JVM options for the Kotlin Language Server process.""" env: dict[str, str] = {} # Get JVM options from settings or use default @@ -427,6 +487,7 @@ class KotlinLanguageServer(SolidLanguageServer): """ Starts the Kotlin Language Server """ + self._intellij_server_ready.clear() def execute_client_command_handler(params: dict) -> list: return [] @@ -437,6 +498,11 @@ class KotlinLanguageServer(SolidLanguageServer): def window_log_message(msg: dict) -> None: log.info(f"LSP: window/logMessage: {msg}") + def intellij_server_ready(_params: dict | None) -> None: + """Mark the modern IntelliJ-based server ready after its startup/import phase.""" + log.info("Kotlin IntelliJ language server reported that it is ready") + self._intellij_server_ready.set() + def work_done_progress_create(params: dict) -> dict: """Handle window/workDoneProgress/create: the server is about to report async progress. Clear the indexing-complete event so _start_server waits until all tokens finish. @@ -487,9 +553,12 @@ class KotlinLanguageServer(SolidLanguageServer): self.server.on_request("workspace/codeLens/refresh", do_nothing) self.server.on_notification("language/status", do_nothing) self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("window/showMessage", window_log_message) self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_request("workspace/diagnostic/refresh", do_nothing) self.server.on_request("window/workDoneProgress/create", work_done_progress_create) self.server.on_notification("$/progress", progress_handler) + self.server.on_notification("intellij/ready-for-test", intellij_server_ready) self.server.on_notification("$/logTrace", do_nothing) self.server.on_notification("$/cancelRequest", do_nothing) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) @@ -503,6 +572,8 @@ class KotlinLanguageServer(SolidLanguageServer): init_response = self.server.send.initialize(initialize_params) capabilities = init_response["capabilities"] + server_info = init_response.get("serverInfo", {}) + wait_for_intellij_ready = isinstance(server_info, dict) and server_info.get("name") == "IntelliJ Language Server by JetBrains" assert "textDocumentSync" in capabilities, "Server must support textDocumentSync" assert "hoverProvider" in capabilities, "Server must support hover" assert "completionProvider" in capabilities, "Server must support code completion" @@ -515,14 +586,17 @@ class KotlinLanguageServer(SolidLanguageServer): self.server.notify.initialized({}) - # Wait for any async indexing to complete. + # Wait for workspace import and async indexing to complete. # - Older KLS (0.253.x): indexing is synchronous inside `initialize`, no $/progress is sent, # _indexing_complete stays SET -> wait() returns immediately. # - Newer KLS (261+): server sends window/workDoneProgress/create after initialized, # which clears the event; wait() blocks until all progress tokens end. + # - IntelliJ-based KLS (262.4739+): server sends intellij/ready-for-test after + # its workspace import and indexing phase completes. _INDEXING_TIMEOUT = 120.0 log.info("Waiting for Kotlin LSP indexing to complete (if async)...") - if self._indexing_complete.wait(timeout=_INDEXING_TIMEOUT): + ready_event = self._intellij_server_ready if wait_for_intellij_ready else self._indexing_complete + if ready_event.wait(timeout=_INDEXING_TIMEOUT): log.info("Kotlin LSP ready") else: log.warning("Kotlin LSP did not signal indexing completion within %.0fs; proceeding anyway", _INDEXING_TIMEOUT) diff --git a/src/solidlsp/resources/downloaded_dependency_hashes.json b/src/solidlsp/resources/downloaded_dependency_hashes.json index a02ce776..c6602add 100644 --- a/src/solidlsp/resources/downloaded_dependency_hashes.json +++ b/src/solidlsp/resources/downloaded_dependency_hashes.json @@ -4,5 +4,16 @@ "https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-darwin-x64-1.54.0-923.vsix": "dfc98abc4e54165a78372e280242a039671729b1b03420608df3b10c6b629fb6", "https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-linux-arm64-1.54.0-923.vsix": "e2bb22c427d90da8dbb1afff72ff1e2dce38d50b76deb02d7bc313a330a1330c", "https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-linux-x64-1.54.0-923.vsix": "9d4b15da54e25a0192f9bac073f086c015397d3676623b68dbf83a5dbaf5132b", - "https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-win32-x64-1.54.0-923.vsix": "66f3914987edeccfee8a2558470e0fde4f8c4154232ff4baa5d73373ebc819d4" + "https://github.com/redhat-developer/vscode-java/releases/download/v1.54.0/java-win32-x64-1.54.0-923.vsix": "66f3914987edeccfee8a2558470e0fde4f8c4154232ff4baa5d73373ebc819d4", + "https://download-cdn.jetbrains.com/kotlin-lsp/261.13587.0/kotlin-lsp-261.13587.0-win-x64.zip": "2806c2bd4810bd8e7ccc27d8c0ca4a5232a1c4f26ea1f4ba40e578b60860ccad", + "https://download-cdn.jetbrains.com/kotlin-lsp/261.13587.0/kotlin-lsp-261.13587.0-linux-x64.zip": "dc0ed2e70cb0d61fdabb26aefce8299b7a75c0dcfffb9413715e92caec6e83ec", + "https://download-cdn.jetbrains.com/kotlin-lsp/261.13587.0/kotlin-lsp-261.13587.0-linux-aarch64.zip": "d1dceb000fe06c5e2c30b95e7f4ab01d05101bd03ed448167feeb544a9f1d651", + "https://download-cdn.jetbrains.com/kotlin-lsp/261.13587.0/kotlin-lsp-261.13587.0-mac-x64.zip": "a3972f27229eba2c226060e54baea1c958c82c326dfc971bf53f72a74d0564a3", + "https://download-cdn.jetbrains.com/kotlin-lsp/261.13587.0/kotlin-lsp-261.13587.0-mac-aarch64.zip": "d4ea28b22b29cf906fe16d23698a8468f11646a6a66dcb15584f306aaefbee6c", + "https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0.win.zip": "f2daaa476f26d99301b406f76de6d87c437d04dc72f06845154619d8f991c51f", + "https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0-aarch64.win.zip": "73a552a6a420158622e5ad8d96b53da8aa8ced3f88a24fded01575927a2fd8e7", + "https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0.tar.gz": "2d99d8e198fbe4aa8f4481e37799724ce94803b4ea12a60b416040e3fcd7cc5e", + "https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0-aarch64.tar.gz": "2317831c6e5607d05b7ebc1da655330125ce0e3d66fbf24517dfce442debc14e", + "https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0.sit": "17369fda97c85418ac24ab38a9df56b21522a3468dfe193832fe455c13920745", + "https://download-cdn.jetbrains.com/language-server/kotlin-server/262.9593.0/kotlin-server-262.9593.0-aarch64.sit": "6ba6021a706b21e64cef33f7e2b79f187c0910320722bb2d3ed05ad1115ec43f" } diff --git a/test/solidlsp/kotlin/test_kotlin_basic.py b/test/solidlsp/kotlin/test_kotlin_basic.py index d903a06d..7530a545 100644 --- a/test/solidlsp/kotlin/test_kotlin_basic.py +++ b/test/solidlsp/kotlin/test_kotlin_basic.py @@ -10,7 +10,7 @@ from test.conftest import language_server_tests_enabled from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols -# Kotlin LSP (IntelliJ-based, pre-alpha v261) crashes on JVM restart under CI resource constraints +# Kotlin LSP (IntelliJ-based, pre-alpha v262) crashes on JVM restart under CI resource constraints # (2 CPUs, 7GB RAM). First start succeeds but subsequent starts fail with cancelled (-32800). # Tests pass reliably on developer machines. See PR #1061 for investigation details. # (The CI quarantine lives centrally in test/conftest.py::_determine_disabled_languages.) diff --git a/test/solidlsp/kotlin/test_kotlin_dependency_provider.py b/test/solidlsp/kotlin/test_kotlin_dependency_provider.py new file mode 100644 index 00000000..707a0633 --- /dev/null +++ b/test/solidlsp/kotlin/test_kotlin_dependency_provider.py @@ -0,0 +1,275 @@ +"""Tests for Kotlin Language Server dependency resolution and installation.""" + +from contextlib import nullcontext +from pathlib import Path +from unittest.mock import patch + +import pytest + +from solidlsp.dependency_provider import DownloadedDependency, DownloadedDependencyHashDatabase +from solidlsp.language_servers.kotlin_language_server import ( + DEFAULT_KOTLIN_LSP_VERSION, + INITIAL_KOTLIN_LSP_VERSION, + KOTLIN_LSP_ALLOWED_HOSTS, + KotlinLanguageServer, +) +from solidlsp.ls_utils import PlatformId +from solidlsp.settings import SolidLSPSettings + + +def _make_provider( + tmp_path: Path, + custom_settings: dict[str, str] | None = None, +) -> KotlinLanguageServer.DependencyProvider: + return KotlinLanguageServer.DependencyProvider( + custom_settings=SolidLSPSettings.CustomLSSettings(custom_settings or {}), + ls_resources_dir=str(tmp_path), + project_cache_dir=str(tmp_path / "project-cache"), + ) + + +@pytest.mark.kotlin +class TestKotlinDependencyProvider: + @pytest.mark.parametrize( + ("platform_id", "asset_suffix", "archive_type", "launcher_parts", "sha256"), + [ + ( + PlatformId.WIN_x64, + ".win.zip", + "zip", + ("bin", "intellij-server.exe"), + "f2daaa476f26d99301b406f76de6d87c437d04dc72f06845154619d8f991c51f", + ), + ( + PlatformId.WIN_arm64, + "-aarch64.win.zip", + "zip", + ("bin", "intellij-server.exe"), + "73a552a6a420158622e5ad8d96b53da8aa8ced3f88a24fded01575927a2fd8e7", + ), + ( + PlatformId.LINUX_x64, + ".tar.gz", + "gztar", + (f"kotlin-server-{DEFAULT_KOTLIN_LSP_VERSION}", "bin", "intellij-server"), + "2d99d8e198fbe4aa8f4481e37799724ce94803b4ea12a60b416040e3fcd7cc5e", + ), + ( + PlatformId.LINUX_arm64, + "-aarch64.tar.gz", + "gztar", + (f"kotlin-server-{DEFAULT_KOTLIN_LSP_VERSION}", "bin", "intellij-server"), + "2317831c6e5607d05b7ebc1da655330125ce0e3d66fbf24517dfce442debc14e", + ), + ( + PlatformId.OSX_x64, + ".sit", + "zip", + (f"kotlin-server-{DEFAULT_KOTLIN_LSP_VERSION}", "bin", "intellij-server"), + "17369fda97c85418ac24ab38a9df56b21522a3468dfe193832fe455c13920745", + ), + ( + PlatformId.OSX_arm64, + "-aarch64.sit", + "zip", + (f"kotlin-server-{DEFAULT_KOTLIN_LSP_VERSION}", "bin", "intellij-server"), + "6ba6021a706b21e64cef33f7e2b79f187c0910320722bb2d3ed05ad1115ec43f", + ), + ], + ) + def test_default_artifacts_match_jetbrains_release_matrix( + self, + tmp_path: Path, + platform_id: PlatformId, + asset_suffix: str, + archive_type: str, + launcher_parts: tuple[str, ...], + sha256: str, + ) -> None: + artifact = KotlinLanguageServer.DependencyProvider._create_artifact(DEFAULT_KOTLIN_LSP_VERSION, platform_id) + url = ( + f"https://download-cdn.jetbrains.com/language-server/kotlin-server/{DEFAULT_KOTLIN_LSP_VERSION}/" + f"kotlin-server-{DEFAULT_KOTLIN_LSP_VERSION}{asset_suffix}" + ) + + with patch("solidlsp.dependency_provider.FileUtils.download_and_extract_archive_verified") as download: + artifact.dependency.download_to(tmp_path) + + download.assert_called_once_with( + url, + str(tmp_path), + archive_type=archive_type, + expected_sha256=sha256, + allowed_hosts=KOTLIN_LSP_ALLOWED_HOSTS, + ) + assert artifact.launcher_parts == launcher_parts + + @pytest.mark.parametrize( + ("version", "platform_id", "url", "launcher_parts"), + [ + ( + "262.2310.0", + PlatformId.LINUX_x64, + "https://download-cdn.jetbrains.com/kotlin-lsp/262.2310.0/kotlin-lsp-262.2310.0-linux-x64.zip", + ("kotlin-lsp.sh",), + ), + ( + "262.4739.0", + PlatformId.LINUX_x64, + "https://download-cdn.jetbrains.com/kotlin-lsp/262.4739.0/kotlin-server-262.4739.0.tar.gz", + ("kotlin-server-262.4739.0", "bin", "intellij-server"), + ), + ( + "262.8190.0", + PlatformId.OSX_arm64, + "https://download-cdn.jetbrains.com/language-server/kotlin-server/262.8190.0/kotlin-server-262.8190.0-aarch64.sit", + ("kotlin-server-262.8190.0", "bin", "intellij-server"), + ), + ], + ) + def test_version_boundaries_select_the_published_layout( + self, + version: str, + platform_id: PlatformId, + url: str, + launcher_parts: tuple[str, ...], + ) -> None: + artifact = KotlinLanguageServer.DependencyProvider._create_artifact(version, platform_id) + + assert artifact.dependency.get_url() == url + assert artifact.launcher_parts == launcher_parts + + def test_custom_versions_skip_hash_lookup_by_design(self, tmp_path: Path) -> None: + artifact = KotlinLanguageServer.DependencyProvider._create_artifact("262.8190.1", PlatformId.LINUX_x64) + + with ( + patch( + "solidlsp.dependency_provider.DownloadedDependencyHashDatabase.get_instance", + side_effect=AssertionError("custom versions must not consult the pinned hash database"), + ), + patch("solidlsp.dependency_provider.FileUtils.download_and_extract_archive_verified") as download, + ): + artifact.dependency.download_to(tmp_path) + + download.assert_called_once_with( + "https://download-cdn.jetbrains.com/language-server/kotlin-server/262.8190.1/kotlin-server-262.8190.1.tar.gz", + str(tmp_path), + archive_type="gztar", + expected_sha256=None, + allowed_hosts=KOTLIN_LSP_ALLOWED_HOSTS, + ) + + def test_hash_updater_registers_frozen_and_default_artifacts(self) -> None: + updated_asset_names: list[str] = [] + + class RecordingUpdater: + def update(self, dependency: DownloadedDependency) -> None: + updated_asset_names.append(dependency.get_url().rsplit("/", 1)[-1]) + + with patch.object(DownloadedDependencyHashDatabase, "get_instance") as get_hash_database: + get_hash_database.return_value.update_context.return_value = nullcontext(RecordingUpdater()) + KotlinLanguageServer.DependencyProvider.update_dep_hashes() + + assert set(updated_asset_names) == { + "kotlin-lsp-261.13587.0-win-x64.zip", + "kotlin-lsp-261.13587.0-linux-x64.zip", + "kotlin-lsp-261.13587.0-linux-aarch64.zip", + "kotlin-lsp-261.13587.0-mac-x64.zip", + "kotlin-lsp-261.13587.0-mac-aarch64.zip", + "kotlin-server-262.9593.0.win.zip", + "kotlin-server-262.9593.0-aarch64.win.zip", + "kotlin-server-262.9593.0.tar.gz", + "kotlin-server-262.9593.0-aarch64.tar.gz", + "kotlin-server-262.9593.0.sit", + "kotlin-server-262.9593.0-aarch64.sit", + } + + @pytest.mark.parametrize( + ("settings", "platform_id", "relative_launcher", "expected_directory"), + [ + ( + {"kotlin_lsp_version": INITIAL_KOTLIN_LSP_VERSION}, + PlatformId.LINUX_x64, + ("kotlin-lsp.sh",), + "kotlin_language_server", + ), + ( + {}, + PlatformId.LINUX_arm64, + (f"kotlin-server-{DEFAULT_KOTLIN_LSP_VERSION}", "bin", "intellij-server"), + f"kotlin_language_server-{DEFAULT_KOTLIN_LSP_VERSION}", + ), + ], + ) + def test_installs_pinned_versions_in_compatible_directories( + self, + tmp_path: Path, + settings: dict[str, str], + platform_id: PlatformId, + relative_launcher: tuple[str, ...], + expected_directory: str, + ) -> None: + provider = _make_provider(tmp_path, settings) + + def fake_download( + _url: str, + target_path: str, + archive_type: str, + expected_sha256: str | None = None, + allowed_hosts: tuple[str, ...] | list[str] | None = None, + ) -> None: + del archive_type, expected_sha256, allowed_hosts + launcher = Path(target_path).joinpath(*relative_launcher) + launcher.parent.mkdir(parents=True, exist_ok=True) + launcher.write_text("#!/bin/sh\n", encoding="utf-8") + + with ( + patch( + "solidlsp.language_servers.kotlin_language_server.PlatformUtils.get_platform_id", + return_value=platform_id, + ), + patch( + "solidlsp.dependency_provider.FileUtils.download_and_extract_archive_verified", + side_effect=fake_download, + ), + ): + launcher_path = provider._get_or_install_core_dependency() + + assert launcher_path == str((tmp_path / expected_directory).joinpath(*relative_launcher)) + + @pytest.mark.parametrize( + ("platform_id", "modern_launcher", "other_os_launcher"), + [ + (PlatformId.LINUX_x64, "/path/to/intellij-server", "/path/to/intellij-server.exe"), + (PlatformId.WIN_x64, "/path/to/intellij-server.exe", "/path/to/intellij-server"), + ], + ) + def test_custom_launcher_arguments_use_the_active_os_name( + self, + tmp_path: Path, + platform_id: PlatformId, + modern_launcher: str, + other_os_launcher: str, + ) -> None: + modern_provider = _make_provider(tmp_path, {"ls_path": modern_launcher}) + other_os_provider = _make_provider(tmp_path, {"ls_path": other_os_launcher}) + + with patch( + "solidlsp.language_servers.kotlin_language_server.PlatformUtils.get_platform_id", + return_value=platform_id, + ): + assert modern_provider.create_launch_command() == [ + modern_launcher, + "--stdio", + "--system-path", + str(tmp_path / "project-cache" / "kotlin-lsp-system"), + ] + assert other_os_provider.create_launch_command() == [other_os_launcher, "--stdio"] + + def test_invalid_version_is_rejected_before_download(self) -> None: + with pytest.raises(ValueError, match="dot-separated integers"): + KotlinLanguageServer.DependencyProvider._create_artifact("latest", PlatformId.OSX_arm64) + + def test_unsupported_modern_platform_is_rejected(self) -> None: + with pytest.raises(ValueError, match="Unsupported platform"): + KotlinLanguageServer.DependencyProvider._create_artifact(DEFAULT_KOTLIN_LSP_VERSION, PlatformId.LINUX_x86) diff --git a/test/solidlsp/kotlin/test_kotlin_diagnostics.py b/test/solidlsp/kotlin/test_kotlin_diagnostics.py index c981add4..0d0d824c 100644 --- a/test/solidlsp/kotlin/test_kotlin_diagnostics.py +++ b/test/solidlsp/kotlin/test_kotlin_diagnostics.py @@ -2,9 +2,11 @@ import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import LanguageServerId +from test.conftest import language_server_tests_enabled from test.solidlsp.util.diagnostics import assert_file_diagnostics +@pytest.mark.skipif(not language_server_tests_enabled(LanguageServerId.KOTLIN), reason="Kotlin tests are disabled") @pytest.mark.kotlin class TestKotlinDiagnostics: @pytest.mark.parametrize("language_server", [LanguageServerId.KOTLIN], indirect=True) diff --git a/test/solidlsp/kotlin/test_kotlin_startup.py b/test/solidlsp/kotlin/test_kotlin_startup.py new file mode 100644 index 00000000..0f6a0345 --- /dev/null +++ b/test/solidlsp/kotlin/test_kotlin_startup.py @@ -0,0 +1,109 @@ +"""Regression tests for Kotlin LSP startup readiness handling.""" + +import logging +from pathlib import Path +from typing import Any, cast +from unittest.mock import Mock, patch + +import pytest + +from solidlsp.language_servers.kotlin_language_server import KotlinLanguageServer +from solidlsp.ls_config import LanguageServerConfig, LanguageServerId +from solidlsp.settings import SolidLSPSettings + +pytestmark = pytest.mark.kotlin + +_REQUIRED_CAPABILITIES = { + "textDocumentSync": 1, + "hoverProvider": True, + "completionProvider": {}, + "signatureHelpProvider": {}, + "definitionProvider": True, + "referencesProvider": True, + "documentSymbolProvider": True, + "workspaceSymbolProvider": True, + "semanticTokensProvider": {}, +} + + +def _make_server(tmp_path: Path) -> tuple[KotlinLanguageServer, Mock, Mock]: + settings = SolidLSPSettings( + solidlsp_dir=str(tmp_path / "global"), + project_data_path=str(tmp_path / "project"), + ls_specific_settings={LanguageServerId.KOTLIN: {}}, + ) + server_interface = Mock() + with patch.object(KotlinLanguageServer, "_create_language_server_interface", return_value=server_interface): + server = KotlinLanguageServer( + LanguageServerConfig(ls_id=LanguageServerId.KOTLIN), + str(tmp_path), + settings, + ) + + indexing_complete = Mock() + indexing_complete.wait.return_value = True + intellij_server_ready = Mock() + intellij_server_ready.wait.return_value = True + server_state = cast(Any, server) + server_state._indexing_complete = indexing_complete + server_state._intellij_server_ready = intellij_server_ready + return server, indexing_complete, intellij_server_ready + + +def _configure_initialize(server: KotlinLanguageServer, server_name: str | None) -> dict[str, Any]: + response: dict[str, Any] = {"capabilities": _REQUIRED_CAPABILITIES} + if server_name is not None: + response["serverInfo"] = {"name": server_name} + language_server_interface = cast(Any, server.server) + language_server_interface.send.initialize.return_value = response + return response + + +def test_modern_server_registers_and_waits_for_explicit_ready_notification(tmp_path: Path) -> None: + server, indexing_complete, intellij_server_ready = _make_server(tmp_path) + _configure_initialize(server, "IntelliJ Language Server by JetBrains") + events: list[str] = [] + notification_handlers: dict[str, Any] = {} + language_server_interface = cast(Any, server.server) + + def on_notification(method: str, handler: Any) -> None: + events.append(f"notification:{method}") + notification_handlers[method] = handler + + language_server_interface.on_notification.side_effect = on_notification + language_server_interface.start.side_effect = lambda: events.append("start") + language_server_interface.send.initialize.side_effect = lambda _params: ( + events.append("initialize") + or {"capabilities": _REQUIRED_CAPABILITIES, "serverInfo": {"name": "IntelliJ Language Server by JetBrains"}} + ) + + server._start_server() + + assert events.index("notification:intellij/ready-for-test") < events.index("start") < events.index("initialize") + intellij_server_ready.clear.assert_called_once_with() + intellij_server_ready.wait.assert_called_once_with(timeout=120.0) + indexing_complete.wait.assert_not_called() + + notification_handlers["intellij/ready-for-test"](None) + intellij_server_ready.set.assert_called_once_with() + + +def test_legacy_server_keeps_progress_event_wait(tmp_path: Path) -> None: + server, indexing_complete, intellij_server_ready = _make_server(tmp_path) + _configure_initialize(server, None) + + server._start_server() + + indexing_complete.wait.assert_called_once_with(timeout=120.0) + intellij_server_ready.wait.assert_not_called() + + +def test_modern_ready_timeout_warns_and_continues(tmp_path: Path, caplog: Any) -> None: + server, _indexing_complete, intellij_server_ready = _make_server(tmp_path) + _configure_initialize(server, "IntelliJ Language Server by JetBrains") + intellij_server_ready.wait.return_value = False + + with caplog.at_level(logging.WARNING): + server._start_server() + + assert "Kotlin LSP did not signal indexing completion within 120s; proceeding anyway" in caplog.text From 600d3b2fadd305321cba3d7fcb07251eb1e9f26f Mon Sep 17 00:00:00 2001 From: Merlin Hughes Date: Sat, 8 Aug 2026 10:28:04 -0400 Subject: [PATCH 4/9] Scala: answer Metals' build-import prompt (#1769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * scala: answer Metals' build-import prompt Metals asks, via window/showMessageRequest, whether to import a workspace it has not seen before. Serena registered no handler, so the request came back `method 'window/showMessageRequest' not handled on client` and Metals logged "Unexpected error initializing server" and gave up. No build server, no build target, and every cross-file query answered by the fallback presentation compiler — which cannot see past the file it is given. A client cannot decline the question instead: Metals' `disableShowMessageRequest` is server-side configuration, and its no-op fallback answers "Not now", which imports nothing either. Answering is the only route to a build. So answer the three prompts that lead to a build server, and dismiss anything else with `null` — a prompt we do not recognise is one whose consequences we cannot judge, and two of Metals' others offer to kill a process and to open a window. "Don't show again" is never chosen; Metals persists that in the project's own state. Since answering yes lets Metals run the project's build tool, `ls_specific_settings.scala.auto_import_build: false` declines instead. * scala: name the build-tool choice as a gap, and test the setting `Messages.ChooseBuildTool` ("Multiple build definitions found. Which would you like to use?") offers the build tools' own executable names, and precedes the import prompt wherever a workspace holds more than one kind of build. It is dismissed like anything else unrecognised, so such a workspace is still not imported — a deliberate choice, since picking one is a guess of a different order, but one the comment and the setup guide should admit to rather than claim every prompt on the path is answered. Also: cover the route from `ls_specific_settings` to `auto_import_build`, which nothing exercised, and correct the `ImportBuildChanges` message in the fixtures, which had the notification variant's trailing full stop rather than the request's own text. --- CHANGELOG.md | 7 ++ docs/01-about/020_programming-languages.md | 2 +- .../scala_setup_guide_for_serena.md | 13 ++- .../language_servers/scala_language_server.py | 56 +++++++++++ .../scala/test_scala_show_message_request.py | 94 +++++++++++++++++++ 5 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 test/solidlsp/scala/test_scala_show_message_request.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 316009cb..2861231d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,13 @@ Status of the `main` branch. Changes prior to the next official version change w - Language servers and their dependency providers now go through the `subprocess_run` helper instead of calling `subprocess.run` directly (e.g. for installation processes), so all such subprocesses get `stdin=DEVNULL` and can no longer interfere with the stdio MCP connection #1748 + - `scala`: Fix: Metals asks via `window/showMessageRequest` whether to import a workspace it has not + seen before, and Serena had no handler, so the request failed with `MethodNotFound` and Metals gave + up on the import ("Unexpected error initializing server"). No build server was ever connected and + every cross-file query fell back to the presentation compiler, which sees one file at a time, unless + the project happened to have been imported beforehand by another editor. The three prompts that lead + to a build server are now answered; anything else is dismissed, including the choice between several + build definitions in one workspace. `ls_specific_settings.scala.auto_import_build: false` opts out * Dashboard: - Fix: Serena PyPI version check triggered by callback on main thread could delay agent startup #1774 diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 00bfe33b..25d413a9 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -147,7 +147,7 @@ Some languages require additional installations or setup steps, as noted. * **Rust** (requires [rustup](https://rustup.rs/) - uses rust-analyzer from your toolchain) * **Scala** - (requires some [manual setup](../03-special-guides/scala_setup_guide_for_serena); uses Metals LSP) + (uses Metals LSP, which imports the build on first use — see the [setup guide](../03-special-guides/scala_setup_guide_for_serena)) * **SCSS / Sass / CSS** (experimental; requires Node.js + npm; uses [some-sass-language-server](https://github.com/wkillerud/some-sass) to handle `.scss`, `.sass`, and `.css`) diff --git a/docs/03-special-guides/scala_setup_guide_for_serena.md b/docs/03-special-guides/scala_setup_guide_for_serena.md index 1e4bebca..643e42ac 100644 --- a/docs/03-special-guides/scala_setup_guide_for_serena.md +++ b/docs/03-special-guides/scala_setup_guide_for_serena.md @@ -15,7 +15,16 @@ Install the following on your system and ensure they are available on `PATH`: - Serena uses `cs` if available; if only `coursier` exists, it will attempt to install `cs`. If neither is present, install Coursier first. --- -## Quick Start (Recommended: VS Code + Metals auto‑import) +## Quick Start + +Start Serena in your project root. Metals asks whether to import a workspace it has not seen before, and Serena answers that prompt for it — the build is imported (for sbt, by running `sbt bloopInstall`), `.bloop/` and `.metals/` are created, and cross-file navigation works from there. The first run therefore takes as long as your build takes to load. + +Set `auto_import_build: false` under `ls_specific_settings.scala` to decline instead; you then need to import the build yourself by one of the routes below, or cross-file queries will be served by the fallback presentation compiler and see only one file at a time. + +Serena answers three of Metals' prompts — “Import build”, “Import changes”, “Connect”. Anything else Metals asks is dismissed and logged, including “Multiple build definitions found. Which would you like to use?”, so a workspace holding more than one kind of build (say both an sbt and a Maven definition) still needs importing by one of the routes below. + +--- +## Importing the build yourself (VS Code) 1. Open your Scala project in VS Code. 2. When prompted by Metals, accept “Import build”. Wait until the import and initial compile/indexing finish. @@ -25,7 +34,7 @@ Install the following on your system and ensure they are available on `PATH`: This flow ensures the `.bloop/` and (if applicable) `.metals/` directories are created and your build is known to the build server that Metals uses. --- -## Manual Setup (No VS Code) +## Importing the build yourself (No VS Code) Follow these steps if you prefer a manual setup or you are not using VS Code: diff --git a/src/solidlsp/language_servers/scala_language_server.py b/src/solidlsp/language_servers/scala_language_server.py index 2a7d6330..771c6104 100644 --- a/src/solidlsp/language_servers/scala_language_server.py +++ b/src/solidlsp/language_servers/scala_language_server.py @@ -28,6 +28,22 @@ DEFAULT_METALS_VERSION = "1.6.4" DEFAULT_CLIENT_NAME = "Serena" DEFAULT_ON_STALE_LOCK = "auto-clean" DEFAULT_LOG_MULTI_INSTANCE_NOTICE = True +DEFAULT_AUTO_IMPORT_BUILD = True + +# The `window/showMessageRequest` actions Serena answers affirmatively: the ones standing between +# an un-imported workspace and a build server. Everything else is dismissed, since a prompt we do +# not recognise is one whose consequences we cannot judge — `Messages.OldBloopVersionRunning` +# offers to kill a process, `Messages.NewScalaProject` to open a window. "Don't show again" is +# never chosen: Metals persists that dismissal in the project's own state. +# (scala/meta/internal/metals/Messages.scala, at `ImportBuild`, `ImportBuildChanges`, +# `GenerateBspAndConnect`.) +# +# Not answered, deliberately: `Messages.ChooseBuildTool` ("Multiple build definitions found. Which +# would you like to use?"), whose actions are the build tools' own executable names. It precedes +# the import prompt in a workspace holding more than one kind of build, so such a workspace is +# still not imported — but choosing a build tool for the user is a guess of a different order, and +# Metals offers no way to say "whichever you would have picked". +BUILD_IMPORT_PROMPT_ACTIONS = ("Import build", "Import changes", "Connect") class StaleLockMode(Enum): @@ -43,6 +59,28 @@ class StaleLockMode(Enum): """Raise an error and refuse to start.""" +def choose_show_message_request_action(params: dict, auto_import_build: bool = DEFAULT_AUTO_IMPORT_BUILD) -> dict | None: + """ + Choose Serena's answer to a `window/showMessageRequest`, which Metals uses to ask whether to + import the build. + + :param params: the request's `ShowMessageRequestParams` + :param auto_import_build: whether to answer the build-import prompts affirmatively + :return: the action to select, or None to select none — which is what the LSP spec provides + for, and what leaving the request unanswered fails to say + """ + message = params.get("message", "") + actions = params.get("actions") or [] + if auto_import_build: + for action in actions: + if isinstance(action, dict) and action.get("title") in BUILD_IMPORT_PROMPT_ACTIONS: + log.info(f"Metals asked: {message!r}; answering {action['title']!r}") + return action + offered = [action.get("title") for action in actions if isinstance(action, dict)] + log.info(f"Metals asked: {message!r}; dismissing (offered: {offered})") + return None + + def _get_scala_settings(solidlsp_settings: SolidLSPSettings) -> dict[str, object]: """ Extract Scala-specific settings with defaults applied. @@ -52,6 +90,7 @@ def _get_scala_settings(solidlsp_settings: SolidLSPSettings) -> dict[str, object - client_name: str - on_stale_lock: StaleLockMode - log_multi_instance_notice: bool + - auto_import_build: bool """ from solidlsp.ls_config import LanguageServerId @@ -60,6 +99,7 @@ def _get_scala_settings(solidlsp_settings: SolidLSPSettings) -> dict[str, object "client_name": DEFAULT_CLIENT_NAME, "on_stale_lock": StaleLockMode.AUTO_CLEAN, "log_multi_instance_notice": DEFAULT_LOG_MULTI_INSTANCE_NOTICE, + "auto_import_build": DEFAULT_AUTO_IMPORT_BUILD, } if not solidlsp_settings.ls_specific_settings: @@ -80,6 +120,7 @@ def _get_scala_settings(solidlsp_settings: SolidLSPSettings) -> dict[str, object "client_name": scala_settings.get("client_name", DEFAULT_CLIENT_NAME), "on_stale_lock": on_stale_lock, "log_multi_instance_notice": scala_settings.get("log_multi_instance_notice", DEFAULT_LOG_MULTI_INSTANCE_NOTICE), + "auto_import_build": scala_settings.get("auto_import_build", DEFAULT_AUTO_IMPORT_BUILD), } @@ -100,6 +141,14 @@ class ScalaLanguageServer(SolidLanguageServer): metals_version: '1.6.4' # Client identifier sent to Metals (default: DEFAULT_CLIENT_NAME) client_name: 'Serena' + # Answer Metals' build-import prompts affirmatively, which lets it run the project's + # build tool (e.g. sbt bloopInstall). Set false to leave the build un-imported. + auto_import_build: true + + Build import: + Metals asks, via `window/showMessageRequest`, whether to import a workspace it has not + seen before; until that is answered it has no build server and so no build target, and + every cross-file query is served by the fallback presentation compiler. Multi-instance support: Metals uses H2 AUTO_SERVER mode (enabled by default) to support multiple @@ -116,6 +165,8 @@ class ScalaLanguageServer(SolidLanguageServer): # Check for stale locks before setting up dependencies (fail-fast) self._check_metals_db_status(repository_root_path, solidlsp_settings) + self._auto_import_build: bool = _get_scala_settings(solidlsp_settings)["auto_import_build"] # type: ignore[assignment] + scala_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) super().__init__( config, @@ -300,10 +351,15 @@ class ScalaLanguageServer(SolidLanguageServer): } return initialize_params + def _answer_show_message_request(self, params: dict) -> dict | None: + return choose_show_message_request_action(params, auto_import_build=self._auto_import_build) + def _start_server(self) -> None: """ Starts the Scala Language Server """ + self.server.on_request("window/showMessageRequest", self._answer_show_message_request) + log.info("Starting Scala server process") self.server.start() diff --git a/test/solidlsp/scala/test_scala_show_message_request.py b/test/solidlsp/scala/test_scala_show_message_request.py new file mode 100644 index 00000000..beb336b9 --- /dev/null +++ b/test/solidlsp/scala/test_scala_show_message_request.py @@ -0,0 +1,94 @@ +""" +Unit tests for Serena's answer to Metals' `window/showMessageRequest` prompts. +""" + +import pytest + +from solidlsp.language_servers.scala_language_server import ( + _get_scala_settings, + choose_show_message_request_action, +) +from solidlsp.ls_config import LanguageServerId +from solidlsp.settings import SolidLSPSettings + +# Metals' own prompts, with the message and action titles as its `Messages` object builds them +# (scala/meta/internal/metals/Messages.scala) and `type` as lsp4j serialises `MessageType.Info`. +IMPORT_BUILD = { + "message": "New sbt workspace detected, would you like to import the build?", + "type": 3, + "actions": [{"title": "Import build"}, {"title": "Not now"}, {"title": "Don't show again"}], +} +IMPORT_CHANGES = { + "message": "sbt build needs to be re-imported", + "type": 3, + "actions": [{"title": "Import changes"}, {"title": "Not now"}, {"title": "Don't show again"}], +} +GENERATE_BSP_AND_CONNECT = { + "message": "New sbt workspace detected, would you like connect to the Bloop build server?", + "type": 3, + "actions": [{"title": "Connect"}, {"title": "Not now"}, {"title": "Don't show again"}], +} +OLD_BLOOP_VERSION_RUNNING = { + "message": "Deprecated Bloop server is still running and is taking up resources, do you want to kill the process?", + "type": 3, + "actions": [{"title": "Yes"}, {"title": "Not now"}], +} +CHOOSE_BUILD_TOOL = { + "message": "Multiple build definitions found. Which would you like to use?", + "type": 3, + "actions": [{"title": "sbt"}, {"title": "mill"}], +} + + +@pytest.mark.scala +class TestChooseShowMessageRequestAction: + @pytest.mark.parametrize("params", [IMPORT_BUILD, IMPORT_CHANGES, GENERATE_BSP_AND_CONNECT]) + def test_build_import_prompts_are_answered_affirmatively(self, params: dict) -> None: + chosen = choose_show_message_request_action(params) + assert chosen is not None + assert chosen["title"] in ("Import build", "Import changes", "Connect") + + @pytest.mark.parametrize("params", [IMPORT_BUILD, IMPORT_CHANGES, GENERATE_BSP_AND_CONNECT]) + def test_auto_import_build_can_be_turned_off(self, params: dict) -> None: + assert choose_show_message_request_action(params, auto_import_build=False) is None + + def test_dont_show_again_is_never_chosen(self) -> None: + """Metals persists that dismissal in the project's own state.""" + params = {"message": "…", "actions": [{"title": "Don't show again"}, {"title": "Not now"}]} + assert choose_show_message_request_action(params) is None + + def test_an_unrecognised_prompt_is_dismissed(self) -> None: + """Answering "Yes" here would kill a process on the user's machine.""" + assert choose_show_message_request_action(OLD_BLOOP_VERSION_RUNNING) is None + + def test_a_prompt_with_no_actions_is_dismissed(self) -> None: + assert choose_show_message_request_action({"message": "just so you know"}) is None + assert choose_show_message_request_action({"message": "just so you know", "actions": None}) is None + + def test_malformed_actions_do_not_raise(self) -> None: + params = {"message": "…", "actions": ["Import build", None, {"title": "Import build"}]} + assert choose_show_message_request_action(params) == {"title": "Import build"} + + def test_choosing_between_build_tools_is_left_alone(self) -> None: + """Naming the build tool for the user is a guess of a different order; see the comment + on BUILD_IMPORT_PROMPT_ACTIONS. A workspace with several build definitions is therefore + still not imported. + """ + assert choose_show_message_request_action(CHOOSE_BUILD_TOOL) is None + + +@pytest.mark.scala +class TestAutoImportBuildSetting: + """`auto_import_build` has to reach the handler from the project configuration.""" + + @staticmethod + def setting(**scala_settings) -> object: + settings = SolidLSPSettings(ls_specific_settings={LanguageServerId.SCALA: scala_settings}) + return _get_scala_settings(settings)["auto_import_build"] + + def test_defaults_to_true(self) -> None: + assert self.setting() is True + assert _get_scala_settings(SolidLSPSettings())["auto_import_build"] is True + + def test_can_be_disabled(self) -> None: + assert self.setting(auto_import_build=False) is False From 7acd78bde0c5ee7fa8d1df32f7684d3006bfe198 Mon Sep 17 00:00:00 2001 From: Tyce Herrman Date: Thu, 30 Jul 2026 23:32:26 -0400 Subject: [PATCH 5/9] ci: exclude JDTLS workspaces from cache --- .github/workflows/pytest.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index e9b73699..4249aaa9 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -706,7 +706,11 @@ jobs: id: cache-language-servers uses: actions/cache@v3 with: - path: ~/.serena/language_servers/static + # JDTLS workspaces contain mutable Eclipse/Buildship runtime state. + # Cache the installed servers, but create each JDTLS workspace locally. + path: | + ~/.serena/language_servers/static + !~/.serena/language_servers/static/EclipseJDTLS/workspaces/** key: language-servers-${{ runner.os }}-${{ matrix.batch }}-v1 restore-keys: | language-servers-${{ runner.os }}-${{ matrix.batch }}- From 0968bae6ebbb18acd6105631c78c475cf881fc36 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 8 Aug 2026 13:52:09 +0200 Subject: [PATCH 6/9] Enclose sub-prompts in tags --- CHANGELOG.md | 1 + src/serena/agent.py | 22 ++++++++++++------- .../config/prompt_templates/system_prompt.yml | 2 -- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 256dd973..c89dea24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Status of the `main` branch. Changes prior to the next official version change w failed `--project-from-cwd` auto-detection (#1773). - ProjectServer: Configure trusted hosts (local hosts only) when listening on localhost - SerenaDashboardTrayManager: Configure trusted hosts (local hosts only) + - Enclose sub-prompts in XML-like tags to make scopes explicit * CLI: - Fix: `start-mcp-server` help text for `--project-from-cwd` falsely promised a fallback to the CWD, which was diff --git a/src/serena/agent.py b/src/serena/agent.py index 2b70389a..6dd9954c 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -956,14 +956,21 @@ class SerenaAgent: result[tool_class.get_name_from_cls()] = new_tool_class.get_name_from_cls() return result - def _format_prompt(self, prompt_template: str) -> str: + def _format_prompt(self, prompt_template: str, tag: str | None = None, name : str | None = None) -> str: template = JinjaTemplate(prompt_template) - return template.render( + text = template.render( available_tools=self._exposed_tools.tool_names, available_markers=self._exposed_tools.tool_marker_names, tool_names=self._prompt_tool_names_mapping, ) + if tag is not None: + open_tag = f"<{tag}" + (f' name="{name}"' if name is not None else "") + ">" + close_tag = f"" + text = f"{open_tag}{text}{close_tag}" + + return text + def create_connection_prompt(self) -> str: """ Returns the bootstrap prompt to be sent at MCP connection time. @@ -997,8 +1004,8 @@ class SerenaAgent: self._project_prompt_status.mark_mode_prompts_as_provided(session_id) system_prompt = self.prompt_factory.create_system_prompt( - context_system_prompt=self._format_prompt(self._context.prompt), - mode_system_prompts=[self._format_prompt(mode.prompt) for mode in relevant_modes], + context_system_prompt=self._format_prompt(self._context.prompt, tag="context"), + mode_system_prompts=[self._format_prompt(mode.prompt, tag="mode", name=mode.name) for mode in relevant_modes], available_tools=available_tools.tool_names, available_markers=available_markers, global_memories_list=global_memories_str, @@ -1007,7 +1014,7 @@ class SerenaAgent: # provide the project activation message if it hasn't yet been provided if self._active_project is not None and not self._project_prompt_status.is_project_activation_message_already_provided(session_id): - system_prompt += "\n\n" + self.get_project_activation_message(session_id) + system_prompt += "\n\n" + self.get_project_activation_message(session_id) + "" elif self._project_activation_error: system_prompt += f"\n\nNo project is active ({self._project_activation_error})." @@ -1054,14 +1061,13 @@ class SerenaAgent: # add prompts for modes that were dynamically activated by the project modes_with_prompts = self._project_prompt_status.get_modes_with_prompts_to_be_provided_for_project_activation(session_id) if modes_with_prompts: - msg += "\nNewly applicable mode instructions:" for mode in modes_with_prompts: - msg += f"\n{mode.prompt}" + msg += f'\n{mode.prompt}' self._project_prompt_status.mark_mode_prompts_as_provided(session_id) # add project-specific prompt if proj.project_config.initial_prompt: - msg += f"\nProject-specific instructions:\n {proj.project_config.initial_prompt}" + msg += f"\n{proj.project_config.initial_prompt}" self._project_prompt_status.mark_project_activation_message_as_provided(session_id) diff --git a/src/serena/resources/config/prompt_templates/system_prompt.yml b/src/serena/resources/config/prompt_templates/system_prompt.yml index 78a7ad18..99604bf1 100644 --- a/src/serena/resources/config/prompt_templates/system_prompt.yml +++ b/src/serena/resources/config/prompt_templates/system_prompt.yml @@ -49,10 +49,8 @@ prompts: The context and modes of operation are described below. These determine how to interact with your user and which kinds of interactions are expected of you. - Context description: {{ context_system_prompt }} - Modes descriptions: {% for prompt in mode_system_prompts %} {{ prompt }} {% endfor %} From 548aea28a620a181a28ae2283cfdf4278d4d205c Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 8 Aug 2026 13:56:19 +0200 Subject: [PATCH 7/9] Allow project initial prompt & newly activated dynamic mode prompts to use templating/variables Add helper method _format_prompt_tag --- CHANGELOG.md | 1 + src/serena/agent.py | 21 ++++++++++++--------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c89dea24..a5b0966e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Status of the `main` branch. Changes prior to the next official version change w - ProjectServer: Configure trusted hosts (local hosts only) when listening on localhost - SerenaDashboardTrayManager: Configure trusted hosts (local hosts only) - Enclose sub-prompts in XML-like tags to make scopes explicit + - Allow initial project prompts and project-specific newly activated modes to use templating * CLI: - Fix: `start-mcp-server` help text for `--project-from-cwd` falsely promised a fallback to the CWD, which was diff --git a/src/serena/agent.py b/src/serena/agent.py index 6dd9954c..545bc599 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -956,7 +956,12 @@ class SerenaAgent: result[tool_class.get_name_from_cls()] = new_tool_class.get_name_from_cls() return result - def _format_prompt(self, prompt_template: str, tag: str | None = None, name : str | None = None) -> str: + def _format_prompt_tag(self, text: str, tag: str, tag_name_attr: str | None = None) -> str: + open_tag = f"<{tag}" + (f' name="{tag_name_attr}"' if tag_name_attr is not None else "") + ">" + close_tag = f"" + return f"{open_tag}\n{text.strip()}\n{close_tag}" + + def _format_prompt(self, prompt_template: str, tag: str | None = None, tag_name_attr: str | None = None) -> str: template = JinjaTemplate(prompt_template) text = template.render( available_tools=self._exposed_tools.tool_names, @@ -965,9 +970,7 @@ class SerenaAgent: ) if tag is not None: - open_tag = f"<{tag}" + (f' name="{name}"' if name is not None else "") + ">" - close_tag = f"" - text = f"{open_tag}{text}{close_tag}" + text = self._format_prompt_tag(text, tag=tag, tag_name_attr=tag_name_attr) return text @@ -1005,7 +1008,7 @@ class SerenaAgent: system_prompt = self.prompt_factory.create_system_prompt( context_system_prompt=self._format_prompt(self._context.prompt, tag="context"), - mode_system_prompts=[self._format_prompt(mode.prompt, tag="mode", name=mode.name) for mode in relevant_modes], + mode_system_prompts=[self._format_prompt(mode.prompt, tag="mode", tag_name_attr=mode.name) for mode in relevant_modes], available_tools=available_tools.tool_names, available_markers=available_markers, global_memories_list=global_memories_str, @@ -1014,11 +1017,11 @@ class SerenaAgent: # provide the project activation message if it hasn't yet been provided if self._active_project is not None and not self._project_prompt_status.is_project_activation_message_already_provided(session_id): - system_prompt += "\n\n" + self.get_project_activation_message(session_id) + "" + system_prompt += "\n\n" + self._format_prompt_tag(self.get_project_activation_message(session_id), tag="active-project") elif self._project_activation_error: system_prompt += f"\n\nNo project is active ({self._project_activation_error})." - return system_prompt + return self._format_prompt_tag(system_prompt, tag="serena") def get_project_activation_message(self, session_id: str) -> str: """ @@ -1062,12 +1065,12 @@ class SerenaAgent: modes_with_prompts = self._project_prompt_status.get_modes_with_prompts_to_be_provided_for_project_activation(session_id) if modes_with_prompts: for mode in modes_with_prompts: - msg += f'\n{mode.prompt}' + msg += self._format_prompt(mode.prompt, tag="mode", tag_name_attr=mode.name) + "\n" self._project_prompt_status.mark_mode_prompts_as_provided(session_id) # add project-specific prompt if proj.project_config.initial_prompt: - msg += f"\n{proj.project_config.initial_prompt}" + msg += "\n" + self._format_prompt(proj.project_config.initial_prompt, tag="project-instructions") self._project_prompt_status.mark_project_activation_message_as_provided(session_id) From d3a8e9b5658a9721596142cbc7f2dedabb585859 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 8 Aug 2026 14:11:41 +0200 Subject: [PATCH 8/9] Improve project activation message --- src/serena/agent.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 545bc599..b9649fcf 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -1041,13 +1041,13 @@ class SerenaAgent: # provide basic project information (name, location, languages, encoding) if proj.is_newly_created: - msg = f"Created and activated a new project with name '{proj.project_name}' at {proj.project_root}. " + msg = f"Created and activated a new project with name '{proj.project_name}' at {proj.project_root}.\n" else: - msg = f"The project with name '{proj.project_name}' at {proj.project_root} is activated." + msg = f"The project with name '{proj.project_name}' at {proj.project_root} is activated.\n" if self._language_backend == LanguageBackend.LSP: - languages_str = ", ".join([lang.value for lang in proj.project_config.language_servers]) - msg += f"\nProgramming languages: {languages_str}." - msg += f"File encoding: {proj.project_config.encoding}." + language_servers_str = ", ".join([ls.value for ls in proj.project_config.language_servers]) + msg += f"Active language servers: {language_servers_str}.\n" + msg += f"File encoding: {proj.project_config.encoding}.\n" # add list of memories (if memories are enabled) include_memories = self._active_tools.contains_tool_class(ReadMemoryTool) @@ -1055,11 +1055,11 @@ class SerenaAgent: project_memories = proj.memory_manager.list_project_memories() if project_memories: msg += ( - f"\n{json.dumps(project_memories.to_dict())}\n" - + "Use the `read_memory` tool to read these memories later if they are relevant to the task." + f"{json.dumps(project_memories.to_dict())}\n" + + f"Use the `{ReadMemoryTool.get_name_from_cls()}` tool to read these memories later if they are relevant to the task.\n" ) elif self._active_tools.contains_tool_class(OnboardingTool): - msg += "Onboarding has not been performed yet, you should call Serena's `onboarding` tool now to set up project memories." + msg += f"Onboarding has not been performed yet. Ask the user whether to perform onboarding via the `{OnboardingTool.get_name_from_cls()}` tool.\n" # add prompts for modes that were dynamically activated by the project modes_with_prompts = self._project_prompt_status.get_modes_with_prompts_to_be_provided_for_project_activation(session_id) From ed401769b514c71200bd610022fce37ad1859eb6 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 8 Aug 2026 16:57:40 +0200 Subject: [PATCH 9/9] ProjectServer: Use general headless mode config overrides --- src/serena/project_server.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/serena/project_server.py b/src/serena/project_server.py index c2cbcd65..0debaac0 100644 --- a/src/serena/project_server.py +++ b/src/serena/project_server.py @@ -55,9 +55,7 @@ class ProjectServer: if port is None: port = self.PORT - serena_config = SerenaConfig.from_config_file() - serena_config.gui_log_window = False - serena_config.web_dashboard = False + serena_config = SerenaConfig.from_config_file().with_headless_mode_overrides() serena_config.language_backend = LanguageBackend.LSP self._agent = SerenaAgent(serena_config=serena_config)