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
+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