Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 72 additions & 6 deletions src/core/services/tool_call_handlers/pytest_full_suite_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import logging
import re
import shlex
from dataclasses import dataclass
from pathlib import Path
from typing import Any
Expand All @@ -27,7 +28,20 @@


# Matches commands invoking pytest (pytest, python -m pytest, py.test, etc.)
_PYTEST_ROOT_PATTERN = re.compile(r"\b(pytest|py\.test)(?:\b|\.py\b)", re.IGNORECASE)
_PYTEST_ROOT_PATTERN = re.compile(
r"^(pytest|py\.test)(?:$|\.py$|\.exe$|\.bat$)",
re.IGNORECASE,
)
Comment on lines 31 to +34

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore detection for path-qualified pytest commands

The updated regex now insists that each token be exactly pytest/py.test (via fullmatch), so _extract_pytest_command ignores commands like ./.venv/bin/pytest or C:\venv\Scripts\pytest.exe. These are common ways to invoke pytest from virtual environments and were previously matched because the pattern searched for pytest anywhere in the token. After this change, full‑suite runs executed via a path skip the steering logic entirely, defeating the handler’s purpose.

Useful? React with 👍 / 👎.



def _token_invokes_pytest(token: str) -> bool:
"""Return True when the token represents a pytest executable."""

if not token:
return False

executable_name = Path(token).name
return bool(_PYTEST_ROOT_PATTERN.fullmatch(executable_name))


