@loadmill/droid-cua 2.24.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 CHANGED
@@ -136,6 +136,11 @@ npm install -g @loadmill/droid-cua
136
136
 
137
137
  Install the bundled Agent Skill for your coding agent:
138
138
 
139
+ In the desktop app, choose **Help → Install Droid CUA Skill**, then select your
140
+ coding agent from the submenu. The app installs the skill globally for your user account.
141
+
142
+ Alternatively, install it from the CLI:
143
+
139
144
  ```sh
140
145
  # Global installation (default)
141
146
  droid-cua skill install cursor
@@ -86,6 +86,7 @@ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionP
86
86
  toolCapabilities: {
87
87
  assert: true,
88
88
  navigate: isBrowserTarget,
89
+ closeKeyboard: !isBrowserTarget,
89
90
  loadmill: true,
90
91
  },
91
92
  secretsByKey: secrets?.secretsByKey || {},
@@ -174,6 +175,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
174
175
  aimAssistMode: headlessConfig.aimAssistMode,
175
176
  testDataContext,
176
177
  availableSecretKeys: secrets?.availableSecretKeys || [],
178
+ cuaModel: runtimeContext.config.cuaModel,
177
179
  });
178
180
  session.setSystemPrompt(executionPrompt);
179
181
  const screenshotRecorder = headlessDebug.createExecutionScreenshotRecorder({
@@ -67,8 +67,7 @@ export async function handleRun(args, session, context) {
67
67
  if (context.setAgentWorking) {
68
68
  context.setAgentWorking(true, 'Executing test...');
69
69
  }
70
- // Reset session state to prevent context leak from design mode
71
- // Each test instruction should execute in isolation
70
+ // Reset session state to prevent context leak from design mode.
72
71
  session.updateResponseId(undefined);
73
72
  session.clearMessages();
74
73
  const appContext = await resolveExecutionAppContext({ contextPath: context.appContextPath });
@@ -89,7 +88,10 @@ export async function handleRun(args, session, context) {
89
88
  }
90
89
  // Set execution mode system prompt (replaces any design mode prompt)
91
90
  const availableSecretKeys = context.engine.getAvailableSecretKeys();
92
- const executionPrompt = buildExecutionModePrompt(session.deviceInfo, {}, appContextText, { availableSecretKeys });
91
+ const executionPrompt = buildExecutionModePrompt(session.deviceInfo, {}, appContextText, {
92
+ availableSecretKeys,
93
+ cuaModel: context.runtimeConfig?.cuaModel,
94
+ });
93
95
  session.setSystemPrompt(executionPrompt);
94
96
  // Create execution mode
95
97
  const executionMode = new ExecutionMode(session, context.engine, instructions);
@@ -1,6 +1,10 @@
1
1
  export const DEFAULT_OPENAI_CUA_MODEL = "gpt-5.4";
2
2
  export const DEFAULT_LOADMILL_CUA_MODEL = "loadmill-smart";
3
3
  export const DEFAULT_CUA_MODEL = DEFAULT_LOADMILL_CUA_MODEL;
4
+ export const CUA_CONVERSATION_POLICIES = Object.freeze({
5
+ CHAINED: "chained",
6
+ ISOLATED: "isolated",
7
+ });
4
8
  export const CUA_MODEL_OPTIONS = Object.freeze([
5
9
  Object.freeze({
6
10
  id: "loadmill-smart",
@@ -70,3 +74,8 @@ export function resolveDefaultCuaModelForProvider(providerMode) {
70
74
  ? DEFAULT_LOADMILL_CUA_MODEL
71
75
  : DEFAULT_OPENAI_CUA_MODEL;
72
76
  }
77
+ export function resolveConversationPolicy(cuaModel) {
78
+ return cuaModel === "loadmill-beacon" || cuaModel === "beacon"
79
+ ? CUA_CONVERSATION_POLICIES.ISOLATED
80
+ : CUA_CONVERSATION_POLICIES.CHAINED;
81
+ }
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { getScreenshotAsBase64, getCurrentPlatform } from "../device/connection.js";
3
3
  import { closeCurrentTab as closeCurrentBrowserTab, getBrowserInfo, navigateToUrl as navigateBrowserToUrl } from "../browser/connection.js";
4
- import { handleModelAction } from "../device/actions.js";
4
+ import { closeKeyboard, handleModelAction } from "../device/actions.js";
5
5
  import { sendCUARequest } from "../device/openai.js";
6
6
  import { executeLoadmillInstruction } from "../device/loadmill.js";
7
7
  import { emitDesktopDebug } from "../utils/desktop-debug.js";
@@ -14,6 +14,7 @@ import { ScreenshotStore } from "./screenshot-store.js";
14
14
  import { handlePreviewClick } from "./tool-handlers/preview-click.js";
15
15
  import { handleNavigateToolCall } from "./tool-handlers/navigate.js";
16
16
  import { handleCloseCurrentTabToolCall } from "./tool-handlers/close-current-tab.js";
17
+ import { handleCloseKeyboardToolCall } from "./tool-handlers/close-keyboard.js";
17
18
  import { handleAssertionToolCall } from "./tool-handlers/assert.js";
18
19
  import { handleLoadmillToolCall } from "./tool-handlers/loadmill.js";
19
20
  import { handleTestDesignCompleteToolCall } from "./tool-handlers/test-design-complete.js";
@@ -176,6 +177,8 @@ function formatActionForRecovery(action) {
176
177
  }
177
178
  case "scroll":
178
179
  return "Scrolled";
180
+ case "close_keyboard":
181
+ return "Closed the keyboard";
179
182
  default:
180
183
  return null;
181
184
  }
@@ -240,6 +243,7 @@ export class ExecutionEngine {
240
243
  this.stepDelayMs = options.stepDelayMs ?? getConfiguredStepDelayMs();
241
244
  this.getScreenshotAsBase64 = options.getScreenshotAsBase64 || getScreenshotAsBase64;
242
245
  this.handleModelAction = options.handleModelAction || handleModelAction;
246
+ this.closeKeyboard = options.closeKeyboard || closeKeyboard;
243
247
  this.sendCUARequest = options.sendCUARequest || sendCUARequest;
244
248
  this.reviseTestScript = options.reviseTestScript || null;
245
249
  this.getCurrentPlatform = options.getCurrentPlatform || getCurrentPlatform;
@@ -323,6 +327,8 @@ export class ExecutionEngine {
323
327
  return await handleNavigateToolCall(handlerArgs);
324
328
  case "close_current_tab":
325
329
  return await handleCloseCurrentTabToolCall(handlerArgs);
330
+ case "close_keyboard":
331
+ return await handleCloseKeyboardToolCall(handlerArgs);
326
332
  case "loadmill":
327
333
  return await handleLoadmillToolCall(handlerArgs);
328
334
  case "type_sensitive_text":
@@ -45,7 +45,7 @@ export class Session {
45
45
  this.messages = [{ role: "system", content: prompt }];
46
46
  }
47
47
  /**
48
- * Add a line to the transcript (for error recovery and logging)
48
+ * Add a line to the scenario transcript for assertions, recovery, and logging.
49
49
  */
50
50
  addToTranscript(line) {
51
51
  this.transcript.push(line);
@@ -57,7 +57,7 @@ export class Session {
57
57
  return this.transcript.join("\n");
58
58
  }
59
59
  /**
60
- * Clear the per-instruction transcript.
60
+ * Clear the scenario transcript at a run boundary.
61
61
  */
62
62
  clearTranscript() {
63
63
  this.transcript = [];
@@ -0,0 +1,103 @@
1
+ import { CAPTURE_SOURCES } from "../capture-sources.js";
2
+ import { KEYBOARD_CLOSE_RESULTS, buildKeyboardCloseResult } from "../../device/keyboard.js";
3
+ import { printCliOutput } from "../../utils/console-output.js";
4
+ import { emitDesktopDebug } from "../../utils/desktop-debug.js";
5
+ import { buildFunctionCallOutputItem, buildToolOutputContent, buildTextToolOutputItem, isDesignContext, parseJsonArguments, } from "./utils.js";
6
+ function parseDismissalPoint(argumentsText, deviceInfo = {}) {
7
+ const parsed = parseJsonArguments(argumentsText || "{}");
8
+ if (!parsed.ok || !parsed.value || typeof parsed.value !== "object" || Array.isArray(parsed.value)) {
9
+ return { ok: false, message: "Invalid close_keyboard arguments: expected a JSON object." };
10
+ }
11
+ const hasX = Object.prototype.hasOwnProperty.call(parsed.value, "x");
12
+ const hasY = Object.prototype.hasOwnProperty.call(parsed.value, "y");
13
+ if (!hasX && !hasY) {
14
+ return { ok: true, value: null };
15
+ }
16
+ if (!hasX || !hasY || !Number.isFinite(parsed.value.x) || !Number.isFinite(parsed.value.y)) {
17
+ return { ok: false, message: "Invalid close_keyboard arguments: x and y must both be finite numbers." };
18
+ }
19
+ const point = { x: parsed.value.x, y: parsed.value.y };
20
+ const width = Number(deviceInfo.scaled_width);
21
+ const height = Number(deviceInfo.scaled_height);
22
+ // We can validate coordinate shape and bounds, but only the model can judge
23
+ // whether a cloud-iOS point is a safe dismissal target in the current UI.
24
+ if (point.x < 0 || point.y < 0 ||
25
+ (Number.isFinite(width) && point.x >= width) ||
26
+ (Number.isFinite(height) && point.y >= height)) {
27
+ return { ok: false, message: "Invalid close_keyboard arguments: the dismissal point is outside the current screenshot." };
28
+ }
29
+ return { ok: true, value: point };
30
+ }
31
+ export async function handleCloseKeyboardToolCall({ engine, callId, argumentsText, context = null, stepContext = null }) {
32
+ const session = engine.session;
33
+ if (session?.targetKind === "browser" || session?.deviceInfo?.platform === "browser") {
34
+ return {
35
+ kind: "continue",
36
+ outputItem: buildFunctionCallOutputItem(callId, buildToolOutputContent({ text: "close_keyboard is only supported for mobile runs." })),
37
+ };
38
+ }
39
+ const parsedPoint = parseDismissalPoint(argumentsText, session.deviceInfo);
40
+ if (!parsedPoint.ok) {
41
+ return {
42
+ kind: "continue",
43
+ outputItem: buildTextToolOutputItem(callId, parsedPoint.message),
44
+ };
45
+ }
46
+ const dismissalPoint = parsedPoint.value;
47
+ let result;
48
+ try {
49
+ result = await engine.closeKeyboard(session.deviceId, {
50
+ dismissalPoint,
51
+ scale: session.deviceInfo?.scale || 1,
52
+ });
53
+ }
54
+ catch (error) {
55
+ result = buildKeyboardCloseResult(KEYBOARD_CLOSE_RESULTS.FAILED, error);
56
+ }
57
+ const addOutput = context?.addOutput || printCliOutput;
58
+ const outputText = result.status === KEYBOARD_CLOSE_RESULTS.FAILED
59
+ ? `${result.message}. The preceding text entry was unaffected.`
60
+ : result.message;
61
+ const scope = isDesignContext(context) ? "design" : "execution";
62
+ const ids = {
63
+ ...(scope === "design" ? { sessionId: context?.sessionId } : { runId: context?.runId }),
64
+ ...(stepContext?.stepId ? { stepId: stepContext.stepId } : {}),
65
+ ...(Number.isInteger(stepContext?.instructionIndex)
66
+ ? { instructionIndex: stepContext.instructionIndex }
67
+ : {}),
68
+ };
69
+ const payload = {
70
+ platform: session.deviceInfo?.platform,
71
+ toolName: "close_keyboard",
72
+ callId,
73
+ status: result.status,
74
+ failed: result.status === KEYBOARD_CLOSE_RESULTS.FAILED,
75
+ ...(dismissalPoint ? { dismissalPoint } : {}),
76
+ ...(result.error ? { error: result.error } : {}),
77
+ };
78
+ emitDesktopDebug("tool_call", scope, ids, payload);
79
+ addOutput({
80
+ type: "action",
81
+ text: result.message,
82
+ eventType: "tool_call",
83
+ runId: context?.runId,
84
+ stepId: stepContext?.stepId,
85
+ instructionIndex: stepContext?.instructionIndex,
86
+ payload,
87
+ });
88
+ session.addToTranscript(`[Close keyboard] ${outputText}`);
89
+ const screenshotBase64 = await engine.getScreenshotAsBase64(session.deviceId, session.deviceInfo);
90
+ const currentScreenshot = await engine.recordScreenshot(screenshotBase64, {
91
+ runId: context?.runId,
92
+ sessionId: context?.sessionId,
93
+ stepId: stepContext?.stepId,
94
+ instructionIndex: stepContext?.instructionIndex,
95
+ captureSource: CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION,
96
+ });
97
+ return {
98
+ kind: "continue",
99
+ outputItem: buildFunctionCallOutputItem(callId, buildToolOutputContent({ text: outputText, imageBase64: screenshotBase64 })),
100
+ currentScreenshot,
101
+ executedActions: [{ type: "close_keyboard", ...(dismissalPoint || {}) }],
102
+ };
103
+ }
@@ -20,3 +20,10 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
20
20
  const backend = getDeviceBackend(platform);
21
21
  return backend.handleModelAction(deviceId, action, scale, context);
22
22
  }
23
+ export async function closeKeyboard(deviceId, options = {}) {
24
+ const platform = getCurrentPlatform();
25
+ if (!platform) {
26
+ throw new Error("No platform set. Call connectToDevice first.");
27
+ }
28
+ return getDeviceBackend(platform).closeKeyboard(deviceId, options);
29
+ }
@@ -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
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_AIM_ASSIST_MODE } from "../core/aim-assist.js";
2
+ import { CUA_CONVERSATION_POLICIES, resolveConversationPolicy } from "../core/cua-models.js";
2
3
  import { normalizeSecretKeys } from "../core/secrets.js";
3
4
  import { appendCustomSections, buildBaseSystemPrompt, isBrowserTarget } from "./base.js";
4
5
  export function buildAppContextSection(text) {
@@ -43,10 +44,14 @@ export function buildExecutionModePrompt(deviceInfo, customInstructions = {}, ap
43
44
  const testDataContextSection = buildTestDataContextSection(options.testDataContext);
44
45
  const secretKeysContextSection = buildSecretKeysContextSection(options.availableSecretKeys);
45
46
  const browserTarget = isBrowserTarget(deviceInfo);
47
+ const conversationPolicy = resolveConversationPolicy(options.cuaModel);
48
+ const instructionContextRule = conversationPolicy === CUA_CONVERSATION_POLICIES.ISOLATED
49
+ ? "This model starts each instruction without prior model history. Execute only the current instruction and stop when it is complete."
50
+ : "Instructions are sequential parts of one scenario. Use prior instructions and outcomes to resolve references in the current instruction, but execute only the current instruction. Preserve expected values established earlier; never replace them with the observed state merely to produce a pass.";
46
51
  const prompt = `${basePrompt}
