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

439 lines
13 KiB
Python

"""scheduler 在 step 开始/成功时分别发出 meta_step_state(running/succeeded)。"""
import asyncio
import pytest
from opensquilla.engine.types import (
MetaPreflightEvent,
MetaRunAnnouncedEvent,
MetaRunCompletedEvent,
MetaStepStateEvent,
ToolResultEvent,
)
from opensquilla.skills.meta.events import _StepDone
from opensquilla.skills.meta.scheduler import _failure_hint, _failure_rescue_payload
from opensquilla.skills.meta.types import (
ClarifyField,
ClarifyStepConfig,
MetaMatch,
MetaPaused,
MetaPlan,
MetaPreflightRequired,
MetaResult,
MetaStep,
)
@pytest.fixture
def make_two_step_match():
plan = MetaPlan(
name="meta-fake",
triggers=("fake",),
priority=0,
steps=(
MetaStep(id="intake", skill="intake", kind="llm_chat", label="意图提取"),
MetaStep(
id="summary", skill="summary", kind="llm_chat",
label="总结", depends_on=("intake",),
),
),
final_text_mode="raw",
)
return MetaMatch(plan=plan, inputs={"user_message": "hi"})
@pytest.fixture
def fake_dispatch_stream():
async def _dispatch(step, effective_skill, inputs, outputs):
yield _StepDone(text=f"out:{step.id}")
return _dispatch
@pytest.fixture
def fake_preface():
async def _preface(step_id, effective_skill):
return
yield # never reached; keeps it an async generator
return _preface
async def _collect_all_events(match, dispatch, preface):
from opensquilla.skills.meta.scheduler import run_dag
events = []
async for ev in run_dag(
match,
dispatch_step_stream=dispatch,
yield_skill_view_preface=preface,
):
events.append(ev)
return events
def test_running_emitted_at_step_start(
make_two_step_match, fake_dispatch_stream, fake_preface,
):
events = asyncio.run(_collect_all_events(
make_two_step_match, fake_dispatch_stream, fake_preface,
))
step_states = [
(ev.step_id, ev.state)
for ev in events
if isinstance(ev, MetaStepStateEvent)
]
assert ("intake", "running") in step_states
assert ("intake", "succeeded") in step_states
def test_required_preflight_pauses_before_announcing_run(fake_dispatch_stream, fake_preface):
plan = MetaPlan(
name="meta-preflight-required",
triggers=("fake",),
priority=0,
steps=(MetaStep(id="draft", skill="draft", kind="llm_chat", label="Draft"),),
request_template={
"mode": "confirm",
"fields": [
{"name": "audience", "required": True},
{"name": "deadline", "required": False},
],
"assumptions": ["Use default depth unless the user says otherwise."],
},
final_text_mode="raw",
)
events = asyncio.run(_collect_all_events(
MetaMatch(plan=plan, inputs={"user_message": "write a brief"}),
fake_dispatch_stream,
fake_preface,
))
preflight = next(e for e in events if isinstance(e, MetaPreflightEvent))
result = next(e for e in events if isinstance(e, MetaResult))
assert preflight.requires_confirmation is True
assert preflight.can_skip is False
assert preflight.missing_fields == ["audience"]
assert not any(isinstance(e, MetaRunAnnouncedEvent) for e in events)
assert not any(isinstance(e, MetaStepStateEvent) for e in events)
assert result.paused is True
assert isinstance(result.paused_payload, MetaPreflightRequired)
assert result.paused_payload.missing_fields == ["audience"]
def test_preview_preflight_event_does_not_pause_without_explicit_gate(
fake_dispatch_stream,
fake_preface,
):
plan = MetaPlan(
name="meta-preflight-preview",
triggers=("fake",),
priority=0,
steps=(MetaStep(id="draft", skill="draft", kind="llm_chat", label="Draft"),),
request_template={
"fields": [{"name": "audience", "required": True}],
"assumptions": ["Use default depth unless the user says otherwise."],
},
final_text_mode="raw",
)
events = asyncio.run(_collect_all_events(
MetaMatch(plan=plan, inputs={"user_message": "write a brief"}),
fake_dispatch_stream,
fake_preface,
))
preflight = next(e for e in events if isinstance(e, MetaPreflightEvent))
result = next(e for e in events if isinstance(e, MetaResult))
assert preflight.requires_confirmation is False
assert preflight.can_skip is True
assert preflight.missing_fields == ["audience"]
assert any(isinstance(e, MetaRunAnnouncedEvent) for e in events)
assert any(isinstance(e, MetaStepStateEvent) for e in events)
assert result.paused is False
assert result.ok is True
def test_confirmed_preflight_still_pauses_when_required_fields_missing(
fake_dispatch_stream,
fake_preface,
):
plan = MetaPlan(
name="meta-preflight-required",
triggers=("fake",),
priority=0,
steps=(MetaStep(id="draft", skill="draft", kind="llm_chat", label="Draft"),),
request_template={
"mode": "confirm",
"fields": [{"name": "audience", "required": True}],
"assumptions": ["Use default depth unless the user says otherwise."],
},
final_text_mode="raw",
)
events = asyncio.run(_collect_all_events(
MetaMatch(
plan=plan,
inputs={
"user_message": "write a brief",
"meta_preflight_confirmed": True,
},
),
fake_dispatch_stream,
fake_preface,
))
result = next(e for e in events if isinstance(e, MetaResult))
assert not any(isinstance(e, MetaRunAnnouncedEvent) for e in events)
assert result.paused is True
assert isinstance(result.paused_payload, MetaPreflightRequired)
assert result.paused_payload.missing_fields == ["audience"]
def test_running_precedes_succeeded(
make_two_step_match, fake_dispatch_stream, fake_preface,
):
events = asyncio.run(_collect_all_events(
make_two_step_match, fake_dispatch_stream, fake_preface,
))
seq = [
ev.state
for ev in events
if isinstance(ev, MetaStepStateEvent) and ev.step_id == "intake"
]
assert seq.index("running") < seq.index("succeeded")
@pytest.fixture
def make_skipped_match():
plan = MetaPlan(
name="meta-skip-fake",
triggers=("fake",),
priority=0,
steps=(
MetaStep(id="intake", skill="intake", kind="llm_chat", label="意图提取"),
MetaStep(
id="optional", skill="optional", kind="llm_chat",
label="可选", depends_on=("intake",), when="False",
),
),
final_text_mode="raw",
)
return MetaMatch(plan=plan, inputs={"user_message": "hi"})
def test_skipped_emitted_on_when_false(
make_skipped_match, fake_dispatch_stream, fake_preface,
):
events = asyncio.run(_collect_all_events(
make_skipped_match, fake_dispatch_stream, fake_preface,
))
states = [
(ev.step_id, ev.state)
for ev in events
if isinstance(ev, MetaStepStateEvent)
]
assert ("optional", "skipped") in states
@pytest.fixture
def failing_dispatch():
async def _dispatch(step, effective_skill, inputs, outputs):
if step.id == "search":
raise RuntimeError("simulated step failure")
yield _StepDone(text=f"out:{step.id}")
return _dispatch
@pytest.fixture
def make_failover_match():
plan = MetaPlan(
name="meta-fail-fake",
triggers=("fake",),
priority=0,
steps=(
MetaStep(
id="search", skill="search", kind="agent", label="检索",
on_failure="search_fallback",
),
MetaStep(
id="search_fallback", skill="search_fallback",
kind="llm_chat", label="替代检索",
),
),
final_text_mode="raw",
)
return MetaMatch(plan=plan, inputs={"user_message": "hi"})
def test_failed_then_substituted(
make_failover_match, failing_dispatch, fake_preface,
):
events = asyncio.run(_collect_all_events(
make_failover_match, failing_dispatch, fake_preface,
))
states = [
(ev.step_id, ev.state, ev.substitute_for)
for ev in events
if isinstance(ev, MetaStepStateEvent)
]
assert ("search", "failed", None) in states
assert ("search_fallback", "substituted", "search") in states
def test_successful_substitute_completes_run_ok(
make_failover_match, failing_dispatch, fake_preface,
):
events = asyncio.run(_collect_all_events(
make_failover_match, failing_dispatch, fake_preface,
))
completed = next(
(e for e in events if isinstance(e, MetaRunCompletedEvent)), None,
)
assert completed is not None
assert completed.outcome == "ok"
assert completed.failed_steps == []
assert completed.recovered_steps == ["search"]
def test_hard_failure_tool_result_includes_rescue_hints(fake_preface):
plan = MetaPlan(
name="meta-hard-fail",
triggers=("fake",),
priority=0,
steps=(
MetaStep(id="intake", skill="intake", kind="llm_chat", label="Intake"),
MetaStep(
id="render",
skill="render",
kind="skill_exec",
label="Render PDF",
depends_on=("intake",),
),
),
final_text_mode="raw",
)
async def dispatch(step, effective_skill, inputs, outputs):
if step.id == "render":
raise RuntimeError("wkhtmltopdf not found")
yield _StepDone(text=f"out:{step.id}")
events = asyncio.run(_collect_all_events(
MetaMatch(plan=plan, inputs={"user_message": "hi"}),
dispatch,
fake_preface,
))
failure = next(
(
e for e in events
if isinstance(e, ToolResultEvent) and e.tool_name == "meta-step:render"
),
None,
)
assert failure is not None
rescue = (failure.arguments or {}).get("rescue")
assert rescue["failed_step_id"] == "render"
assert rescue["partial_output_step_ids"] == ["intake"]
action_ids = [item["id"] for item in rescue["actions"]]
assert "retry-run" in action_ids
assert "retry-with-partial-context" in action_ids
assert rescue["hint"]["category"] == "missing_dependency"
failed_state = next(
(
e for e in events
if isinstance(e, MetaStepStateEvent)
and e.step_id == "render"
and e.state == "failed"
),
None,
)
assert failed_state is not None
assert failed_state.rescue["hint"]["category"] == "missing_dependency"
@pytest.mark.parametrize(
("error", "category"),
[
("401 unauthorized: token expired", "auth_or_permission"),
("request timed out after 30s", "timeout"),
("unexpected parser crash", "runtime_error"),
],
)
def test_failure_hint_classifies_recovery_category(error: str, category: str) -> None:
assert _failure_hint(error)["category"] == category
def test_failure_rescue_payload_includes_actionable_recovery_context() -> None:
payload = _failure_rescue_payload(
plan_name="meta-fake",
step=MetaStep(id="render_artifact", skill="render", kind="skill_exec"),
error="ffmpeg command not found",
outputs={"draft": "x" * 900},
has_substitute=False,
)
action_ids = [action["id"] for action in payload["actions"]]
assert "retry-step" in action_ids
assert "install-dependency" in action_ids
assert "continue-text-only" in action_ids
assert payload["prior_outputs"][0]["step_id"] == "draft"
assert len(payload["prior_outputs"][0]["excerpt"]) < 700
assert payload["prior_outputs"][0]["truncated"] is True
def test_run_completed_emitted_at_end(
make_two_step_match, fake_dispatch_stream, fake_preface,
):
events = asyncio.run(_collect_all_events(
make_two_step_match, fake_dispatch_stream, fake_preface,
))
completed = next(
(e for e in events if isinstance(e, MetaRunCompletedEvent)), None,
)
assert completed is not None
assert completed.outcome == "ok"
assert sorted(completed.completed_steps) == ["intake", "summary"]
def test_paused_step_completes_run_cancelled(fake_preface):
cfg = ClarifyStepConfig(
mode="form",
fields=(ClarifyField(name="topic", type="string", required=True),),
)
plan = MetaPlan(
name="meta-pause-fake",
triggers=("fake",),
priority=0,
steps=(
MetaStep(id="collect", skill="collect", kind="user_input", label="澄清"),
),
final_text_mode="raw",
)
async def paused_dispatch(step, effective_skill, inputs, outputs):
raise MetaPaused(run_id="r-paused", step_id=step.id, schema=cfg)
yield # never reached; keeps it an async generator
events = asyncio.run(_collect_all_events(
MetaMatch(plan=plan, inputs={"user_message": "hi"}),
paused_dispatch,
fake_preface,
))
completed = next(
(e for e in events if isinstance(e, MetaRunCompletedEvent)), None,
)
assert completed is not None
assert completed.outcome == "cancelled"