@loadmill/droid-cua 2.23.0 → 2.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/build/index.js +12 -0
- package/build/src/cli/headless-runner.js +2 -0
- package/build/src/cli/skill-command.js +240 -0
- package/build/src/commands/help.js +2 -1
- package/build/src/commands/run.js +5 -3
- package/build/src/core/cua-models.js +29 -9
- package/build/src/core/droid-cua-run-usage.js +16 -7
- package/build/src/core/execution-engine.js +7 -1
- package/build/src/core/runtime-config.js +2 -5
- package/build/src/core/session.js +2 -2
- package/build/src/core/tool-handlers/close-keyboard.js +103 -0
- package/build/src/device/actions.js +7 -0
- package/build/src/device/android/actions.js +16 -0
- package/build/src/device/cloud/actions.js +27 -0
- package/build/src/device/cloud/cloud-appium-client.js +15 -0
- package/build/src/device/connection.js +5 -2
- package/build/src/device/factory.js +3 -0
- package/build/src/device/interface.js +4 -0
- package/build/src/device/ios/actions.js +13 -0
- package/build/src/device/ios/appium-client.js +16 -0
- package/build/src/device/keyboard.js +119 -0
- package/build/src/device/openai.js +19 -1
- package/build/src/modes/design-mode-ink.js +1 -0
- package/build/src/modes/design-mode.js +1 -0
- package/build/src/modes/execution-mode.js +88 -16
- package/build/src/prompts/base.js +29 -4
- package/build/src/prompts/execution.js +14 -3
- package/package.json +2 -1
- package/skill/droid-cua/SKILL.md +39 -0
- package/skill/droid-cua/agents/openai.yaml +6 -0
- package/skill/droid-cua/assets/droid-cua-app-icon.png +0 -0
- package/skill/droid-cua/references/authoring-tests.md +39 -0
- package/skill/droid-cua/references/onboarding-and-troubleshooting.md +74 -0
- package/skill/droid-cua/references/project-and-auth.md +41 -0
- package/skill/droid-cua/references/running-and-healing.md +110 -0
|
@@ -6,9 +6,25 @@ import { buildMobileKeypressPayload, getAndroidAdbTransport, normalizeMobileKeyp
|
|
|
6
6
|
import { actionForErrorLog, buildActionErrorOutput, buildDebugTextField, buildSensitiveDebugFields, buildTypeActionPayload, buildTypeActionText, isSensitiveAction, } from "../sensitive-action.js";
|
|
7
7
|
import { resolveScrollGesture } from "../scroll-gesture.js";
|
|
8
8
|
import { execAdb } from "./tools.js";
|
|
9
|
+
import { closeAdbKeyboard, resolveAdbKeyboardVisibility } from "../keyboard.js";
|
|
9
10
|
function adbShell(deviceId, command) {
|
|
10
11
|
return execAdb(["-s", deviceId, "shell", command]);
|
|
11
12
|
}
|
|
13
|
+
async function getKeyboardVisibility(deviceId) {
|
|
14
|
+
const { stdout: inputMethodDump } = await adbShell(deviceId, "dumpsys input_method");
|
|
15
|
+
const { stdout: showImeWithHardwareKeyboard } = await adbShell(deviceId, "settings get secure show_ime_with_hard_keyboard");
|
|
16
|
+
return resolveAdbKeyboardVisibility(inputMethodDump, showImeWithHardwareKeyboard);
|
|
17
|
+
}
|
|
18
|
+
export async function closeKeyboard(deviceId) {
|
|
19
|
+
// KEYCODE_BACK dismisses the Android IME, but it would navigate if the app
|
|
20
|
+
// already hid the keyboard. getKeyboardVisibility also filters the hardware
|
|
21
|
+
// keyboard configuration where mInputShown is true without a software input
|
|
22
|
+
// view. closeAdbKeyboard invokes Back only for an affirmative result.
|
|
23
|
+
return closeAdbKeyboard({
|
|
24
|
+
getVisibility: () => getKeyboardVisibility(deviceId),
|
|
25
|
+
pressBack: () => adbShell(deviceId, "input keyevent KEYCODE_BACK"),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
12
28
|
export async function handleModelAction(deviceId, action, scale = 1.0, context = null) {
|
|
13
29
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
14
30
|
const meta = (payload = {}) => buildActionEventMeta({
|
|
@@ -6,6 +6,33 @@ import { actionForErrorLog, buildActionErrorOutput, buildDebugTextField, buildSe
|
|
|
6
6
|
import { buildMobileKeypressPayload, getCloudAndroidTransport, getIosTransport, normalizeMobileKeypress } from "../mobile-keypress.js";
|
|
7
7
|
import { getActiveSession, getDevicePixelRatio } from "./connection.js";
|
|
8
8
|
import { resolveScrollGesture } from "../scroll-gesture.js";
|
|
9
|
+
import { closeAppiumKeyboard, closeAppiumKeyboardWithTap } from "../keyboard.js";
|
|
10
|
+
export async function closeKeyboard(_deviceId, { dismissalPoint = null, scale = 1 } = {}) {
|
|
11
|
+
const session = getActiveSession();
|
|
12
|
+
if (!session?.client) {
|
|
13
|
+
throw new Error("No active cloud session");
|
|
14
|
+
}
|
|
15
|
+
if (session.platform === "ios") {
|
|
16
|
+
// XCUITest hideKeyboard was unreliable on the tested AWS real devices, and
|
|
17
|
+
// Return/newline can submit forms. Trust the model to identify a dismissal
|
|
18
|
+
// control or inert background, but tap only after confirming the keyboard
|
|
19
|
+
// is open. With no trusted point, fail safely without touching the screen.
|
|
20
|
+
const dpr = getDevicePixelRatio();
|
|
21
|
+
const tap = dismissalPoint
|
|
22
|
+
? () => session.client.tap(session.sessionId, Math.round((dismissalPoint.x / scale) / dpr), Math.round((dismissalPoint.y / scale) / dpr))
|
|
23
|
+
: null;
|
|
24
|
+
return closeAppiumKeyboardWithTap({
|
|
25
|
+
isKeyboardShown: () => session.client.isKeyboardShown(session.sessionId),
|
|
26
|
+
tap,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
// UiAutomator2's native query/hide commands are reliable on cloud Android and
|
|
30
|
+
// avoid the navigation risk of treating Android Back as a generic fallback.
|
|
31
|
+
return closeAppiumKeyboard({
|
|
32
|
+
isKeyboardShown: () => session.client.isKeyboardShown(session.sessionId),
|
|
33
|
+
hideKeyboard: () => session.client.hideKeyboard(session.sessionId),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
9
36
|
export async function handleModelAction(deviceId, action, scale = 1.0, context = null) {
|
|
10
37
|
const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
|
|
11
38
|
const session = getActiveSession();
|
|
@@ -99,6 +99,21 @@ export class CloudAppiumClient {
|
|
|
99
99
|
]
|
|
100
100
|
});
|
|
101
101
|
}
|
|
102
|
+
async isKeyboardShown(sessionId) {
|
|
103
|
+
const response = await this.request("POST", `/session/${sessionId}/execute/sync`, {
|
|
104
|
+
script: "mobile: isKeyboardShown",
|
|
105
|
+
args: [{}]
|
|
106
|
+
});
|
|
107
|
+
return response.value === true;
|
|
108
|
+
}
|
|
109
|
+
async hideKeyboard(sessionId) {
|
|
110
|
+
// Currently called by cloud Android only. Cloud iOS deliberately routes to
|
|
111
|
+
// its guarded model-selected tap in cloud/actions.js.
|
|
112
|
+
await this.request("POST", `/session/${sessionId}/execute/sync`, {
|
|
113
|
+
script: "mobile: hideKeyboard",
|
|
114
|
+
args: [{}]
|
|
115
|
+
});
|
|
116
|
+
}
|
|
102
117
|
async scroll(sessionId, startX, startY, endX, endY, duration = 500) {
|
|
103
118
|
await this.request("POST", `/session/${sessionId}/actions`, {
|
|
104
119
|
actions: [
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Thin wrapper that delegates to the appropriate platform backend.
|
|
5
5
|
* Maintains backwards compatibility with existing code.
|
|
6
6
|
*/
|
|
7
|
-
import { getDeviceBackend, detectPlatform, setCurrentPlatform, setCurrentSource, getCurrentPlatform } from "./factory.js";
|
|
7
|
+
import { getDeviceBackend, detectPlatform, setCurrentPlatform, setCurrentSource, getCurrentPlatform, getCurrentSource } from "./factory.js";
|
|
8
8
|
let currentBackend = null;
|
|
9
9
|
/**
|
|
10
10
|
* Connect to a device (Android target ID/serial or iOS simulator name)
|
|
@@ -40,7 +40,10 @@ export async function getDeviceInfo(deviceId, options = {}) {
|
|
|
40
40
|
if (!currentBackend) {
|
|
41
41
|
throw new Error("Not connected to a device. Call connectToDevice first.");
|
|
42
42
|
}
|
|
43
|
-
return
|
|
43
|
+
return {
|
|
44
|
+
...await currentBackend.getDeviceInfo(deviceId, options),
|
|
45
|
+
source: getCurrentSource(),
|
|
46
|
+
};
|
|
44
47
|
}
|
|
45
48
|
/**
|
|
46
49
|
* Get screenshot as base64 string
|
|
@@ -47,6 +47,7 @@ export function getDeviceBackend(platform) {
|
|
|
47
47
|
getDeviceInfo: cloudConnection.getDeviceInfo,
|
|
48
48
|
getScreenshotAsBase64: cloudConnection.getScreenshotAsBase64,
|
|
49
49
|
handleModelAction: cloudActions.handleModelAction,
|
|
50
|
+
closeKeyboard: cloudActions.closeKeyboard,
|
|
50
51
|
disconnect: cloudConnection.disconnect,
|
|
51
52
|
};
|
|
52
53
|
}
|
|
@@ -56,6 +57,7 @@ export function getDeviceBackend(platform) {
|
|
|
56
57
|
getDeviceInfo: iosConnection.getDeviceInfo,
|
|
57
58
|
getScreenshotAsBase64: iosConnection.getScreenshotAsBase64,
|
|
58
59
|
handleModelAction: iosActions.handleModelAction,
|
|
60
|
+
closeKeyboard: iosActions.closeKeyboard,
|
|
59
61
|
disconnect: iosConnection.disconnect,
|
|
60
62
|
};
|
|
61
63
|
}
|
|
@@ -65,6 +67,7 @@ export function getDeviceBackend(platform) {
|
|
|
65
67
|
getDeviceInfo: androidConnection.getDeviceInfo,
|
|
66
68
|
getScreenshotAsBase64: androidConnection.getScreenshotAsBase64,
|
|
67
69
|
handleModelAction: androidActions.handleModelAction,
|
|
70
|
+
closeKeyboard: androidActions.closeKeyboard,
|
|
68
71
|
disconnect: async () => { }, // Android doesn't need explicit disconnect
|
|
69
72
|
};
|
|
70
73
|
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* @property {string} [platform] - Device platform ('android' or 'ios')
|
|
15
15
|
* @property {string} [device_name] - Human-readable device or simulator name
|
|
16
16
|
* @property {string} [model] - Device model identifier/name
|
|
17
|
+
* @property {string} [source] - Device source such as 'local' or a cloud provider ID
|
|
17
18
|
*/
|
|
18
19
|
/**
|
|
19
20
|
* @typedef {Object} ActionContext
|
|
@@ -41,6 +42,9 @@
|
|
|
41
42
|
* - Executes an action from the CUA model
|
|
42
43
|
* - Supported action types: click, type, scroll, drag, keypress, wait
|
|
43
44
|
* - keypress is limited to single mobile-safe keys only; desktop shortcut chords are unsupported
|
|
45
|
+
*
|
|
46
|
+
* closeKeyboard(deviceId: string, options?: {dismissalPoint?: {x: number, y: number}, scale?: number}): Promise<{status: string, message: string, error?: string}>
|
|
47
|
+
* - Safely attempts to close the software keyboard without changing app navigation
|
|
44
48
|
*/
|
|
45
49
|
export const SUPPORTED_ACTIONS = [
|
|
46
50
|
'click', // Tap at (x, y) coordinates
|
|
@@ -12,6 +12,19 @@ import { logger } from "../../utils/logger.js";
|
|
|
12
12
|
import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js";
|
|
13
13
|
import { getConfiguredStepDelayMs } from "../../utils/step-delay.js";
|
|
14
14
|
import { resolveScrollGesture } from "../scroll-gesture.js";
|
|
15
|
+
import { closeAppiumKeyboard } from "../keyboard.js";
|
|
16
|
+
export async function closeKeyboard() {
|
|
17
|
+
const session = getActiveSession();
|
|
18
|
+
if (!session) {
|
|
19
|
+
throw new Error("No active iOS session");
|
|
20
|
+
}
|
|
21
|
+
// The local simulator supports Appium's keyboard command reliably, so it
|
|
22
|
+
// needs neither a model-selected tap nor a direct Return keypress.
|
|
23
|
+
return closeAppiumKeyboard({
|
|
24
|
+
isKeyboardShown: () => appium.isKeyboardShown(session.sessionId),
|
|
25
|
+
hideKeyboard: () => appium.hideKeyboard(session.sessionId),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
15
28
|
/**
|
|
16
29
|
* Handle an action from the CUA model
|
|
17
30
|
* @param {string} simulatorId - The simulator UDID
|
|
@@ -123,6 +123,22 @@ export async function type(sessionId, text) {
|
|
|
123
123
|
],
|
|
124
124
|
});
|
|
125
125
|
}
|
|
126
|
+
export async function isKeyboardShown(sessionId) {
|
|
127
|
+
const response = await appiumRequest("POST", `/session/${sessionId}/execute/sync`, {
|
|
128
|
+
script: "mobile: isKeyboardShown",
|
|
129
|
+
args: [{}],
|
|
130
|
+
});
|
|
131
|
+
return response.value === true;
|
|
132
|
+
}
|
|
133
|
+
export async function hideKeyboard(sessionId) {
|
|
134
|
+
// This XCUITest form is used only by the locally validated simulator path.
|
|
135
|
+
// Real cloud iOS devices use a guarded tap instead because this command was
|
|
136
|
+
// unreliable there and sending Return directly can submit the current form.
|
|
137
|
+
await appiumRequest("POST", `/session/${sessionId}/execute/sync`, {
|
|
138
|
+
script: "mobile: hideKeyboard",
|
|
139
|
+
args: [{ keys: ["return"] }],
|
|
140
|
+
});
|
|
141
|
+
}
|
|
126
142
|
/**
|
|
127
143
|
* Perform a scroll action using W3C Actions
|
|
128
144
|
* @param {string} sessionId
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { getConfiguredStepDelayMs } from "../utils/step-delay.js";
|
|
2
|
+
/**
|
|
3
|
+
* Keyboard dismissal is intentionally platform-specific:
|
|
4
|
+
*
|
|
5
|
+
* - Local Android uses ADB. Back is the reliable dismissal key, but it can also
|
|
6
|
+
* navigate away. Android's mInputShown flag stays true when a hardware
|
|
7
|
+
* keyboard suppresses the software keyboard, so that configuration is
|
|
8
|
+
* treated as already closed before Back is considered.
|
|
9
|
+
* - Local iOS and cloud Android use Appium's native keyboard commands, which
|
|
10
|
+
* worked reliably in device testing and do not require choosing a tap target.
|
|
11
|
+
* - Cloud iOS uses a model-selected tap. XCUITest's hideKeyboard command was
|
|
12
|
+
* unreliable on the tested real devices, while Return/newline could submit a
|
|
13
|
+
* form. The caller therefore supplies a trusted dismissal point instead.
|
|
14
|
+
*
|
|
15
|
+
* Every strategy follows the same query -> dismiss -> wait -> verify shape and
|
|
16
|
+
* returns the normalized result below.
|
|
17
|
+
*/
|
|
18
|
+
export const KEYBOARD_CLOSE_RESULTS = {
|
|
19
|
+
CLOSED: "closed",
|
|
20
|
+
ALREADY_CLOSED: "already_closed",
|
|
21
|
+
FAILED: "failed",
|
|
22
|
+
};
|
|
23
|
+
const KEYBOARD_CLOSE_MESSAGES = {
|
|
24
|
+
[KEYBOARD_CLOSE_RESULTS.CLOSED]: "Keyboard closed",
|
|
25
|
+
[KEYBOARD_CLOSE_RESULTS.ALREADY_CLOSED]: "Keyboard closed",
|
|
26
|
+
[KEYBOARD_CLOSE_RESULTS.FAILED]: "Keyboard could not be closed and may still be open",
|
|
27
|
+
};
|
|
28
|
+
export function buildKeyboardCloseResult(status, error = null) {
|
|
29
|
+
return {
|
|
30
|
+
status,
|
|
31
|
+
message: KEYBOARD_CLOSE_MESSAGES[status],
|
|
32
|
+
...(error ? { error: error instanceof Error ? error.message : String(error) } : {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function parseAdbKeyboardVisibility(output) {
|
|
36
|
+
const matches = [...String(output ?? "").matchAll(/\bmInputShown=(true|false)\b/g)]
|
|
37
|
+
.map((match) => match[1] === "true");
|
|
38
|
+
const states = new Set(matches);
|
|
39
|
+
// Multiple contradictory sections can appear while the IME is transitioning.
|
|
40
|
+
// Treat missing or ambiguous state as unknown so callers never risk Back.
|
|
41
|
+
return states.size === 1 ? matches[0] : null;
|
|
42
|
+
}
|
|
43
|
+
export function parseAdbHardwareKeyboardVisibility(output) {
|
|
44
|
+
const matches = [...String(output ?? "").matchAll(/\b(?:qwerty|12key)\/[vh]\/(v|h)\b/g)]
|
|
45
|
+
.map((match) => match[1] === "v");
|
|
46
|
+
const states = new Set(matches);
|
|
47
|
+
return states.size === 1 ? matches[0] : null;
|
|
48
|
+
}
|
|
49
|
+
export function resolveAdbKeyboardVisibility(inputMethodDump, showImeWithHardwareKeyboard) {
|
|
50
|
+
const softwareKeyboardRequested = parseAdbKeyboardVisibility(inputMethodDump);
|
|
51
|
+
const hardwareKeyboardVisible = parseAdbHardwareKeyboardVisibility(inputMethodDump);
|
|
52
|
+
const showSoftwareKeyboard = String(showImeWithHardwareKeyboard ?? "").trim();
|
|
53
|
+
// mInputShown means Android asked the IME to show; it does not guarantee that
|
|
54
|
+
// the input view appeared. With an exposed hardware keyboard and this setting
|
|
55
|
+
// disabled, only an IME accessory toolbar may be visible, and Back would be
|
|
56
|
+
// delivered to the app instead of dismissing a software keyboard.
|
|
57
|
+
if (showSoftwareKeyboard === "0" && hardwareKeyboardVisible === true) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return softwareKeyboardRequested;
|
|
61
|
+
}
|
|
62
|
+
async function waitForKeyboardUpdate() {
|
|
63
|
+
const delayMs = getConfiguredStepDelayMs();
|
|
64
|
+
if (delayMs > 0) {
|
|
65
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export async function closeAppiumKeyboard({ isKeyboardShown, hideKeyboard }) {
|
|
69
|
+
try {
|
|
70
|
+
if (!await isKeyboardShown()) {
|
|
71
|
+
return buildKeyboardCloseResult(KEYBOARD_CLOSE_RESULTS.ALREADY_CLOSED);
|
|
72
|
+
}
|
|
73
|
+
await hideKeyboard();
|
|
74
|
+
await waitForKeyboardUpdate();
|
|
75
|
+
return await isKeyboardShown()
|
|
76
|
+
? buildKeyboardCloseResult(KEYBOARD_CLOSE_RESULTS.FAILED)
|
|
77
|
+
: buildKeyboardCloseResult(KEYBOARD_CLOSE_RESULTS.CLOSED);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
return buildKeyboardCloseResult(KEYBOARD_CLOSE_RESULTS.FAILED, error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export async function closeAppiumKeyboardWithTap({ isKeyboardShown, tap }) {
|
|
84
|
+
try {
|
|
85
|
+
if (!await isKeyboardShown()) {
|
|
86
|
+
return buildKeyboardCloseResult(KEYBOARD_CLOSE_RESULTS.ALREADY_CLOSED);
|
|
87
|
+
}
|
|
88
|
+
if (typeof tap !== "function") {
|
|
89
|
+
return buildKeyboardCloseResult(KEYBOARD_CLOSE_RESULTS.FAILED);
|
|
90
|
+
}
|
|
91
|
+
await tap();
|
|
92
|
+
await waitForKeyboardUpdate();
|
|
93
|
+
return await isKeyboardShown()
|
|
94
|
+
? buildKeyboardCloseResult(KEYBOARD_CLOSE_RESULTS.FAILED)
|
|
95
|
+
: buildKeyboardCloseResult(KEYBOARD_CLOSE_RESULTS.CLOSED);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
return buildKeyboardCloseResult(KEYBOARD_CLOSE_RESULTS.FAILED, error);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export async function closeAdbKeyboard({ getVisibility, pressBack }) {
|
|
102
|
+
try {
|
|
103
|
+
const visibility = await getVisibility();
|
|
104
|
+
if (visibility === false) {
|
|
105
|
+
return buildKeyboardCloseResult(KEYBOARD_CLOSE_RESULTS.ALREADY_CLOSED);
|
|
106
|
+
}
|
|
107
|
+
if (visibility !== true) {
|
|
108
|
+
return buildKeyboardCloseResult(KEYBOARD_CLOSE_RESULTS.FAILED);
|
|
109
|
+
}
|
|
110
|
+
await pressBack();
|
|
111
|
+
await waitForKeyboardUpdate();
|
|
112
|
+
return await getVisibility() === false
|
|
113
|
+
? buildKeyboardCloseResult(KEYBOARD_CLOSE_RESULTS.CLOSED)
|
|
114
|
+
: buildKeyboardCloseResult(KEYBOARD_CLOSE_RESULTS.FAILED);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
return buildKeyboardCloseResult(KEYBOARD_CLOSE_RESULTS.FAILED, error);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -99,6 +99,21 @@ const closeCurrentTabTool = {
|
|
|
99
99
|
additionalProperties: false,
|
|
100
100
|
},
|
|
101
101
|
};
|
|
102
|
+
const closeKeyboardTool = {
|
|
103
|
+
type: "function",
|
|
104
|
+
name: "close_keyboard",
|
|
105
|
+
description: "Close the mobile software keyboard after text entry. Call this when a full software keyboard is visible, or after typing into an iOS secure field even when its keyboard is hidden. Do not call it for a slim hardware-keyboard toolbar or suggestions alone. On cloud iOS, optionally provide x and y for a dismissal control or clearly noninteractive background that you trust; never target Return, Go, Search, Next, Continue, Send, or an app action.",
|
|
106
|
+
strict: false,
|
|
107
|
+
parameters: {
|
|
108
|
+
type: "object",
|
|
109
|
+
properties: {
|
|
110
|
+
x: { type: "number", description: "Optional horizontal coordinate in the current screenshot." },
|
|
111
|
+
y: { type: "number", description: "Optional vertical coordinate in the current screenshot." },
|
|
112
|
+
},
|
|
113
|
+
required: [],
|
|
114
|
+
additionalProperties: false,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
102
117
|
const loadmillTool = {
|
|
103
118
|
type: "function",
|
|
104
119
|
name: "loadmill",
|
|
@@ -116,7 +131,7 @@ const loadmillTool = {
|
|
|
116
131
|
const typeSensitiveTextTool = {
|
|
117
132
|
type: "function",
|
|
118
133
|
name: "type_sensitive_text",
|
|
119
|
-
description: "Type a secret value by key without exposing the value to the model. Use this only for secret keys listed in the prompt.",
|
|
134
|
+
description: "Type a secret value by key without exposing the value to the model. Use this only for secret keys listed in the prompt. On mobile, apply the prompt's keyboard-dismissal rules afterward.",
|
|
120
135
|
strict: true,
|
|
121
136
|
parameters: {
|
|
122
137
|
type: "object",
|
|
@@ -175,6 +190,9 @@ export function buildFunctionTools({ aimAssistMode = DEFAULT_AIM_ASSIST_MODE, to
|
|
|
175
190
|
if (toolCapabilities.closeCurrentTab) {
|
|
176
191
|
tools.push(closeCurrentTabTool);
|
|
177
192
|
}
|
|
193
|
+
if (toolCapabilities.closeKeyboard) {
|
|
194
|
+
tools.push(closeKeyboardTool);
|
|
195
|
+
}
|
|
178
196
|
if (toolCapabilities.loadmill) {
|
|
179
197
|
tools.push(loadmillTool);
|
|
180
198
|
}
|
|
@@ -41,6 +41,7 @@ export class DesignModeInk {
|
|
|
41
41
|
assert: true,
|
|
42
42
|
navigate: this.session?.deviceInfo?.platform === "browser",
|
|
43
43
|
closeCurrentTab: this.session?.deviceInfo?.platform === "browser",
|
|
44
|
+
closeKeyboard: this.session?.deviceInfo?.platform !== "browser",
|
|
44
45
|
loadmill: false,
|
|
45
46
|
typeSensitiveText: this.engine.hasSecrets(),
|
|
46
47
|
testDesignComplete: true,
|
|
@@ -38,6 +38,7 @@ export class DesignMode {
|
|
|
38
38
|
assert: true,
|
|
39
39
|
navigate: this.session?.deviceInfo?.platform === "browser",
|
|
40
40
|
closeCurrentTab: this.session?.deviceInfo?.platform === "browser",
|
|
41
|
+
closeKeyboard: this.session?.deviceInfo?.platform !== "browser",
|
|
41
42
|
loadmill: false,
|
|
42
43
|
typeSensitiveText: this.engine.hasSecrets(),
|
|
43
44
|
testDesignComplete: true,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CAPTURE_SOURCES } from "../core/capture-sources.js";
|
|
2
|
+
import { CUA_CONVERSATION_POLICIES, resolveConversationPolicy } from "../core/cua-models.js";
|
|
2
3
|
import { getScreenshotAsBase64, connectToDevice, getDeviceInfo, getCurrentPlatform } from "../device/connection.js";
|
|
3
4
|
import { connectToBrowser, getBrowserInfo, getCurrentPlatform as getBrowserCurrentPlatform, } from "../browser/connection.js";
|
|
4
5
|
import { normalizeBrowserInfoForSession } from "../browser/normalize.js";
|
|
@@ -22,7 +23,8 @@ const LLM_USAGE_LIMIT_ERROR_CODES = new Set([
|
|
|
22
23
|
]);
|
|
23
24
|
const LOADMILL_UPGRADE_URL = "https://www.loadmill.com/book-a-demo";
|
|
24
25
|
const LLM_REQUEST_RATE_LIMIT_ERROR_CODE = "llm_rate_limit_exceeded";
|
|
25
|
-
const
|
|
26
|
+
const OPENAI_INSUFFICIENT_QUOTA_ERROR_CODE = "insufficient_quota";
|
|
27
|
+
const DEFAULT_ASSERTION_CONTEXT = "Use the session transcript to resolve references to prior scenario steps.";
|
|
26
28
|
function isLlmUsageLimitError(err) {
|
|
27
29
|
if (!err || typeof err !== "object") {
|
|
28
30
|
return false;
|
|
@@ -35,15 +37,31 @@ function isLlmUsageLimitError(err) {
|
|
|
35
37
|
function isLlmRequestRateLimitError(err) {
|
|
36
38
|
return err && typeof err === "object" && err.code === LLM_REQUEST_RATE_LIMIT_ERROR_CODE;
|
|
37
39
|
}
|
|
40
|
+
function isOpenAiInsufficientQuotaError(err) {
|
|
41
|
+
return err && typeof err === "object" && err.code === OPENAI_INSUFFICIENT_QUOTA_ERROR_CODE;
|
|
42
|
+
}
|
|
43
|
+
function isRejectedContinuationError(err) {
|
|
44
|
+
if (!err || typeof err !== "object") {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return err.param === "previous_response_id" || [
|
|
48
|
+
"provider_continuation_mismatch",
|
|
49
|
+
"unknown_previous_response_id",
|
|
50
|
+
"invalid_previous_response_id",
|
|
51
|
+
].includes(err.code);
|
|
52
|
+
}
|
|
38
53
|
function getLlmUsageLimitMessage() {
|
|
39
54
|
return `Execution stopped: you've reached the AI usage limit for this account. Contact the Loadmill team to upgrade: ${LOADMILL_UPGRADE_URL}`;
|
|
40
55
|
}
|
|
41
56
|
function getLlmRequestRateLimitMessage() {
|
|
42
57
|
return "Execution stopped: too many AI requests are running for this account right now. Please retry shortly or reduce parallel test runs.";
|
|
43
58
|
}
|
|
59
|
+
function getOpenAiInsufficientQuotaMessage() {
|
|
60
|
+
return "Execution stopped: your OpenAI account has insufficient quota. Check your OpenAI plan and billing details.";
|
|
61
|
+
}
|
|
44
62
|
/**
|
|
45
63
|
* Execution Mode - Run test scripts line-by-line
|
|
46
|
-
*
|
|
64
|
+
* Instructions share scenario context unless the selected model requires isolation.
|
|
47
65
|
*/
|
|
48
66
|
export class ExecutionMode {
|
|
49
67
|
constructor(session, executionEngine, instructions, isHeadlessMode = false) {
|
|
@@ -77,6 +95,7 @@ export class ExecutionMode {
|
|
|
77
95
|
assert: true,
|
|
78
96
|
navigate: this.isBrowserSession,
|
|
79
97
|
closeCurrentTab: this.isBrowserSession,
|
|
98
|
+
closeKeyboard: !this.isBrowserSession,
|
|
80
99
|
loadmill: true,
|
|
81
100
|
typeSensitiveText: this.engine.hasSecrets(),
|
|
82
101
|
};
|
|
@@ -132,8 +151,9 @@ export class ExecutionMode {
|
|
|
132
151
|
payload: extra.payload
|
|
133
152
|
});
|
|
134
153
|
}
|
|
135
|
-
|
|
154
|
+
resetScenarioConversationState() {
|
|
136
155
|
this.session.updateResponseId(null);
|
|
156
|
+
this.session.clearMessages();
|
|
137
157
|
if (typeof this.session.clearTranscript === "function") {
|
|
138
158
|
this.session.clearTranscript();
|
|
139
159
|
return;
|
|
@@ -142,17 +162,53 @@ export class ExecutionMode {
|
|
|
142
162
|
this.session.transcript = [];
|
|
143
163
|
}
|
|
144
164
|
}
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
165
|
+
startInstructionConversation(context) {
|
|
166
|
+
const conversationPolicy = resolveConversationPolicy(context?.runtimeConfig?.cuaModel);
|
|
167
|
+
if (conversationPolicy === CUA_CONVERSATION_POLICIES.ISOLATED) {
|
|
168
|
+
this.session.updateResponseId(null);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
stopForOpenAiInsufficientQuota(err, context, stepContext) {
|
|
172
|
+
const addOutput = context.addOutput || printCliOutput;
|
|
173
|
+
const stopMessage = getOpenAiInsufficientQuotaMessage();
|
|
174
|
+
logger.debug('Stopping execution because the OpenAI account has insufficient quota', {
|
|
175
|
+
instruction: context?.currentInstruction,
|
|
176
|
+
message: err.message,
|
|
177
|
+
status: err.status,
|
|
178
|
+
code: err.code,
|
|
179
|
+
type: err.type,
|
|
180
|
+
});
|
|
181
|
+
this.emit(addOutput, 'error', stopMessage, context, stepContext, {
|
|
182
|
+
eventType: 'error',
|
|
183
|
+
payload: {
|
|
184
|
+
message: stopMessage,
|
|
185
|
+
status: err.status,
|
|
186
|
+
code: err.code,
|
|
187
|
+
type: err.type,
|
|
188
|
+
}
|
|
155
189
|
});
|
|
190
|
+
return { success: false, error: stopMessage, usageLimitReached: true };
|
|
191
|
+
}
|
|
192
|
+
async executeVerificationInstruction(instruction, context, stepContext = null) {
|
|
193
|
+
let toolResult;
|
|
194
|
+
try {
|
|
195
|
+
toolResult = await handleAssertionToolCall({
|
|
196
|
+
engine: this.engine,
|
|
197
|
+
callId: stepContext?.stepId ? `${stepContext.stepId}-assert` : "instruction-assert",
|
|
198
|
+
argumentsText: JSON.stringify({
|
|
199
|
+
condition: extractAssertionPrompt(instruction),
|
|
200
|
+
context: DEFAULT_ASSERTION_CONTEXT,
|
|
201
|
+
}),
|
|
202
|
+
context,
|
|
203
|
+
stepContext,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
if (isOpenAiInsufficientQuotaError(err)) {
|
|
208
|
+
return this.stopForOpenAiInsufficientQuota(err, context, stepContext);
|
|
209
|
+
}
|
|
210
|
+
throw err;
|
|
211
|
+
}
|
|
156
212
|
const assertionStatus = toolResult.summaryUpdates?.assertionStatus;
|
|
157
213
|
if (assertionStatus === "passed") {
|
|
158
214
|
this.stats.assertionsPassed++;
|
|
@@ -184,6 +240,7 @@ export class ExecutionMode {
|
|
|
184
240
|
const addOutput = runContext.addOutput || printCliOutput;
|
|
185
241
|
// Start timing
|
|
186
242
|
this.stats.startTime = Date.now();
|
|
243
|
+
this.resetScenarioConversationState();
|
|
187
244
|
for (let i = 0; i < this.instructions.length; i++) {
|
|
188
245
|
const instruction = this.instructions[i];
|
|
189
246
|
const stepContext = this.buildStepContext(i, instruction);
|
|
@@ -288,7 +345,7 @@ export class ExecutionMode {
|
|
|
288
345
|
futureInstructions: this.instructions.slice((stepContext?.instructionIndex ?? -1) + 1),
|
|
289
346
|
};
|
|
290
347
|
if (retryCount === 0) {
|
|
291
|
-
this.
|
|
348
|
+
this.startInstructionConversation(context);
|
|
292
349
|
}
|
|
293
350
|
this.session.addToTranscript(`[User] ${instruction}`);
|
|
294
351
|
this.session.addMessage("user", instruction);
|
|
@@ -364,6 +421,18 @@ export class ExecutionMode {
|
|
|
364
421
|
stack: err.stack
|
|
365
422
|
});
|
|
366
423
|
const addOutput = context.addOutput || printCliOutput;
|
|
424
|
+
if (this.session.previousResponseId && isRejectedContinuationError(err)) {
|
|
425
|
+
this.emit(addOutput, 'error', err.message, context, stepContext, {
|
|
426
|
+
eventType: 'error',
|
|
427
|
+
payload: {
|
|
428
|
+
message: err.message,
|
|
429
|
+
status: err.status,
|
|
430
|
+
code: err.code,
|
|
431
|
+
param: err.param,
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
return { success: false, error: err.message };
|
|
435
|
+
}
|
|
367
436
|
if (isLlmRequestRateLimitError(err)) {
|
|
368
437
|
const stopMessage = getLlmRequestRateLimitMessage();
|
|
369
438
|
logger.debug('Stopping execution because an LLM request rate limit was reached', {
|
|
@@ -384,6 +453,9 @@ export class ExecutionMode {
|
|
|
384
453
|
});
|
|
385
454
|
return { success: false, error: stopMessage, usageLimitReached: true };
|
|
386
455
|
}
|
|
456
|
+
if (isOpenAiInsufficientQuotaError(err)) {
|
|
457
|
+
return this.stopForOpenAiInsufficientQuota(err, context, stepContext);
|
|
458
|
+
}
|
|
387
459
|
if (isLlmUsageLimitError(err)) {
|
|
388
460
|
const stopMessage = getLlmUsageLimitMessage();
|
|
389
461
|
logger.debug('Stopping execution because an LLM usage limit was reached', {
|
|
@@ -448,8 +520,8 @@ export class ExecutionMode {
|
|
|
448
520
|
}
|
|
449
521
|
else {
|
|
450
522
|
const platform = this.getCurrentPlatform();
|
|
451
|
-
const
|
|
452
|
-
const deviceId = await this.connectToDevice(
|
|
523
|
+
const deviceTarget = this.session.deviceId || this.session.deviceName || undefined;
|
|
524
|
+
const deviceId = await this.connectToDevice(deviceTarget, platform);
|
|
453
525
|
const deviceInfo = await this.getDeviceInfo(deviceId);
|
|
454
526
|
this.session.deviceId = deviceId;
|
|
455
527
|
this.session.deviceInfo = deviceInfo;
|
|
@@ -30,6 +30,11 @@ ${customSection}
|
|
|
30
30
|
function isBrowserTarget(deviceInfo = {}) {
|
|
31
31
|
return typeof deviceInfo.platform === "string" && deviceInfo.platform.trim().toLowerCase() === "browser";
|
|
32
32
|
}
|
|
33
|
+
function isCloudIosTarget(deviceInfo = {}) {
|
|
34
|
+
const platform = deviceInfo.platform?.trim().toLowerCase();
|
|
35
|
+
const source = deviceInfo.source?.trim().toLowerCase() || "local";
|
|
36
|
+
return platform === "ios" && source !== "local";
|
|
37
|
+
}
|
|
33
38
|
function describeControlledTarget(deviceInfo = {}) {
|
|
34
39
|
const platform = typeof deviceInfo.platform === "string" ? deviceInfo.platform.trim().toLowerCase() : "";
|
|
35
40
|
const deviceName = typeof deviceInfo.device_name === "string" ? deviceInfo.device_name.trim() : "";
|
|
@@ -38,7 +43,8 @@ function describeControlledTarget(deviceInfo = {}) {
|
|
|
38
43
|
return browserName ? `a browser session (${browserName})` : "a browser session";
|
|
39
44
|
}
|
|
40
45
|
if (platform === "ios") {
|
|
41
|
-
|
|
46
|
+
const target = isCloudIosTarget(deviceInfo) ? "an iOS device" : "an iOS simulator";
|
|
47
|
+
return deviceName ? `${target} (${deviceName})` : target;
|
|
42
48
|
}
|
|
43
49
|
if (platform === "android") {
|
|
44
50
|
return deviceName ? `an Android device (${deviceName})` : "an Android device";
|
|
@@ -150,6 +156,12 @@ function buildBrowserBaseSystemPrompt(deviceInfo, options = {}) {
|
|
|
150
156
|
}
|
|
151
157
|
function buildMobileBaseSystemPrompt(deviceInfo, options = {}) {
|
|
152
158
|
const aimAssistMode = typeof options.aimAssistMode === "string" ? options.aimAssistMode : "always";
|
|
159
|
+
const cloudIosKeyboardGuidance = isCloudIosTarget(deviceInfo) ? `
|
|
160
|
+
- On this cloud iOS device, \`close_keyboard\` can optionally tap a point you choose from the current screenshot.
|
|
161
|
+
- If you can identify a dedicated keyboard dismissal control or clearly noninteractive background that you trust, pass its \`x\` and \`y\` coordinates to \`close_keyboard\`.
|
|
162
|
+
- Never use Return, Go, Search, Next, Continue, Send, or an app action as the dismissal point because it may submit or mutate the app.
|
|
163
|
+
- If dismissal is required but no safe point is identifiable, call \`close_keyboard\` without coordinates. Do not guess.
|
|
164
|
+
- Choose coordinates directly from the post-type screenshot; do not call \`preview_click\` between \`type\` and \`close_keyboard\`.` : "";
|
|
153
165
|
return `
|
|
154
166
|
You are controlling ${describeControlledTarget(deviceInfo)} in a sandboxed testing environment.
|
|
155
167
|
Follow the user's instructions to interact with the device.
|
|
@@ -164,18 +176,31 @@ function buildMobileBaseSystemPrompt(deviceInfo, options = {}) {
|
|
|
164
176
|
- Reason carefully about the approximate pixel position.
|
|
165
177
|
${buildAimAssistGuidance("mobile", aimAssistMode)}
|
|
166
178
|
|
|
167
|
-
Available actions: click, scroll, type, keypress, wait, screenshot.
|
|
179
|
+
Available actions: click, scroll, type, keypress, wait, screenshot. You also have a \`close_keyboard\` tool.
|
|
168
180
|
|
|
169
181
|
CRITICAL - Mobile Input Constraints:
|
|
170
182
|
- This is a mobile device, not a desktop. Do NOT use desktop keyboard shortcuts or modifier chords.
|
|
171
183
|
- NEVER emit key combinations such as CTRL+A, CMD+A, CTRL+C, CTRL+V, ALT+TAB, SHIFT+ENTER, or similar shortcuts.
|
|
172
184
|
- Use 'keypress' only for a single mobile-safe key when absolutely necessary.
|
|
173
185
|
- To replace text, tap into the field and type the desired value. If correction is needed, use mobile-safe deletion only.
|
|
186
|
+
- Every \`type\` action must be the final computer action in its batch.
|
|
187
|
+
- After \`type\`, inspect its post-action screenshot before deciding whether dismissal is needed.
|
|
188
|
+
- If a full software keyboard is visibly covering part of the screen, your next action must be \`close_keyboard\`.
|
|
189
|
+
- Do not finish the current instruction while a required \`close_keyboard\` action is still pending.
|
|
190
|
+
- A slim hardware-keyboard toolbar, autocomplete suggestions, or a focused field without a full software keyboard does not require \`close_keyboard\`.
|
|
191
|
+
- Treat \`type_sensitive_text\` as text entry too and apply the same dismissal decision after it returns.
|
|
192
|
+
- Whenever dismissal is required, do not click, scroll, inspect, submit, type again, issue a keypress, or call another tool before \`close_keyboard\`.
|
|
193
|
+
- Consecutive DELETE or BACKSPACE keypresses may be used for correction without closing the keyboard between them. After typing the replacement, call \`close_keyboard\`.
|
|
194
|
+
- Password and other secure fields may appear empty in screenshots even after text was entered successfully.
|
|
195
|
+
- On iOS, a secure field may also hide an open keyboard from Appium screenshots. After typing into an iOS secure field, call \`close_keyboard\` even when the keyboard is not visible.
|
|
196
|
+
- Do not type into a secure field again solely because its characters or bullets are not visible.
|
|
197
|
+
- Retry secure-field input only when there is separate evidence of failure, such as another field changing or the app rejecting the value.
|
|
198
|
+
${cloudIosKeyboardGuidance}
|
|
174
199
|
- Prefer tapping visible controls over hardware key events.
|
|
175
200
|
- Prefer on-screen navigation controls such as menus, tabs, drawer items, back arrows, close buttons, and explicit logout buttons over keypress actions.
|
|
176
201
|
- Do NOT use Back or ESC for normal app navigation when a reliable on-screen control is visible.
|
|
177
202
|
- Avoid using Back or ESC from a main or root screen, because it may leave the app.
|
|
178
|
-
-
|
|
203
|
+
- Never use Back or ESC to dismiss the software keyboard; use \`close_keyboard\`.
|
|
179
204
|
- Treat keypress actions as a fallback for limited cases only, such as a clearly needed single mobile-safe key or dismissing transient UI when no better visible control exists.
|
|
180
205
|
|
|
181
206
|
CRITICAL - Automatic Timing:
|
|
@@ -185,7 +210,7 @@ function buildMobileBaseSystemPrompt(deviceInfo, options = {}) {
|
|
|
185
210
|
|
|
186
211
|
CRITICAL - Mutating Actions:
|
|
187
212
|
- Mutating actions change app state. Examples: submit, create, save, confirm, approve, reject, login, logout, send, place order, initiate transfer
|
|
188
|
-
- Before tapping a mutating action button,
|
|
213
|
+
- Before tapping a mutating action button, call \`close_keyboard\` first when the dismissal rules above require it
|
|
189
214
|
- After performing a mutating action once, do NOT repeat the same mutating action unless the UI clearly shows the first attempt failed or had no effect
|
|
190
215
|
- Treat visible state change as success. Examples: form fields clear, submit button returns to normal, status changes, list refreshes, new row appears, success message appears, screen changes
|
|
191
216
|
- For form submissions specifically, if the relevant fields clear and the action button returns to its normal idle state, treat that as success even if the new row or confirmation is not obvious yet
|