@loadmill/droid-cua 1.1.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +71 -197
  2. package/build/index.js +2 -0
  3. package/build/src/cli/app.js +60 -3
  4. package/build/src/cli/components/CommandSuggestions.js +46 -6
  5. package/build/src/cli/components/OutputPanel.js +16 -0
  6. package/build/src/cli/device-selector.js +55 -28
  7. package/build/src/commands/help.js +4 -3
  8. package/build/src/core/execution-engine.js +127 -25
  9. package/build/src/core/prompts.js +71 -10
  10. package/build/src/device/actions.js +1 -1
  11. package/build/src/device/android/actions.js +97 -20
  12. package/build/src/device/android/connection.js +176 -73
  13. package/build/src/device/android/tools.js +21 -0
  14. package/build/src/device/assertions.js +28 -6
  15. package/build/src/device/connection.js +2 -2
  16. package/build/src/device/factory.js +1 -1
  17. package/build/src/device/interface.js +6 -2
  18. package/build/src/device/ios/actions.js +87 -26
  19. package/build/src/device/ios/appium-server.js +62 -8
  20. package/build/src/device/ios/connection.js +41 -3
  21. package/build/src/device/loadmill.js +66 -17
  22. package/build/src/device/openai.js +84 -73
  23. package/build/src/integrations/loadmill/client.js +24 -3
  24. package/build/src/integrations/loadmill/executor.js +2 -2
  25. package/build/src/integrations/loadmill/interpreter.js +11 -7
  26. package/build/src/modes/design-mode-ink.js +13 -0
  27. package/build/src/modes/design-mode.js +9 -0
  28. package/build/src/modes/execution-mode.js +225 -29
  29. package/build/src/test-store/test-manager.js +12 -4
  30. package/build/src/utils/cua-debug-tracer.js +362 -0
  31. package/build/src/utils/desktop-debug.js +36 -0
  32. package/package.json +1 -1
@@ -6,6 +6,29 @@
6
6
  import * as appium from "./appium-client.js";
7
7
  import { getActiveSession, getDevicePixelRatio } from "./connection.js";
8
8
  import { logger } from "../../utils/logger.js";
