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>
370 lines
13 KiB
Python
370 lines
13 KiB
Python
"""End-to-end validation for skill-catalog-subscription (tasks 5.1-5.3).
|
|
|
|
Combines: FakeSubscriptionClient → SkillSyncRunner → SkillCatalogStore →
|
|
api.skill_catalog_mcp handlers. Verifies the full sync+query+resolve loop
|
|
and that resolved flow templates drive through the existing device-capability
|
|
tools one step at a time (no batched execution; Observe-Think-Act preserved).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from api.mcp import tool_handlers
|
|
from api.skill_catalog_mcp import skill_tool_handlers
|
|
from api.skill_sync import SkillSyncRunner, SyncDelta
|
|
from device.manager import DeviceManager
|
|
from runtime.executor import Executor, ExecutorConfig
|
|
from runtime.planner import PlannedStep
|
|
from skills_learning.models import (
|
|
FlowStep,
|
|
FlowTemplateSkill,
|
|
KnowledgeSkill,
|
|
SkillMetadata,
|
|
)
|
|
from storage.skill_catalog import SkillCatalogStore
|
|
from tests.fakes import FakeDriver
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Shared fake client
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
class E2EFakeClient:
|
|
"""In-process SubscriptionClient with revocable entitlements."""
|
|
|
|
def __init__(self) -> None:
|
|
self.skills_by_sub: dict[str, list[Any]] = {}
|
|
|
|
def set_skills(self, sub_id: str, skills: list[Any]) -> None:
|
|
self.skills_by_sub[sub_id] = list(skills)
|
|
|
|
def revoke(self, sub_id: str) -> None:
|
|
self.skills_by_sub.pop(sub_id, None)
|
|
|
|
def fetch_entitled_skills(self, subscription_id, since_version=None):
|
|
skills = self.skills_by_sub.get(subscription_id, [])
|
|
return SyncDelta(
|
|
skills=list(skills),
|
|
removed_ids=[],
|
|
latest_version=1,
|
|
is_full_replace=True,
|
|
)
|
|
|
|
|
|
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 {},
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def store(tmp_path):
|
|
return SkillCatalogStore(db_path=tmp_path / "e2e_skills.sqlite3")
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_client():
|
|
return E2EFakeClient()
|
|
|
|
|
|
@pytest.fixture
|
|
def runner(store, fake_client):
|
|
return SkillSyncRunner(
|
|
store=store,
|
|
client=fake_client,
|
|
subscriptions=["sub-a"],
|
|
poll_interval=999.0, # no auto-poll
|
|
)
|
|
|
|
|
|
def _mcp_handlers(store, *, registered_tools, active=None):
|
|
return skill_tool_handlers(
|
|
store=store,
|
|
get_active_subscriptions=lambda: set(active if active is not None else {"sub-a"}),
|
|
get_registered_tools=lambda: set(registered_tools),
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Task 5.1: seed + verify listable / searchable / fetchable via MCP
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_e2e_synced_skills_are_listable_searchable_fetchable_via_mcp(
|
|
store, fake_client, runner
|
|
):
|
|
# Seed the fake Subscription Platform with one of each kind
|
|
fake_client.set_skills(
|
|
"sub-a",
|
|
[
|
|
_knowledge(
|
|
"xhs-tips",
|
|
"Xiaohongshu Search Tips",
|
|
"Use specific keywords and filter by recent posts.",
|
|
tags=["social", "search"],
|
|
),
|
|
_flow(
|
|
"xhs-search-flow",
|
|
"Xiaohongshu Search Flow",
|
|
steps=[
|
|
("tap", {"x": "{search_x}", "y": "{search_y}"}),
|
|
("input_text", {"text": "{query}"}),
|
|
("tap", {"x": "{submit_x}", "y": "{submit_y}"}),
|
|
],
|
|
parameters={
|
|
"search_x": {"type": "number", "required": True},
|
|
"search_y": {"type": "number", "required": True},
|
|
"query": {"type": "string", "required": True},
|
|
"submit_x": {"type": "number", "required": True},
|
|
"submit_y": {"type": "number", "required": True},
|
|
},
|
|
tags=["social", "automation"],
|
|
),
|
|
],
|
|
)
|
|
|
|
# Run one sync tick
|
|
outcomes = runner.tick()
|
|
assert outcomes["sub-a"].success is True
|
|
assert outcomes["sub-a"].fetched == 2
|
|
|
|
# All device-capability tools are registered (used as the validator set)
|
|
registered = {"tap", "input_text", "swipe", "launch_app"}
|
|
handlers = _mcp_handlers(store, registered_tools=registered)
|
|
|
|
# list_skills returns both
|
|
listed = handlers["list_skills"]()
|
|
assert listed["ok"] is True
|
|
ids = {s["id"] for s in listed["skills"]}
|
|
assert ids == {"xhs-tips", "xhs-search-flow"}
|
|
|
|
# search_skills finds the knowledge skill by tag and the flow by name
|
|
by_tag = handlers["search_skills"](query="social")
|
|
assert {s["id"] for s in by_tag["skills"]} == {"xhs-tips", "xhs-search-flow"}
|
|
by_name = handlers["search_skills"](query="Xiaohongshu Search Flow")
|
|
assert {s["id"] for s in by_name["skills"]} == {"xhs-search-flow"}
|
|
|
|
# get_skill returns full content for each kind
|
|
knowledge = handlers["get_skill"](skill_id="xhs-tips")
|
|
assert knowledge["ok"] is True
|
|
assert knowledge["skill"]["kind"] == "knowledge"
|
|
assert "specific keywords" in knowledge["skill"]["content"]
|
|
|
|
flow = handlers["get_skill"](skill_id="xhs-search-flow")
|
|
assert flow["ok"] is True
|
|
assert flow["skill"]["kind"] == "flow_template"
|
|
assert [s["tool_name"] for s in flow["skill"]["steps"]] == [
|
|
"tap",
|
|
"input_text",
|
|
"tap",
|
|
]
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Task 5.2: revocation — sync-time removal + query-time re-check
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_e2e_revocation_via_sync_cycle_removes_skill(
|
|
store, fake_client, runner
|
|
):
|
|
"""When the Subscription Platform no longer includes a skill in the
|
|
entitled set, the next sync cycle removes it locally."""
|
|
fake_client.set_skills(
|
|
"sub-a",
|
|
[_knowledge("k1", "Keep Me", "c"), _knowledge("k2", "Revoke Me", "c")],
|
|
)
|
|
runner.tick()
|
|
handlers = _mcp_handlers(store, registered_tools=set())
|
|
assert {s["id"] for s in handlers["list_skills"]()["skills"]} == {"k1", "k2"}
|
|
|
|
# Subscription Platform revokes k2
|
|
fake_client.set_skills("sub-a", [_knowledge("k1", "Keep Me", "c")])
|
|
runner.tick()
|
|
|
|
after = handlers["list_skills"]()
|
|
ids = {s["id"] for s in after["skills"]}
|
|
assert ids == {"k1"}
|
|
assert handlers["get_skill"](skill_id="k2")["ok"] is False
|
|
|
|
|
|
def test_e2e_revocation_via_query_time_recheck_hides_skill_before_next_sync(
|
|
store, fake_client, runner
|
|
):
|
|
"""Marking a subscription inactive hides its skills on the next query,
|
|
without waiting for the next sync tick to remove the rows."""
|
|
fake_client.set_skills("sub-a", [_knowledge("k1", "K", "c")])
|
|
runner.tick()
|
|
handlers_before = _mcp_handlers(store, registered_tools=set())
|
|
assert handlers_before["get_skill"](skill_id="k1")["ok"] is True
|
|
|
|
# Revoke the subscription locally (e.g., via push notification of revocation)
|
|
store._set_subscription_state("sub-a", active=False)
|
|
|
|
# Same handler config — query re-check excludes sub-a's skills immediately
|
|
handlers_after = _mcp_handlers(store, registered_tools=set())
|
|
assert handlers_after["list_skills"]()["skills"] == []
|
|
assert handlers_after["get_skill"](skill_id="k1")["ok"] is False
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Task 5.3: resolve via MCP, then drive through device-capability tools
|
|
# ----------------------------------------------------------------------
|
|
|
|
|
|
def test_e2e_resolved_flow_template_drives_existing_device_tools_one_step_at_a_time(
|
|
store, fake_client, runner
|
|
):
|
|
"""Resolve a flow-template skill via MCP, then execute each resulting
|
|
step through the existing device-capability tools (Executor + FakeDriver).
|
|
|
|
Confirms the Observe-Think-Act loop is preserved: no batched
|
|
server-side execution primitive is used. Each step is issued
|
|
individually, exactly as the LLM would.
|
|
"""
|
|
# 1. Seed a flow template that uses tap + input_text (real device tools)
|
|
fake_client.set_skills(
|
|
"sub-a",
|
|
[
|
|
_flow(
|
|
"login-flow",
|
|
"Login Flow",
|
|
steps=[
|
|
("tap", {"x": 50, "y": 100}),
|
|
("input_text", {"text": "user@example.com"}),
|
|
("tap", {"x": 50, "y": 200}),
|
|
],
|
|
parameters={},
|
|
)
|
|
],
|
|
)
|
|
runner.tick()
|
|
|
|
# 2. Set up the existing device-capability tool surface (apex-agent-mvp)
|
|
fake_driver = FakeDriver()
|
|
manager = DeviceManager()
|
|
manager.register_device("device-1", lambda: fake_driver)
|
|
manager.connect("device-1", max_retries=1)
|
|
device_handlers = tool_handlers(manager=manager)
|
|
registered_tool_names = set(device_handlers.keys())
|
|
|
|
# 3. Resolve the flow template via the MCP handler
|
|
skill_handlers = _mcp_handlers(store, registered_tools=registered_tool_names)
|
|
resolved = skill_handlers["resolve_flow_template"](
|
|
skill_id="login-flow", params={}
|
|
)
|
|
assert resolved["ok"] is True
|
|
steps = resolved["steps"]
|
|
assert len(steps) == 3
|
|
|
|
# 4. Build an Executor with the device-capability tools — the same
|
|
# Executor the Agent Runtime uses for the Act phase. Bind device_id
|
|
# via closures so each step executes against the connected device.
|
|
# Each step is issued individually through executor.execute(step),
|
|
# exactly as the LLM-driven Observe-Think-Act loop does.
|
|
device_id = "device-1"
|
|
executor = Executor(
|
|
tools={
|
|
"tap": lambda **kw: device_handlers["tap"](device_id=device_id, **kw),
|
|
"input_text": lambda **kw: device_handlers["input_text"](
|
|
device_id=device_id, **kw
|
|
),
|
|
},
|
|
config=ExecutorConfig(max_retries=1, backoff_seconds=0),
|
|
)
|
|
|
|
results = []
|
|
for index, step in enumerate(steps, start=1):
|
|
planned = PlannedStep(
|
|
action=step["tool_name"],
|
|
description=f"login-flow step {index}",
|
|
args=step["args"],
|
|
)
|
|
result = executor.execute(planned)
|
|
results.append(result)
|
|
|
|
# 5. Every step must succeed through the normal Act loop
|
|
assert all(r.success for r in results), (
|
|
f"step failed: {[r.error for r in results if not r.success]}"
|
|
)
|
|
assert len(results) == 3
|
|
|
|
# 6. The FakeDriver must have received the calls in order — proving the
|
|
# resolved template actually reached the device via the existing
|
|
# capability surface (not via a new batch primitive).
|
|
tool_calls = [c for c in fake_driver.calls if c[0] in ("tap", "input")]
|
|
# 3 steps → exactly 3 device-level calls (tap, input, tap)
|
|
assert len(tool_calls) == 3
|
|
tap_calls = [c for c in tool_calls if c[0] == "tap"]
|
|
assert tap_calls[0][1] == (50, 100)
|
|
assert tap_calls[1][1] == (50, 200)
|
|
input_calls = [c for c in tool_calls if c[0] == "input"]
|
|
assert input_calls[0][1] == ("user@example.com",)
|
|
|
|
|
|
def test_e2e_resolved_flow_template_uses_parameter_substitution(
|
|
store, fake_client, runner
|
|
):
|
|
"""Resolution substitutes {param} placeholders so the LLM-driven Act loop
|
|
receives concrete argument values, not templates."""
|
|
fake_client.set_skills(
|
|
"sub-a",
|
|
[
|
|
_flow(
|
|
"param-flow",
|
|
"Parameterized",
|
|
steps=[
|
|
("tap", {"x": "{coord_x}", "y": "{coord_y}"}),
|
|
("input_text", {"text": "fixed-text"}),
|
|
],
|
|
parameters={
|
|
"coord_x": {"type": "number", "required": True},
|
|
"coord_y": {"type": "number", "required": True},
|
|
},
|
|
)
|
|
],
|
|
)
|
|
runner.tick()
|
|
handlers = _mcp_handlers(store, registered_tools={"tap", "input_text"})
|
|
resolved = handlers["resolve_flow_template"](
|
|
skill_id="param-flow",
|
|
params={"coord_x": 42, "coord_y": 99},
|
|
)
|
|
assert resolved["ok"] is True
|
|
steps = resolved["steps"]
|
|
# Placeholder substituted with provided param values
|
|
assert steps[0]["args"] == {"x": 42, "y": 99}
|
|
# Non-parameterized args pass through unchanged
|
|
assert steps[1]["args"] == {"text": "fixed-text"}
|