Merge branch 'main' into bb/gui

This commit is contained in:
emozilla
2026-05-20 16:01:41 -04:00
72 changed files with 2726 additions and 742 deletions
@@ -0,0 +1,210 @@
"""Skip the per-file shell linter when LSP will handle the same file.
The per-file ``npx tsc --noEmit FILE.ts`` shell linter cannot see
``tsconfig.json`` (a documented ``tsc`` quirk: explicit file args bypass
the project config), so it defaults to no-lib / ES5 and floods the
agent's lint field with phantom "Cannot find 'Promise' / 'Map' / 'Set' /
'ReadonlySet' / 'Iterable' / 'imul' / …" errors on every edit — up to
25K tokens per patch. The LSP tier (``tsserver`` via
typescript-language-server) reads tsconfig correctly and surfaces real
diagnostics in the ``lsp_diagnostics`` field of the WriteResult /
PatchResult.
These tests pin the contract:
- When LSP is active AND ``enabled_for(path)`` for a ``.ts`` / ``.go``
/ ``.rs`` file, ``_check_lint`` returns ``skipped`` without invoking
the shell linter at all.
- When LSP is inactive or disabled-for-path, the shell linter runs
exactly as before (regression guard for the default config).
- The skip only applies to extensions in
``_SHELL_LINTER_LSP_REDUNDANT`` — Python ``py_compile`` and
``node --check`` keep running unconditionally because they're fast,
file-local, and correct.
- ``.tsx`` is intentionally NOT in either ``LINTERS`` or
``_SHELL_LINTER_LSP_REDUNDANT``: it had no ``LINTERS`` entry
pre-PR (so it was already implicitly ``skipped`` via the
``ext not in LINTERS`` branch) and adding one would have inherited
``.ts``'s broken ``tsc --noEmit FILE`` invocation for LSP-disabled
users. When LSP IS enabled, ``.tsx`` is still covered by
typescript-language-server via ``_maybe_lsp_diagnostics`` — the
diagnostics show up on ``lsp_diagnostics``, not ``lint``.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
def _make_fops():
from tools.environments.local import LocalEnvironment
from tools.file_operations import ShellFileOperations
return ShellFileOperations(LocalEnvironment())
@pytest.mark.parametrize("ext", [".ts", ".go", ".rs"])
def test_shell_linter_skipped_when_lsp_will_handle(ext, tmp_path):
"""When LSP is active and enabled_for(path), shell linter is skipped.
The shell linter's _exec must NOT be called — that's the whole
point. We assert by patching ``_exec`` to raise, so any accidental
invocation surfaces as a test failure.
"""
fops = _make_fops()
src = tmp_path / f"bad{ext}"
src.write_text("intentionally invalid content\n")
def _exec_must_not_run(*args, **kwargs): # pragma: no cover
raise AssertionError(
"shell linter was invoked despite LSP claiming the file"
)
with patch.object(fops, "_lsp_will_handle", return_value=True), \
patch.object(fops, "_exec", side_effect=_exec_must_not_run), \
patch.object(fops, "_has_command", return_value=True):
result = fops._check_lint(str(src))
assert result.skipped is True
assert "LSP" in (result.message or "")
@pytest.mark.parametrize("ext", [".ts", ".go", ".rs"])
def test_shell_linter_runs_when_lsp_inactive(ext, tmp_path):
"""When LSP is inactive (default config, no service, remote backend, ...),
the shell linter runs as before — no behavior change."""
fops = _make_fops()
src = tmp_path / f"clean{ext}"
src.write_text("// content\n")
fake_result = MagicMock()
fake_result.exit_code = 0
fake_result.stdout = ""
with patch.object(fops, "_lsp_will_handle", return_value=False), \
patch.object(fops, "_exec", return_value=fake_result) as exec_mock, \
patch.object(fops, "_has_command", return_value=True):
result = fops._check_lint(str(src))
# _exec must have been called — proving the shell linter ran.
assert exec_mock.called, "shell linter did NOT run when LSP was inactive"
assert result.success is True
@pytest.mark.parametrize("ext", [".py", ".js"])
def test_lsp_does_not_skip_non_redundant_extensions(ext, tmp_path):
"""``py_compile`` and ``node --check`` keep running even when an LSP
server (pyright/pylsp/typescript-language-server-for-JS) is active —
they're fast, file-local, and correct, so there's no upside to
suppressing them.
"""
fops = _make_fops()
src = tmp_path / f"clean{ext}"
src.write_text("# valid\n" if ext == ".py" else "// valid\n")
fake_result = MagicMock()
fake_result.exit_code = 0
fake_result.stdout = ""
# Even with LSP claiming the file, the shell linter must still run
# for these extensions.
with patch.object(fops, "_lsp_will_handle", return_value=True), \
patch.object(fops, "_exec", return_value=fake_result) as exec_mock, \
patch.object(fops, "_has_command", return_value=True):
fops._check_lint(str(src))
assert exec_mock.called, (
f"shell linter for {ext} did not run despite being in the "
"'always-run' set (py_compile / node --check)"
)
def test_lsp_will_handle_returns_false_when_service_is_none(tmp_path):
"""``_lsp_will_handle`` must return False when the LSP service hasn't
been initialized — otherwise we'd accidentally skip the shell linter
on systems where LSP isn't configured at all."""
fops = _make_fops()
src = tmp_path / "foo.ts"
src.write_text("const x = 1\n")
with patch.object(fops, "_lsp_local_only", return_value=True), \
patch("agent.lsp.get_service", return_value=None):
assert fops._lsp_will_handle(str(src)) is False
def test_lsp_will_handle_returns_false_on_remote_backend(tmp_path):
"""LSP servers run on the host process — remote backends (Docker,
SSH, Modal, …) keep files inside the sandbox where the host LSP
can't reach them. ``_lsp_will_handle`` must short-circuit before
calling into the service in that case."""
fops = _make_fops()
src = tmp_path / "foo.ts"
src.write_text("const x = 1\n")
with patch.object(fops, "_lsp_local_only", return_value=False), \
patch("agent.lsp.get_service") as get_service_mock:
result = fops._lsp_will_handle(str(src))
assert result is False
# Importantly: we never even consulted the service.
assert not get_service_mock.called
def test_lsp_will_handle_swallows_enabled_for_exception(tmp_path):
"""A flaky LSP service must never break the shell-linter fallback —
if ``enabled_for`` raises, we treat the file as "not handled" so the
shell linter still runs."""
fops = _make_fops()
src = tmp_path / "foo.ts"
src.write_text("const x = 1\n")
fake_svc = MagicMock()
fake_svc.enabled_for.side_effect = RuntimeError("server crashed")
with patch.object(fops, "_lsp_local_only", return_value=True), \
patch("agent.lsp.get_service", return_value=fake_svc):
assert fops._lsp_will_handle(str(src)) is False
def test_tsx_stays_out_of_linters_table_for_default_compatibility():
"""Regression: keep ``.tsx`` out of ``LINTERS`` so users with LSP
DISABLED don't suddenly get the broken ``npx tsc --noEmit FILE.tsx``
invocation that ``.ts`` historically used to get.
Pre-PR behavior: ``.tsx`` had no entry in ``LINTERS``, so it fell
through to ``ext not in LINTERS`` → ``LintResult(skipped=True,
message="No linter for .tsx files")``. This PR preserves that for
the default config.
When LSP IS enabled, ``.tsx`` is still covered by the LSP tier via
``_maybe_lsp_diagnostics`` (typescript-language-server claims
``.tsx`` in its extensions list) — the diagnostics show up in the
``lsp_diagnostics`` field, not the ``lint`` field.
"""
from tools.file_operations import LINTERS, _SHELL_LINTER_LSP_REDUNDANT
assert ".tsx" not in LINTERS
assert ".tsx" not in _SHELL_LINTER_LSP_REDUNDANT
def test_tsx_default_check_lint_returns_skipped(tmp_path):
"""End-to-end: ``.tsx`` files get ``LintResult(skipped=True)`` from
``_check_lint`` regardless of LSP status — this is the no-regression
contract that addresses Copilot review #3271017282."""
fops = _make_fops()
src = tmp_path / "foo.tsx"
src.write_text("export const X = () => <div/>\n")
# Even with LSP claiming the file, no shell linter runs for .tsx
# because there's no LINTERS entry — the ``ext not in LINTERS``
# branch fires before the LSP short-circuit is consulted.
with patch.object(fops, "_lsp_will_handle", return_value=True), \
patch.object(fops, "_exec") as exec_mock:
result = fops._check_lint(str(src))
assert result.skipped is True
assert not exec_mock.called, "no shell linter should run for .tsx"
if __name__ == "__main__": # pragma: no cover
pytest.main([__file__, "-v"])
@@ -46,6 +46,26 @@ class TestChatCompletionsBasic:
assert "codex_reasoning_items" in msgs[0]
assert "codex_message_items" in msgs[0]
def test_convert_messages_strips_tool_name(self, transport):
"""Internal `tool_name` (used for FTS indexing in the SQLite store) is
not part of the OpenAI Chat Completions schema. Strict providers like
Moonshot/Kimi reject it with HTTP 400 'Extra inputs are not permitted'.
"""
msgs = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "call_1", "type": "function",
"function": {"name": "execute_code", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "call_1", "tool_name": "execute_code",
"content": "result"},
]
result = transport.convert_messages(msgs)
assert "tool_name" not in result[2]
assert result[2]["content"] == "result"
assert result[2]["tool_call_id"] == "call_1"
# Original list untouched (deepcopy-on-demand)
assert msgs[2]["tool_name"] == "execute_code"
class TestChatCompletionsBuildKwargs:
-24
View File
@@ -160,30 +160,6 @@ class TestBranchCommandCLI:
assert agent.reset_session_state.called
assert agent._last_flushed_db_idx == 4 # len(conversation_history)
def test_branch_updates_agent_session_log_file(self, cli_instance, session_db, tmp_path):
"""Branching must redirect the agent's session_log_file to the new session's path."""
from cli import HermesCLI
from pathlib import Path
logs_dir = tmp_path / "sessions"
logs_dir.mkdir()
agent = MagicMock()
agent._last_flushed_db_idx = 0
agent.logs_dir = logs_dir
agent.session_log_file = logs_dir / f"session_{cli_instance.session_id}.json"
cli_instance.agent = agent
old_log_file = agent.session_log_file
HermesCLI._handle_branch_command(cli_instance, "/branch")
new_session_id = cli_instance.session_id
expected_log = logs_dir / f"session_{new_session_id}.json"
assert agent.session_log_file == expected_log, (
"session_log_file must point to the branch session, not the original"
)
assert agent.session_log_file != old_log_file
def test_branch_sets_resumed_flag(self, cli_instance, session_db):
"""Branch should set _resumed=True to prevent auto-title generation."""
from cli import HermesCLI
+153 -1
View File
@@ -1,11 +1,18 @@
"""Tests for CLI browser CDP auto-launch helpers."""
from contextlib import redirect_stdout
from io import StringIO
import os
from queue import Queue
import subprocess
from unittest.mock import patch
from cli import HermesCLI
from hermes_cli.browser_connect import manual_chrome_debug_command
from hermes_cli.browser_connect import (
get_chrome_debug_candidates,
is_browser_debug_ready,
manual_chrome_debug_command,
)
def _assert_chrome_debug_cmd(cmd, expected_chrome, expected_port):
@@ -19,7 +26,35 @@ def _assert_chrome_debug_cmd(cmd, expected_chrome, expected_port):
assert "chrome-debug" in user_data_args[0]
class _FakeResponse:
status = 200
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
class TestChromeDebugLaunch:
def test_browser_debug_ready_requires_http_cdp_endpoint(self):
requested = []
def fake_urlopen(url, timeout):
requested.append(url)
if url.endswith("/json/version"):
return _FakeResponse()
raise OSError("unexpected probe")
with patch("urllib.request.urlopen", side_effect=fake_urlopen):
assert is_browser_debug_ready("http://127.0.0.1:9222", timeout=0.1) is True
assert requested == ["http://127.0.0.1:9222/json/version"]
def test_browser_debug_ready_rejects_non_cdp_listener(self):
with patch("urllib.request.urlopen", side_effect=OSError("not cdp")):
assert is_browser_debug_ready("http://127.0.0.1:9222", timeout=0.1) is False
def test_windows_launch_uses_browser_found_on_path(self):
captured = {}
@@ -72,6 +107,98 @@ class TestChromeDebugLaunch:
assert command is not None
assert command.startswith("/usr/bin/chromium --remote-debugging-port=9222")
def test_linux_candidates_prefer_chrome_before_brave_when_both_exist(self):
chrome = "/usr/bin/google-chrome"
brave = "/usr/bin/brave-browser"
def fake_which(name):
return {"google-chrome": chrome, "brave-browser": brave}.get(name)
with patch("hermes_cli.browser_connect.shutil.which", side_effect=fake_which), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path in {chrome, brave}):
candidates = get_chrome_debug_candidates("Linux")
command = manual_chrome_debug_command(9222, "Linux")
assert candidates[:2] == [chrome, brave]
assert command is not None
assert command.startswith(f"{chrome} --remote-debugging-port=9222")
def test_linux_candidates_prefer_chrome_install_path_before_brave_on_path(self):
chrome = "/opt/google/chrome/chrome"
brave = "/usr/bin/brave-browser"
with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: brave if name == "brave-browser" else None), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path in {chrome, brave}):
candidates = get_chrome_debug_candidates("Linux")
assert candidates[:2] == [chrome, brave]
def test_windows_candidates_prefer_chrome_install_path_before_brave_on_path(self, monkeypatch):
program_files = r"C:\Program Files"
chrome = os.path.join(program_files, "Google", "Chrome", "Application", "chrome.exe")
brave = r"C:\Brave\brave.exe"
monkeypatch.setenv("ProgramFiles", program_files)
monkeypatch.delenv("ProgramFiles(x86)", raising=False)
monkeypatch.delenv("LOCALAPPDATA", raising=False)
with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: brave if name == "brave.exe" else None), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path in {chrome, brave}):
candidates = get_chrome_debug_candidates("Windows")
assert candidates[:2] == [chrome, brave]
def test_linux_candidates_include_arch_brave_install_path(self):
brave = "/opt/brave-bin/brave"
with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == brave):
candidates = get_chrome_debug_candidates("Linux")
command = manual_chrome_debug_command(9222, "Linux")
assert candidates == [brave]
assert command is not None
assert command.startswith(f"{brave} --remote-debugging-port=9222")
def test_linux_candidates_include_brave_binary_name(self):
brave = "/usr/bin/brave"
with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: brave if name == "brave" else None), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == brave):
candidates = get_chrome_debug_candidates("Linux")
command = manual_chrome_debug_command(9222, "Linux")
assert candidates == [brave]
assert command is not None
assert command.startswith(f"{brave} --remote-debugging-port=9222")
def test_linux_candidates_include_official_brave_and_edge_stable_paths(self):
brave = "/usr/bin/brave-browser-stable"
edge = "/usr/bin/microsoft-edge-stable"
with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path in {brave, edge}):
candidates = get_chrome_debug_candidates("Linux")
assert candidates == [brave, edge]
def test_launch_tries_next_browser_when_first_candidate_fails(self):
brave = "/usr/bin/brave-browser"
chrome = "/usr/bin/google-chrome"
attempts = []
def fake_popen(cmd, **kwargs):
attempts.append(cmd[0])
if cmd[0] == brave:
raise OSError("broken brave install")
return object()
with patch("hermes_cli.browser_connect.get_chrome_debug_candidates", return_value=[brave, chrome]), \
patch("subprocess.Popen", side_effect=fake_popen):
assert HermesCLI._try_launch_chrome_debug(9222, "Linux") is True
assert attempts == [brave, chrome]
def test_manual_command_uses_wsl_windows_chrome_when_available(self):
chrome = "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe"
@@ -99,3 +226,28 @@ class TestChromeDebugLaunch:
with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \
patch("hermes_cli.browser_connect.os.path.isfile", return_value=False):
assert manual_chrome_debug_command(9222, "Linux") is None
def test_connect_context_note_allows_expected_browser_use(self, monkeypatch):
"""`/browser connect` is an instruction to use the CDP browser.
The queued context note must not tell the model to wait for a second
permission step or imply that the attached browser is the user's main
everyday Chrome profile.
"""
cli = HermesCLI.__new__(HermesCLI)
cli._pending_input = Queue()
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
with patch("cli.is_browser_debug_ready", return_value=True), \
patch("tools.browser_tool.cleanup_all_browsers"), \
patch("tools.browser_tool._ensure_cdp_supervisor"), \
redirect_stdout(StringIO()):
cli._handle_browser_command("/browser connect")
note = cli._pending_input.get_nowait()
assert "Chromium-family" in note
assert "dev/debug" in note
assert "using browser tools for their current browser-related request is expected" in note
assert "live Chrome browser" not in note
assert "real browser" not in note
assert "Please await their instruction" not in note
-1
View File
@@ -74,7 +74,6 @@ class _Codex401ThenSuccessAgent(run_agent.AIAgent):
self._cleanup_task_resources = lambda task_id: None
self._persist_session = lambda messages, history=None: None
self._save_trajectory = lambda messages, user_message, completed: None
self._save_session_log = lambda messages: None
def _try_refresh_codex_client_credentials(self, *, force: bool = True) -> bool:
type(self).refresh_attempts += 1
+223
View File
@@ -0,0 +1,223 @@
"""Tests for ``hermes migrate xai`` — apply path with ruamel round-trip."""
from __future__ import annotations
from pathlib import Path
import pytest
from hermes_cli.xai_retirement import (
RetirementIssue,
apply_migration,
find_retired_xai_refs,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def trap_config(tmp_path: Path) -> Path:
"""A config.yaml with retired models AND comments to verify round-trip."""
p = tmp_path / "config.yaml"
p.write_text(
"# Hermes config (sample)\n"
"principal:\n"
" provider: xai # the main model\n"
" model: grok-4-1-fast-non-reasoning # retiring May 15\n"
" temperature: 0.5\n"
"auxiliary:\n"
" vision:\n"
" provider: xai\n"
" model: grok-4-fast-reasoning # retiring\n"
" compression:\n"
" provider: openai # not affected\n"
" model: gpt-4o-mini\n"
"delegation:\n"
" model: grok-code-fast-1 # retiring\n"
"plugins:\n"
" image_gen:\n"
" xai:\n"
" model: grok-imagine-image-pro # retiring\n",
encoding="utf-8",
)
return p
@pytest.fixture
def clean_config(tmp_path: Path) -> Path:
p = tmp_path / "config.yaml"
p.write_text(
"principal:\n"
" provider: xai\n"
" model: grok-4.3\n",
encoding="utf-8",
)
return p
def _parse(path: Path) -> dict:
"""Load with ruamel for assertion convenience."""
from ruamel.yaml import YAML
yaml = YAML(typ="rt")
with path.open("r", encoding="utf-8") as fh:
return yaml.load(fh)
# ---------------------------------------------------------------------------
# Dry-run / no-op
# ---------------------------------------------------------------------------
class TestNoOpPaths:
def test_clean_config_returns_unchanged_result(self, clean_config: Path):
issues = find_retired_xai_refs(_parse(clean_config))
assert issues == []
result = apply_migration(clean_config, issues)
assert result.config_changed is False
assert result.backup_path is None
# File untouched
assert "grok-4.3" in clean_config.read_text(encoding="utf-8")
def test_empty_issues_list_is_noop(self, trap_config: Path):
original = trap_config.read_text(encoding="utf-8")
result = apply_migration(trap_config, issues=[])
assert result.config_changed is False
assert trap_config.read_text(encoding="utf-8") == original
def test_missing_file_raises(self, tmp_path: Path):
with pytest.raises(FileNotFoundError):
apply_migration(tmp_path / "absent.yaml", issues=[
RetirementIssue(
config_path="principal.model",
current_model="grok-3",
replacement="grok-4.3",
)
])
# ---------------------------------------------------------------------------
# Apply: surgical replacement
# ---------------------------------------------------------------------------
class TestApplyReplacement:
def test_replaces_principal_model(self, trap_config: Path):
issues = find_retired_xai_refs(_parse(trap_config))
result = apply_migration(trap_config, issues)
assert result.config_changed is True
cfg = _parse(trap_config)
assert cfg["principal"]["model"] == "grok-4.3"
def test_adds_reasoning_effort_for_non_reasoning_variant(self, trap_config: Path):
issues = find_retired_xai_refs(_parse(trap_config))
apply_migration(trap_config, issues)
cfg = _parse(trap_config)
# Principal was grok-4-1-fast-non-reasoning → reasoning_effort: "none"
assert cfg["principal"]["reasoning_effort"] == "none"
def test_replaces_auxiliary_vision(self, trap_config: Path):
issues = find_retired_xai_refs(_parse(trap_config))
apply_migration(trap_config, issues)
cfg = _parse(trap_config)
assert cfg["auxiliary"]["vision"]["model"] == "grok-4.3"
def test_replaces_delegation(self, trap_config: Path):
issues = find_retired_xai_refs(_parse(trap_config))
apply_migration(trap_config, issues)
cfg = _parse(trap_config)
assert cfg["delegation"]["model"] == "grok-4.3"
def test_replaces_image_gen_plugin(self, trap_config: Path):
issues = find_retired_xai_refs(_parse(trap_config))
apply_migration(trap_config, issues)
cfg = _parse(trap_config)
assert cfg["plugins"]["image_gen"]["xai"]["model"] == "grok-imagine-image-quality"
def test_does_not_touch_unrelated_slots(self, trap_config: Path):
issues = find_retired_xai_refs(_parse(trap_config))
apply_migration(trap_config, issues)
cfg = _parse(trap_config)
# auxiliary.compression was never xAI, must remain untouched
assert cfg["auxiliary"]["compression"]["model"] == "gpt-4o-mini"
assert cfg["auxiliary"]["compression"]["provider"] == "openai"
# principal.temperature must survive
assert cfg["principal"]["temperature"] == 0.5
# ---------------------------------------------------------------------------
# Round-trip preservation (the hard part)
# ---------------------------------------------------------------------------
class TestRoundTripPreservation:
def test_preserves_top_of_file_comment(self, trap_config: Path):
issues = find_retired_xai_refs(_parse(trap_config))
apply_migration(trap_config, issues)
text = trap_config.read_text(encoding="utf-8")
assert "# Hermes config (sample)" in text
def test_preserves_inline_comments_on_unmodified_lines(self, trap_config: Path):
issues = find_retired_xai_refs(_parse(trap_config))
apply_migration(trap_config, issues)
text = trap_config.read_text(encoding="utf-8")
assert "# the main model" in text
assert "# not affected" in text
def test_preserves_top_level_key_order(self, trap_config: Path):
issues = find_retired_xai_refs(_parse(trap_config))
apply_migration(trap_config, issues)
text = trap_config.read_text(encoding="utf-8")
order = [
text.index("principal:"),
text.index("auxiliary:"),
text.index("delegation:"),
text.index("plugins:"),
]
assert order == sorted(order)
# ---------------------------------------------------------------------------
# Backup behaviour
# ---------------------------------------------------------------------------
class TestBackup:
def test_backup_is_written_by_default(self, trap_config: Path):
issues = find_retired_xai_refs(_parse(trap_config))
original = trap_config.read_text(encoding="utf-8")
result = apply_migration(trap_config, issues)
assert result.backup_path is not None
assert result.backup_path.exists()
assert result.backup_path.read_text(encoding="utf-8") == original
def test_backup_filename_prefixed(self, trap_config: Path):
issues = find_retired_xai_refs(_parse(trap_config))
result = apply_migration(trap_config, issues)
assert result.backup_path is not None
assert result.backup_path.name.startswith("config.yaml.bak-pre-migrate-xai-")
def test_no_backup_when_disabled(self, trap_config: Path):
issues = find_retired_xai_refs(_parse(trap_config))
result = apply_migration(trap_config, issues, backup=False)
assert result.backup_path is None
# No bak file in the directory
assert not list(trap_config.parent.glob("*.bak-pre-migrate-xai-*"))
def test_no_backup_when_no_changes(self, clean_config: Path):
issues = find_retired_xai_refs(_parse(clean_config))
result = apply_migration(clean_config, issues, backup=True)
assert result.backup_path is None # nothing to back up
assert not list(clean_config.parent.glob("*.bak-pre-migrate-xai-*"))
# ---------------------------------------------------------------------------
# Idempotence
# ---------------------------------------------------------------------------
class TestIdempotence:
def test_apply_twice_is_safe(self, trap_config: Path):
# First pass: replace
issues_1 = find_retired_xai_refs(_parse(trap_config))
apply_migration(trap_config, issues_1)
# Second pass: nothing to do
issues_2 = find_retired_xai_refs(_parse(trap_config))
assert issues_2 == []
result_2 = apply_migration(trap_config, issues_2)
assert result_2.config_changed is False
+73
View File
@@ -1,6 +1,7 @@
"""_tui_need_npm_install: auto npm when node_modules is behind the lockfile."""
import os
import types
from pathlib import Path
import pytest
@@ -120,3 +121,75 @@ def test_no_install_prebuilt_bundle_mode(tmp_path: Path, main_mod) -> None:
"""dist/entry.js present and no package-lock.json → prebuilt bundle, skip npm install."""
_touch_tui_entry(tmp_path)
assert main_mod._tui_need_npm_install(tmp_path) is False
def test_need_rebuild_when_tui_bundle_missing(tmp_path: Path, main_mod) -> None:
(tmp_path / "src").mkdir()
(tmp_path / "src" / "entry.tsx").write_text("console.log('src')")
assert main_mod._tui_need_rebuild(tmp_path) is True
def test_no_rebuild_when_tui_bundle_newer_than_inputs(tmp_path: Path, main_mod) -> None:
_touch_tui_entry(tmp_path)
src = tmp_path / "src"
src.mkdir()
(src / "entry.tsx").write_text("console.log('src')")
os.utime(src / "entry.tsx", (100, 100))
os.utime(tmp_path / "dist" / "entry.js", (200, 200))
assert main_mod._tui_need_rebuild(tmp_path) is False
def test_rebuild_when_tui_source_newer_than_bundle(tmp_path: Path, main_mod) -> None:
_touch_tui_entry(tmp_path)
src = tmp_path / "src"
src.mkdir()
(src / "entry.tsx").write_text("console.log('src')")
os.utime(tmp_path / "dist" / "entry.js", (100, 100))
os.utime(src / "entry.tsx", (200, 200))
assert main_mod._tui_need_rebuild(tmp_path) is True
def test_make_tui_argv_skips_build_only_on_termux_when_fresh(
tmp_path: Path, main_mod, monkeypatch
) -> None:
_touch_tui_entry(tmp_path)
monkeypatch.setenv("TERMUX_VERSION", "1")
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: False)
monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False)
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
def fail_run(*_args, **_kwargs):
raise AssertionError("fresh Termux TUI launch must not rebuild")
monkeypatch.setattr(main_mod.subprocess, "run", fail_run)
argv, cwd = main_mod._make_tui_argv(tmp_path, tui_dev=False)
assert argv == ["/bin/node", str(tmp_path / "dist" / "entry.js")]
assert cwd == tmp_path
def test_make_tui_argv_keeps_desktop_always_build_behaviour(
tmp_path: Path, main_mod, monkeypatch
) -> None:
_touch_tui_entry(tmp_path)
monkeypatch.delenv("TERMUX_VERSION", raising=False)
monkeypatch.setenv("PREFIX", "/usr")
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: False)
monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False)
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
calls = []
def fake_run(*args, **kwargs):
calls.append((args, kwargs))
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
main_mod._make_tui_argv(tmp_path, tui_dev=False)
assert calls
assert calls[0][0][0] == ["/bin/npm", "run", "build"]
+32
View File
@@ -251,6 +251,38 @@ def test_main_top_level_tui_accepts_toolsets(monkeypatch, main_mod):
assert captured == {"toolsets": "web,terminal", "tui": True}
def test_termux_fast_tui_launch_uses_light_parser(monkeypatch, main_mod):
captured = {}
monkeypatch.setenv("TERMUX_VERSION", "1")
monkeypatch.setattr(
sys, "argv", ["hermes", "--tui", "--toolsets", "web,terminal"]
)
monkeypatch.setattr(
main_mod,
"cmd_chat",
lambda args: captured.update({"toolsets": args.toolsets, "tui": args.tui}),
)
assert main_mod._try_termux_fast_tui_launch() is True
assert captured == {"toolsets": "web,terminal", "tui": True}
def test_termux_fast_tui_launch_skips_help(monkeypatch, main_mod):
monkeypatch.setenv("TERMUX_VERSION", "1")
monkeypatch.setattr(sys, "argv", ["hermes", "--tui", "--help"])
assert main_mod._try_termux_fast_tui_launch() is False
def test_fast_tui_launch_is_termux_only(monkeypatch, main_mod):
monkeypatch.delenv("TERMUX_VERSION", raising=False)
monkeypatch.setenv("PREFIX", "/usr")
monkeypatch.setattr(sys, "argv", ["hermes", "--tui"])
assert main_mod._try_termux_fast_tui_launch() is False
def test_main_top_level_oneshot_accepts_toolsets(monkeypatch, main_mod):
captured = {}
+275
View File
@@ -0,0 +1,275 @@
"""Unit tests for hermes_cli.xai_retirement (May 15, 2026 model retirement)."""
from __future__ import annotations
import pytest
from hermes_cli.xai_retirement import (
MIGRATION_GUIDE_URL,
RETIREMENT_DATE,
RetirementIssue,
_RETIRED_MODELS,
_looks_like_xai,
_normalize,
find_retired_xai_refs,
format_issue,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _paths(issues):
return [i.config_path for i in issues]
# ---------------------------------------------------------------------------
# _normalize / _looks_like_xai
# ---------------------------------------------------------------------------
class TestNormalize:
def test_strips_x_ai_prefix(self):
assert _normalize("x-ai/grok-4") == "grok-4"
def test_strips_xai_prefix(self):
assert _normalize("xai/grok-4-fast") == "grok-4-fast"
def test_lowercases(self):
assert _normalize("Grok-Code-Fast-1") == "grok-code-fast-1"
def test_no_prefix_passthrough(self):
assert _normalize("grok-4.3") == "grok-4.3"
def test_strips_whitespace(self):
assert _normalize(" grok-4 ") == "grok-4"
class TestLooksLikeXai:
def test_grok_prefix(self):
assert _looks_like_xai("grok-4")
assert _looks_like_xai("x-ai/grok-4-1-fast")
def test_non_grok_returns_false(self):
assert not _looks_like_xai("gpt-4")
assert not _looks_like_xai("claude-sonnet-4-6")
assert not _looks_like_xai("openrouter/openai/gpt-4")
def test_none_or_empty(self):
assert not _looks_like_xai(None)
assert not _looks_like_xai("")
assert not _looks_like_xai(" ")
def test_non_string(self):
assert not _looks_like_xai(42)
assert not _looks_like_xai({"model": "grok-4"})
# ---------------------------------------------------------------------------
# find_retired_xai_refs — config scanning
# ---------------------------------------------------------------------------
class TestFindRetiredEdgeCases:
def test_empty_config_no_issues(self):
assert find_retired_xai_refs({}) == []
def test_non_dict_config_returns_empty(self):
assert find_retired_xai_refs(None) == [] # type: ignore[arg-type]
assert find_retired_xai_refs("nope") == [] # type: ignore[arg-type]
def test_no_xai_models_no_issues(self):
cfg = {
"principal": {"provider": "openai", "model": "gpt-4o"},
"auxiliary": {"vision": {"model": "claude-sonnet-4-6"}},
"delegation": {"model": "openai/o3"},
}
assert find_retired_xai_refs(cfg) == []
def test_xai_valid_model_not_flagged(self):
cfg = {
"principal": {"model": "grok-4.3"},
"auxiliary": {
"vision": {"model": "grok-4.20-0309-reasoning"},
"fast": {"model": "grok-4-fast"},
"fast_1": {"model": "grok-4-1-fast"},
"bare": {"model": "grok-4"},
},
}
assert find_retired_xai_refs(cfg) == []
class TestFindRetiredPerSlot:
def test_principal_retired(self):
cfg = {"principal": {"model": "grok-code-fast-1"}}
issues = find_retired_xai_refs(cfg)
assert len(issues) == 1
assert issues[0].config_path == "principal.model"
assert issues[0].current_model == "grok-code-fast-1"
assert issues[0].replacement == "grok-4.3"
assert issues[0].reasoning_effort is None
def test_principal_with_x_ai_prefix(self):
cfg = {"principal": {"model": "x-ai/grok-4-1-fast-non-reasoning"}}
issues = find_retired_xai_refs(cfg)
assert len(issues) == 1
assert issues[0].current_model == "x-ai/grok-4-1-fast-non-reasoning"
assert issues[0].replacement == "grok-4.3"
assert issues[0].reasoning_effort == "none"
def test_auxiliary_multiple_slots(self):
cfg = {
"auxiliary": {
"vision": {"model": "grok-4-fast-reasoning"},
"compression": {"model": "grok-code-fast-1"},
"curator": {"model": "grok-4.3"}, # not retired
"approval": {"model": "gpt-4o-mini"}, # not xAI
}
}
issues = find_retired_xai_refs(cfg)
assert sorted(_paths(issues)) == [
"auxiliary.compression.model",
"auxiliary.vision.model",
]
def test_auxiliary_unknown_slot_still_scanned(self):
cfg = {"auxiliary": {"future_slot_xyz": {"model": "grok-3"}}}
issues = find_retired_xai_refs(cfg)
assert len(issues) == 1
assert issues[0].config_path == "auxiliary.future_slot_xyz.model"
def test_delegation_retired(self):
cfg = {"delegation": {"model": "grok-4-fast-reasoning"}}
issues = find_retired_xai_refs(cfg)
assert _paths(issues) == ["delegation.model"]
def test_tts_xai_retired(self):
cfg = {"tts": {"xai": {"model": "grok-imagine-image-pro"}}}
issues = find_retired_xai_refs(cfg)
assert _paths(issues) == ["tts.xai.model"]
assert issues[0].replacement == "grok-imagine-image-quality"
def test_image_gen_plugin_retired(self):
cfg = {
"plugins": {
"image_gen": {
"xai": {"model": "grok-imagine-image-pro"}
}
}
}
issues = find_retired_xai_refs(cfg)
assert _paths(issues) == ["plugins.image_gen.xai.model"]
assert issues[0].replacement == "grok-imagine-image-quality"
def test_full_trap_config(self):
cfg = {
"principal": {"model": "grok-4-1-fast-non-reasoning"},
"auxiliary": {"vision": {"model": "grok-4-fast-reasoning"}},
"delegation": {"model": "grok-code-fast-1"},
"tts": {"xai": {"model": "grok-3"}}, # text model in TTS slot, but valid path
"plugins": {"image_gen": {"xai": {"model": "grok-imagine-image-pro"}}},
}
issues = find_retired_xai_refs(cfg)
assert len(issues) == 5
# ---------------------------------------------------------------------------
# Migration semantics
# ---------------------------------------------------------------------------
class TestMigrationSemantics:
def test_non_reasoning_variant_recommends_reasoning_effort_none(self):
cfg = {"principal": {"model": "grok-4-fast-non-reasoning"}}
issue = find_retired_xai_refs(cfg)[0]
assert issue.reasoning_effort == "none"
def test_reasoning_variant_no_extra_param(self):
cfg = {"principal": {"model": "grok-4-1-fast-reasoning"}}
issue = find_retired_xai_refs(cfg)[0]
assert issue.reasoning_effort is None
def test_grok_3_maps_to_grok_4_3(self):
cfg = {"principal": {"model": "grok-3"}}
issue = find_retired_xai_refs(cfg)[0]
assert issue.replacement == "grok-4.3"
def test_imagine_pro_maps_to_imagine_quality(self):
cfg = {"plugins": {"image_gen": {"xai": {"model": "grok-imagine-image-pro"}}}}
issue = find_retired_xai_refs(cfg)[0]
assert issue.replacement == "grok-imagine-image-quality"
def test_all_retired_have_replacement(self):
for name, entry in _RETIRED_MODELS.items():
assert entry.get("replacement"), f"{name} has no replacement"
# ---------------------------------------------------------------------------
# format_issue
# ---------------------------------------------------------------------------
class TestFormatIssue:
def test_basic_format(self):
issue = RetirementIssue(
config_path="principal.model",
current_model="grok-3",
replacement="grok-4.3",
)
s = format_issue(issue)
assert "principal.model" in s
assert "'grok-3'" in s
assert "'grok-4.3'" in s
def test_includes_reasoning_effort_when_set(self):
issue = RetirementIssue(
config_path="principal.model",
current_model="grok-4-fast-non-reasoning",
replacement="grok-4.3",
reasoning_effort="none",
)
s = format_issue(issue)
assert 'reasoning_effort: "none"' in s
def test_omits_reasoning_effort_when_none(self):
issue = RetirementIssue(
config_path="principal.model",
current_model="grok-code-fast-1",
replacement="grok-4.3",
reasoning_effort=None,
)
s = format_issue(issue)
assert "reasoning_effort" not in s
def test_includes_note_when_set(self):
issue = RetirementIssue(
config_path="principal.model",
current_model="grok-3",
replacement="grok-4.3",
note="ambiguous variant",
)
s = format_issue(issue)
assert "[note: ambiguous variant]" in s
# ---------------------------------------------------------------------------
# Module-level constants sanity
# ---------------------------------------------------------------------------
class TestModuleConstants:
def test_retirement_date_is_may_15(self):
assert "May 15, 2026" == RETIREMENT_DATE
def test_migration_guide_url_points_to_xai(self):
assert MIGRATION_GUIDE_URL.startswith("https://docs.x.ai/")
assert "may-15" in MIGRATION_GUIDE_URL.lower()
def test_retired_models_keyset_matches_doc(self):
# Snapshot test: if xAI's list changes we want CI to flag it.
expected = {
"grok-4-0709",
"grok-4-fast-reasoning",
"grok-4-fast-non-reasoning",
"grok-4-1-fast-reasoning",
"grok-4-1-fast-non-reasoning",
"grok-code-fast-1",
"grok-3",
"grok-imagine-image-pro",
}
assert set(_RETIRED_MODELS.keys()) == expected
-2
View File
@@ -110,8 +110,6 @@ class TestFlushDeduplication:
db = SessionDB(db_path=db_path)
agent = self._make_agent(db)
# Stub out _save_session_log to avoid file I/O
agent._save_session_log = MagicMock()
conversation_history = [{"role": "user", "content": "old"}]
messages = list(conversation_history) + [
@@ -52,7 +52,7 @@ def _make_agent(monkeypatch, api_mode, provider, response_fn):
kw.update(skip_context_files=True, skip_memory=True, max_iterations=4)
super().__init__(*a, **kw)
self._cleanup_task_resources = self._persist_session = lambda *a, **k: None
self._save_trajectory = self._save_session_log = lambda *a, **k: None
self._save_trajectory = lambda *a, **k: None
def run_conversation(self, msg, conversation_history=None, task_id=None):
self._interruptible_api_call = lambda kw: response_fn()
@@ -9,11 +9,7 @@ def _agent_with_stubbed_persistence():
agent._persist_user_message_override = None
agent._session_db = None
agent._session_messages = []
agent.saved_session_logs = []
agent.flushed_session_db_messages = []
agent._save_session_log = lambda messages: agent.saved_session_logs.append(
[m.copy() for m in messages]
)
agent._flush_messages_to_session_db = lambda messages, conversation_history=None: (
agent.flushed_session_db_messages.append([m.copy() for m in messages])
)
@@ -60,7 +56,7 @@ def test_persist_session_strips_trailing_empty_recovery_scaffolding():
assert messages == [
{"role": "user", "content": "run the task"},
]
assert agent.saved_session_logs[-1] == messages
assert agent.flushed_session_db_messages[-1] == messages
assert all(not msg.get("_empty_recovery_synthetic") for msg in messages)
@@ -77,7 +73,7 @@ def test_persist_session_keeps_unmarked_terminal_empty_response():
{"role": "user", "content": "run the task"},
{"role": "assistant", "content": "(empty)"},
]
assert agent.saved_session_logs[-1] == messages
assert agent.flushed_session_db_messages[-1] == messages
def test_persist_session_strips_marked_terminal_empty_sentinel():
@@ -94,5 +90,5 @@ def test_persist_session_strips_marked_terminal_empty_sentinel():
AIAgent._persist_session(agent, messages, conversation_history=[])
assert messages == [{"role": "user", "content": "continue"}]
assert agent.saved_session_logs[-1] == messages
assert agent.flushed_session_db_messages[-1] == messages
assert all(not msg.get("_empty_terminal_sentinel") for msg in messages)
+43 -36
View File
@@ -554,23 +554,50 @@ class TestExtractReasoning:
assert result == "from structured field"
class TestCleanSessionContent:
def test_none_passthrough(self):
assert AIAgent._clean_session_content(None) is None
class TestSessionJsonSnapshotOptIn:
"""Regression: per-session JSON snapshot writer is opt-in via config.
def test_scratchpad_converted(self):
text = "<REASONING_SCRATCHPAD>think</REASONING_SCRATCHPAD> answer"
result = AIAgent._clean_session_content(text)
assert "<REASONING_SCRATCHPAD>" not in result
assert "<think>" in result
state.db is canonical (PR #29182). ``sessions.write_json_snapshots``
defaults to False, so the agent must NOT write ``session_{sid}.json``
files by default — that behavior caused multi-GB sessions directories
on heavy users. Users can opt back in for external tooling that reads
the JSON files directly.
"""
def test_extra_newlines_cleaned(self):
text = "\n\n\n<think>x</think>\n\n\nafter"
result = AIAgent._clean_session_content(text)
# Should not have excessive newlines around think block
assert "\n\n\n" not in result
# Content after think block must be preserved
assert "after" in result
def test_session_json_disabled_by_default(self, agent):
# Default config: writer is gated off.
assert getattr(agent, "_session_json_enabled", False) is False, (
"sessions.write_json_snapshots must default to False"
)
def test_save_session_log_noops_when_disabled(self, agent, tmp_path):
# When disabled, calling the method must not write any file even
# if logs_dir is writable and messages are non-empty.
agent._session_json_enabled = False
agent.logs_dir = tmp_path
agent._session_messages = [{"role": "user", "content": "hello"}]
agent._save_session_log()
# No session_*.json must appear under logs_dir.
assert list(tmp_path.glob("session_*.json")) == []
def test_save_session_log_writes_when_enabled(self, agent, tmp_path):
# Opt-in path: with the flag on and a session_id, the writer must
# produce ``session_{sid}.json`` under logs_dir.
agent._session_json_enabled = True
agent.logs_dir = tmp_path
messages = [{"role": "user", "content": "hello"}]
agent._save_session_log(messages)
expected = tmp_path / f"session_{agent.session_id}.json"
assert expected.exists(), (
"Opt-in writer must produce session_{sid}.json under logs_dir"
)
def test_logs_dir_retained_for_request_dumps(self, agent):
# logs_dir is kept unconditionally because
# agent_runtime_helpers.dump_api_request_debug still writes
# request_dump_*.json there (debug breadcrumb path), independent of
# the session JSON opt-in.
assert hasattr(agent, "logs_dir")
class TestGetMessagesUpToLastAssistant:
@@ -1901,7 +1928,6 @@ class TestExecuteToolCalls:
agent._interruptible_api_call = _fake_api_call
agent._persist_session = lambda *args, **kwargs: None
agent._save_trajectory = lambda *args, **kwargs: None
agent._save_session_log = lambda *args, **kwargs: None
captured = io.StringIO()
agent._print_fn = lambda *args, **kw: print(*args, file=captured, **kw)
@@ -4253,22 +4279,6 @@ class TestSafeWriter:
assert inner.getvalue() == "test"
class TestSaveSessionLogAtomicWrite:
def test_uses_shared_atomic_json_helper(self, agent, tmp_path):
agent.session_log_file = tmp_path / "session.json"
messages = [{"role": "user", "content": "hello"}]
with patch("run_agent.atomic_json_write", create=True) as mock_atomic_write:
agent._save_session_log(messages)
mock_atomic_write.assert_called_once()
call_args = mock_atomic_write.call_args
assert call_args.args[0] == agent.session_log_file
payload = call_args.args[1]
assert payload["session_id"] == agent.session_id
assert payload["messages"] == messages
assert call_args.kwargs["indent"] == 2
assert call_args.kwargs["default"] is str
# ===================================================================
@@ -5056,12 +5066,9 @@ class TestPersistUserMessageOverride:
{"role": "assistant", "content": "Hi!"},
]
with patch.object(agent, "_save_session_log") as mock_save:
agent._persist_session(messages, [])
agent._persist_session(messages, [])
assert messages[0]["content"] == "Hello there"
saved_messages = mock_save.call_args.args[0]
assert saved_messages[0]["content"] == "Hello there"
first_db_write = agent._session_db.append_message.call_args_list[0].kwargs
assert first_db_write["content"] == "Hello there"
@@ -54,7 +54,6 @@ def _build_agent(monkeypatch):
agent._cleanup_task_resources = lambda task_id: None
agent._persist_session = lambda messages, history=None: None
agent._save_trajectory = lambda messages, user_message, completed: None
agent._save_session_log = lambda messages: None
return agent
@@ -75,7 +74,6 @@ def _build_copilot_agent(monkeypatch, *, model="gpt-5.4"):
agent._cleanup_task_resources = lambda task_id: None
agent._persist_session = lambda messages, history=None: None
agent._save_trajectory = lambda messages, user_message, completed: None
agent._save_session_log = lambda messages: None
return agent
@@ -335,7 +333,6 @@ def test_build_api_kwargs_codex_clamps_minimal_effort(monkeypatch):
agent._cleanup_task_resources = lambda task_id: None
agent._persist_session = lambda messages, history=None: None
agent._save_trajectory = lambda messages, user_message, completed: None
agent._save_session_log = lambda messages: None
kwargs = agent._build_api_kwargs(
[
@@ -365,7 +362,6 @@ def test_build_api_kwargs_codex_preserves_supported_efforts(monkeypatch):
agent._cleanup_task_resources = lambda task_id: None
agent._persist_session = lambda messages, history=None: None
agent._save_trajectory = lambda messages, user_message, completed: None
agent._save_session_log = lambda messages: None
kwargs = agent._build_api_kwargs(
[
@@ -594,7 +590,6 @@ def _build_xai_oauth_agent(monkeypatch):
agent._cleanup_task_resources = lambda task_id: None
agent._persist_session = lambda messages, history=None: None
agent._save_trajectory = lambda messages, user_message, completed: None
agent._save_session_log = lambda messages: None
return agent
+6 -6
View File
@@ -3995,7 +3995,7 @@ def test_browser_manage_connect_sets_env_and_cleans_twice(monkeypatch):
assert resp["result"]["connected"] is True
assert resp["result"]["url"] == "http://127.0.0.1:9222"
assert resp["result"]["messages"] == ["Chrome is already listening on port 9222"]
assert resp["result"]["messages"] == ["Chromium-family browser is already listening on port 9222"]
assert os.environ.get("BROWSER_CDP_URL") == "http://127.0.0.1:9222"
# First cleanup runs against the OLD env (none here), second against the NEW.
assert cleanup_calls == ["", "http://127.0.0.1:9222"]
@@ -4015,7 +4015,7 @@ def test_browser_manage_connect_defaults_to_loopback(monkeypatch):
assert resp["result"]["connected"] is True
assert resp["result"]["url"] == "http://127.0.0.1:9222"
assert resp["result"]["messages"] == ["Chrome is already listening on port 9222"]
assert resp["result"]["messages"] == ["Chromium-family browser is already listening on port 9222"]
assert urls[0] == "http://127.0.0.1:9222/json/version"
@@ -4058,10 +4058,10 @@ def test_browser_manage_connect_default_local_reports_launch_hint(monkeypatch):
assert resp["result"]["url"] == "http://127.0.0.1:9222"
assert (
resp["result"]["messages"][0]
== "Chrome isn't running with remote debugging — attempting to launch..."
== "Chromium-family browser isn't running with remote debugging — attempting to launch..."
)
assert any(
"No Chrome/Chromium executable was found" in line
"No supported Chromium-family browser executable was found" in line
for line in resp["result"]["messages"]
)
assert any(
@@ -4188,8 +4188,8 @@ def test_browser_manage_connect_default_local_retries_after_launch(monkeypatch):
assert resp["result"]["connected"] is True
assert resp["result"]["url"] == "http://127.0.0.1:9222"
assert resp["result"]["messages"] == [
"Chrome isn't running with remote debugging — attempting to launch...",
"Chrome launched and listening on port 9222",
"Chromium-family browser isn't running with remote debugging — attempting to launch...",
"Chromium-family browser launched and listening on port 9222",
]
assert os.environ["BROWSER_CDP_URL"] == "http://127.0.0.1:9222"
+138
View File
@@ -509,3 +509,141 @@ class TestParseErrorSignalling:
ops, err = parse_v4a_patch(patch)
assert err is None
assert len(ops) == 1
class TestV4ALspDiagnosticsPropagation:
"""V4A patches must surface ``WriteResult.lsp_diagnostics`` from the
underlying ``write_file`` calls on ``PatchResult.lsp_diagnostics``.
Without explicit propagation the LSP tier's output gets silently
dropped on the V4A code path see Copilot review #3271017295 on
PR #29054. The shell-linter LSP skip introduced by that PR makes
this gap visible: a ``.ts`` / ``.go`` / ``.rs`` V4A patch with LSP
active would otherwise return ``lint = {f: {skipped: True, ...}}``
and zero diagnostics from any channel.
"""
def _build_ops_writing(self, path: str, content: str):
"""Build a single ADD operation that writes ``content`` to ``path``."""
# Use the V4A parser so we don't have to construct PatchOperation
# / Hunk / Line objects by hand.
lines = "\n".join(f"+{line}" for line in content.splitlines())
patch_text = (
"*** Begin Patch\n"
f"*** Add File: {path}\n"
f"{lines}\n"
"*** End Patch"
)
ops, err = parse_v4a_patch(patch_text)
assert err is None, err
return ops
def test_lsp_diagnostics_propagated_from_write_file_on_add(self):
"""ADD op: ``WriteResult.lsp_diagnostics`` flows through to
``PatchResult.lsp_diagnostics``."""
ops = self._build_ops_writing("foo.ts", "const x: number = 1\n")
diag_block = (
"<diagnostics file=\"foo.ts\">\n"
"ERROR [1:7] some diagnostic\n"
"</diagnostics>"
)
class FakeFileOps:
def write_file(self, path, content):
return SimpleNamespace(error=None, lsp_diagnostics=diag_block)
def _check_lint(self, path):
return SimpleNamespace(to_dict=lambda: {"skipped": True})
result = apply_v4a_operations(ops, FakeFileOps())
assert result.success is True
assert result.lsp_diagnostics == diag_block
def test_lsp_diagnostics_propagated_from_write_file_on_update(self):
"""UPDATE op: ``WriteResult.lsp_diagnostics`` flows through to
``PatchResult.lsp_diagnostics``."""
patch_text = (
"*** Begin Patch\n"
"*** Update File: bar.ts\n"
"-old\n"
"+new\n"
"*** End Patch"
)
ops, err = parse_v4a_patch(patch_text)
assert err is None
diag_block = (
"<diagnostics file=\"bar.ts\">\n"
"ERROR [3:1] something\n"
"</diagnostics>"
)
class FakeFileOps:
def read_file_raw(self, path):
return SimpleNamespace(content="ctx\nold\nctx\n", error=None)
def write_file(self, path, content):
return SimpleNamespace(error=None, lsp_diagnostics=diag_block)
def _check_lint(self, path):
return SimpleNamespace(to_dict=lambda: {"skipped": True})
result = apply_v4a_operations(ops, FakeFileOps())
assert result.success is True
assert result.lsp_diagnostics == diag_block
def test_lsp_diagnostics_none_when_no_blocks_emitted(self):
"""When no underlying ``write_file`` produced diagnostics, the
aggregated field stays ``None`` (so it doesn't get serialized
as an empty string in ``PatchResult.to_dict``)."""
ops = self._build_ops_writing("foo.py", "x = 1\n")
class FakeFileOps:
def write_file(self, path, content):
# lsp_diagnostics omitted entirely (older WriteResult shape).
return SimpleNamespace(error=None)
def _check_lint(self, path):
return SimpleNamespace(to_dict=lambda: {"success": True})
result = apply_v4a_operations(ops, FakeFileOps())
assert result.success is True
assert result.lsp_diagnostics is None
def test_lsp_diagnostics_combined_across_multiple_files(self):
"""When several files in one V4A patch produce diagnostics,
each block appears in the combined output so per-file attribution
is preserved."""
patch_text = (
"*** Begin Patch\n"
"*** Add File: a.ts\n"
"+const a = 1\n"
"*** Add File: b.ts\n"
"+const b = 2\n"
"*** End Patch"
)
ops, err = parse_v4a_patch(patch_text)
assert err is None
per_file = {
"a.ts": "<diagnostics file=\"a.ts\">\nERR a\n</diagnostics>",
"b.ts": "<diagnostics file=\"b.ts\">\nERR b\n</diagnostics>",
}
class FakeFileOps:
def write_file(self, path, content):
return SimpleNamespace(error=None, lsp_diagnostics=per_file[path])
def _check_lint(self, path):
return SimpleNamespace(to_dict=lambda: {"skipped": True})
result = apply_v4a_operations(ops, FakeFileOps())
assert result.success is True
assert result.lsp_diagnostics is not None
assert per_file["a.ts"] in result.lsp_diagnostics
assert per_file["b.ts"] in result.lsp_diagnostics
+81
View File
@@ -0,0 +1,81 @@
"""Tests for xAI TTS speech-tag handling."""
from unittest.mock import Mock
from tools.tts_tool import _apply_xai_auto_speech_tags, _generate_xai_tts
def test_apply_xai_auto_speech_tags_adds_light_pause_after_first_sentence():
text = "Bonjour Monsieur Talbot. Ceci est un test de réponse vocale."
assert _apply_xai_auto_speech_tags(text) == (
"Bonjour Monsieur Talbot. [pause] Ceci est un test de réponse vocale."
)
def test_apply_xai_auto_speech_tags_preserves_explicit_tags():
text = "Bonjour. [pause] <whisper>Déjà balisé.</whisper>"
assert _apply_xai_auto_speech_tags(text) == text
def test_apply_xai_auto_speech_tags_preserves_all_documented_xai_tags():
text = "Bonjour Monsieur Talbot. [sigh] <slow>Je parle lentement.</slow> <emphasis>Important.</emphasis>"
assert _apply_xai_auto_speech_tags(text) == text
def test_generate_xai_tts_sends_auto_speech_tags_when_enabled(tmp_path, monkeypatch):
captured = {}
class FakeResponse:
content = b"mp3"
def raise_for_status(self):
pass
def fake_post(url, headers, json, timeout):
captured["url"] = url
captured["headers"] = headers
captured["json"] = json
captured["timeout"] = timeout
return FakeResponse()
monkeypatch.setenv("XAI_API_KEY", "test-xai-key")
monkeypatch.setattr("requests.post", fake_post)
out = tmp_path / "out.mp3"
_generate_xai_tts(
"Bonjour Monsieur Talbot. Ceci est un test.",
str(out),
{"xai": {"voice_id": "ara", "language": "fr", "auto_speech_tags": True}},
)
assert out.read_bytes() == b"mp3"
assert captured["url"] == "https://api.x.ai/v1/tts"
assert captured["json"]["voice_id"] == "ara"
assert captured["json"]["language"] == "fr"
assert captured["json"]["text"] == "Bonjour Monsieur Talbot. [pause] Ceci est un test."
def test_generate_xai_tts_leaves_text_plain_by_default(tmp_path, monkeypatch):
captured = {}
fake_response = Mock()
fake_response.content = b"mp3"
fake_response.raise_for_status.return_value = None
def fake_post(url, headers, json, timeout):
captured["json"] = json
return fake_response
monkeypatch.setenv("XAI_API_KEY", "test-xai-key")
monkeypatch.setattr("requests.post", fake_post)
_generate_xai_tts(
"Bonjour Monsieur Talbot. Ceci est un test.",
str(tmp_path / "out.mp3"),
{"xai": {"voice_id": "ara", "language": "fr"}},
)
assert captured["json"]["text"] == "Bonjour Monsieur Talbot. Ceci est un test."