@loadmill/droid-cua 2.24.0 → 2.26.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 (46) hide show
  1. package/README.md +13 -5
  2. package/build/index.js +206 -2
  3. package/build/src/cli/cloud-status-line.js +58 -26
  4. package/build/src/cli/headless-runner.js +2 -1
  5. package/build/src/cli/headless-target.js +63 -7
  6. package/build/src/cli/loadmill-cloud-credentials.js +11 -0
  7. package/build/src/cli/loadmill-cloud-usage.js +18 -0
  8. package/build/src/commands/help.js +2 -0
  9. package/build/src/commands/run.js +5 -3
  10. package/build/src/core/cua-models.js +16 -0
  11. package/build/src/core/droid-cua-run-usage.js +8 -2
  12. package/build/src/core/execution-engine.js +8 -1
  13. package/build/src/core/runtime-config.js +8 -3
  14. package/build/src/core/session.js +2 -2
  15. package/build/src/core/tool-handlers/close-keyboard.js +103 -0
  16. package/build/src/device/actions.js +7 -0
  17. package/build/src/device/android/actions.js +16 -0
  18. package/build/src/device/cloud/actions.js +27 -0
  19. package/build/src/device/cloud/aws-device-farm/upload-cache.js +1 -1
  20. package/build/src/device/cloud/cloud-appium-client.js +15 -0
  21. package/build/src/device/cloud/connection.js +18 -6
  22. package/build/src/device/cloud/loadmill-cloud/adapter.js +537 -0
  23. package/build/src/device/cloud/loadmill-cloud/artifact-downloader.js +191 -0
  24. package/build/src/device/cloud/loadmill-cloud/control-plane-client.js +423 -0
  25. package/build/src/device/cloud/loadmill-cloud/errors.js +16 -0
  26. package/build/src/device/cloud/loadmill-cloud/format-bytes.js +18 -0
  27. package/build/src/device/cloud/loadmill-cloud/timing.js +14 -0
  28. package/build/src/device/cloud/progress.js +29 -0
  29. package/build/src/device/cloud/registry.js +2 -1
  30. package/build/src/device/connection.js +8 -2
  31. package/build/src/device/factory.js +3 -0
  32. package/build/src/device/interface.js +4 -0
  33. package/build/src/device/ios/actions.js +13 -0
  34. package/build/src/device/ios/appium-client.js +16 -0
  35. package/build/src/device/keyboard.js +119 -0
  36. package/build/src/device/openai.js +20 -1
  37. package/build/src/modes/design-mode-ink.js +1 -0
  38. package/build/src/modes/design-mode.js +1 -0
  39. package/build/src/modes/execution-mode.js +88 -16
  40. package/build/src/prompts/base.js +29 -4
  41. package/build/src/prompts/execution.js +14 -3
  42. package/package.json +1 -1
  43. package/skill/droid-cua/SKILL.md +6 -3
  44. package/skill/droid-cua/agents/openai.yaml +1 -1
  45. package/skill/droid-cua/references/project-and-auth.md +16 -13
  46. package/skill/droid-cua/references/running-and-healing.md +45 -5
@@ -7,6 +7,15 @@
7
7
  * previous phase (if any) just ended
8
8
  * - "note": persistent information worth keeping after the line clears
9
9
  * (e.g. billed device-minutes)
10
+ * - "phase-close-text": overrides the persisted "✔ <name> (Nms)" line that
11
+ * will be printed when the current phase closes, with an
12
+ * arbitrary caller-supplied string (e.g. richer poll/download
13
+ * summaries) instead of the default elapsed-ms text.
14
+ * - "print": a verbatim persistent line (no ℹ/✔ prefix) that first closes
15
+ * any still-open phase, so a final summary can never race
16
+ * ahead of / glue onto the active spinner or an unflushed
17
+ * phase-close line. Use this instead of raw console.log while
18
+ * a status line may be active.
10
19
  * With no listener installed, reports are dropped.
11
20
  */
12
21
  let listener = null;
