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
6 changes: 6 additions & 0 deletions artemis/agents/flash/flash_runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ You are an autonomous and highly efficient Android Device Execution Agent. Your

**Objective: {{ goal }}**

The same original objective as an ASCII-escaped JSON string (an exact character reference):
```json
{{ goal_json }}
```
When entering user-provided text, copy the requested text exactly from the original objective. Preserve emoji, variation selectors, skin tones, joiners, punctuation and whitespace; do not substitute a visually similar character or reinterpret a symbol by its name. JSON Unicode escapes may be used in tool arguments; they decode to the actual characters, not literal backslash text. Before reporting completion, compare the field's contents with the original requested text, not just with your previous tool arguments.

---

# 1. COGNITIVE PROTOCOL
Expand Down
6 changes: 5 additions & 1 deletion artemis/agents/flash/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,11 @@ def _render_system_prompt(self, tools_declaration: list) -> str:
prompt_path = Path(__file__).parent / "flash_runner.md"
prompt_template = prompt_path.read_text(encoding="utf-8")
available_tools = frozenset(t.name for t in tools_declaration)
return Template(prompt_template).render(goal=self.goal, available_tools=available_tools)
return Template(prompt_template).render(
goal=self.goal,
goal_json=json.dumps(self.goal, ensure_ascii=True),
available_tools=available_tools,
)

