1081 lines
45 KiB
Python
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"
|