Files
2026-07-13 13:12:33 +08:00

1081 lines
45 KiB
Python

"""Tests covering the hermes migrator fixes:
- H1: --overwrite creates an item-level backup before destroying existing target
- H2: short source body whose stripped form appears as substring is no longer
silently treated as duplicate
- H3: deferred CLI option ids surface as `status: deferred` records
- M1: workspace prose gets rebranded from Hermes -> OpenSquilla
- M2: skill_conflict=overwrite backs up existing skill dir before rmtree
- M3: oversized merged MEMORY.md is split and the tail archived
- M4: skill compatibility is reported (missing frontmatter / size limit)
"""
from __future__ import annotations
from pathlib import Path
import pytest
from opensquilla.migration.hermes import (
DEFERRED_OPTIONS,
MAX_MEMORY_CHARS,
HermesMigrationOptions,
HermesMigrator,
_hermes_rebrand_text,
_load_env_file,
)
def _make_hermes_home_with_user_data(root: Path) -> Path:
home = root / ".hermes"
home.mkdir(parents=True)
(home / "config.yaml").write_text("model:\n provider: openrouter\n", encoding="utf-8")
(home / "SOUL.md").write_text("Hermes soul\n", encoding="utf-8")
(home / "memories").mkdir()
(home / "memories" / "MEMORY.md").write_text("memory line\n", encoding="utf-8")
(home / "memories" / "USER.md").write_text("user profile\n", encoding="utf-8")
return home
def test_overwrite_creates_item_level_backup_before_replacing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = _make_hermes_home_with_user_data(tmp_path)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
workspace_soul = home / "workspace" / "SOUL.md"
workspace_soul.parent.mkdir(parents=True)
workspace_soul.write_text("pre-existing opensquilla soul to preserve\n", encoding="utf-8")
HermesMigrator(
HermesMigrationOptions(source=source, apply=True, overwrite=True)
).migrate()
backups = list(workspace_soul.parent.glob("SOUL.md.backup.*"))
assert len(backups) == 1, f"expected exactly one backup, found: {backups}"
assert backups[0].read_text(encoding="utf-8") == "pre-existing opensquilla soul to preserve\n"
# Newer content from the Hermes source replaces the file.
assert workspace_soul.read_text(encoding="utf-8") == "Hermes soul\n"
def test_short_source_substring_match_is_not_falsely_deduped(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = _make_hermes_home_with_user_data(tmp_path)
# source MEMORY.md contains "tea" — a short string that happens to appear
# inside an unrelated existing destination block. Old naive `in` check
# would silently drop the source; semantic dedupe should merge it.
(source / "memories" / "MEMORY.md").write_text("tea\n", encoding="utf-8")
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
dest = home / "workspace" / "MEMORY.md"
dest.parent.mkdir(parents=True)
dest.write_text(
"I had a long conversation about tea ceremonies in Kyoto.\n",
encoding="utf-8",
)
HermesMigrator(HermesMigrationOptions(source=source, apply=True)).migrate()
merged = dest.read_text(encoding="utf-8")
# Pre-existing block kept.
assert "Kyoto" in merged
# Source body merged in as its own block.
assert merged.count("tea\n") >= 1
assert merged.endswith("tea\n")
def test_deferred_option_ids_appear_in_report(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = _make_hermes_home_with_user_data(tmp_path)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = HermesMigrator(
HermesMigrationOptions(
source=source,
apply=True,
include=("tools-config", "browser-config", "workspace-files"),
)
).migrate()
deferred = {
item["kind"]
for item in report["items"]
if item["status"] == "deferred"
}
# All eight stub options are now selected via the default `full` preset
# plus the explicit --include list, so each appears as a deferred record.
assert DEFERRED_OPTIONS <= deferred
sample = next(item for item in report["items"] if item["kind"] == "tools-config")
assert sample["reason"] == "handler not implemented yet"
def test_workspace_prose_is_rebranded(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = _make_hermes_home_with_user_data(tmp_path)
(source / "SOUL.md").write_text(
# Branding tokens that should be rewritten:
"You are a Hermes Agent assistant. The Hermes memory lives in .hermes/.\n"
# Source-reference tokens that must be preserved for traceability:
"Originally bootstrapped via the NousResearch/hermes-agent project, "
"honoring HERMES_HOME.\n",
encoding="utf-8",
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = HermesMigrator(HermesMigrationOptions(source=source, apply=True)).migrate()
migrated = (home / "workspace" / "SOUL.md").read_text(encoding="utf-8")
assert "OpenSquilla assistant" in migrated
assert "OpenSquilla memory" in migrated
assert ".opensquilla/" in migrated
# Source-reference tokens are protected.
assert "NousResearch" in migrated
assert "hermes-agent" in migrated
assert "HERMES_HOME" in migrated
# Original unrebranded file archived for review.
original = (
Path(report["output_dir"])
/ "archive"
/ "files"
/ "workspace-original"
/ "SOUL.md"
)
assert "Hermes Agent assistant" in original.read_text(encoding="utf-8")
soul_item = next(item for item in report["items"] if item["kind"] == "soul")
assert soul_item["details"]["semantic_conversions"] == ["hermes-branding"]
def test_rebrand_helper_does_not_touch_unrelated_uses_of_hermes() -> None:
# Bare "Hermes" without a workspace-context word is left alone — keeps
# rebrand conservative for unrelated prose, mythology, project names, etc.
text = "Hermes is the Greek messenger god."
out, changed = _hermes_rebrand_text(text)
assert out == text
assert changed is False
def test_rebrand_helper_skips_text_that_already_mentions_opensquilla() -> None:
# Mixed-subject prose. The user is documenting that BOTH Hermes and
# OpenSquilla are installed; mechanical replacement would collapse the
# two subjects into one and produce nonsense like
# "OpenSquilla skills loadable by OpenSquilla". The helper must
# leave the text alone in this case.
text = (
"Hermes Agent v0.13.0 installed at ~/.local/bin/hermes.\n"
"OpenSquilla also installed at ~/.local/bin/opensquilla. "
"Has `migrate hermes` subcommand.\n"
"Only those two flat Hermes skills are loadable by OpenSquilla.\n"
)
out, changed = _hermes_rebrand_text(text)
assert out == text
assert changed is False
def test_rebrand_skip_reason_detects_opensquilla_case_insensitively() -> None:
from opensquilla.migration.hermes import (
REBRAND_SKIP_REASON_MIXED,
_rebrand_skip_reason,
)
# CamelCase brand mention.
assert _rebrand_skip_reason("OpenSquilla is great") == REBRAND_SKIP_REASON_MIXED
# Lowercase path / module mention.
assert _rebrand_skip_reason("~/.opensquilla/config") == REBRAND_SKIP_REASON_MIXED
# ALL CAPS env var name.
assert _rebrand_skip_reason("OPENSQUILLA_HOME") == REBRAND_SKIP_REASON_MIXED
# No mention -> rebrand should proceed normally.
assert _rebrand_skip_reason("Hermes Agent home and skills") is None
def test_workspace_prose_with_opensquilla_mention_is_kept_verbatim(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Reproduces the user-reported bug: the source MEMORY.md describes
# both Hermes and OpenSquilla as distinct entities. Mechanical
# replacement used to corrupt every sentence that mentioned both
# (path mismatch, tautologies, self-referential commands). The
# migrator must now detect this and write the text VERBATIM, with
# `details.rebrand_skipped: "mentions-opensquilla"` so the user
# knows to reword by hand.
source = _make_hermes_home_with_user_data(tmp_path)
mixed = (
"Hermes Agent v0.13.0 installed at ~/.local/bin/hermes.\n"
"OpenSquilla also installed at ~/.local/bin/opensquilla. "
"Has `migrate hermes` subcommand.\n"
"Only those two flat Hermes skills are loadable by OpenSquilla.\n"
"Plan: migrate Hermes skills to OpenSquilla.\n"
)
(source / "memories" / "MEMORY.md").write_text(mixed, encoding="utf-8")
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = HermesMigrator(HermesMigrationOptions(source=source, apply=True)).migrate()
migrated = (home / "workspace" / "MEMORY.md").read_text(encoding="utf-8")
# Verbatim: every original wording survives.
for line in (
"Hermes Agent v0.13.0 installed at ~/.local/bin/hermes",
"OpenSquilla also installed at ~/.local/bin/opensquilla",
"Only those two flat Hermes skills are loadable by OpenSquilla",
"migrate Hermes skills to OpenSquilla",
):
assert line in migrated, f"missing verbatim line: {line!r}"
# The four broken-translation outputs we observed in the bug report
# must NOT appear anywhere in the migrated file.
assert "OpenSquilla v0.13.0 installed at ~/.local/bin/hermes" not in migrated
assert "Only those two flat OpenSquilla skills" not in migrated
assert "migrate OpenSquilla skills to OpenSquilla" not in migrated
memory_item = next(i for i in report["items"] if i["kind"] == "memory")
assert memory_item["details"]["rebrand_skipped"] == "mentions-opensquilla"
# Did NOT also claim a successful rebrand.
assert "semantic_conversions" not in memory_item["details"]
def test_rebrand_does_not_mangle_path_substrings_of_dot_hermes() -> None:
# Prefix-substring regression: a plain string replace turned
# ``.hermesrc`` into ``.opensquillarc`` and ``.hermes-cache`` into
# ``.opensquilla-cache``. Both are nonsense. Only ``.hermes`` as a
# complete path token (followed by ``/``, whitespace, quote, etc.)
# should be rebranded.
cases = [
("Path ~/.hermesrc keeps working", "Path ~/.hermesrc keeps working"),
("Cache .hermes-cache here", "Cache .hermes-cache here"),
("Backup ~/.hermes_backup", "Backup ~/.hermes_backup"),
# Legit path forms still rebrand:
("Use ~/.hermes for state", "Use ~/.opensquilla for state"),
('Quoted "~/.hermes" works', 'Quoted "~/.opensquilla" works'),
("Slashed ~/.hermes/sub", "Slashed ~/.opensquilla/sub"),
]
for source_text, expected in cases:
out, _ = _hermes_rebrand_text(source_text)
assert out == expected, f"{source_text!r} -> {out!r}, expected {expected!r}"
def test_hermes_migrate_survives_non_utf8_source_bytes(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Regression: a source MEMORY.md containing stray non-UTF-8 bytes
# (BOM tail, CP1252 fragment, accidental binary paste) used to crash
# the entire migration with UnicodeDecodeError out of
# ``read_text(encoding="utf-8-sig")``. Now the read uses
# ``errors="replace"`` so migration completes; the bad bytes land as
# U+FFFD replacement chars in the destination.
source = _make_hermes_home_with_user_data(tmp_path)
(source / "memories" / "MEMORY.md").write_bytes(
b"\xff garbage \xff\nthen valid markdown\n"
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
# The previous version raised here. Should now succeed.
report = HermesMigrator(HermesMigrationOptions(source=source, apply=True)).migrate()
memory_item = next(i for i in report["items"] if i["kind"] == "memory")
assert memory_item["status"] == "migrated"
written = (home / "workspace" / "MEMORY.md").read_text(encoding="utf-8")
assert "then valid markdown" in written
# Replacement chars survived where the bad bytes were.
assert "" in written
def test_hermes_mcp_enabled_false_is_preserved_when_user_has_servers(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Regression: ``cfg.mcp.enabled = True`` was set unconditionally
# when importing MCP servers, silently re-enabling MCP for users
# who had deliberately turned it off. Now the migrator respects an
# explicit ``mcp.enabled = false`` if the user already had
# configured servers (i.e. it isn't just the framework default).
source = _make_hermes_home_with_user_data(tmp_path)
(source / "config.yaml").write_text(
"model:\n provider: openrouter\n"
"mcp:\n servers:\n new:\n command: /usr/bin/x\n",
encoding="utf-8",
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
home.mkdir(parents=True, exist_ok=True)
(home / "config.toml").write_text(
"[mcp]\nenabled = false\n\n"
"[[mcp.servers]]\n"
"name = \"existing\"\ncommand = \"/usr/bin/y\"\ntransport = \"stdio\"\n",
encoding="utf-8",
)
report = HermesMigrator(
HermesMigrationOptions(
source=source, config_path=home / "config.toml", apply=True
)
).migrate()
import tomllib
data = tomllib.loads((home / "config.toml").read_text())
assert data["mcp"]["enabled"] is False, (
"explicit mcp.enabled=false must NOT be silently flipped on import"
)
mc = next(i for i in report["items"] if i["kind"] == "mcp-servers")
assert mc["details"]["mcp_enabled_left_disabled"] is True
assert "manual_steps" in mc["details"]
def test_hermes_mcp_enabled_flips_when_default_and_no_existing_servers(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Symmetric regression guard: when MCP is at its framework default
# (enabled=false because there's nothing to enable) and the user
# imports MCP servers for the first time, we DO turn MCP on so the
# imported servers actually work.
source = _make_hermes_home_with_user_data(tmp_path)
(source / "config.yaml").write_text(
"model:\n provider: openrouter\n"
"mcp:\n servers:\n fresh:\n command: /usr/bin/x\n",
encoding="utf-8",
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
# No pre-existing config.toml at all — MCP defaulted off.
HermesMigrator(HermesMigrationOptions(source=source, apply=True)).migrate()
import tomllib
data = tomllib.loads((home / "config.toml").read_text())
assert data["mcp"]["enabled"] is True
def test_pure_hermes_prose_still_rebrands(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Regression: source that does NOT mention OpenSquilla should keep
# the original rebrand behavior.
source = _make_hermes_home_with_user_data(tmp_path)
(source / "memories" / "MEMORY.md").write_text(
"Single-subject note about Hermes Agent home and Hermes skills.\n",
encoding="utf-8",
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = HermesMigrator(HermesMigrationOptions(source=source, apply=True)).migrate()
migrated = (home / "workspace" / "MEMORY.md").read_text(encoding="utf-8")
assert "OpenSquilla home" in migrated
assert "OpenSquilla skills" in migrated
memory_item = next(i for i in report["items"] if i["kind"] == "memory")
assert memory_item["details"].get("semantic_conversions") == ["hermes-branding"]
assert "rebrand_skipped" not in memory_item["details"]
def test_skill_overwrite_creates_backup_dir(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = _make_hermes_home_with_user_data(tmp_path)
skill = source / "skills" / "demo"
skill.mkdir(parents=True)
(skill / "SKILL.md").write_text(
"---\nname: demo\ndescription: New\n---\nNew body\n", encoding="utf-8"
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
# Pre-existing skill at the target with local edits the user does not want lost.
existing = home / "skills" / "hermes-imports" / "demo"
existing.mkdir(parents=True)
(existing / "SKILL.md").write_text(
"---\nname: demo\ndescription: Local\n---\nLocal edits\n", encoding="utf-8"
)
HermesMigrator(
HermesMigrationOptions(
source=source, apply=True, skill_conflict="overwrite"
)
).migrate()
backups = list(existing.parent.glob("demo.backup.*"))
assert len(backups) == 1
assert (backups[0] / "SKILL.md").read_text(encoding="utf-8") == (
"---\nname: demo\ndescription: Local\n---\nLocal edits\n"
)
# Source skill won the overwrite.
assert "New body" in (existing / "SKILL.md").read_text(encoding="utf-8")
def test_oversized_memory_is_split_with_overflow_archived(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = _make_hermes_home_with_user_data(tmp_path)
huge = "block\n\n" * ((MAX_MEMORY_CHARS // 7) + 1000)
(source / "memories" / "MEMORY.md").write_text(huge, encoding="utf-8")
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = HermesMigrator(HermesMigrationOptions(source=source, apply=True)).migrate()
merged = (home / "workspace" / "MEMORY.md").read_text(encoding="utf-8")
assert len(merged) <= MAX_MEMORY_CHARS + 200 # only the overflow marker may push it slightly
assert "Migration overflow" in merged
overflow = (
Path(report["output_dir"]) / "archive" / "memory-overflow" / "MEMORY.overflow.md"
)
assert overflow.is_file()
assert overflow.read_text(encoding="utf-8").strip().startswith("block")
overflow_item = next(
item for item in report["items"] if item["kind"] == "memory-overflow"
)
assert overflow_item["status"] == "archived"
def test_repeat_migration_reports_dedupe_as_skipped(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Run the same migration twice. The second pass finds the source content
# already present in the destination — the report should say `skipped`
# with a clear reason, not `migrated`, because nothing was written.
source = _make_hermes_home_with_user_data(tmp_path)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
HermesMigrator(HermesMigrationOptions(source=source, apply=True)).migrate()
second = HermesMigrator(HermesMigrationOptions(source=source, apply=True)).migrate()
soul_item = next(item for item in second["items"] if item["kind"] == "soul")
assert soul_item["status"] == "skipped"
assert soul_item["reason"] == "duplicate of existing destination block"
assert soul_item["details"]["deduplicated"] is True
def test_multi_block_memory_is_correctly_deduped_on_re_migration(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Regression: the previous dedupe normalized the whole source into a
# single string and compared it against each destination block. For a
# multi-block MEMORY.md the comparison NEVER matched, so re-running the
# migration appended duplicate blocks unboundedly.
source = _make_hermes_home_with_user_data(tmp_path)
multi_block = (
"First memory entry.\n\n"
"Second memory entry.\n\n"
"Third memory entry.\n"
)
(source / "memories" / "MEMORY.md").write_text(multi_block, encoding="utf-8")
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
HermesMigrator(HermesMigrationOptions(source=source, apply=True)).migrate()
after_first = (home / "workspace" / "MEMORY.md").read_text(encoding="utf-8")
assert after_first.count("First memory entry") == 1
assert after_first.count("Second memory entry") == 1
assert after_first.count("Third memory entry") == 1
second = HermesMigrator(HermesMigrationOptions(source=source, apply=True)).migrate()
after_second = (home / "workspace" / "MEMORY.md").read_text(encoding="utf-8")
# Each entry still appears exactly once — no duplication on re-migration.
assert after_second.count("First memory entry") == 1
assert after_second.count("Second memory entry") == 1
assert after_second.count("Third memory entry") == 1
memory_item = next(i for i in second["items"] if i["kind"] == "memory")
assert memory_item["status"] == "skipped"
assert memory_item["details"]["deduplicated"] is True
def test_subset_dedupe_skips_when_all_source_blocks_already_present(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Source is a strict subset of the destination — every source block
# already has an equivalent in the destination, so the merge is skipped.
source = _make_hermes_home_with_user_data(tmp_path)
(source / "memories" / "MEMORY.md").write_text(
"Block one.\n\nBlock two.\n", encoding="utf-8"
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
dest = home / "workspace" / "MEMORY.md"
dest.parent.mkdir(parents=True)
dest.write_text(
"Block one.\n\nBlock two.\n\nBlock three from elsewhere.\n",
encoding="utf-8",
)
report = HermesMigrator(HermesMigrationOptions(source=source, apply=True)).migrate()
final = dest.read_text(encoding="utf-8")
assert final.count("Block one.") == 1
assert final.count("Block two.") == 1
assert final.count("Block three from elsewhere.") == 1
memory_item = next(i for i in report["items"] if i["kind"] == "memory")
assert memory_item["status"] == "skipped"
def test_split_memory_overflow_helper_is_noop_when_under_limit() -> None:
# Regression: previous helper unconditionally appended an overflow
# marker even when the input fit, growing the trimmed output beyond
# MAX_MEMORY_CHARS and pointing to an empty archive.
text = "alpha\n\n" * 1000 # well under MAX_MEMORY_CHARS
trimmed, overflow = HermesMigrator._split_memory_overflow(text)
assert trimmed == text
assert overflow == ""
assert "Migration overflow" not in trimmed
def test_invalid_profile_name_is_rejected_before_path_resolution(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Regression: `--profile "../escape"` used to be joined onto
# ~/.hermes/profiles/ verbatim and could escape the hermes home via
# ``..`` traversal. The migrator now rejects profile names that do not
# match hermes' own profile-id regex.
real_home = tmp_path / ".hermes"
real_home.mkdir()
(real_home / "config.yaml").write_text("model:\n provider: openrouter\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(real_home))
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
for bad in ("../escape", "..", "with/slash", "UpperCase", " spaced ", "../../etc"):
report = HermesMigrator(HermesMigrationOptions(profile=bad)).migrate()
errors = [i for i in report["items"] if i["status"] == "error"]
assert errors, f"profile {bad!r} was not rejected"
assert errors[0]["kind"] == "profile", f"profile {bad!r} produced wrong error: {errors[0]}"
assert "invalid profile name" in errors[0]["reason"]
def test_unknown_provider_is_not_written_to_config(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Regression: an unknown provider (`bedrock`, `ollama`, `auto`, …) used
# to be written verbatim into ``cfg.llm.provider``. That broke two
# invariants downstream:
# 1. GatewayConfig's enum-style validator rejects unknown providers.
# 2. ``squilla_router.tier_profile`` must agree with
# ``llm.provider`` after case normalisation; a stale ``openrouter``
# tier-profile vs a fresh ``auto`` provider crashes persist_config.
# The migrator now leaves ``llm.provider`` untouched and reports the
# gap so the user can configure it manually.
source = _make_hermes_home_with_user_data(tmp_path)
(source / "config.yaml").write_text(
"model:\n provider: bedrock\n model: claude-3-opus\n", encoding="utf-8"
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = HermesMigrator(HermesMigrationOptions(source=source, apply=True)).migrate()
mc = next(i for i in report["items"] if i["kind"] == "model-config")
assert mc["status"] == "migrated"
assert mc["details"]["unrecognized_provider"] == "bedrock"
assert "llm_provider_left_unchanged" in mc["details"]
assert mc["details"]["manual_steps"]
import tomllib
data = tomllib.loads((home / "config.toml").read_text())
# Provider was NOT overwritten with the unknown value. Sparse persistence
# may omit the key entirely (it was left at the default), which satisfies
# the same invariant. The model id still came through because that's a
# free-form string.
assert data["llm"].get("provider") != "bedrock"
assert data["llm"]["model"] == "claude-3-opus"
def test_hermes_auto_provider_does_not_crash_when_tier_profile_already_set(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# The reported user-facing crash:
# ValidationError: squilla_router.tier_profile requires llm.provider
# to match ('openrouter' != 'auto')
# Reproduce it by pre-seeding ~/.opensquilla/config.toml with a valid
# (openrouter, openrouter) pair, then run `migrate hermes` against a
# config that says model.provider: "auto". The migrator must NOT
# rewrite llm.provider to "auto" or persist_config will refuse to
# write the file at all.
source = _make_hermes_home_with_user_data(tmp_path)
(source / "config.yaml").write_text(
"model:\n provider: auto\n model: anthropic/claude-3-opus\n",
encoding="utf-8",
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
# Seed the existing opensquilla config with matching (provider,
# tier_profile) — same situation the user reported on their machine.
(home).mkdir(parents=True, exist_ok=True)
(home / "config.toml").write_text(
"[llm]\nprovider = \"openrouter\"\nmodel = \"anthropic/claude-opus-4-7\"\n"
"api_key_env = \"OPENROUTER_API_KEY\"\n\n"
"[squilla_router]\ntier_profile = \"openrouter\"\n",
encoding="utf-8",
)
# Must not raise pydantic.ValidationError.
report = HermesMigrator(
HermesMigrationOptions(
source=source, config_path=home / "config.toml", apply=True
)
).migrate()
mc = next(i for i in report["items"] if i["kind"] == "model-config")
assert mc["status"] == "migrated"
assert mc["details"]["unrecognized_provider"] == "auto"
assert mc["details"]["llm_provider_left_unchanged"] == "openrouter"
import tomllib
data = tomllib.loads((home / "config.toml").read_text())
# Pre-existing provider preserved; tier_profile still agrees with it.
assert data["llm"]["provider"] == "openrouter"
assert data["squilla_router"]["tier_profile"] == "openrouter"
# The migrator did pick up the model id, which is a free-form string.
assert data["llm"]["model"] == "anthropic/claude-3-opus"
def test_known_provider_differing_from_tier_profile_does_not_crash(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Same crash class as the "auto" case, but with a provider that IS in
# PROVIDER_ENV_KEYS (so the old fix's "unknown branch" doesn't catch it).
# Hermes config: provider=anthropic. Existing opensquilla home: tier_profile
# already pinned to openrouter. Writing llm.provider=anthropic would
# violate GatewayConfig._validate_squilla_router_tier_profile_provider and
# abort the whole migration with pydantic ValidationError.
source = _make_hermes_home_with_user_data(tmp_path)
(source / "config.yaml").write_text(
"model:\n provider: anthropic\n model: claude-3-5-sonnet\n",
encoding="utf-8",
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
(home).mkdir(parents=True, exist_ok=True)
(home / "config.toml").write_text(
"[llm]\nprovider = \"openrouter\"\nmodel = \"anthropic/claude-opus-4-7\"\n"
"api_key_env = \"OPENROUTER_API_KEY\"\n\n"
"[squilla_router]\ntier_profile = \"openrouter\"\n",
encoding="utf-8",
)
# Must not raise pydantic.ValidationError.
report = HermesMigrator(
HermesMigrationOptions(
source=source, config_path=home / "config.toml", apply=True
)
).migrate()
mc = next(i for i in report["items"] if i["kind"] == "model-config")
assert mc["status"] == "migrated"
# Report surfaces the conflict explicitly so the user can act on it.
assert mc["details"]["tier_profile_conflict"] == "openrouter"
assert mc["details"]["llm_provider_left_unchanged"] == "openrouter"
assert "manual_steps" in mc["details"]
# Crucially we did NOT mislabel this as "unrecognized" — anthropic IS a
# known provider, it just clashes with the pinned tier_profile.
assert "unrecognized_provider" not in mc["details"]
import tomllib
data = tomllib.loads((home / "config.toml").read_text())
# Pre-existing provider preserved; tier_profile still agrees with it.
assert data["llm"]["provider"] == "openrouter"
assert data["squilla_router"]["tier_profile"] == "openrouter"
# The model id was still picked up.
assert data["llm"]["model"] == "claude-3-5-sonnet"
def test_model_id_extracted_from_nested_dict_config(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Regression: a hermes config whose `model.model` is a dict
# (`{primary: ..., fallback: ...}`) used to write the Python repr of
# the dict as the model id, producing garbage in opensquilla config.
source = _make_hermes_home_with_user_data(tmp_path)
(source / "config.yaml").write_text(
"model:\n provider: openrouter\n model:\n primary: anthropic/claude-3-opus\n"
" fallback: openai/gpt-4o\n",
encoding="utf-8",
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
HermesMigrator(HermesMigrationOptions(source=source, apply=True)).migrate()
import tomllib
data = tomllib.loads((home / "config.toml").read_text())
assert data["llm"]["model"] == "anthropic/claude-3-opus"
assert "{" not in data["llm"]["model"] # not a stringified dict
def test_env_file_with_export_prefix_is_parsed_correctly(tmp_path: Path) -> None:
# Regression: `export FOO=bar` is common in hand-written .env files. The
# previous parser kept "export " in the key, so secrets like
# `export OPENROUTER_API_KEY=...` failed to match SECRET_ENV_KEYS and
# were silently dropped on `--migrate-secrets`.
env_path = tmp_path / ".env"
env_path.write_text(
"export OPENROUTER_API_KEY=sk-or-123\n"
"OPENAI_API_KEY=sk-oa-456\n"
"export ANTHROPIC_API_KEY=sk-an-789\n",
encoding="utf-8",
)
parsed = _load_env_file(env_path)
assert parsed == {
"OPENROUTER_API_KEY": "sk-or-123",
"OPENAI_API_KEY": "sk-oa-456",
"ANTHROPIC_API_KEY": "sk-an-789",
}
def test_export_prefixed_secret_is_actually_migrated(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = _make_hermes_home_with_user_data(tmp_path)
(source / ".env").write_text(
"export OPENROUTER_API_KEY=sk-or-from-export-form\n", encoding="utf-8"
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
HermesMigrator(
HermesMigrationOptions(source=source, apply=True, migrate_secrets=True)
).migrate()
written = (home / ".env").read_text(encoding="utf-8")
assert "OPENROUTER_API_KEY=sk-or-from-export-form" in written
def test_mcp_servers_as_list_of_dicts_are_migrated(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Hermes config.yaml accepts both dict-of-dicts and list-of-dicts forms
# for mcp.servers. The migrator used to silently drop the list form.
source = _make_hermes_home_with_user_data(tmp_path)
(source / "config.yaml").write_text(
"""model:
provider: openrouter
mcp:
servers:
- name: srv-one
command: node
args: ["server-one.js"]
- name: srv-two
url: https://example.test/mcp
""",
encoding="utf-8",
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = HermesMigrator(HermesMigrationOptions(source=source, apply=True)).migrate()
mcp_item = next(i for i in report["items"] if i["kind"] == "mcp-servers")
assert mcp_item["status"] == "migrated"
assert mcp_item["details"]["server_count"] == 2
import tomllib
cfg = tomllib.loads((home / "config.toml").read_text())
server_names = sorted(s["name"] for s in cfg["mcp"]["servers"])
assert server_names == ["srv-one", "srv-two"]
transports = {s["name"]: s["transport"] for s in cfg["mcp"]["servers"]}
assert transports["srv-one"] == "stdio"
assert transports["srv-two"] == "sse"
def test_mcp_servers_malformed_entries_are_reported_as_dropped(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = _make_hermes_home_with_user_data(tmp_path)
(source / "config.yaml").write_text(
"""mcp:
servers:
valid:
command: node
broken: "not a dict"
""",
encoding="utf-8",
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = HermesMigrator(HermesMigrationOptions(source=source, apply=True)).migrate()
mcp_item = next(i for i in report["items"] if i["kind"] == "mcp-servers")
assert mcp_item["status"] == "migrated"
assert mcp_item["details"]["server_count"] == 1
assert mcp_item["details"]["dropped_entries"] == ["broken"]
def test_mcp_migration_preserves_existing_opensquilla_servers(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Regression: the migrator used to assign cfg.mcp.servers = imported,
# silently destroying any MCP servers the user already had configured.
# New behavior is upsert-by-name: same-name entries are replaced (with
# the hermes definition winning), unrelated entries are preserved.
source = _make_hermes_home_with_user_data(tmp_path)
(source / "config.yaml").write_text(
"""model:
provider: openrouter
mcp:
servers:
fresh-from-hermes:
command: /usr/bin/new-tool
shared-name:
command: /override/from/hermes
""",
encoding="utf-8",
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
home.mkdir(parents=True)
(home / "config.toml").write_text(
"[mcp]\nenabled = true\n\n"
"[[mcp.servers]]\n"
"name = \"shared-name\"\ncommand = \"/usr/local/bin/old\"\ntransport = \"stdio\"\n\n"
"[[mcp.servers]]\n"
"name = \"opensquilla-only\"\ncommand = \"/usr/local/bin/keep-me\"\n"
"transport = \"stdio\"\n",
encoding="utf-8",
)
report = HermesMigrator(
HermesMigrationOptions(
source=source, config_path=home / "config.toml", apply=True
)
).migrate()
mcp_item = next(i for i in report["items"] if i["kind"] == "mcp-servers")
assert mcp_item["status"] == "migrated"
assert mcp_item["details"]["added"] == ["fresh-from-hermes"]
assert mcp_item["details"]["replaced"] == ["shared-name"]
assert mcp_item["details"]["preserved_existing"] == ["opensquilla-only"]
import tomllib
data = tomllib.loads((home / "config.toml").read_text())
by_name = {s["name"]: s for s in data["mcp"]["servers"]}
# Pre-existing unrelated server still here.
assert by_name["opensquilla-only"]["command"] == "/usr/local/bin/keep-me"
# Same-name conflict: hermes version wins (the user invoked migrate;
# they asked for the import).
assert by_name["shared-name"]["command"] == "/override/from/hermes"
# New hermes server added.
assert by_name["fresh-from-hermes"]["command"] == "/usr/bin/new-tool"
def test_repeated_migrate_secrets_does_not_duplicate_env_entries(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Regression: re-running --migrate-secrets used to append duplicate env
# entries each run, growing .env unboundedly.
source = _make_hermes_home_with_user_data(tmp_path)
(source / ".env").write_text(
"OPENROUTER_API_KEY=sk-stable\n", encoding="utf-8"
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
for _ in range(3):
HermesMigrator(
HermesMigrationOptions(source=source, apply=True, migrate_secrets=True)
).migrate()
env_text = (home / ".env").read_text(encoding="utf-8")
assert env_text.count("OPENROUTER_API_KEY=") == 1
def test_env_write_updates_existing_key_value(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# If the user already has an unrelated key in ~/.opensquilla/.env, the
# migrator should keep it and only update / add migrated keys.
source = _make_hermes_home_with_user_data(tmp_path)
(source / ".env").write_text(
"OPENROUTER_API_KEY=sk-new\n", encoding="utf-8"
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
(home).mkdir(parents=True)
(home / ".env").write_text(
"# user existing config\nUNRELATED_KEY=keep-me\nOPENROUTER_API_KEY=sk-old\n",
encoding="utf-8",
)
HermesMigrator(
HermesMigrationOptions(source=source, apply=True, migrate_secrets=True)
).migrate()
text = (home / ".env").read_text(encoding="utf-8")
assert "# user existing config" in text
assert "UNRELATED_KEY=keep-me" in text
assert "OPENROUTER_API_KEY=sk-new" in text
assert "OPENROUTER_API_KEY=sk-old" not in text
assert text.count("OPENROUTER_API_KEY=") == 1
def test_malformed_config_yaml_does_not_crash_migration(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Regression: a hand-edited config.yaml with a YAML syntax error used to
# bubble a yaml.YAMLError out of the whole migration. Now the migrator
# records an `error` item for config.yaml and continues — user-data
# items still migrate.
source = _make_hermes_home_with_user_data(tmp_path)
(source / "config.yaml").write_text(
"model:\n provider: openrouter\nfoo: [unclosed\n", encoding="utf-8"
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = HermesMigrator(HermesMigrationOptions(source=source, apply=True)).migrate()
config_errors = [
i for i in report["items"]
if i["kind"] == "config.yaml" and i["status"] == "error"
]
assert len(config_errors) == 1
assert "could not parse" in config_errors[0]["reason"]
# User data still migrated despite the broken config.yaml.
assert (home / "workspace" / "SOUL.md").is_file()
def test_empty_skills_dir_emits_skipped_record(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# An empty `skills/` directory used to produce no record at all, leaving
# users unable to distinguish "checked but empty" from "never checked".
source = _make_hermes_home_with_user_data(tmp_path)
(source / "skills").mkdir()
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = HermesMigrator(HermesMigrationOptions(source=source, apply=True)).migrate()
skill_items = [i for i in report["items"] if i["kind"] == "skills"]
assert len(skill_items) == 1
assert skill_items[0]["status"] == "skipped"
assert skill_items[0]["reason"] == "no skills to migrate"
def test_skills_path_is_file_does_not_crash(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Regression: when `skills` is a regular file (misconfiguration, leftover
# artifact, etc.) the migrator used to crash with NotADirectoryError on
# iterdir(). It now records a clean skipped status and continues.
source = _make_hermes_home_with_user_data(tmp_path)
(source / "skills").write_text("this is a file, not a directory", encoding="utf-8")
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = HermesMigrator(HermesMigrationOptions(source=source, apply=True)).migrate()
skill_items = [i for i in report["items"] if i["kind"] == "skills"]
assert len(skill_items) == 1
assert skill_items[0]["status"] == "skipped"
assert "not a directory" in skill_items[0]["reason"]
def test_migrate_secrets_with_no_env_still_records_channels(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Regression: a `--migrate-secrets` run against a hermes source with no
# `.env` file (or no channel tokens) used to emit zero `channels`
# records, leaving the user unsure whether channels were considered.
source = _make_hermes_home_with_user_data(tmp_path)
# Explicitly remove .env to simulate the no-secrets case.
env_path = source / ".env"
if env_path.exists():
env_path.unlink()
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = HermesMigrator(
HermesMigrationOptions(source=source, apply=True, migrate_secrets=True)
).migrate()
channels_items = [i for i in report["items"] if i["kind"] == "channels"]
assert len(channels_items) == 1
assert channels_items[0]["status"] == "skipped"
assert "no channel tokens" in channels_items[0]["reason"]
def test_rebrand_handles_whitespace_variants_of_hermes_agent() -> None:
cases = [
("Built with Hermes Agent for power users.", "single space"),
("Built with Hermes Agent today.", "double space"),
("Built with Hermes\tAgent today.", "tab"),
("Built with Hermes\nAgent today.", "newline"),
]
for text, label in cases:
out, changed = _hermes_rebrand_text(text)
assert "OpenSquilla" in out, f"{label}: rebrand did not fire for {text!r}"
assert "Hermes" not in out.replace("HERMES_HOME", ""), (
f"{label}: residual Hermes after rebrand: {out!r}"
)
assert changed is True
def test_skill_with_missing_frontmatter_is_reported_not_loadable(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = _make_hermes_home_with_user_data(tmp_path)
skill = source / "skills" / "broken"
skill.mkdir(parents=True)
(skill / "SKILL.md").write_text("just plain markdown, no frontmatter\n", encoding="utf-8")
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = HermesMigrator(HermesMigrationOptions(source=source, apply=True)).migrate()
item = next(
i for i in report["items"]
if i["kind"] == "skills" and "broken" in (i["source"] or "")
)
assert item["details"]["compatibility"] == "not_loadable"
assert "missing YAML frontmatter" in item["details"]["compatibility_issues"]
def test_skill_with_empty_frontmatter_does_not_crash_migration(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Regression: an empty YAML frontmatter block (``---\n\n---\n``) parses
# to None, and the migrator used to call ``.get(...)`` on it,
# crashing the entire migration with AttributeError. The skill should
# be reported as not_loadable, not propagate as an unhandled exception.
source = _make_hermes_home_with_user_data(tmp_path)
skill = source / "skills" / "empty-fm"
skill.mkdir(parents=True)
(skill / "SKILL.md").write_text("---\n\n---\nbody\n", encoding="utf-8")
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = HermesMigrator(HermesMigrationOptions(source=source, apply=True)).migrate()
item = next(
i for i in report["items"]
if i["kind"] == "skills" and "empty-fm" in (i["source"] or "")
)
assert item["details"]["opensquilla_loadable"] is False
assert item["details"]["compatibility"] == "not_loadable"
assert "missing frontmatter name" in item["details"]["compatibility_issues"]
def test_skill_with_list_frontmatter_does_not_crash_migration(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Non-dict frontmatter (e.g. a YAML list) used to crash with
# AttributeError on .get; report it as not_loadable instead.
source = _make_hermes_home_with_user_data(tmp_path)
skill = source / "skills" / "list-fm"
skill.mkdir(parents=True)
(skill / "SKILL.md").write_text("---\n- one\n- two\n---\nbody\n", encoding="utf-8")
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = HermesMigrator(HermesMigrationOptions(source=source, apply=True)).migrate()
item = next(
i for i in report["items"]
if i["kind"] == "skills" and "list-fm" in (i["source"] or "")
)
assert item["details"]["compatibility"] == "not_loadable"