Add Grok Build support (context, setup, native hooks)

Adds first-class support for xAI's Grok Build CLI, following the
existing client-integration pattern (Claude Code, CodeBuddy, Codex):

- grok context: single-project, shaped like claude-code.yml, with the
  file-tool exclusions appropriate for a CLI agent that has its own
  read/edit/shell tools. structured_tool_output is left at the auto
  default (as in the codex and codebuddy contexts); claude-code's
  explicit false is a Claude-Code-specific bug workaround that is not
  carried over.
- serena setup grok: registers Serena as a user-scoped MCP server via
  "grok mcp add" after detecting a compatible grok CLI.
- serena-hooks --client=grok: Grok-native PreToolUse output
  ({decision, reason}) instead of the Claude-style hookSpecificOutput
  envelope; grep/read detection extended to Grok's tool names and
  shell payloads (target_file/targetFile path keys, non-string command
  values handled defensively). The payload-parsing refinements live in
  the remind hook's client-agnostic parsing and apply to all hook
  clients: a non-string command value previously raised, and
  target_file/targetFile paths were not recognized.
- Docs (030_clients.md #grok), unit tests for setup/hooks/context, and
  scripts/live_test_grok.py - a zero-inference, state-preserving live
  smoke test against a real grok installation (documented in
  CONTRIBUTING.md). Its M5 check verifies the structured-output wire
  shape: the grok context (auto default) serves an output schema and
  structuredContent for string-returning tools, while the claude-code
  context (explicit false workaround) serves plain text only.
This commit is contained in:
xormania authored and Dominik Jain committed 2026-07-21 21:18:16 +02:00
1 parent 96ee9b65e2
commit 9debf7742a
13 files changed
+2122 -8

No files matched your search

+9
View File
@@ -2,9 +2,18 @@
Status of the `main` branch. Changes prior to the next official version change will appear here.
* General:
- Add Grok Build support (context `grok`, setup CLI, hooks)
* Language Servers:
- Add `python_basedpyright` as an alternative Python language server
* Hooks:
- Add `serena-hooks --client=grok`, including Grok-native PreToolUse allow/deny output.
- PreToolUse remind hook: coerce non-string shell command values instead of failing, and recognize
`target_file`/`targetFile` file-path keys (shared payload parsing, applies to all hook clients).
# v1.6.1 (2026-07-21)
* General:
+82
View File
@@ -380,6 +380,88 @@ The hooks will:
The `PreToolUse` matcher is intentionally restricted to `Bash`. The Serena reminder hook for Codex
tracks shell-based grep and code-file reads, so running it for every tool call is unnecessary.
## Grok
Serena provides native support for xAI's Grok Build CLI. To set up the Serena MCP server for Grok,
simply run:
serena setup grok
### Manual Setup
**Global Configuration**. To add the Serena MCP server for all your projects, use Grok's user-level configuration and the `--project-from-cwd` flag:
```bash
grok mcp add --scope user serena -- serena start-mcp-server --context=grok --project-from-cwd
```
Alternatively, add the following to `~/.grok/config.toml`:
```toml
[mcp_servers.serena]
command = "serena"
args = ["start-mcp-server", "--project-from-cwd", "--context=grok"]
```
**Project-Level Configuration**. To add the Serena MCP server for a single project only:
```bash
grok mcp add --scope project serena -- serena start-mcp-server --context=grok --project "$(pwd)"
```
**Verification.**
Run `grok inspect` and verify that Serena is listed as an MCP server. You can also use Grok's `/mcps`
modal to inspect, refresh, enable, or disable configured MCP servers.
Grok can also load existing Claude Code MCP configuration. If you previously ran `serena setup claude-code`,
Serena may already appear in Grok, but it will use the `claude-code` context instead of the dedicated `grok` context.
### Hooks
Grok supports lifecycle hooks; see Grok's bundled hooks documentation for details. To enable Serena's hooks
for Grok globally, create `~/.grok/hooks/serena-hooks.json` with the following content. For a single project,
create `.grok/hooks/serena-hooks.json` in that project instead; note that Grok loads project-scoped hooks
only after you have trusted the project for hook execution (via Grok's `/hooks-trust` command).
```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "grep|read_file|run_terminal_command",
"hooks": [
{
"type": "command",
"command": "serena-hooks remind --client=grok",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "serena-hooks cleanup --client=grok",
"timeout": 5
}
]
}
]
}
}
```
The hooks will:
- **`remind`**: Nudge the agent to use Serena's symbolic tools when it makes too many consecutive
code-search or code-file-read calls without using Serena tools in between.
- **`cleanup`**: Clean up hook session data when the agent turn ends.
Grok ignores stdout from passive hooks such as `SessionStart`, so the Grok hook setup intentionally
uses only `PreToolUse` for reminders and `Stop` for cleanup.
## Claude Desktop
On Windows and macOS, there are official [Claude Desktop applications by Anthropic](https://claude.ai/download); for Linux, there is an [open-source
+2 -1
View File
@@ -73,6 +73,7 @@ Serena comes with pre-defined contexts:
The full set of Serena's tools is provided, as the application is assumed to have no prior coding-specific capabilities.
* `claude-code`: Optimized for use with Claude Code, it disables tools that would duplicate Claude Code's built-in capabilities.
* `codex`: Optimized for use with OpenAI Codex.
* `grok`: Optimized for use with xAI's Grok Build CLI.
* `ide`: Generic context for IDE assistants/coding agents, e.g. VSCode, Cursor, or Cline, focusing on augmenting existing capabilities.
Basic file operations and shell execution are assumed to be handled by the assistant's own capabilities.
* `agent`: Designed for scenarios where Serena acts as a more autonomous agent, for example, when used with Agno.
@@ -81,7 +82,7 @@ Choose the context that best matches the type of integration you are using.
Find the concrete definitions of the above contexts [here](https://github.com/oraios/serena/tree/main/src/serena/resources/config/contexts).
Note that the contexts `ide` and `claude-code` are **single-project contexts** (defining `single_project: true`).
Note that the contexts `ide`, `claude-code`, and `grok` are **single-project contexts** (defining `single_project: true`).
For such contexts, if a project is provided at startup, the set of tools is limited to those required by the project's
concrete configuration, and other tools are excluded completely, allowing the set of tools to be minimal.
Tools explicitly disabled by the project will not be available at all. Since changing the active project
File diff suppressed because it is too large. Load diff
+35 -1
View File
@@ -109,4 +109,38 @@ class ClientSetupHandlerCodeBuddy(ClientSetupHandler):
return is_success
client_setup_handlers = [ClientSetupHandlerClaudeCode(), ClientSetupHandlerCodeBuddy(), ClientSetupHandlerCodex()]
class ClientSetupHandlerGrok(ClientSetupHandler):
"""
Setup for xAI Grok Build.
"""
def __init__(self) -> None:
super().__init__("grok")
def is_applicable(self) -> bool:
version_result = execute_shell_command("grok --version", capture_stderr=True)
if version_result.return_code != 0 or not version_result.stdout.lower().startswith("grok "):
return False
mcp_result = execute_shell_command("grok mcp add --help", capture_stderr=True)
return mcp_result.return_code == 0 and "Add or update an MCP server" in mcp_result.stdout
def get_mcp_server_options(self) -> list[str]:
return ["--context=grok", "--project-from-cwd"]
def apply(self) -> bool:
cmd = f"grok mcp add --scope user serena -- {self.get_mcp_server_command()}"
is_success = self._run_shell_command(cmd)
if is_success:
click.echo("\nIMPORTANT: We additionally recommend to set up hooks for Grok to ensure the best experience.")
click.echo(" Please read the instructions here:")
click.echo(" https://oraios.github.io/serena/02-usage/030_clients.html#grok")
return is_success
client_setup_handlers = [
ClientSetupHandlerClaudeCode(),
ClientSetupHandlerCodeBuddy(),
ClientSetupHandlerCodex(),
ClientSetupHandlerGrok(),
]
+23 -6
View File
@@ -25,6 +25,7 @@ class HookClient(Enum):
CODEBUDDY = "codebuddy"
VSCODE = "vscode"
CODEX = "codex"
GROK = "grok"
class Hook(ABC):
@@ -88,6 +89,12 @@ class PreToolUseHook(Hook, ABC):
additional_context: str = ""
def to_json_string(self, client: HookClient) -> str:
if client == HookClient.GROK:
grok_output: dict[str, str] = {"decision": self.permission_decision}
if self.permission_decision == "deny":
grok_output["reason"] = self.permission_decision_reason
return json.dumps(grok_output)
hook_output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
@@ -267,11 +274,11 @@ class PreToolUseRemindAboutSymbolicToolsHook(PreToolUseHook):
#: are caught alongside ``read_file``, while ``write_file``/``edit_file`` are not.
_READ_FILE_VERB_SUBSTRINGS: frozenset[str] = frozenset(("read", "view", "open", "show"))
#: Shell commands that perform grep-like search; used to classify Codex
#: Shell commands that perform grep-like search; used to classify Codex/Grok
#: shell-command tool calls whose ``cmd`` or ``command`` field starts with one of these.
_GREP_SHELL_COMMANDS: frozenset[str] = frozenset(("grep", "rg", "ag", "ack", "fgrep", "egrep", "search_for_pattern"))
#: Shell commands that perform file-read operations; used to classify Codex
#: Shell commands that perform file-read operations; used to classify Codex/Grok
#: shell-command tool calls whose ``cmd`` or ``command`` field starts with one of these.
_READ_SHELL_COMMANDS: frozenset[str] = frozenset(("cat", "head", "tail", "sed", "less", "more", "bat", "get-content", "gc"))
@@ -345,7 +352,7 @@ class PreToolUseRemindAboutSymbolicToolsHook(PreToolUseHook):
super().__init__(client)
self._tool_call_counter = self.ToolUseCounter.load(self)
# extract a shell ``cmd``/``command`` field (Codex shell-command-style payloads):
# extract a shell ``cmd``/``command`` field (Codex/Grok shell-command-style payloads):
# split into command name (basename, lowercased) and the remaining argument string
# so both bare names (``rg``) and path-prefixed invocations (``/usr/bin/grep``) are
# normalised the same way. Stays ``None`` when no shell command is present.
@@ -356,18 +363,26 @@ class PreToolUseRemindAboutSymbolicToolsHook(PreToolUseHook):
# payloads pass the target as ``file_path`` rather than via a shell command).
self._file_path: str | None = None
if self._tool_input is not None:
self._command = self._tool_input.get("cmd", self._tool_input.get("command", "")).strip()
self._command = str(self._tool_input.get("cmd", self._tool_input.get("command", ""))).strip()
if self._command:
cmd_split = self._command.split(maxsplit=1)
if len(cmd_split) > 1:
self._command_args_str = cmd_split[1]
self._command_name = os.path.basename(cmd_split[0]).lower()
file_path = self._tool_input.get("file_path") or self._tool_input.get("filePath") or ""
file_path = (
self._tool_input.get("file_path")
or self._tool_input.get("filePath")
or self._tool_input.get("target_file")
or self._tool_input.get("targetFile")
or ""
)
self._file_path = str(file_path).strip() or None
def is_grep_call(self) -> bool:
if self._client in (HookClient.CLAUDE_CODE, HookClient.CODEBUDDY):
return self._tool_name == "grep" or "search_for_pattern" in self._tool_name
if self._client == HookClient.GROK:
return self._tool_name == "grep" or (self._is_shell_command_call() and self._command_name in self._GREP_SHELL_COMMANDS)
if self._client == HookClient.CODEX and self._is_shell_command_call():
return self._command_name in self._GREP_SHELL_COMMANDS
# heuristic for other clients
@@ -376,6 +391,8 @@ class PreToolUseRemindAboutSymbolicToolsHook(PreToolUseHook):
def is_read_call(self) -> bool:
if self._client in (HookClient.CLAUDE_CODE, HookClient.CODEBUDDY):
return self._tool_name == "read" or "read_file" in self._tool_name
if self._client == HookClient.GROK:
return self._tool_name == "read_file" or (self._is_shell_command_call() and self._command_name in self._READ_SHELL_COMMANDS)
if self._client == HookClient.CODEX and self._is_shell_command_call():
return self._command_name in self._READ_SHELL_COMMANDS
# heuristic for other clients
@@ -400,7 +417,7 @@ class PreToolUseRemindAboutSymbolicToolsHook(PreToolUseHook):
if self._file_path is not None:
return self._is_code_file_path(self._file_path)
if self._client == HookClient.CODEX and self._command_args_str is not None:
if self._client in (HookClient.CODEX, HookClient.GROK) and self._command_args_str is not None:
return any(self._is_code_file_path(argument) for argument in self._iter_shell_path_arguments())
return True
@@ -0,0 +1,52 @@
description: Grok Build (CLI agent where file operations, basic edits, etc. are already covered; single project mode)
prompt: |
You are running in a CLI coding agent context where file operations, basic (line-based) edits and reads
as well as shell commands are handled by your own, internal tools.
You have access to Serena's code intelligence tools that exploit the symbolic
structure of the code and are much more efficient than your own tools for most coding scenarios.
If you are working on any coding task and if Serena's tools are deferred, you
should load them all immediately, before performing any read, grep or bash commands.
If Serena's tools can be used to achieve your task, you should prioritize them.
**CRITICAL**: 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, prefer Serena's editing tools since they are more token efficient.
Especially when editing code you have read through Serena, use Serena's tools for editing to preserve the symbolic context you already gathered.
**CRITICAL**: Never use a tool before having read its schema via the tool-search tool!
**CRITICAL**: You have a preference for relying on your own tools like grep, read_file and search_replace. It is of paramount
importance that you overcome this preference and instead develop a preference for using Serena's tools since they
are superior for most coding tasks. Follow the following rules:
For any code files:
- read_file -> FORBIDDEN for discovery. Use get_symbols_overview, then find_symbol with include_body.
You may use read_file only if you already have an overview of the file and if reading
whole symbols is inappropriate (e.g. a few lines of code).
- list_dir (by name) -> Allowed for discovery only.
- grep (content) -> Allowed for discovery only; follow up reads or reference searches must be Serena.
- search_replace -> FORBIDDEN. Use replace_symbol_body / insert_*_symbol / replace_content.
Disallowed reasoning. Do NOT use any of the following to justify read_file/search_replace on a code file:
- "I already know the path"
- "one read_file call is faster than three Serena calls"
- "the built-in tool description says to use read_file for known paths"
If you catch yourself reaching for one of these, that is the signal to switch to Serena.
excluded_tools:
- create_text_file
- read_file
- execute_shell_command
- find_file
- list_dir
- search_for_pattern
tool_description_overrides: {}
# whether to assume that Serena shall only work on a single project in this context (provided that a project is given
# when Serena is started).
# If set to true and a project is provided at startup, the set of tools is limited to those required by the project's
# concrete configuration, and other tools are excluded completely, allowing the set of tools to be minimal.
# Tools explicitly disabled by the project will not be available at all.
# The `activate_project` tool is always disabled in this case, as project switching cannot be allowed.
single_project: true
+131
View File
@@ -0,0 +1,131 @@
import pytest
from serena.config.client_setup import ClientSetupHandlerGrok, client_setup_handlers
from serena.config.context_mode import SerenaAgentContext
from serena.util.shell import ShellCommandResult
def _result(command: str, return_code: int = 0, stdout: str = "") -> ShellCommandResult:
return ShellCommandResult(stdout=stdout, stderr="", return_code=return_code, cwd=".")
def test_grok_setup_handler_is_applicable_for_grok_build(monkeypatch):
commands: list[str] = []
def fake_execute_shell_command(command: str, capture_stderr: bool = False) -> ShellCommandResult:
commands.append(command)
if command == "grok --version":
return _result(command, stdout="grok 0.2.82 (6d0b07d2de) [stable]\n")
if command == "grok mcp add --help":
return _result(command, stdout="Add or update an MCP server\n")
return _result(command, return_code=1)
monkeypatch.setattr("serena.config.client_setup.execute_shell_command", fake_execute_shell_command)
assert ClientSetupHandlerGrok().is_applicable() is True
assert commands == ["grok --version", "grok mcp add --help"]
def test_grok_setup_handler_rejects_binary_without_mcp_add(monkeypatch):
def fake_execute_shell_command(command: str, capture_stderr: bool = False) -> ShellCommandResult:
if command == "grok --version":
return _result(command, stdout="grok 0.2.82 (6d0b07d2de) [stable]\n")
return _result(command, return_code=1, stdout="unknown command\n")
monkeypatch.setattr("serena.config.client_setup.execute_shell_command", fake_execute_shell_command)
assert ClientSetupHandlerGrok().is_applicable() is False
@pytest.mark.parametrize(
("return_code", "stdout"),
[
(1, ""),
(0, "0.0.34\n"),
(0, "my-grok wrapper 1.0\n"),
(0, "grok\n"),
(0, " grok 0.2.82"),
],
)
def test_grok_setup_handler_short_circuits_when_version_probe_does_not_match(monkeypatch, return_code: int, stdout: str):
commands: list[str] = []
def fake_execute_shell_command(command: str, capture_stderr: bool = False) -> ShellCommandResult:
commands.append(command)
if command == "grok --version":
return _result(command, return_code=return_code, stdout=stdout)
return _result(command, stdout="Add or update an MCP server\n")
monkeypatch.setattr("serena.config.client_setup.execute_shell_command", fake_execute_shell_command)
assert ClientSetupHandlerGrok().is_applicable() is False
assert commands == ["grok --version"]
def test_grok_setup_handler_accepts_case_insensitive_grok_version(monkeypatch):
commands: list[str] = []
def fake_execute_shell_command(command: str, capture_stderr: bool = False) -> ShellCommandResult:
commands.append(command)
if command == "grok --version":
return _result(command, stdout="Grok 0.3.0 [stable]")
if command == "grok mcp add --help":
return _result(command, stdout="Add or update an MCP server\n")
return _result(command, return_code=1)
monkeypatch.setattr("serena.config.client_setup.execute_shell_command", fake_execute_shell_command)
assert ClientSetupHandlerGrok().is_applicable() is True
assert commands == ["grok --version", "grok mcp add --help"]
@pytest.mark.parametrize("help_stdout", ["Usage: grok mcp add\n", "add or update an mcp server\n"])
def test_grok_setup_handler_rejects_mcp_add_help_without_expected_text(monkeypatch, help_stdout: str):
commands: list[str] = []
def fake_execute_shell_command(command: str, capture_stderr: bool = False) -> ShellCommandResult:
commands.append(command)
if command == "grok --version":
return _result(command, stdout="grok 0.2.82 (6d0b07d2de) [stable]\n")
if command == "grok mcp add --help":
return _result(command, stdout=help_stdout)
return _result(command, return_code=1)
monkeypatch.setattr("serena.config.client_setup.execute_shell_command", fake_execute_shell_command)
assert ClientSetupHandlerGrok().is_applicable() is False
assert commands == ["grok --version", "grok mcp add --help"]
def test_grok_setup_handler_apply_uses_grok_mcp_add(monkeypatch):
commands: list[str] = []
def fake_run_shell_command(self: ClientSetupHandlerGrok, command: str) -> bool:
commands.append(command)
return True
monkeypatch.setattr(ClientSetupHandlerGrok, "_run_shell_command", fake_run_shell_command)
assert ClientSetupHandlerGrok().apply() is True
assert commands == ["grok mcp add --scope user serena -- serena start-mcp-server --context=grok --project-from-cwd"]
def test_grok_setup_handler_apply_failure_skips_hook_recommendation(monkeypatch, capsys):
def fake_run_shell_command(self: ClientSetupHandlerGrok, command: str) -> bool:
return False
monkeypatch.setattr(ClientSetupHandlerGrok, "_run_shell_command", fake_run_shell_command)
assert ClientSetupHandlerGrok().apply() is False
assert "recommend" not in capsys.readouterr().out.lower()
def test_client_setup_handlers_use_resolvable_contexts():
handler_names = [handler.name for handler in client_setup_handlers]
assert "grok" in handler_names
for handler in client_setup_handlers:
context_options = [option for option in handler.get_mcp_server_options() if option.startswith("--context=")]
assert len(context_options) == 1
context_name = context_options[0].removeprefix("--context=")
assert SerenaAgentContext.from_name(context_name).name == context_name
+60
View File
@@ -0,0 +1,60 @@
import pytest
from interprompt.jinja_template import JinjaTemplate
from serena.config.context_mode import SerenaAgentContext
GROK_EXCLUDED_TOOLS = {
"create_text_file",
"read_file",
"execute_shell_command",
"find_file",
"list_dir",
"search_for_pattern",
}
BUILTIN_RUNTIME_CONTEXT_NAMES = [
name for name in SerenaAgentContext.list_registered_context_names(include_user_contexts=False) if name != "context.template"
]
def _render_context_prompt(context: SerenaAgentContext) -> str:
"""Render with the same template variables SerenaAgent._format_prompt provides.
Note: context YAMLs (e.g. grok.yml) are currently static (no Jinja), but the variables
match exactly what _format_prompt supplies. The outer system prompt template uses them.
"""
# Provide non-empty values so any future Jinja in a context prompt would still be exercised.
return JinjaTemplate(context.prompt).render(
available_tools=["find_symbol", "get_symbols_overview", "replace_content"],
available_markers=["ToolMarkerSymbolicRead"],
tool_names={"find_symbol": "find_symbol", "get_symbols_overview": "get_symbols_overview"},
)
def test_grok_context_loads():
context = SerenaAgentContext.from_name("grok")
assert context.name == "grok"
assert context.single_project is True
assert context.structured_tool_output is None
assert set(context.excluded_tools) == GROK_EXCLUDED_TOOLS
def test_grok_context_prompt_renders():
context = SerenaAgentContext.from_name("grok")
rendered_prompt = _render_context_prompt(context)
assert rendered_prompt.strip()
assert "Serena's code intelligence tools" in rendered_prompt
@pytest.mark.parametrize("context_name", BUILTIN_RUNTIME_CONTEXT_NAMES)
def test_builtin_contexts_load_and_prompt_templates_render(context_name: str):
context = SerenaAgentContext.from_name(context_name)
rendered_prompt = _render_context_prompt(context)
assert context.name == context_name
assert isinstance(context.excluded_tools, list)
assert rendered_prompt == "" or rendered_prompt.strip()
+67
View File
@@ -0,0 +1,67 @@
import inspect
import json
import re
from io import StringIO
from pathlib import Path
from unittest.mock import patch
from serena.config.client_setup import ClientSetupHandlerGrok
from serena.hooks import HookClient, PreToolUseRemindAboutSymbolicToolsHook
PROJECT_ROOT = Path(__file__).resolve().parents[3]
def _extract_section(markdown: str, heading: str) -> str:
"""Extract the content under a top-level ## heading (simple and sufficient for our docs)."""
pattern = rf"\n## {re.escape(heading)}\n(.*?)(?=\n## |\Z)"
match = re.search(pattern, markdown, re.DOTALL)
return match.group(1) if match else ""
def _grok_payload(tool_name: str, tool_input: dict) -> dict:
return {
"hookEventName": "pre_tool_use",
"sessionId": f"docs-{tool_name}",
"toolName": tool_name,
"toolInput": tool_input,
"toolInputTruncated": False,
"permissionMode": "bypassPermissions",
}
def test_grok_docs_are_consistent():
clients_doc = (PROJECT_ROOT / "docs/02-usage/030_clients.md").read_text()
config_doc = (PROJECT_ROOT / "docs/02-usage/050_configuration.md").read_text()
handler_source = inspect.getsource(ClientSetupHandlerGrok.apply)
assert "\n## Grok\n" in clients_doc
grok_section = _extract_section(clients_doc, "Grok")
assert "030_clients.html#grok" in handler_source
assert "serena setup grok" in grok_section
assert "serena-hooks remind --client=grok" in grok_section
assert "serena-hooks cleanup --client=grok" in grok_section
assert "* `grok`: Optimized for use with xAI's Grok Build CLI." in config_doc
assert "contexts `ide`, `claude-code`, and `grok` are **single-project contexts**" in config_doc
def test_grok_hook_matcher_docs_match_code(tmp_path: Path):
clients_doc = (PROJECT_ROOT / "docs/02-usage/030_clients.md").read_text()
grok_section = _extract_section(clients_doc, "Grok")
matcher = re.search(r'"matcher": "([^"]+)"', grok_section)
assert matcher is not None
matcher_tools = set(matcher.group(1).split("|"))
assert matcher_tools == {"grep", "read_file", "run_terminal_command"}
payloads = {
"grep": _grok_payload("grep", {"pattern": "foo", "path": "."}),
"read_file": _grok_payload("read_file", {"target_file": "src/foo.py"}),
"run_terminal_command": _grok_payload("run_terminal_command", {"command": "rg -n foo README.md"}),
}
for tool_name in matcher_tools:
with patch("sys.stdin", StringIO(json.dumps(payloads[tool_name]))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.GROK)
assert hook.is_grep_call() or hook.is_read_call() or hook._is_shell_command_call()
+91
View File
@@ -0,0 +1,91 @@
from types import SimpleNamespace
from click.testing import CliRunner
from serena.cli import TopLevelCommands
from serena.util.shell import ShellCommandResult
GROK_ADD_COMMAND = "grok mcp add --scope user serena -- serena start-mcp-server --context=grok --project-from-cwd"
def _result(command: str, return_code: int = 0, stdout: str = "", stderr: str = "") -> ShellCommandResult:
return ShellCommandResult(stdout=stdout, stderr=stderr, return_code=return_code, cwd=".")
def test_setup_grok_success(monkeypatch):
commands: list[str] = []
def fake_execute_shell_command(command: str, capture_stderr: bool = False) -> ShellCommandResult:
commands.append(command)
if command == "grok --version":
return _result(command, stdout="grok 0.2.82 (6d0b07d2de) [stable]\n")
if command == "grok mcp add --help":
return _result(command, stdout="Add or update an MCP server\n")
if command == GROK_ADD_COMMAND:
return _result(command)
return _result(command, return_code=1)
monkeypatch.setattr("serena.config.client_setup.execute_shell_command", fake_execute_shell_command)
result = CliRunner().invoke(TopLevelCommands.setup, ["grok"])
assert result.exit_code == 0, result.output
assert "successfully set up for grok" in result.output
assert "recommend" in result.output.lower()
assert "030_clients.html#grok" in result.output
assert commands == ["grok --version", "grok mcp add --help", GROK_ADD_COMMAND]
def test_setup_grok_not_applicable_exits_1(monkeypatch):
def fake_execute_shell_command(command: str, capture_stderr: bool = False) -> ShellCommandResult:
if command == "grok --version":
return _result(command, return_code=1)
return _result(command)
monkeypatch.setattr("serena.config.client_setup.execute_shell_command", fake_execute_shell_command)
result = CliRunner().invoke(TopLevelCommands.setup, ["grok"])
assert result.exit_code == 1
assert "Cannot apply setup for client 'grok'" in result.output
def test_setup_grok_apply_failure_exits_1(monkeypatch):
def fake_execute_shell_command(command: str, capture_stderr: bool = False) -> ShellCommandResult:
if command == "grok --version":
return _result(command, stdout="grok 0.2.82 (6d0b07d2de) [stable]\n")
if command == "grok mcp add --help":
return _result(command, stdout="Add or update an MCP server\n")
if command == GROK_ADD_COMMAND:
return _result(command, return_code=1, stderr="boom")
return _result(command, return_code=1)
monkeypatch.setattr("serena.config.client_setup.execute_shell_command", fake_execute_shell_command)
result = CliRunner().invoke(TopLevelCommands.setup, ["grok"])
assert result.exit_code == 1
assert "Failed to set up Serena for grok" in result.output
assert "recommend" not in result.output.lower()
assert "030_clients.html#grok" not in result.output
def test_init_suggests_grok_when_detected(monkeypatch):
def fake_execute_shell_command(command: str, capture_stderr: bool = False) -> ShellCommandResult:
if command == "grok --version":
return _result(command, stdout="grok 0.2.82 (6d0b07d2de) [stable]\n")
if command == "grok mcp add --help":
return _result(command, stdout="Add or update an MCP server\n")
return _result(command, return_code=1)
def fake_init(**kwargs) -> SimpleNamespace:
return SimpleNamespace(config_file_path="/tmp/serena_config.yml")
monkeypatch.setattr("serena.config.client_setup.execute_shell_command", fake_execute_shell_command)
monkeypatch.setattr("serena.cli.SerenaConfig.init", fake_init)
result = CliRunner().invoke(TopLevelCommands.init, [])
assert result.exit_code == 0, result.output
assert "serena setup grok" in result.output
assert "serena setup claude-code" not in result.output
+355
View File
@@ -11,6 +11,7 @@ from click.testing import CliRunner
from serena.hooks import (
HookClient,
PreToolUseAutoApproveSerenaHook,
PreToolUseHook,
PreToolUseRemindAboutSymbolicToolsHook,
SessionEndCleanupHook,
hook_commands,
@@ -35,6 +36,22 @@ def _base_input(
}
def _grok_input(
tool_name: str,
tool_input: dict | None = None,
session_id: str = "grok-session-123",
) -> dict:
"""Build a Grok PreToolUse payload using its real camelCase envelope."""
return {
"hookEventName": "pre_tool_use",
"sessionId": session_id,
"toolName": tool_name,
"toolInput": tool_input if tool_input is not None else {},
"toolInputTruncated": False,
"permissionMode": "bypassPermissions",
}
def _read_input(tool_name: str = "read", session_id: str = "test-session-123", file_path: str = "src/foo.py") -> dict:
"""Build a hook payload for a read-style tool call against a file."""
return {
@@ -44,6 +61,19 @@ def _read_input(tool_name: str = "read", session_id: str = "test-session-123", f
}
def _execute_remind_hook(client: HookClient, payload: dict, tmp_path: Path) -> None:
with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(client).execute()
def _assert_grok_native_deny(output: str, reason_fragment: str) -> dict:
result = json.loads(output)
assert result["decision"] == "deny"
assert reason_fragment in result["reason"].lower()
assert "hookSpecificOutput" not in result
return result
class TestHookClientDetection:
"""Tests for the --client option propagation."""
@@ -59,6 +89,12 @@ class TestHookClientDetection:
hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.VSCODE)
assert hook._client == HookClient.VSCODE
def test_grok_client(self, tmp_path: Path):
stdin_data = _grok_input("grep", {"pattern": "foo", "path": "."})
with patch("sys.stdin", _make_stdin(stdin_data)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.GROK)
assert hook._client == HookClient.GROK
class TestPreToolUseRemindAboutSerenaHook:
"""Tests for the PreToolUse hook that nudges the agent toward symbolic tools."""
@@ -104,6 +140,37 @@ class TestPreToolUseRemindAboutSerenaHook:
hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.CODEX)
assert hook.is_grep_call() == expected, f"is_grep_tool() wrong for {tool_name} / {tool_input}"
def test_grep_tool_detection_grok(self, tmp_path: Path):
"""Grok uses ``grep`` for its search tool and ``run_terminal_command`` for shell commands."""
cases = [
("grep", {"pattern": "foo", "path": "."}, True),
("grep_search", {"pattern": "foo", "path": "."}, False),
("run_terminal_command", {"command": "rg -n foo README.md"}, True),
("run_terminal_command", {"command": "cat README.md"}, False),
]
for tool_name, tool_input, expected in cases:
with (
patch("sys.stdin", _make_stdin(_grok_input(tool_name=tool_name, tool_input=tool_input))),
patch("serena.hooks.serena_home_dir", str(tmp_path)),
):
hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.GROK)
assert hook.is_grep_call() == expected, f"is_grep_tool() wrong for {tool_name} / {tool_input}"
def test_grok_camel_case_payload_detection(self, tmp_path: Path):
"""Grok emits camelCase ``toolName`` / ``toolInput`` payload keys."""
grep_payload = _grok_input("grep", {"pattern": "foo", "path": "."})
with patch("sys.stdin", _make_stdin(grep_payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
grep_hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.GROK)
assert grep_hook.is_grep_call() is True
assert grep_hook.is_read_file_call() is False
read_payload = _grok_input("read_file", {"target_file": "src/foo.py"})
with patch("sys.stdin", _make_stdin(read_payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
read_hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.GROK)
assert read_hook.is_read_file_call() is True
assert read_hook.is_read_code_file_call() is True
assert read_hook.is_grep_call() is False
def test_read_file_tool_detection_claude_code(self, tmp_path: Path):
"""Claude Code uses the exact tool name ``Read`` (lowercased to ``read``)."""
for name, expected in [("read", True), ("mcp__serena__read_file", True), ("grep", False), ("serena_search_for_pattern", False)]:
@@ -177,12 +244,133 @@ class TestPreToolUseRemindAboutSerenaHook:
hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.CODEX)
assert hook.is_read_file_call() == expected, f"is_read_file_tool() wrong for {tool_name} / {tool_input}"
def test_read_file_tool_detection_grok(self, tmp_path: Path):
"""Grok uses ``target_file`` for direct file reads and ``command`` for shell reads."""
cases = [
("read_file", {"target_file": "src/foo.py"}, True, True),
("read_file", {"target_file": "README.md"}, True, False),
("run_terminal_command", {"command": "cat src/foo.py"}, True, True),
("run_terminal_command", {"command": "cat README.md"}, True, False),
("run_terminal_command", {"command": "rg -n foo README.md"}, False, False),
]
for tool_name, tool_input, expected_read, expected_code_read in cases:
with (
patch("sys.stdin", _make_stdin(_grok_input(tool_name=tool_name, tool_input=tool_input))),
patch("serena.hooks.serena_home_dir", str(tmp_path)),
):
hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.GROK)
assert hook.is_read_file_call() == expected_read, f"is_read_file_tool() wrong for {tool_name} / {tool_input}"
assert hook.is_read_code_file_call() == expected_code_read, f"is_read_code_file_call() wrong for {tool_name} / {tool_input}"
def test_grok_edit_and_list_payloads_do_not_count_as_search_or_read(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""Grok edit/list tools carry path fields but must not trigger the search/read reminder."""
payloads = [
_grok_input(
"search_replace",
{"file_path": "src/foo.py", "old_string": "old", "new_string": "new"},
session_id="grok-edit-list",
),
_grok_input("list_dir", {"target_directory": "."}, session_id="grok-edit-list"),
]
for payload in payloads:
with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.GROK)
assert hook.is_grep_call() is False
assert hook.is_read_call() is False
assert hook.is_read_code_file_call() is False
for _ in range(ToolUseCounter._NON_SYMBOLIC_USES_THRESHOLD):
for payload in payloads:
with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.GROK).execute()
assert capsys.readouterr().out == ""
@pytest.mark.parametrize(
("payload", "expected_grep", "expected_read_file", "expected_read_code_file", "expected_shell"),
[
(
{
**_grok_input("run_terminal_command", session_id="grok-robust-string-input"),
"toolInput": "cat src/foo.py",
},
False,
False,
False,
False,
),
(
{
key: value
for key, value in _grok_input("run_terminal_command", session_id="grok-robust-missing-input").items()
if key != "toolInput"
},
False,
False,
False,
False,
),
(_grok_input("run_terminal_command", {}, session_id="grok-robust-empty-input"), False, False, False, False),
(
{
**_grok_input("grep", {"pattern": "foo", "path": "."}, session_id="grok-robust-truncated-input"),
"toolInputTruncated": True,
},
True,
False,
False,
False,
),
(_grok_input("run_terminal_command", {"command": 123}, session_id="grok-robust-numeric-command"), False, False, False, True),
(
_grok_input("run_terminal_command", {"command": f"cat {'a' * 10000}.py"}, session_id="grok-robust-long-command"),
False,
True,
True,
True,
),
],
ids=[
"string-tool-input",
"missing-tool-input",
"empty-tool-input",
"truncated-tool-input",
"numeric-command",
"long-command",
],
)
def test_grok_payload_robustness(
self,
payload: dict,
expected_grep: bool,
expected_read_file: bool,
expected_read_code_file: bool,
expected_shell: bool,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
):
"""Malformed or unusual Grok envelopes must not crash the hook."""
with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.GROK)
assert hook.is_grep_call() is expected_grep
assert hook.is_read_file_call() is expected_read_file
assert hook.is_read_code_file_call() is expected_read_code_file
assert hook._is_shell_command_call() is expected_shell
hook.execute()
assert capsys.readouterr().out == ""
def test_serena_tool_detection(self, tmp_path: Path):
for name, expected in [("mcp_serena_find_symbol", True), ("serena_overview", True), ("grep_search", False)]:
with patch("sys.stdin", _make_stdin(_base_input(tool_name=name))), patch("serena.hooks.serena_home_dir", str(tmp_path)):
hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.CLAUDE_CODE)
assert hook.is_serena_symbolic_tool() == expected, f"is_serena_tool() wrong for {name}"
def test_serena_tool_detection_grok_namespace(self, tmp_path: Path):
payload = _grok_input("serena__find_symbol")
with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
hook = PreToolUseRemindAboutSymbolicToolsHook(HookClient.GROK)
assert hook.is_serena_symbolic_tool() is True
def test_no_output_below_threshold(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""Below the threshold, the hook should produce no output (tool is allowed)."""
for _ in range(ToolUseCounter._GREP_USES_THRESHOLD - 1):
@@ -228,6 +416,92 @@ class TestPreToolUseRemindAboutSerenaHook:
assert "additionalContext" not in hook_output
assert "grep" in hook_output["permissionDecisionReason"].lower()
def test_deny_output_after_threshold_greps_grok(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""Grok expects native ``decision`` / ``reason`` JSON, not Claude-style hookSpecificOutput."""
payload = _grok_input("grep", {"pattern": "foo", "path": "."})
for _ in range(ToolUseCounter._GREP_USES_THRESHOLD):
with patch("sys.stdin", _make_stdin(payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.GROK).execute()
output = capsys.readouterr().out.strip()
result = json.loads(output)
assert result["decision"] == "deny"
assert "grep" in result["reason"].lower()
assert "hookSpecificOutput" not in result
@pytest.mark.parametrize(
("tool_name", "tool_input"),
[
("run_terminal_command", {"command": "cat src/foo.py"}),
("read_file", {"target_file": "src/foo.py"}),
],
ids=["shell-cat", "direct-read-file"],
)
def test_deny_output_after_threshold_reads_grok(
self,
tool_name: str,
tool_input: dict,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
):
"""Grok code-read reminders are emitted in native ``decision`` / ``reason`` JSON."""
payload = _grok_input(tool_name, tool_input, session_id=f"grok-read-{tool_name}")
for _ in range(ToolUseCounter._READ_FILE_USES_THRESHOLD):
_execute_remind_hook(HookClient.GROK, payload, tmp_path)
_assert_grok_native_deny(capsys.readouterr().out.strip(), "read")
_execute_remind_hook(HookClient.GROK, payload, tmp_path)
assert capsys.readouterr().out == ""
def test_non_symbolic_deny_mixed_burst_grok(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""Alternating Grok grep/read calls trip the combined non-symbolic threshold only."""
session_id = "grok-mixed-non-symbolic"
payloads = [
_grok_input("grep", {"pattern": "foo", "path": "."}, session_id=session_id),
_grok_input("run_terminal_command", {"command": "cat src/foo.py"}, session_id=session_id),
_grok_input("grep", {"pattern": "bar", "path": "."}, session_id=session_id),
_grok_input("run_terminal_command", {"command": "cat src/bar.py"}, session_id=session_id),
]
for payload in payloads:
_execute_remind_hook(HookClient.GROK, payload, tmp_path)
_assert_grok_native_deny(capsys.readouterr().out.strip(), "non-symbolic")
_execute_remind_hook(HookClient.GROK, payloads[-1], tmp_path)
assert capsys.readouterr().out == ""
def test_grok_native_allow_and_deny_output_json(self):
allow_output = PreToolUseHook.OutputData(permission_decision="allow", permission_decision_reason="allowed").to_json_string(
HookClient.GROK
)
assert json.loads(allow_output) == {"decision": "allow"}
deny_output = PreToolUseHook.OutputData(permission_decision="deny", permission_decision_reason="blocked").to_json_string(
HookClient.GROK
)
assert json.loads(deny_output) == {"decision": "deny", "reason": "blocked"}
assert "hookSpecificOutput" not in json.loads(deny_output)
def test_grok_serena_tool_resets_counters(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""Grok MCP tool names use ``serena__`` and must reset non-symbolic counters."""
session_id = "grok-reset"
grep_payload = _grok_input("grep", {"pattern": "foo", "path": "."}, session_id=session_id)
serena_payload = _grok_input("serena__find_symbol", {"name_path": "Foo"}, session_id=session_id)
for _ in range(ToolUseCounter._GREP_USES_THRESHOLD - 1):
with patch("sys.stdin", _make_stdin(grep_payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.GROK).execute()
with patch("sys.stdin", _make_stdin(serena_payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.GROK).execute()
with patch("sys.stdin", _make_stdin(grep_payload)), patch("serena.hooks.serena_home_dir", str(tmp_path)):
PreToolUseRemindAboutSymbolicToolsHook(HookClient.GROK).execute()
assert capsys.readouterr().out == ""
def test_deny_output_after_threshold_reads(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]):
"""After reaching the read file threshold, the hook should output a deny.
@@ -693,6 +967,19 @@ class TestHookCli:
assert result.exit_code == 0
assert not session_dir.exists()
def test_cleanup_command_grok_camelcase_session(self, tmp_path: Path):
session_id = "cli-grok-cleanup"
session_dir = tmp_path / "hook_data" / session_id
session_dir.mkdir(parents=True)
(session_dir / "somefile").write_text("data")
stdin_json = json.dumps({"sessionId": session_id, "hookEventName": "stop"})
runner = CliRunner()
with patch("serena.hooks.serena_home_dir", str(tmp_path)):
result = runner.invoke(hook_commands, ["cleanup", "--client", "grok"], input=stdin_json)
assert result.exit_code == 0
assert not session_dir.exists()
def test_remind_command(self, tmp_path: Path):
"""Invoke the remind command enough times to trigger a deny."""
runner = CliRunner()
@@ -705,6 +992,20 @@ class TestHookCli:
output = json.loads(result.output)
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
def test_remind_command_grok_emits_native_deny(self, tmp_path: Path):
"""The documented Grok remind CLI emits Grok-native deny JSON."""
runner = CliRunner()
payload = _grok_input("grep", {"pattern": "foo", "path": "."}, session_id="cli-grok-remind")
for _ in range(ToolUseCounter._GREP_USES_THRESHOLD):
with patch("serena.hooks.serena_home_dir", str(tmp_path)):
result = runner.invoke(hook_commands, ["remind", "--client", "grok"], input=json.dumps(payload))
assert result.exit_code == 0
output = json.loads(result.output)
assert output["decision"] == "deny"
assert "grep" in output["reason"].lower()
assert "hookSpecificOutput" not in output
def test_auto_approve_command(self, tmp_path: Path):
"""The ``auto-approve`` CLI command emits an allow for a Serena tool in acceptEdits mode."""
stdin_json = json.dumps(
@@ -755,6 +1056,60 @@ class TestHookCli:
assert result.exit_code == 0
assert result.output == ""
def test_auto_approve_command_grok_uses_native_output(self, tmp_path: Path):
"""The ``auto-approve`` CLI command accepts ``--client=grok`` and emits Grok-native JSON."""
stdin_json = json.dumps(
{
"session_id": "cli-auto-approve-grok",
"toolName": "serena__find_symbol",
"toolInput": {},
"permissionMode": "auto",
}
)
runner = CliRunner()
with patch("serena.hooks.serena_home_dir", str(tmp_path)):
result = runner.invoke(hook_commands, ["auto-approve", "--client", "grok"], input=stdin_json)
assert result.exit_code == 0
output = json.loads(result.output)
assert output == {"decision": "allow"}
@pytest.mark.parametrize(
("tool_name", "permission_mode", "expected_output"),
[
("serena__find_symbol", "auto", {"decision": "allow"}),
("serena__find_symbol", "bypassPermissions", None),
("serena__find_symbol", "dontAsk", None),
("serena__find_symbol", "", None),
("grep", "auto", None),
],
ids=["auto-allows-serena", "bypass-silent", "dont-ask-silent", "empty-mode-silent", "non-serena-silent"],
)
def test_auto_approve_command_grok_permission_modes(
self,
tool_name: str,
permission_mode: str,
expected_output: dict | None,
tmp_path: Path,
):
"""Grok auto-approve is intentionally active only for Serena tools in ``auto`` mode."""
stdin_json = json.dumps(
{
"sessionId": f"cli-grok-auto-approve-{tool_name}-{permission_mode}",
"toolName": tool_name,
"toolInput": {},
"permissionMode": permission_mode,
}
)
runner = CliRunner()
with patch("serena.hooks.serena_home_dir", str(tmp_path)):
result = runner.invoke(hook_commands, ["auto-approve", "--client", "grok"], input=stdin_json)
assert result.exit_code == 0
if expected_output is None:
assert result.output == ""
else:
assert json.loads(result.output) == expected_output
def test_client_default_is_claude_code(self, tmp_path: Path):
"""When --client is omitted, it defaults to claude-code."""
stdin_json = json.dumps({"session_id": "cli-default"})
+21
View File
@@ -13,6 +13,7 @@ import pytest
from _pytest.mark import Mark, MarkDecorator, ParameterSet
from serena.agent import SerenaAgent
from serena.config.context_mode import SerenaAgentContext
from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig
from serena.project import Project
from serena.tools import (
@@ -39,6 +40,7 @@ from test.conftest import (
get_repo_path,
language_tests_enabled,
)
from test.serena.config.test_context_mode import GROK_EXCLUDED_TOOLS
@dataclass
@@ -877,6 +879,25 @@ class TestSerenaAgent:
serena_config = SerenaConfig().with_headless_mode_overrides()
SerenaAgent(project=project, serena_config=serena_config)
@pytest.mark.python
@pytest.mark.skipif(not language_tests_enabled(Language.PYTHON), reason="python tests are disabled in this environment")
def test_grok_context_restricts_toolset_and_prompt(self, serena_config):
agent = SerenaAgent(
project="test_repo_python",
serena_config=serena_config,
context=SerenaAgentContext.from_name("grok"),
)
agent.execute_task(lambda: None)
try:
exposed = {tool.get_name() for tool in agent.get_exposed_tool_instances()}
assert exposed.isdisjoint(GROK_EXCLUDED_TOOLS)
assert "activate_project" not in exposed
assert {"find_symbol", "get_symbols_overview", "replace_symbol_body"} <= exposed
assert "Serena's code intelligence tools" in agent.create_system_prompt()
finally:
agent.on_shutdown(timeout=5)
def _symbol_matches_expected_name(self, symbol: dict, expected_name: str) -> bool:
return (
symbol.get("name") == expected_name