47
52
 
48
53
  EXECUTION MODE - Critical Behavior:
49
- You are executing test script commands one at a time. This is NOT a conversation.
54
+ You are executing test script commands one at a time. This is an automated test run, not a chat with the user.
50
55
 
51
56
  CRITICAL RULES:
52
57
  - DO NOT generate conversational text or narration
@@ -70,14 +75,20 @@ Your process:
70
75
  - Before calling \`assert\`, make sure the current screen contains the evidence needed to validate the condition. If needed, perform only minimal preparation first, such as taking a screenshot, waiting briefly, closing the keyboard, scrolling within the relevant view, or opening the relevant menu.
71
76
  - Once you call \`assert\`, validation is read-only. The assertion tool will not click, type, wait, scroll, request screenshots, or change the device state.
72
77
  - Use \`navigate\` for direct browser navigation instead of trying to manipulate the address bar
78
+ ${browserTarget ? "" : ` - Every \`type\` action must end its computer-action batch so you can inspect the post-type screenshot
79
+ - Call \`close_keyboard\` next when a full software keyboard is visible, or after typing into an iOS secure field even when its keyboard is hidden
80
+ - Do not complete the current instruction while that required \`close_keyboard\` action is pending
81
+ - Otherwise, including when only a slim hardware-keyboard toolbar or suggestions are visible, continue without \`close_keyboard\`
82
+ - Apply the same dismissal decision after \`type_sensitive_text\`
83
+ `}
73
84
  - Use the \`loadmill\` tool whenever the instruction explicitly references running or using a Loadmill flow. Pass the requested flow name/intent and parameters as the command; the tool will search Loadmill, select the best matching flow, run it, and wait for completion. Do not handle Loadmill by typing in the app UI.
74
85
  3. Execute the required actions
75
- 4. Before tapping a mutating action, dismiss the keyboard if it is open and not needed
86
+ 4. ${browserTarget ? "Before tapping a mutating action, dismiss the keyboard if it is open and not needed" : "Use `close_keyboard` before any later interaction when the post-type dismissal rules require it"}
76
87
  5. After a mutating action, inspect the resulting screen for success cues such as cleared fields, reset buttons, changed status, refreshed content, or navigation
77
88
  6. Stop as soon as success is visible
78
89
  7. Stop immediately - no commentary, no questions
79
90
 
80
- Each instruction is independent. Do not reference previous instructions or ask about next steps.
91
+ ${instructionContextRule}
81
92
  ${appContextSection ? `\n\n${appContextSection}` : ""}${testDataContextSection ? `\n\n${testDataContextSection}` : ""}${secretKeysContextSection ? `\n\n${secretKeysContextSection}` : ""}`;
