@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.
Files changed (36) hide show
  1. package/README.md +30 -0
  2. package/build/index.js +12 -0
  3. package/build/src/cli/headless-runner.js +2 -0
  4. package/build/src/cli/skill-command.js +240 -0
  5. package/build/src/commands/help.js +2 -1
  6. package/build/src/commands/run.js +5 -3
  7. package/build/src/core/cua-models.js +29 -9
  8. package/build/src/core/droid-cua-run-usage.js +16 -7
  9. package/build/src/core/execution-engine.js +7 -1
  10. package/build/src/core/runtime-config.js +2 -5
  11. package/build/src/core/session.js +2 -2
  12. package/build/src/core/tool-handlers/close-keyboard.js +103 -0
  13. package/build/src/device/actions.js +7 -0
  14. package/build/src/device/android/actions.js +16 -0
  15. package/build/src/device/cloud/actions.js +27 -0
  16. package/build/src/device/cloud/cloud-appium-client.js +15 -0
  17. package/build/src/device/connection.js +5 -2
  18. package/build/src/device/factory.js +3 -0
  19. package/build/src/device/interface.js +4 -0
  20. package/build/src/device/ios/actions.js +13 -0
  21. package/build/src/device/ios/appium-client.js +16 -0
  22. package/build/src/device/keyboard.js +119 -0
  23. package/build/src/device/openai.js +19 -1
  24. package/build/src/modes/design-mode-ink.js +1 -0
  25. package/build/src/modes/design-mode.js +1 -0
  26. package/build/src/modes/execution-mode.js +88 -16
  27. package/build/src/prompts/base.js +29 -4
  28. package/build/src/prompts/execution.js +14 -3
  29. package/package.json +2 -1
  30. package/skill/droid-cua/SKILL.md +39 -0
  31. package/skill/droid-cua/agents/openai.yaml +6 -0
  32. package/skill/droid-cua/assets/droid-cua-app-icon.png +0 -0
  33. package/skill/droid-cua/references/authoring-tests.md +39 -0
  34. package/skill/droid-cua/references/onboarding-and-troubleshooting.md +74 -0
  35. package/skill/droid-cua/references/project-and-auth.md +41 -0
  36. 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 currentBackend.getDeviceInfo(deviceId, options);
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 DEFAULT_ASSERTION_CONTEXT = "No additional prior context is needed; validate directly from the current screen.";
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
- * Each instruction is executed in isolation (messages cleared after each turn)
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
- resetInstructionConversationState() {
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
- async executeVerificationInstruction(instruction, context, stepContext = null) {
146
- const toolResult = await handleAssertionToolCall({
147
- engine: this.engine,
148
- callId: stepContext?.stepId ? `${stepContext.stepId}-assert` : "instruction-assert",
149
- argumentsText: JSON.stringify({
150
- condition: extractAssertionPrompt(instruction),
151
- context: DEFAULT_ASSERTION_CONTEXT,
152
- }),
153
- context,
154
- stepContext,
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.resetInstructionConversationState();
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 deviceName = this.session.deviceName || undefined;
452
- const deviceId = await this.connectToDevice(deviceName, platform);
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
- return deviceName ? `an iOS simulator (${deviceName})` : "an iOS device";
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
- - Exception: if the software keyboard is open and blocking the next needed control, Back or ESC may be used to dismiss the keyboard before continuing.
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, dismiss the software keyboard first when it is open and not required for the tap
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