This commit is contained in:
2026-07-06 23:15:30 +08:00
parent c7ae2d86ec
commit 1fb62f9d45
15 changed files with 1865 additions and 44 deletions
+1
View File
@@ -16,5 +16,6 @@ def test_imports_new_packages() -> None:
"storage",
"tools",
"world",
"workflow",
):
importlib.import_module(package)
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
from datetime import timedelta
from types import SimpleNamespace
import pytest
from core.models import Bounds, Scene, SceneElement, utc_now
from workflow.conditions import UnknownConditionKindError, evaluate_condition
from workflow.models import ConditionSpec, WorkflowStepResult
def _scene(text: str) -> Scene:
return Scene(
width=10,
height=20,
elements=[
SceneElement(
id="label",
type="text",
text=text,
bounds=Bounds(1, 2, 3, 4),
)
],
)
def test_scene_contains_text_evaluator_true_and_false() -> None:
spec = ConditionSpec("scene_contains_text", {"text": "hello"})
assert evaluate_condition(spec, scene=_scene("Hello world"), world_state=None)
assert not evaluate_condition(spec, scene=_scene("Goodbye"), world_state=None)
def test_world_variable_equals_evaluator_true_false_and_absent() -> None:
spec = ConditionSpec("world_variable_equals", {"name": "ready", "value": True})
assert evaluate_condition(
spec,
scene=None,
world_state=SimpleNamespace(variables={"ready": True}),
)
assert not evaluate_condition(
spec,
scene=None,
world_state=SimpleNamespace(variables={"ready": False}),
)
assert not evaluate_condition(spec, scene=None, world_state=None)
def test_elapsed_seconds_evaluator_true_and_false() -> None:
spec = ConditionSpec("elapsed_seconds", {"seconds": 1})
assert evaluate_condition(
spec,
scene=None,
world_state=None,
started_at=utc_now() - timedelta(seconds=2),
)
assert not evaluate_condition(
spec,
scene=None,
world_state=None,
started_at=utc_now(),
)
def test_step_result_success_evaluator_true_and_false() -> None:
spec = ConditionSpec("step_result_success", {"step_id": "first"})
results = [WorkflowStepResult("first", "planned_goal", True)]
assert evaluate_condition(spec, scene=None, world_state=None, step_results=results)
assert not evaluate_condition(
ConditionSpec("step_result_success", {"step_id": "missing"}),
scene=None,
world_state=None,
step_results=results,
)
def test_unknown_condition_kind_raises_distinguishable_error() -> None:
with pytest.raises(UnknownConditionKindError):
evaluate_condition(ConditionSpec("unknown"), scene=None, world_state=None)
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import pytest
from workflow.models import (
BranchStep,
ConditionSpec,
PlannedGoalStep,
SkillInvocationStep,
WaitForConditionStep,
WorkflowDefinition,
)
def test_workflow_definition_accepts_heterogeneous_steps() -> None:
definition = WorkflowDefinition(
name="chat workflow",
entry_step_id="start",
steps=[
PlannedGoalStep("start", "open chat", next_step_id="skill"),
SkillInvocationStep("skill", "skill-1", {"message": "hello"}, "wait"),
WaitForConditionStep(
"wait",
ConditionSpec("scene_contains_text", {"text": "Done"}),
timeout_seconds=1,
poll_interval_seconds=0,
next_step_id="branch",
),
BranchStep(
"branch",
ConditionSpec("world_variable_equals", {"name": "ok", "value": True}),
on_true="start",
on_false="skill",
),
],
)
assert definition.step_by_id("skill").kind == "skill_invocation"
def test_workflow_definition_rejects_duplicate_step_id() -> None:
with pytest.raises(ValueError, match="duplicate"):
WorkflowDefinition(
name="bad",
entry_step_id="same",
steps=[
PlannedGoalStep("same", "one"),
PlannedGoalStep("same", "two"),
],
)
@pytest.mark.parametrize(
"definition",
[
WorkflowDefinition,
],
)
def test_workflow_definition_rejects_dangling_references(definition) -> None:
with pytest.raises(ValueError, match="unknown|entry"):
definition(
name="bad next",
entry_step_id="start",
steps=[PlannedGoalStep("start", "one", next_step_id="missing")],
)
with pytest.raises(ValueError, match="unknown|entry"):
definition(
name="bad branch",
entry_step_id="branch",
steps=[
BranchStep(
"branch",
ConditionSpec("elapsed_seconds", {"seconds": 0}),
on_true="missing",
on_false="branch",
)
],
)
with pytest.raises(ValueError, match="entry"):
definition(
name="bad entry",
entry_step_id="missing",
steps=[PlannedGoalStep("start", "one")],
)
+387
View File
@@ -0,0 +1,387 @@
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
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
import pytest
from skills_learning.models import FlowStep, FlowTemplateSkill, SkillMetadata
from workflow.skill_exec import (
SkillExecutionError,
resolve_skill_steps,
run_flow_template_skill,
)
def _skill() -> FlowTemplateSkill:
return FlowTemplateSkill(
metadata=SkillMetadata(
name="send message",
description="Send a message",
kind="flow_template",
),
steps=[
FlowStep("input_text", {"text": "{message}"}),
FlowStep("tap", {"x": 1, "y": 2}),
],
parameters={"message": {"type": "string"}},
)
def test_resolve_skill_steps_substitutes_valid_args() -> None:
resolved = resolve_skill_steps(_skill(), {"message": "hello"})
assert resolved == [
{"tool_name": "input_text", "args": {"text": "hello"}},
{"tool_name": "tap", "args": {"x": 1, "y": 2}},
]
def test_missing_required_parameter_fails_before_tool_call() -> None:
calls: list[str] = []
with pytest.raises(SkillExecutionError, match="missing required"):
run_flow_template_skill(
_skill(),
{},
tools={"input_text": lambda **kwargs: calls.append("input_text")},
)
assert calls == []
def test_non_flow_template_skill_is_rejected() -> None:
skill = FlowTemplateSkill(
metadata=SkillMetadata(
name="knowledge",
description="Not executable",
kind="knowledge",
)
)
with pytest.raises(SkillExecutionError, match="flow_template"):
resolve_skill_steps(skill, {})
def test_run_flow_template_skill_dispatches_resolved_tools() -> None:
calls: list[tuple[str, dict[str, object]]] = []
results = run_flow_template_skill(
_skill(),
{"message": "hello"},
tools={
"input_text": lambda **kwargs: calls.append(("input_text", kwargs)) or {"ok": True},
"tap": lambda **kwargs: calls.append(("tap", kwargs)) or {"ok": True},
},
)
assert [result.success for result in results] == [True, True]
assert calls == [
("input_text", {"text": "hello"}),
("tap", {"x": 1, "y": 2}),
]
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
from workflow.models import PlannedGoalStep, WorkflowDefinition, WorkflowStepResult
from workflow.store import WorkflowStore
def _definition() -> WorkflowDefinition:
return WorkflowDefinition(
name="linear",
entry_step_id="first",
steps=[
PlannedGoalStep("first", "one", next_step_id="second"),
PlannedGoalStep("second", "two"),
],
)
def test_workflow_store_round_trips_definition_and_run(tmp_path) -> None:
store = WorkflowStore(tmp_path / "workflows.sqlite3")
definition = _definition()
store.save_definition(definition)
run = store.create_run(
definition.id,
{"contact": "Zhang San"},
device_id="phone",
)
loaded = store.get_run(run.id)
assert store.get_definition(definition.id) == definition
assert loaded is not None
assert loaded.status == "running"
assert loaded.current_step_id == "first"
assert loaded.variables == {"contact": "Zhang San"}
assert loaded.device_id == "phone"
def test_workflow_store_appends_step_results_in_order(tmp_path) -> None:
store = WorkflowStore(tmp_path / "workflows.sqlite3")
definition = _definition()
store.save_definition(definition)
run = store.create_run(definition.id, {})
store.append_step_result(run.id, WorkflowStepResult("first", "planned_goal", True))
store.append_step_result(run.id, WorkflowStepResult("second", "planned_goal", True))
loaded = store.get_run(run.id)
assert loaded is not None
assert [result.step_id for result in loaded.step_results] == ["first", "second"]
def test_workflow_store_survives_reopen(tmp_path) -> None:
db_path = tmp_path / "workflows.sqlite3"
first_store = WorkflowStore(db_path)
definition = _definition()
first_store.save_definition(definition)
run = first_store.create_run(definition.id, {"x": 1})
first_store.append_step_result(run.id, WorkflowStepResult("first", "planned_goal", True))
first_store.update_run(run.id, current_step_id="second", variables={"x": 2})
reopened = WorkflowStore(db_path)
loaded = reopened.get_run(run.id)
assert loaded is not None
assert loaded.current_step_id == "second"
assert loaded.variables == {"x": 2}
assert [result.step_id for result in loaded.step_results] == ["first"]