Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions artemis/interfaces/cli/commands/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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] = []
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -274,5 +292,6 @@ def batch_command(
delay_seconds=delay,
verification_level=verification_level,
explorer_pro_mode=explorer_pro_mode,
device_serial=target_serial,
)
)
7 changes: 6 additions & 1 deletion artemis/sdk/builders/agent_config_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
49 changes: 49 additions & 0 deletions tests/unit/sdk/test_agent_config_builder_device.py
Original file line number Diff line number Diff line change
@@ -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
171 changes: 171 additions & 0 deletions tests/unit/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
Loading