diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..d359e4f7 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +MUST use the project-specific instructions from the `CLAUDE.md` file located in the project root. \ No newline at end of file diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index b2f1e58b..aa4a4834 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -91,7 +91,7 @@ see Serena's [memory on that](https://github.com/oraios/serena/blob/main/.serena As an alternative to language servers, the [Serena JetBrains Plugin](https://plugins.jetbrains.com/plugin/28946-serena/) leverages the powerful code analysis capabilities of JetBrains IDEs. The plugin naturally supports all programming languages and frameworks that are supported by JetBrains IDEs, -including IntelliJ IDEA, PyCharm, Android Studio, AppCode, WebStorm, PhpStorm, RubyMine, GoLand, AppCode, CLion, Rider, and others. +including IntelliJ IDEA, PyCharm, Android Studio, AppCode, WebStorm, PhpStorm, RubyMine, GoLand, AppCode, CLion, and others. When using the plugin, Serena connects to an instance of your JetBrains IDE via the plugin. For users who already work in a JetBrains IDE, this means Serena seamlessly integrates with the IDE instance you typically have open anyway, diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 1a62f6b0..68a99284 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -134,6 +134,47 @@ For advanced users, Serena's configuration can be further customized. The Serena user data directory (where configuration, language server files, logs, etc. are stored) defaults to `~/.serena`. You can change this location by setting the `SERENA_HOME` environment variable to your desired path. +### Language server specific settings (`ls_specific_settings`) + +`ls_specific_settings` lets you pass per-language, language-server-specific configuration. + +Structure: + +```yaml +ls_specific_settings: + : + # language-server-specific keys +``` + +Where to put it: +- Global default: `~/.serena/serena_config.yml` +- Per-project override: `/.serena/project.yml` (takes precedence over the global config for the same keys) +- There is currently no `serena-mcp-server` CLI option to set `ls_specific_settings`; use the config files above. + +#### Go (`gopls`) + +Serena forwards `ls_specific_settings.go.gopls_settings` to `gopls` as LSP `initializationOptions` when the Go language server is started. + +Example: enable build tags and set a build environment: + +```yaml +ls_specific_settings: + go: + gopls_settings: + buildFlags: + - "-tags=foo" + env: + GOOS: "linux" + GOARCH: "amd64" + CGO_ENABLED: "0" +``` + +Notes: +- To enable multiple tags, use `"-tags=foo,bar"`. +- `gopls_settings.env` values are strings. +- `GOFLAGS` (from the environment you start Serena in) may also affect the Go build context. Prefer `buildFlags` for tags. +- Build context changes are only picked up when `gopls` starts. After changing `gopls_settings` (or relevant env vars like `GOFLAGS`), restart the Serena process (or server) that hosts the Go language server, or use your client's "Restart language server" action if it causes `gopls` to restart. + ### Custom Prompts All of Serena's prompts can be fully customized. diff --git a/docs/02-usage/060_dashboard.md b/docs/02-usage/060_dashboard.md index eeeab1ca..9bb6406e 100644 --- a/docs/02-usage/060_dashboard.md +++ b/docs/02-usage/060_dashboard.md @@ -12,6 +12,9 @@ Serena comes with built-in tools for monitoring and managing the current session By default, it will be accessible at `http://localhost:24282/dashboard/index.html`, but a higher port may be used if the default port is unavailable/multiple instances are running. + **We recommend always enabling the dashboard**. If you don't want the browser to open automatically, + you can disable it while still keeping the dashboard running in the background (see below). + * the **GUI tool** (disabled by default) The GUI tool is a native application window which displays logs. @@ -26,7 +29,8 @@ For the dashboard, this can be disabled if desired (see below). ## Disabling Automatic Browser Opening If you prefer not to have the dashboard open automatically (e.g., to avoid focus stealing), you can disable it -by setting `web_dashboard_open_on_launch: False` in your `serena_config.yml`. +by setting `web_dashboard_open_on_launch: False` in your `serena_config.yml` or by passing `--open-web-dashboard False` +to `start-mcp-server` CLI command. When automatic opening is disabled, you can still access the dashboard by: * asking the LLM to "open the Serena dashboard", which will open the dashboard in your default browser diff --git a/src/serena/cli.py b/src/serena/cli.py index 217cb409..40d0fc92 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -36,6 +36,8 @@ from solidlsp.util.subprocess_util import subprocess_kwargs log = logging.getLogger(__name__) +_MAX_CONTENT_WIDTH = 100 + def find_project_root(root: str | Path | None = None) -> str: """Find project root by walking up from CWD. @@ -138,7 +140,7 @@ class TopLevelCommands(AutoRegisteringGroup): super().__init__(name="serena", help="Serena CLI commands. You can run ` --help` for more info on each command.") @staticmethod - @click.command("start-mcp-server", help="Starts the Serena MCP server.") + @click.command("start-mcp-server", help="Starts the Serena MCP server.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) @click.option("--project", "project", type=PROJECT_TYPE, default=None, help="Path or name of project to activate at startup.") @click.option("--project-file", "project", type=PROJECT_TYPE, default=None, help="[DEPRECATED] Use --project instead.") @click.argument("project_file_arg", type=PROJECT_TYPE, required=False, default=None, metavar="") @@ -177,8 +179,29 @@ class TopLevelCommands(AutoRegisteringGroup): @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( + "--enable-web-dashboard", + type=bool, + is_flag=False, + default=None, + help="Enable the web dashboard (overriding the setting in Serena's config). " + "It is recommended to always enable the dashboard. If you don't want the browser to open on startup, set open-web-dashboard to False. " + "For more information, see\nhttps://oraios.github.io/serena/02-usage/060_dashboard.html", + ) + @click.option( + "--enable-gui-log-window", + type=bool, + is_flag=False, + default=None, + help="Enable the gui log window (currently only displays logs; overriding the setting in Serena's config).", + ) + @click.option( + "--open-web-dashboard", + type=bool, + is_flag=False, + default=None, + help="Open Serena's dashboard in your browser after MCP server startup (overriding the setting in Serena's config).", + ) @click.option( "--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), @@ -204,6 +227,7 @@ class TopLevelCommands(AutoRegisteringGroup): host: str, port: int, enable_web_dashboard: bool | None, + open_web_dashboard: bool | None, enable_gui_log_window: bool | None, log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None, trace_lsp_communication: bool | None, @@ -244,6 +268,7 @@ class TopLevelCommands(AutoRegisteringGroup): modes=modes, language_backend=LanguageBackend.from_str(language_backend) if language_backend else None, enable_web_dashboard=enable_web_dashboard, + open_web_dashboard=open_web_dashboard, enable_gui_log_window=enable_gui_log_window, log_level=log_level, trace_lsp_communication=trace_lsp_communication, @@ -258,7 +283,9 @@ class TopLevelCommands(AutoRegisteringGroup): server.run(transport=transport) @staticmethod - @click.command("print-system-prompt", help="Print the system prompt for a project.") + @click.command( + "print-system-prompt", help="Print the system prompt for a project.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH} + ) @click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False) @click.option( "--log-level", @@ -309,7 +336,7 @@ class ModeCommands(AutoRegisteringGroup): super().__init__(name="mode", help="Manage Serena modes. You can run `mode --help` for more info on each command.") @staticmethod - @click.command("list", help="List available modes.") + @click.command("list", help="List available modes.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) def list() -> None: mode_names = SerenaAgentMode.list_registered_mode_names() max_len_name = max(len(name) for name in mode_names) if mode_names else 20 @@ -321,7 +348,7 @@ class ModeCommands(AutoRegisteringGroup): click.echo(name_descr_string) @staticmethod - @click.command("create", help="Create a new mode or copy an internal one.") + @click.command("create", help="Create a new mode or copy an internal one.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) @click.option( "--name", "-n", @@ -350,7 +377,7 @@ class ModeCommands(AutoRegisteringGroup): _open_in_editor(dest) @staticmethod - @click.command("edit", help="Edit a custom mode YAML file.") + @click.command("edit", help="Edit a custom mode YAML file.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) @click.argument("mode_name") def edit(mode_name: str) -> None: path = os.path.join(SerenaPaths().user_modes_dir, f"{mode_name}.yml") @@ -366,7 +393,7 @@ class ModeCommands(AutoRegisteringGroup): _open_in_editor(path) @staticmethod - @click.command("delete", help="Delete a custom mode file.") + @click.command("delete", help="Delete a custom mode file.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) @click.argument("mode_name") def delete(mode_name: str) -> None: path = os.path.join(SerenaPaths().user_modes_dir, f"{mode_name}.yml") @@ -386,7 +413,7 @@ class ContextCommands(AutoRegisteringGroup): ) @staticmethod - @click.command("list", help="List available contexts.") + @click.command("list", help="List available contexts.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) def list() -> None: context_names = SerenaAgentContext.list_registered_context_names() max_len_name = max(len(name) for name in context_names) if context_names else 20 @@ -398,7 +425,9 @@ class ContextCommands(AutoRegisteringGroup): click.echo(name_descr_string) @staticmethod - @click.command("create", help="Create a new context or copy an internal one.") + @click.command( + "create", help="Create a new context or copy an internal one.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH} + ) @click.option( "--name", "-n", @@ -427,7 +456,7 @@ class ContextCommands(AutoRegisteringGroup): _open_in_editor(dest) @staticmethod - @click.command("edit", help="Edit a custom context YAML file.") + @click.command("edit", help="Edit a custom context YAML file.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) @click.argument("context_name") def edit(context_name: str) -> None: path = os.path.join(SerenaPaths().user_contexts_dir, f"{context_name}.yml") @@ -443,7 +472,7 @@ class ContextCommands(AutoRegisteringGroup): _open_in_editor(path) @staticmethod - @click.command("delete", help="Delete a custom context file.") + @click.command("delete", help="Delete a custom context file.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) @click.argument("context_name") def delete(context_name: str) -> None: path = os.path.join(SerenaPaths().user_contexts_dir, f"{context_name}.yml") @@ -462,7 +491,9 @@ class SerenaConfigCommands(AutoRegisteringGroup): @staticmethod @click.command( - "edit", help="Edit serena_config.yml in your default editor. Will create a config file from the template if no config is found." + "edit", + help="Edit serena_config.yml in your default editor. Will create a config file from the template if no config is found.", + context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, ) def edit() -> None: serena_config = SerenaConfig.from_config_file() @@ -512,7 +543,7 @@ class ProjectCommands(AutoRegisteringGroup): return generated_conf @staticmethod - @click.command("create", help="Create a new Serena project configuration.") + @click.command("create", help="Create a new Serena project configuration.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) @click.argument("project_path", type=click.Path(exists=True, file_okay=False), default=os.getcwd()) @click.option("--name", type=str, default=None, help="Project name; defaults to directory name if not specified.") @click.option( @@ -538,7 +569,11 @@ class ProjectCommands(AutoRegisteringGroup): raise click.ClickException(str(e)) @staticmethod - @click.command("index", help="Index a project by saving symbols to the LSP cache. Auto-creates project.yml if it doesn't exist.") + @click.command( + "index", + help="Index a project by saving symbols to the LSP cache. Auto-creates project.yml if it doesn't exist.", + context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, + ) @click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False) @click.option("--name", type=str, default=None, help="Project name (only used if auto-creating project.yml).") @click.option( @@ -570,7 +605,9 @@ class ProjectCommands(AutoRegisteringGroup): ProjectCommands._index_project(project, log_level, timeout=timeout) @staticmethod - @click.command("index-deprecated", help="Deprecated alias for 'serena project index'.") + @click.command( + "index-deprecated", help="Deprecated alias for 'serena project index'.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH} + ) @click.argument("project", type=click.Path(exists=True), default=os.getcwd(), required=False) @click.option("--log-level", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), default="WARNING") @click.option("--timeout", type=float, default=10, help="Timeout for indexing a single file.") @@ -622,7 +659,11 @@ class ProjectCommands(AutoRegisteringGroup): ls_mgr.stop_all() @staticmethod - @click.command("is_ignored_path", help="Check if a path is ignored by the project configuration.") + @click.command( + "is_ignored_path", + help="Check if a path is ignored by the project configuration.", + context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, + ) @click.argument("path", type=click.Path(exists=False, file_okay=True, dir_okay=True)) @click.argument("project", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=os.getcwd()) def is_ignored_path(path: str, project: str) -> None: @@ -639,7 +680,11 @@ class ProjectCommands(AutoRegisteringGroup): click.echo(f"Path '{path}' IS {'ignored' if is_ignored else 'IS NOT ignored'} by the project configuration.") @staticmethod - @click.command("index-file", help="Index a single file by saving its symbols to the LSP cache.") + @click.command( + "index-file", + help="Index a single file by saving its symbols to the LSP cache.", + context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, + ) @click.argument("file", type=click.Path(exists=True, file_okay=True, dir_okay=False)) @click.argument("project", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=os.getcwd()) @click.option("--verbose", "-v", is_flag=True, help="Print detailed information about the indexed symbols.") @@ -672,7 +717,11 @@ class ProjectCommands(AutoRegisteringGroup): ls_mgr.stop_all() @staticmethod - @click.command("health-check", help="Perform a comprehensive health check of the project's tools and language server.") + @click.command( + "health-check", + help="Perform a comprehensive health check of the project's tools and language server.", + context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, + ) @click.argument("project", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=os.getcwd()) def health_check(project: str) -> None: """ @@ -827,6 +876,7 @@ class ToolCommands(AutoRegisteringGroup): @click.command( "list", help="Prints an overview of the tools that are active by default (not just the active ones for your project). For viewing all tools, pass `--all / -a`", + context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, ) @click.option("--quiet", "-q", is_flag=True) @click.option("--all", "-a", "include_optional", is_flag=True, help="List all tools, including those not enabled by default.") @@ -849,6 +899,7 @@ class ToolCommands(AutoRegisteringGroup): @click.command( "description", help="Print the description of a tool, optionally with a specific context (the latter may modify the default description).", + context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, ) @click.argument("tool_name", type=str) @click.option("--context", type=str, default=None, help="Context name or path to context file.") @@ -879,7 +930,9 @@ class PromptCommands(AutoRegisteringGroup): return os.path.join(templates_dir, prompt_yaml_name) @staticmethod - @click.command("list", help="Lists yamls that are used for defining prompts.") + @click.command( + "list", help="Lists yamls that are used for defining prompts.", context_settings={"max_content_width": _MAX_CONTENT_WIDTH} + ) def list() -> None: serena_prompt_yaml_names = [os.path.basename(f) for f in glob.glob(PROMPT_TEMPLATES_DIR_INTERNAL + "/*.yml")] for prompt_yaml_name in serena_prompt_yaml_names: @@ -890,7 +943,11 @@ class PromptCommands(AutoRegisteringGroup): click.echo(prompt_yaml_name) @staticmethod - @click.command("create-override", help="Create an override of an internal prompts yaml for customizing Serena's prompts") + @click.command( + "create-override", + help="Create an override of an internal prompts yaml for customizing Serena's prompts", + context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, + ) @click.argument("prompt_yaml_name") def create_override(prompt_yaml_name: str) -> None: """ @@ -908,7 +965,9 @@ class PromptCommands(AutoRegisteringGroup): _open_in_editor(user_prompt_yaml_path) @staticmethod - @click.command("edit-override", help="Edit an existing prompt override file") + @click.command( + "edit-override", help="Edit an existing prompt override file", context_settings={"max_content_width": _MAX_CONTENT_WIDTH} + ) @click.argument("prompt_yaml_name") def edit_override(prompt_yaml_name: str) -> None: """ @@ -925,7 +984,7 @@ class PromptCommands(AutoRegisteringGroup): _open_in_editor(user_prompt_yaml_path) @staticmethod - @click.command("list-overrides", help="List existing prompt override files") + @click.command("list-overrides", help="List existing prompt override files", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) def list_overrides() -> None: user_templates_dir = SerenaPaths().user_prompt_templates_dir os.makedirs(user_templates_dir, exist_ok=True) @@ -936,7 +995,7 @@ class PromptCommands(AutoRegisteringGroup): click.echo(file_path) @staticmethod - @click.command("delete-override", help="Delete a prompt override file") + @click.command("delete-override", help="Delete a prompt override file", context_settings={"max_content_width": _MAX_CONTENT_WIDTH}) @click.argument("prompt_yaml_name") def delete_override(prompt_yaml_name: str) -> None: """ diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 0165d1c4..fdc55656 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -56,6 +56,7 @@ class ResponseConfigOverview(BaseModel): jetbrains_mode: bool languages: list[str] encoding: str | None + current_client: str | None class ResponseAvailableLanguages(BaseModel): @@ -370,6 +371,7 @@ class SerenaDashboardAPI: def _get_config_overview(self) -> ResponseConfigOverview: from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode + from serena.tools.tools_base import Tool # Get active project info project = self._agent.get_active_project() @@ -491,6 +493,7 @@ class SerenaDashboardAPI: jetbrains_mode=self._agent.serena_config.language_backend == LanguageBackend.JETBRAINS, languages=languages, encoding=encoding, + current_client=Tool.get_last_tool_call_client_str(), ) def _shutdown(self) -> None: diff --git a/src/serena/mcp.py b/src/serena/mcp.py index cb886da4..2e885cf9 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -239,7 +239,9 @@ class SerenaMCPFactory: parameters=parameters, fn_metadata=func_arg_metadata, is_async=is_async, - context_kwarg=None, + # keep the value in sync with the kwarg name in Tool.apply_ex. The mcp sdk uses reflection to infer this + # when the tool is constructed via from_function (which is a bit crazy IMO, but well...) + context_kwarg="mcp_ctx", annotations=annotations, title=tool_title, ) @@ -274,6 +276,7 @@ class SerenaMCPFactory: language_backend: LanguageBackend | None = None, enable_web_dashboard: bool | None = None, enable_gui_log_window: bool | None = None, + open_web_dashboard: bool | None = None, log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, trace_lsp_communication: bool | None = None, tool_timeout: float | None = None, @@ -288,6 +291,8 @@ class SerenaMCPFactory: :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. + :param open_web_dashboard: Whether to open the web dashboard on launch. + If not specified, will take the value from the serena configuration. :param log_level: Log level. If not specified, will take the value from the serena configuration. :param trace_lsp_communication: Whether to trace the communication between Serena and the language servers. This is useful for debugging language server issues. @@ -301,6 +306,8 @@ class SerenaMCPFactory: config.web_dashboard = enable_web_dashboard if enable_gui_log_window is not None: config.gui_log_window_enabled = enable_gui_log_window + if open_web_dashboard is not None: + config.web_dashboard_open_on_launch = open_web_dashboard if log_level is not None: log_level = cast(Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], log_level.upper()) config.log_level = logging.getLevelNamesMapping()[log_level] diff --git a/src/serena/project.py b/src/serena/project.py index 968ad545..90a2a33e 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -230,15 +230,21 @@ class Project(ToStringMixin): def is_path_in_project(self, path: str | Path) -> bool: """ Checks if the given (absolute or relative) path is inside the project directory. - Note that even relative paths may be outside if they contain ".." or point to symlinks. - """ - path = Path(path) - _proj_root = Path(self.project_root) - if not path.is_absolute(): - path = _proj_root / path - path = path.resolve() - return path.is_relative_to(_proj_root) + Note: This is intended to catch cases where ".." segments would lead outside of the project directory, + but we intentionally allow symlinks, as the assumption is that they point to relevant project files. + """ + if not os.path.isabs(path): + path = os.path.join(self.project_root, path) + + # collapse any ".." or "." segments (purely lexically) + path = os.path.normpath(path) + + try: + return os.path.commonpath([self.project_root, path]) == self.project_root + except ValueError: + # occurs, in particular, if paths are on different drives on Windows + return False def relative_path_exists(self, relative_path: str) -> bool: """ diff --git a/src/serena/resources/dashboard/dashboard.js b/src/serena/resources/dashboard/dashboard.js index b7cd3c56..934fd8b9 100644 --- a/src/serena/resources/dashboard/dashboard.js +++ b/src/serena/resources/dashboard/dashboard.js @@ -619,6 +619,10 @@ class Dashboard { html += '
File Encoding:
'; html += '
' + (config.encoding || 'N/A') + '
'; + // Current Client info + html += '
Current Client:
'; + html += '
' + (config.current_client || 'None') + '
'; + html += ''; // Active tools - collapsible diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index 26158083..ddfd930c 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -27,6 +27,12 @@ web_dashboard: True # whether to open the Serena web dashboard (which will be accessible through your web browser) that # provides access to Serena's configuration and state as well as the current session logs. # The web dashboard is supported on all platforms. +# We strongly recommend to always enable this option, since the dashboard provides important information +# about the current state of Serena, including the configuration, the logs and tool usage statistics. +# If you don't want the browser window to pop up automatically, set the `web_dashboard_open_on_launch` to False +# (either here or through the corresponding flag in the `start-mcp-server` CLI command). +# You can then open the dashboard by asking your agent to do so (e.g., by saying "open the dashboard"), Serena provides +# a tool for this. # Further information: https://oraios.github.io/serena/02-usage/060_dashboard.html web_dashboard_listen_address: 127.0.0.1 @@ -34,7 +40,7 @@ web_dashboard_listen_address: 127.0.0.1 web_dashboard_open_on_launch: True # whether to open a browser window with the web dashboard when Serena starts (provided that web_dashboard -# is enabled). +# is enabled). See also the web_dashboard option. # If set to false, you can still open the dashboard manually by # a) telling the LLM to "open the dashboard" (provided that the open_dashboard tool is enabled) or by # b) manually navigating to http://localhost:24282/dashboard/ in your web browser (actual port diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index 16f3f517..de4f0c39 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -4,8 +4,10 @@ from abc import ABC from collections.abc import Iterable from dataclasses import dataclass from types import TracebackType -from typing import TYPE_CHECKING, Any, Protocol, Self, TypeVar +from typing import TYPE_CHECKING, Any, Protocol, Self, TypeVar, cast +from mcp import Implementation +from mcp.server.fastmcp import Context from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata from sensai.util import logging from sensai.util.string import dict_string @@ -115,6 +117,17 @@ class Tool(Component): # (which is use by the LLM, so a good description is important) # and to validate the tool call arguments. + _last_tool_call_client_str: str | None = None + """We can only get the client info from within a tool call. Each tool call will update this variable.""" + + @classmethod + def set_last_tool_call_client_str(cls, client_str: str | None) -> None: + cls._last_tool_call_client_str = client_str + + @classmethod + def get_last_tool_call_client_str(cls) -> str | None: + return cls._last_tool_call_client_str + @classmethod def get_name_from_cls(cls) -> str: name = cls.__name__ @@ -219,10 +232,21 @@ class Tool(Component): def is_active(self) -> bool: return self.agent.tool_is_active(self.__class__) - def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, **kwargs) -> str: # type: ignore + def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, mcp_ctx: Context | None = None, **kwargs) -> str: # type: ignore """ Applies the tool with logging and exception handling, using the given keyword arguments """ + if mcp_ctx is not None: + try: + client_params = mcp_ctx.session.client_params + if client_params is not None: + client_info = cast(Implementation, client_params.clientInfo) + client_str = client_info.title if client_info.title else client_info.name + " " + client_info.version + if client_str != self.get_last_tool_call_client_str(): + log.debug(f"Updating client info: {client_info}") + self.set_last_tool_call_client_str(client_str) + except BaseException as e: + log.info(f"Failed to get client info: {e}.") def task() -> str: apply_fn = self.get_apply_fn() diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index f7ed5c22..da34052b 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -746,6 +746,7 @@ class EclipseJDTLS(SolidLanguageServer): return def lang_status_handler(params: dict) -> None: + log.info("Language status update: %s", params) if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": self._service_ready_event.set() if params["type"] == "ProjectStatus": @@ -797,8 +798,22 @@ class EclipseJDTLS(SolidLanguageServer): ) assert intellicode_enable_result - self._service_ready_event.wait() - self._project_ready_event.wait() + if not self._service_ready_event.is_set(): + log.info("Waiting for service to be ready ...") + self._service_ready_event.wait() + log.info("Service is ready") + + if not self._project_ready_event.is_set(): + log.info("Waiting for project to be ready ...") + project_ready_timeout = 20 # Hotfix: Using timeout until we figure out why sometimes we don't get the project ready event + if self._project_ready_event.wait(timeout=project_ready_timeout): + log.info("Project is ready") + else: + log.warning("Did not receive project ready status within %d seconds; proceeding anyway", project_ready_timeout) + else: + log.info("Project is ready") + + log.info("Startup complete") @override def _request_hover(self, uri: str, line: int, column: int) -> ls_types.Hover | None: diff --git a/src/solidlsp/language_servers/gopls.py b/src/solidlsp/language_servers/gopls.py index afb628b9..419109ef 100644 --- a/src/solidlsp/language_servers/gopls.py +++ b/src/solidlsp/language_servers/gopls.py @@ -3,7 +3,7 @@ import os import pathlib import subprocess import threading -from typing import cast +from typing import Any, cast from overrides import override @@ -98,13 +98,12 @@ class Gopls(SolidLanguageServer): self.server_ready = threading.Event() self.request_id = 0 - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Go Language Server. """ root_uri = pathlib.Path(repository_absolute_path).as_uri() - initialize_params = { + initialize_params: dict = { "locale": "en", "capabilities": { "textDocument": { @@ -128,8 +127,83 @@ class Gopls(SolidLanguageServer): } ], } + + # Apply gopls-specific settings via initializationOptions + # Serena applies gopls settings at initialization time via initializationOptions + # (Access settings directly to avoid extra INFO logging from CustomLSSettings.get.) + gopls_settings = self._custom_settings.settings.get("gopls_settings") + if gopls_settings: + gopls_settings = self._validate_gopls_settings_dict(gopls_settings) + + # Validate JSON-serializability early: initializationOptions is sent over JSON-RPC. + import json + + self._canonical_json_or_raise(json, gopls_settings) + + # Log keys only (and at DEBUG) to avoid leaking sensitive values and to reduce startup noise. + log.debug("Applying gopls settings via initializationOptions: keys=%s", list(gopls_settings.keys())) + initialize_params["initializationOptions"] = gopls_settings + return cast(InitializeParams, initialize_params) + def _validate_gopls_settings_dict(self, gopls_settings: object) -> dict: + if not isinstance(gopls_settings, dict): + raise TypeError( + f"gopls_settings must be a dict, got {type(gopls_settings).__name__}. " + "Expected structure: {'buildFlags': ['-tags=foo'], 'env': {...}, ...}" + ) + + return gopls_settings + + def _canonical_json_or_raise(self, json_module: Any, data: object) -> str: + try: + return json_module.dumps(data, sort_keys=True, separators=(",", ":")) + except (TypeError, ValueError) as exc: + raise TypeError( + "gopls_settings must be JSON-serializable (json.dumps). Use JSON-compatible values (dict/list/str/int/float/bool/null) and prefer string keys." + ) from exc + + # Environment variables that influence Go build context and affect cached symbols. + _CACHE_CONTEXT_ENV_KEYS = ("GOFLAGS", "GOOS", "GOARCH", "CGO_ENABLED") + + @override + def _cache_context_fingerprint(self) -> str | None: + """ + Compute a deterministic fingerprint of the Go build context. + + The fingerprint includes gopls_settings and selected env vars that affect symbol discovery. + """ + import hashlib + import json + + gopls_settings_raw = self._custom_settings.settings.get("gopls_settings") + + gopls_settings: dict | None + if gopls_settings_raw is None: + gopls_settings = None + else: + # Treat an explicitly empty dict the same as not providing settings at all. + gopls_settings = self._validate_gopls_settings_dict(gopls_settings_raw) or None + + # Only include env vars that are set to a non-empty value. + env_subset: dict[str, str] = {} + for key in self._CACHE_CONTEXT_ENV_KEYS: + value = os.environ.get(key) + if value: + env_subset[key] = value + + # Return None only when BOTH settings and env subset are effectively empty. + if gopls_settings is None and not env_subset: + return None + + fingerprint_data: dict[str, object] = {"env": env_subset} + if gopls_settings is not None: + fingerprint_data["gopls_settings"] = gopls_settings + + canonical_json = self._canonical_json_or_raise(json, fingerprint_data) + + return hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()[:16] + def _start_server(self) -> None: """Start gopls server process""" diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 9dd49138..396ba533 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -1835,6 +1835,29 @@ class SolidLanguageServer(ABC): return defining_symbol + def _cache_context_fingerprint(self) -> Hashable | None: + """ + Return a fingerprint of any language-server-specific context that affects cached results. + + Subclasses may override to provide a deterministic value that changes when cached results + would be invalidated (e.g., build flags, environment variables). + + The value must be hashable and safe for inclusion in cache version tuples. + Returns None if no context-specific fingerprint is needed. + """ + return None + + def _document_symbols_cache_version(self) -> Hashable: + """ + Return the version for the document symbols cache. + + Incorporates cache context fingerprint if provided by the language server. + """ + fingerprint = self._cache_context_fingerprint() + if fingerprint is not None: + return (self.DOCUMENT_SYMBOL_CACHE_VERSION, fingerprint) + return self.DOCUMENT_SYMBOL_CACHE_VERSION + def _save_raw_document_symbols_cache(self) -> None: cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME @@ -1853,8 +1876,12 @@ class SolidLanguageServer(ABC): e, ) - def _raw_document_symbols_cache_version(self) -> tuple[int, Hashable]: - return (self.RAW_DOCUMENT_SYMBOLS_CACHE_VERSION, self._ls_specific_raw_document_symbols_cache_version) + def _raw_document_symbols_cache_version(self) -> tuple[Hashable, ...]: + base_version: tuple[Hashable, ...] = (self.RAW_DOCUMENT_SYMBOLS_CACHE_VERSION, self._ls_specific_raw_document_symbols_cache_version) + fingerprint = self._cache_context_fingerprint() + if fingerprint is not None: + return (*base_version, fingerprint) + return base_version def _load_raw_document_symbols_cache(self) -> None: cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME @@ -1910,7 +1937,7 @@ class SolidLanguageServer(ABC): log.info("Saving updated document symbols cache to %s", cache_file) try: - save_cache(str(cache_file), self.DOCUMENT_SYMBOL_CACHE_VERSION, self._document_symbols_cache) + save_cache(str(cache_file), self._document_symbols_cache_version(), self._document_symbols_cache) self._document_symbols_cache_is_modified = False except Exception as e: log.error( @@ -1924,7 +1951,7 @@ class SolidLanguageServer(ABC): if cache_file.exists(): log.info("Loading document symbols cache from %s", cache_file) try: - saved_cache = load_cache(str(cache_file), self.DOCUMENT_SYMBOL_CACHE_VERSION) + saved_cache = load_cache(str(cache_file), self._document_symbols_cache_version()) if saved_cache is not None: self._document_symbols_cache = saved_cache log.info(f"Loaded {len(self._document_symbols_cache)} entries from document symbols cache.") diff --git a/test/conftest.py b/test/conftest.py index 84aa80f3..e02b428c 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,10 +1,11 @@ import logging import os from collections.abc import Iterator +from contextlib import contextmanager from pathlib import Path +from typing import Any import pytest -from blib2to3.pgen2.parse import contextmanager from sensai.util.logging import configure from serena.config.serena_config import SerenaPaths @@ -38,7 +39,12 @@ def get_repo_path(language: Language) -> Path: def _create_ls( - language: Language, repo_path: str | None = None, ignored_paths: list[str] | None = None, trace_lsp_communication: bool = False + language: Language, + repo_path: str | None = None, + ignored_paths: list[str] | None = None, + trace_lsp_communication: bool = False, + ls_specific_settings: dict[Language, dict[str, Any]] | None = None, + solidlsp_dir: Path | None = None, ) -> SolidLanguageServer: ignored_paths = ignored_paths or [] if repo_path is None: @@ -47,20 +53,28 @@ def _create_ls( for spec in gitignore_parser.get_ignore_specs(): ignored_paths.extend(spec.patterns) config = LanguageServerConfig(code_language=language, ignored_paths=ignored_paths, trace_lsp_communication=trace_lsp_communication) + effective_solidlsp_dir = solidlsp_dir if solidlsp_dir is not None else SerenaPaths().serena_user_home_dir return SolidLanguageServer.create( config, repo_path, solidlsp_settings=SolidLSPSettings( - solidlsp_dir=SerenaPaths().serena_user_home_dir, project_data_relative_path=SERENA_MANAGED_DIR_NAME + solidlsp_dir=effective_solidlsp_dir, + project_data_relative_path=SERENA_MANAGED_DIR_NAME, + ls_specific_settings=ls_specific_settings or {}, ), ) @contextmanager def start_ls_context( - language: Language, repo_path: str | None = None, ignored_paths: list[str] | None = None, trace_lsp_communication: bool = False + language: Language, + repo_path: str | None = None, + ignored_paths: list[str] | None = None, + trace_lsp_communication: bool = False, + ls_specific_settings: dict[Language, dict[str, Any]] | None = None, + solidlsp_dir: Path | None = None, ) -> Iterator[SolidLanguageServer]: - ls = _create_ls(language, repo_path, ignored_paths, trace_lsp_communication) + ls = _create_ls(language, repo_path, ignored_paths, trace_lsp_communication, ls_specific_settings, solidlsp_dir) log.info(f"Starting language server for {language} {repo_path}") ls.start() try: diff --git a/test/resources/repos/go/test_repo/buildtags/foo.go b/test/resources/repos/go/test_repo/buildtags/foo.go new file mode 100644 index 00000000..43df3dc6 --- /dev/null +++ b/test/resources/repos/go/test_repo/buildtags/foo.go @@ -0,0 +1,8 @@ +//go:build foo +// +build foo + +package buildtags + +type XFoo struct { + Value int +} diff --git a/test/resources/repos/go/test_repo/buildtags/notfoo.go b/test/resources/repos/go/test_repo/buildtags/notfoo.go new file mode 100644 index 00000000..831a63b5 --- /dev/null +++ b/test/resources/repos/go/test_repo/buildtags/notfoo.go @@ -0,0 +1,8 @@ +//go:build !foo +// +build !foo + +package buildtags + +type XNotFoo struct { + Value int +} diff --git a/test/resources/repos/go/test_repo/go.mod b/test/resources/repos/go/test_repo/go.mod new file mode 100644 index 00000000..59623c28 --- /dev/null +++ b/test/resources/repos/go/test_repo/go.mod @@ -0,0 +1,3 @@ +module test_repo + +go 1.21 diff --git a/test/solidlsp/go/test_go_basic.py b/test/solidlsp/go/test_go_basic.py index eac511e9..cd7e8a86 100644 --- a/test/solidlsp/go/test_go_basic.py +++ b/test/solidlsp/go/test_go_basic.py @@ -1,4 +1,5 @@ import os +from pathlib import Path import pytest @@ -28,6 +29,166 @@ class TestGoLanguageServer: assert helper_symbol is not None, "Could not find 'Helper' function symbol in main.go" sel_start = helper_symbol["selectionRange"]["start"] refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) - assert any( - "main.go" in ref.get("relativePath", "") for ref in refs - ), "main.go should reference Helper (tried all positions in selectionRange)" + assert any("main.go" in ref.get("uri", "") for ref in refs), "Expected at least one reference result to point at main.go" + + +def _filter_symbols_by_name_in_repo(symbols: list | None, target_name: str, repo_name: str = "test_repo") -> list: + """Filter workspace symbols to exact name matches in the test repo.""" + if symbols is None: + return [] + return [s for s in symbols if s.get("name") == target_name and repo_name in s.get("location", {}).get("uri", "")] + + +@pytest.mark.go +class TestGoBuildTags: + """Tests for Go build tag/constraint support.""" + + def _copy_go_fixture(self, tmp_path: Path) -> Path: + """Copy Go fixture repo into tmp_path.""" + import shutil + + from test.conftest import get_repo_path + + fixture_path = get_repo_path(Language.GO) + target_path = tmp_path / "test_repo" + + shutil.copytree(fixture_path, target_path) + return target_path + + def test_default_context_contains_xnotfoo(self, tmp_path: Path) -> None: + """Default build context should contain XNotFoo and not XFoo.""" + from test.conftest import start_ls_context + + repo_path = self._copy_go_fixture(tmp_path) + + with start_ls_context(Language.GO, repo_path=str(repo_path), solidlsp_dir=tmp_path) as ls: + xnotfoo_symbols = ls.request_workspace_symbol("XNotFoo") + xfoo_symbols = ls.request_workspace_symbol("XFoo") + + xnotfoo_matches = _filter_symbols_by_name_in_repo(xnotfoo_symbols, "XNotFoo") + xfoo_matches = _filter_symbols_by_name_in_repo(xfoo_symbols, "XFoo") + + assert len(xnotfoo_matches) > 0, "Default context should contain XNotFoo" + assert len(xfoo_matches) == 0, "Default context should NOT contain XFoo" + + def test_foo_context_contains_xfoo(self, tmp_path: Path) -> None: + """Build context with -tags=foo should contain XFoo and not XNotFoo.""" + from test.conftest import start_ls_context + + repo_path = self._copy_go_fixture(tmp_path) + + ls_settings = { + Language.GO: { + "gopls_settings": { + "buildFlags": ["-tags=foo"], + }, + }, + } + + with start_ls_context(Language.GO, repo_path=str(repo_path), ls_specific_settings=ls_settings, solidlsp_dir=tmp_path) as ls: + xfoo_symbols = ls.request_workspace_symbol("XFoo") + xnotfoo_symbols = ls.request_workspace_symbol("XNotFoo") + + xfoo_matches = _filter_symbols_by_name_in_repo(xfoo_symbols, "XFoo") + xnotfoo_matches = _filter_symbols_by_name_in_repo(xnotfoo_symbols, "XNotFoo") + + assert len(xfoo_matches) > 0, "Foo context should contain XFoo" + assert len(xnotfoo_matches) == 0, "Foo context should NOT contain XNotFoo" + + def test_disk_cache_is_invalidated_on_build_context_switch(self, tmp_path: Path) -> None: + """Go build context switches must not reuse persisted SolidLSP document-symbol caches.""" + import pickle + + from test.conftest import start_ls_context + + repo_path = self._copy_go_fixture(tmp_path) + + ls_settings_foo = { + Language.GO: { + "gopls_settings": { + "buildFlags": ["-tags=foo"], + }, + }, + } + + main_go = os.path.join("main.go") + + def _assert_caches_loaded_and_clean(ls: SolidLanguageServer) -> None: + # White-box assertions: SolidLanguageServer currently has no public API to verify that + # caches were loaded from disk vs created lazily on first request. + assert ls._raw_document_symbols_cache, "Expected raw document-symbol cache to load from disk" + assert ls._document_symbols_cache, "Expected document-symbol cache to load from disk" + assert not ls._raw_document_symbols_cache_is_modified + assert not ls._document_symbols_cache_is_modified + + def _assert_caches_empty(ls: SolidLanguageServer) -> None: + assert ls._raw_document_symbols_cache == {} + assert ls._document_symbols_cache == {} + + def _assert_caches_modified(ls: SolidLanguageServer) -> None: + assert ls._raw_document_symbols_cache_is_modified + assert ls._document_symbols_cache_is_modified + + # Run 1 (default context): populate caches and persist them to disk. + with start_ls_context(Language.GO, repo_path=str(repo_path), solidlsp_dir=tmp_path) as ls_default: + _ = ls_default.request_document_symbols(main_go) + + default_raw_cache_version = ls_default._raw_document_symbols_cache_version() + default_doc_cache_version = ls_default._document_symbols_cache_version() + + ls_default.save_cache() + cache_dir = ls_default.cache_dir + + cache_files = [p for p in cache_dir.rglob("*") if p.is_file()] + assert cache_files, f"Expected SolidLSP to create cache artifacts under {cache_dir}" + + versioned_cache_files: list[tuple[Path, object]] = [] + for p in cache_files: + try: + with p.open("rb") as f: + data = pickle.load(f) + except Exception: + continue + if isinstance(data, dict) and "__cache_version" in data: + versioned_cache_files.append((p, data["__cache_version"])) + + assert versioned_cache_files, f"Expected at least one SolidLSP cache file with a __cache_version under {cache_dir}" + saved_versions = {v for _, v in versioned_cache_files} + assert ( + default_raw_cache_version in saved_versions or default_doc_cache_version in saved_versions + ), "Expected at least one persisted cache to match the default-context cache version" + + # Run 2 (default context again): prove that persisted caches are actually loaded and used. + with start_ls_context(Language.GO, repo_path=str(repo_path), solidlsp_dir=tmp_path) as ls_default_again: + assert ls_default_again.cache_dir == cache_dir + + _assert_caches_loaded_and_clean(ls_default_again) + + _ = ls_default_again.request_document_symbols(main_go) + + # A cache hit should not mark caches as modified. + assert not ls_default_again._raw_document_symbols_cache_is_modified + assert not ls_default_again._document_symbols_cache_is_modified + + # Run 3 (foo context): the same on-disk cache directory exists, but MUST be treated as stale. + with start_ls_context( + Language.GO, + repo_path=str(repo_path), + ls_specific_settings=ls_settings_foo, + solidlsp_dir=tmp_path, + ) as ls_foo: + assert ls_foo.cache_dir == cache_dir + + foo_raw_cache_version = ls_foo._raw_document_symbols_cache_version() + foo_doc_cache_version = ls_foo._document_symbols_cache_version() + + assert foo_raw_cache_version != default_raw_cache_version + assert foo_doc_cache_version != default_doc_cache_version + + # Different build context => persisted caches must not be loaded. + _assert_caches_empty(ls_foo) + + _ = ls_foo.request_document_symbols(main_go) + + # A cache miss should repopulate and mark caches modified. + _assert_caches_modified(ls_foo)