Merge branch 'refs/heads/main' into cleanup/remove_set_modes

This commit is contained in:
Michael Panchenko committed 2026-04-25 16:56:48 +02:00
commit 0a07b83ccd
36 files changed
+1399 -299

No files matched your search

+15 -1
View File
@@ -25,12 +25,20 @@ Status of the `main` branch. Changes prior to the next official version change w
- Project server:
- `query_project`: Support use of project root instead of project name #1388
- `list_queryable_projects`: Return both project names and project roots
- Fix: `search_for_pattern` tool returned 1-based line numbers (in contrast to all other tools); cause: implementation of `text_utils.search_text`
- Serena's system prompt (a.k.a. the 'Serena Instructions Manual') is now provided lazily.
At MCP connection time, only a one-sentence bootstrap prompt is provided.
The `initial_instructions` tool provides the full prompt on demand, keeping the initial context lean.
- Add `serena_info` tool for on-demand retrieval of usage information
* JetBrains:
- `Move` and `SafeDelete` tools: transform empty string to None (counteracts client errors)
- Add `debug` tool: The agent can set breakpoints, inspect variables, evaluate expressions and control execution flow
by directly interacting with the IDE's debugger, using a REPL-style interface for maximum flexibility.
- `move` and `safe_delete` tools: transform empty string to None (counteracts client errors)
* Dependencies:
- `pywebview`: Switch back to official release (new version 6.2) #1253
- `mcp`: Update from `1.26.0` to `1.27.0`
* Evaluations:
- Added new evaluations for Junie Plugin with Opus 4.6 and GLM 5.1 in Claude Code.
@@ -39,7 +47,13 @@ Status of the `main` branch. Changes prior to the next official version change w
- Fix: clangd capability checks now tolerate valid initialize response shape differences and invalidate cached C++ document symbols when clangd/compile commands context changes #1359
- Fix: `rename_symbol` for Vue files now correctly propagates edits to the TypeScript server, enabling cross-file renames in `.vue` files
- Fix: Lean4 stale cache — empty document symbol responses (returned before `lake build` completes) are no longer persisted, preventing symbols from being permanently hidden #1356
- Add JSON language server support via `vscode-json-languageserver` (experimental) #1391
- Fix: Elixir/Expert deadlock on startup — Expert's build pipeline requires a `textDocument/didOpen` notification to start; Serena now opens `mix.exs` immediately after `initialized` so Expert begins compiling instead of waiting indefinitely #1397
Dashboard:
- Add configurable dashboard interface mode (new global configuration setting `web_dashboard_interface`):
Three modes (browser, native app with tray, tray manager for aggregating multiple instances) are supported, depending on the OS
- Fix: Memory leaks in frontend when using Chromium-based browsers/Windows webview #1389
# v1.1.2 (2026-04-14)
+8 -2
View File
@@ -14,7 +14,7 @@
<br>
* Serena provides essential **semantic code retrieval, editing and refactoring tools** that are akin to an IDE's capabilities,
* Serena provides essential **semantic code retrieval, editing, refactoring and debugging tools** that are akin to an IDE's capabilities,
operating at the symbol level and exploiting relational structure.
* It integrates with any client/LLM via the model context protocol (**MCP**).
@@ -110,7 +110,7 @@ Serena incorporates a powerful abstraction layer for the integration of language
The underlying language servers are typically open-source projects or at least freely available for use.
When using Serena's language server backend, we provide **support for over 40 programming languages**, including
AL, Ansible, Bash, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GLSL, Go, Groovy, Haskell, Haxe, HLSL, Java, JavaScript, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig.
AL, Ansible, Bash, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GLSL, Go, Groovy, Haskell, Haxe, HLSL, Java, JavaScript, JSON, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig.
### The Serena JetBrains Plugin
@@ -171,6 +171,12 @@ Serena's symbolic editing tools are less error-prone and much more token-efficie
| insert before symbol | yes | yes |
| safe delete | yes | yes |
### Interactive Debugging
Exclusive to the JetBrains plugin, Serena supports a highly general debugging tool,
which allows an agent to set breakpoints, inspect variables, evaluate expressions and control execution flow
via a persistent REPL-style interface.
### Basic Features
Beyond its semantic capabilities, Serena includes a set of basic utilities for completeness.
@@ -115,6 +115,8 @@ Some languages require additional installations or setup steps, as noted.
* **Vue**
(3.x with TypeScript; requires Node.js v18+ and npm; supports .vue Single File Components with monorepo detection)
* **YAML**
* **JSON**
(experimental; must be explicitly added to the languages list; requires Node.js and npm)
* **Zig**
(requires installation of ZLS - Zig Language Server)
+2
View File
@@ -43,6 +43,8 @@ There are multiple features that are only available when using the JetBrains plu
* **Enhanced retrieval & refactoring capabilities**: The plugin adds additional [tools](../01-about/035_tools) (e.g. type
hierarchy retrieval, move, find declaration, inline symbol, etc.)
and transforms the underlying mechanisms of shared tools to build upon the IDE's capabilities.
* **Interactive debugging**: The agent can set breakpoints, inspect variables, evaluate expressions and control execution flow
by directly interacting with the IDE's debugger, using a REPL-style interface for maximum flexibility.
* **Improved multi-agent support**: A single IDE instance naturally serves arbitrarily many agent sessions without requiring additional resources.
* **Enhanced performance**: Faster tool execution thanks to optimized IDE integration.
* **Multi-language excellence** and **framework support**: First-class support for polyglot projects with multiple languages.
+10 -5
View File
@@ -19,10 +19,13 @@ Both can be configured in Serena's [configuration](050_configuration) file (`ser
## The Serena Dashboard
The dashboard is a web application, which is opened via a native application wrapper on supported platforms (e.g. Windows),
where it will appear in the system tray. **Click on the tray icon** to open the application window.
The dashboard is a web application, which is opened in one of three ways:
On other platforms, where native app support is difficult to provide (e.g. Linux), the dashboard will be opened in your default web browser instead.
* via a native application wrapper with accompanying tray icon (on supported platforms)
* via a tray manager that aggregates multiple instances (on supported platforms)
* via your default web browser (supported on all platforms)
Configure your preferred interface via the setting `web_dashoard_interface` in [Serena's global configuration file](global-config) (see below).
By default, the dashboard can be accessed at `http://localhost:24282/dashboard/index.html`,
but a higher port may be used if the default port is unavailable/multiple instances are running.
@@ -45,6 +48,7 @@ The dashboard provides ...
You can configure settings for the dashboard in the [global configuration file](global-config).
In particular, you can configure
* the interface through which the dashboard is accessed (see above)
* whether the dashboard is enabled at all.
We highly recommend keeping it enabled, as it provides valuable insights into Serena's operation and allows you to adjust configuration conveniently.
* whether the dashboard window is opened automatically when Serena is started (see below).
@@ -54,11 +58,12 @@ In particular, you can configure
When Serena is started, the Dashboard window is opened by default in order to make users aware of its existence.
If you prefer not to have this happen (e.g., to avoid focus stealing), you can prevent it from opening
If you prefer not to have this happen, you can prevent it from opening
by setting `web_dashboard_open_on_launch: False` in the [global configuration file](global-config) or by passing `--open-web-dashboard False`
to the `start-mcp-server` CLI command.
On platforms supporting the tray icon, you can conveniently open the dashboard at any time by clicking on the tray icon, so automatic
On platforms supporting the tray icon (see also configuration option `web_dashboard_interface`),
you can conveniently open the dashboard at any time by clicking on the tray icon, so automatic
opening is not a requirement to be able to access the dashboard on these platforms.
On other platforms, you may still access it by
+3 -4
View File
@@ -21,8 +21,8 @@ dependencies = [
"pyright==1.1.403",
"fortls==3.2.2",
"overrides==7.7.0",
"python-dotenv==1.2.1",
"mcp==1.26.0",
"python-dotenv==1.2.2",
"mcp==1.27.0",
"flask==3.1.3", # bumped from 3.1.1 for CVE fix (also fixes werkzeug alert)
"sensai-utils==1.5.0",
"pydantic==2.12.5",
@@ -88,13 +88,11 @@ dev = [
"sphinx_book_theme==1.1.4",
"sphinx-toolbox==3.7.0",
"jupyter-book==1.0.4.post1",
"nbsphinx==0.9.7",
"pyinstrument==5.1.1",
"pytest-timeout==2.4.0",
# Transitive dev deps pinned for security (dependabot alerts).
# Exact pins because uvx installs from git, ignoring the lock file.
"tornado==6.5.5",
"nbconvert==7.17.0",
"wheel==0.46.3",
"pyasn1==0.6.3",
"PyJWT==2.12.0",
@@ -338,6 +336,7 @@ markers = [
"haskell: Haskell language server tests",
"haxe: Haxe language server tests",
"yaml: language server running for YAML",
"json: language server running for JSON (uses vscode-json-languageserver)",
"powershell: language server running for PowerShell",
"pascal: language server running for Pascal (Free Pascal/Lazarus)",
"cpp: language server running for C/C++",
+12
View File
@@ -21,3 +21,15 @@ def serena_version() -> str:
except:
pass
return version
def _init_log_configuration() -> None:
from sensai.util import logging
def _configure() -> None:
logging.getLogger("PIL").setLevel(logging.WARNING)
logging.set_configure_callback(_configure)
_init_log_configuration()
+209 -72
View File
@@ -7,12 +7,13 @@ import multiprocessing
import os
import platform
import signal
import subprocess
import sys
import threading
from collections import defaultdict
from collections.abc import Callable, Iterator, Sequence
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from logging import Logger
from typing import TYPE_CHECKING, Optional, TypeVar
@@ -36,7 +37,7 @@ from serena.config.serena_config import (
SerenaPaths,
ToolInclusionDefinition,
)
from serena.dashboard import SerenaDashboardAPI, SerenaDashboardViewer
from serena.dashboard import SerenaDashboardAPI, SerenaDashboardTrayManager, SerenaDashboardViewer, open_url_in_browser
from serena.ls_manager import LanguageServerManager
from serena.project import MemoriesManager, Project
from serena.prompt_factory import SerenaPromptFactory
@@ -321,6 +322,178 @@ class ProjectPromptProvisionStatus:
return self._get_session_status(session_id).project_activation_message_provided
class DashboardManager:
class Mode(Enum):
BROWSER = "browser"
"""
Open the dashboard in the default browser; supported on all platforms.
"""
WEBVIEW = "app"
"""
Open the dashboard via a native window (using pywebview) which minimises to the tray;
supported on Windows and macOS (but on macOS, tray apps for multiple instances accumulate
in the top bar, which users may not want)
"""
TRAY_MANAGER = "tray_manager"
"""
Register dashboard instance with a central manager tray app (single tray icon for all instances),
spawning the tray manager if not already running; supported on macOS and Windows.
"""
@classmethod
def from_platform(cls) -> "DashboardManager.Mode":
match platform.system():
case "Windows":
return cls.WEBVIEW
case "Darwin":
# TODO: Switch to TRAY_MANAGER once support is tested
return cls.BROWSER
case _:
return cls.BROWSER
def is_supported(self) -> bool:
"""
:return: whether the mode is supported on the current platform
"""
if self == DashboardManager.Mode.WEBVIEW:
return SerenaDashboardViewer.is_current_platform_supported()
elif self == DashboardManager.Mode.TRAY_MANAGER:
return SerenaDashboardTrayManager.is_current_platform_supported()
else:
return True
def __init__(
self,
port: int,
host_listen_address: str,
open_dashboard_on_launch: bool,
active_project: Project | None = None,
mode_str: str | None = None,
):
# determine requested mode
if mode_str is not None:
try:
mode = self.Mode(mode_str)
except ValueError:
mode = self.Mode.from_platform()
log.warning(f"Invalid dashboard interface mode '{mode_str}' specified; falling back to platform default '{mode.value}'.")
else:
mode = self.Mode.from_platform()
# check for mode compatibility
if not mode.is_supported():
fallback_mode = self.Mode.from_platform()
log.warning(
f"Dashboard interface mode '{mode.value}' is not supported on the current platform; "
"falling back to '{fallback_mode.value}'."
)
mode = fallback_mode
self._port = port
self._mode = mode
self._dashboard_viewer_process: multiprocessing.Process | None = None
self._tray_manager_lock = threading.Lock()
dashboard_host = host_listen_address
if dashboard_host == "0.0.0.0":
dashboard_host = "localhost"
self.url = f"http://{dashboard_host}:{port}/dashboard/index.html"
# handle startup
match self._mode:
case self.Mode.WEBVIEW:
self._start_dashboard_viewer(minimized=not open_dashboard_on_launch)
case self.Mode.TRAY_MANAGER:
init_fn = lambda: self._tray_manager_register(open_on_launch=open_dashboard_on_launch, active_project=active_project)
threading.Thread(target=init_fn, name="init-DashboardTrayManager", daemon=True).start()
case self.Mode.BROWSER:
if open_dashboard_on_launch:
if not system_has_usable_display():
log.info("Not opening the Serena dashboard because no usable display was detected.")
else:
self.open_dashboard_in_browser()
def open_dashboard_in_browser(self) -> None:
open_url_in_browser(self.url, use_subprocess=True)
@staticmethod
def _start_dashboard_viewer_process_function(url: str, minimized: bool, parent_process_id: int) -> None:
"""
Main function of the subprocess for starting the dashboard viewer
"""
try:
SerenaDashboardViewer(url, start_minimized=minimized, parent_process_id=parent_process_id).run()
except webview.errors.WebViewException as e:
log.warning(f"Could not open Serena Dashboard viewer. Cause:\n{e}")
# Fall back to opening the browser window if the window was supposed to be shown directly
if not minimized:
open_url_in_browser(url, use_subprocess=True)
def _start_dashboard_viewer(self, minimized: bool) -> None:
"""
Starts the dashboard viewer (in a separate process) or, if the current platform does not support it,
opens the dashboard in the default web browser.
:param minimized: whether the dashboard viewer should be started minimized (if supported on the current platform).
If the viewer is not supported on the current platform, then this controls whether to open the browser window.
"""
self._dashboard_viewer_process = multiprocessing.Process(
target=self._start_dashboard_viewer_process_function, args=(self.url, minimized, os.getpid()), daemon=True
)
self._dashboard_viewer_process.start()
def _tray_manager_register(self, open_on_launch: bool, active_project: Project | None) -> None:
"""
Ensure the tray manager is running and register this dashboard instance with it.
If the current platform supports the native tray manager, this method starts
the manager (if not already running) and registers the instance. Otherwise,
it falls back to opening the dashboard in the default web browser.
:param open_on_launch: whether the dashboard should be opened immediately
"""
with LogTime("Dashboard tray manager initialisation"):
with self._tray_manager_lock:
# ensure the singleton tray manager process is running
SerenaDashboardTrayManager.ensure_running()
# determine the current project name (if any)
project_name = active_project.project_name if active_project is not None else None
# register this instance with the tray manager
SerenaDashboardTrayManager.register_instance(
port=self._port,
dashboard_url=self.url,
project=project_name,
started_at=datetime.now().isoformat(timespec="seconds"),
open_viewer=open_on_launch,
)
def shutdown(self) -> None:
"""
Frees resources, terminating the dashboard viewer process (if any) and unregistering from the tray manager (if applicable).
"""
if self._dashboard_viewer_process is not None:
log.info("Stopping the dashboard viewer process ...")
self._dashboard_viewer_process.terminate()
self._dashboard_viewer_process = None
if self._mode == self.Mode.TRAY_MANAGER:
with self._tray_manager_lock:
SerenaDashboardTrayManager.unregister_instance(port=self._port)
def update_active_project(self, active_project: Project | None) -> None:
"""
Updates the active project (where applicable).
:param active_project: the currently active project or None if no project is active
"""
if self._mode == self.Mode.TRAY_MANAGER:
with self._tray_manager_lock:
project_name = active_project.project_name if active_project is not None else None
SerenaDashboardTrayManager.update_project(port=self._port, project=project_name)
class SerenaAgent:
def __init__(
self,
@@ -344,8 +517,9 @@ class SerenaAgent:
if necessary.
"""
self._active_project: Project | None = None
self._project_activation_callback = project_activation_callback
self._gui_log_viewer: Optional["GuiLogViewer"] = None
self._dashboard_viewer_process: multiprocessing.Process | None = None
self._dashboard_manager: DashboardManager | None = None
self._project_prompt_status = ProjectPromptProvisionStatus()
self._mode_overrides = modes
self.version = serena_version()
@@ -365,9 +539,6 @@ class SerenaAgent:
self.serena_config.get_registered_project(project, autoregister=True) if project is not None else None
)
# dashboard URL (set when dashboard is started)
self._dashboard_url: str | None = None
# adjust log level
serena_log_level = self.serena_config.log_level
if Logger.root.level != serena_log_level:
@@ -445,7 +616,6 @@ class SerenaAgent:
# Initialize the prompt factory
self.prompt_factory = SerenaPromptFactory()
self._project_activation_callback = project_activation_callback
# activate the given project (if any), also updating the active modes
# Note: We cannot update the active tools yet, because the base toolset has not been computed yet
@@ -475,16 +645,17 @@ class SerenaAgent:
self._dashboard_thread, port = SerenaDashboardAPI(
get_memory_log_handler(), tool_names, agent=self, tool_usage_stats=self._tool_usage_stats
).run_in_thread(host=self.serena_config.web_dashboard_listen_address)
dashboard_host = self.serena_config.web_dashboard_listen_address
if dashboard_host == "0.0.0.0":
dashboard_host = "localhost"
dashboard_url = f"http://{dashboard_host}:{port}/dashboard/index.html"
self._dashboard_url = dashboard_url
log.info("Serena web dashboard started at %s", dashboard_url)
self._start_dashboard_viewer(minimized=not self.serena_config.web_dashboard_open_on_launch)
self._dashboard_manager = DashboardManager(
port,
self.serena_config.web_dashboard_listen_address,
self.serena_config.web_dashboard_open_on_launch,
self._active_project,
mode_str=self.serena_config.web_dashboard_interface,
)
log.info("Serena web dashboard started at %s", self._dashboard_manager.url)
# inform the GUI window (if any)
if self._gui_log_viewer is not None:
self._gui_log_viewer.set_dashboard_url(dashboard_url)
self._gui_log_viewer.set_dashboard_url(self._dashboard_manager.url)
self._send_usage_info()
@@ -652,44 +823,9 @@ class SerenaAgent:
"""
:return: the URL of the web dashboard, or None if the dashboard is not running
"""
return self._dashboard_url
@staticmethod
def _start_dashboard_viewer_process_function(url: str, minimized: bool, parent_process_id: int) -> None:
"""
Main function of the subprocess for starting the dashboard viewer
"""
try:
SerenaDashboardViewer(url, start_minimized=minimized, parent_process_id=parent_process_id).run()
except webview.errors.WebViewException as e:
log.warning(f"Could not open Serena Dashboard viewer. Cause:\n{e}")
# Fall back to opening the browser window if the window was supposed to be shown directly
if not minimized:
SerenaAgent._open_dashboard_in_browser(url)
def _start_dashboard_viewer(self, minimized: bool) -> None:
"""
Starts the dashboard viewer (in a separate process) or, if the current platform does not support it,
opens the dashboard in the default web browser.
:param minimized: whether the dashboard viewer should be started minimized (if supported on the current platform).
If the viewer is not supported on the current platform, then this controls whether to open the browser window.
"""
if not system_has_usable_display():
log.info("Not starting the Serena dashboard viewer because no usable display was detected.")
return
url = self.get_dashboard_url()
assert url is not None
if SerenaDashboardViewer.is_current_platform_supported():
self._dashboard_viewer_process = multiprocessing.Process(
target=self._start_dashboard_viewer_process_function, args=(url, minimized, os.getpid()), daemon=True
)
self._dashboard_viewer_process.start()
else:
log.info("Not starting Serena dashboard viewer because the current platform does not support it; using browser-based fallback")
if not minimized:
self._open_dashboard_in_browser(url)
if self._dashboard_manager is None:
return None
return self._dashboard_manager.url
def open_dashboard(self) -> bool:
"""
@@ -697,27 +833,16 @@ class SerenaAgent:
:return: True if the dashboard was opened, False if it could not be opened
"""
if self._dashboard_url is None:
if self._dashboard_manager is None:
raise Exception("Dashboard is not running.")
if not system_has_usable_display():
log.warning("Not opening the Serena web dashboard because no usable display was detected.")
return False
self._open_dashboard_in_browser(self._dashboard_url)
self._dashboard_manager.open_dashboard_in_browser()
return True
@staticmethod
def _open_dashboard_in_browser(url: str) -> None:
# Use a subprocess to avoid any output from webbrowser.open being written to stdout
subprocess.Popen(
[sys.executable, "-c", f"import webbrowser; webbrowser.open({url!r})"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=False,
)
def get_exposed_tool_instances(self) -> list["Tool"]:
"""
:return: the tool instances which are exposed (e.g. to the MCP client).
@@ -754,8 +879,18 @@ class SerenaAgent:
template = JinjaTemplate(prompt_template)
return template.render(available_tools=self._exposed_tools.tool_names, available_markers=self._exposed_tools.tool_marker_names)
def create_connection_prompt(self) -> str:
"""
Returns the bootstrap prompt to be sent at MCP connection time.
:return: the prompt
"""
return self.prompt_factory.create_connection_prompt()
def create_system_prompt(self, session_id: str = "global") -> str:
"""
Returns the 'Serena Instructions Manual', i.e. Serena's system prompt.
:param session_id: the client session ID for the case where this is run from a tool; "global" for the connection time case
:return: the prompt
"""
@@ -788,7 +923,6 @@ class SerenaAgent:
if self._active_project is not None and not self._project_prompt_status.is_project_activation_message_already_provided(session_id):
system_prompt += "\n\n" + self.get_project_activation_message(session_id)
log.info("System prompt:\n%s", system_prompt)
return system_prompt
def get_project_activation_message(self, session_id: str) -> str:
@@ -971,6 +1105,10 @@ class SerenaAgent:
if self._project_activation_callback is not None:
self._project_activation_callback()
# notify the dashboard manager of the project change (if applicable)
if self._dashboard_manager:
self._dashboard_manager.update_active_project(self._active_project)
return True
def activate_project_from_path_or_name(
@@ -1114,10 +1252,9 @@ class SerenaAgent:
log.info("Stopping the GUI log window ...")
self._gui_log_viewer.stop()
self._gui_log_viewer = None
if self._dashboard_viewer_process:
log.info("Stopping the dashboard viewer process ...")
self._dashboard_viewer_process.terminate()
self._dashboard_viewer_process = None
if self._dashboard_manager:
self._dashboard_manager.shutdown()
self._dashboard_manager = None
def shutdown(self) -> None:
"""
+2 -3
View File
@@ -479,11 +479,10 @@ class TopLevelCommands(AutoRegisteringGroup):
@click.argument("url", type=str)
@click.option("--width", type=int, default=1400, show_default=True, help="Window width.")
@click.option("--height", type=int, default=900, show_default=True, help="Window height.")
@click.option("--minimized", is_flag=True, default=False, help="Whether to start minimized/in tray.")
def dashboard_viewer(url: str, width: int, height: int, minimized: bool) -> None:
def dashboard_viewer(url: str, width: int, height: int) -> None:
from serena.dashboard import SerenaDashboardViewer
viewer = SerenaDashboardViewer(url, start_minimized=minimized, width=width, height=height)
viewer = SerenaDashboardViewer(url, width=width, height=height)
viewer.run()
+1
View File
@@ -692,6 +692,7 @@ class SerenaConfig(SharedConfig):
trace_lsp_communication: bool = False
web_dashboard: bool = True
web_dashboard_open_on_launch: bool = True
web_dashboard_interface: str | None = None
web_dashboard_listen_address: str = "127.0.0.1"
jetbrains_plugin_server_address: str = "127.0.0.1"
tool_timeout: float = DEFAULT_TOOL_TIMEOUT
+7
View File
@@ -32,3 +32,10 @@ SERENA_LOG_FORMAT = "%(levelname)-5s %(asctime)-15s [%(threadName)s] %(name)s:%(
LOG_MESSAGES_BUFFER_SIZE = 2500
"""The maximum number of log messages to keep in the buffer (for the dashboard)."""
class SerenaPorts:
TRAY_MANAGER_PORT = 0x5EA0
PROJECT_SERVER_PORT = 0x5EA1
JETBRAINS_PLUGIN_SERVER_BASE_PORT = 0x5EA2
DASHBOARD_API_BASE_PORT = 0x5EDA
+463 -11
View File
@@ -1,22 +1,29 @@
import json
import multiprocessing
import os
import socket
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from collections.abc import Callable
from dataclasses import dataclass
from html import escape
from pathlib import Path
from typing import TYPE_CHECKING, Any, Self
from typing import TYPE_CHECKING, Any, Optional, Self
import psutil
from flask import Flask, Response, redirect, request, send_from_directory
from PIL import Image
from pydantic import BaseModel
from sensai.util import logging
from sensai.util.pickle import dump_pickle, load_pickle
from serena.analytics import ToolUsageStats
from serena.config.serena_config import SerenaConfig, SerenaPaths
from serena.constants import SERENA_DASHBOARD_DIR
from serena.constants import SERENA_DASHBOARD_DIR, SerenaPorts
from serena.task_executor import TaskExecutor
from serena.util.logging import MemoryLogHandler
from serena.util.pywebview import WebViewWithTray
@@ -180,6 +187,8 @@ class ReadNews:
class SerenaDashboardAPI:
BASE_PORT = SerenaPorts.DASHBOARD_API_BASE_PORT
log = logging.getLogger(__qualname__)
def __init__(
@@ -773,16 +782,45 @@ class SerenaDashboardAPI:
return port
def run_in_thread(self, host: str) -> tuple[threading.Thread, int]:
port = self._find_first_free_port(0x5EDA, host)
port = self._find_first_free_port(self.BASE_PORT, host)
log.info("Starting dashboard (listen_address=%s, port=%d)", host, port)
thread = threading.Thread(target=lambda: self.run(host=host, port=port), daemon=True)
thread.start()
return thread, port
def open_url_in_browser(url: str, use_subprocess: bool = False) -> None:
"""
Opens the given URL in the user's default web browser,
optionally using a subprocess to ensure that no output is written to stdout
(highly problematic when run within a stdio MCP server context)
:param url: the URL to open
:param use_subprocess: whether to use a subprocess to opening the URL, making stdio contamination impossible
"""
if use_subprocess:
# Use a subprocess to avoid any output from webbrowser.open being written to stdout
try:
subprocess.Popen(
[sys.executable, "-c", f"import webbrowser; webbrowser.open({url!r})"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=False,
)
except Exception as e:
# Subprocess creation can fail in rare cases (e.g. on some Linux systems; possibly subprocess/glibc bug)
# See #1363
log.error("Failed to open URL (%s) in subprocess; %s", url, e)
else:
import webbrowser
webbrowser.open(url)
class SerenaDashboardViewer(WebViewWithTray):
"""
Minimal pywebview wrapper with optional system tray.
Minimal pywebview wrapper that opens a dashboard in a native window with optional system tray.
"""
DEBUG = False
@@ -797,6 +835,15 @@ class SerenaDashboardViewer(WebViewWithTray):
parent_process_id: int | None = None,
tray: bool = True,
):
"""
:param url: the URL to open
:param width: the width of the window
:param height: the height of the window
:param start_minimized: whether to start the window minimized (to the tray if tray is enabled)
:param parent_process_id: the process ID of the parent Serena agent process, which is monitored for termination,
automatically closing the dashboard when the parent process dies
:param tray: whether to use a system tray icon (which the app minimizes to when the window is closed)
"""
dashboard_path = Path(SERENA_DASHBOARD_DIR)
# .ico is Windows-only; macOS expects a PNG for the window/dock icon.
@@ -822,21 +869,426 @@ class SerenaDashboardViewer(WebViewWithTray):
@staticmethod
def is_current_platform_supported() -> bool:
"""
:return: whether the current platform supports the dashboard viewer
:return: whether the current platform supports the native dashboard viewer (and tray manager).
"""
# The dashboard viewer (with system tray) is technically supported only on Windows and macOS.
# Linux support is problematic; see https://github.com/oraios/serena/pull/1117#issuecomment-4128753943
# supported on Windows and macOS; Linux support is problematic
# (see https://github.com/oraios/serena/pull/1117#issuecomment-4128753943)
supported_platforms = [
"win32",
# NOTE: Disabling macOS support for now, because the tray behaviour is suboptimal (too many icons when
# subagents are spawned, etc.)
# "darwin"
"darwin",
]
return sys.platform in supported_platforms
def run(self) -> None:
if self.DEBUG:
logging.configure(level=logging.DEBUG)
logging.configure(level=logging.DEBUG, stream=sys.stderr)
logging.add_file_logger(SerenaPaths().get_next_log_file_path("dashboard-viewer"))
super().run()
@dataclass
class TrayManagedInstance:
"""A registered Serena dashboard instance managed by the tray manager."""
port: int
"""the port on which the dashboard API is listening"""
parent_process: psutil.Process
"""the process of the Serena agent owning this dashboard instance"""
dashboard_url: str
"""the full URL to the dashboard frontend"""
project: str | None
"""the name of the active project, or None if no project is activated"""
started_at: str
"""ISO 8601 timestamp of when the agent instance was started"""
class SerenaDashboardTrayManager:
"""
Singleton process managing a system tray icon for all Serena dashboard instances.
Runs a Flask backend on a fixed port and displays a single tray icon that
aggregates all running Serena instances. Individual dashboard viewers are
spawned on demand when the user clicks a menu item.
The manager is started as a detached process by the first Serena agent that
needs it and terminates automatically when no dashboard instances remain.
"""
DEBUG = False
PORT = SerenaPorts.TRAY_MANAGER_PORT
HOST = "127.0.0.1"
"""listen address (local only)"""
ALIVE_CHECK_INTERVAL_SECONDS = 3
"""interval in seconds between alive checks of registered instances"""
def __init__(self, use_pywebview: bool = False, alive_check_use_pid: bool = True) -> None:
"""
:param use_pywebview: whether to use pywebview-based viewer applications (separate child processes)
for opening dashboards; if False, open them directly in the user's default web browser.
:param alive_check_use_pid: whether to use the process ID for alive checks of registered instances.
If True, the manager will check whether the process with the registered PID is still running.
If False, the manager will perform an HTTP request to the instance's heartbeat endpoint to check
if it's alive.
"""
import pystray
self._instances: dict[int, TrayManagedInstance] = {}
self._lock = threading.Lock()
self._tray_icon: Optional["pystray.Icon"] = None
self._alive_check_use_pid = alive_check_use_pid
self._app = Flask(__name__)
self._setup_routes()
self._use_pywebview = use_pywebview
def _setup_routes(self) -> None:
@self._app.route("/health", methods=["GET"])
def health() -> dict[str, str]:
return {"status": "alive"}
@self._app.route("/register", methods=["POST"])
def register() -> dict[str, str]:
data = request.get_json()
instance = TrayManagedInstance(
port=data["port"],
parent_process=psutil.Process(data["pid"]),
dashboard_url=data["dashboard_url"],
project=data.get("project"),
started_at=data["started_at"],
)
log.info("Registering instance on port %d (project=%s)", instance.port, instance.project)
with self._lock:
self._instances[instance.port] = instance
self._update_menu()
# open a viewer immediately if requested
if data.get("open_viewer", False):
self._open_dashboard(instance)
return {"status": "registered"}
@self._app.route("/update_project", methods=["POST"])
def update_project() -> dict[str, str]:
data = request.get_json()
port = data["port"]
project = data.get("project")
with self._lock:
if port in self._instances:
self._instances[port].project = project
self._update_menu()
log.info("Updated project for instance on port %d to '%s'", port, project)
return {"status": "updated"}
@self._app.route("/unregister", methods=["POST"])
def unregister() -> dict[str, str]:
data = request.get_json()
port = data["port"]
with self._lock:
self._instances.pop(port, None)
self._update_menu()
log.info("Unregistered instance on port %d", port)
return {"status": "unregistered"}
def _update_menu(self) -> None:
if self._tray_icon:
self._tray_icon.update_menu()
def _build_menu_items(self) -> tuple[Any, ...]:
"""
Callable that returns the current tray menu items.
Invoked dynamically by pystray each time the menu is shown.
When there is exactly one instance, it is marked as the default action
so that a left-click on the tray icon opens the viewer immediately.
When there are multiple instances, a hidden default item forces the menu
to appear on left-click (Windows only; macOS always shows the menu).
"""
from pystray import MenuItem as Item
with self._lock:
instances = list(self._instances.values())
if not instances:
return (Item("No instances", None, enabled=False),)
# determine whether a single-instance shortcut applies
is_single = len(instances) == 1
items: list[Any] = []
# for multi-instance: add a hidden default item that opens the menu on left-click
if not is_single:
def _force_show_menu(icon: Any, _item: Any) -> None:
if hasattr(icon, "_show_menu"):
icon._show_menu()
items.append(Item("Instances", _force_show_menu, default=True, visible=False))
for inst in sorted(instances, key=lambda i: i.started_at):
label = f"{inst.project or 'SerenaAgent'} ({inst.started_at})"
# closure to capture the current instance
def _make_callback(instance: TrayManagedInstance) -> Callable:
def _callback(_icon: Any, _item: Any) -> None:
self._open_dashboard(instance)
return _callback
items.append(Item(label, _make_callback(inst), default=is_single))
return tuple(items)
def _open_dashboard(self, instance: TrayManagedInstance) -> None:
"""Opens the dashboard of the given instance."""
if self._use_pywebview:
# spawn pywebview process showing the dashboard
process = multiprocessing.Process(
target=self._run_viewer,
args=(instance.dashboard_url,),
daemon=True,
)
process.start()
else:
open_url_in_browser(instance.dashboard_url)
@staticmethod
def _run_viewer(url: str) -> None:
"""Process target for running a dashboard viewer window."""
SerenaDashboardViewer(url, tray=False, start_minimized=False).run()
def _alive_check_loop(self) -> None:
"""Periodically check whether registered instances are still reachable.
Removes unreachable instances and terminates the manager when none remain.
"""
time.sleep(max(1, 5 - self.ALIVE_CHECK_INTERVAL_SECONDS)) # initial delay to allow instances to start and register
while True:
time.sleep(self.ALIVE_CHECK_INTERVAL_SECONDS)
try:
dead_ports: list[int] = []
if self._alive_check_use_pid:
with self._lock:
ports_and_processes = [(port, inst.parent_process) for port, inst in self._instances.items()]
dead_ports = []
for port, process in ports_and_processes:
is_dead = False
try:
if not process.is_running():
is_dead = True
except psutil.NoSuchProcess:
# parent process already exited
is_dead = True
if is_dead:
dead_ports.append(port)
else:
# collect ports to check
with self._lock:
ports_to_check = list(self._instances.keys())
# probe each instance
for port in ports_to_check:
try:
url = f"http://127.0.0.1:{port}/heartbeat"
req = urllib.request.Request(url, method="GET")
urllib.request.urlopen(req, timeout=1)
except Exception:
dead_ports.append(port)
# remove dead instances
if dead_ports:
with self._lock:
for port in dead_ports:
self._instances.pop(port, None)
log.info("Removed unreachable instance on port %d", port)
# terminate if no instances remain
with self._lock:
remaining = len(self._instances)
if remaining == 0:
log.info("No dashboard instances remaining; shutting down tray manager")
if self._tray_icon is not None:
self._tray_icon.stop()
return
except Exception as e:
log.error("Error during alive check loop: %s", e, exc_info=e)
def run(self) -> None:
"""Run the tray manager (blocking). Starts Flask, alive-check thread, and tray icon."""
import pystray
if self.DEBUG:
logging.configure(level=logging.DEBUG, stream=sys.stderr)
logging.add_file_logger(SerenaPaths().get_next_log_file_path("tray-manager"))
log.info("Starting tray manager")
dashboard_path = Path(SERENA_DASHBOARD_DIR)
# select the appropriate icon for the platform
icon_filename = "serena-icon-tray-mac.png" if sys.platform == "darwin" else "serena-icon-48.png"
icon_img = Image.open(dashboard_path / icon_filename)
# start Flask in a background thread
flask_thread = threading.Thread(
target=lambda: self._app.run(host=self.HOST, port=self.PORT, debug=False, use_reloader=False, threaded=True),
daemon=True,
)
flask_thread.start()
# start alive-check in a background thread
alive_thread = threading.Thread(target=self._alive_check_loop, daemon=True)
alive_thread.start()
# set up tray icon with a dynamic menu (callable returns items on each open)
kwargs: dict[str, Any] = {}
if sys.platform == "darwin":
from AppKit import NSApplication
kwargs["darwin_nsapplication"] = NSApplication.sharedApplication()
self._tray_icon = pystray.Icon(
"serena_tray_manager",
icon_img,
"Serena",
menu=pystray.Menu(self._build_menu_items),
**kwargs,
)
# blocks until stop() is called
self._tray_icon.run()
# ------------------------------------------------------------------
# Class-level helpers (used by agents to interact with the manager)
# ------------------------------------------------------------------
@classmethod
def is_current_platform_supported(cls) -> bool:
"""
:return: whether the current platform supports the tray manager
"""
# supported_platforms = ["win32", "darwin"]
# return sys.platform in supported_platforms
# TODO: Enable all platforms for the time being for users to try out (only Windows tested)
return True
@classmethod
def is_running(cls) -> bool:
"""
:return: True if a tray manager process is already listening on the fixed port
"""
try:
url = f"http://{cls.HOST}:{cls.PORT}/health"
req = urllib.request.Request(url, method="GET")
urllib.request.urlopen(req, timeout=2)
return True
except Exception:
return False
@classmethod
def ensure_running(cls) -> None:
"""Ensure a tray manager process is running, starting one if necessary."""
log.info("Ensuring dashboard tray manager availability")
if cls.is_running():
log.info("Dashboard tray manager is already running")
return
# spawn a detached process
log.info("Starting new dashboard tray manager process")
cmd = [
sys.executable,
"-c",
"from serena.dashboard import SerenaDashboardTrayManager; SerenaDashboardTrayManager().run()",
]
kwargs: dict[str, Any] = {
"stdin": subprocess.DEVNULL,
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
}
if sys.platform == "win32":
# CREATE_NO_WINDOW suppresses the console; CREATE_NEW_PROCESS_GROUP
# isolates the child from the parent's Ctrl+C group.
# Note: DETACHED_PROCESS must NOT be combined with CREATE_NO_WINDOW.
CREATE_NEW_PROCESS_GROUP = 0x00000200
CREATE_NO_WINDOW = 0x08000000
kwargs["creationflags"] = CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW
else:
kwargs["start_new_session"] = True
subprocess.Popen(cmd, **kwargs)
# wait for the manager to become available
for _ in range(30):
time.sleep(0.1)
if cls.is_running():
log.info("Dashboard tray manager started successfully")
return
log.warning("Dashboard tray manager did not start within the expected time")
@classmethod
def register_instance(cls, port: int, dashboard_url: str, project: str | None, started_at: str, open_viewer: bool = False) -> None:
"""Register a dashboard instance with the running tray manager.
:param port: the port of the dashboard API (used for alive checks)
:param dashboard_url: the full URL to the dashboard frontend
:param project: the currently active project name, or None
:param started_at: ISO 8601 timestamp of when the agent was started
:param open_viewer: whether the tray manager should immediately open a viewer for this instance
"""
url = f"http://{cls.HOST}:{cls.PORT}/register"
data = json.dumps(
{
"port": port,
"pid": os.getpid(),
"dashboard_url": dashboard_url,
"project": project,
"started_at": started_at,
"open_viewer": open_viewer,
}
).encode()
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
try:
urllib.request.urlopen(req, timeout=2)
except Exception as e:
log.warning("Failed to register with tray manager: %s", e)
@classmethod
def update_project(cls, port: int, project: str | None) -> None:
"""Notify the tray manager of a project change for the given instance.
:param port: the port of the dashboard API
:param project: the new active project name, or None
"""
url = f"http://{cls.HOST}:{cls.PORT}/update_project"
data = json.dumps({"port": port, "project": project}).encode()
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
try:
urllib.request.urlopen(req, timeout=2)
except Exception as e:
log.warning("Failed to update project with tray manager: %s", e)
@classmethod
def unregister_instance(cls, port: int) -> None:
"""Unregister a dashboard instance from the tray manager.
:param port: the port of the dashboard API to unregister
"""
url = f"http://{cls.HOST}:{cls.PORT}/unregister"
data = json.dumps({"port": port}).encode()
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
try:
urllib.request.urlopen(req, timeout=2)
except Exception as e:
log.warning("Failed to unregister from tray manager: %s", e)
@@ -14,23 +14,14 @@ class PromptFactory(PromptFactoryBase):
A class for retrieving and rendering prompt templates and prompt lists.
"""
def create_info_jet_brains_debug_repl(self) -> str:
return self._render_prompt("info_jet_brains_debug_repl", locals())
def create_onboarding_prompt(self, *, system: Any) -> str:
return self._render_prompt("onboarding_prompt", locals())
def create_think_about_collected_information(self) -> str:
return self._render_prompt("think_about_collected_information", locals())
def create_think_about_task_adherence(self) -> str:
return self._render_prompt("think_about_task_adherence", locals())
def create_think_about_whether_you_are_done(self) -> str:
return self._render_prompt("think_about_whether_you_are_done", locals())
def create_summarize_changes(self) -> str:
return self._render_prompt("summarize_changes", locals())
def create_prepare_for_new_conversation(self) -> str:
return self._render_prompt("prepare_for_new_conversation", locals())
def create_connection_prompt(self) -> str:
return self._render_prompt("connection_prompt", locals())
def create_system_prompt(
self,
@@ -18,6 +18,7 @@ from sensai.util.string import ToStringMixin
import serena.jetbrains.jetbrains_types as jb
from serena.config.serena_config import RegisteredProject
from serena.constants import SerenaPorts
from serena.jetbrains.jetbrains_types import PluginStatusDTO
from serena.project import Project
from serena.util.class_decorators import singleton
@@ -174,7 +175,7 @@ class JetBrainsPluginClient(ToStringMixin):
Provides simple methods to interact with all available endpoints.
"""
BASE_PORT = 0x5EA2
BASE_PORT = SerenaPorts.JETBRAINS_PLUGIN_SERVER_BASE_PORT
PLUGIN_REQUEST_TIMEOUT = 300
"""
the timeout used for request handling within the plugin (a constant in the plugin)
@@ -632,6 +633,34 @@ class JetBrainsPluginClient(ToStringMixin):
self._postprocess_symbol_collection_response(symbol_collection)
return symbol_collection
def debug_eval(self, repl_key: str, expression: str) -> dict[str, Any]:
"""
Evaluates a Groovy expression in the persistent debug REPL.
:param repl_key: the session key identifying the REPL instance
:param expression: the Groovy expression to evaluate
:return: the response containing REPL key and result
"""
self._require_version_at_least(2023, 2, 16)
request_data = {
"replKey": repl_key,
"expression": expression,
}
return self._make_request("POST", "/debugReplEval", request_data)
def debug_close(self, repl_key: str) -> dict[str, Any]:
"""
Closes the debug REPL for the given session key, clearing all state.
:param repl_key: the key identifying the REPL instance to close
:return: the status response
"""
self._require_version_at_least(2023, 2, 16)
request_data = {
"replKey": repl_key,
}
return self._make_request("POST", "/debugReplClose", request_data)
def close(self) -> None:
self._session.close()
+2 -1
View File
@@ -332,6 +332,7 @@ class SerenaMCPFactory:
# retain only FASTMCP_ prefix for already set environment variables.
Settings.model_config = SettingsConfigDict(env_prefix="FASTMCP_")
instructions = self._get_initial_instructions()
log.info("MCP server initial instructions:\n%s", instructions)
mcp = FastMCP(
name="Serena",
lifespan=self.server_lifespan,
@@ -357,4 +358,4 @@ class SerenaMCPFactory:
def _get_initial_instructions(self) -> str:
assert self.agent is not None
return self.agent.create_system_prompt()
return self.agent.create_connection_prompt()
+2 -2
View File
@@ -8,7 +8,7 @@ from pydantic import BaseModel
from sensai.util.logging import LogTime
from serena.config.serena_config import LanguageBackend, SerenaConfig
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient
from serena.constants import SerenaPorts
if TYPE_CHECKING:
from serena.project import Project
@@ -42,7 +42,7 @@ class ProjectServer:
:class:`~serena.tools.query_project_tools.QueryProjectTool`.
"""
PORT = JetBrainsPluginClient.BASE_PORT - 1
PORT = SerenaPorts.PROJECT_SERVER_PORT
def __init__(self) -> None:
from serena.agent import SerenaAgent
@@ -7,16 +7,15 @@ prompt: |
**IMPORTANT**: If you haven't seen Serena's tools yet, you should use the tool search tool to load all of them
right now.
For exploring and reading code in a token-efficient manner, use Serena's code-centric overview and symbolic search tools.
For edits, where it makes sense, use Serena's editing tools since they are more token efficient.
Use your own editing tools only if Serena's editing tools would be suboptimal.
**IMPORTANT**: Never use a tool before reading its schema via the tool-search tool!
For edits, prefer Serena's editing tools since they are more token efficient.
Especially when editing code you have read through Serena, ALWAYS use Serena's tools for editing, because your built-in tools will deny such edits (they will assume you haven't read the content).
**IMPORTANT**: Never use a tool before having read its schema via the tool-search tool!
excluded_tools:
- create_text_file
- read_file
- execute_shell_command
- replace_content
- find_file
- list_dir
- search_for_pattern
@@ -23,3 +23,5 @@ included_optional_tools:
- jet_brains_type_hierarchy
- jet_brains_rename
- jet_brains_safe_delete
- jet_brains_debug
- serena_info
@@ -0,0 +1,64 @@
# Defines prompts the SerenaInfoTool
prompts:
info_jet_brains_debug_repl: |
Note: The debugger uses 0-based line numbers like other Serena tools (while humans typically communicate 1-based ones).
You rely on the user to provide suitable run configurations for you to work with.
State (objects, variables assigned) persists across calls within the same repl_key.
Pre-bound variables: ``debug`` (debugger interface)
Run configurations:
debug.listRunConfigs()
session = debug.startDebug("MyTestRun") # returns a DebugSession
debug.startRun("MyApp") # non-debug run, fire-and-forget
Session discovery:
debug.sessions()
session = debug.currentSession()
session = debug.session(id)
Breakpoints (project-global, set before or during debugging):
debug.addBreakpoint(file, line)
debug.addBreakpoint(file, line, condition)
debug.addLogpoint(file, line, logExpression)
debug.listBreakpoints()
debug.removeBreakpointAt(file, line)
Execution control (on a DebugSession, e.g. ``session``)::
session.stepOver() | .stepInto() | .stepOut()
session.resume() | .pause() | .stop()
session.runToLine(file, line)
Wait for pause (blocks until the session pauses or times out):
session.waitForPause(timeoutSeconds)
session.stepOverAndWait(timeoutSeconds) # step + wait in one call
session.resumeAndWait(timeoutSeconds)
Inspection (when paused):
session.status() # location + source + variable values in one call
session.variables() # overview of locals/args (values may be truncated)
session.variable(name) # single variable (VariableInfo instance)
session.frames() | .frame() # stack trace | current frame
session.sourceContext(lines) # source around current line
session.threads()
VariableInfo members: name, value (String as presented by the debugger), type
Evaluation & modification:
session.eval("expr") # evaluate in current frame
session.setVar("x", "42") # set variable via assignment
Example sequence of expressions one might execute in a REPL:
debug.addBreakpoint("src/main/java/Foo.java", 42)
session = debug.startDebug("MyTest")
session.waitForPause(30)
session.status()
You can pass multiple statements at once; the result of the final expression is returned:
session = debug.startDebug("MyTest"); session.waitForPause(30); session.status()
Control flow statements are also possible:
while (session.variable("count").value.equals("0")) { session.stepOverAndWait(5) }
When done, close the REPL by passing an empty expression.
@@ -2,6 +2,8 @@
# in which case this prompt will be given as a regular message on the call of a simple tool which the agent
# is encouraged (via the tool description) to call at the beginning of the conversation.
prompts:
connection_prompt: |
CRITICAL: Before starting to work on a coding task, call the `initial_instructions` tool to read the 'Serena Instructions Manual'.
system_prompt: |
You have access to semantic coding tools upon which you rely heavily for all your work.
You operate in a resource-efficient and intelligent manner, always keeping in mind to not read or generate
@@ -41,7 +43,9 @@ prompts:
The following global (not project-specific) memories are available to you: {{ global_memories_list }}
{%- endif -%}
{%- endif %}
Line numbers returned by Serena's tools are 0-based!
The context and modes of operation are described below. These determine how to interact with your user
and which kinds of interactions are expected of you.
+31 -44
View File
@@ -485,32 +485,9 @@ class Dashboard {
// Initialize the application
this.loadToolNames().then(function () {
// Start on overview page
self.loadNews();
self.loadConfigOverview();
self.startConfigPolling();
self.startExecutionsPolling();
});
// Initialize heartbeat interval
setInterval(this.heartbeat.bind(this), 250);
}
heartbeat() {
let self = this;
$.ajax({
url: '/heartbeat',
type: 'GET',
success: function (response) {
self.heartbeatFailureCount = 0;
},
error: function (xhr, status, error) {
self.heartbeatFailureCount++;
console.error('Heartbeat failure; count = ', self.heartbeatFailureCount);
if (self.heartbeatFailureCount >= 1) {
console.log('Server appears to be down, closing tab');
window.close();
}
},
// start on overview page
self.navigateToPage("overview");
});
}
@@ -541,7 +518,6 @@ class Dashboard {
// Start appropriate polling for the page
if (page === 'overview') {
this.loadNews();
this.loadConfigOverview();
this.startConfigPolling();
this.startExecutionsPolling();
} else if (page === 'logs') {
@@ -599,7 +575,8 @@ class Dashboard {
} else {
console.log('Config unchanged, skipping display update');
}
}, error: function (xhr, status, error) {
},
error: function (xhr, status, error) {
console.error('Error loading config overview:', error);
self.$configDisplay.html('<div class="error-message">Error loading configuration</div>');
self.$basicStatsDisplay.html('<div class="error-message">Error loading stats</div>');
@@ -607,24 +584,23 @@ class Dashboard {
self.$availableToolsDisplay.html('<div class="error-message">Error loading tools</div>');
self.$availableModesDisplay.html('<div class="error-message">Error loading modes</div>');
self.$availableContextsDisplay.html('<div class="error-message">Error loading contexts</div>');
}, complete: function () {
},
complete: function () {
self.waitingForConfigPollingResult = false;
}
});
}
startConfigPolling() {
this.loadConfigOverview();
this.configPollInterval = setInterval(this.loadConfigOverview.bind(this), 1000);
}
startExecutionsPolling() {
this.loadExecutions()
// Poll every 1 second for executions (independent of config polling)
// This ensures stuck executions can still be cancelled even if config polling is blocked
this.loadExecutions()
this.executionsPollInterval = setInterval(() => {
this.loadQueuedExecutions();
this.loadLastExecution();
}, 1000);
this.executionsPollInterval = setInterval(this.loadExecutions.bind(this), 1000);
}
displayConfig(config) {
@@ -905,26 +881,30 @@ class Dashboard {
// ===== Executions Methods =====
loadQueuedExecutions() {
loadQueuedExecutions(onComplete) {
let self = this;
$.ajax({
url: '/queued_task_executions', type: 'GET', success: function (response) {
url: '/queued_task_executions', type: 'GET',
success: function (response) {
if (response.status === 'success') {
self.displayActiveExecutionsQueue(response.queued_executions || []);
} else {
console.error('Error loading executions:', response.message);
}
}, error: function (xhr, status, error) {
},
error: function (xhr, status, error) {
console.error('Error loading executions:', error);
self.$activeExecutionQueueDisplay.html('<div class="error-message">Error loading executions</div>');
}
},
complete: onComplete
});
}
loadLastExecution() {
loadLastExecution(onComplete) {
let self = this;
$.ajax({
url: '/last_execution', type: 'GET', success: function (response) {
url: '/last_execution', type: 'GET',
success: function (response) {
if (response.status === 'success') {
if (response.last_execution !== null && response.last_execution.logged) {
self.displayLastExecution(response.last_execution);
@@ -932,21 +912,28 @@ class Dashboard {
} else {
console.error('Error loading last execution:', response.message);
}
}, error: function (xhr, status, error) {
},
error: function (xhr, status, error) {
console.error('Error loading last execution:', error);
self.$lastExecutionDisplay.html('<div class="error-message">Error loading last execution</div>');
}
},
complete: onComplete
});
}
loadExecutions() {
const self = this;
if (this.waitingForExecutionsPollingResult) {
console.log('Still waiting for previous executions poll result, skipping this poll');
} else {
}
else {
this.waitingForExecutionsPollingResult = true;
console.log('Polling for executions...');
this.loadQueuedExecutions();
this.loadLastExecution();
this.loadQueuedExecutions(function() {
self.loadLastExecution(function() {
self.waitingForExecutionsPollingResult = false;
});
});
}
}
@@ -46,6 +46,21 @@ web_dashboard: True
# See also: https://oraios.github.io/serena/02-usage/060_dashboard.html
web_dashboard_open_on_launch: True
# defines the interface (application mode) used for the web dashboard (if enabled).
# If empty/null, use platform-dependent default. Otherwise, possible values:
# * browser: the dashboard is opened in the default browser (if `web_dashboard_open_on_launch` is true)
# This is supported on all platforms.
# * app: the dashboard is opened in a separate native-like app window with accompanying tray icon, whose
# lifecycle is tied to the Serena process.
# If `web_dashboard_open_on_launch` is false, the dashboard can be conveniently accessed via the tray icon.
# This is supported on Windows and macOS, but note that on macOS, where tray icons are very visible,
# this may result in too many icons being displayed when using multi-agent setups.
# * tray_manager: use a global tray icon to provide access to the dashboards of all running Serena instances,
# opening the dashboard in browser tabs when selected from the tray menu.
# This is EXPERIMENTAL. It is tested on Windows only. We will establish macOS support, but it is yet untested.
# On Linux, this cannot be universally supported, but it may work in some desktop environments.
web_dashboard_interface:
# the address the web dashboard will listen on (bind address).
web_dashboard_listen_address: 127.0.0.1
+2 -2
View File
@@ -347,7 +347,6 @@ class SearchForPatternTool(Tool):
is used to further restrict the search).
Smartly combining the various restrictions allows you to perform very targeted searches.
:param substring_pattern: Regular expression for a substring pattern to search for
:param context_lines_before: Number of lines of context to include before each match
:param context_lines_after: Number of lines of context to include after each match
@@ -373,7 +372,7 @@ class SearchForPatternTool(Tool):
For example, for finding classes or methods from a name pattern.
Setting to False is a better choice if you also want to search in non-code files, like in html or yaml files,
which is why it is the default.
:return: A mapping of file paths to lists of matched consecutive lines.
:return: A mapping from file paths to matched consecutive lines (0-based line numbers).
"""
abs_path = os.path.join(self.get_project_root(), relative_path)
if not os.path.exists(abs_path):
@@ -411,6 +410,7 @@ class SearchForPatternTool(Tool):
paths_include_glob=paths_include_glob,
paths_exclude_glob=paths_exclude_glob,
)
# group matches by file
file_to_matches: dict[str, list[str]] = defaultdict(list)
for match in matches:
+30
View File
@@ -565,3 +565,33 @@ class JetBrainsRenameTool(Tool, ToolMarkerSymbolicEdit, ToolMarkerOptional):
rename_in_text_occurrences=rename_in_text_occurrences,
)
return self._to_json(result)
class JetBrainsDebugTool(Tool, ToolMarkerOptional, ToolMarkerBeta):
"""
Provides debugging functionality (run configs, breakpoints, stepping, inspection, and evaluation)
via a persistent debug REPL connected to the JetBrains IDE.
"""
def apply(
self,
expression: str,
repl_key: str = "default",
) -> str:
"""
Debug code interactively by evaluating Groovy expressions in a persistent REPL.
Important: Debugging should only be applied if the user has requested it!
Use the `serena_info` tool with topic `jet_brains_debug_repl` for usage information.
:param expression: a Groovy/Java expression/statement to evaluate in the REPL.
If empty/null, closes the REPL with the given key.
:param repl_key: identifier for the REPL instance. State persists across calls with the same key.
:return: string representation of the result
"""
with JetBrainsPluginClient.from_project(self.project) as client:
if expression:
response = client.debug_eval(repl_key=repl_key, expression=expression)
else:
response = client.debug_close(repl_key=repl_key)
return response.get("result", str(response))
+18 -1
View File
@@ -4,7 +4,7 @@ Tools supporting the general workflow of the agent
import platform
from serena.tools import ReadMemoryTool, Tool, ToolMarkerDoesNotRequireActiveProject, WriteMemoryTool
from serena.tools import ReadMemoryTool, Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional, WriteMemoryTool
class CheckOnboardingPerformedTool(Tool):
@@ -72,3 +72,20 @@ class InitialInstructionsTool(Tool, ToolMarkerDoesNotRequireActiveProject):
as it will critically inform you!
"""
return self.agent.create_system_prompt(session_id=session_id)
class SerenaInfoTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveProject):
"""
Provides information about an advanced topic on demand, facilitating context-efficiency.
"""
def apply(self, topic: str) -> str:
"""
Retrieves Serena-specific information
:param topic: the topic, which you must have been given explicitly
"""
match topic:
case "jet_brains_debug_repl":
return self.agent.prompt_factory.create_info_jet_brains_debug_repl()
case _:
raise ValueError("Invalid topic: " + topic)
+7 -10
View File
@@ -188,17 +188,16 @@ def search_text(
end_pos = match.end()
# Find the line numbers for the start and end positions
start_line_num = content[:start_pos].count("\n") + 1
end_line_num = content[:end_pos].count("\n") + 1
start_line_num = content[:start_pos].count("\n")
end_line_num = content[:end_pos].count("\n")
# Calculate the range of lines to include in the context
context_start = max(1, start_line_num - context_lines_before)
context_end = min(total_lines, end_line_num + context_lines_after)
context_start = max(0, start_line_num - context_lines_before)
context_end = min(total_lines - 1, end_line_num + context_lines_after)
# Create TextLine objects for the context
context_lines = []
for i in range(context_start - 1, context_end):
line_num = i + 1
for line_num in range(context_start, context_end + 1):
if context_start <= line_num < start_line_num:
match_type = LineType.BEFORE_MATCH
elif end_line_num < line_num <= context_end:
@@ -206,7 +205,7 @@ def search_text(
else:
match_type = LineType.MATCH
context_lines.append(TextLine(line_number=line_num, line_content=lines[i], match_type=match_type))
context_lines.append(TextLine(line_number=line_num, line_content=lines[line_num], match_type=match_type))
matches.append(MatchedConsecutiveLines(lines=context_lines, source_file_path=source_file_path))
else:
@@ -215,7 +214,6 @@ def search_text(
# Search line by line, normal compile without DOTALL
compiled_pattern = re.compile(pattern)
for i, line in enumerate(lines):
line_num = i + 1
if compiled_pattern.search(line):
# Calculate the range of lines to include in the context
context_start = max(0, i - context_lines_before)
@@ -224,7 +222,6 @@ def search_text(
# Create TextLine objects for the context
context_lines = []
for j in range(context_start, context_end + 1):
context_line_num = j + 1
if j < i:
match_type = LineType.BEFORE_MATCH
elif j > i:
@@ -232,7 +229,7 @@ def search_text(
else:
match_type = LineType.MATCH
context_lines.append(TextLine(line_number=context_line_num, line_content=lines[j], match_type=match_type))
context_lines.append(TextLine(line_number=j, line_content=lines[j], match_type=match_type))
matches.append(MatchedConsecutiveLines(lines=context_lines, source_file_path=source_file_path))
@@ -360,6 +360,28 @@ class ElixirTools(SolidLanguageServer):
self.server.notify.initialized({})
# Expert's build pipeline is triggered by a textDocument/didOpen notification.
# Without it Expert sits idle and never emits the $/progress signals we wait for,
# causing a deadlock. Opening mix.exs is the minimal trigger; we close it again
# once the server is ready so it does not linger in the open-file set.
mix_exs_path = os.path.join(self.repository_root_path, "mix.exs")
mix_exs_uri = pathlib.Path(mix_exs_path).as_uri() if os.path.exists(mix_exs_path) else None
if mix_exs_uri is not None:
with open(mix_exs_path, encoding="utf-8") as f:
mix_exs_content = f.read()
self.server.notify.did_open_text_document(
{
"textDocument": {
"uri": mix_exs_uri,
"languageId": "elixir",
"version": 1,
"text": mix_exs_content,
}
}
)
log.debug("Opened mix.exs to trigger Expert's build pipeline")
# Expert needs time to compile the project and build indexes on first run.
# This can take 2-3+ minutes for mid-sized codebases.
# After the first run, subsequent startups are much faster.
@@ -370,3 +392,7 @@ class ElixirTools(SolidLanguageServer):
else:
log.warning(f"Expert did not signal readiness within {ready_timeout}s. Proceeding with requests anyway.")
self.server_ready.set() # Mark as ready anyway to allow requests
if mix_exs_uri is not None:
self.server.notify.did_close_text_document({"textDocument": {"uri": mix_exs_uri}})
log.debug("Closed mix.exs after Expert is ready")
@@ -0,0 +1,164 @@
"""
Provides JSON specific instantiation of the LanguageServer class using vscode-json-languageserver.
Contains various configurations and settings specific to JSON files.
"""
import logging
import os
import pathlib
import shutil
from typing import Any
from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.settings import SolidLSPSettings
log = logging.getLogger(__name__)
class JsonLanguageServer(SolidLanguageServer):
"""
Provides JSON specific instantiation of the LanguageServer class using vscode-json-languageserver.
Contains various configurations and settings specific to JSON files.
Note: Cross-file references are not supported for JSON (the language server only provides
document symbols and hover). JSON is useful for getting a structured overview of JSON files
and navigating their contents.
"""
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
"""
Creates a JsonLanguageServer instance. This class is not meant to be instantiated directly.
Use LanguageServer.create() instead.
"""
super().__init__(
config,
repository_root_path,
None,
"json",
solidlsp_settings,
)
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
def _get_or_install_core_dependency(self) -> str:
"""
Setup runtime dependencies for JSON Language Server and return the path to the executable.
"""
is_node_installed = shutil.which("node") is not None
assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again."
is_npm_installed = shutil.which("npm") is not None
assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again."
json_language_server_version = self._custom_settings.get("json_language_server_version", "1.3.4")
npm_registry = self._custom_settings.get("npm_registry")
deps = RuntimeDependencyCollection(
[
RuntimeDependency(
id="vscode-json-languageserver",
description="vscode-json-languageserver package (Microsoft)",
command=build_npm_install_command("vscode-json-languageserver", json_language_server_version, npm_registry),
platform_id="any",
),
]
)
json_ls_dir = os.path.join(self._ls_resources_dir, "json-lsp")
json_executable_path = os.path.join(json_ls_dir, "node_modules", ".bin", "vscode-json-languageserver")
if os.name == "nt":
json_executable_path += ".cmd"
if not os.path.exists(json_executable_path):
log.info(f"JSON Language Server executable not found at {json_executable_path}. Installing...")
deps.install(json_ls_dir)
log.info("JSON language server dependencies installed successfully")
if not os.path.exists(json_executable_path):
raise FileNotFoundError(
f"vscode-json-languageserver executable not found at {json_executable_path}, something went wrong with the installation."
)
return json_executable_path
def _create_launch_command(self, core_path: str) -> list[str]:
return [core_path, "--stdio"]
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialize params for the JSON Language Server.
"""
root_uri = pathlib.Path(repository_absolute_path).as_uri()
initialize_params = {
"locale": "en",
"capabilities": {
"textDocument": {
"synchronization": {"didSave": True, "dynamicRegistration": True},
"completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}},
"definition": {"dynamicRegistration": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"hierarchicalDocumentSymbolSupport": True,
"symbolKind": {"valueSet": list(range(1, 27))},
},
"hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]},
},
"workspace": {
"workspaceFolders": True,
"didChangeConfiguration": {"dynamicRegistration": True},
},
},
"processId": os.getpid(),
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"workspaceFolders": [
{
"uri": root_uri,
"name": os.path.basename(repository_absolute_path),
}
],
}
return initialize_params # type: ignore
def _start_server(self) -> None:
"""
Starts the JSON Language Server, waits for the server to be ready and yields the LanguageServer instance.
"""
def register_capability_handler(params: Any) -> None:
return
def do_nothing(params: Any) -> None:
return
def window_log_message(msg: dict) -> None:
log.info(f"LSP: window/logMessage: {msg}")
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("$/progress", do_nothing)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
log.info("Starting JSON server process")
self.server.start()
initialize_params = self._get_initialize_params(self.repository_root_path)
log.info("Sending initialize request from LSP client to LSP server and awaiting response")
init_response = self.server.send.initialize(initialize_params)
log.debug(f"Received initialize response from JSON server: {init_response}")
if "documentSymbolProvider" in init_response.get("capabilities", {}):
log.info("JSON server supports document symbols")
else:
log.warning("Warning: JSON server does not report document symbol support")
self.server.notify.initialized({})
log.info("JSON server initialization complete")
+13
View File
@@ -122,6 +122,12 @@ class Language(str, Enum):
"""YAML language server (experimental).
Must be explicitly specified as the main language, not auto-detected.
"""
JSON = "json"
"""JSON language server using vscode-json-languageserver (experimental).
Provides document symbol navigation and hover for JSON files.
Must be explicitly specified as the main language, not auto-detected.
Requires Node.js and npm.
"""
TOML = "toml"
"""TOML language server using Taplo.
Supports TOML validation, formatting, and schema support.
@@ -174,6 +180,7 @@ class Language(str, Enum):
self.PHP_PHPACTOR,
self.MARKDOWN,
self.YAML,
self.JSON,
self.TOML,
self.GROOVY,
self.CPP_CCLS,
@@ -252,6 +259,8 @@ class Language(str, Enum):
return FilenameMatcher("*.cr")
case self.YAML:
return FilenameMatcher("*.yaml", "*.yml")
case self.JSON:
return FilenameMatcher("*.json", "*.jsonc")
case self.TOML:
return FilenameMatcher("*.toml")
case self.ZIG:
@@ -446,6 +455,10 @@ class Language(str, Enum):
from solidlsp.language_servers.yaml_language_server import YamlLanguageServer
return YamlLanguageServer
case self.JSON:
from solidlsp.language_servers.json_language_server import JsonLanguageServer
return JsonLanguageServer
case self.TOML:
from solidlsp.language_servers.taplo_server import TaploServer
@@ -0,0 +1,24 @@
{
"app": {
"name": "test-application",
"version": "1.0.0",
"port": 8080,
"debug": true
},
"database": {
"host": "localhost",
"port": 5432,
"name": "testdb",
"username": "admin"
},
"logging": {
"level": "info",
"format": "json",
"outputs": ["console", "file"]
},
"features": {
"authentication": true,
"caching": false,
"monitoring": true
}
}
@@ -0,0 +1,30 @@
{
"users": [
{
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"roles": ["admin", "user"]
},
{
"id": 2,
"name": "Bob",
"email": "bob@example.com",
"roles": ["user"]
}
],
"projects": [
{
"id": 101,
"name": "Project Alpha",
"status": "active",
"tags": ["backend", "api"]
},
{
"id": 102,
"name": "Project Beta",
"status": "planning",
"tags": ["frontend"]
}
]
}
+2 -2
View File
@@ -19,8 +19,8 @@ class TestSearchText:
assert len(matches) == 1
assert matches[0].num_matched_lines == 1
assert matches[0].start_line == 3
assert matches[0].end_line == 3
assert matches[0].start_line == 2
assert matches[0].end_line == 2
assert matches[0].lines[0].line_content.strip() == 'print("Hello, World!")'
def test_search_text_with_regex_pattern(self):
+104
View File
@@ -0,0 +1,104 @@
"""Unit tests for Elixir Expert language server startup behavior."""
import threading
from unittest.mock import MagicMock
from solidlsp.language_servers.elixir_tools.elixir_tools import ElixirTools
def _make_elixir_tools(tmp_path):
language_server = object.__new__(ElixirTools)
language_server.repository_root_path = str(tmp_path)
language_server.server_ready = threading.Event()
language_server.request_id = 0
return language_server
def _make_mock_server():
server = MagicMock()
server.send.initialize.return_value = {"capabilities": {"textDocumentSync": {}}}
return server
class TestElixirToolsStartup:
"""Tests for ElixirTools._start_server() startup behavior — the Expert deadlock fix."""
def test_sends_did_open_for_mix_exs_after_initialized(self, tmp_path):
"""Expert blocks waiting for a didOpen notification to trigger its build pipeline."""
(tmp_path / "mix.exs").write_text("defmodule App.MixProject do\n use Mix.Project\nend\n", encoding="utf-8")
language_server = _make_elixir_tools(tmp_path)
server = _make_mock_server()
language_server.server = server
language_server.server_ready.set()
language_server._start_server()
calls = server.notify.did_open_text_document.call_args_list
assert len(calls) == 1, f"Expected one didOpen notification for mix.exs, got {len(calls)}"
text_doc = calls[0][0][0]["textDocument"]
assert text_doc["uri"].endswith("mix.exs"), f"Expected mix.exs URI, got {text_doc['uri']}"
assert text_doc["languageId"] == "elixir"
def test_did_open_contains_mix_exs_content(self, tmp_path):
"""The didOpen notification must include the file content so Expert can parse it."""
content = "defmodule App.MixProject do\n use Mix.Project\nend\n"
(tmp_path / "mix.exs").write_text(content, encoding="utf-8")
language_server = _make_elixir_tools(tmp_path)
server = _make_mock_server()
language_server.server = server
language_server.server_ready.set()
language_server._start_server()
calls = server.notify.did_open_text_document.call_args_list
assert len(calls) == 1
text_doc = calls[0][0][0]["textDocument"]
assert text_doc["text"] == content
def test_closes_mix_exs_after_server_ready(self, tmp_path):
"""mix.exs is closed after Expert signals readiness so it does not linger open."""
(tmp_path / "mix.exs").write_text("defmodule App.MixProject do\n use Mix.Project\nend\n", encoding="utf-8")
language_server = _make_elixir_tools(tmp_path)
server = _make_mock_server()
language_server.server = server
language_server.server_ready.set()
language_server._start_server()
close_calls = server.notify.did_close_text_document.call_args_list
assert len(close_calls) == 1, f"Expected one didClose for mix.exs, got {len(close_calls)}"
text_doc = close_calls[0][0][0]["textDocument"]
assert text_doc["uri"].endswith("mix.exs"), f"Expected mix.exs URI, got {text_doc['uri']}"
def test_startup_event_order_initialized_then_didopen_then_didclose(self, tmp_path):
"""Initialized → didOpen(mix.exs) → [ready] → didClose(mix.exs) is the required sequence."""
(tmp_path / "mix.exs").write_text("defmodule App.MixProject do\n use Mix.Project\nend\n", encoding="utf-8")
events: list[str] = []
language_server = _make_elixir_tools(tmp_path)
server = _make_mock_server()
language_server.server = server
language_server.server_ready.set()
server.notify.initialized.side_effect = lambda *_: events.append("initialized")
server.notify.did_open_text_document.side_effect = lambda *_: events.append("didOpen")
server.notify.did_close_text_document.side_effect = lambda *_: events.append("didClose")
language_server._start_server()
assert events == ["initialized", "didOpen", "didClose"], f"Unexpected startup event order: {events}"
def test_no_crash_when_mix_exs_missing(self, tmp_path):
"""_start_server must not crash if mix.exs is absent (non-standard project layout)."""
language_server = _make_elixir_tools(tmp_path)
server = _make_mock_server()
language_server.server = server
language_server.server_ready.set()
language_server._start_server()
server.notify.did_open_text_document.assert_not_called()
View File
Whitespace-only changes.
+78
View File
@@ -0,0 +1,78 @@
"""
Basic integration tests for the JSON language server functionality.
These tests validate the functionality of the language server APIs
like request_document_symbols using the JSON test repository.
"""
from pathlib import Path
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from test.solidlsp.conftest import format_symbol_for_assert, has_malformed_name, request_all_symbols
@pytest.mark.json
class TestJsonLanguageServerBasics:
"""Test basic functionality of the JSON language server."""
@pytest.mark.parametrize("language_server", [Language.JSON], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.JSON], indirect=True)
def test_json_language_server_initialization(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
"""Test that JSON language server can be initialized successfully."""
assert language_server is not None
assert language_server.language == Language.JSON
assert language_server.is_running()
assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve()
@pytest.mark.parametrize("language_server", [Language.JSON], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.JSON], indirect=True)
def test_json_config_file_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
"""Test document symbols detection in config.json with specific symbol verification."""
all_symbols, root_symbols = language_server.request_document_symbols("config.json").get_all_symbols_and_roots()
assert all_symbols is not None, "Should return symbols for config.json"
assert len(all_symbols) > 0, f"Should find symbols in config.json, found {len(all_symbols)}"
symbol_names = [sym.get("name") for sym in all_symbols]
assert "app" in symbol_names, "Should detect 'app' key in config.json"
assert "database" in symbol_names, "Should detect 'database' key in config.json"
assert "logging" in symbol_names, "Should detect 'logging' key in config.json"
assert "features" in symbol_names, "Should detect 'features' key in config.json"
# Verify nested symbols
assert "name" in symbol_names, "Should detect nested 'name' key"
assert "port" in symbol_names, "Should detect nested 'port' key"
assert "debug" in symbol_names, "Should detect nested 'debug' key"
@pytest.mark.parametrize("language_server", [Language.JSON], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.JSON], indirect=True)
def test_json_data_file_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
"""Test symbol detection in data.json with array structures."""
all_symbols, root_symbols = language_server.request_document_symbols("data.json").get_all_symbols_and_roots()
assert all_symbols is not None, "Should return symbols for data.json"
assert len(all_symbols) > 0, f"Should find symbols in data.json, found {len(all_symbols)}"
symbol_names = [sym.get("name") for sym in all_symbols]
assert "users" in symbol_names, "Should detect 'users' array"
assert "projects" in symbol_names, "Should detect 'projects' array"
assert "name" in symbol_names, "Should detect 'name' fields"
assert "email" in symbol_names, "Should detect 'email' fields"
assert "id" in symbol_names, "Should detect 'id' fields"
@pytest.mark.parametrize("language_server", [Language.JSON], indirect=True)
def test_bare_symbol_names(self, language_server: SolidLanguageServer) -> None:
"""Test that symbol names do not contain malformed characters."""
all_symbols = request_all_symbols(language_server)
malformed_symbols = []
for s in all_symbols:
if has_malformed_name(s, period_allowed=True):
malformed_symbols.append(s)
if malformed_symbols:
pytest.fail(
f"Found malformed symbols: {[format_symbol_for_assert(sym) for sym in malformed_symbols]}",
pytrace=False,
)
Generated
+8 -119
View File
@@ -190,23 +190,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" },
]
[[package]]
name = "bleach"
version = "6.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "webencodings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" },
]
[package.optional-dependencies]
css = [
{ name = "tinycss2" },
]
[[package]]
name = "blinker"
version = "1.9.0"
@@ -571,15 +554,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
]
[[package]]
name = "defusedxml"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
]
[[package]]
name = "dict2css"
version = "0.3.0.post1"
@@ -1274,15 +1248,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" },
]
[[package]]
name = "jupyterlab-pygments"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" },
]
[[package]]
name = "latexcodec"
version = "3.0.1"
@@ -1417,7 +1382,7 @@ wheels = [
[[package]]
name = "mcp"
version = "1.26.0"
version = "1.27.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -1435,9 +1400,9 @@ dependencies = [
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
{ url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" },
]
[[package]]
@@ -1461,15 +1426,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "mistune"
version = "3.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" },
]
[[package]]
name = "more-itertools"
version = "10.8.0"
@@ -1636,31 +1592,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/83/a0/5b0c2f11142ed1dddec842457d3f65eaf71a0080894eb6f018755b319c3a/nbclient-0.10.4-py3-none-any.whl", hash = "sha256:9162df5a7373d70d606527300a95a975a47c137776cd942e52d9c7e29ff83440", size = 25465, upload-time = "2025-12-23T07:45:44.51Z" },
]
[[package]]
name = "nbconvert"
version = "7.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beautifulsoup4" },
{ name = "bleach", extra = ["css"] },
{ name = "defusedxml" },
{ name = "jinja2" },
{ name = "jupyter-core" },
{ name = "jupyterlab-pygments" },
{ name = "markupsafe" },
{ name = "mistune" },
{ name = "nbclient" },
{ name = "nbformat" },
{ name = "packaging" },
{ name = "pandocfilters" },
{ name = "pygments" },
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/47/81f886b699450d0569f7bc551df2b1673d18df7ff25cc0c21ca36ed8a5ff/nbconvert-7.17.0.tar.gz", hash = "sha256:1b2696f1b5be12309f6c7d707c24af604b87dfaf6d950794c7b07acab96dda78", size = 862855, upload-time = "2026-01-29T16:37:48.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/4b/8d5f796a792f8a25f6925a96032f098789f448571eb92011df1ae59e8ea8/nbconvert-7.17.0-py3-none-any.whl", hash = "sha256:4f99a63b337b9a23504347afdab24a11faa7d86b405e5c8f9881cd313336d518", size = 261510, upload-time = "2026-01-29T16:37:46.322Z" },
]
[[package]]
name = "nbformat"
version = "5.10.4"
@@ -1676,23 +1607,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" },
]
[[package]]
name = "nbsphinx"
version = "0.9.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "docutils" },
{ name = "jinja2" },
{ name = "nbconvert" },
{ name = "nbformat" },
{ name = "sphinx" },
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1e/84/b1856b7651ac34e965aa567a158714c7f3bd42a1b1ce76bf423ffb99872c/nbsphinx-0.9.7.tar.gz", hash = "sha256:abd298a686d55fa894ef697c51d44f24e53aa312dadae38e82920f250a5456fe", size = 180479, upload-time = "2025-03-03T19:46:08.069Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/2d/8c8e635bcc6757573d311bb3c5445426382f280da32b8cd6d82d501ef4a4/nbsphinx-0.9.7-py3-none-any.whl", hash = "sha256:7292c3767fea29e405c60743eee5393682a83982ab202ff98f5eb2db02629da8", size = 31660, upload-time = "2025-03-03T19:46:06.581Z" },
]
[[package]]
name = "nest-asyncio"
version = "1.6.0"
@@ -1729,15 +1643,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pandocfilters"
version = "1.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" },
]
[[package]]
name = "parso"
version = "0.8.6"
@@ -2412,11 +2317,11 @@ wheels = [
[[package]]
name = "python-dotenv"
version = "1.2.1"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
@@ -2999,8 +2904,6 @@ dev = [
{ name = "jinja2" },
{ name = "jupyter-book" },
{ name = "mypy" },
{ name = "nbconvert" },
{ name = "nbsphinx" },
{ name = "poethepoet" },
{ name = "pyasn1" },
{ name = "pyinstrument" },
@@ -3041,10 +2944,8 @@ requires-dist = [
{ name = "joblib", specifier = "==1.5.1" },
{ name = "jupyter-book", marker = "extra == 'dev'", specifier = "==1.0.4.post1" },
{ name = "lsprotocol", specifier = "==2025.0.0" },
{ name = "mcp", specifier = "==1.26.0" },
{ name = "mcp", specifier = "==1.27.0" },
{ name = "mypy", marker = "extra == 'dev'", specifier = "==1.17.0" },
{ name = "nbconvert", marker = "extra == 'dev'", specifier = "==7.17.0" },
{ name = "nbsphinx", marker = "extra == 'dev'", specifier = "==0.9.7" },
{ name = "overrides", specifier = "==7.7.0" },
{ name = "pathspec", specifier = "==0.12.1" },
{ name = "poethepoet", marker = "extra == 'dev'", specifier = "==0.36.0" },
@@ -3059,7 +2960,7 @@ requires-dist = [
{ name = "pytest", marker = "extra == 'dev'", specifier = "==8.4.1" },
{ name = "pytest-timeout", marker = "extra == 'dev'", specifier = "==2.4.0" },
{ name = "pytest-xdist", marker = "extra == 'dev'", specifier = "==3.8.0" },
{ name = "python-dotenv", specifier = "==1.2.1" },
{ name = "python-dotenv", specifier = "==1.2.2" },
{ name = "python-multipart", specifier = "==0.0.26" },
{ name = "pythonnet", marker = "sys_platform == 'win32'", specifier = "==3.1.0rc0" },
{ name = "pywebview", specifier = "==6.2" },
@@ -3647,18 +3548,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" },
]
[[package]]
name = "tinycss2"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "webencodings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" },
]
[[package]]
name = "toml-sort"
version = "0.24.2"