From f4f339379882e1330e501882e7905d86d2e303ee Mon Sep 17 00:00:00 2001 From: LunarECL Date: Sun, 20 Sep 2026 10:48:13 +0900 Subject: [PATCH] fix(cli): bind `artemis batch` to the requested device serial `artemis batch` had no device option and dropped ADB_DEVICE_SERIAL / ARTEMIS_DEVICE_ID on both paths, so with several devices attached the goals ran on whichever device the tool picked (first idle device via the daemon queue, first listed device standalone). - batch: add `--device-serial/-s`, falling back to ADB_DEVICE_SERIAL / ARTEMIS_DEVICE_ID; pass it to submit_batch_to_daemon() and bind it with for_device() in run_batch_tasks(). - AgentConfigBuilder.build(): an env-provided serial now defaults device_platform to ANDROID. Agent._init_internal() falls back to get_first_device() when either field is missing, which silently discarded the serial. Fixes #135 --- artemis/interfaces/cli/commands/batch.py | 19 ++ artemis/sdk/builders/agent_config_builder.py | 7 +- .../sdk/test_agent_config_builder_device.py | 49 +++++ tests/unit/test_cli.py | 171 ++++++++++++++++++ 4 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 tests/unit/sdk/test_agent_config_builder_device.py diff --git a/artemis/interfaces/cli/commands/batch.py b/artemis/interfaces/cli/commands/batch.py index 13f52a9f..ee45d5fe 100644 --- a/artemis/interfaces/cli/commands/batch.py +++ b/artemis/interfaces/cli/commands/batch.py @@ -38,6 +38,7 @@ async def run_batch_tasks( delay_seconds: float = 5.0, verification_level: str | None = None, explorer_pro_mode: str | None = None, + device_serial: str | None = None, ) -> None: """Executes a list of automation tasks sequentially. @@ -49,6 +50,7 @@ async def run_batch_tasks( 'strict') for the Pro profile; ignored by Flash. explorer_pro_mode: Explorer tier ('flash', 'pro', 'ultra') behind ``ask_explorer`` under the Pro profile; ignored by Flash. + device_serial: ADB serial to bind every goal to; None picks the first device. """ if not os.environ.get("ARTEMIS_TASK_INGRESS"): os.environ["ARTEMIS_TASK_INGRESS"] = "cli" @@ -59,6 +61,8 @@ async def run_batch_tasks( config_builder.with_verification_level(verification_level) if explorer_pro_mode is not None: config_builder.with_explorer(pro_mode=explorer_pro_mode) + if device_serial: + config_builder.for_device(device_serial) config = config_builder.build() agent = Agent(config=config) @@ -162,6 +166,14 @@ def batch_command( help="Explorer tier behind ask_explorer under the Pro profile ('flash', 'pro', 'ultra').", ), ] = None, + device_serial: Annotated[ + str | None, + typer.Option( + "--device-serial", + "-s", + help="Target device serial for every goal (falls back to ARTEMIS_DEVICE_ID, then ADB_DEVICE_SERIAL).", + ), + ] = None, ) -> None: """Execute multiple automation tasks in sequence.""" task_list: list[str] = [] @@ -203,6 +215,11 @@ def batch_command( ) raise typer.Exit(1) + # Same precedence as AgentConfigBuilder.build(): explicit flag, then + # ARTEMIS_DEVICE_ID, then ADB_DEVICE_SERIAL. + target_serial = ( + device_serial or os.environ.get("ARTEMIS_DEVICE_ID") or os.environ.get("ADB_DEVICE_SERIAL") + ) is_standalone = standalone or os.environ.get("ARTEMIS_STANDALONE") == "1" if not is_standalone: try: @@ -222,6 +239,7 @@ def batch_command( resp = submit_batch_to_daemon( task_list, profile=profile, + device_serial=target_serial, verification_level=verification_level, explorer_mode=explorer_pro_mode, base_url=base_url, @@ -274,5 +292,6 @@ def batch_command( delay_seconds=delay, verification_level=verification_level, explorer_pro_mode=explorer_pro_mode, + device_serial=target_serial, ) ) diff --git a/artemis/sdk/builders/agent_config_builder.py b/artemis/sdk/builders/agent_config_builder.py index 4d4b8700..f581b041 100644 --- a/artemis/sdk/builders/agent_config_builder.py +++ b/artemis/sdk/builders/agent_config_builder.py @@ -599,13 +599,18 @@ def build(self, validate_profiles: bool = True) -> AgentConfig: or os.environ.get("ARTEMIS_DEVICE_ID") or os.environ.get("ADB_DEVICE_SERIAL") ) + # An env-provided serial has no platform; Agent._init_internal falls back to + # get_first_device() when either field is missing, silently ignoring the serial. + device_platform = self._device_platform + if device_id and device_platform is None: + device_platform = DevicePlatform.ANDROID return AgentConfig( agent_profiles=self._agent_profiles, task_request_defaults=self._task_request_defaults or TaskRequestCommon(), default_profile=default_profile, device_id=device_id, - device_platform=self._device_platform, + device_platform=device_platform, servers=self._servers, graph_config_callbacks=self._graph_config_callbacks, video_recording_tools_enabled=self._video_recording_tools_enabled, diff --git a/tests/unit/sdk/test_agent_config_builder_device.py b/tests/unit/sdk/test_agent_config_builder_device.py new file mode 100644 index 00000000..c49d85f8 --- /dev/null +++ b/tests/unit/sdk/test_agent_config_builder_device.py @@ -0,0 +1,49 @@ +"""Device binding on ``AgentConfigBuilder.build``. + +``Agent._init_internal`` falls back to ``get_first_device()`` whenever either +``device_id`` or ``device_platform`` is missing, so a serial that arrives via +``ADB_DEVICE_SERIAL`` / ``ARTEMIS_DEVICE_ID`` must leave the builder with a +platform as well, or it is silently discarded. +""" + +from artemis.context import DevicePlatform +from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder + + +def test_env_serial_binds_id_and_platform(monkeypatch): + monkeypatch.delenv("ARTEMIS_DEVICE_ID", raising=False) + monkeypatch.setenv("ADB_DEVICE_SERIAL", "10.0.0.8:5555") + + cfg = AgentConfigBuilder().build() + + assert cfg.device_id == "10.0.0.8:5555" + assert cfg.device_platform == DevicePlatform.ANDROID + + +def test_artemis_device_id_takes_precedence_over_adb_serial(monkeypatch): + monkeypatch.setenv("ARTEMIS_DEVICE_ID", "10.0.0.1:5555") + monkeypatch.setenv("ADB_DEVICE_SERIAL", "10.0.0.8:5555") + + cfg = AgentConfigBuilder().build() + + assert cfg.device_id == "10.0.0.1:5555" + assert cfg.device_platform == DevicePlatform.ANDROID + + +def test_no_serial_leaves_first_device_fallback(monkeypatch): + monkeypatch.delenv("ARTEMIS_DEVICE_ID", raising=False) + monkeypatch.delenv("ADB_DEVICE_SERIAL", raising=False) + + cfg = AgentConfigBuilder().build() + + assert cfg.device_id is None + assert cfg.device_platform is None + + +def test_explicit_for_device_wins_over_env(monkeypatch): + monkeypatch.setenv("ADB_DEVICE_SERIAL", "10.0.0.8:5555") + + cfg = AgentConfigBuilder().for_device("emulator-5554").build() + + assert cfg.device_id == "emulator-5554" + assert cfg.device_platform == DevicePlatform.ANDROID diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 11e9077a..70fd174f 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -153,6 +153,177 @@ def test_run_batch_tasks_applies_pro_tuning_to_agent_config(monkeypatch): fake_agent.run_task.assert_awaited_once_with(goal="Goal A", profile="pro") +def test_cli_batch_forwards_device_serial_in_standalone_mode(monkeypatch): + """`artemis batch --standalone -s` threads the serial into run_batch_tasks.""" + import artemis.interfaces.cli.commands.batch as batch_module + + monkeypatch.delenv("ADB_DEVICE_SERIAL", raising=False) + monkeypatch.delenv("ARTEMIS_DEVICE_ID", raising=False) + captured: dict = {} + + async def fake_run_batch_tasks(tasks, **kwargs): + captured["tasks"] = tasks + captured.update(kwargs) + + monkeypatch.setattr(batch_module, "run_batch_tasks", fake_run_batch_tasks) + result = runner.invoke( + app, + ["batch", "--standalone", "-s", "10.0.0.8:5555", "Open Settings"], + ) + assert result.exit_code == 0, result.output + assert captured["device_serial"] == "10.0.0.8:5555" + + +def test_cli_batch_forwards_device_serial_to_daemon(monkeypatch): + """Daemon-routed batches carry the serial so the queue does not pick a device itself.""" + import artemis.runtime as runtime + + monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False) + monkeypatch.delenv("ADB_DEVICE_SERIAL", raising=False) + monkeypatch.delenv("ARTEMIS_DEVICE_ID", raising=False) + captured: dict = {} + + def fake_submit_batch(goals, **kwargs): + captured["goals"] = goals + captured.update(kwargs) + return {"tasks": [{"session_id": "sid-1", "goal": goals[0]}]} + + monkeypatch.setattr(runtime, "ensure_daemon_running", lambda **_: (True, "http://x:1")) + monkeypatch.setattr(runtime, "submit_batch_to_daemon", fake_submit_batch) + monkeypatch.setattr(runtime, "wait_for_daemon_task", lambda *_, **__: {"status": "completed"}) + + result = runner.invoke(app, ["batch", "--device-serial", "10.0.0.8:5555", "Goal A"]) + assert result.exit_code == 0, result.output + assert captured["device_serial"] == "10.0.0.8:5555" + + +def test_cli_batch_reads_device_serial_from_env(monkeypatch): + """Without -s, the documented ADB_DEVICE_SERIAL binds the batch to a device.""" + import artemis.runtime as runtime + + monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False) + monkeypatch.delenv("ARTEMIS_DEVICE_ID", raising=False) + monkeypatch.setenv("ADB_DEVICE_SERIAL", "10.0.0.9:5555") + captured: dict = {} + + def fake_submit_batch(goals, **kwargs): + captured.update(kwargs) + return {"tasks": [{"session_id": "sid-1", "goal": goals[0]}]} + + monkeypatch.setattr(runtime, "ensure_daemon_running", lambda **_: (True, "http://x:1")) + monkeypatch.setattr(runtime, "submit_batch_to_daemon", fake_submit_batch) + monkeypatch.setattr(runtime, "wait_for_daemon_task", lambda *_, **__: {"status": "completed"}) + + result = runner.invoke(app, ["batch", "Goal A"]) + assert result.exit_code == 0, result.output + assert captured["device_serial"] == "10.0.0.9:5555" + + +def _fake_daemon(monkeypatch, captured: dict) -> None: + """Route `artemis batch` through a stubbed daemon that records the submission.""" + import artemis.runtime as runtime + + def fake_submit_batch(goals, **kwargs): + captured.update(kwargs) + return {"tasks": [{"session_id": "sid-1", "goal": goals[0]}]} + + monkeypatch.setattr(runtime, "ensure_daemon_running", lambda **_: (True, "http://x:1")) + monkeypatch.setattr(runtime, "submit_batch_to_daemon", fake_submit_batch) + monkeypatch.setattr(runtime, "wait_for_daemon_task", lambda *_, **__: {"status": "completed"}) + + +def test_cli_batch_env_precedence_matches_agent_config_builder(monkeypatch): + """With both variables set, batch and AgentConfigBuilder must pick the same phone.""" + from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder + + monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False) + monkeypatch.setenv("ARTEMIS_DEVICE_ID", "10.0.0.1:5555") + monkeypatch.setenv("ADB_DEVICE_SERIAL", "10.0.0.8:5555") + captured: dict = {} + _fake_daemon(monkeypatch, captured) + + result = runner.invoke(app, ["batch", "Goal A"]) + assert result.exit_code == 0, result.output + assert captured["device_serial"] == AgentConfigBuilder().build().device_id == "10.0.0.1:5555" + + +def test_cli_batch_explicit_serial_overrides_env_on_daemon_path(monkeypatch): + monkeypatch.delenv("ARTEMIS_STANDALONE", raising=False) + monkeypatch.setenv("ARTEMIS_DEVICE_ID", "10.0.0.1:5555") + monkeypatch.setenv("ADB_DEVICE_SERIAL", "10.0.0.8:5555") + captured: dict = {} + _fake_daemon(monkeypatch, captured) + + result = runner.invoke(app, ["batch", "-s", "emulator-5554", "Goal A"]) + assert result.exit_code == 0, result.output + assert captured["device_serial"] == "emulator-5554" + + +def test_cli_batch_explicit_serial_overrides_env_in_standalone_mode(monkeypatch): + import artemis.interfaces.cli.commands.batch as batch_module + + monkeypatch.setenv("ARTEMIS_DEVICE_ID", "10.0.0.1:5555") + monkeypatch.setenv("ADB_DEVICE_SERIAL", "10.0.0.8:5555") + captured: dict = {} + + async def fake_run_batch_tasks(tasks, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr(batch_module, "run_batch_tasks", fake_run_batch_tasks) + result = runner.invoke(app, ["batch", "--standalone", "-s", "emulator-5554", "Goal A"]) + assert result.exit_code == 0, result.output + assert captured["device_serial"] == "emulator-5554" + + +def test_cli_batch_standalone_env_precedence_matches_agent_config_builder(monkeypatch): + import artemis.interfaces.cli.commands.batch as batch_module + from artemis.sdk.builders.agent_config_builder import AgentConfigBuilder + + monkeypatch.setenv("ARTEMIS_DEVICE_ID", "10.0.0.1:5555") + monkeypatch.setenv("ADB_DEVICE_SERIAL", "10.0.0.8:5555") + captured: dict = {} + + async def fake_run_batch_tasks(tasks, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr(batch_module, "run_batch_tasks", fake_run_batch_tasks) + result = runner.invoke(app, ["batch", "--standalone", "Goal A"]) + assert result.exit_code == 0, result.output + assert captured["device_serial"] == AgentConfigBuilder().build().device_id == "10.0.0.1:5555" + + +def test_run_batch_tasks_binds_device_serial_on_agent_config(monkeypatch): + """The standalone batch runner binds the serial through for_device (id + platform).""" + from unittest.mock import AsyncMock, MagicMock + + import artemis.interfaces.cli.commands.batch as batch_module + + fake_builder = MagicMock() + fake_builders = MagicMock() + fake_builders.AgentConfig.with_default_profile.return_value = fake_builder + fake_agent = MagicMock() + fake_agent.init = AsyncMock() + fake_agent.run_task = AsyncMock(return_value="ok") + fake_agent.clean = AsyncMock() + + monkeypatch.setattr(batch_module, "initialize_llm_config", lambda: MagicMock()) + monkeypatch.setattr(batch_module, "AgentProfile", MagicMock()) + monkeypatch.setattr(batch_module, "Builders", fake_builders) + monkeypatch.setattr(batch_module, "Agent", MagicMock(return_value=fake_agent)) + + import asyncio + + asyncio.run( + batch_module.run_batch_tasks( + ["Goal A"], + profile_name="flash", + delay_seconds=0, + device_serial="10.0.0.8:5555", + ) + ) + fake_builder.for_device.assert_called_once_with("10.0.0.8:5555") + + def test_cli_trace_help(): """Verify 'artemis trace --help' lists trace subcommands.""" result = runner.invoke(app, ["trace", "--help"])