82
93
  return appendCustomSections(prompt, [
83
94
  { title: "Base Prompt Instructions", text: customInstructions.basePromptInstructions },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loadmill/droid-cua",
3
- "version": "2.24.0",
3
+ "version": "2.25.0",
4
4
  "description": "AI-powered Android testing agent using OpenAI's computer-use model and ADB",
5
5
  "main": "build/index.js",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: droid-cua
3
- description: Write, run, monitor, and troubleshoot Droid CUA mobile and web tests. Use when an agent needs to onboard @loadmill/droid-cua, configure Loadmill authentication, discover Android devices or AVDs, prepare iOS simulators or local browsers, author or revise .dcua tests and project context, run tests locally or on supported cloud devices, analyze reports and debug artifacts, distinguish test failures from product bugs, or perform guarded test healing.
3
+ description: Operate Droid CUA `.dcua` test projects. Use when the user explicitly names Droid CUA or @loadmill/droid-cua, or asks to author, configure, run, or diagnose `.dcua` tests with the Droid CUA CLI or desktop app. Do not use for general web browsing, website development, browser automation, generic mobile work, or non-Droid test tasks.
4
4
  ---
5
5
 
6
6
  # Droid CUA
@@ -13,13 +13,16 @@ Use the existing `droid-cua` CLI and repository files. Do not invent commands, f
13
13
  2. Determine the requested platform and target from the task or project. Ask the user only when the choice is unclear.
14
14
  3. Check authentication and environment readiness before the first run. Read [project-and-auth.md](references/project-and-auth.md) for project boundaries and token handling. Read [onboarding-and-troubleshooting.md](references/onboarding-and-troubleshooting.md) when setup, dependency, device, simulator, browser, or connection work is needed.
15
15
  4. Author or update the smallest useful test. Read [authoring-tests.md](references/authoring-tests.md) before changing `.dcua`, context, test data, secrets, or config.
16
- 5. Run the narrowest relevant test and monitor it to completion. Read [running-and-healing.md](references/running-and-healing.md) for commands, targets, artifacts, failure classification, and guarded healing.
17
- 6. Report the outcome, evidence paths, changes made, and whether the failure belongs to the test, environment, or product.
16
+ 5. Before execution, present the proposed tests and target to the user, resolve any questions, and get their confirmation. Read [running-and-healing.md](references/running-and-healing.md) for the required pre-run check.
17
+ 6. Run the confirmed, narrowest relevant test and monitor it to completion. Keep the user oriented during the run by surfacing meaningful live screenshots, not just terminal output.
18
+ 7. Report the outcome, evidence paths, changes made, and whether the failure belongs to the test, environment, or product.
18
19
 
19
20
  ## Safety
20
21
 
21
22
  - Never print, open, or echo secret values. Check only whether required keys are configured.
22
23
  - Never search for credentials outside the active test project or source another project's `.env` file.
24
+ - Use Loadmill for every run unless the user explicitly asks to use their own OpenAI API key. A discovered OpenAI key is never permission to switch providers.
25
+ - Never use credentials from another project, a skill, an agent configuration, or the user's home directory. Configure credentials only in the active test project's `.env` (or an explicitly supplied CI secret).
23
26
  - Get approval before installing dependencies, running `sudo`, accepting licenses, or changing system configuration.
24
27
  - During setup, do not create or edit user tests. Rerun only the failed readiness check, then resume the original task.
25
28
  - During healing, edit only `.dcua`, `context.md`, `test-data.md`, or Droid CUA config. Never modify application code under the guise of healing a test.
@@ -1,6 +1,6 @@
1
1
  interface:
2
2
  display_name: "Droid Cua"
3
- short_description: "Write, run, monitor, and troubleshoot Droid CUA tests"
3
+ short_description: "Operate Droid CUA .dcua test projects"
4
4
  icon_small: "./assets/droid-cua-app-icon.png"
5
5
  icon_large: "./assets/droid-cua-app-icon.png"
6
6
  brand_color: "#1895F7"
@@ -15,24 +15,27 @@ Keep `.env` and `.secrets` out of version control. Use `git check-ignore -q .env
15
15
 
16
16
  ## Loadmill authentication
17
17
 
18
- Loadmill is the default LLM provider. Before a run, check only for presence:
18
+ Loadmill is the required default LLM provider. Use it unless the user explicitly asks to use their own OpenAI API key. Never switch to OpenAI because a Loadmill token is missing or because an OpenAI key happens to be available.
19
+
20
+ Before a run, check only for the presence of a Loadmill token in the active test project's `.env` or explicitly supplied CI secret:
19
21
 
20
22
  ```sh
21
- test -n "${LOADMILL_API_TOKEN:-}" || grep -Eq '^LOADMILL_API_TOKEN=.+$' .env 2>/dev/null
23
+ grep -Eq '^LOADMILL_API_TOKEN=.+$' .env 2>/dev/null
22
24
  ```
23
25
 
24
- Run this check from the active test project's working directory. Do not print the variable, run `env`, open `.env`, or ask the user to paste the token. Do not search parent directories, sibling repositories, the user's home directory, or the Droid CUA source repository for credentials.
26
+ Run this check from the active test project's working directory. Do not print the variable, run `env`, open `.env`, or inspect inherited environment variables. Do not search parent directories, sibling repositories, the user's home directory, agent skills/configuration, or the Droid CUA source repository for credentials.
27
+
28
+ If it is missing, explain that Droid CUA uses a Loadmill token by default and guide the user to:
29
+
30
+ 1. Sign in, then open [Loadmill Settings → Security](https://app.loadmill.com/app/user/settings/security).
31
+ 2. Create an API token with a description such as `Droid CUA CLI`.
32
+ 3. Copy it when shown; Loadmill shows it once.
25
33
 
26
- If it is missing, instruct the user to:
34
+ Offer exactly these two choices, then wait for the user's selection:
27
35
 
28
- 1. Sign in at <https://app.loadmill.com>.
29
- 2. Open **Settings → Security**.
30
- 3. Create an API token with a description such as `Droid CUA CLI`.
31
- 4. Copy it when shown; Loadmill shows it once.
32
- 5. Ensure `.env` is ignored by version control before storing a project token; add `.env` to `.gitignore` if needed.
33
- 6. Save it as `LOADMILL_API_TOKEN=...` in the active test project's `.env`, a shell environment variable, or a CI secret.
34
- 7. Confirm when configuration is complete, without sharing the value.
36
+ 1. **Use it once:** The user provides the token through the agent's secret-input mechanism and asks to use it for this run only. Pass it only to that command through a secret-safe mechanism; do not write it to `.env`, a shell profile, or any other file.
37
+ 2. **Save it for this project:** The user provides the token through the agent's secret-input mechanism and approves saving it. Create the active project's `.env` if needed or add/update only its `LOADMILL_API_TOKEN` entry. First ensure `.env` is ignored by version control; add `.env` to the active project's `.gitignore` when needed.
35
38
 
36
- Do not create or populate the token value for the user. Do not copy a token from another file or source another project's `.env`. Sourcing an unrelated file can import development endpoints, proxy settings, and unrelated secrets. When the active project's `.env` contains the key, run `droid-cua` normally from that project; the CLI loads that file automatically.
39
+ Use a secret-safe mechanism that does not print or expose the value in commands, logs, or responses. Verify only that the key is present. Never copy a token from another file, a shell profile, another project, an agent skill/configuration, or any other location. Do not inspect inherited environment variables to discover a token. When the active project's `.env` contains the key, run `droid-cua` normally from that project; the CLI loads that file automatically.
37
40
 
38
- If the user explicitly selects direct OpenAI access, use `--llm-provider openai` with one of the supported OpenAI CUA models: `gpt-5.6-terra`, `gpt-5.6-luna`, or `gpt-5.4`. Check only for the presence of `OPENAI_API_KEY`. If it is missing, instruct the user to create an API key in their OpenAI account and save it in the active project's gitignored `.env`, their shell environment, or a CI secret. Apply the same secret-handling rules: never request, print, inspect, copy, or source the key. Do not silently switch providers to bypass missing Loadmill authentication.
41
+ Only when the user explicitly selects direct OpenAI access, use `--llm-provider openai` with one of the supported OpenAI CUA models: `gpt-5.6-terra`, `gpt-5.6-luna`, or `gpt-5.4`. Check only the active project's `.env` for `OPENAI_API_KEY`; do not use one found elsewhere. If it is missing, guide the user to create and configure their own key in the same project using the same secret-safe process. Do not silently switch providers to bypass missing Loadmill authentication.
@@ -14,7 +14,7 @@ Prefer a single test before a directory or scenario run. Preserve an existing pr
14
14
 
15
15
  Both Loadmill models require `LOADMILL_API_TOKEN`. Do not switch between Smart and Beacon silently; preserve the project's model or explain the speed/cost tradeoff and follow the user's choice. If the user chooses direct OpenAI access instead, require `OPENAI_API_KEY` and use `--llm-provider openai` with one of `gpt-5.6-terra`, `gpt-5.6-luna`, or `gpt-5.4`. Do not mix a provider with another provider's model, silently switch providers, or override an existing project choice without explaining it.
16
16
 
17
- Include `--report reports/<test-name>.html` for normal test runs unless the user opts out. Prefer an explicit filename over a directory so the result is easy to locate. Add `--debug` when diagnosis may be needed.
17
+ Include `--report reports/<test-name>.html` for normal test runs unless the user opts out. Prefer an explicit filename over a directory so the result is easy to locate. Add `--debug` for every agent-observed run: it writes the screenshots needed for live progress updates as well as diagnosis.
18
18
 
19
19
  ```sh
20
20
  # Attached Android device or running emulator
@@ -41,9 +41,49 @@ droid-cua --llm-provider openai --cua-model gpt-5.4 --avd adb:<serial> --instruc
41
41
 
42
42
  Use `.ipa` and `--platform ios` for supported cloud iOS runs. Preserve existing config flags rather than replacing them with defaults.
43
43
 
44
+ ## Confirm the run with the user
45
+
46
+ Before starting any test command, send the user a short, plain-language run plan and wait for explicit confirmation. Treat this as a product conversation, not a command preview. Do not infer that a request to run a test also confirms an automatically selected device, browser, or batch of tests.
47
+
48
+ The run plan should briefly state:
49
+
50
+ - What behavior or flow will be checked, and whether one test or several will run. Mention the test filename only when it helps identify the test.
51
+ - Where it will run: the chosen device, simulator, or browser. If more than one viable target is available, present the human-readable choices and ask the user to choose.
52
+ - Any user-visible consequence, such as launching an emulator, resetting a fresh browser session, or starting a metered cloud-device session.
53
+ - That the agent will share progress screenshots and provide a report afterward.
54
+
55
+ Do not include CLI flags, commands, provider or model names, credential/key names, config/context filenames, or internal log/report paths unless the user asks or one changes a decision they need to make. Do not paste the test source into the confirmation.
56
+
57
+ Ask the user about every unresolved or ambiguous choice before running. If the plan is complete, ask for a clear go-ahead (for example, `Ready to run this?`). Start only after they confirm. If they change the tests, target, or other material run detail, show the revised plan and confirm it again.
58
+
59
+ For example: `I’m ready to run the Chrome smoke test in a fresh local Chrome window. It will check that Chrome opens and then closes. I’ll share screenshots as it progresses and provide a report at the end. Ready to run this?`
60
+
44
61
  ## Monitor and collect evidence
45
62
 
46
- Remain attached until the command exits and cloud cleanup finishes. Read terminal progress as it appears; do not treat a long model step as success or failure before completion.
63
+ Remain attached until the command exits and cloud cleanup finishes. Run the command through an interactive/pollable terminal mechanism so progress and new artifacts can be observed before it exits. Read terminal progress as it appears; do not treat a long model step as success or failure before completion.
64
+
65
+ ## Show live visual progress
66
+
67
+ Use the screenshots already captured by `--debug` to make the run understandable while it is happening. This is a required user-facing behavior, not merely an artifact to inspect after the run. After the CLI prints `Debug logging enabled: <logs/execution-...>.jsonl`, new PNGs appear in that log file's sibling directory. Keep the CLI running in a pollable/background session and poll that directory while the test is running.
68
+
69
+ For agents whose chat UI renders local Markdown images (including Cursor and Codex), send a separate progress message containing an absolute-path image, for example:
70
+
71
+ ```md
72
+ Step 2/4: add the item to the cart
73
+
74
+ ![Live test progress](/absolute/path/to/logs/execution-.../0004_....png)
75
+ ```
76
+
77
+ Do not put this Markdown only in a terminal command, tool argument, or hidden reasoning: it must be an actual user-visible chat update. Do not replace it by opening the final report.
78
+
79
+ - After every completed executable instruction, send exactly one short progress update with that instruction and its screenshot (for example, `Step 2/4 complete: add the item to the cart`). An image path alone is not a visual update.
80
+ - Screenshot filenames include `instruction-001`, `instruction-002`, and so on. Select the latest safe PNG for the completed instruction; prefer an end-of-turn or assertion screenshot over an input or click-preview frame. This connects each user-visible update to the matching script step even when the model took several actions to finish it.
81
+ - Also show a frame immediately when the run is retrying, reconnecting, or fails. Do not stream every model action or repeat visually identical frames.
82
+ - Do not show `preview_click` / aim-assist annotations unless they help explain an issue.
83
+ - If the latest frame visibly exposes a secret or other sensitive user data, do not render it; provide a text-only progress update and wait for a safe frame.
84
+ - Do not delay the test to collect screenshots. If no new frame exists during a long model call, report the current stage in text and keep polling.
85
+
86
+ When the command completes, show the final safe screenshot in addition to the result summary, then provide a clickable link to the HTML report (or its absolute path when local links are unavailable). The report is the final artifact; the interim frames are progress updates, not a substitute for it.
47
87
 
48
88
  - Exit `0`: all requested tests passed.
49
89
  - Exit `1`: configuration, connection, execution, or assertion failure.