from __future__ import annotations from core.models import Bounds, Scene, SceneElement, Task from runtime.executor import Executor, ExecutorConfig from runtime.planner import PlannedStep, Planner from runtime.task import TaskRunner, TaskRunnerConfig from skills_learning.config import SkillAuthoringConfig from skills_learning.retrieval import retrieve_candidate_skills from skills_learning.store import SkillStore from storage.artifact_store import ArtifactStore from storage.timeline import Timeline from tests.fakes import PNG_10X20 class ScriptedPlanner(Planner): def __init__(self, steps: list[PlannedStep]) -> None: self.steps = steps def plan(self, *, goal, scene, context): if len(context.step_results) >= len(self.steps): return [] return [self.steps[len(context.step_results)]] def goal_reached(self, *, goal, scene, context): return len(context.step_results) >= len(self.steps) and all( result.success for result in context.step_results ) class FakeEmbeddingClient: def __init__(self, *, fail: bool = False) -> None: self.fail = fail def embed(self, text: str, *, model: str) -> list[float]: if self.fail: raise TimeoutError("embedding timeout") lowered = text.lower() if "coffee" in lowered: return [1.0, 0.0] if "tea" in lowered: return [0.0, 1.0] return [0.5, 0.5] def _scene() -> Scene: return Scene( width=10, height=20, elements=[ SceneElement( id="input", type="input", text="Search", bounds=Bounds(1, 2, 3, 4), ) ], ) def _runner( *, tmp_path, planner: Planner, timeline: Timeline | None = None, store: SkillStore | None = None, skill_config: SkillAuthoringConfig | None = None, embedding_client: FakeEmbeddingClient | None = None, on_task_succeeded=None, ) -> TaskRunner: return TaskRunner( planner=planner, executor=Executor( tools={"input_text": lambda **kwargs: {"ok": True, **kwargs}}, config=ExecutorConfig(max_retries=1, backoff_seconds=0), ), timeline=timeline or Timeline(ArtifactStore(tmp_path / "history")), config=TaskRunnerConfig(max_steps=5), observer=lambda device_id: _scene(), screenshot_provider=lambda device_id: PNG_10X20, skill_store=store, skill_authoring_config=skill_config, skill_embedding_client=embedding_client, on_task_succeeded=on_task_succeeded, ) def test_task_runner_calls_explicit_success_hook_once(tmp_path) -> None: calls: list[tuple[str, str, Timeline]] = [] timeline = Timeline(ArtifactStore(tmp_path / "history")) runner = _runner( tmp_path=tmp_path, timeline=timeline, planner=ScriptedPlanner( [PlannedStep("input_text", "type", {"text": "coffee"})] ), on_task_succeeded=lambda task_id, goal, timeline: calls.append( (task_id, goal, timeline) ), ) task = Task(goal="search coffee", device_id="phone") result = runner.run(task) assert result.status == "completed" assert calls == [(task.id, "search coffee", timeline)] def test_task_runner_skill_authoring_disabled_by_default_writes_no_skill(tmp_path) -> None: store = SkillStore() runner = _runner( tmp_path=tmp_path, planner=ScriptedPlanner( [PlannedStep("input_text", "type", {"text": "coffee"})] ), store=store, ) result = runner.run(Task(goal="search coffee", device_id="phone")) assert result.status == "completed" assert store.list_all() == [] def test_task_runner_skill_authoring_enabled_stores_skill_and_embedding(tmp_path) -> None: store = SkillStore() runner = _runner( tmp_path=tmp_path, planner=ScriptedPlanner( [PlannedStep("input_text", "type", {"text": "coffee"})] ), store=store, skill_config=SkillAuthoringConfig(enabled=True, embedding_model="fake"), embedding_client=FakeEmbeddingClient(), ) result = runner.run(Task(goal="search coffee", device_id="phone")) assert result.status == "completed" skill = store.get_latest_by_name("search coffee") assert skill is not None assert skill.steps[0].args == {"text": "coffee"} assert store.get_embedding(skill.id, skill.version) is not None def test_task_runner_embedding_failure_still_stores_skill_without_embedding(tmp_path) -> None: store = SkillStore() runner = _runner( tmp_path=tmp_path, planner=ScriptedPlanner( [PlannedStep("input_text", "type", {"text": "coffee"})] ), store=store, skill_config=SkillAuthoringConfig(enabled=True, embedding_model="fake"), embedding_client=FakeEmbeddingClient(fail=True), ) result = runner.run(Task(goal="search coffee", device_id="phone")) assert result.status == "completed" skill = store.get_latest_by_name("search coffee") assert skill is not None assert store.get_embedding(skill.id, skill.version) is None def test_two_successful_tasks_promote_parameter_and_store_embedding(tmp_path) -> None: store = SkillStore() config = SkillAuthoringConfig(enabled=True, embedding_model="fake") _runner( tmp_path=tmp_path, planner=ScriptedPlanner( [PlannedStep("input_text", "type coffee", {"text": "coffee"})] ), store=store, skill_config=config, embedding_client=FakeEmbeddingClient(), ).run(Task(goal="search coffee", device_id="phone")) _runner( tmp_path=tmp_path, planner=ScriptedPlanner( [PlannedStep("input_text", "type tea", {"text": "tea"})] ), store=store, skill_config=config, embedding_client=FakeEmbeddingClient(), ).run(Task(goal="search tea", device_id="phone")) skill = store.get_latest_by_name("search coffee") assert skill is not None assert skill.version == 1 assert skill.steps[0].args == {"text": "{param_1}"} assert "param_1" in skill.parameters assert store.get_embedding(skill.id, skill.version) is not None def test_retrieve_candidate_skills_after_successful_task(tmp_path) -> None: store = SkillStore() config = SkillAuthoringConfig(enabled=True, embedding_model="fake") _runner( tmp_path=tmp_path, planner=ScriptedPlanner( [PlannedStep("input_text", "type coffee", {"text": "coffee"})] ), store=store, skill_config=config, embedding_client=FakeEmbeddingClient(), ).run(Task(goal="search coffee", device_id="phone")) results = retrieve_candidate_skills( "find coffee", store=store, embedding_client=FakeEmbeddingClient(), config=config, ) assert [result.skill.name for result in results] == ["search coffee"]