"""End-to-end programmatic resume tests (PR3, design §8.3).""" from __future__ import annotations import json import sqlite3 from pathlib import Path import pytest from yoyo import get_backend, read_migrations from opensquilla.persistence.meta_run_writer import MetaRunWriter from opensquilla.skills.meta.events import _StepDone from opensquilla.skills.meta.orchestrator import MetaOrchestrator from opensquilla.skills.meta.plan_serde import to_jsonable from opensquilla.skills.meta.types import ( ClarifyField, ClarifyStepConfig, MetaMatch, MetaPlan, MetaResult, MetaStep, ) @pytest.fixture def writer(tmp_path: Path) -> MetaRunWriter: db = tmp_path / "test.sqlite" backend = get_backend(f"sqlite:///{db}") backend.apply_migrations(read_migrations("migrations")) conn = sqlite3.connect(db, check_same_thread=False) conn.row_factory = sqlite3.Row return MetaRunWriter(conn) def _plan_with_collect_then_summary() -> MetaPlan: cfg = ClarifyStepConfig( mode="form", fields=( ClarifyField(name="destination", type="string", required=True), ClarifyField(name="days", type="int", required=True, min=1, max=14), ClarifyField( name="language", type="enum", required=False, choices=("en", "zh", "mixed"), ), ClarifyField( name="additional_notes", type="string", required=False, max_chars=2000, ), ), intro="Trip info needed.", ) return MetaPlan( name="trip", triggers=("plan a trip",), priority=0, steps=( MetaStep( id="collect", skill="collect", kind="user_input", clarify_config=cfg, ), MetaStep( id="summary", skill="summarize", kind="agent", depends_on=("collect",), with_args={ "context": ( "destination={{ inputs.collected.collect.destination }} " "days={{ inputs.collected.collect.days }}" ), }, ), ), ) def _seed_running_run(writer, plan, run_id="r1", session_key="S1"): inputs = {"user_message": "I want to plan a trip", "collected": {}} snapshot_json = json.dumps(to_jsonable(plan), sort_keys=True, ensure_ascii=False) with writer._lock: writer._conn.execute( "INSERT INTO meta_skill_runs " "(run_id, meta_skill_name, meta_skill_digest, plan_snapshot_json, " " triggered_by, session_key, status, started_at_ms, inputs_json) " "VALUES (?,?,?,?,?,?,?,?,?)", (run_id, plan.name, "d", snapshot_json, "soft_meta_invoke", session_key, "running", 0, json.dumps(inputs)), ) writer._conn.commit() return inputs async def _sv(*_a): return yield # type: ignore[unreachable] @pytest.mark.asyncio async def test_resume_updates_language_instruction_from_clarify_language(writer): plan = _plan_with_collect_then_summary() inputs = _seed_running_run(writer, plan) inputs["language_instruction"] = ( "Output language rule: write final user-facing prose, headings, " "labels, and summaries in English only unless the user explicitly " "asks for another language." ) dispatched_context: dict[str, dict] = {} orch = MetaOrchestrator( agent_runner=None, # type: ignore[arg-type] skill_loader=None, dao=writer, ) async def _dispatch(step, effective_skill, match_inputs, outputs): if step.kind == "user_input": async for ev in orch._dispatch_one_step( step, effective_skill, match_inputs, outputs, run_id="r1", session_id="S1", ): yield ev return if step.id == "summary": dispatched_context["summary"] = { "user_language": match_inputs.get("user_language"), "language_instruction": match_inputs.get("language_instruction"), "inputs_collected": match_inputs.get("collected"), } yield _StepDone(text="summary-done", status="ok") paused = await orch.run_once( MetaMatch(plan=plan, inputs=inputs), run_id="r1", session_id="S1", dispatch_step_stream=_dispatch, yield_skill_view_preface=_sv, ) assert paused.paused is True await orch.resume( run_id="r1", session_id="S1", filled_fields={"destination": "纸盘动物", "days": 14, "language": "mixed"}, dispatch_step_stream=_dispatch, yield_skill_view_preface=_sv, ) summary = dispatched_context["summary"] assert summary["inputs_collected"]["collect"]["language"] == "mixed" assert summary["user_language"] == "zh" assert "English only" not in summary["language_instruction"] assert "Simplified Chinese" in summary["language_instruction"] @pytest.mark.asyncio async def test_resume_prefers_chinese_when_any_clarify_field_contains_chinese(writer): plan = _plan_with_collect_then_summary() inputs = _seed_running_run(writer, plan) inputs["language_instruction"] = ( "Output language rule: write final user-facing prose, headings, " "labels, and summaries in English only unless the user explicitly " "asks for another language." ) dispatched_context: dict[str, dict] = {} orch = MetaOrchestrator( agent_runner=None, # type: ignore[arg-type] skill_loader=None, dao=writer, ) async def _dispatch(step, effective_skill, match_inputs, outputs): if step.kind == "user_input": async for ev in orch._dispatch_one_step( step, effective_skill, match_inputs, outputs, run_id="r1", session_id="S1", ): yield ev return if step.id == "summary": dispatched_context["summary"] = { "user_language": match_inputs.get("user_language"), "language_instruction": match_inputs.get("language_instruction"), } yield _StepDone(text="summary-done", status="ok") paused = await orch.run_once( MetaMatch(plan=plan, inputs=inputs), run_id="r1", session_id="S1", dispatch_step_stream=_dispatch, yield_skill_view_preface=_sv, ) assert paused.paused is True await orch.resume( run_id="r1", session_id="S1", filled_fields={"destination": "纸盘动物", "days": 14, "language": "en"}, dispatch_step_stream=_dispatch, yield_skill_view_preface=_sv, ) summary = dispatched_context["summary"] assert summary["user_language"] == "zh" assert "English only" not in summary["language_instruction"] assert "Simplified Chinese" in summary["language_instruction"] @pytest.mark.asyncio async def test_empty_form_resume_lets_llm_infer_all_clarify_fields(writer): plan = _plan_with_collect_then_summary() inputs = _seed_running_run(writer, plan) inputs["user_message"] = "帮我给孩子安排一个北京三日游。" async def fake_chat(_system: str, user: str) -> str: payload = json.loads(user) target_names = { field["name"] for field in payload["fields_to_infer"] } assert target_names == { "destination", "days", "language", "additional_notes", } return json.dumps( { "destination": "北京", "days": 3, "language": "zh", "additional_notes": "按亲子节奏安排,不要太赶。", }, ensure_ascii=False, ) dispatched_context: dict[str, dict] = {} orch = MetaOrchestrator( agent_runner=None, # type: ignore[arg-type] skill_loader=None, dao=writer, llm_chat=fake_chat, ) async def _dispatch(step, effective_skill, match_inputs, outputs): if step.kind == "user_input": async for ev in orch._dispatch_one_step( step, effective_skill, match_inputs, outputs, run_id="r1", session_id="S1", ): yield ev return if step.id == "summary": dispatched_context["summary"] = { "inputs_user_message": match_inputs.get("user_message"), "inputs_collected": match_inputs.get("collected"), } yield _StepDone(text="summary-done", status="ok") paused = await orch.run_once( MetaMatch(plan=plan, inputs=inputs), run_id="r1", session_id="S1", dispatch_step_stream=_dispatch, yield_skill_view_preface=_sv, ) assert paused.paused is True await orch.resume( run_id="r1", session_id="S1", filled_fields={}, dispatch_step_stream=_dispatch, yield_skill_view_preface=_sv, ) collected = dispatched_context["summary"]["inputs_collected"]["collect"] assert collected == { "destination": "北京", "days": 3, "language": "zh", "additional_notes": "按亲子节奏安排,不要太赶。", } assert "按亲子节奏安排" in dispatched_context["summary"]["inputs_user_message"] @pytest.mark.asyncio async def test_pause_then_resume_completes_dag(writer): plan = _plan_with_collect_then_summary() inputs = _seed_running_run(writer, plan) dispatched_context: dict[str, dict] = {} orch = MetaOrchestrator( agent_runner=None, # type: ignore[arg-type] skill_loader=None, dao=writer, ) async def _dispatch(step, effective_skill, match_inputs, outputs): if step.kind == "user_input": async for ev in orch._dispatch_one_step( step, effective_skill, match_inputs, outputs, run_id="r1", session_id="S1", ): yield ev return if step.id == "summary": dispatched_context["summary"] = { "context_arg": step.with_args.get("context", ""), "inputs_user_message": match_inputs.get("user_message"), "inputs_collected": match_inputs.get("collected"), } yield _StepDone(text="summary-done", status="ok") match = MetaMatch(plan=plan, inputs=inputs) result = await orch.run_once( match, run_id="r1", session_id="S1", dispatch_step_stream=_dispatch, yield_skill_view_preface=_sv, ) assert isinstance(result, MetaResult) assert result.paused is True assert result.paused_payload is not None assert result.paused_payload.step_id == "collect" result2 = await orch.resume( run_id="r1", session_id="S1", filled_fields={"destination": "Tokyo", "days": 5}, dispatch_step_stream=_dispatch, yield_skill_view_preface=_sv, ) assert result2.paused is False assert result2.ok is True sm = dispatched_context["summary"] assert sm["inputs_user_message"] == "I want to plan a trip" assert sm["inputs_collected"]["collect"]["destination"] == "Tokyo" assert sm["inputs_collected"]["collect"]["days"] == 5 @pytest.mark.asyncio async def test_resume_injects_additional_notes_into_downstream_user_message(writer): plan = _plan_with_collect_then_summary() inputs = _seed_running_run(writer, plan) dispatched_context: dict[str, dict] = {} orch = MetaOrchestrator( agent_runner=None, # type: ignore[arg-type] skill_loader=None, dao=writer, ) async def _dispatch(step, effective_skill, match_inputs, outputs): if step.kind == "user_input": async for ev in orch._dispatch_one_step( step, effective_skill, match_inputs, outputs, run_id="r1", session_id="S1", ): yield ev return if step.id == "summary": dispatched_context["summary"] = { "inputs_user_message": match_inputs.get("user_message"), "inputs_collected": match_inputs.get("collected"), } yield _StepDone(text="summary-done", status="ok") paused = await orch.run_once( MetaMatch(plan=plan, inputs=inputs), run_id="r1", session_id="S1", dispatch_step_stream=_dispatch, yield_skill_view_preface=_sv, ) assert paused.paused is True await orch.resume( run_id="r1", session_id="S1", filled_fields={ "destination": "Tokyo", "days": 5, "additional_notes": "Please avoid museums; kid wants trains.", }, dispatch_step_stream=_dispatch, yield_skill_view_preface=_sv, ) sm = dispatched_context["summary"] assert sm["inputs_collected"]["collect"]["additional_notes"] == ( "Please avoid museums; kid wants trains." ) assert sm["inputs_user_message"].startswith("I want to plan a trip") assert "Additional user notes" in sm["inputs_user_message"] assert "Please avoid museums; kid wants trains." in sm["inputs_user_message"] @pytest.mark.asyncio async def test_resume_finalizes_run_to_ok_status(writer): plan = _plan_with_collect_then_summary() inputs = _seed_running_run(writer, plan) orch = MetaOrchestrator( agent_runner=None, skill_loader=None, dao=writer, # type: ignore[arg-type] ) async def _dispatch(step, effective_skill, match_inputs, outputs): if step.kind == "user_input": async for ev in orch._dispatch_one_step( step, effective_skill, match_inputs, outputs, run_id="r1", session_id="S1", ): yield ev return if step.id == "summary": yield _StepDone(text="summary-done", status="ok") await orch.run_once( MetaMatch(plan=plan, inputs=inputs), run_id="r1", session_id="S1", dispatch_step_stream=_dispatch, yield_skill_view_preface=_sv, ) final = await orch.resume( run_id="r1", session_id="S1", filled_fields={"destination": "Tokyo", "days": 5}, dispatch_step_stream=_dispatch, yield_skill_view_preface=_sv, ) assert final.ok is True with writer._lock: row = writer._conn.execute( "SELECT status, ended_at_ms FROM meta_skill_runs WHERE run_id='r1'", ).fetchone() assert row["status"] == "ok" assert row["ended_at_ms"] is not None and row["ended_at_ms"] > 0 @pytest.mark.asyncio async def test_resume_rejects_unknown_run(writer): orch = MetaOrchestrator( agent_runner=None, skill_loader=None, dao=writer, # type: ignore[arg-type] ) result = await orch.resume( run_id="does-not-exist", session_id="S1", filled_fields={}, dispatch_step_stream=None, yield_skill_view_preface=None, ) assert result.ok is False assert result.paused is False assert result.error is not None assert "not found" in result.error.lower() or "race" in result.error.lower() @pytest.mark.asyncio async def test_resume_writes_clarify_summary_into_outputs(writer): plan = _plan_with_collect_then_summary() inputs = _seed_running_run(writer, plan) observed_outputs: dict[str, dict] = {} orch = MetaOrchestrator( agent_runner=None, skill_loader=None, dao=writer, # type: ignore[arg-type] ) async def _dispatch(step, effective_skill, match_inputs, outputs): if step.kind == "user_input": async for ev in orch._dispatch_one_step( step, effective_skill, match_inputs, outputs, run_id="r1", session_id="S1", ): yield ev return if step.id == "summary": observed_outputs["summary"] = dict(outputs) yield _StepDone(text="ok", status="ok") paused = await orch.run_once( MetaMatch(plan=plan, inputs=inputs), run_id="r1", session_id="S1", dispatch_step_stream=_dispatch, yield_skill_view_preface=_sv, ) assert paused.paused is True, "test fixture expected pause before resume" await orch.resume( run_id="r1", session_id="S1", filled_fields={"destination": "Tokyo", "days": 5}, dispatch_step_stream=_dispatch, yield_skill_view_preface=_sv, ) summary_outputs = observed_outputs["summary"] collect_md = summary_outputs.get("collect", "") assert "destination: Tokyo (from user)" in collect_md assert "days: 5 (from user)" in collect_md @pytest.mark.asyncio async def test_resume_persists_followup_step_lifecycle_and_usage(writer): from opensquilla.engine.usage import UsageTracker from opensquilla.persistence.meta_run_writer import summarize_run_record plan = _plan_with_collect_then_summary() inputs = _seed_running_run(writer, plan) tracker = UsageTracker() writer.begin_step_sync( run_id="r1", step=plan.steps[0], effective_skill="collect", rendered_inputs={}, ) orch = MetaOrchestrator( agent_runner=None, # type: ignore[arg-type] skill_loader=None, dao=writer, usage_tracker=tracker, session_key="S1", ) async def _dispatch(step, effective_skill, match_inputs, outputs): if step.kind == "user_input": async for ev in orch._dispatch_one_step( step, effective_skill, match_inputs, outputs, run_id="r1", session_id="S1", ): yield ev return if step.id == "summary": tracker.add( "S1", input_tokens=21, output_tokens=9, model_id="resume-model", billed_cost=0.021, ) yield _StepDone(text="summary-done", status="ok") paused = await orch.run_once( MetaMatch(plan=plan, inputs=inputs), run_id="r1", session_id="S1", dispatch_step_stream=_dispatch, yield_skill_view_preface=_sv, ) assert paused.paused is True, "test fixture expected pause before resume" final = await orch.resume( run_id="r1", session_id="S1", filled_fields={"destination": "Tokyo", "days": 5}, dispatch_step_stream=_dispatch, yield_skill_view_preface=_sv, ) assert final.ok is True record = writer.get_run("r1") assert record is not None raw_steps = {step.step_id: step for step in record.steps} assert raw_steps["collect"].status == "ok" assert "destination: Tokyo" in (raw_steps["collect"].output_text or "") summary = summarize_run_record(record) by_step = {step["step_id"]: step for step in summary["steps"]} assert by_step["collect"]["status"] == "ok" assert by_step["summary"]["status"] == "ok" assert by_step["summary"]["usage"]["available"] is True assert by_step["summary"]["usage"]["input_tokens"] == 21 assert by_step["summary"]["usage"]["model"] == "resume-model"