9
+ import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js";
10
+ function normalizeMobileKeypress(keys = []) {
11
+ if (!Array.isArray(keys) || keys.length === 0) {
12
+ throw new Error("Keypress action is missing keys");
13
+ }
14
+ if (keys.length > 1) {
15
+ throw new Error(`Unsupported mobile key chord: ${keys.join(", ")}. Use taps and text entry instead.`);
16
+ }
17
+ const key = String(keys[0]).trim().toUpperCase();
18
+ if (key === "ESC" || key === "ESCAPE" || key === "HOME") {
19
+ return { kind: "button", originalKey: keys[0], mapped: "home" };
20
+ }
21
+ if (key === "ENTER" || key === "RETURN") {
22
+ return { kind: "text", originalKey: keys[0], mapped: "\n", label: "Return key" };
23
+ }
24
+ if (key === "BACKSPACE" || key === "DELETE") {
25
+ return { kind: "text", originalKey: keys[0], mapped: "\b", label: "Delete key" };
26
+ }
27
+ if (key === "SPACE") {
28
+ return { kind: "text", originalKey: keys[0], mapped: " ", label: "Space key" };
29
+ }
30
+ throw new Error(`Unsupported mobile keypress: ${keys[0]}. Only single mobile-safe keys are allowed.`);
31
+ }
9
32
  /**
10
33
  * Handle an action from the CUA model
11
34
  * @param {string} simulatorId - The simulator UDID
@@ -16,24 +39,50 @@ import { logger } from "../../utils/logger.js";
16
39
  export async function handleModelAction(simulatorId, action, scale = 1.0, context = null) {
17
40
  const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
18
41
  const session = getActiveSession();
42
+ const meta = (payload = {}) => ({
43
+ eventType: "tool_call",
44
+ actionType: action?.type,
45
+ runId: context?.runId,
46
+ stepId: context?.stepId,
47
+ instructionIndex: context?.instructionIndex,
48
+ payload: {
49
+ platform: "ios",
50
+ ...payload
51
+ }
52
+ });
19
53
  if (!session) {
20
54
  throw new Error("No active iOS session");
21
55
  }
22
56
  try {
57
+ emitDesktopDebug("device.action.execute", "device", {
58
+ runId: context?.runId,
59
+ sessionId: context?.sessionId,
60
+ stepId: context?.stepId,
61
+ instructionIndex: context?.instructionIndex
62
+ }, {
63
+ platform: "ios",
64
+ simulatorId,
65
+ actionType: action?.type,
66
+ scale,
67
+ text: typeof action?.text === "string" ? truncateForDebug(action.text, 300) : undefined,
68
+ keyCount: Array.isArray(action?.keys) ? action.keys.length : 0,
69
+ pathPoints: Array.isArray(action?.path) ? action.path.length : 0
70
+ });
23
71
  switch (action.type) {
24
- case "click": {
72
+ case "click":
73
+ case "double_click": {
25
74
  // Convert scaled coordinates to pixels, then to logical points for Appium
26
75
  const dpr = getDevicePixelRatio();
27
76
  const pixelX = Math.round(action.x / scale);
28
77
  const pixelY = Math.round(action.y / scale);
29
78
  const pointX = Math.round(pixelX / dpr);
30
79
  const pointY = Math.round(pixelY / dpr);
31
- addOutput({ type: "action", text: `Tapping at (${pointX}, ${pointY}) points` });
80
+ addOutput({ type: "action", text: `Tapping at (${pointX}, ${pointY}) points`, ...meta({ x: pointX, y: pointY, unit: "points", deviceScale: dpr }) });
32
81
  await appium.tap(session.sessionId, pointX, pointY);
33
82
  break;
34
83
  }
35
84
  case "type": {
36
- addOutput({ type: "action", text: `Typing text: ${action.text}` });
85
+ addOutput({ type: "action", text: `Typing text: ${action.text}`, ...meta({ text: action.text }) });
37
86
  await appium.type(session.sessionId, action.text);
38
87
  break;
39
88
  }
@@ -41,7 +90,7 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
41
90
  const dpr = getDevicePixelRatio();
42
91
  const scrollX = Math.round((action.scroll_x / scale) / dpr);
43
92
  const scrollY = Math.round((action.scroll_y / scale) / dpr);
44
- addOutput({ type: "action", text: `Scrolling by (${scrollX}, ${scrollY}) points` });
93
+ addOutput({ type: "action", text: `Scrolling by (${scrollX}, ${scrollY}) points`, ...meta({ scrollX, scrollY, unit: "points" }) });
45
94
  // Start from center of screen (in logical points)
46
95
  const centerX = 197; // Center of iPhone 16 (393/2)
47
96
  const centerY = 426; // Center of iPhone 16 (852/2)
@@ -64,6 +113,7 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
64
113
  addOutput({
65
114
  type: "action",
66
115
  text: `Dragging from (${startX}, ${startY}) to (${endX}, ${endY}) points`,
116
+ ...meta({ pathStart: { x: startX, y: startY }, pathEnd: { x: endX, y: endY }, unit: "points" })
67
117
  });
68
118
  await appium.drag(session.sessionId, startX, startY, endX, endY);
69
119
  }
@@ -73,31 +123,19 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
73
123
  break;
74
124
  }
75
125
  case "keypress": {
76
- const { keys } = action;
77
- for (const key of keys) {
78
- const upperKey = key.toUpperCase();
79
- if (upperKey === "ESC" || upperKey === "ESCAPE") {
80
- // Map ESC to home button on iOS
81
- addOutput({ type: "action", text: "Pressing Home button" });
82
- await appium.pressButton(session.sessionId, "home");
83
- }
84
- else if (upperKey === "ENTER" || upperKey === "RETURN") {
85
- addOutput({ type: "action", text: "Pressing Return key" });
86
- await appium.type(session.sessionId, "\n");
87
- }
88
- else if (upperKey === "BACKSPACE" || upperKey === "DELETE") {
89
- addOutput({ type: "action", text: "Pressing Delete key" });
90
- await appium.type(session.sessionId, "\b");
91
- }
92
- else {
93
- addOutput({ type: "action", text: `Pressing key: ${key}` });
94
- await appium.type(session.sessionId, key);
95
- }
126
+ const normalized = normalizeMobileKeypress(action.keys);
127
+ if (normalized.kind === "button") {
128
+ addOutput({ type: "action", text: "Pressing Home button", ...meta({ keys: [normalized.originalKey], mapped: normalized.mapped }) });
129
+ await appium.pressButton(session.sessionId, normalized.mapped);
130
+ }
131
+ else {
132
+ addOutput({ type: "action", text: `Pressing ${normalized.label}`, ...meta({ keys: [normalized.originalKey], mapped: normalized.label }) });
133
+ await appium.type(session.sessionId, normalized.mapped);
96
134
  }
97
135
  break;
98
136
  }
99
137
  case "wait": {
100
- addOutput({ type: "action", text: "Waiting..." });
138
+ addOutput({ type: "action", text: "Waiting...", ...meta({}) });
101
139
  await new Promise((resolve) => setTimeout(resolve, 1000));
102
140
  break;
103
141
  }
@@ -111,7 +149,30 @@ export async function handleModelAction(simulatorId, action, scale = 1.0, contex
111
149
  message: error.message,
112
150
  stack: error.stack,
113
151
  });
114
- addOutput({ type: "error", text: `Error executing action: ${error.message}` });
152
+ emitDesktopDebug("device.error", "device", {
153
+ runId: context?.runId,
154
+ sessionId: context?.sessionId,
155
+ stepId: context?.stepId,
156
+ instructionIndex: context?.instructionIndex
157
+ }, {
158
+ platform: "ios",
159
+ operation: "action.execute",
160
+ actionType: action?.type,
161
+ message: error.message
162
+ });
163
+ addOutput({
164
+ type: "error",
165
+ text: `Error executing action: ${error.message}`,
166
+ eventType: "error",
167
+ actionType: action?.type,
168
+ runId: context?.runId,
169
+ stepId: context?.stepId,
170
+ instructionIndex: context?.instructionIndex,
171
+ payload: {
172
+ message: error.message,
173
+ platform: "ios"
174
+ }
175
+ });
115
176
  addOutput({ type: "info", text: "Full error details have been logged to the debug log." });
116
177
  }
117
178
  }
@@ -5,16 +5,64 @@
5
5
  * Auto-starts if not running, cleans up on process exit.
6
6
  */