# ------------------------------------------------------------------
# Per-turn helpers (observe / think)
Expand Down
2 changes: 1 addition & 1 deletion artemis/agents/flash/summarizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ def __init__(
if model_name:
self._llm = get_google_llm(model_name=target_model, temperature=0.0)
else:
self._llm = get_llm(ctx, name="summarizer", is_utils=True)
self._llm = get_llm(ctx, name="summarizer")
except Exception:
self._llm = get_google_llm(model_name=target_model, temperature=0.0)
try:
Expand Down
14 changes: 12 additions & 2 deletions artemis/clients/screen_client_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,8 +300,18 @@ def get_screenshot_base64(self) -> str | None:
def set_clipboard(self, text: str) -> bool:
return self._call("set_clipboard", text)

def send_text(self, text: str) -> Any:
return self._call("send_text", text)
def send_text(self, text: str) -> bool:
result = self._call("send_text", text)
if result is False and self._active_backend == "helper":
# A healthy helper may observe a field but be unable to set its text.
# Try IME input without marking the whole helper service unavailable.
self._ensure_device_online()
try:
return self.uiautomator.send_text(text)
finally:
# UiAutomation can unbind the helper even when input fails.
self._set_active("uiautomator", "Helper rejected direct text input")
return result

def clear_text(self) -> bool:
return self._call("clear_text")
Expand Down
3 changes: 2 additions & 1 deletion artemis/clients/ui_automator_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ def press_key(self, key: str):
device = self._ensure_connected()
return device.press(key=key)

def send_text(self, text: str) -> None:
def send_text(self, text: str) -> bool:
"""Send text input to the device using FastInputIME.

This method supports special characters (e.g., 'ö') that ADB shell
Expand All @@ -343,6 +343,7 @@ def send_text(self, text: str) -> None:
# Give FastInputIME time to process the broadcast and commit text
# before switching it off and killing it.
time.sleep(0.5)
return True
finally:
device.set_fastinput_ime(False)

Expand Down
23 changes: 11 additions & 12 deletions artemis/drivers/android/adb_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,21 +325,15 @@ async def input_text(self, text: str, clear_existing: bool = True) -> bool:
# Normalize literal escaped newlines from LLM / tool call serialization
norm_text = text.replace(r"\r\n", "\n").replace(r"\n", "\n").replace(r"\r", "\n")

# 1. Tier 1: Try clipboard injection + KEYCODE_PASTE (Zero IME interference, preserves multiline, works for all charsets)
# Prefer direct input. A background clipboard write can be silently
# denied by Android even when the helper reports success.
if self._ui_adb_client:
try:
set_clip_ok = False
if hasattr(self._ui_adb_client, "set_clipboard"):
set_clip_ok = self._ui_adb_client.set_clipboard(norm_text)
elif hasattr(self._ui_adb_client, "_device") and self._ui_adb_client._device:
self._ui_adb_client._device.set_clipboard(norm_text)
set_clip_ok = True

if set_clip_ok:
await asyncio.to_thread(self.device.shell, "input keyevent 279")
result = self._ui_adb_client.send_text(norm_text)
if result is True:
return True
except Exception as e:
logger.debug(f"Clipboard paste fallback to ADB input: {e}")
logger.debug(f"Direct text input failed, trying ADBKeyboard: {e}")

# 2. Tier 2: Check if ADBKeyboard is currently active
try:
Expand All @@ -355,7 +349,12 @@ async def input_text(self, text: str, clear_existing: bool = True) -> bool:
# ADBKeyboard probe/broadcast failed; fall through to native input.
logger.debug(f"ADBKeyboard IME path failed, falling back to ADB input: {e}")

# 3. Tier 3: Universal Native ADB input text fallback
# Native adb input text cannot reliably enter Unicode characters.
if not norm_text.isascii():
logger.warning("Unicode input failed: no supported input channel succeeded")
return False

# 3. Tier 3: Native ADB input text fallback for ASCII
lines = norm_text.split("\n")
for i, line in enumerate(lines):
if i > 0:
Expand Down
16 changes: 16 additions & 0 deletions tests/unit/agents/test_flash_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -556,3 +556,19 @@ async def test_final_report_persists_native_thinking(mock_context):
assert kwargs["operator_raw_thinking"] == "final text"
assert kwargs["operator_native_thinking"] == "native summary"
assert isinstance(messages[-1], ToolMessage)


@pytest.mark.parametrize('text', ['你好中文英😅', '👍🏽👩‍💻🇨🇳❤️', r'路径\n"原文"'])
def test_prompt_preserves_literal_unicode_goal_as_json(mock_context, text):
import json

goal = '在当前输入框输入:' + text
with patch('artemis.controllers.unified_controller.get_driver'):
runner = FlashRunner(mock_context, goal=goal)
prompt = runner._render_system_prompt(runner._get_tools())
# A second, ASCII-only representation makes the exact code points available
# even when the model misreads an emoji glyph (😅 was changed to ㊅ in a trace).
encoded = json.dumps(goal, ensure_ascii=True)
assert encoded in prompt
assert json.loads(encoded) == goal
assert 'original objective' in prompt
46 changes: 46 additions & 0 deletions tests/unit/clients/test_screen_client_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,49 @@ def test_disconnect_stops_the_uiautomator_server_it_started(composite):
client.connect()
client.disconnect()
u2.disconnect.assert_called_once_with(stop_server=True)


def test_rejected_helper_text_uses_uiautomator(composite):
client, helper, u2, _, _ = composite
helper.send_text.return_value = False
u2.send_text.return_value = True
assert client.send_text("你好") is True
u2.send_text.assert_called_once_with("你好")
assert client.active_backend == "uiautomator"


def test_successful_helper_text_is_not_duplicated(composite):
client, helper, u2, _, _ = composite
helper.send_text.return_value = True
assert client.send_text("你好") is True
u2.send_text.assert_not_called()


def test_rejected_uiautomator_text_is_not_retried(composite):
client, helper, u2, _, _ = composite
helper.send_text.side_effect = RuntimeError("unavailable")
u2.send_text.return_value = False
assert client.send_text("你好") is False
u2.send_text.assert_called_once_with("你好")


def test_failed_ime_fallback_releases_uiautomation_before_helper_retry(composite):
client, helper, u2, _, _ = composite
helper.send_text.return_value = False
u2.send_text.side_effect = RuntimeError("IME unavailable")
with pytest.raises(RuntimeError, match="IME unavailable"):
client.send_text("你好")
assert client.active_backend == "uiautomator"
helper.get_hierarchy.return_value = "<hierarchy/>"
assert client.get_hierarchy() == "<hierarchy/>"
u2.stop_server.assert_called_once()
assert client.active_backend == "helper"


def test_rejected_text_does_not_start_fallback_on_offline_device(composite):
client, helper, _, factory, clock = composite
helper.send_text.return_value = False
clock["state"] = "offline"
with pytest.raises(DeviceOfflineError):
client.send_text("你好")
factory.assert_not_called()
104 changes: 104 additions & 0 deletions tests/unit/test_android_text_input.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Regression coverage for rejected clipboard writes and Unicode input fallback."""

import base64
from unittest.mock import MagicMock

import pytest

from artemis.clients.ui_automator_client import UIAutomatorClient
from artemis.drivers.android.adb_driver import AndroidAdbDriver


@pytest.fixture
def input_path():
device = MagicMock()
device.shell.return_value = "com.example/.Keyboard"
client = MagicMock()
client.send_text.return_value = True
# The real helper can claim clipboard success despite a denied write.
client.set_clipboard.return_value = True
adb = MagicMock()
adb.device.return_value = device
action = AndroidAdbDriver("test", adb, client).input_text
return action, device, client


@pytest.mark.asyncio
async def test_direct_unicode_input_does_not_paste_or_duplicate(input_path):
action, device, client = input_path
assert await action("héllo\n你好", clear_existing=False) is True
client.send_text.assert_called_once_with("héllo\n你好")
client.set_clipboard.assert_not_called()
assert [c.args[0] for c in device.shell.call_args_list] == ["input keyevent 123"]


@pytest.mark.asyncio
@pytest.mark.parametrize("failure", [False, RuntimeError("unsupported field")])
async def test_failed_direct_input_falls_back_to_adbkeyboard(input_path, failure):
action, device, client = input_path
if isinstance(failure, Exception):
client.send_text.side_effect = failure
else:
client.send_text.return_value = failure
device.shell.return_value = "com.android.adbkeyboard/.AdbIME"
assert await action("你好", clear_existing=False) is True
payload = base64.b64encode("你好".encode()).decode()
device.shell.assert_any_call(f"am broadcast -a ADB_INPUT_B64 --es msg '{payload}'")
client.set_clipboard.assert_not_called()


@pytest.mark.asyncio
async def test_unicode_without_supported_channel_reports_failure(input_path):
action, device, client = input_path
client.send_text.return_value = False
assert await action("你好", clear_existing=False) is False
assert not any(c.args[0].startswith("input text ") for c in device.shell.call_args_list)
client.set_clipboard.assert_not_called()


def test_uiautomator_send_text_returns_success_and_restores_ime(monkeypatch):
device = MagicMock()
monkeypatch.setattr(UIAutomatorClient, "_ensure_connected", lambda self: device)
monkeypatch.setattr("artemis.clients.ui_automator_client.time.sleep", lambda _: None)
client = object.__new__(UIAutomatorClient)
assert client.send_text("你好") is True
device.send_keys.assert_called_once_with("你好")
assert [c.args for c in device.set_fastinput_ime.call_args_list] == [(True,), (False,)]


@pytest.mark.asyncio
async def test_ascii_fallback_still_inputs_text(input_path):
action, device, client = input_path
client.send_text.return_value = False
assert await action("hello world", clear_existing=False) is True
device.shell.assert_any_call("input text hello%sworld")


@pytest.mark.asyncio
async def test_driver_normalizes_escaped_newlines_before_direct_input():
client = MagicMock()
client.send_text.return_value = True
driver = AndroidAdbDriver("test", MagicMock(), client)
assert await driver.input_text(r"你好\n世界") is True
client.send_text.assert_called_once_with("你好\n世界")


def test_uiautomator_send_text_restores_ime_on_error(monkeypatch):
device = MagicMock()
device.send_keys.side_effect = RuntimeError("input failed")
monkeypatch.setattr(UIAutomatorClient, "_ensure_connected", lambda self: device)
monkeypatch.setattr("artemis.clients.ui_automator_client.time.sleep", lambda _: None)
with pytest.raises(RuntimeError, match="input failed"):
object.__new__(UIAutomatorClient).send_text("你好")
device.set_fastinput_ime.assert_called_with(False)


@pytest.mark.asyncio
async def test_unicode_without_client_or_adbkeyboard_fails():
device = MagicMock()
device.shell.return_value = "com.example/.Keyboard"
adb = MagicMock()
adb.device.return_value = device
action = AndroidAdbDriver("test", adb).input_text
assert await action("你好", clear_existing=False) is False
assert not any(c.args[0].startswith("input text ") for c in device.shell.call_args_list)
Loading