feat(skill-catalog-subscription): synced catalog + HTTP sync + MCP tools
Consumes the external Subscription Platform as source of truth for skill content; reuses skills_learning domain models (extended with KnowledgeSkill) and workflow.skill_exec resolver. HTTP/MCP deps land in api/ per CONSTITUTION.md; synced skills use a physically separate SQLite file (tasks/skills.sqlite3) to preserve the skill-authoring capability boundary. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
"""Tests for storage/skill_catalog.py.
|
||||
|
||||
Covers task 2.7: visibility filtering, not-found indistinguishability,
|
||||
dangling-tool-reference invalidation, stable sort, round-trip serialization
|
||||
for both knowledge and flow_template kinds.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from core.models import utc_now
|
||||
from skills_learning.models import (
|
||||
FlowStep,
|
||||
FlowTemplateSkill,
|
||||
KnowledgeSkill,
|
||||
SkillMetadata,
|
||||
)
|
||||
from storage.skill_catalog import (
|
||||
SkillCatalogStore,
|
||||
validate_flow_template_tools,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
return SkillCatalogStore(db_path=tmp_path / "skills.sqlite3")
|
||||
|
||||
|
||||
def _knowledge(skill_id: str, name: str, content: str, *, tags=None) -> KnowledgeSkill:
|
||||
return KnowledgeSkill(
|
||||
metadata=SkillMetadata(
|
||||
id=skill_id,
|
||||
name=name,
|
||||
kind="knowledge",
|
||||
tags=tags or [],
|
||||
source="subscription",
|
||||
),
|
||||
content=content,
|
||||
)
|
||||
|
||||
|
||||
def _flow(
|
||||
skill_id: str,
|
||||
name: str,
|
||||
*,
|
||||
steps: list[tuple[str, dict]],
|
||||
parameters: dict | None = None,
|
||||
tags=None,
|
||||
) -> FlowTemplateSkill:
|
||||
return FlowTemplateSkill(
|
||||
metadata=SkillMetadata(
|
||||
id=skill_id,
|
||||
name=name,
|
||||
kind="flow_template",
|
||||
tags=tags or [],
|
||||
source="subscription",
|
||||
),
|
||||
steps=[FlowStep(tool_name=t, args=a) for t, a in steps],
|
||||
parameters=parameters or {},
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Task 2.1: schema + sync-only write contract
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_store_creates_db_file(tmp_path):
|
||||
db = tmp_path / "nested" / "skills.sqlite3"
|
||||
SkillCatalogStore(db_path=db)
|
||||
assert db.exists()
|
||||
|
||||
|
||||
def test_register_subscription_is_idempotent(store):
|
||||
store._register_subscription("sub-a")
|
||||
store._register_subscription("sub-a")
|
||||
# No error, single row, active by default
|
||||
assert store._effective_active({"sub-a"}) == {"sub-a"}
|
||||
|
||||
|
||||
def test_apply_sync_replace_all_clears_prior_skills(store):
|
||||
store._register_subscription("sub-a")
|
||||
store._apply_sync_replace_all(
|
||||
"sub-a",
|
||||
[_knowledge("k1", "keeps", "c"), _flow("f1", "flowy", steps=[("tap", {})])],
|
||||
latest_version=5,
|
||||
)
|
||||
assert len(store.list_skills({"sub-a"})) == 2
|
||||
# Replace with a single skill → previous two must be gone
|
||||
store._apply_sync_replace_all("sub-a", [_knowledge("k2", "new", "c")])
|
||||
ids = {m.id for m in store.list_skills({"sub-a"})}
|
||||
assert ids == {"k2"}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Task 2.2 / 2.3 / 2.4: visibility filtering
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_two_subscriptions(store):
|
||||
store._register_subscription("sub-a")
|
||||
store._register_subscription("sub-b")
|
||||
store._apply_sync_replace_all(
|
||||
"sub-a",
|
||||
[
|
||||
_knowledge("k-a1", "Alpha Knowledge", "c-a1", tags=["alpha"]),
|
||||
_flow(
|
||||
"f-a2",
|
||||
"Alpha Flow",
|
||||
steps=[("tap", {"x": 1})],
|
||||
tags=["alpha"],
|
||||
),
|
||||
],
|
||||
)
|
||||
store._apply_sync_replace_all(
|
||||
"sub-b",
|
||||
[
|
||||
_knowledge("k-b1", "Beta Knowledge", "c-b1", tags=["beta"]),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_list_skills_filters_by_active_subscription(store):
|
||||
_seed_two_subscriptions(store)
|
||||
a = {m.id for m in store.list_skills({"sub-a"})}
|
||||
b = {m.id for m in store.list_skills({"sub-b"})}
|
||||
both = {m.id for m in store.list_skills({"sub-a", "sub-b"})}
|
||||
assert a == {"k-a1", "f-a2"}
|
||||
assert b == {"k-b1"}
|
||||
assert both == {"k-a1", "f-a2", "k-b1"}
|
||||
|
||||
|
||||
def test_list_skills_empty_active_set_returns_empty(store):
|
||||
_seed_two_subscriptions(store)
|
||||
assert store.list_skills(set()) == []
|
||||
|
||||
|
||||
def test_list_skills_with_inactive_subscription(store):
|
||||
_seed_two_subscriptions(store)
|
||||
# Mark sub-a inactive in storage
|
||||
store._set_subscription_state("sub-a", active=False)
|
||||
assert store.list_skills({"sub-a"}) == []
|
||||
# sub-b still visible
|
||||
assert {m.id for m in store.list_skills({"sub-a", "sub-b"})} == {"k-b1"}
|
||||
|
||||
|
||||
def test_list_skills_unknown_subscription_returns_empty(store):
|
||||
_seed_two_subscriptions(store)
|
||||
assert store.list_skills({"unknown-sub"}) == []
|
||||
|
||||
|
||||
def test_search_skills_matches_name_tag_description(store):
|
||||
_seed_two_subscriptions(store)
|
||||
# Name match
|
||||
assert {m.id for m in store.search_skills("Alpha", {"sub-a", "sub-b"})} == {
|
||||
"k-a1",
|
||||
"f-a2",
|
||||
}
|
||||
# Tag match
|
||||
assert {m.id for m in store.search_skills("beta", {"sub-a", "sub-b"})} == {"k-b1"}
|
||||
# Description match would work the same way; verify no-match returns empty
|
||||
assert store.search_skills("zzz", {"sub-a", "sub-b"}) == []
|
||||
|
||||
|
||||
def test_search_skills_respects_visibility(store):
|
||||
_seed_two_subscriptions(store)
|
||||
# sub-a inactive → its skills don't surface even if query matches
|
||||
store._set_subscription_state("sub-a", active=False)
|
||||
assert {m.id for m in store.search_skills("Alpha", {"sub-a", "sub-b"})} == set()
|
||||
|
||||
|
||||
def test_get_skill_returns_full_content_for_knowledge(store):
|
||||
_seed_two_subscriptions(store)
|
||||
skill = store.get_skill("k-a1", {"sub-a"})
|
||||
assert isinstance(skill, KnowledgeSkill)
|
||||
assert skill.content == "c-a1"
|
||||
assert skill.metadata.name == "Alpha Knowledge"
|
||||
|
||||
|
||||
def test_get_skill_returns_full_steps_for_flow_template(store):
|
||||
_seed_two_subscriptions(store)
|
||||
skill = store.get_skill("f-a2", {"sub-a"})
|
||||
assert isinstance(skill, FlowTemplateSkill)
|
||||
assert len(skill.steps) == 1
|
||||
assert skill.steps[0].tool_name == "tap"
|
||||
assert skill.steps[0].args == {"x": 1}
|
||||
|
||||
|
||||
def test_get_skill_unknown_id_returns_none(store):
|
||||
_seed_two_subscriptions(store)
|
||||
assert store.get_skill("does-not-exist", {"sub-a"}) is None
|
||||
|
||||
|
||||
def test_get_skill_not_visible_indistinguishable_from_not_found(store):
|
||||
"""Existence must not leak: invisible skill returns None, same as unknown."""
|
||||
_seed_two_subscriptions(store)
|
||||
# Skill exists under sub-a but caller asks with only sub-b
|
||||
invisible = store.get_skill("k-a1", {"sub-b"})
|
||||
unknown = store.get_skill("never-existed", {"sub-b"})
|
||||
assert invisible is None
|
||||
assert unknown is None
|
||||
|
||||
|
||||
def test_get_skill_with_inactive_subscription_returns_none(store):
|
||||
_seed_two_subscriptions(store)
|
||||
store._set_subscription_state("sub-a", active=False)
|
||||
assert store.get_skill("k-a1", {"sub-a"}) is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Task 2.5: sync-only write contract (no public writes)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_public_write_methods_on_store():
|
||||
"""SkillCatalogStore must not expose create/update/remove publicly."""
|
||||
public_methods = {
|
||||
name
|
||||
for name in dir(SkillCatalogStore)
|
||||
if not name.startswith("_")
|
||||
and callable(getattr(SkillCatalogStore, name))
|
||||
}
|
||||
forbidden = {"create", "update", "remove", "delete", "save", "put", "post"}
|
||||
assert not (public_methods & forbidden), (
|
||||
f"Public write methods found: {public_methods & forbidden}"
|
||||
)
|
||||
|
||||
|
||||
def test_apply_sync_upsert_round_trips_both_kinds(store):
|
||||
store._register_subscription("sub-a")
|
||||
original_k = _knowledge("k1", "Knowledge One", "content body", tags=["t1"])
|
||||
original_f = _flow(
|
||||
"f1",
|
||||
"Flow One",
|
||||
steps=[("tap", {"x": 1}), ("input_text", {"text": "hi"})],
|
||||
parameters={"text": {"type": "string", "required": True}},
|
||||
tags=["t2"],
|
||||
)
|
||||
store._apply_sync_upsert(original_k, "sub-a")
|
||||
store._apply_sync_upsert(original_f, "sub-a")
|
||||
|
||||
fetched_k = store.get_skill("k1", {"sub-a"})
|
||||
fetched_f = store.get_skill("f1", {"sub-a"})
|
||||
assert isinstance(fetched_k, KnowledgeSkill)
|
||||
assert isinstance(fetched_f, FlowTemplateSkill)
|
||||
assert fetched_k.content == "content body"
|
||||
assert fetched_k.metadata.tags == ["t1"]
|
||||
assert [s.tool_name for s in fetched_f.steps] == ["tap", "input_text"]
|
||||
assert fetched_f.steps[0].args == {"x": 1}
|
||||
assert fetched_f.parameters == {"text": {"type": "string", "required": True}}
|
||||
|
||||
|
||||
def test_apply_sync_upsert_overwrites_on_conflict(store):
|
||||
store._register_subscription("sub-a")
|
||||
store._apply_sync_upsert(_knowledge("k1", "v1", "old"), "sub-a")
|
||||
store._apply_sync_upsert(_knowledge("k1", "v1-updated", "new"), "sub-a")
|
||||
skill = store.get_skill("k1", {"sub-a"})
|
||||
assert isinstance(skill, KnowledgeSkill)
|
||||
assert skill.content == "new"
|
||||
assert skill.metadata.name == "v1-updated"
|
||||
|
||||
|
||||
def test_apply_sync_remove_deletes_skill(store):
|
||||
store._register_subscription("sub-a")
|
||||
store._apply_sync_upsert(_knowledge("k1", "v1", "c"), "sub-a")
|
||||
store._apply_sync_remove("k1")
|
||||
assert store.get_skill("k1", {"sub-a"}) is None
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Task 2.6: flow-template tool-reference validation
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_flow_template_tools_all_known_returns_true():
|
||||
skill = _flow("f", "f", steps=[("tap", {}), ("input_text", {"text": "x"})])
|
||||
assert validate_flow_template_tools(skill, {"tap", "input_text"}) is True
|
||||
|
||||
|
||||
def test_validate_flow_template_tools_dangling_returns_false():
|
||||
skill = _flow("f", "f", steps=[("tap", {}), ("ghost_tool", {})])
|
||||
assert validate_flow_template_tools(skill, {"tap"}) is False
|
||||
|
||||
|
||||
def test_validate_flow_template_tools_empty_steps_returns_true():
|
||||
skill = _flow("f", "f", steps=[])
|
||||
assert validate_flow_template_tools(skill, set()) is True
|
||||
|
||||
|
||||
def test_get_skill_returns_none_when_flow_template_has_dangling_reference(store):
|
||||
store._register_subscription("sub-a")
|
||||
skill = _flow("f1", "Flow", steps=[("tap", {}), ("ghost_tool", {})])
|
||||
store._apply_sync_upsert(skill, "sub-a")
|
||||
# Without registered_tools: skill is returned as-is
|
||||
assert store.get_skill("f1", {"sub-a"}) is not None
|
||||
# With registered_tools missing ghost_tool: returns None (unavailable)
|
||||
result = store.get_skill("f1", {"sub-a"}, registered_tools={"tap"})
|
||||
assert result is None
|
||||
# With all tools registered: returns the skill
|
||||
result = store.get_skill("f1", {"sub-a"}, registered_tools={"tap", "ghost_tool"})
|
||||
assert isinstance(result, FlowTemplateSkill)
|
||||
|
||||
|
||||
def test_get_skill_validation_skipped_for_knowledge(store):
|
||||
"""registered_tools must not affect knowledge skills."""
|
||||
store._register_subscription("sub-a")
|
||||
store._apply_sync_upsert(_knowledge("k1", "K", "c"), "sub-a")
|
||||
result = store.get_skill("k1", {"sub-a"}, registered_tools=set())
|
||||
assert isinstance(result, KnowledgeSkill)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Stable sort order
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_skills_is_sorted_by_name(store):
|
||||
store._register_subscription("sub-a")
|
||||
store._apply_sync_replace_all(
|
||||
"sub-a",
|
||||
[
|
||||
_knowledge("z", "Zebra", "c"),
|
||||
_knowledge("a", "Apple", "c"),
|
||||
_knowledge("m", "Mango", "c"),
|
||||
],
|
||||
)
|
||||
names = [m.name for m in store.list_skills({"sub-a"})]
|
||||
assert names == ["Apple", "Mango", "Zebra"]
|
||||
|
||||
|
||||
def test_search_skills_ranks_name_prefix_above_contains(store):
|
||||
store._register_subscription("sub-a")
|
||||
store._apply_sync_replace_all(
|
||||
"sub-a",
|
||||
[
|
||||
_knowledge("k1", "Find Search", "c"), # contains only
|
||||
_knowledge("k2", "Search Tips", "c"), # prefix match
|
||||
],
|
||||
)
|
||||
results = store.search_skills("Search", {"sub-a"})
|
||||
names = [m.name for m in results]
|
||||
# Prefix match "Search Tips" must rank above contains-only "Find Search"
|
||||
assert names[0] == "Search Tips"
|
||||
assert names[1] == "Find Search"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Round-trip serialization for both kinds
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_round_trip_knowledge_preserves_all_metadata(store):
|
||||
store._register_subscription("sub-a")
|
||||
original = KnowledgeSkill(
|
||||
metadata=SkillMetadata(
|
||||
id="rt-k",
|
||||
name="Round",
|
||||
description="desc",
|
||||
kind="knowledge",
|
||||
tags=["a", "b"],
|
||||
source="subscription",
|
||||
version=3,
|
||||
created_at=utc_now(),
|
||||
updated_at=utc_now(),
|
||||
),
|
||||
content="body",
|
||||
)
|
||||
store._apply_sync_upsert(original, "sub-a")
|
||||
fetched = store.get_skill("rt-k", {"sub-a"})
|
||||
assert isinstance(fetched, KnowledgeSkill)
|
||||
assert fetched.metadata.id == "rt-k"
|
||||
assert fetched.metadata.name == "Round"
|
||||
assert fetched.metadata.description == "desc"
|
||||
assert fetched.metadata.kind == "knowledge"
|
||||
assert fetched.metadata.tags == ["a", "b"]
|
||||
assert fetched.metadata.source == "subscription"
|
||||
assert fetched.metadata.version == 3
|
||||
assert fetched.content == "body"
|
||||
|
||||
|
||||
def test_round_trip_flow_template_preserves_steps_and_parameters(store):
|
||||
store._register_subscription("sub-a")
|
||||
original = FlowTemplateSkill(
|
||||
metadata=SkillMetadata(
|
||||
id="rt-f",
|
||||
name="RT Flow",
|
||||
description="d",
|
||||
kind="flow_template",
|
||||
tags=["x"],
|
||||
source="subscription",
|
||||
version=2,
|
||||
),
|
||||
steps=[
|
||||
FlowStep(tool_name="tap", args={"x": 10, "y": 20}),
|
||||
FlowStep(tool_name="input_text", args={"text": "hello"}),
|
||||
],
|
||||
parameters={"text": {"type": "string", "required": True}},
|
||||
)
|
||||
store._apply_sync_upsert(original, "sub-a")
|
||||
fetched = store.get_skill("rt-f", {"sub-a"})
|
||||
assert isinstance(fetched, FlowTemplateSkill)
|
||||
assert [s.tool_name for s in fetched.steps] == ["tap", "input_text"]
|
||||
assert fetched.steps[0].args == {"x": 10, "y": 20}
|
||||
assert fetched.parameters == {"text": {"type": "string", "required": True}}
|
||||
assert fetched.metadata.version == 2
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Subscription state observability (used by sync failure path)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_set_subscription_state_records_error_and_version(store):
|
||||
store._register_subscription("sub-a")
|
||||
store._set_subscription_state(
|
||||
"sub-a",
|
||||
active=False,
|
||||
last_synced_version=7,
|
||||
last_error="401 Unauthorized",
|
||||
)
|
||||
with store._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT active, last_synced_version, last_error FROM subscriptions WHERE id = ?",
|
||||
("sub-a",),
|
||||
).fetchone()
|
||||
assert dict(row) == {
|
||||
"active": 0,
|
||||
"last_synced_version": 7,
|
||||
"last_error": "401 Unauthorized",
|
||||
}
|
||||
Reference in New Issue
Block a user