from __future__ import annotations from core.models import Bounds, Scene, SceneElement, Task from runtime.executor import Executor, ExecutorConfig from runtime.planner import Planner from runtime.task import TaskRunner, TaskRunnerConfig from skills_learning.models import FlowStep, FlowTemplateSkill, SkillMetadata from skills_learning.store import SkillStore from tests.fakes import PNG_10X20 from workflow.models import ( BranchStep, ConditionSpec, PlannedGoalStep, SkillInvocationStep, WaitForConditionStep, WorkflowDefinition, ) from workflow.runner import WorkflowRunner from workflow.store import WorkflowStore class FakeTaskRunner: def __init__( self, calls: list[str], *, status: str = "completed", failure_reason: str | None = None, ) -> None: self.calls = calls self.status = status self.failure_reason = failure_reason def run(self, task: Task) -> Task: self.calls.append(task.goal) task.status = self.status # type: ignore[assignment] task.failure_reason = self.failure_reason return task def _scene(text: str = "Ready") -> Scene: return Scene( width=10, height=20, elements=[ SceneElement( id="label", type="text", text=text, bounds=Bounds(1, 2, 3, 4), ) ], ) def _skill_store() -> tuple[SkillStore, FlowTemplateSkill]: store = SkillStore() skill = store.create_version( FlowTemplateSkill( metadata=SkillMetadata( name="send message", description="Send message", kind="flow_template", ), steps=[FlowStep("input_text", {"text": "{message}"})], parameters={"message": {"type": "string"}}, ) ) return store, skill def _store(tmp_path) -> WorkflowStore: return WorkflowStore(tmp_path / "workflows.sqlite3") def test_workflow_runner_linear_planned_goal_completes(tmp_path) -> None: calls: list[str] = [] definition = WorkflowDefinition( name="linear", entry_step_id="first", steps=[ PlannedGoalStep("first", "open app", next_step_id="second"), PlannedGoalStep("second", "send message"), ], ) runner = WorkflowRunner( _store(tmp_path), task_runner_factory=lambda: FakeTaskRunner(calls), ) run = runner.run(definition, "phone") assert run.status == "completed" assert calls == ["open app", "send message"] assert [result.step_id for result in run.step_results] == ["first", "second"] def test_workflow_runner_failing_planned_goal_marks_run_failed(tmp_path) -> None: definition = WorkflowDefinition( name="fail", entry_step_id="first", steps=[PlannedGoalStep("first", "open app")], ) runner = WorkflowRunner( _store(tmp_path), task_runner_factory=lambda: FakeTaskRunner( [], status="failed", failure_reason="device offline", ), ) run = runner.run(definition, "phone") assert run.status == "failed" assert run.step_results[0].detail["failure_reason"] == "device offline" def test_workflow_runner_skill_invocation_executes_resolved_tool_calls(tmp_path) -> None: skill_store, skill = _skill_store() calls: list[dict[str, object]] = [] definition = WorkflowDefinition( name="skill", entry_step_id="skill", steps=[SkillInvocationStep("skill", skill.id, {"message": "hello"})], ) runner = WorkflowRunner( _store(tmp_path), skill_store=skill_store, tools={"input_text": lambda **kwargs: calls.append(kwargs) or {"ok": True}}, ) run = runner.run(definition, "phone") assert run.status == "completed" assert calls == [{"text": "hello"}] def test_workflow_runner_wait_step_succeeds_and_times_out(tmp_path) -> None: success_definition = WorkflowDefinition( name="wait success", entry_step_id="wait", steps=[ WaitForConditionStep( "wait", ConditionSpec("scene_contains_text", {"text": "ready"}), timeout_seconds=0.1, poll_interval_seconds=0, ) ], ) success_runner = WorkflowRunner( _store(tmp_path / "success"), scene_provider=lambda: _scene("Ready"), sleep_func=lambda seconds: None, ) success_run = success_runner.run(success_definition, "phone") assert success_run.status == "completed" timeout_definition = WorkflowDefinition( name="wait timeout", entry_step_id="wait", steps=[ WaitForConditionStep( "wait", ConditionSpec("scene_contains_text", {"text": "missing"}), timeout_seconds=0, poll_interval_seconds=0, ) ], ) timeout_runner = WorkflowRunner( _store(tmp_path / "timeout"), scene_provider=lambda: _scene("Ready"), sleep_func=lambda seconds: None, ) timeout_run = timeout_runner.run(timeout_definition, "phone") assert timeout_run.status == "failed" assert "timed out" in timeout_run.step_results[0].detail["reason"] def test_workflow_runner_branch_paths_and_sequential_advancement(tmp_path) -> None: true_calls: list[str] = [] definition = WorkflowDefinition( name="branch", entry_step_id="branch", steps=[ BranchStep( "branch", ConditionSpec("world_variable_equals", {"name": "ready", "value": True}), on_true="true-step", on_false="false-step", ), PlannedGoalStep("true-step", "true path", next_step_id="end"), PlannedGoalStep("false-step", "false path", next_step_id="end"), WaitForConditionStep( "end", ConditionSpec("elapsed_seconds", {"seconds": 0}), timeout_seconds=0, poll_interval_seconds=0, ), ], ) true_runner = WorkflowRunner( _store(tmp_path / "true"), task_runner_factory=lambda: FakeTaskRunner(true_calls), ) true_run = true_runner.run(definition, "phone", {"ready": True}) assert true_run.status == "completed" assert true_calls == ["true path"] assert [result.step_id for result in true_run.step_results] == [ "branch", "true-step", "end", ] false_calls: list[str] = [] false_runner = WorkflowRunner( _store(tmp_path / "false"), task_runner_factory=lambda: FakeTaskRunner(false_calls), ) false_run = false_runner.run(definition, "phone", {"ready": False}) assert false_run.status == "completed" assert false_calls == ["false path"] def test_workflow_runner_resume_skips_completed_steps_after_restart(tmp_path) -> None: calls: list[str] = [] db_path = tmp_path / "workflows.sqlite3" definition = WorkflowDefinition( name="resume", entry_step_id="one", steps=[ PlannedGoalStep("one", "one"), PlannedGoalStep("two", "two"), PlannedGoalStep("three", "three"), ], ) first_runner = WorkflowRunner( WorkflowStore(db_path), task_runner_factory=lambda: FakeTaskRunner(calls), step_limit=2, ) partial = first_runner.run(definition, "phone") assert partial.status == "running" assert partial.current_step_id == "three" assert calls == ["one", "two"] resumed_runner = WorkflowRunner( WorkflowStore(db_path), task_runner_factory=lambda: FakeTaskRunner(calls), ) resumed = resumed_runner.resume(partial.id) assert resumed.status == "completed" assert calls == ["one", "two", "three"] def test_workflow_run_persists_variables_and_ordered_results(tmp_path) -> None: store = _store(tmp_path) definition = WorkflowDefinition( name="persist", entry_step_id="one", steps=[PlannedGoalStep("one", "one"), PlannedGoalStep("two", "two")], ) run = WorkflowRunner( store, task_runner_factory=lambda: FakeTaskRunner([]), ).run(definition, "phone", {"contact": "Zhang San"}) loaded = store.get_run(run.id) assert loaded is not None assert loaded.variables == {"contact": "Zhang San"} assert [result.step_id for result in loaded.step_results] == ["one", "two"] def test_workflow_runner_branch_wait_and_skill_combination(tmp_path) -> None: skill_store, skill = _skill_store() calls: list[dict[str, object]] = [] definition = WorkflowDefinition( name="combo", entry_step_id="branch", steps=[ BranchStep( "branch", ConditionSpec("world_variable_equals", {"name": "ready", "value": True}), on_true="wait", on_false="skip", ), WaitForConditionStep( "wait", ConditionSpec("elapsed_seconds", {"seconds": 0}), timeout_seconds=1, poll_interval_seconds=0, next_step_id="skill", ), SkillInvocationStep( "skill", skill.id, {"message": "hello"}, next_step_id="end", ), PlannedGoalStep("skip", "skip", next_step_id="end"), WaitForConditionStep( "end", ConditionSpec("elapsed_seconds", {"seconds": 0}), timeout_seconds=0, poll_interval_seconds=0, ), ], ) runner = WorkflowRunner( _store(tmp_path), skill_store=skill_store, tools={"input_text": lambda **kwargs: calls.append(kwargs) or {"ok": True}}, ) run = runner.run(definition, "phone", {"ready": True}) assert run.status == "completed" assert [result.step_id for result in run.step_results] == [ "branch", "wait", "skill", "end", ] assert calls == [{"text": "hello"}] def test_resume_completed_run_is_noop(tmp_path) -> None: calls: list[str] = [] runner = WorkflowRunner( _store(tmp_path), task_runner_factory=lambda: FakeTaskRunner(calls), ) definition = WorkflowDefinition( name="done", entry_step_id="one", steps=[PlannedGoalStep("one", "one")], ) completed = runner.run(definition, "phone") resumed = runner.resume(completed.id) assert resumed == completed assert calls == ["one"] def test_workflow_runner_composes_real_task_runner_contract(tmp_path) -> None: scene = _scene("Ready") def task_runner_factory() -> TaskRunner: return TaskRunner( planner=Planner(), executor=Executor( tools={"describe_screen": lambda **kwargs: scene}, config=ExecutorConfig(max_retries=1, backoff_seconds=0), ), config=TaskRunnerConfig(max_steps=3), observer=lambda device_id: scene, screenshot_provider=lambda device_id: PNG_10X20, ) definition = WorkflowDefinition( name="real task runner", entry_step_id="goal", steps=[PlannedGoalStep("goal", "inspect")], ) run = WorkflowRunner( _store(tmp_path), task_runner_factory=task_runner_factory, ).run(definition, "phone") assert run.status == "completed" assert run.step_results[0].task_id is not None