@loadmill/droid-cua 2.22.0 → 2.23.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.
@@ -98,6 +98,48 @@ export function runSummaryData(event) {
98
98
  error,
99
99
  };
100
100
  }
101
+ export function stepCompletionData(eventOrText) {
102
+ const text = typeof eventOrText === "string"
103
+ ? eventOrText
104
+ : typeof eventOrText?.text === "string"
105
+ ? eventOrText.text
106
+ : null;
107
+ if (!text)
108
+ return null;
109
+ const normalized = text.replace(/\r/g, "").trim();
110
+ const prefix = "Step complete:";
111
+ if (!normalized.startsWith(prefix))
112
+ return null;
113
+ const rest = normalized.slice(prefix.length);
114
+ const reasonLabel = /\sReason:\s*/.exec(rest);
115
+ if (!reasonLabel)
116
+ return null;
117
+ const instruction = rest.slice(0, reasonLabel.index).trim();
118
+ const reasonStart = reasonLabel.index + reasonLabel[0].length;
119
+ const afterReason = rest.slice(reasonStart);
120
+ const visibleStateLabel = /\sVisible state:\s*/.exec(afterReason);
121
+ if (!visibleStateLabel)
122
+ return null;
123
+ const reason = afterReason.slice(0, visibleStateLabel.index).trim();
124
+ const visibleState = afterReason.slice(visibleStateLabel.index + visibleStateLabel[0].length).trim();
125
+ if (!instruction || !reason || !visibleState)
126
+ return null;
127
+ return { instruction, reason, visibleState };
128
+ }
129
+ export function stepCompletionReasonTooltip(stepCompletion, maxLength = 140) {
130
+ if (!stepCompletion?.reason)
131
+ return null;
132
+ const firstSentence = stepCompletion.reason.match(/^.*?[.!?](?:\s|$)/)?.[0]?.trim();
133
+ const text = (firstSentence || stepCompletion.reason).replace(/\s+/g, " ").trim();
134
+ if (!text)
135
+ return null;
136
+ if (text.length <= maxLength)
137
+ return `Reason: ${text}`;
138
+ const truncated = text.slice(0, Math.max(0, maxLength - 1)).trimEnd();
139
+ const wordBoundary = truncated.lastIndexOf(" ");
140
+ const cleanTruncated = wordBoundary > maxLength * 0.6 ? truncated.slice(0, wordBoundary).trimEnd() : truncated;
141
+ return `Reason: ${cleanTruncated}…`;
142
+ }
101
143
  export function formatDuration(ms) {
102
144
  if (ms === null || ms === undefined)
103
145
  return "n/a";
@@ -2,13 +2,12 @@ import OpenAI from "openai";
2
2
  import { normalizeOptionalAutomationUrl } from "../browser/playwright/url.js";
3
3
  import { readContextOptimizationEnv, normalizeContextOptimizationEnabled, normalizeContextOptimizationThreshold, } from "./context-optimization.js";
4
4
  import { DEFAULT_OPENAI_CUA_MODEL, isLoadmillCuaModel, resolveCuaModel, resolveCuaModelProviderMode, } from "./cua-models.js";
5
- import { DEFAULT_FARA_BASE_URL, DEFAULT_FARA_MODEL, resolveCuaBackend, } from "./cua-backends.js";
6
5
  import { normalizeStepDelayMs } from "../utils/step-delay.js";
7
6
  export const LOADMILL_MODEL_ALIASES = Object.freeze({
8
7
  DEFAULT_AGENT_MODEL: "default-agent-model",
9
8
  FAST: "fast",
10
9
  SMART: "smart",
11
- COMPUTER_USE: "computer-use",
10
+ BEACON: "beacon",
12
11
  });
13
12
  const LOADMILL_MODEL_ALIAS_SET = new Set(Object.values(LOADMILL_MODEL_ALIASES));
14
13
  export const LLM_PROVIDER_MODES = Object.freeze(["loadmill", "openai"]);
@@ -89,19 +88,19 @@ export function inferLlmProviderModeFromCredentials(credentials = {}) {
89
88
  }
90
89
  return DEFAULT_LLM_PROVIDER_MODE;
91
90
  }
