From 51041e93f133ab6fb631e01c801835e854eae266 Mon Sep 17 00:00:00 2001 From: Robin Breathe Date: Tue, 16 Dec 2025 16:55:06 +0100 Subject: [PATCH 01/10] feat: CWD-based project detection for CLI-based agents through --project-from-cwd (#836) --- docs/02-usage/020_running.md | 3 +- docs/02-usage/030_clients.md | 12 ++++ src/serena/cli.py | 52 +++++++++++++++ test/serena/test_cli_project_commands.py | 82 +++++++++++++++++++++++- 4 files changed, 147 insertions(+), 2 deletions(-) diff --git a/docs/02-usage/020_running.md b/docs/02-usage/020_running.md index 231dd70c..896814db 100644 --- a/docs/02-usage/020_running.md +++ b/docs/02-usage/020_running.md @@ -150,6 +150,7 @@ to get a list of all available options. Some useful options include: * `--project `: specify the project to work on by name or path. + * `--project-from-cwd`: auto-detect project from current working directory (searches up for `.serena/project.yml` or `.git`, falls back to CWD). Intended only for CLI-based agents like Claude Code, Gemini and Codex. * `--context `: specify the operation [context](contexts) in which Serena shall operate * `--mode `: specify one or more [modes](modes) to enable (can be passed several times) * `--enable-web-dashboard `: enable or disable the web dashboard (enabled by default) @@ -227,4 +228,4 @@ Here are some examples of commands you might find useful: prompts edit-override prompt-name ``` -Explore the full set of commands and options using the CLI itself! \ No newline at end of file +Explore the full set of commands and options using the CLI itself! diff --git a/docs/02-usage/030_clients.md b/docs/02-usage/030_clients.md index 74b84a00..b474100a 100644 --- a/docs/02-usage/030_clients.md +++ b/docs/02-usage/030_clients.md @@ -88,6 +88,18 @@ Note: that Serena is configured to work on the current project from the get-go, following Claude Code's mode of operation. +Alternatively, use `--project-from-cwd` for user-level configuration that works across all projects: + +```shell +claude mcp add --scope user serena -- start-mcp-server --context=claude-code --project-from-cwd +``` + +This searches up from the current directory for `.serena/project.yml` or `.git` markers, +falling back to the current directory if neither is found. This makes it suitable for a +single global MCP configuration. The `--project-from-cwd` option is intended for CLI-based agents +(like Claude Code, Gemina or Codex) that invoke Serena from within project directories. + +Be sure to use at least `v1.0.52` of Claude Code (as earlier versions do not read MCP server system prompts upon startup). ### VSCode diff --git a/src/serena/cli.py b/src/serena/cli.py index e7cca2be..2a722ad3 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -5,6 +5,7 @@ import os import shutil import subprocess import sys +from collections.abc import Iterator from logging import Logger from pathlib import Path from typing import Any, Literal @@ -35,6 +36,42 @@ from solidlsp.util.subprocess_util import subprocess_kwargs log = logging.getLogger(__name__) + +def find_project_root(root: str | Path | None = None) -> str: + """Find project root by walking up from CWD. + + Checks for .serena/project.yml first (explicit Serena project), then .git (git root). + Falls back to CWD if no marker is found. + + :param root: If provided, constrains the search to this directory and below + (acts as a virtual filesystem root). Search stops at this boundary. + :return: absolute path to project root (falls back to CWD if no marker found) + """ + current = Path.cwd().resolve() + boundary = Path(root).resolve() if root is not None else None + + def ancestors() -> Iterator[Path]: + """Yield current directory and ancestors up to boundary.""" + yield current + for parent in current.parents: + yield parent + if boundary is not None and parent == boundary: + return + + # First pass: look for .serena + for directory in ancestors(): + if (directory / ".serena" / "project.yml").is_file(): + return str(directory) + + # Second pass: look for .git + for directory in ancestors(): + if (directory / ".git").exists(): # .git can be file (worktree) or dir + return str(directory) + + # Fall back to CWD + return str(current) + + # --------------------- Utilities ------------------------------------- @@ -136,9 +173,16 @@ class TopLevelCommands(AutoRegisteringGroup): ) @click.option("--trace-lsp-communication", type=bool, is_flag=False, default=None, help="Whether to trace LSP communication.") @click.option("--tool-timeout", type=float, default=None, help="Override tool execution timeout in config.") + @click.option( + "--project-from-cwd", + is_flag=True, + default=False, + help="Auto-detect project from current working directory (searches for .serena/project.yml or .git, falls back to CWD). Intended for CLI-based agents like Claude Code, Gemini and Codex.", + ) def start_mcp_server( project: str | None, project_file_arg: str | None, + project_from_cwd: bool | None, context: str, modes: tuple[str, ...], transport: Literal["stdio", "sse", "streamable-http"], @@ -169,6 +213,14 @@ class TopLevelCommands(AutoRegisteringGroup): log.info("Initializing Serena MCP server") log.info("Storing logs in %s", log_path) + + # Handle --project-from-cwd flag + if project_from_cwd: + if project is not None or project_file_arg is not None: + raise click.UsageError("--project-from-cwd cannot be used with --project or positional project argument") + project = find_project_root() + log.info("Auto-detected project root: %s", project) + project_file = project_file_arg or project factory = SerenaMCPFactorySingleProcess(context=context, project=project_file, memory_log_handler=memory_log_handler) server = factory.create_mcp_server( diff --git a/test/serena/test_cli_project_commands.py b/test/serena/test_cli_project_commands.py index ccdf79a3..893299e6 100644 --- a/test/serena/test_cli_project_commands.py +++ b/test/serena/test_cli_project_commands.py @@ -4,11 +4,12 @@ import os import shutil import tempfile import time +from pathlib import Path import pytest from click.testing import CliRunner -from serena.cli import ProjectCommands +from serena.cli import ProjectCommands, TopLevelCommands, find_project_root from serena.config.serena_config import ProjectConfig pytestmark = pytest.mark.filterwarnings("ignore::UserWarning") @@ -262,6 +263,85 @@ class TestProjectCreateHelper: ProjectCommands._create_project(temp_project_dir, None, ("python",)) +class TestFindProjectRoot: + """Tests for find_project_root helper with virtual chroot boundary.""" + + def test_finds_serena_from_subdirectory(self, temp_project_dir): + """Test that .serena/project.yml is found when searching from a subdirectory.""" + serena_dir = os.path.join(temp_project_dir, ".serena") + os.makedirs(serena_dir) + Path(os.path.join(serena_dir, "project.yml")).touch() + subdir = os.path.join(temp_project_dir, "src", "nested") + os.makedirs(subdir) + + original_cwd = os.getcwd() + try: + os.chdir(subdir) + result = find_project_root(root=temp_project_dir) + assert result is not None + assert os.path.samefile(result, temp_project_dir) + finally: + os.chdir(original_cwd) + + def test_serena_preferred_over_git(self, temp_project_dir): + """Test that .serena/project.yml takes priority over .git at the same level.""" + serena_dir = os.path.join(temp_project_dir, ".serena") + os.makedirs(serena_dir) + Path(os.path.join(serena_dir, "project.yml")).touch() + os.makedirs(os.path.join(temp_project_dir, ".git")) + + original_cwd = os.getcwd() + try: + os.chdir(temp_project_dir) + result = find_project_root(root=temp_project_dir) + assert result is not None + assert os.path.isdir(os.path.join(result, ".serena")) + assert os.path.samefile(result, temp_project_dir) + finally: + os.chdir(original_cwd) + + def test_git_used_as_fallback(self, temp_project_dir): + """Test that .git is found when no .serena exists.""" + os.makedirs(os.path.join(temp_project_dir, ".git")) + subdir = os.path.join(temp_project_dir, "src") + os.makedirs(subdir) + + original_cwd = os.getcwd() + try: + os.chdir(subdir) + result = find_project_root(root=temp_project_dir) + assert result is not None + assert os.path.samefile(result, temp_project_dir) + finally: + os.chdir(original_cwd) + + def test_falls_back_to_cwd_when_no_markers(self, temp_project_dir): + """Test falls back to CWD when no markers exist within boundary.""" + subdir = os.path.join(temp_project_dir, "src") + os.makedirs(subdir) + + original_cwd = os.getcwd() + try: + os.chdir(subdir) + result = find_project_root(root=temp_project_dir) + assert os.path.samefile(result, subdir) + finally: + os.chdir(original_cwd) + + +class TestProjectFromCwdMutualExclusivity: + """Tests for --project-from-cwd mutual exclusivity.""" + + def test_project_from_cwd_with_project_flag_fails(self, cli_runner): + """Test that --project-from-cwd with --project raises error.""" + result = cli_runner.invoke( + TopLevelCommands.start_mcp_server, + ["--project-from-cwd", "--project", "/some/path"], + ) + assert result.exit_code != 0 + assert "cannot be used with" in result.output + + if __name__ == "__main__": # For manual testing, you can run this file directly: # uv run pytest test/serena/test_cli_project_commands.py -v From f4ab15da985ed1c5d3ad119f1f1ca773a732f059 Mon Sep 17 00:00:00 2001 From: Michael Panchenko <35432522+MischaPanch@users.noreply.github.com> Date: Tue, 16 Dec 2025 17:06:35 +0100 Subject: [PATCH 02/10] Changelog [ci skip] --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4497495..e6228cd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ Status of the `main` branch. Changes prior to the next official version change w * Add monorepo/multi-language support * Project configuration files (`project.yml`) can now define multiple languages. Auto-detection adds only the most prominent language by default. - * Additional languages can be conveniently added via the Dashboard while a project is already activated. + * Additional languages can be conveniently added via the Dashboard while a project is already activated. + * The current project can be activated automatically even when the MCP configuration is global (through the --project-from-cwd flag) * Support overloaded symbols in `FindSymbolTool` and related tools * Name paths of overloaded symbols now include an index (e.g., `myOverloadedFunction[2]`) * Responses of the Java language server, which handled this in its own way, are now adapted accordingly, From 946b0e7d515b2266fefaf409f3a9092079977b09 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 16 Dec 2025 17:20:32 +0100 Subject: [PATCH 03/10] Revert "SerenaAgentMode.from_name: Allow loading an internal mode (if explicitly requested)" This reverts commit 7e138397d3e7e010d76f5cc11614aed3153c8a6d. --- src/serena/config/context_mode.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/src/serena/config/context_mode.py b/src/serena/config/context_mode.py index 6cc94350..f1f34fd9 100644 --- a/src/serena/config/context_mode.py +++ b/src/serena/config/context_mode.py @@ -70,7 +70,7 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): own_yaml_path = os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, fname) if not os.path.exists(own_yaml_path): raise FileNotFoundError( - f"Mode {name} not found in {SerenaPaths().user_modes_dir} or in {SERENAS_OWN_MODE_YAMLS_DIR}. " + f"Mode {name} not found in {SerenaPaths().user_modes_dir} or in {SERENAS_OWN_MODE_YAMLS_DIR}." f"Available modes:\n{cls.list_registered_mode_names()}" ) return own_yaml_path @@ -78,16 +78,8 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): @classmethod def from_name(cls, name: str) -> Self: """Load a registered Serena mode.""" - try: - mode_path = cls.get_path(name) - return cls.from_yaml(mode_path) - except: - try: - # Allow loading an internal mode if it is explicitly requested - return cls.from_name_internal(name) - except: - pass - raise + mode_path = cls.get_path(name) + return cls.from_yaml(mode_path) @classmethod def from_name_internal(cls, name: str) -> Self: From ddbe25f2663247b22e43ceb1c3545c7d5b38c13b Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 16 Dec 2025 16:25:31 +0100 Subject: [PATCH 04/10] ReplaceContentTool: Detect ambiguous regex match and raise an exception Fixes #845 Add tests for both regular and ambiguous replacements --- src/serena/tools/file_tools.py | 55 ++++++++++++-- .../repos/typescript/test_repo/ws_manager.js | 73 +++++++++++++++++++ test/serena/test_serena_agent.py | 73 ++++++++++++++++++- 3 files changed, 193 insertions(+), 8 deletions(-) create mode 100644 test/resources/repos/typescript/test_repo/ws_manager.js diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index ea52e9eb..fc3d3184 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -9,6 +9,7 @@ File and file system-related tools, specifically for import os import re from collections import defaultdict +from collections.abc import Callable from fnmatch import fnmatch from pathlib import Path from typing import Literal @@ -202,6 +203,45 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit): relative_path, needle, repl, mode=mode, allow_multiple_occurrences=allow_multiple_occurrences, require_not_ignored=True ) + @staticmethod + def _create_replacement_function(regex_pattern: str, repl_template: str, regex_flags: int) -> Callable[[re.Match], str]: + """ + Creates a replacement function that validates for ambiguity and handles backreferences. + + :param regex_pattern: The regex pattern being used for matching + :param repl_template: The replacement template with $!1, $!2, etc. for backreferences + :param regex_flags: The flags to use when searching (e.g., re.DOTALL | re.MULTILINE) + :return: A function suitable for use with re.sub() or re.subn() + """ + + def validate_and_replace(match: re.Match) -> str: + matched_text = match.group(0) + + # For multi-line match, check if the same pattern matches again within the already-matched text, + # rendering the match ambiguous. Typical pattern in the code: + # + # When matching + # .*? + # this will match the entire span above, while only the suffix may have been intended. + # (See test case for a practical example.) + # To detect this, we check if the same pattern matches again within the matched text, + if "\n" in matched_text and re.search(regex_pattern, matched_text[1:], flags=regex_flags): + raise ValueError( + "Match is ambiguous: the search pattern matches multiple overlapping occurrences. " + "Please revise the search pattern to be more specific to avoid ambiguity." + ) + + # Handle backreferences: replace $!1, $!2, etc. with actual matched groups + def expand_backreference(m: re.Match) -> str: + group_num = int(m.group(1)) + group_value = match.group(group_num) + return group_value if group_value is not None else m.group(0) + + result = re.sub(r"\$!(\d+)", expand_backreference, repl_template) + return result + + return validate_and_replace + def replace_content( self, relative_path: str, @@ -226,19 +266,22 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit): else: raise ValueError(f"Invalid mode: '{mode}', expected 'literal' or 'regex'.") + regex_flags = re.DOTALL | re.MULTILINE + # escape backslashes in repl repl = repl.replace("\\", "\\\\") - # convert $!1, $!2, etc. to \1, \2, etc. for backreferences - repl = re.sub(r"\$!(\d+)", "\\\\1", repl) + + # create replacement function with validation and backreference handling + repl_fn = self._create_replacement_function(regex, repl, regex_flags=regex_flags) # perform replacement - updated_content, n = re.subn(regex, repl, original_content, flags=re.DOTALL | re.MULTILINE) + updated_content, n = re.subn(regex, repl_fn, original_content, flags=regex_flags) if n == 0: - return f"Error: No matches of search expression found in file '{relative_path}'." + raise ValueError(f"Error: No matches of search expression found in file '{relative_path}'.") if not allow_multiple_occurrences and n > 1: - return ( - f"Error: Expression matches {n} occurrences in file '{relative_path}'. " + raise ValueError( + f"Expression matches {n} occurrences in file '{relative_path}'. " "Please revise the expression to be more specific or enable allow_multiple_occurrences if this is expected." ) context.set_updated_content(updated_content) diff --git a/test/resources/repos/typescript/test_repo/ws_manager.js b/test/resources/repos/typescript/test_repo/ws_manager.js new file mode 100644 index 00000000..5262cc58 --- /dev/null +++ b/test/resources/repos/typescript/test_repo/ws_manager.js @@ -0,0 +1,73 @@ +/** + * Dummy WebSocket manager class for testing ambiguous regex matching. + */ +class WebSocketManager { + constructor() { + this.ws = null; + this.statusElement = document.getElementById("status"); + } + + /** + * Connects to the WebSocket server. + */ + connectToServer() { + if (this.ws?.readyState === WebSocket.OPEN) { + this.updateConnectionStatus("Already connected", true); + return; + } + + try { + this.ws = new WebSocket("ws://localhost:4402"); + this.updateConnectionStatus("Connecting...", false); + + this.ws.onopen = () => { + console.log("Connected to server"); + this.updateConnectionStatus("Connected", true); + }; + + this.ws.onmessage = (event) => { + console.log("Received message:", event.data); + try { + const data = JSON.parse(event.data); + this.handleMessage(data); + } catch (error) { + console.error("Failed to parse message:", error); + this.updateConnectionStatus("Parse error", false); + } + }; + + this.ws.onclose = (event) => { + console.log("Connection closed"); + const message = event.reason || undefined; + this.updateConnectionStatus("Disconnected", false, message); + this.ws = null; + }; + + this.ws.onerror = (error) => { + console.error("WebSocket error:", error); + this.updateConnectionStatus("Connection error", false); + }; + } catch (error) { + console.error("Failed to connect to server:", error); + this.updateConnectionStatus("Connection failed", false); + } + } + + /** + * Updates the connection status display. + */ + updateConnectionStatus(status, isConnected, message) { + if (this.statusElement) { + const text = message ? `${status}: ${message}` : status; + this.statusElement.textContent = text; + this.statusElement.style.color = isConnected ? "green" : "red"; + } + } + + /** + * Handles incoming messages. + */ + handleMessage(data) { + console.log("Handling:", data); + } +} \ No newline at end of file diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 441cdf53..011386c1 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -3,13 +3,14 @@ import logging import os import time from collections.abc import Iterator +from contextlib import contextmanager import pytest from serena.agent import SerenaAgent from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig from serena.project import Project -from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, ReplaceSymbolBodyTool +from serena.tools import SUCCESS_RESULT, FindReferencingSymbolsTool, FindSymbolTool, ReplaceContentTool, ReplaceSymbolBodyTool from solidlsp.ls_config import Language from test.conftest import get_repo_path, language_tests_enabled from test.solidlsp import clojure as clj @@ -56,6 +57,24 @@ def serena_config(): return config +@contextmanager +def project_file_modification_context(serena_agent: SerenaAgent, relative_path: str) -> Iterator[None]: + """Context manager to modify a project file and revert the changes after use.""" + project = serena_agent.get_active_project() + file_path = os.path.join(project.project_root, relative_path) + + # Read the original content + with open(file_path, encoding=project.project_config.encoding) as f: + original_content = f.read() + + try: + yield + finally: + # Revert to the original content + with open(file_path, "w", encoding=project.project_config.encoding) as f: + f.write(original_content) + + @pytest.fixture def serena_agent(request: pytest.FixtureRequest, serena_config) -> Iterator[SerenaAgent]: language = Language(request.param) @@ -70,7 +89,8 @@ def serena_agent(request: pytest.FixtureRequest, serena_config) -> Iterator[Sere agent.execute_task(lambda: None) yield agent - # explicitly delete to free resources + + # explicitly shut down to free resources agent.shutdown(timeout=5) @@ -355,3 +375,52 @@ class TestSerenaAgent: replace_symbol_body_tool = serena_agent.get_tool(ReplaceSymbolBodyTool) with pytest.raises(ValueError, match=match_text): replace_symbol_body_tool.apply(name_path=name_path, relative_path=relative_path, body="") + + @pytest.mark.parametrize( + "serena_agent", + [ + pytest.param( + Language.TYPESCRIPT, + marks=pytest.mark.typescript, + ), + ], + indirect=["serena_agent"], + ) + def test_replace_content_regex(self, serena_agent: SerenaAgent): + """ + Tests a regex-based content replacement that has a unique match + """ + relative_path = "ws_manager.js" + with project_file_modification_context(serena_agent, relative_path): + replace_content_tool = serena_agent.get_tool(ReplaceContentTool) + result = replace_content_tool.apply( + needle=r'catch \(error\) \{\s*console.error\("Failed to connect.*?\}', + repl='catch(error) { console.log("Never mind"); }', + relative_path=relative_path, + mode="regex", + ) + assert result == SUCCESS_RESULT + + @pytest.mark.parametrize( + "serena_agent", + [ + pytest.param( + Language.TYPESCRIPT, + marks=pytest.mark.typescript, + ), + ], + indirect=["serena_agent"], + ) + def test_replace_content_regex_ambiguous(self, serena_agent: SerenaAgent): + """ + Tests that an ambiguous replacement where there is a larger match that internally contains + a smaller match triggers an exception + """ + replace_content_tool = serena_agent.get_tool(ReplaceContentTool) + with pytest.raises(ValueError, match="ambiguous"): + replace_content_tool.apply( + needle=r'catch \(error\) \{.*?this\.updateConnectionStatus\("Connection failed", false\);.*?\}', + repl='catch(error) { console.log("Never mind"); }', + relative_path="ws_manager.js", + mode="regex", + ) From 08d791e6370c6406e28021a89c2877c3ccdb1249 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 16 Dec 2025 20:35:07 +0100 Subject: [PATCH 05/10] Fix: With changes from #846, backslashes in repl must no longer be escaped --- src/serena/tools/file_tools.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index fc3d3184..09e7a0e3 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -268,9 +268,6 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit): regex_flags = re.DOTALL | re.MULTILINE - # escape backslashes in repl - repl = repl.replace("\\", "\\\\") - # create replacement function with validation and backreference handling repl_fn = self._create_replacement_function(regex, repl, regex_flags=regex_flags) From 6a3785aca688c09a99290e0aca86532476a862b2 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 16 Dec 2025 21:28:34 +0100 Subject: [PATCH 06/10] Add regression test for ReplaceContentTool with backslashes in needle/repl --- .../repos/typescript/test_repo/ws_manager.js | 1 + test/serena/test_serena_agent.py | 48 +++++++++++++++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/test/resources/repos/typescript/test_repo/ws_manager.js b/test/resources/repos/typescript/test_repo/ws_manager.js index 5262cc58..60173214 100644 --- a/test/resources/repos/typescript/test_repo/ws_manager.js +++ b/test/resources/repos/typescript/test_repo/ws_manager.js @@ -3,6 +3,7 @@ */ class WebSocketManager { constructor() { + console.log("WebSocketManager initializing\nStatus OK"); this.ws = null; this.statusElement = document.getElementById("status"); } diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 011386c1..761affc2 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -1,9 +1,11 @@ import json import logging import os +import re import time from collections.abc import Iterator from contextlib import contextmanager +from typing import Literal import pytest @@ -57,6 +59,13 @@ def serena_config(): return config +def read_project_file(project: Project, relative_path: str) -> str: + """Utility function to read a file from the project.""" + file_path = os.path.join(project.project_root, relative_path) + with open(file_path, encoding=project.project_config.encoding) as f: + return f.read() + + @contextmanager def project_file_modification_context(serena_agent: SerenaAgent, relative_path: str) -> Iterator[None]: """Context manager to modify a project file and revert the changes after use.""" @@ -64,8 +73,7 @@ def project_file_modification_context(serena_agent: SerenaAgent, relative_path: file_path = os.path.join(project.project_root, relative_path) # Read the original content - with open(file_path, encoding=project.project_config.encoding) as f: - original_content = f.read() + original_content = read_project_file(project, relative_path) try: yield @@ -386,7 +394,7 @@ class TestSerenaAgent: ], indirect=["serena_agent"], ) - def test_replace_content_regex(self, serena_agent: SerenaAgent): + def test_replace_content_regex_with_wildcard_ok(self, serena_agent: SerenaAgent): """ Tests a regex-based content replacement that has a unique match """ @@ -411,7 +419,39 @@ class TestSerenaAgent: ], indirect=["serena_agent"], ) - def test_replace_content_regex_ambiguous(self, serena_agent: SerenaAgent): + @pytest.mark.parametrize("mode", ["literal", "regex"]) + def test_replace_content_with_backslashes(self, serena_agent: SerenaAgent, mode: Literal["literal", "regex"]): + """ + Tests a content replacement where the needle and replacement strings contain backslashes. + This is a regression test for escaping issues. + """ + relative_path = "ws_manager.js" + needle = r'console.log("WebSocketManager initializing\nStatus OK");' + repl = r'console.log("WebSocketManager initialized\nAll systems go!");' + replace_content_tool = serena_agent.get_tool(ReplaceContentTool) + mode: Literal["literal", "regex"] + with project_file_modification_context(serena_agent, relative_path): + result = replace_content_tool.apply( + needle=re.escape(needle) if mode == "regex" else needle, + repl=repl, + relative_path=relative_path, + mode=mode, + ) + assert result == SUCCESS_RESULT + new_content = read_project_file(serena_agent.get_active_project(), relative_path) + assert repl in new_content + + @pytest.mark.parametrize( + "serena_agent", + [ + pytest.param( + Language.TYPESCRIPT, + marks=pytest.mark.typescript, + ), + ], + indirect=["serena_agent"], + ) + def test_replace_content_regex_with_wildcard_ambiguous(self, serena_agent: SerenaAgent): """ Tests that an ambiguous replacement where there is a larger match that internally contains a smaller match triggers an exception From d410aa9f905db98534ef21a4a8b560e7ea8cd67c Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 16 Dec 2025 18:08:59 +0100 Subject: [PATCH 07/10] SerenaConfig: Use configuration setting 'language_backend' instead of 'jetbrains' introducing enum LanguageBackend --- scripts/profile_tool_call.py | 1 - src/serena/agent.py | 11 ++-- src/serena/config/serena_config.py | 63 +++++++++++++++---- src/serena/dashboard.py | 3 +- .../resources/serena_config.template.yml | 27 +++----- 5 files changed, 70 insertions(+), 35 deletions(-) diff --git a/scripts/profile_tool_call.py b/scripts/profile_tool_call.py index e0e69981..8fe922a5 100644 --- a/scripts/profile_tool_call.py +++ b/scripts/profile_tool_call.py @@ -25,7 +25,6 @@ if __name__ == "__main__": serena_config = SerenaConfig.from_config_file() serena_config.log_level = logging.INFO - serena_config.jetbrains = False serena_config.gui_log_window_enabled = False serena_config.web_dashboard = False diff --git a/src/serena/agent.py b/src/serena/agent.py index e596dd1c..6f3c203b 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -18,7 +18,7 @@ from interprompt.jinja_template import JinjaTemplate from serena import serena_version from serena.analytics import RegisteredTokenCountEstimator, ToolUsageStats from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode -from serena.config.serena_config import SerenaConfig, ToolInclusionDefinition +from serena.config.serena_config import LanguageBackend, SerenaConfig, ToolInclusionDefinition from serena.dashboard import SerenaDashboardAPI from serena.ls_manager import LanguageServerManager from serena.project import Project @@ -226,7 +226,10 @@ class SerenaAgent: self._tool_usage_stats = ToolUsageStats(token_count_estimator) # log fundamental information - log.info(f"Starting Serena server (version={serena_version()}, process id={os.getpid()}, parent process id={os.getppid()})") + log.info( + f"Starting Serena server (version={serena_version()}, process id={os.getpid()}, parent process id={os.getppid()}; " + f"language backend={self.serena_config.language_backend.name})" + ) log.info("Configuration file: %s", self.serena_config.config_file_path) log.info("Available projects: {}".format(", ".join(self.serena_config.project_names))) log.info(f"Loaded tools ({len(self._all_tools)}): {', '.join([tool.get_name_from_cls() for tool in self._all_tools.values()])}") @@ -238,7 +241,7 @@ class SerenaAgent: tool_inclusion_definitions: list[ToolInclusionDefinition] = [self.serena_config, self._context] if self._context.single_project: tool_inclusion_definitions.extend(self._single_project_context_tool_inclusion_definitions(project)) - if self.serena_config.jetbrains: + if self.serena_config.language_backend == LanguageBackend.JETBRAINS: tool_inclusion_definitions.append(SerenaAgentMode.from_name_internal("jetbrains")) self._base_tool_set = ToolSet.default().apply(*tool_inclusion_definitions) @@ -514,7 +517,7 @@ class SerenaAgent: """ :return: whether this agent uses language server-based code analysis """ - return not self.serena_config.jetbrains + return self.serena_config.language_backend == LanguageBackend.LSP def _activate_project(self, project: Project) -> None: log.info(f"Activating {project.project_name} at {project.project_root}") diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 06e1c6d1..5c3b26fd 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -9,6 +9,7 @@ from collections.abc import Iterable from copy import deepcopy from dataclasses import dataclass, field from datetime import datetime +from enum import Enum from functools import cached_property from pathlib import Path from typing import TYPE_CHECKING, Any, Optional, Self, TypeVar @@ -106,6 +107,26 @@ def get_serena_managed_in_project_dir(project_root: str | Path) -> str: return os.path.join(project_root, SERENA_MANAGED_DIR_NAME) +class LanguageBackend(Enum): + LSP = "LSP" + """ + Use the language server protocol (LSP), spawning freely available language servers + via the SolidLSP library that is part of Serena + """ + JETBRAINS = "JetBrains" + """ + Use the Serena plugin in your JetBrains IDE. + (requires the plugin to be installed and the project being worked on to be open in your IDE) + """ + + @staticmethod + def from_str(backend_str: str) -> "LanguageBackend": + for backend in LanguageBackend: + if backend.value.lower() == backend_str.lower(): + return backend + raise ValueError(f"Unknown language backend '{backend_str}': valid values are {[b.value for b in LanguageBackend]}") + + @dataclass(kw_only=True) class ProjectConfig(ToolInclusionDefinition, ToStringMixin): project_name: str @@ -362,9 +383,10 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): the path to the configuration file to which updates of the configuration shall be saved; if None, the configuration is not saved to disk """ - jetbrains: bool = False + + language_backend: LanguageBackend = LanguageBackend.LSP """ - whether to apply JetBrains mode + the language backend to use for code understanding features """ token_count_estimator: str = RegisteredTokenCountEstimator.CHAR_COUNT.name @@ -446,7 +468,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): # load list of known projects instance.projects = [] - num_project_migrations = 0 + num_migrations = 0 for path in loaded_commented_yaml["projects"]: path = Path(path).resolve() if not path.exists() or (path.is_dir() and not (path / ProjectConfig.rel_path_to_project_yml()).exists()): @@ -456,7 +478,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): path = cls._migrate_out_of_project_config_file(path) if path is None: continue - num_project_migrations += 1 + num_migrations += 1 project_config = ProjectConfig.load(path) project = RegisteredProject( project_root=str(path), @@ -469,7 +491,21 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): field_name = key return loaded_commented_yaml.get(key, get_dataclass_default(SerenaConfig, field_name)) - # set other configuration parameters + # determine language backend + language_backend = get_dataclass_default(SerenaConfig, "language_backend") + if "language_backend" in loaded_commented_yaml: + backend_str = loaded_commented_yaml["language_backend"] + language_backend = LanguageBackend.from_str(backend_str) + else: + # backward compatibility (migrate Boolean field "jetbrains") + if "jetbrains" in loaded_commented_yaml: + num_migrations += 1 + if loaded_commented_yaml["jetbrains"]: + language_backend = LanguageBackend.JETBRAINS + del loaded_commented_yaml["jetbrains"] + instance.language_backend = language_backend + + # set other configuration parameters (primitive types) instance.gui_log_window_enabled = get_value_or_default("gui_log_window", "gui_log_window_enabled") instance.web_dashboard_listen_address = get_value_or_default("web_dashboard_listen_address") instance.log_level = loaded_commented_yaml.get("log_level", loaded_commented_yaml.get("gui_log_level", logging.INFO)) @@ -479,16 +515,13 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): instance.trace_lsp_communication = get_value_or_default("trace_lsp_communication") instance.excluded_tools = get_value_or_default("excluded_tools") instance.included_optional_tools = get_value_or_default("included_optional_tools") - instance.jetbrains = get_value_or_default("jetbrains") instance.token_count_estimator = get_value_or_default("token_count_estimator") instance.default_max_tool_answer_chars = get_value_or_default("default_max_tool_answer_chars") instance.ls_specific_settings = get_value_or_default("ls_specific_settings") # re-save the configuration file if any migrations were performed - if num_project_migrations > 0: - log.info( - f"Migrated {num_project_migrations} project configurations from legacy format to in-project configuration; re-saving configuration" - ) + if num_migrations > 0: + log.info("Legacy configuration was migrated; re-saving configuration file") instance.save() return instance @@ -591,9 +624,15 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): """ if self.config_file_path is None: return + assert self.loaded_commented_yaml is not None, "Cannot save configuration without loaded YAML" + loaded_original_yaml = deepcopy(self.loaded_commented_yaml) - # projects are unique absolute paths - # we also canonicalize them before saving + + # convert project objects into list of paths loaded_original_yaml["projects"] = sorted({str(project.project_root) for project in self.projects}) + + # convert language backend to string + loaded_original_yaml["language_backend"] = self.language_backend.value + save_yaml(self.config_file_path, loaded_original_yaml, preserve_comments=True) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 9002c3bf..55d8b87b 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -9,6 +9,7 @@ from pydantic import BaseModel from sensai.util import logging from serena.analytics import ToolUsageStats +from serena.config.serena_config import LanguageBackend from serena.constants import SERENA_DASHBOARD_DIR from serena.task_executor import TaskExecutor from serena.util.logging import MemoryLogHandler @@ -443,7 +444,7 @@ class SerenaDashboardAPI: available_modes=available_modes, available_contexts=available_contexts, available_memories=available_memories, - jetbrains_mode=self._agent.serena_config.jetbrains, + jetbrains_mode=self._agent.serena_config.language_backend == LanguageBackend.JETBRAINS, languages=languages, encoding=encoding, ) diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index 974fe7e5..d197991d 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -1,3 +1,12 @@ +language_backend: LSP +# the language backend to use for code understanding and manipulation. +# Possible values are: +# * LSP: Use the language server protocol (LSP), spawning freely available language servers +# via the SolidLSP library that is part of Serena. +# * JetBrains: Use the Serena plugin in your JetBrains IDE. +# (requires the plugin to be installed and the project being worked on to be open +# in your IDE). + gui_log_window: False # whether to open a graphical window with Serena's logs. # This is mainly supported on Windows and (partly) on Linux; not available on macOS. @@ -42,7 +51,6 @@ ls_specific_settings: {} # (same entry as in project.yml) to the options. # Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. # No documentation on options means no options are available. -# tool_timeout: 240 # timeout, in seconds, after which tool executions are terminated @@ -53,12 +61,6 @@ excluded_tools: [] included_optional_tools: [] # list of optional tools (which are disabled by default) to be included -jetbrains: False -# whether to enable JetBrains mode and use tools based on the Serena JetBrains IDE plugin -# instead of language server-based tools -# NOTE: The plugin is yet unreleased. This is for Serena developers only. - - default_max_tool_answer_chars: 150000 # Used as default for tools where the apply method has a default maximal answer length. # Even though the value of the max_answer_chars can be changed when calling the tool, it may make sense to adjust this default @@ -73,14 +75,5 @@ token_count_estimator: CHAR_COUNT # data files on first run), or ANTHROPIC_CLAUDE_SONNET_4 which will use the (free of cost) Anthropic API to # estimate the token count using the Claude Sonnet 4 tokenizer. - -# MANAGED BY SERENA, KEEP AT THE BOTTOM OF THE YAML AND DON'T EDIT WITHOUT NEED -# The list of registered projects. -# To add a project, within a chat, simply ask Serena to "activate the project /path/to/project" or, -# if the project was previously added, "activate the project ". -# By default, the project's name will be the name of the directory containing the project, but you may change it -# by editing the (auto-generated) project configuration file `/path/project/project/.serena/project.yml` file. -# If you want to maintain full control of the project configuration, create the project.yml file manually and then -# instruct Serena to activate the project by its path for first-time activation. -# NOTE: Make sure there are no name collisions in the names of registered projects. +# The list of registered project paths (updated automatically). projects: [] From f76db506f92ad6286968604979c5839a09cb25f4 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 16 Dec 2025 18:24:27 +0100 Subject: [PATCH 08/10] Add help texts for server parameters: host, port --- src/serena/cli.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/serena/cli.py b/src/serena/cli.py index 2a722ad3..b1c5475f 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -161,8 +161,16 @@ class TopLevelCommands(AutoRegisteringGroup): show_default=True, help="Transport protocol.", ) - @click.option("--host", type=str, default="0.0.0.0", show_default=True) - @click.option("--port", type=int, default=8000, show_default=True) + @click.option( + "--host", + type=str, + default="0.0.0.0", + show_default=True, + help="Listen address for the MCP server (when using corresponding transport).", + ) + @click.option( + "--port", type=int, default=8000, show_default=True, help="Listen port for the MCP server (when using corresponding transport)." + ) @click.option("--enable-web-dashboard", type=bool, is_flag=False, default=None, help="Override dashboard setting in config.") @click.option("--enable-gui-log-window", type=bool, is_flag=False, default=None, help="Override GUI log window setting in config.") @click.option( From 9d34347acc325f47c605be26ebf00538f9884c7c Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 16 Dec 2025 18:25:22 +0100 Subject: [PATCH 09/10] Allow to override language backend via CLI parameter --- src/serena/cli.py | 10 +++++++++- src/serena/mcp.py | 5 +++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/serena/cli.py b/src/serena/cli.py index b1c5475f..bc4af3e6 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -18,7 +18,7 @@ from tqdm import tqdm from serena.agent import SerenaAgent from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode -from serena.config.serena_config import ProjectConfig, SerenaConfig, SerenaPaths +from serena.config.serena_config import LanguageBackend, ProjectConfig, SerenaConfig, SerenaPaths from serena.constants import ( DEFAULT_CONTEXT, DEFAULT_MODES, @@ -154,6 +154,12 @@ class TopLevelCommands(AutoRegisteringGroup): show_default=True, help="Built-in mode names or paths to custom mode YAMLs.", ) + @click.option( + "--language-backend", + type=click.Choice([lb.value for lb in LanguageBackend]), + default=None, + help="Override the configured language backend.", + ) @click.option( "--transport", type=click.Choice(["stdio", "sse", "streamable-http"]), @@ -193,6 +199,7 @@ class TopLevelCommands(AutoRegisteringGroup): project_from_cwd: bool | None, context: str, modes: tuple[str, ...], + language_backend: str | None, transport: Literal["stdio", "sse", "streamable-http"], host: str, port: int, @@ -235,6 +242,7 @@ class TopLevelCommands(AutoRegisteringGroup): host=host, port=port, modes=modes, + language_backend=LanguageBackend.from_str(language_backend) if language_backend else None, enable_web_dashboard=enable_web_dashboard, enable_gui_log_window=enable_gui_log_window, log_level=log_level, diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 24c9bd38..f4e6646f 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -23,6 +23,7 @@ from serena.agent import ( SerenaConfig, ) from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode +from serena.config.serena_config import LanguageBackend from serena.constants import DEFAULT_CONTEXT, DEFAULT_MODES, SERENA_LOG_FORMAT from serena.tools import Tool from serena.util.exception import show_fatal_exception_safe @@ -251,6 +252,7 @@ class SerenaMCPFactory: host: str = "0.0.0.0", port: int = 8000, modes: Sequence[str] = DEFAULT_MODES, + language_backend: LanguageBackend | None = None, enable_web_dashboard: bool | None = None, enable_gui_log_window: bool | None = None, log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, @@ -263,6 +265,7 @@ class SerenaMCPFactory: :param host: The host to bind to :param port: The port to bind to :param modes: List of mode names or paths to mode files + :param language_backend: the language backend to use, overriding the configuration setting. :param enable_web_dashboard: Whether to enable the web dashboard. If not specified, will take the value from the serena configuration. :param enable_gui_log_window: Whether to enable the GUI log window. It currently does not work on macOS, and setting this to True will be ignored then. If not specified, will take the value from the serena configuration. @@ -286,6 +289,8 @@ class SerenaMCPFactory: config.trace_lsp_communication = trace_lsp_communication if tool_timeout is not None: config.tool_timeout = tool_timeout + if language_backend is not None: + config.language_backend = language_backend modes_instances = [SerenaAgentMode.load(mode) for mode in modes] self._instantiate_agent(config, modes_instances) From a7e1665752480bbbf2d8dd1d6a450930da574e47 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 11 Dec 2025 20:15:34 +0100 Subject: [PATCH 10/10] Try to detect usable display before attempting to open dashboard in browser Fixes #648 --- src/serena/agent.py | 14 +++++++++----- src/serena/util/gui.py | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 src/serena/util/gui.py diff --git a/src/serena/agent.py b/src/serena/agent.py index 394c14bb..28cedc7b 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -25,6 +25,7 @@ from serena.project import Project from serena.prompt_factory import SerenaPromptFactory from serena.task_executor import TaskExecutor from serena.tools import ActivateProjectTool, GetCurrentConfigTool, ReplaceContentTool, Tool, ToolMarker, ToolRegistry +from serena.util.gui import system_has_usable_display from serena.util.inspection import iter_subclasses from serena.util.logging import MemoryLogHandler from solidlsp.ls_config import Language @@ -278,11 +279,14 @@ class SerenaAgent: dashboard_url = f"http://{dashboard_host}:{port}/dashboard/index.html" log.info("Serena web dashboard started at %s", dashboard_url) if self.serena_config.web_dashboard_open_on_launch: - # open the dashboard URL in the default web browser (using a separate process to control - # output redirection) - process = multiprocessing.Process(target=self._open_dashboard, args=(dashboard_url,)) - process.start() - process.join(timeout=1) + if not system_has_usable_display(): + log.warning("Not opening the Serena web dashboard automatically because no usable display was detected.") + else: + # open the dashboard URL in the default web browser (using a separate process to control + # output redirection) + process = multiprocessing.Process(target=self._open_dashboard, args=(dashboard_url,)) + process.start() + process.join(timeout=1) # inform the GUI window (if any) if self._gui_log_viewer is not None: self._gui_log_viewer.set_dashboard_url(dashboard_url) diff --git a/src/serena/util/gui.py b/src/serena/util/gui.py new file mode 100644 index 00000000..f87236d4 --- /dev/null +++ b/src/serena/util/gui.py @@ -0,0 +1,22 @@ +import os +import platform + + +def system_has_usable_display() -> bool: + system = platform.system() + + # macOS and native Windows: assume display is available for desktop usage + if system == "Darwin" or system == "Windows": + return True + + # Other systems, assumed to be Unix-like (Linux, FreeBSD, Cygwin/MSYS, etc.): + # detect display availability since users may operate in CLI contexts + else: + # Check X11 or Wayland - if environment variables are set to non-empty values, assume display is usable + display = os.environ.get("DISPLAY", "") + wayland_display = os.environ.get("WAYLAND_DISPLAY", "") + + if display or wayland_display: + return True + + return False