7
7
  import { spawn } from "child_process";
8
- const APPIUM_URL = process.env.APPIUM_URL || "http://localhost:4723";
8
+ import { existsSync, readdirSync } from "fs";
9
+ import path from "path";
9
10
  let appiumProcess = null;
10
11
  let cleanupRegistered = false;
12
+ function getAppiumBaseUrl() {
13
+ return process.env.APPIUM_URL || "http://localhost:4723";
14
+ }
15
+ function collectNvmAppiumCandidates() {
16
+ const home = process.env.HOME;
17
+ if (!home)
18
+ return [];
19
+ const nvmNodeVersionsDir = path.join(home, ".nvm", "versions", "node");
20
+ if (!existsSync(nvmNodeVersionsDir))
21
+ return [];
22
+ try {
23
+ return readdirSync(nvmNodeVersionsDir)
24
+ .map((versionDir) => path.join(nvmNodeVersionsDir, versionDir, "bin", "appium"))
25
+ .filter((candidate) => existsSync(candidate))
26
+ .reverse();
27
+ }
28
+ catch {
29
+ return [];
30
+ }
31
+ }
32
+ export function resolveAppiumCommand() {
33
+ const explicit = process.env.DROID_CUA_APPIUM_PATH?.trim() || process.env.APPIUM_PATH?.trim();
34
+ if (explicit) {
35
+ return explicit;
36
+ }
37
+ const commonCandidates = [
38
+ "/opt/homebrew/bin/appium",
39
+ "/usr/local/bin/appium",
40
+ ...collectNvmAppiumCandidates()
41
+ ];
42
+ for (const candidate of commonCandidates) {
43
+ if (existsSync(candidate)) {
44
+ return candidate;
45
+ }
46
+ }
47
+ return "appium";
48
+ }
49
+ async function spawnAppiumProcess(command) {
50
+ return await new Promise((resolve, reject) => {
51
+ const child = spawn(command, [], {
52
+ detached: true,
53
+ stdio: "ignore"
54
+ });
55
+ child.once("error", reject);
56
+ child.once("spawn", () => resolve(child));
57
+ });
58
+ }
11
59
  /**
12
60
  * Check if Appium server is running
13
61
  * @returns {Promise<boolean>}
14
62
  */
