459 lines
15 KiB
Python
459 lines
15 KiB
Python
"""meta_resolution sticky-continuation tests.
|
|
|
|
Covers the in-memory session-keyed cache that keeps a meta-skill match
|
|
alive across follow-up turns when the LLM failed to actually emit
|
|
``meta_invoke`` on the originating turn (e.g. length-capped on
|
|
reasoning).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from opensquilla.skills.meta.types import MetaPlan, MetaStep
|
|
|
|
mr = importlib.import_module("opensquilla.engine.steps.meta_resolution")
|
|
meta_resolution = mr.meta_resolution
|
|
|
|
|
|
def _meta_spec(*, name: str, triggers: tuple[str, ...]):
|
|
"""Build a minimal meta skill spec that ``parse_meta_plan`` accepts."""
|
|
plan = MetaPlan(
|
|
name=name,
|
|
triggers=triggers,
|
|
priority=50,
|
|
steps=(MetaStep(id="s1", skill="paper-section-author", kind="agent"),),
|
|
)
|
|
spec = SimpleNamespace(
|
|
name=name,
|
|
kind="meta",
|
|
triggers=list(triggers),
|
|
composition_raw={
|
|
"meta_priority": plan.priority,
|
|
"steps": [{"id": "s1", "skill": "paper-section-author", "kind": "agent"}],
|
|
},
|
|
metadata={"opensquilla": {"meta_priority": plan.priority}},
|
|
body="",
|
|
)
|
|
return spec
|
|
|
|
|
|
def _ctx(
|
|
*,
|
|
message: str,
|
|
session_id: str,
|
|
skills: list,
|
|
metadata: dict | None = None,
|
|
model: str = "",
|
|
tiers: dict | None = None,
|
|
):
|
|
loader = MagicMock()
|
|
loader.load_all.return_value = skills
|
|
meta = {"skill_loader": loader}
|
|
if metadata:
|
|
meta.update(metadata)
|
|
return SimpleNamespace(
|
|
message=message,
|
|
session_key=session_id,
|
|
metadata=meta,
|
|
model=model,
|
|
system_prompt="",
|
|
config=SimpleNamespace(
|
|
squilla_router=SimpleNamespace(tiers=tiers or {}),
|
|
meta_skill=SimpleNamespace(enabled=True, auto_trigger=True),
|
|
),
|
|
surface_kind="cli",
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_sticky_cache():
|
|
"""Ensure each test sees a fresh sticky cache."""
|
|
with mr._sticky_lock:
|
|
mr._meta_sticky_cache.clear()
|
|
yield
|
|
with mr._sticky_lock:
|
|
mr._meta_sticky_cache.clear()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fresh_match_arms_sticky_cache():
|
|
skills = [_meta_spec(name="meta-paper-write", triggers=("帮我写篇论文",))]
|
|
ctx = _ctx(message="帮我写篇论文", session_id="S-A", skills=skills)
|
|
|
|
out = await meta_resolution(ctx)
|
|
assert "meta_match" in out.metadata
|
|
assert out.metadata.get("meta_match_sticky") is not True
|
|
|
|
cached = mr._sticky_get("S-A")
|
|
assert cached is not None
|
|
assert cached["skill"] == "meta-paper-write"
|
|
assert cached["trigger"] == "帮我写篇论文"
|
|
assert cached["uses"] == mr._STICKY_MAX_USES
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_meta_match_upgrades_low_router_tier_to_c2_entry_model():
|
|
skills = [_meta_spec(name="meta-short-drama", triggers=("生成一个短剧",))]
|
|
tiers = {
|
|
"c0": {"model": "deepseek-v4-flash"},
|
|
"c1": {"model": "deepseek-v4-flash"},
|
|
"c2": {"model": "deepseek-v4-pro"},
|
|
"c3": {"model": "highest-tier-model"},
|
|
}
|
|
ctx = _ctx(
|
|
message="生成一个短剧,啥都行",
|
|
session_id="S-META-UPGRADE",
|
|
skills=skills,
|
|
model="deepseek-v4-flash",
|
|
tiers=tiers,
|
|
metadata={
|
|
"routed_tier": "c0",
|
|
"routed_model": "deepseek-v4-flash",
|
|
"routing_source": "v4_phase3",
|
|
"routing_applied": True,
|
|
},
|
|
)
|
|
|
|
out = await meta_resolution(ctx)
|
|
|
|
assert out.model == "deepseek-v4-pro"
|
|
assert out.metadata["routed_tier"] == "c2"
|
|
assert out.metadata["routed_model"] == "deepseek-v4-pro"
|
|
assert out.metadata["meta_required_source"] == "meta-skill-entry"
|
|
assert out.metadata["meta_resolution_model_upgrade"] == {
|
|
"from_model": "deepseek-v4-flash",
|
|
"to_model": "deepseek-v4-pro",
|
|
"to_tier": "c2",
|
|
"source": "meta-skill-entry",
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_meta_upgrade_realigns_routed_provider_to_upgraded_tier():
|
|
skills = [_meta_spec(name="meta-short-drama", triggers=("生成一个短剧",))]
|
|
tiers = {
|
|
"c0": {"model": "small-model-1", "provider": "provider-a"},
|
|
"c1": {"model": "mid-model-1", "provider": "provider-a"},
|
|
"c2": {"model": "big-model-1", "provider": "provider-b"},
|
|
"c3": {"model": "max-model-1", "provider": "provider-b"},
|
|
}
|
|
ctx = _ctx(
|
|
message="生成一个短剧,啥都行",
|
|
session_id="S-META-PROVIDER-REALIGN",
|
|
skills=skills,
|
|
model="small-model-1",
|
|
tiers=tiers,
|
|
metadata={
|
|
"routed_tier": "c0",
|
|
"routed_model": "small-model-1",
|
|
"routed_provider": "provider-a",
|
|
"routing_source": "v4_phase3",
|
|
"routing_applied": True,
|
|
},
|
|
)
|
|
|
|
out = await meta_resolution(ctx)
|
|
|
|
assert out.metadata["routed_tier"] == "c2"
|
|
assert out.metadata["routed_model"] == "big-model-1"
|
|
assert out.metadata["routed_provider"] == "provider-b"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_meta_upgrade_clears_routed_provider_when_upgraded_tier_declares_none():
|
|
skills = [_meta_spec(name="meta-short-drama", triggers=("生成一个短剧",))]
|
|
tiers = {
|
|
"c0": {"model": "small-model-1", "provider": "provider-a"},
|
|
"c1": {"model": "mid-model-1", "provider": "provider-a"},
|
|
"c2": {"model": "big-model-1"},
|
|
"c3": {"model": "max-model-1"},
|
|
}
|
|
ctx = _ctx(
|
|
message="生成一个短剧,啥都行",
|
|
session_id="S-META-PROVIDER-CLEAR",
|
|
skills=skills,
|
|
model="small-model-1",
|
|
tiers=tiers,
|
|
metadata={
|
|
"routed_tier": "c0",
|
|
"routed_model": "small-model-1",
|
|
"routed_provider": "provider-a",
|
|
"routing_source": "v4_phase3",
|
|
"routing_applied": True,
|
|
},
|
|
)
|
|
|
|
out = await meta_resolution(ctx)
|
|
|
|
assert out.metadata["routed_tier"] == "c2"
|
|
assert "routed_provider" not in out.metadata
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_meta_match_without_router_tier_does_not_force_entry_model():
|
|
skills = [_meta_spec(name="meta-short-drama", triggers=("生成一个短剧",))]
|
|
tiers = {"c2": {"model": "deepseek-v4-pro"}}
|
|
ctx = _ctx(
|
|
message="生成一个短剧,啥都行",
|
|
session_id="S-META-NO-ROUTER",
|
|
skills=skills,
|
|
model="operator-selected-model",
|
|
tiers=tiers,
|
|
)
|
|
|
|
out = await meta_resolution(ctx)
|
|
|
|
assert out.model == "operator-selected-model"
|
|
assert "meta_resolution_model_upgrade" not in out.metadata
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_followup_without_trigger_replays_from_sticky():
|
|
skills = [_meta_spec(name="meta-paper-write", triggers=("帮我写篇论文",))]
|
|
# T1: arm cache.
|
|
await meta_resolution(_ctx(
|
|
message="帮我写篇论文", session_id="S-B", skills=skills,
|
|
))
|
|
|
|
# T2: same session, follow-up text does not contain the trigger.
|
|
out = await meta_resolution(_ctx(
|
|
message="我想写一个关于 RAG 的论文,20 页左右",
|
|
session_id="S-B",
|
|
skills=skills,
|
|
))
|
|
assert "meta_match" in out.metadata
|
|
assert out.metadata.get("meta_match_sticky") is True
|
|
assert out.metadata.get("meta_match").plan.name == "meta-paper-write"
|
|
# uses decremented by 1
|
|
assert mr._sticky_get("S-B")["uses"] == mr._STICKY_MAX_USES - 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sticky_replay_clamps_thinking_to_low():
|
|
skills = [_meta_spec(name="meta-paper-write", triggers=("帮我写篇论文",))]
|
|
ctx_fresh = _ctx(message="帮我写篇论文", session_id="S-C", skills=skills)
|
|
out_fresh = await meta_resolution(ctx_fresh)
|
|
assert out_fresh.metadata.get("thinking_level") == "low"
|
|
assert out_fresh.metadata.get("thinking_source") == "meta_resolution"
|
|
|
|
ctx_followup = _ctx(message="补充细节", session_id="S-C", skills=skills)
|
|
out_followup = await meta_resolution(ctx_followup)
|
|
assert out_followup.metadata.get("meta_match_sticky") is True
|
|
assert out_followup.metadata.get("thinking_level") == "low"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sticky_uses_decrement_to_zero_then_drops():
|
|
skills = [_meta_spec(name="meta-paper-write", triggers=("帮我写篇论文",))]
|
|
# Fresh match arms cache with MAX_USES.
|
|
await meta_resolution(_ctx(
|
|
message="帮我写篇论文", session_id="S-D", skills=skills,
|
|
))
|
|
|
|
for _ in range(mr._STICKY_MAX_USES):
|
|
out = await meta_resolution(_ctx(
|
|
message="follow-up", session_id="S-D", skills=skills,
|
|
))
|
|
assert out.metadata.get("meta_match_sticky") is True
|
|
|
|
# After exhausting uses, the next no-trigger turn no longer matches.
|
|
out_after = await meta_resolution(_ctx(
|
|
message="another follow-up", session_id="S-D", skills=skills,
|
|
))
|
|
assert "meta_match" not in out_after.metadata
|
|
assert mr._sticky_get("S-D") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sticky_cancel_keyword_drops_entry():
|
|
skills = [_meta_spec(name="meta-paper-write", triggers=("帮我写篇论文",))]
|
|
await meta_resolution(_ctx(
|
|
message="帮我写篇论文", session_id="S-E", skills=skills,
|
|
))
|
|
assert mr._sticky_get("S-E") is not None
|
|
|
|
# User cancels — sticky cache is dropped, no replay on this turn.
|
|
out = await meta_resolution(_ctx(
|
|
message="算了,取消吧", session_id="S-E", skills=skills,
|
|
))
|
|
assert "meta_match" not in out.metadata
|
|
assert mr._sticky_get("S-E") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pasted_context_drops_sticky_without_replay():
|
|
skills = [_meta_spec(name="meta-paper-write", triggers=("帮我写篇论文",))]
|
|
await meta_resolution(_ctx(
|
|
message="帮我写篇论文", session_id="S-PASTE", skills=skills,
|
|
))
|
|
assert mr._sticky_get("S-PASTE") is not None
|
|
|
|
dump_body = "\n".join(
|
|
[
|
|
"WebChat dump",
|
|
"assistant: old skill list",
|
|
"meta-paper-write 帮我写篇论文",
|
|
]
|
|
+ [f"history line {i}" for i in range(12)]
|
|
)
|
|
out = await meta_resolution(_ctx(
|
|
message=f"请分析下面历史页面是否误触发。\n{dump_body}",
|
|
session_id="S-PASTE",
|
|
skills=skills,
|
|
))
|
|
|
|
assert "meta_match" not in out.metadata
|
|
assert mr._sticky_get("S-PASTE") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fresh_match_on_followup_refreshes_uses():
|
|
"""If the user re-utters the trigger, uses are re-armed."""
|
|
skills = [_meta_spec(name="meta-paper-write", triggers=("帮我写篇论文",))]
|
|
await meta_resolution(_ctx(
|
|
message="帮我写篇论文", session_id="S-F", skills=skills,
|
|
))
|
|
# Burn one use.
|
|
await meta_resolution(_ctx(
|
|
message="补充", session_id="S-F", skills=skills,
|
|
))
|
|
assert mr._sticky_get("S-F")["uses"] == mr._STICKY_MAX_USES - 1
|
|
|
|
# Re-trigger restores full budget.
|
|
await meta_resolution(_ctx(
|
|
message="帮我写篇论文", session_id="S-F", skills=skills,
|
|
))
|
|
assert mr._sticky_get("S-F")["uses"] == mr._STICKY_MAX_USES
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sessions_are_isolated():
|
|
skills = [_meta_spec(name="meta-paper-write", triggers=("帮我写篇论文",))]
|
|
await meta_resolution(_ctx(
|
|
message="帮我写篇论文", session_id="S-G1", skills=skills,
|
|
))
|
|
# Different session — no sticky for it.
|
|
out = await meta_resolution(_ctx(
|
|
message="hello", session_id="S-G2", skills=skills,
|
|
))
|
|
assert "meta_match" not in out.metadata
|
|
assert mr._sticky_get("S-G1") is not None
|
|
assert mr._sticky_get("S-G2") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_sticky_when_skill_was_removed():
|
|
"""If the meta-skill is no longer loaded, sticky entry is dropped."""
|
|
skills = [_meta_spec(name="meta-paper-write", triggers=("帮我写篇论文",))]
|
|
await meta_resolution(_ctx(
|
|
message="帮我写篇论文", session_id="S-H", skills=skills,
|
|
))
|
|
# Next turn — loader returns nothing.
|
|
out = await meta_resolution(_ctx(
|
|
message="follow-up", session_id="S-H", skills=[],
|
|
))
|
|
assert "meta_match" not in out.metadata
|
|
assert mr._sticky_get("S-H") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skill_marketplace_intent_skips_semantic_meta_fallback(monkeypatch):
|
|
skills = [_meta_spec(name="meta-skill-creator", triggers=("create a meta-skill",))]
|
|
semantic_called = False
|
|
|
|
def fake_semantic_candidate(ctx, candidates):
|
|
nonlocal semantic_called
|
|
semantic_called = True
|
|
priority, name, plan, _spec = candidates[0]
|
|
return (priority, name, plan, "semantic")
|
|
|
|
monkeypatch.setattr(mr, "_semantic_meta_candidate", fake_semantic_candidate)
|
|
|
|
out = await meta_resolution(_ctx(
|
|
message="I want to install skills",
|
|
session_id="S-I",
|
|
skills=skills,
|
|
))
|
|
|
|
assert semantic_called is False
|
|
assert "meta_match" not in out.metadata
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skill_marketplace_intent_clears_sticky_replay():
|
|
skills = [_meta_spec(name="meta-paper-write", triggers=("帮我写篇论文",))]
|
|
await meta_resolution(_ctx(
|
|
message="帮我写篇论文", session_id="S-J", skills=skills,
|
|
))
|
|
assert mr._sticky_get("S-J") is not None
|
|
|
|
out = await meta_resolution(_ctx(
|
|
message="帮我搜索并安装 plot skill",
|
|
session_id="S-J",
|
|
skills=skills,
|
|
))
|
|
|
|
assert "meta_match" not in out.metadata
|
|
assert mr._sticky_get("S-J") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_meta_skill_discussion_clears_sticky_replay():
|
|
skills = [_meta_spec(name="AwesomeWebpageMetaSkill", triggers=("生成图文音视频网页",))]
|
|
await meta_resolution(_ctx(
|
|
message="帮我生成图文音视频网页:主题是海洋塑料污染",
|
|
session_id="S-META-DISCUSS",
|
|
skills=skills,
|
|
))
|
|
assert mr._sticky_get("S-META-DISCUSS") is not None
|
|
|
|
out = await meta_resolution(_ctx(
|
|
message="你最后生成的时候调用了meta skills吗,怎么生成的好差",
|
|
session_id="S-META-DISCUSS",
|
|
skills=skills,
|
|
))
|
|
|
|
assert "meta_match" not in out.metadata
|
|
assert mr._sticky_get("S-META-DISCUSS") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_meta_skill_discussion_skips_semantic_fallback(monkeypatch):
|
|
skills = [_meta_spec(name="AwesomeWebpageMetaSkill", triggers=("生成图文音视频网页",))]
|
|
semantic_called = False
|
|
|
|
def fake_semantic_candidate(ctx, candidates):
|
|
nonlocal semantic_called
|
|
semantic_called = True
|
|
priority, name, plan, _spec = candidates[0]
|
|
return (priority, name, plan, "semantic")
|
|
|
|
monkeypatch.setattr(mr, "_semantic_meta_candidate", fake_semantic_candidate)
|
|
|
|
out = await meta_resolution(_ctx(
|
|
message="你最后生成的时候调用了meta skills吗,怎么生成的好差",
|
|
session_id="S-META-SEMANTIC",
|
|
skills=skills,
|
|
))
|
|
|
|
assert semantic_called is False
|
|
assert "meta_match" not in out.metadata
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skill_marketplace_guard_does_not_block_explicit_meta_trigger():
|
|
skills = [_meta_spec(name="meta-skill-creator", triggers=("create a meta-skill",))]
|
|
|
|
out = await meta_resolution(_ctx(
|
|
message="create a meta-skill that orchestrates skill search",
|
|
session_id="S-K",
|
|
skills=skills,
|
|
))
|
|
|
|
assert out.metadata.get("meta_match").plan.name == "meta-skill-creator"
|