92
- export function resolveGatewayCuaModelAlias({ cuaBackend, cuaModel } = {}) {
93
- if (resolveCuaBackend(cuaBackend) === "fara") {
94
- return LOADMILL_MODEL_ALIASES.COMPUTER_USE;
95
- }
91
+ export function resolveGatewayCuaModelAlias({ cuaModel } = {}) {
96
92
  const normalizedCuaModel = resolveCuaModel(cuaModel);
93
+ if (normalizedCuaModel === "loadmill-beacon") {
94
+ return LOADMILL_MODEL_ALIASES.BEACON;
95
+ }
97
96
  return normalizedCuaModel === "gpt-5.4-mini"
98
97
  ? LOADMILL_MODEL_ALIASES.FAST
99
98
  : LOADMILL_MODEL_ALIASES.SMART;
100
99
  }
101
- export function resolveEffectiveCuaModel({ llmProviderMode = null, cuaBackend = null, cuaModel = null, } = {}) {
100
+ export function resolveEffectiveCuaModel({ llmProviderMode = null, cuaModel = null, } = {}) {
102
101
  const providerMode = resolveLlmProviderMode(llmProviderMode);
103
102
  if (providerMode === "loadmill") {
104
- return resolveGatewayCuaModelAlias({ cuaBackend, cuaModel });
103
+ return resolveGatewayCuaModelAlias({ cuaModel });
105
104
  }
106
105
  return resolveCuaModelProviderMode(cuaModel) === "openai"
107
106
  ? resolveCuaModel(cuaModel)
@@ -115,17 +114,14 @@ export function resolveEffectiveHelperModel({ llmProviderMode = null, cuaModel =
115
114
  ? resolveCuaModel(cuaModel)
116
115
  : DEFAULT_OPENAI_CUA_MODEL;
117
116
  }
118
- export function resolveRuntimeCredentials({ openAiApiKey = null, faraApiKey = null, faraBaseUrl = null, faraModel = null, loadmillApiToken = null, loadmillBaseUrl = null, } = {}) {
117
+ export function resolveRuntimeCredentials({ openAiApiKey = null, loadmillApiToken = null, loadmillBaseUrl = null, } = {}) {
119
118
  return {
120
119
  openAiApiKey: trimNullableString(openAiApiKey),
121
- faraApiKey: trimNullableString(faraApiKey),
122
- faraBaseUrl: trimNullableString(faraBaseUrl) || DEFAULT_FARA_BASE_URL,
123
- faraModel: trimNullableString(faraModel) || DEFAULT_FARA_MODEL,
124
120
  loadmillApiToken: trimNullableString(loadmillApiToken),
125
121
  loadmillBaseUrl: normalizeLoadmillSiteBaseUrl(loadmillBaseUrl),
126
122
  };
127
123
  }
128
- export function resolveRuntimeConfig({ llmProviderMode = null, cuaBackend = null, cuaModel = null, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
124
+ export function resolveRuntimeConfig({ llmProviderMode = null, cuaModel = null, stepDelayMs = undefined, contextOptimizationEnabled = undefined, contextOptimizationThreshold = undefined, promptCustomizations = null, appContext = null, } = {}) {
129
125
  const normalizedCuaModel = typeof cuaModel === "string" && LOADMILL_MODEL_ALIAS_SET.has(cuaModel)
130
126
  ? cuaModel
131
127
  : resolveCuaModel(cuaModel);
@@ -134,7 +130,6 @@ export function resolveRuntimeConfig({ llmProviderMode = null, cuaBackend = null
134
130
  llmProviderMode: llmProviderMode == null && isLoadmillCuaModel(normalizedCuaModel)
135
131
  ? "loadmill"
136
132
  : normalizedProviderMode,
137
- cuaBackend: resolveCuaBackend(cuaBackend),
138
133
  cuaModel: normalizedCuaModel,
139
134
  stepDelayMs: normalizeStepDelayMs(Number(stepDelayMs)),
140
135
  contextOptimizationEnabled: normalizeContextOptimizationEnabled(contextOptimizationEnabled),
@@ -161,11 +156,6 @@ export function resolveRuntimeConfigFromEnv(env = process.env, overrides = {}) {
161
156
  const contextOptimization = readContextOptimizationEnv(env);
162
157
  return resolveRuntimeConfig({
163
158
  llmProviderMode: env.DROID_CUA_LLM_PROVIDER,
164
- // Only the explicit backend flag should influence CUA model routing.
165
- // Legacy screenshot-provider env vars must not silently remap Loadmill
166
- // model aliases like "smart" to backend-specific aliases like
167
- // "computer-use".
168
- cuaBackend: env.DROID_CUA_BACKEND,
169
159
  cuaModel: env.OPENAI_CUA_MODEL,
170
160
  stepDelayMs: env.DROID_CUA_STEP_DELAY_MS,
171
161
  contextOptimizationEnabled: contextOptimization.enabled,
@@ -176,9 +166,6 @@ export function resolveRuntimeConfigFromEnv(env = process.env, overrides = {}) {
176
166
  export function resolveRuntimeCredentialsFromEnv(env = process.env, overrides = {}) {
177
167
  return resolveRuntimeCredentials({
178
168
  openAiApiKey: env.OPENAI_API_KEY,
179
- faraApiKey: env.DROID_CUA_FARA_API_KEY,
180
- faraBaseUrl: env.DROID_CUA_FARA_BASE_URL,
181
- faraModel: env.DROID_CUA_FARA_MODEL,
182
169
  loadmillApiToken: env.LOADMILL_API_TOKEN,
183
170
  loadmillBaseUrl: env.LOADMILL_BASE_URL,
184
171
  ...overrides,
@@ -278,19 +265,27 @@ export function createLlmClient({ loadmillApiToken, loadmillBaseUrl, clientName
278
265
  },
279
266
  });
280
267
  }
281
- export function createFaraClient({ apiKey, baseUrl }) {
282
- return new OpenAI({
283
- apiKey: trimNullableString(apiKey) || "",
284
- baseURL: trimNullableString(baseUrl) || DEFAULT_FARA_BASE_URL,
285
- });
286
- }
287
- export function createResolvedLlmClient({ llmProviderMode = null, credentials = null, clientName = "desktop", maxRetries = 1, } = {}) {
268
+ export function assertRuntimeCredentialsForProvider({ llmProviderMode = null, credentials = null, } = {}) {
288
269
  const resolvedCredentials = resolveRuntimeCredentials(credentials);
289
270
  const resolvedProviderMode = resolveLlmProviderMode(llmProviderMode);
290
271
  if (resolvedProviderMode === "loadmill") {
291
272
  if (!hasLoadmillLlmAccess(resolvedCredentials)) {
292
273
  throw new Error(missingCredentialErrorMessage(resolvedProviderMode));
293
274
  }
275
+ return;
276
+ }
277
+ if (!hasOpenAiLlmAccess(resolvedCredentials)) {
278
+ throw new Error(missingCredentialErrorMessage(resolvedProviderMode));
279
+ }
280
+ }
281
+ export function createResolvedLlmClient({ llmProviderMode = null, credentials = null, clientName = "desktop", maxRetries = 1, } = {}) {
282
+ const resolvedCredentials = resolveRuntimeCredentials(credentials);
283
+ const resolvedProviderMode = resolveLlmProviderMode(llmProviderMode);
284
+ assertRuntimeCredentialsForProvider({
285
+ llmProviderMode: resolvedProviderMode,
286
+ credentials: resolvedCredentials,
287
+ });
288
+ if (resolvedProviderMode === "loadmill") {
294
289
  return createLlmClient({
295
290
  loadmillApiToken: resolvedCredentials.loadmillApiToken,
296
291
  loadmillBaseUrl: resolvedCredentials.loadmillBaseUrl,
@@ -298,9 +293,6 @@ export function createResolvedLlmClient({ llmProviderMode = null, credentials =
298
293
  maxRetries,
299
294
  });
300
295
  }
301
- if (!hasOpenAiLlmAccess(resolvedCredentials)) {
302
- throw new Error(missingCredentialErrorMessage(resolvedProviderMode));
303
- }
304
296
  return createOpenAiClient(resolvedCredentials.openAiApiKey);
305
297
  }
306
298
  export function createRunContext({ config, credentials }) {
@@ -312,7 +304,6 @@ export function createRunContext({ config, credentials }) {
312
304
  llmProviderMode: resolvedProviderMode,
313
305
  cuaModel: resolveEffectiveCuaModel({
314
306
  llmProviderMode: resolvedProviderMode,
315
- cuaBackend: resolvedConfig.cuaBackend,
316
307
  cuaModel: resolvedConfig.cuaModel,
317
308
  }),
318
309
  };
@@ -328,12 +319,6 @@ export function createRunContext({ config, credentials }) {
328
319
  credentials: resolvedCredentials,
329
320
  clientName: "desktop",
330
321
  }),
331
- fara: resolvedConfig.cuaBackend === "fara"
332
- ? createFaraClient({
333
- apiKey: resolvedCredentials.faraApiKey,
334
- baseUrl: resolvedCredentials.faraBaseUrl,
335
- })
336
- : null,
337
322
  },
338
323
  };
339
324
  }
@@ -56,6 +56,12 @@ export class Session {
56
56
  getTranscriptText() {
57
57
  return this.transcript.join("\n");
58
58
  }
59
+ /**
60
+ * Clear the per-instruction transcript.
61
+ */
62
+ clearTranscript() {
63
+ this.transcript = [];
64
+ }
59
65
  /**
60
66
  * Update the previous response ID for chaining
61
67
  */
@@ -29,6 +29,9 @@ export async function handleLoadmillToolCall({ engine, callId, argumentsText, co
29
29
  return {
30
30
  kind: "close_turn",
31
31
  outputItem: buildTextToolOutputItem(callId, "Loadmill command completed successfully."),
32
+ nextTurnConfig: {
33
+ attachFreshScreenshotOnContinuation: true,
34
+ },
32
35
  summaryUpdates: result.runtimeParameterContext?.note
33
36
  ? { preserveSessionMessages: true }
34
37
  : undefined,
@@ -88,6 +88,7 @@ export async function handleTestDesignCompleteToolCall({ engine, callId, argumen
88
88
  testDesignComplete: false,
89
89
  },
90
90
  includeComputerToolOverride: false,
91
+ continuationMode: "fresh",
91
92
  },
92
93
  };
93
94
  }
@@ -58,5 +58,6 @@ export async function handleTypeSensitiveTextToolCall({ engine, callId, argument
58
58
  imageBase64: screenshotBase64,
59
59
  })),
60
60
  currentScreenshot,
61
+ executedActions: [action],
61
62
  };
62
63
  }
@@ -51,8 +51,12 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
51
51
  case "scroll":
52
52
  const { scrollX, scrollY, startX, startY, endX, endY, hasAnchor } = resolveScrollGesture(action, {
53
53
  scale,
54
- fallbackStartX: 500,
55
- fallbackStartY: 500
54
+ fallbackStartX: Number.isFinite(context?.fallbackScrollStartX) ? Math.round(context.fallbackScrollStartX) : 500,
55
+ fallbackStartY: Number.isFinite(context?.fallbackScrollStartY) ? Math.round(context.fallbackScrollStartY) : 500,
56
+ minX: 0,
57
+ maxX: Number.isFinite(context?.deviceWidth) ? Math.round(context.deviceWidth) - 1 : undefined,
58
+ minY: 0,
59
+ maxY: Number.isFinite(context?.deviceHeight) ? Math.round(context.deviceHeight) - 1 : undefined
56
60
  });
57
61
  addOutput({
58
62
  type: 'action',
@@ -21,6 +21,18 @@ async function listConnectedDevices() {
21
21
  })
22
22
  .filter(entry => entry.id.length > 0);
23
23
  }
24
+ export function getAndroidConnectErrorMessage({ state, avdName } = {}) {
25
+ if (state === "offline" && avdName) {
26
+ return `Emulator "${avdName}" is running but offline. Restart adb or cold boot the emulator, then retry.`;
27
+ }
28
+ if (state === "offline") {
29
+ return "Device is offline. Reconnect device or restart adb.";
30
+ }
31
+ if (state === "unauthorized") {
32
+ return "Authorize this computer on the device and retry.";
33
+ }
34
+ return "Selected device is no longer connected. Refresh and select again.";
35
+ }
24
36
  function wait(ms) {
25
37
  return new Promise((resolve) => setTimeout(resolve, ms));
26
38
  }
@@ -35,6 +47,14 @@ function parseTargetToken(rawTarget) {
35
47
  }
36
48
  return { kind: "legacy", value: rawTarget };
37
49
  }
50
+ function normalizeAvdName(value) {
51
+ const parts = value
52
+ .split(/\r?\n/)
53
+ .map((line) => line.trim())
54
+ .filter(Boolean)
55
+ .filter((line) => line.toUpperCase() !== "OK");
56
+ return parts[0] || "";
57
+ }
38
58
  async function getEmulatorAvdName(serial) {
39
59
  const candidates = [
40
60
  ["-s", serial, "emu", "avd", "name"],
@@ -45,7 +65,7 @@ async function getEmulatorAvdName(serial) {
45
65
  for (const args of candidates) {
46
66
  try {
47
67
  const { stdout } = await execAdb(args);
48
- const value = stdout.trim();
68
+ const value = normalizeAvdName(stdout);
49
69
  if (value)
50
70
  return value;
51
71
  }
@@ -74,16 +94,23 @@ function emulatorLaunchTimeoutError() {
74
94
  return new Error(`Emulator launch timed out after ${AVD_READY_TIMEOUT_SECONDS}s. Check AVD health and retry.`);
75
95
  }
76
96
  async function findRunningEmulatorByAvdName(avdName) {
97
+ const match = await findConnectedEmulatorByAvdName(avdName, { includeOffline: false });
98
+ return match?.state === "device" ? match.id : null;
99
+ }
100
+ async function findConnectedEmulatorByAvdName(avdName, options = {}) {
77
101
  const devices = await listConnectedDevices();
78
- const readyEmulators = devices.filter((entry) => entry.state === "device" && entry.id.startsWith("emulator-"));
79
- for (const entry of readyEmulators) {
102
+ const emulatorEntries = devices.filter((entry) => entry.id.startsWith("emulator-") && (options.includeOffline || entry.state === "device"));
103
+ for (const entry of emulatorEntries) {
80
104
  const runningAvdName = await getEmulatorAvdName(entry.id);
81
105
  if (runningAvdName === avdName) {
82
- return entry.id;
106
+ return entry;
83
107
  }
84
108
  }
85
109
  return null;
86
110
  }
111
+ function offlineEmulatorError(avdName) {
112
+ return new Error(getAndroidConnectErrorMessage({ state: "offline", avdName }));
113
+ }
87
114
  async function launchAndWaitForAvd(avdName, timeoutMs = AVD_READY_TIMEOUT_MS, options = {}) {
88
115
  const log = options.log || console.log;
89
116
  let avdExists = false;
@@ -99,6 +126,10 @@ async function launchAndWaitForAvd(avdName, timeoutMs = AVD_READY_TIMEOUT_MS, op
99
126
  if (!avdExists) {
100
127
  throw new Error(`AVD "${avdName}" was not found. Create it in Android Studio and retry.`);
101
128
  }
129
+ const offlineMatch = await findConnectedEmulatorByAvdName(avdName, { includeOffline: true });
130
+ if (offlineMatch?.state === "offline") {
131
+ throw offlineEmulatorError(avdName);
132
+ }
102
133
  const baseline = await listConnectedDevices();
103
134
  const baselineReadyEmulators = new Set(baseline.filter((entry) => entry.state === "device" && entry.id.startsWith("emulator-")).map((entry) => entry.id));
104
135
  log(`Launching Android emulator "${avdName}"...`);
@@ -128,12 +159,16 @@ export async function connectToDevice(deviceTargetId, options = {}) {
128
159
  const readyDevices = devices.filter((entry) => entry.state === "device");
129
160
  if (parsedTarget.kind === "auto") {
130
161
  if (readyDevices.length === 0) {
162
+ const offlineEmulator = devices.find((entry) => entry.state === "offline" && entry.id.startsWith("emulator-"));
163
+ const message = offlineEmulator
164
+ ? "Android emulator is running but offline. Restart adb or cold boot the emulator, then retry."
165
+ : "No ready Android devices found. Connect a device (or start an emulator), authorize adb, and retry.";
131
166
  emitDesktopDebug("device.error", "device", {}, {
132
167
  platform: "android",
133
168
  operation: "connect",
134
- message: "No ready Android devices detected."
169
+ message
135
170
  });
136
- throw new Error("No ready Android devices found. Connect a device (or start an emulator), authorize adb, and retry.");
171
+ throw new Error(message);
137
172
  }
138
173
  const selected = readyDevices[0].id;
139
174
  log(`Using connected Android device: ${selected}`);
@@ -142,15 +177,18 @@ export async function connectToDevice(deviceTargetId, options = {}) {
142
177
  }
143
178
  if (parsedTarget.kind === "avd") {
144
179
  const deadline = Date.now() + AVD_READY_TIMEOUT_MS;
145
- const alreadyRunning = await findRunningEmulatorByAvdName(parsedTarget.avdName);
146
- if (alreadyRunning) {
147
- const ready = await waitForWindowServiceReady(alreadyRunning, remainingTimeoutMs(deadline));
180
+ const existingEmulator = await findConnectedEmulatorByAvdName(parsedTarget.avdName, { includeOffline: true });
181
+ if (existingEmulator?.state === "offline") {
182
+ throw offlineEmulatorError(parsedTarget.avdName);
183
+ }
184
+ if (existingEmulator?.state === "device") {
185
+ const ready = await waitForWindowServiceReady(existingEmulator.id, remainingTimeoutMs(deadline));
148
186
  if (!ready) {
149
187
  throw emulatorLaunchTimeoutError();
150
188
  }
151
- log(`Using already-running emulator for AVD "${parsedTarget.avdName}": ${alreadyRunning}`);
152
- emitDesktopDebug("device.connect", "device", {}, { platform: "android", stage: "success", deviceId: alreadyRunning, reused: true });
153
- return alreadyRunning;
189
+ log(`Using already-running emulator for AVD "${parsedTarget.avdName}": ${existingEmulator.id}`);
190
+ emitDesktopDebug("device.connect", "device", {}, { platform: "android", stage: "success", deviceId: existingEmulator.id, reused: true });
191
+ return existingEmulator.id;
154
192
  }
155
193
  const launchedId = await launchAndWaitForAvd(parsedTarget.avdName, remainingTimeoutMs(deadline), { log });
156
194
  const ready = await waitForWindowServiceReady(launchedId, remainingTimeoutMs(deadline));
@@ -179,19 +217,16 @@ export async function connectToDevice(deviceTargetId, options = {}) {
179
217
  return targetId;
180
218
  }
181
219
  if (parsedTarget.kind === "legacy" && targetId) {
182
- const runningLegacyAvd = await findRunningEmulatorByAvdName(targetId);
183
- if (runningLegacyAvd) {
184
- emitDesktopDebug("device.connect", "device", {}, { platform: "android", stage: "success", deviceId: runningLegacyAvd, reused: true });
185
- return runningLegacyAvd;
220
+ const matchedLegacyAvd = await findConnectedEmulatorByAvdName(targetId, { includeOffline: true });
221
+ if (matchedLegacyAvd?.state === "offline") {
222
+ throw offlineEmulatorError(targetId);
223
+ }
224
+ if (matchedLegacyAvd?.state === "device") {
225
+ emitDesktopDebug("device.connect", "device", {}, { platform: "android", stage: "success", deviceId: matchedLegacyAvd.id, reused: true });
226
+ return matchedLegacyAvd.id;
186
227
  }
187
228
  }
188
- let message = "Selected device is no longer connected. Refresh and select again.";
189
- if (state === "offline") {
190
- message = "Device is offline. Reconnect device or restart adb.";
191
- }
192
- else if (state === "unauthorized") {
193
- message = "Authorize this computer on the device and retry.";
194
- }
229
+ const message = getAndroidConnectErrorMessage({ state });
195
230
  emitDesktopDebug("device.error", "device", {}, { platform: "android", operation: "connect", message, targetId: targetId || deviceTargetId, state: state || "missing" });
196
231
  throw new Error(message);
197
232
  }
@@ -3,21 +3,28 @@
3
3
  */
4
4
  import { printCliOutput } from "../utils/console-output.js";
5
5
  export { buildAssertionSystemPrompt } from "../prompts/execution.js";
6
+ const ASSERTION_PREFIXES = ["assert", "verify", "validate", "ensure"];
7
+ function hasAssertionPrefix(lower) {
8
+ return ASSERTION_PREFIXES.some((prefix) => lower === prefix || lower.startsWith(`${prefix}:`) || lower.startsWith(`${prefix} `));
9
+ }
6
10
  export function isAssertion(userInput) {
7
11
  const trimmed = userInput.trim();
8
12
  const lower = trimmed.toLowerCase();
9
- return lower.startsWith("assert:") || lower.startsWith("assert ");
13
+ return hasAssertionPrefix(lower);
10
14
  }
11
15
  export function extractAssertionPrompt(userInput) {
12
16
  const trimmed = userInput.trim();
13
17
  const lower = trimmed.toLowerCase();
14
- // Handle "assert:" or "Assert:"
15
- if (lower.startsWith("assert:")) {
16
- return trimmed.substring("assert:".length).trim();
17
- }
18
- // Handle "assert " or "Assert "
19
- if (lower.startsWith("assert ")) {
20
- return trimmed.substring("assert".length).trim();
18
+ for (const prefix of ASSERTION_PREFIXES) {
19
+ if (lower === prefix) {
20
+ return trimmed;
21
+ }
22
+ if (lower.startsWith(`${prefix}:`)) {
23
+ return trimmed.substring(prefix.length + 1).trim();
24
+ }
25
+ if (lower.startsWith(`${prefix} `)) {
26
+ return trimmed.substring(prefix.length).trim();
27
+ }
21
28
  }
22
29
  return trimmed;
23
30
  }
@@ -14,11 +14,11 @@ const execAsync = promisify(exec);
14
14
  // Active session state
15
15
  let activeSession = null;
16
16
  /**
17
- * Get simulator UDID by name (prefers already-booted simulator)
18
- * @param {string} simulatorName
19
- * @returns {Promise<string|null>}
17
+ * Resolve simulator by UDID or name (name matches prefer already-booted simulators).
18
+ * @param {string} simulatorNameOrUdid
19
+ * @returns {Promise<{name: string, udid: string, state: string}|null>}
20
20
  */
21
- async function getSimulatorUDID(simulatorName) {
21
+ async function resolveSimulator(simulatorNameOrUdid) {
22
22
  try {
23
23
  const { stdout } = await execAsync("xcrun simctl list devices --json");
24
24
  const data = JSON.parse(stdout);
@@ -26,25 +26,31 @@ async function getSimulatorUDID(simulatorName) {
26
26
  let bootedMatch = null;
27
27
  for (const runtime of Object.values(data.devices)) {
28
28
  for (const device of runtime) {
29
- if (device.name === simulatorName && device.isAvailable) {
29
+ if (!device.isAvailable) {
30
+ continue;
31
+ }
32
+ if (device.udid === simulatorNameOrUdid) {
33
+ return { name: device.name, udid: device.udid, state: device.state };
34
+ }
35
+ if (device.name === simulatorNameOrUdid) {
36
+ const match = { name: device.name, udid: device.udid, state: device.state };
30
37
  if (!firstMatch) {
31
- firstMatch = device.udid;
38
+ firstMatch = match;
32
39
  }
33
- // Prefer the already-booted one
34
40
  if (device.state === "Booted") {
35
- bootedMatch = device.udid;
41
+ bootedMatch = match;
36
42
  }
37
43
  }
38
44
  }
39
45
  }
40
46
  if (bootedMatch) {
41
- logger.debug(`Found booted simulator: ${bootedMatch}`);
47
+ logger.debug(`Found booted simulator: ${bootedMatch.udid}`);
42
48
  return bootedMatch;
43
49
  }
44
50
  return firstMatch;
45
51
  }
46
52
  catch (error) {
47
- logger.error("Failed to get simulator UDID", { error: error.message });
53
+ logger.error("Failed to resolve simulator", { error: error.message });
48
54
  }
49
55
  return null;
50
56
  }
@@ -84,33 +90,33 @@ async function findAnySimulator() {
84
90
  */
85
91
  async function bootSimulator(simulatorName, options = {}) {
86
92
  const log = options.log || console.log;
87
- const udid = await getSimulatorUDID(simulatorName);
88
- if (!udid) {
93
+ const simulator = await resolveSimulator(simulatorName);
94
+ if (!simulator) {
89
95
  throw new Error(`Simulator "${simulatorName}" not found. Run "xcrun simctl list devices" to see available simulators.`);
90
96
  }
91
- log(`Found simulator "${simulatorName}" with UDID: ${udid}`);
97
+ log(`Found simulator "${simulator.name}" with UDID: ${simulator.udid}`);
92
98
  // Check if already booted
93
99
  const { stdout } = await execAsync("xcrun simctl list devices --json");
94
100
  const data = JSON.parse(stdout);
95
101
  for (const runtime of Object.values(data.devices)) {
96
102
  for (const device of runtime) {
97
- if (device.udid === udid) {
103
+ if (device.udid === simulator.udid) {
98
104
  if (device.state === "Booted") {
99
- log(`Simulator "${simulatorName}" is already booted`);
100
- return udid;
105
+ log(`Simulator "${simulator.name}" is already booted`);
106
+ return simulator;
101
107
  }
102
108
  break;
103
109
  }
104
110
  }
105
111
  }
106
- log(`Booting simulator "${simulatorName}"...`);
107
- await execAsync(`xcrun simctl boot ${udid}`);
112
+ log(`Booting simulator "${simulator.name}"...`);
113
+ await execAsync(`xcrun simctl boot ${simulator.udid}`);
108
114
  // Open Simulator app to show the device
109
115
  await execAsync("open -a Simulator");
110
116
  // Wait for boot to complete
111
- await waitForSimulatorBoot(udid);
112
- log(`Simulator "${simulatorName}" is fully booted`);
113
- return udid;
117
+ await waitForSimulatorBoot(simulator.udid);
118
+ log(`Simulator "${simulator.name}" is fully booted`);
119
+ return simulator;
114
120
  }
115
121
  /**
116
122
  * Wait for simulator to finish booting
@@ -163,30 +169,30 @@ export async function connectToDevice(simulatorName, options = {}) {
163
169
  log(`No simulator specified, using: ${simulatorName}`);
164
170
  }
165
171
  // Boot simulator
166
- const udid = await bootSimulator(simulatorName, { log });
172
+ const simulator = await bootSimulator(simulatorName, { log });
167
173
  // Create Appium session
168
174
  log("Creating Appium session...");
169
175
  const session = await appium.createSession({
170
176
  platformName: "iOS",
171
177
  "appium:automationName": "XCUITest",
172
- "appium:deviceName": simulatorName,
173
- "appium:udid": udid,
178
+ "appium:deviceName": simulator.name,
179
+ "appium:udid": simulator.udid,
174
180
  "appium:noReset": true,
175
181
  "appium:shouldTerminateApp": false,
176
182
  });
177
183
  activeSession = {
178
184
  sessionId: session.sessionId,
179
- udid,
180
- simulatorName,
185
+ udid: simulator.udid,
186
+ simulatorName: simulator.name,
181
187
  };
182
- log(`Connected to simulator "${simulatorName}" (${udid})`);
188
+ log(`Connected to simulator "${simulator.name}" (${simulator.udid})`);
183
189
  emitDesktopDebug("device.connect", "device", {}, {
184
190
  platform: "ios",
185
191
  stage: "success",
186
- simulatorName,
187
- udid
192
+ simulatorName: simulator.name,
193
+ udid: simulator.udid
188
194
  });
189
- return udid;
195
+ return simulator.udid;
190
196
  }
191
197
  /**
192
198
  * Get device info (screen dimensions and scale)