15
63
  export async function isAppiumRunning() {
16
64
  try {
17
- const response = await fetch(`${APPIUM_URL}/status`);
65
+ const response = await fetch(`${getAppiumBaseUrl()}/status`);
18
66
  return response.ok;
19
67
  }
20
68
  catch {
@@ -45,11 +93,17 @@ export async function startAppium() {
45
93
  console.log("Appium server is already running");
46
94
  return;
47
95
  }
48
- console.log("Starting Appium server...");
49
- appiumProcess = spawn("appium", [], {
50
- detached: true,
51
- stdio: "ignore"
52
- });
96
+ const appiumCommand = resolveAppiumCommand();
97
+ console.log(`Starting Appium server (${appiumCommand})...`);
98
+ try {
99
+ appiumProcess = await spawnAppiumProcess(appiumCommand);
100
+ }
101
+ catch (error) {
102
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
103
+ throw new Error('Appium CLI not found. Install Appium or set an "iOS Appium Path" in Settings. Finder-launched apps may not inherit your shell PATH.');
104
+ }
105
+ throw error;
106
+ }
53
107
  appiumProcess.unref();
54
108
  const ready = await waitForAppiumReady(30000);
55
109
  if (!ready) {
@@ -97,5 +151,5 @@ export function setupAppiumCleanup() {
97
151
  * @returns {string}
98
152
  */
99
153
  export function getAppiumUrl() {
100
- return APPIUM_URL;
154
+ return getAppiumBaseUrl();
101
155
  }
@@ -9,6 +9,7 @@ import sharp from "sharp";
9
9
  import { startAppium, setupAppiumCleanup } from "./appium-server.js";
10
10
  import * as appium from "./appium-client.js";
11
11
  import { logger } from "../../utils/logger.js";
12
+ import { emitDesktopDebug } from "../../utils/desktop-debug.js";
12
13
  const execAsync = promisify(exec);
13
14
  // Active session state
14
15
  let activeSession = null;
@@ -142,6 +143,7 @@ async function waitForSimulatorBoot(udid, timeoutMs = 60000) {
142
143
  * @returns {Promise<string>} Simulator ID for use with other functions
143
144
  */
144
145
  export async function connectToDevice(simulatorName) {
146
+ emitDesktopDebug("device.connect", "device", {}, { platform: "ios", stage: "start", simulatorName: simulatorName || null });
145
147
  // Setup cleanup handlers
146
148
  setupAppiumCleanup();
147
149
  // Start Appium if not running
@@ -175,6 +177,12 @@ export async function connectToDevice(simulatorName) {
175
177
  simulatorName,
176
178
  };
177
179
  console.log(`Connected to simulator "${simulatorName}" (${udid})`);
180
+ emitDesktopDebug("device.connect", "device", {}, {
181
+ platform: "ios",
182
+ stage: "success",
183
+ simulatorName,
184
+ udid
185
+ });
178
186
  return udid;
179
187
  }
180
188
  /**
@@ -204,6 +212,9 @@ export async function getDeviceInfo(simulatorId) {
204
212
  const scaledWidth = Math.round(pixelWidth * scale);
205
213
  const scaledHeight = Math.round(pixelHeight * scale);
206
214
  return {
215
+ platform: "ios",
216
+ device_name: activeSession?.simulatorName || simulatorId,
217
+ model: activeSession?.simulatorName || simulatorId,
207
218
  device_width: pixelWidth,
208
219
  device_height: pixelHeight,
209
220
  scaled_width: scaledWidth,
@@ -219,8 +230,8 @@ export async function getDeviceInfo(simulatorId) {
219
230
  */
220
231
  export async function getScreenshotAsBase64(simulatorId, deviceInfo) {
221
232
  await ensureSessionAlive();
222
- const base64 = await appium.getScreenshot(activeSession.sessionId);
223
- let buffer = Buffer.from(base64, "base64");
233
+ const rawBase64 = await appium.getScreenshot(activeSession.sessionId);
234
+ let buffer = Buffer.from(rawBase64, "base64");
224
235
  logger.debug(`iOS screenshot captured: ${buffer.length} bytes before scaling`);
225
236
  if (deviceInfo.scale < 1.0) {
226
237
  buffer = await sharp(buffer)
@@ -229,7 +240,15 @@ export async function getScreenshotAsBase64(simulatorId, deviceInfo) {
229
240
  .toBuffer();
230
241
  logger.debug(`iOS screenshot scaled: ${buffer.length} bytes after scaling`);
231
242
  }
232
- return buffer.toString("base64");
243
+ const base64 = buffer.toString("base64");
244
+ emitDesktopDebug("device.screenshot", "device", {}, {
245
+ platform: "ios",
246
+ simulatorId,
247
+ width: deviceInfo?.scaled_width,
248
+ height: deviceInfo?.scaled_height,
249
+ base64Length: base64.length
250
+ });
251
+ return base64;
233
252
  }
234
253
  /**
235
254
  * Ensure the Appium session is still alive, recreate if dead
@@ -240,6 +259,15 @@ async function ensureSessionAlive() {
240
259
  }
241
260
  const status = await appium.getSessionStatus(activeSession.sessionId);
242
261
  if (!status) {
262
+ emitDesktopDebug("reconnect.attempt", "device", {}, {
263
+ platform: "ios",
264
+ stage: "start",
265
+ reason: "appium session status check failed"
266
+ });
267
+ emitDesktopDebug("device.disconnect", "device", {}, {
268
+ platform: "ios",
269
+ reason: "appium session no longer active"
270
+ });
243
271
  console.log("Session died, recreating...");
244
272
  const session = await appium.createSession({
245
273
  platformName: "iOS",
@@ -250,6 +278,11 @@ async function ensureSessionAlive() {
250
278
  "appium:shouldTerminateApp": false,
251
279
  });
252
280
  activeSession.sessionId = session.sessionId;
281
+ emitDesktopDebug("reconnect.attempt", "device", {}, {
282
+ platform: "ios",
283
+ stage: "success",
284
+ sessionId: session.sessionId
285
+ });
253
286
  }
254
287
  }
255
288
  /**
@@ -271,6 +304,11 @@ export function getDevicePixelRatio() {
271
304
  */
272
305
  export async function disconnect() {
273
306
  if (activeSession) {
307
+ emitDesktopDebug("device.disconnect", "device", {}, {
308
+ platform: "ios",
309
+ udid: activeSession.udid,
310
+ simulatorName: activeSession.simulatorName
311
+ });
274
312
  try {
275
313
  await appium.deleteSession(activeSession.sessionId);
276
314
  }
@@ -2,6 +2,10 @@
2
2
  * Loadmill instruction handling for script execution
3
3
  */
4
4
  import { executeLoadmillCommand } from "../integrations/loadmill/index.js";
5
+ function getLoadmillSiteBaseUrl() {
6
+ const rawBaseUrl = process.env.LOADMILL_BASE_URL || "https://app.loadmill.com/api";
7
+ return rawBaseUrl.replace(/\/api\/?$/, "");
8
+ }
5
9
  /**
6
10
  * Check if an instruction is a Loadmill command
7
11
  * @param {string} userInput - The instruction text
@@ -35,22 +39,37 @@ export function extractLoadmillCommand(userInput) {
35
39
  * @param {string} command - The Loadmill command to execute
36
40
  * @param {boolean} isHeadlessMode - Whether running in headless/CI mode
37
41
  * @param {Object} context - Execution context
42
+ * @param {Object|null} stepContext - Optional step context metadata
38
43
  * @returns {Promise<{success: boolean, error?: string}>}
39
44
  */
40
- export async function executeLoadmillInstruction(command, isHeadlessMode, context) {
45
+ export async function executeLoadmillInstruction(command, isHeadlessMode, context, stepContext = null) {
41
46
  const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
42
- addOutput({ type: 'info', text: `[Loadmill] Executing: ${command}` });
47
+ const meta = {
48
+ runId: context?.runId,
49
+ stepId: stepContext?.stepId,
50
+ instructionIndex: stepContext?.instructionIndex
51
+ };
52
+ addOutput({
53
+ type: 'info',
54
+ text: `[Loadmill] Executing: ${command}`,
55
+ eventType: 'system_message',
56
+ payload: { loadmillCommand: command, loadmillBaseUrl: getLoadmillSiteBaseUrl() },
57
+ ...meta
58
+ });
43
59
  const result = await executeLoadmillCommand(command, {
44
- onProgress: ({ message }) => {
45
- addOutput({ type: 'info', text: `[Loadmill] ${message}` });
60
+ onProgress: ({ message, runId }) => {
61
+ const payload = runId
62
+ ? { loadmillSuiteRunId: runId, loadmillCommand: command, loadmillBaseUrl: getLoadmillSiteBaseUrl() }
63
+ : { loadmillCommand: command, loadmillBaseUrl: getLoadmillSiteBaseUrl() };
64
+ addOutput({ type: 'info', text: `[Loadmill] ${message}`, eventType: 'system_message', payload, ...meta });
46
65
  }
47
66
  });
48
67
  if (result.success) {
49
- handleLoadmillSuccess(command, result, context);
68
+ handleLoadmillSuccess(command, result, context, stepContext);
50
69
  return { success: true };
51
70
  }
52
71
  else {
53
- return await handleLoadmillFailure(command, result.error, isHeadlessMode, context);
72
+ return await handleLoadmillFailure(command, result.error, isHeadlessMode, context, stepContext, result.runId);
54
73
  }
55
74
  }
56
75
  /**
@@ -59,13 +78,22 @@ export async function executeLoadmillInstruction(command, isHeadlessMode, contex
59
78
  * @param {string} error - Error message
60
79
  * @param {boolean} isHeadlessMode - Whether running in headless/CI mode
61
80
  * @param {Object} context - Execution context
81
+ * @param {Object|null} stepContext - Optional step context metadata
62
82
  * @returns {Promise<{success: boolean, error?: string}>}
63
83
  */
64
- export async function handleLoadmillFailure(command, error, isHeadlessMode, context) {
84
+ export async function handleLoadmillFailure(command, error, isHeadlessMode, context, stepContext = null, suiteRunId = null) {
65
85
  const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
66
- addOutput({ type: 'error', text: '[Loadmill] FAILED' });
67
- addOutput({ type: 'error', text: `Command: ${command}` });
68
- addOutput({ type: 'error', text: `Error: ${error}` });
86
+ const meta = {
87
+ runId: context?.runId,
88
+ stepId: stepContext?.stepId,
89
+ instructionIndex: stepContext?.instructionIndex
90
+ };
91
+ const payload = suiteRunId
92
+ ? { loadmillSuiteRunId: suiteRunId, loadmillCommand: command, loadmillBaseUrl: getLoadmillSiteBaseUrl() }
93
+ : { loadmillCommand: command, loadmillBaseUrl: getLoadmillSiteBaseUrl() };
94
+ addOutput({ type: 'error', text: '[Loadmill] FAILED', eventType: 'error', payload, ...meta });
95
+ addOutput({ type: 'error', text: `Command: ${command}`, eventType: 'error', payload, ...meta });
96
+ addOutput({ type: 'error', text: `Error: ${error}`, eventType: 'error', payload, ...meta });
69
97
  if (isHeadlessMode) {
70
98
  // Headless mode: exit with error code
71
99
  if (context?.exit) {
@@ -74,7 +102,13 @@ export async function handleLoadmillFailure(command, error, isHeadlessMode, cont
74
102
  process.exit(1);
75
103
  }
76
104
  // Interactive mode: ask user what to do
77
- addOutput({ type: 'system', text: 'What would you like to do? (retry/skip/stop)' });
105
+ addOutput({
106
+ type: 'system',
107
+ text: 'What would you like to do? (retry/skip/stop)',
108
+ eventType: 'input_request',
109
+ payload: { options: ['retry', 'skip', 'stop'], ...payload },
110
+ ...meta
111
+ });
78
112
  const userChoice = await new Promise((resolve) => {
79
113
  if (context?.waitForUserInput) {
80
114
  context.waitForUserInput().then(resolve);
@@ -90,7 +124,13 @@ export async function handleLoadmillFailure(command, error, isHeadlessMode, cont
90
124
  return { success: false, retry: true };
91
125
  }
92
126
  else if (choice === 'skip' || choice === 's') {
93
- addOutput({ type: 'info', text: 'Skipping failed Loadmill command and continuing...' });
127
+ addOutput({
128
+ type: 'info',
129
+ text: 'Skipping failed Loadmill command and continuing...',
130
+ eventType: 'system_message',
131
+ payload,
132
+ ...meta
133
+ });
94
134
  return { success: true }; // Continue to next instruction
95
135
  }
96
136
  else {
@@ -103,20 +143,29 @@ export async function handleLoadmillFailure(command, error, isHeadlessMode, cont
103
143
  * @param {string} command - The executed command
104
144
  * @param {Object} result - The execution result
105
145
  * @param {Object} context - Execution context
146
+ * @param {Object|null} stepContext - Optional step context metadata
106
147
  */
107
- export function handleLoadmillSuccess(command, result, context) {
148
+ export function handleLoadmillSuccess(command, result, context, stepContext = null) {
108
149
  const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
150
+ const meta = {
151
+ runId: context?.runId,
152
+ stepId: stepContext?.stepId,
153
+ instructionIndex: stepContext?.instructionIndex
154
+ };
155
+ const payload = result.runId
156
+ ? { loadmillSuiteRunId: result.runId, loadmillCommand: command, loadmillBaseUrl: getLoadmillSiteBaseUrl() }
157
+ : { loadmillCommand: command, loadmillBaseUrl: getLoadmillSiteBaseUrl() };
109
158
  if (result.action === "search") {
110
- addOutput({ type: 'success', text: `[Loadmill] Found ${result.result.flows.length} flow(s)` });
159
+ addOutput({ type: 'success', text: `[Loadmill] Found ${result.result.flows.length} flow(s)`, eventType: 'system_message', payload, ...meta });
111
160
  result.result.flows.forEach((flow, i) => {
112
161
  const name = flow.description || flow.name || "Unknown";
113
- addOutput({ type: 'info', text: ` ${i + 1}. ${name} (ID: ${flow.id})` });
162
+ addOutput({ type: 'info', text: ` ${i + 1}. ${name} (ID: ${flow.id})`, eventType: 'system_message', payload, ...meta });
114
163
  });
115
164
  }
116
165
  else {
117
- addOutput({ type: 'success', text: `[Loadmill] Flow "${result.flowName}" passed` });
166
+ addOutput({ type: 'success', text: `[Loadmill] Flow "${result.flowName}" passed`, eventType: 'system_message', payload, ...meta });
118
167
  if (result.runId) {
119
- addOutput({ type: 'info', text: ` Run ID: ${result.runId}` });
168
+ addOutput({ type: 'info', text: ` Run ID: ${result.runId}`, eventType: 'system_message', payload, ...meta });
120
169
  }
121
170
  }
122
171
  }