@@ -22,3 +31,23 @@ export function reportCloudPhase(name) {
22
31
  export function reportCloudNote(message) {
23
32
  listener?.({ kind: "note", message });
24
33
  }
34
+ export function reportCloudPhaseCloseText(text) {
35
+ listener?.({ kind: "phase-close-text", message: text });
36
+ }
37
+ // Forces the currently-open phase (if any) to flush its "✔ ..." close line
38
+ // immediately, instead of waiting for the next reportCloudPhase() call to
39
+ // lazily trigger it. Needed when a note must appear strictly *after* a
40
+ // phase's close line rather than racing ahead of it (the close line only
41
+ // carries its final text via reportCloudPhaseCloseText, which by itself
42
+ // doesn't print anything until something forces the phase closed).
43
+ export function reportCloudPhaseFlush() {
44
+ listener?.({ kind: "phase-flush" });
45
+ }
46
+ export function reportCloudPrint(text) {
47
+ if (listener) {
48
+ listener({ kind: "print", message: text });
49
+ }
50
+ else {
51
+ console.log(text);
52
+ }
53
+ }
@@ -1,7 +1,8 @@
1
1
  import { browserStackAdapter } from "./browserstack/adapter.js";
2
2
  import { lambdaTestAdapter } from "./lambdatest/adapter.js";
3
3
  import { awsDeviceFarmAdapter } from "./aws-device-farm/adapter.js";
4
- const availableAdapters = [browserStackAdapter, lambdaTestAdapter, awsDeviceFarmAdapter];
4
+ import { loadmillCloudAdapter } from "./loadmill-cloud/adapter.js";
5
+ const availableAdapters = [browserStackAdapter, lambdaTestAdapter, awsDeviceFarmAdapter, loadmillCloudAdapter];
5
6
  export function listCloudProviderAdapters() {
6
7
  return availableAdapters;
7
8
  }
@@ -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)
@@ -23,6 +23,9 @@ export async function connectToDevice(deviceNameOrOptions, platform = null, opti
23
23
  log(`Platform: ${detectedPlatform}`);
24
24
  return currentBackend.connectToDevice(cloudOptions, options);
25
25
  }
26
+ if (getCurrentSource() !== "local" && currentBackend) {
27
+ throw new Error("A cloud device session is active; disconnect it before connecting to a local device.");
28
+ }
26
29
  const detectedPlatform = platform || detectPlatform(deviceNameOrOptions);
27
30
  setCurrentSource("local");
28
31
  setCurrentPlatform(detectedPlatform);
@@ -40,7 +43,10 @@ export async function getDeviceInfo(deviceId, options = {}) {
40
43
  if (!currentBackend) {
41
44
  throw new Error("Not connected to a device. Call connectToDevice first.");
42
45
  }
43
- return currentBackend.getDeviceInfo(deviceId, options);
46
+ return {
47
+ ...await currentBackend.getDeviceInfo(deviceId, options),
48
+ source: getCurrentSource(),
49
+ };
44
50
  }
45
51
  /**
46
52
  * 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
+ }
@@ -9,6 +9,7 @@ const cuaDebugTracer = new CuaDebugTracer(logger);
9
9
  const LOADMILL_DEVICE_METADATA_MODELS = new Set([
10
10
  ...LOADMILL_CUA_MODELS,
11
11
  LOADMILL_MODEL_ALIASES.SMART,
12
+ LOADMILL_MODEL_ALIASES.PULSE,
12
13
  LOADMILL_MODEL_ALIASES.BEACON,
13
14
  ]);
14
15
  function resolveOpenAiBaseUrl(client) {
@@ -99,6 +100,21 @@ const closeCurrentTabTool = {
99
100
  additionalProperties: false,
100
101
  },
101
102
  };
103
+ const closeKeyboardTool = {
104
+ type: "function",
105
+ name: "close_keyboard",
106
+ 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.",
107
+ strict: false,
108
+ parameters: {
109
+ type: "object",
110
+ properties: {
111
+ x: { type: "number", description: "Optional horizontal coordinate in the current screenshot." },
112
+ y: { type: "number", description: "Optional vertical coordinate in the current screenshot." },
113
+ },
114
+ required: [],
115
+ additionalProperties: false,
116
+ },
117
+ };
102
118
  const loadmillTool = {
103
119
  type: "function",
104
120
  name: "loadmill",
@@ -116,7 +132,7 @@ const loadmillTool = {
116
132
  const typeSensitiveTextTool = {
117
133
  type: "function",
118
134
  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.",
135
+ 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
136
  strict: true,
121
137
  parameters: {
122
138
  type: "object",
@@ -175,6 +191,9 @@ export function buildFunctionTools({ aimAssistMode = DEFAULT_AIM_ASSIST_MODE, to
175
191
  if (toolCapabilities.closeCurrentTab) {
176
192
  tools.push(closeCurrentTabTool);
177
193
  }
194
+ if (toolCapabilities.closeKeyboard) {
195
+ tools.push(closeKeyboardTool);
196
+ }
178
197
  if (toolCapabilities.loadmill) {
179
198
  tools.push(loadmillTool);
180
199
  }
@@ -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