feat(plugins): enqueue skill upgrade candidates
This commit is contained in:
@ -8,7 +8,7 @@ import pytest
|
||||
from beaver.foundation.utils.file_lock import WorkspaceWriteLock
|
||||
from beaver.memory.skills import SkillLearningStore
|
||||
from beaver.plugins.discovery import discover_plugins
|
||||
from beaver.plugins.skills import PluginManager
|
||||
from beaver.plugins.skills import PluginManager, classify_plugin_skill_update
|
||||
from beaver.plugins.state import PluginStateStore
|
||||
from beaver.skills.catalog.loader import SkillsLoader
|
||||
from beaver.skills.learning.safety import SkillDraftSafetyChecker
|
||||
@ -55,6 +55,24 @@ def _write_skill_plugin(
|
||||
return plugin_root
|
||||
|
||||
|
||||
def _rewrite_plugin_version(plugin_root: Path, *, version: str, skill_text: str | None = None, template: str | None = None) -> None:
|
||||
manifest_path = plugin_root / "beaver.plugin.json"
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest["version"] = version
|
||||
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
skill_name = manifest["skills"][0]["name"]
|
||||
skill_root = plugin_root / "skills" / skill_name
|
||||
if skill_text is not None:
|
||||
(skill_root / "SKILL.md").write_text(
|
||||
"---\nname: {0}\ndescription: Comic workflow\ntools: []\n---\n\n{1}".format(skill_name, skill_text),
|
||||
encoding="utf-8",
|
||||
)
|
||||
if template is not None:
|
||||
target = skill_root / "templates" / "panel.txt"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(template, encoding="utf-8")
|
||||
|
||||
|
||||
def _manager(workspace: Path) -> PluginManager:
|
||||
discovery = discover_plugins(workspace, search_paths=[])
|
||||
skill_store = SkillSpecStore(workspace)
|
||||
@ -143,3 +161,76 @@ def test_enable_plugin_is_idempotent(tmp_path: Path) -> None:
|
||||
assert first.status == "synced"
|
||||
assert second.status == "synced"
|
||||
assert SkillSpecStore(workspace).list_versions("baoyu-comic") == ["v0001"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("base", "local", "upstream", "expected"),
|
||||
[
|
||||
("A", "A", "A", "unchanged"),
|
||||
("A", "B", "B", "already_applied"),
|
||||
("A", "A", "B", "fast_forward"),
|
||||
("A", "LOCAL", "UPSTREAM", "three_way"),
|
||||
],
|
||||
)
|
||||
def test_classify_plugin_skill_update(base: str, local: str, upstream: str, expected: str) -> None:
|
||||
assert classify_plugin_skill_update(base, local, upstream) == expected
|
||||
|
||||
|
||||
def test_sync_enabled_creates_idempotent_fast_forward_candidate_for_supporting_file_update(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
plugin_root = _write_skill_plugin(workspace / "plugins", extra_files={"templates/panel.txt": "v1"})
|
||||
manager = _manager(workspace)
|
||||
manager.enable("baoyu-comic")
|
||||
_rewrite_plugin_version(plugin_root, version="1.1.0", template="v2")
|
||||
|
||||
first = _manager(workspace).sync_enabled()
|
||||
second = _manager(workspace).sync_enabled()
|
||||
candidates = SkillLearningStore(workspace / "memory" / "skills").list_learning_candidates()
|
||||
|
||||
assert first["baoyu-comic"].skills["baoyu-comic"].status == "update_pending"
|
||||
assert second["baoyu-comic"].skills["baoyu-comic"].status == "update_pending"
|
||||
assert len(candidates) == 1
|
||||
candidate = candidates[0]
|
||||
assert candidate.kind == "plugin_skill_update"
|
||||
assert candidate.candidate_id.startswith("plugin-update:baoyu-comic:baoyu-comic:")
|
||||
assert candidate.evidence["merge_mode"] == "fast_forward"
|
||||
assert "Draw panels" not in json.dumps(candidate.evidence)
|
||||
|
||||
|
||||
def test_sync_enabled_creates_three_way_candidate_when_local_diverged(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
plugin_root = _write_skill_plugin(workspace / "plugins")
|
||||
manager = _manager(workspace)
|
||||
manager.enable("baoyu-comic")
|
||||
store = SkillSpecStore(workspace)
|
||||
loaded = store.read_published_skill("baoyu-comic")
|
||||
assert loaded is not None
|
||||
local_version = loaded.version
|
||||
local_version.version = "v0002"
|
||||
local_version.parent_version = "v0001"
|
||||
store.write_skill_version(local_version, loaded.content + "\nLocal learning.\n")
|
||||
store.set_current_version("baoyu-comic", "v0002")
|
||||
_rewrite_plugin_version(plugin_root, version="1.1.0", skill_text="# Baoyu Comic\n\nUpstream change.\n")
|
||||
|
||||
_manager(workspace).sync_enabled()
|
||||
candidate = SkillLearningStore(workspace / "memory" / "skills").list_learning_candidates()[0]
|
||||
|
||||
assert candidate.evidence["merge_mode"] == "three_way"
|
||||
assert candidate.evidence["local_version"] == "v0002"
|
||||
|
||||
|
||||
def test_sync_enabled_supersedes_stale_pending_update(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
plugin_root = _write_skill_plugin(workspace / "plugins")
|
||||
_manager(workspace).enable("baoyu-comic")
|
||||
_rewrite_plugin_version(plugin_root, version="1.1.0", skill_text="# Baoyu Comic\n\nFirst update.\n")
|
||||
_manager(workspace).sync_enabled()
|
||||
first_candidate = SkillLearningStore(workspace / "memory" / "skills").list_learning_candidates()[0]
|
||||
|
||||
_rewrite_plugin_version(plugin_root, version="1.2.0", skill_text="# Baoyu Comic\n\nSecond update.\n")
|
||||
_manager(workspace).sync_enabled()
|
||||
candidates = SkillLearningStore(workspace / "memory" / "skills").list_learning_candidates()
|
||||
|
||||
assert len(candidates) == 2
|
||||
assert {candidate.status for candidate in candidates} == {"open", "superseded"}
|
||||
assert any(candidate.candidate_id != first_candidate.candidate_id for candidate in candidates)
|
||||
|
||||
Reference in New Issue
Block a user