Files
opensquilla--opensquilla/tests/test_migration/test_openclaw_bootstrap_template_repro.py
2026-07-13 13:12:33 +08:00

539 lines
23 KiB
Python

"""Repro: opensquilla bootstrap-template MEMORY.md blocks openclaw migration.
When opensquilla initializes a workspace (via ``ensure_agent_workspace``) it
seeds bootstrap templates for ``SOUL.md`` / ``USER.md`` / ``AGENTS.md`` /
``MEMORY.md``. These are placeholder docs (5-line comments). Before the fix
the openclaw migrator's ``_write_text_target`` hit the conflict gate on
every one of them, silently dropping every workspace file the user actually
wanted migrated — including the real reason the user invoked the migration,
their daily memory.
The fix detects "destination still holds the pristine bootstrap template"
and treats it as overwrite-safe (item-level backup + replace), with an
explicit ``details.replaced_bootstrap_template`` flag in the report.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from opensquilla.identity.bootstrap import ensure_agent_workspace
from opensquilla.migration.openclaw import MigrationOptions, OpenClawMigrator
def _make_openclaw_source(root: Path) -> Path:
source = root / ".openclaw"
workspace = source / "workspace"
workspace.mkdir(parents=True)
(workspace / "SOUL.md").write_text("openclaw real soul\n", encoding="utf-8")
(workspace / "USER.md").write_text("openclaw real user\n", encoding="utf-8")
(workspace / "AGENTS.md").write_text("openclaw agents guide\n", encoding="utf-8")
(workspace / "MEMORY.md").write_text("openclaw long-term memory\n", encoding="utf-8")
(workspace / "memory").mkdir()
(workspace / "memory" / "2026-05-04.md").write_text(
"real daily entry that needs to survive migration\n",
encoding="utf-8",
)
(source / "openclaw.json").write_text("{}", encoding="utf-8")
return source
def test_pristine_bootstrap_templates_do_not_block_migration(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = _make_openclaw_source(tmp_path)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
ensure_agent_workspace(home / "workspace")
# Confirm the templates were seeded (precondition for the bug).
for filename in ("SOUL.md", "USER.md", "AGENTS.md", "MEMORY.md"):
assert (home / "workspace" / filename).is_file()
report = OpenClawMigrator(
MigrationOptions(source=source, config_path=tmp_path / "cfg.toml", apply=True)
).migrate()
statuses = {item["kind"]: item["status"] for item in report["items"]}
for kind in ("soul", "user-profile", "workspace-agents", "memory"):
assert statuses.get(kind) == "migrated", (
f"kind={kind} expected migrated, got {statuses.get(kind)!r}. "
f"items: {[i for i in report['items'] if i['kind']==kind]}"
)
# Bootstrap-template replacement is announced via the details flag so the
# report makes the special case visible rather than silent.
for kind in ("soul", "user-profile", "workspace-agents", "memory"):
item = next(i for i in report["items"] if i["kind"] == kind)
assert item["details"].get("replaced_bootstrap_template") is True, (
f"kind={kind} did not record replaced_bootstrap_template: {item}"
)
# The migrated content actually landed and the daily memory is in MEMORY.md.
memory_text = (home / "workspace" / "MEMORY.md").read_text(encoding="utf-8")
assert "real daily entry that needs to survive migration" in memory_text
soul_text = (home / "workspace" / "SOUL.md").read_text(encoding="utf-8")
# rebrand: openclaw -> opensquilla in workspace prose.
assert "opensquilla real soul" in soul_text
assert "openclaw" not in soul_text.lower()
# Item-level backups of the pristine templates exist for rollback.
backups = sorted((home / "workspace").glob("*.backup.*"))
backup_basenames = {b.name.split(".backup.")[0] for b in backups}
assert {"SOUL.md", "USER.md", "MEMORY.md", "AGENTS.md"} <= backup_basenames
def test_user_edited_memory_is_preserved_and_openclaw_appended(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# User who has truly edited their MEMORY.md should NOT have their
# content silently overwritten AND the openclaw memory should NOT be
# silently dropped. The migrator merges instead: user content stays
# at the top, openclaw blocks that aren't already there are appended.
source = _make_openclaw_source(tmp_path)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
ensure_agent_workspace(home / "workspace")
(home / "workspace" / "MEMORY.md").write_text(
"# My real, edited memory\n\nThis is not the template.\n",
encoding="utf-8",
)
report = OpenClawMigrator(
MigrationOptions(source=source, config_path=tmp_path / "cfg.toml", apply=True)
).migrate()
memory_item = next(i for i in report["items"] if i["kind"] == "memory")
assert memory_item["status"] == "migrated"
assert memory_item["details"]["appended_to_existing"] is True
assert memory_item["details"]["new_blocks_appended"] >= 1
# User content verbatim at the top.
text = (home / "workspace" / "MEMORY.md").read_text(encoding="utf-8")
assert text.startswith("# My real, edited memory")
assert "This is not the template." in text
# openclaw content appended below.
assert "real daily entry that needs to survive migration" in text
# The pre-existing edited file was backed up before being modified.
backups = list((home / "workspace").glob("MEMORY.md.backup.*"))
assert len(backups) == 1
assert (
"This is not the template."
in backups[0].read_text(encoding="utf-8")
)
def test_re_migration_against_existing_destination_dedupes_to_skip(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Second pass over an already-migrated destination should record
# `skipped: all openclaw memory blocks already present in destination`
# and leave the file untouched. No duplicate growth, no conflict.
source = _make_openclaw_source(tmp_path)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
ensure_agent_workspace(home / "workspace")
OpenClawMigrator(
MigrationOptions(source=source, config_path=tmp_path / "cfg.toml", apply=True)
).migrate()
after_first = (home / "workspace" / "MEMORY.md").read_text(encoding="utf-8")
second = OpenClawMigrator(
MigrationOptions(source=source, config_path=tmp_path / "cfg.toml", apply=True)
).migrate()
memory_item = next(i for i in second["items"] if i["kind"] == "memory")
assert memory_item["status"] == "skipped"
assert "already present" in memory_item["reason"]
assert memory_item["details"]["deduplicated_against_existing"] is True
after_second = (home / "workspace" / "MEMORY.md").read_text(encoding="utf-8")
assert after_first == after_second # byte-identical, no second backup either
def test_partial_overlap_appends_only_new_blocks(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Destination already contains one of the openclaw daily blocks (e.g. a
# prior migration's output). Only the genuinely new blocks should be
# appended; duplicates should be counted in details.
source = tmp_path / ".openclaw"
ws = source / "workspace"
ws.mkdir(parents=True)
(ws / "memory").mkdir()
(ws / "memory" / "day-one.md").write_text("shared fact\n", encoding="utf-8")
(ws / "memory" / "day-two.md").write_text("brand new fact\n", encoding="utf-8")
(source / "openclaw.json").write_text("{}", encoding="utf-8")
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
(home / "workspace").mkdir(parents=True)
# Pre-existing destination already has the day-one block.
(home / "workspace" / "MEMORY.md").write_text(
"Unique opensquilla note.\n\n"
"## Imported daily memory: day-one.md\n\n"
"shared fact\n",
encoding="utf-8",
)
report = OpenClawMigrator(
MigrationOptions(source=source, config_path=tmp_path / "cfg.toml", apply=True)
).migrate()
memory_item = next(i for i in report["items"] if i["kind"] == "memory")
assert memory_item["status"] == "migrated"
assert memory_item["details"]["new_blocks_appended"] == 1
assert memory_item["details"]["deduplicated_blocks_vs_existing"] >= 1
text = (home / "workspace" / "MEMORY.md").read_text(encoding="utf-8")
assert "Unique opensquilla note" in text
assert text.count("shared fact") == 1
assert "brand new fact" in text
def test_persona_conflict_default_in_non_tty_keeps_opensquilla_and_archives(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# In a non-TTY context (pytest, CI, --json) the default `prompt` mode
# cannot actually prompt, so it falls back to the safe choice: keep
# the user's opensquilla content and archive the openclaw original
# under archive/files/openclaw-orphaned/ so nothing is silently lost.
source = _make_openclaw_source(tmp_path)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
(home / "workspace").mkdir(parents=True)
(home / "workspace" / "SOUL.md").write_text(
"# My real opensquilla persona\nI am precise and terse.\n",
encoding="utf-8",
)
report = OpenClawMigrator(
MigrationOptions(source=source, config_path=tmp_path / "cfg.toml", apply=True)
).migrate()
soul = next(i for i in report["items"] if i["kind"] == "soul")
assert soul["status"] == "skipped"
assert "kept existing opensquilla content" in soul["reason"]
assert soul["details"]["persona_conflict_resolution"] == "use-opensquilla"
# Existing opensquilla content untouched.
assert (
"I am precise and terse."
in (home / "workspace" / "SOUL.md").read_text(encoding="utf-8")
)
# Openclaw original archived, not silently dropped.
orphan = Path(report["output_dir"]) / "archive" / "files" / "openclaw-orphaned" / "SOUL.md"
assert orphan.is_file()
assert "openclaw real soul" in orphan.read_text(encoding="utf-8")
def test_persona_conflict_use_openclaw_replaces_with_backup(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = _make_openclaw_source(tmp_path)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
(home / "workspace").mkdir(parents=True)
(home / "workspace" / "SOUL.md").write_text(
"OPENSQUILLA ORIGINAL\n", encoding="utf-8"
)
OpenClawMigrator(
MigrationOptions(
source=source,
config_path=tmp_path / "cfg.toml",
apply=True,
persona_conflict="use-openclaw",
)
).migrate()
text = (home / "workspace" / "SOUL.md").read_text(encoding="utf-8")
assert "OPENSQUILLA ORIGINAL" not in text
# rebrand turns "openclaw real soul" -> "opensquilla real soul"
assert "opensquilla real soul" in text
backups = list((home / "workspace").glob("SOUL.md.backup.*"))
assert len(backups) == 1
assert "OPENSQUILLA ORIGINAL" in backups[0].read_text(encoding="utf-8")
def test_persona_conflict_merge_appends_with_separator(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = _make_openclaw_source(tmp_path)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
(home / "workspace").mkdir(parents=True)
(home / "workspace" / "USER.md").write_text(
"I am Alice, working on RPC layer.\n", encoding="utf-8"
)
report = OpenClawMigrator(
MigrationOptions(
source=source,
config_path=tmp_path / "cfg.toml",
apply=True,
persona_conflict="merge",
)
).migrate()
user_item = next(i for i in report["items"] if i["kind"] == "user-profile")
assert user_item["status"] == "migrated"
assert user_item["details"]["persona_conflict_resolution"] == "merge"
text = (home / "workspace" / "USER.md").read_text(encoding="utf-8")
# Existing opensquilla content preserved at the top.
assert text.startswith("I am Alice")
# OpenClaw content appended after a clear separator.
assert "## Imported from OpenClaw" in text
assert "opensquilla real user" in text # rebrand applied
# Pre-existing file was backed up.
backups = list((home / "workspace").glob("USER.md.backup.*"))
assert len(backups) == 1
def test_persona_conflict_skip_drops_both_with_explicit_record(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
source = _make_openclaw_source(tmp_path)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
(home / "workspace").mkdir(parents=True)
(home / "workspace" / "AGENTS.md").write_text(
"WORKSPACE OPERATING RULES\n", encoding="utf-8"
)
report = OpenClawMigrator(
MigrationOptions(
source=source,
config_path=tmp_path / "cfg.toml",
apply=True,
persona_conflict="skip",
)
).migrate()
agents_item = next(i for i in report["items"] if i["kind"] == "workspace-agents")
assert agents_item["status"] == "skipped"
assert agents_item["reason"] == "user chose to skip this file"
assert agents_item["details"]["persona_conflict_resolution"] == "skip"
# Existing content unchanged.
assert (
"WORKSPACE OPERATING RULES"
in (home / "workspace" / "AGENTS.md").read_text(encoding="utf-8")
)
# When user explicitly says "skip" we do NOT archive openclaw — they
# asked to drop it.
orphan = Path(report["output_dir"]) / "archive" / "files" / "openclaw-orphaned" / "AGENTS.md"
assert not orphan.exists()
def test_persona_conflict_use_opensquilla_keeps_dest_and_archives_openclaw(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Explicit (non-prompt) use-opensquilla. Same behavior as the non-TTY
# default but reached via an explicit CLI flag instead of fallback.
source = _make_openclaw_source(tmp_path)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
(home / "workspace").mkdir(parents=True)
(home / "workspace" / "SOUL.md").write_text(
"EXISTING CONTENT TO PRESERVE\n", encoding="utf-8"
)
report = OpenClawMigrator(
MigrationOptions(
source=source,
config_path=tmp_path / "cfg.toml",
apply=True,
persona_conflict="use-opensquilla",
)
).migrate()
soul = next(i for i in report["items"] if i["kind"] == "soul")
assert soul["status"] == "skipped"
assert soul["details"]["persona_conflict_resolution"] == "use-opensquilla"
assert (
"EXISTING CONTENT TO PRESERVE"
in (home / "workspace" / "SOUL.md").read_text(encoding="utf-8")
)
orphan = Path(report["output_dir"]) / "archive" / "files" / "openclaw-orphaned" / "SOUL.md"
assert orphan.is_file()
def test_overwrite_flag_still_replaces_everything(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# --overwrite is the explicit "replace, do not merge" escape hatch.
# Confirm its semantics are unchanged by the new merge-by-default flow.
source = _make_openclaw_source(tmp_path)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
(home / "workspace").mkdir(parents=True)
(home / "workspace" / "MEMORY.md").write_text(
"REAL USER MEMORY THAT GETS REPLACED\n", encoding="utf-8"
)
OpenClawMigrator(
MigrationOptions(
source=source, config_path=tmp_path / "cfg.toml", apply=True, overwrite=True
)
).migrate()
text = (home / "workspace" / "MEMORY.md").read_text(encoding="utf-8")
assert "REAL USER MEMORY THAT GETS REPLACED" not in text
assert "real daily entry that needs to survive migration" in text
# Backup of the previous content is still kept on --overwrite.
backups = list((home / "workspace").glob("MEMORY.md.backup.*"))
assert len(backups) == 1
assert "REAL USER MEMORY THAT GETS REPLACED" in backups[0].read_text(encoding="utf-8")
def test_template_detection_is_robust_to_trailing_whitespace(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# A stray trailing newline or platform EOL artifact should not disqualify
# the destination from being treated as the pristine template.
source = _make_openclaw_source(tmp_path)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
ensure_agent_workspace(home / "workspace")
memory_path = home / "workspace" / "MEMORY.md"
original = memory_path.read_text(encoding="utf-8")
memory_path.write_text(original.rstrip() + "\n\n\n", encoding="utf-8")
report = OpenClawMigrator(
MigrationOptions(source=source, config_path=tmp_path / "cfg.toml", apply=True)
).migrate()
memory_item = next(i for i in report["items"] if i["kind"] == "memory")
assert memory_item["status"] == "migrated"
assert memory_item["details"]["replaced_bootstrap_template"] is True
def test_openclaw_workspace_with_opensquilla_mention_is_kept_verbatim(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Same mixed-subject prose bug as hermes, on the openclaw side.
# Source SOUL.md mentions OpenClaw AND OpenSquilla as distinct
# entities — mechanical rebrand would corrupt every sentence. The
# migrator must write the text verbatim and surface
# ``details.rebrand_skipped``.
source = _make_openclaw_source(tmp_path)
(source / "workspace" / "SOUL.md").write_text(
"OpenClaw v1.2 is installed at ~/.openclaw.\n"
"OpenSquilla is also installed at ~/.opensquilla and exposes "
"`migrate openclaw` for importing OpenClaw state.\n",
encoding="utf-8",
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = OpenClawMigrator(
MigrationOptions(source=source, config_path=tmp_path / "cfg.toml", apply=True)
).migrate()
migrated = (home / "workspace" / "SOUL.md").read_text(encoding="utf-8")
# OpenClaw mention preserved verbatim (no rebrand).
assert "OpenClaw v1.2 is installed at ~/.openclaw" in migrated
# The "migrate openclaw" command name is kept verbatim too.
assert "`migrate openclaw`" in migrated
soul_item = next(i for i in report["items"] if i["kind"] == "soul")
assert soul_item["details"]["rebrand_skipped"] == "mentions-opensquilla"
assert "semantic_conversions" not in soul_item["details"]
def test_openclaw_rebrand_does_not_mangle_path_substrings() -> None:
# Plain string replace turned ``.openclawrc`` into ``.opensquillarc``,
# ``OpenClawFlavored`` into ``OpenSquillaFlavored``, and
# ``openclaw_pid`` into ``opensquilla_pid``. None of these were
# intentional rebrands. Word-boundary aware regex replacement
# leaves prefix-substring tokens alone.
from opensquilla.migration.openclaw import _rebrand_text
cases = [
("Config ~/.openclawrc keeps working", "Config ~/.openclawrc keeps working"),
("var openclaw_pid", "var openclaw_pid"),
("Has OpenClawFlavored name", "Has OpenClawFlavored name"),
# Legitimate rebrands:
("Use ~/.openclaw home", "Use ~/.opensquilla home"),
("I am OpenClaw", "I am OpenSquilla"),
("Run: openclaw migrate", "Run: opensquilla migrate"),
]
for source_text, expected in cases:
out, _ = _rebrand_text(source_text)
assert out == expected, f"{source_text!r} -> {out!r}, expected {expected!r}"
def test_openclaw_mcp_enabled_false_is_preserved(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Same fix as hermes side: respect an explicit ``mcp.enabled=false``
# when the user already has configured servers.
import json as _json
source = tmp_path / ".openclaw"
(source / "workspace").mkdir(parents=True)
(source / "openclaw.json").write_text(
_json.dumps({"mcp": {"servers": {"new": {"command": "/usr/bin/x"}}}}),
encoding="utf-8",
)
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
home.mkdir(parents=True, exist_ok=True)
config_path = tmp_path / "cfg.toml"
config_path.write_text(
"[mcp]\nenabled = false\n\n"
"[[mcp.servers]]\n"
"name = \"existing\"\ncommand = \"/usr/bin/y\"\ntransport = \"stdio\"\n",
encoding="utf-8",
)
report = OpenClawMigrator(
MigrationOptions(source=source, config_path=config_path, apply=True)
).migrate()
import tomllib
data = tomllib.loads(config_path.read_text())
assert data["mcp"]["enabled"] is False
mc = next(i for i in report["items"] if i["kind"] == "mcp-servers")
assert mc["details"]["mcp_enabled_left_disabled"] is True
def test_openclaw_memory_blocks_track_skipped_count(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Two daily-memory entries: one is pure OpenClaw prose (gets rebranded),
# the other is mixed-subject (must be kept verbatim). The memory
# report should record that exactly one block was skipped while the
# other was rebranded.
#
# Note: the openclaw rebrand protects "OpenClaw home/skills/..." as
# source-reference markers, so we use plain "I am OpenClaw" (which
# IS rebranded to "I am OpenSquilla") to drive the rebranded branch.
source = tmp_path / ".openclaw"
ws = source / "workspace"
ws.mkdir(parents=True)
(ws / "memory").mkdir()
(ws / "memory" / "single.md").write_text(
"I am OpenClaw and I remember everything.\n",
encoding="utf-8",
)
(ws / "memory" / "mixed.md").write_text(
"OpenClaw v1.2 lives at ~/.openclaw; OpenSquilla is at ~/.opensquilla.\n",
encoding="utf-8",
)
(source / "openclaw.json").write_text("{}", encoding="utf-8")
home = tmp_path / "opensquilla-home"
monkeypatch.setenv("OPENSQUILLA_STATE_DIR", str(home))
report = OpenClawMigrator(
MigrationOptions(source=source, config_path=tmp_path / "cfg.toml", apply=True)
).migrate()
text = (home / "workspace" / "MEMORY.md").read_text(encoding="utf-8")
# Rebranded block: "I am OpenClaw" -> "I am OpenSquilla".
assert "I am OpenSquilla" in text
# Mixed-subject block: kept verbatim, OpenClaw mention survives.
assert "OpenClaw v1.2 lives at ~/.openclaw" in text
memory_item = next(i for i in report["items"] if i["kind"] == "memory")
assert memory_item["details"]["rebrand_skipped"] == "mentions-opensquilla"
assert memory_item["details"]["rebrand_skipped_block_count"] == 1
# The other block was rebranded, so semantic_conversions still appears.
assert memory_item["details"].get("semantic_conversions") == ["openclaw-branding"]