mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 06:17:58 +00:00
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:
1 parent
96ee9b65e2
commit
9debf7742a
13 files changed
+2122
-8
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user