mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-12 14:15:14 +00:00
Merge branch 'refs/heads/main' into cleanup/remove_set_modes
This commit is contained in:
commit
0a07b83ccd
36 files changed
+1399
-299
No files matched your search
+15
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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++",
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
Whitespace-only changes.
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user