DEFAULT_STEERING_MESSAGE = (
Expand Down Expand Up @@ -91,6 +105,56 @@ def _normalize_whitespace(command: str) -> str:
return " ".join(command.strip().split())


def _split_command_tokens(command: str) -> list[str]:
try:
return shlex.split(command, posix=True)
except ValueError:
return command.split()


Comment on lines +108 to +114
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Windows path tokenization bug: shlex POSIX parsing mangles backslashes.

For commands like C:\venv\Scripts\pytest.exe -q, shlex.split(posix=True) collapses backslashes, so _token_invokes_pytest never sees pytest.exe. Handler then fails to detect/steer on Windows strings passed via shell tools.

Apply a Windows-aware fallback in _split_command_tokens:

 def _split_command_tokens(command: str) -> list[str]:
-    try:
-        return shlex.split(command, posix=True)
-    except ValueError:
-        return command.split()
+    # Prefer Windows parsing when an absolute Windows path is present (e.g., C:\...).
+    # This avoids POSIX backslash-escaping mangling Windows paths.
+    if re.search(r'(?i)\b[A-Z]:\\', command):
+        try:
+            return shlex.split(command, posix=False)
+        except ValueError:
+            pass
+    try:
+        return shlex.split(command, posix=True)
+    except ValueError:
+        return command.split()

Please add a handler-level test for C:\\venv\\Scripts\\pytest.exe (see tests suggestion).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _split_command_tokens(command: str) -> list[str]:
try:
return shlex.split(command, posix=True)
except ValueError:
return command.split()
def _split_command_tokens(command: str) -> list[str]:
# Prefer Windows parsing when an absolute Windows path is present (e.g., C:\...).
# This avoids POSIX backslash-escaping mangling Windows paths.
if re.search(r'(?i)\b[A-Z]:\\', command):
try:
return shlex.split(command, posix=False)
except ValueError:
pass
try:
return shlex.split(command, posix=True)
except ValueError:
return command.split()
🤖 Prompt for AI Agents
In src/core/services/tool_call_handlers/pytest_full_suite_handler.py around
lines 108 to 114, shlex.split(..., posix=True) mangles Windows backslashes
causing tokens like C:\venv\Scripts\pytest.exe to be altered; update
_split_command_tokens to try shlex.split(command, posix=True) and on ValueError
or when the result lacks backslashes on Windows fall back to using
shlex.split(command, posix=False) or a raw command.split() that preserves
backslashes (detect via os.name == "nt" or pathlib.Path heuristics), and add a
unit test that asserts _split_command_tokens("C:\\venv\\Scripts\\pytest.exe -q")
returns ["C:\\venv\\Scripts\\pytest.exe", "-q"] to ensure the handler sees
pytest.exe on Windows.

def _command_invokes_pytest(command: str) -> bool:
tokens = _split_command_tokens(command)
if not tokens:
return False

separators = {"&&", ";", "||", "|"}
last_separator_index = -1

for index, token in enumerate(tokens):
if token in separators:
last_separator_index = index
continue

if not _token_invokes_pytest(token):
continue

segment_start = last_separator_index + 1
segment_tokens = tokens[segment_start : index + 1]

if _segment_represents_installation(segment_tokens[:-1]):
continue

return True

return False


def _segment_represents_installation(tokens: list[str]) -> bool:
if not tokens:
return False

installation_keywords = {
"install",
"add",
"remove",
"uninstall",
"update",
"upgrade",
}

return any(token.lower() in installation_keywords for token in tokens)


def _looks_like_full_suite(command: str) -> bool:
"""Determine if the pytest command targets the entire suite.

Expand All @@ -101,7 +165,7 @@ def _looks_like_full_suite(command: str) -> bool:
"""

normalized = _normalize_whitespace(command)
if not _PYTEST_ROOT_PATTERN.search(normalized):
if not normalized:
return False

tokens = normalized.split()
Expand All @@ -110,7 +174,7 @@ def _looks_like_full_suite(command: str) -> bool:
# Identify index where pytest command appears and inspect subsequent tokens.
try:
pytest_index = next(
i for i, tok in enumerate(tokens) if _PYTEST_ROOT_PATTERN.search(tok)
i for i, tok in enumerate(tokens) if _token_invokes_pytest(tok)
)
except StopIteration:
return False
Expand Down Expand Up @@ -254,14 +318,16 @@ def _extract_pytest_command(self, context: ToolCallContext) -> str | None:

command = _extract_command(arguments)

if normalized_tool_name in shell_tools:
if command and normalized_tool_name in shell_tools:
if not _command_invokes_pytest(command):
return None
return command

# Some providers map pytest directly as function name
if _PYTEST_ROOT_PATTERN.search(tool_name):
if _PYTEST_ROOT_PATTERN.fullmatch(tool_name):
return command or tool_name

if command and _PYTEST_ROOT_PATTERN.search(command):
if command and _command_invokes_pytest(command):
prefix = tool_name
if prefix:
return f"{prefix} {command}".strip()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
("pytest", True),
("python -m pytest", True),
("py.test", True),
("./.venv/bin/pytest", True),
(r"C:\\venv\\Scripts\\pytest.exe", True),
("pytest -q", True),
("pytest --maxfail=1", True),
("pytest tests/unit", False),
Expand All @@ -22,6 +24,7 @@
("pytest some/test/path", False),
("pytest some/test/path::TestSuite::test_case", False),
("pytest tests.unit.test_example", False),
("./.venv/bin/pytest tests/unit", False),
("pytest .", True),
("pytest ./tests", False),
],
Expand Down Expand Up @@ -143,6 +146,17 @@ async def test_handler_detects_list_based_command() -> None:
assert result.should_swallow is True


@pytest.mark.asyncio
async def test_handler_detects_path_qualified_pytest_invocation() -> None:
handler = PytestFullSuiteHandler(enabled=True)
context = _build_context("./.venv/bin/pytest")

assert await handler.can_handle(context) is True
result = await handler.handle(context)

assert result.should_swallow is True


@pytest.mark.asyncio
async def test_handler_enabled_flag_controls_behavior() -> None:
handler = PytestFullSuiteHandler(enabled=False)
Expand Down Expand Up @@ -184,3 +198,25 @@ async def test_handler_allows_targeted_python_pytest_invocation() -> None:
result = await handler.handle(context)

assert result.should_swallow is False


@pytest.mark.asyncio
async def test_handler_ignores_pytest_installation_commands() -> None:
handler = PytestFullSuiteHandler(enabled=True)
context = _build_context("pip install pytest")

assert await handler.can_handle(context) is False
result = await handler.handle(context)

assert result.should_swallow is False


@pytest.mark.asyncio
async def test_handler_ignores_pytest_plugin_installation() -> None:
handler = PytestFullSuiteHandler(enabled=True)
context = _build_context("pip install pytest-cov")

assert await handler.can_handle(context) is False
result = await handler.handle(context)

assert result.should_swallow is False