common-qa-mcp 0.4.1 โ†’ 0.6.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 (58) hide show
  1. package/dist/adapters/remoteMcp/adapter.d.ts +4 -1
  2. package/dist/adapters/remoteMcp/adapter.d.ts.map +1 -1
  3. package/dist/adapters/remoteMcp/adapter.js +8 -3
  4. package/dist/adapters/remoteMcp/adapter.js.map +1 -1
  5. package/dist/adapters/simplifyqa/adapter.d.ts +130 -36
  6. package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -1
  7. package/dist/adapters/simplifyqa/adapter.js +833 -265
  8. package/dist/adapters/simplifyqa/adapter.js.map +1 -1
  9. package/dist/adapters/simplifyqa/mapper.d.ts +5 -1
  10. package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -1
  11. package/dist/adapters/simplifyqa/mapper.js +35 -1
  12. package/dist/adapters/simplifyqa/mapper.js.map +1 -1
  13. package/dist/adapters/types.d.ts +47 -10
  14. package/dist/adapters/types.d.ts.map +1 -1
  15. package/dist/adapters/walnut/actionTypes.d.ts +1 -1
  16. package/dist/adapters/walnut/actionTypes.d.ts.map +1 -1
  17. package/dist/adapters/walnut/actionTypes.js +37 -0
  18. package/dist/adapters/walnut/actionTypes.js.map +1 -1
  19. package/dist/adapters/walnut/mapper.d.ts.map +1 -1
  20. package/dist/adapters/walnut/mapper.js +12 -1
  21. package/dist/adapters/walnut/mapper.js.map +1 -1
  22. package/dist/domain/types.d.ts +98 -17
  23. package/dist/domain/types.d.ts.map +1 -1
  24. package/dist/initAgents.d.ts.map +1 -1
  25. package/dist/initAgents.js +31 -11
  26. package/dist/initAgents.js.map +1 -1
  27. package/dist/server.js +1 -1
  28. package/dist/tools/backlog.tools.d.ts.map +1 -1
  29. package/dist/tools/backlog.tools.js +154 -26
  30. package/dist/tools/backlog.tools.js.map +1 -1
  31. package/dist/tools/connection.tools.d.ts.map +1 -1
  32. package/dist/tools/connection.tools.js +15 -3
  33. package/dist/tools/connection.tools.js.map +1 -1
  34. package/dist/tools/defects.tools.d.ts.map +1 -1
  35. package/dist/tools/defects.tools.js +29 -3
  36. package/dist/tools/defects.tools.js.map +1 -1
  37. package/dist/tools/discovery.tools.d.ts.map +1 -1
  38. package/dist/tools/discovery.tools.js +304 -137
  39. package/dist/tools/discovery.tools.js.map +1 -1
  40. package/dist/tools/markdown.d.ts.map +1 -1
  41. package/dist/tools/markdown.js +9 -5
  42. package/dist/tools/markdown.js.map +1 -1
  43. package/dist/tools/objects.tools.d.ts.map +1 -1
  44. package/dist/tools/objects.tools.js +111 -6
  45. package/dist/tools/objects.tools.js.map +1 -1
  46. package/dist/tools/recording.tools.d.ts.map +1 -1
  47. package/dist/tools/recording.tools.js +108 -10
  48. package/dist/tools/recording.tools.js.map +1 -1
  49. package/dist/tools/run.tools.d.ts.map +1 -1
  50. package/dist/tools/run.tools.js +263 -63
  51. package/dist/tools/run.tools.js.map +1 -1
  52. package/dist/tools/tasks.tools.d.ts.map +1 -1
  53. package/dist/tools/tasks.tools.js +29 -3
  54. package/dist/tools/tasks.tools.js.map +1 -1
  55. package/dist/tools/testcase.tools.d.ts.map +1 -1
  56. package/dist/tools/testcase.tools.js +5 -2
  57. package/dist/tools/testcase.tools.js.map +1 -1
  58. package/package.json +1 -1
@@ -14,6 +14,14 @@ function localAgentLine(a) {
14
14
  const host = a.host && a.host !== a.name ? ` ยท ${a.host}` : '';
15
15
  return `- ${a.online ? '๐ŸŸข' : 'โšช'} ${a.name ?? a.id}${host} \`${a.id}\`${a.status ? ` (${a.status})` : ''}`;
16
16
  }
17
+ function mobileDeviceLine(d) {
18
+ // `active` here is the last-known connection flag from the SQA DB (stale by design). Show it as
19
+ // a hint, not a gate โ€” a device the user just plugged in may still read `active:false` here.
20
+ const dot = d.active ? '๐ŸŸข' : 'โšช';
21
+ const os = d.osVersion ? ` ยท ${d.platform.toLowerCase()} ${d.osVersion}` : ` ยท ${d.platform.toLowerCase()}`;
22
+ const status = d.status ? ` (${d.status})` : '';
23
+ return `- ${dot} ${d.name}${os} \`${d.id}\`${status}`;
24
+ }
17
25
  /** One line per schedule for management listings โ€” what it runs, cadence, where + pinned agent,
18
26
  * browser, next run, status; the credential-free id in backticks is what the management tools take. */
19
27
  function scheduleLine(s) {
@@ -52,6 +60,12 @@ async function localAgentOfflineForNow(adapter, agentId) {
52
60
  try {
53
61
  const agents = await adapter.listLocalAgents();
54
62
  const picked = agents.find((a) => a.id === agentId);
63
+ // If the picked agent physically belongs to this machine (hostname / MAC / IP matched), the
64
+ // adapter's DB-flag override in listLocalAgents already set `online: true`. Never refuse a
65
+ // run-now for our OWN machine โ€” the DB flag is stale by design (7s cron in the executions
66
+ // service that we cannot touch).
67
+ if (picked?.isThisMachine)
68
+ return null;
55
69
  if (picked && !picked.online) {
56
70
  const online = agents.filter((a) => a.online);
57
71
  return (`Agent \`${agentId}\`${picked.name ? ` (${picked.name})` : ''} is offline โ€” a run-now needs an ONLINE agent. ` +
@@ -82,8 +96,12 @@ function statusText(s) {
82
96
  return parts.length ? ` ยท ${parts.join(', ')}` : '';
83
97
  })()
84
98
  : '';
85
- const kids = s.children?.length
86
- ? '\n\n' + s.children.map((c) => `- ${icon(c.state)} ${c.id}${c.title ? ` โ€” ${c.title}` : ''} (${c.state})`).join('\n')
99
+ const CHILD_CAP = 100;
100
+ const total = s.children?.length ?? 0;
101
+ const shown = s.children?.slice(0, CHILD_CAP) ?? [];
102
+ const kids = shown.length
103
+ ? '\n\n' + shown.map((c) => `- ${icon(c.state)} ${c.id}${c.title ? ` โ€” ${c.title}` : ''} (${c.state})`).join('\n') +
104
+ (total > CHILD_CAP ? `\nโ€ฆ and ${total - CHILD_CAP} more (${total} total)` : '')
87
105
  : '';
88
106
  return `Run ${s.runId} ยท **${s.state}**${prog}${tally}${link}${kids}`;
89
107
  }
@@ -114,13 +132,12 @@ export function registerRunTools(server, deps) {
114
132
  }
115
133
  });
116
134
  server.registerTool('local_agents', {
117
- title: 'List local agents (and which are online)',
118
- description: 'List the LOCAL agents registered to the connected customer and which are online. Use this ' +
119
- 'to show the user their local run options, or to pick a specific agent id (its credential_id) to ' +
120
- 'pass as `agentId` to a LOCAL tc_run / suite_run. Requires a live connection. ' +
121
- 'NOTE on online status: agents whose `machineName` matches this MCP host are reported online ' +
122
- '(mirrors sqa-ui\'s own-machine override โ€” the DB `active` flag is only refreshed at first ' +
123
- 'registration + a stale cron, so it lags behind). Other agents\' status still comes from the DB.',
135
+ title: "List THIS machine's local agent(s)",
136
+ description: "List the LOCAL agent(s) registered from THIS machine โ€” the desktop agent running on the box " +
137
+ "this MCP is on. Use for a LOCAL tc_run / suite_run. There is exactly one desktop agent per " +
138
+ "machine, so this is 0 or 1 row: empty means no agent is registered from this box (start the " +
139
+ "SimplifyQA desktop agent, or use mode:'remote' to dispatch to a teammate's). Use `remote_agents` " +
140
+ "for the other registered machines. Requires a live connection.",
124
141
  inputSchema: {},
125
142
  annotations: { readOnlyHint: true },
126
143
  }, async () => {
@@ -130,11 +147,43 @@ export function registerRunTools(server, deps) {
130
147
  if (!adapter.listLocalAgents)
131
148
  return err('This product has no local agents to list.');
132
149
  try {
133
- const agents = await adapter.listLocalAgents();
150
+ const all = await adapter.listLocalAgents();
151
+ const agents = all.filter((a) => a.isThisMachine);
152
+ if (!agents.length) {
153
+ return ok("No local agent is registered from this machine. Start the SimplifyQA desktop agent on " +
154
+ "this box, or use remote_agents + mode:'remote' to run on a teammate's machine.", { agents: [] });
155
+ }
134
156
  const online = agents.filter((a) => a.online);
157
+ const text = `${agents.length} local agent${agents.length === 1 ? '' : 's'} on this machine ยท ${online.length} online:\n\n` +
158
+ agents.map(localAgentLine).join('\n');
159
+ return ok(text, { agents, online: online.length });
160
+ }
161
+ catch (e) {
162
+ return err(describeError(e));
163
+ }
164
+ });
165
+ server.registerTool('remote_agents', {
166
+ title: 'List REMOTE agents (other registered machines)',
167
+ description: "List the LOCAL agents registered on OTHER machines (teammates' boxes) โ€” the pool a REMOTE " +
168
+ "run dispatches to. Use to pick an `agentId` for a REMOTE tc_run / suite_run. Excludes this " +
169
+ "machine's own agent (that's `local_agents`). REMOTE dispatches to a specific teammate's " +
170
+ "registered desktop agent through the SimplifyQA queue; the picked agent's browsers / adb " +
171
+ "devices are what actually execute. Requires a live connection.",
172
+ inputSchema: {},
173
+ annotations: { readOnlyHint: true },
174
+ }, async () => {
175
+ const adapter = deps.getAdapter();
176
+ if (!adapter)
177
+ return err(NOT_CONNECTED);
178
+ if (!adapter.listLocalAgents)
179
+ return err('This product has no local agents to list.');
180
+ try {
181
+ const all = await adapter.listLocalAgents();
182
+ const agents = all.filter((a) => !a.isThisMachine);
135
183
  if (!agents.length)
136
- return ok('No local agents are registered to this project.', { agents: [] });
137
- const text = `${agents.length} local agent${agents.length === 1 ? '' : 's'} ยท ${online.length} online:\n\n` +
184
+ return ok('No other machines are registered as agents for this customer.', { agents: [] });
185
+ const online = agents.filter((a) => a.online);
186
+ const text = `${agents.length} remote agent${agents.length === 1 ? '' : 's'} ยท ${online.length} online:\n\n` +
138
187
  agents.map(localAgentLine).join('\n');
139
188
  return ok(text, { agents, online: online.length });
140
189
  }
@@ -142,24 +191,81 @@ export function registerRunTools(server, deps) {
142
191
  return err(describeError(e));
143
192
  }
144
193
  });
194
+ server.registerTool('mobile_list_devices', {
195
+ title: 'List mobile devices (Android or iOS)',
196
+ description: "List MOBILE devices available to the project โ€” union of the LOCAL roster (real devices + " +
197
+ "emulators/simulators registered via the desktop agent, from `/tm/mobileDevices/search`) " +
198
+ "and the CLOUD roster (whichever farm the project has integrated: LambdaTest / " +
199
+ "BrowserStack / SauceLabs / SimplifyQA Device Farm โ€” the first non-empty one wins, " +
200
+ "matching the UI's single-provider-per-project model). Use before `tc_run` for an ANDROID " +
201
+ "or IOS test case to pick a `deviceId` (and read `deviceType` + `cloudDeviceType` off the " +
202
+ "same row). The `active` flag on local rows is last-known: stale by design (the SQA " +
203
+ "backend writes it when the desktop agent connects/disconnects, and a 7s-stale cron flips " +
204
+ "it back); a device the user just plugged in may still read `active:false` here. Cloud " +
205
+ "rows don't carry `active` โ€” cloud farms are always assumed reachable. Requires a live " +
206
+ "connection.",
207
+ inputSchema: {
208
+ platform: z.enum(['android', 'ios']).describe("Which mobile platform to list devices for."),
209
+ },
210
+ annotations: { readOnlyHint: true },
211
+ }, async ({ platform }) => {
212
+ const adapter = deps.getAdapter();
213
+ if (!adapter)
214
+ return err(NOT_CONNECTED);
215
+ if (!adapter.listMobileDevices)
216
+ return err('This product has no mobile device roster to list.');
217
+ try {
218
+ const devices = await adapter.listMobileDevices(platform.toUpperCase());
219
+ if (!devices.length) {
220
+ return ok(`No ${platform.toUpperCase()} devices are registered to this project. Connect a device ` +
221
+ `via the SimplifyQA desktop agent, then try again.`, { devices: [] });
222
+ }
223
+ // Group by deviceType so the client can present the same tabs the UI does (Real / Emulator
224
+ // / Simulator / Cloud Device). Cloud rows come from the configured farm (LT / BS / SL / SQA
225
+ // Device Farm); the row's `cloudDeviceType` names which one โ€” pass it back on `tc_run`.
226
+ const groups = {};
227
+ for (const d of devices) {
228
+ if (!groups[d.deviceType])
229
+ groups[d.deviceType] = [];
230
+ groups[d.deviceType].push(d);
231
+ }
232
+ const active = devices.filter((d) => d.active).length;
233
+ const sections = Object.entries(groups)
234
+ .map(([type, rows]) => `**${type}** (${rows.length})\n${rows.map(mobileDeviceLine).join('\n')}`)
235
+ .join('\n\n');
236
+ const text = `${devices.length} ${platform.toUpperCase()} device${devices.length === 1 ? '' : 's'} ยท ${active} active:\n\n` +
237
+ sections;
238
+ return ok(text, { devices, active });
239
+ }
240
+ catch (e) {
241
+ return err(describeError(e));
242
+ }
243
+ });
145
244
  server.registerTool('tc_run', {
146
- title: 'Run a test case (local or cloud; now or scheduled)',
245
+ title: 'Run a test case (local / remote / cloud; now or scheduled)',
147
246
  description: "Execute ONE existing test case in the connected product. ASK the user for EVERY field the " +
148
247
  "Execute dialog asks โ€” never assume โ€” and in ORDER: (1) `releaseId` (from list_releases; " +
149
248
  "pass '' if the user skips), (2) `sprintId` (from list_sprints; pass '' if the user skips), " +
150
249
  "(3) `versionId` (from tc_get's `versions[]`; pass '' to accept the TC's current version), " +
151
- '(4) `environment` (from tc_list_environments โ€” required), (5) `browser` (required), ' +
152
- '(6) `mode` local/cloud, (7) `when` immediate/schedule, (8) for a schedule the start time + ' +
153
- 'recurrence + timezone, (9) THEN offer the eligible agent/runner and pass the pick. The tool ' +
154
- 'REFUSES when any of (1)-(7) is omitted โ€” that is deliberate; it forces the ask instead of ' +
155
- 'silently defaulting. `mode` decides WHERE it runs:\n' +
156
- " โ€ข 'local' โ€” a local agent's own browser. Pass `agentId` from local_agents. For when:'immediate' only " +
157
- "ONLINE agents are eligible (a run-now can't target an offline agent; omit to auto-pick your own online " +
158
- "agent). For when:'schedule' ANY agent (online OR offline) is eligible and `agentId` is REQUIRED.\n" +
250
+ '(4) `environment` (from tc_list_environments โ€” required), (5) FOR WEB TCs: `browser` ' +
251
+ '(required); FOR ANDROID/IOS TCs: skip browser โ€” `deviceType` + `deviceId` are asked in step ' +
252
+ "(9) instead (call `mobile_list_devices` to enumerate), (6) `mode` local/remote/cloud, (7) " +
253
+ "`when` immediate/schedule, (8) for a schedule the start time + recurrence + timezone, (9) " +
254
+ 'THEN offer the eligible agent/runner and (for mobile) the device โ€” pass the picks. The tool ' +
255
+ 'REFUSES when any of (1)-(7) is omitted, and additionally when `deviceType`/`deviceId` are ' +
256
+ 'omitted on a mobile TC โ€” that is deliberate; it forces the ask instead of silently ' +
257
+ 'defaulting. `mode` decides WHERE it runs:\n' +
258
+ " โ€ข 'local' โ€” THIS machine's desktop agent (its own browser). Call `local_agents` (0 or 1 row per box). " +
259
+ "For when:'immediate' the tool first probes the desktop agent (:4040) and refuses cleanly if it's not up. " +
260
+ "Omit `agentId` to auto-pick this machine's agent (or pass it explicitly โ€” must be this machine's row). " +
261
+ "For when:'schedule' `agentId` is REQUIRED.\n" +
262
+ " โ€ข 'remote' โ€” ANOTHER registered machine's desktop agent (a teammate's box). Call `remote_agents` and " +
263
+ "OFFER the list; `agentId` is REQUIRED (no auto-pick across boxes). For when:'immediate' only ONLINE " +
264
+ "remote agents are eligible; for when:'schedule' ANY remote agent works (it runs when it fires).\n" +
159
265
  " โ€ข 'cloud' โ€” an online cloud runner VM (ONLINE-only whether now or scheduled). Pass `cloudRunnerId` " +
160
266
  'from cloud_runners; for a schedule omit it to let the backend auto-pick a live VM at fire time.\n' +
161
- 'IF THE USER HAS NOT SAID local or cloud, ASK โ€” this tool refuses when `mode` is omitted. For cloud, if ' +
162
- 'no runner is online it reports that there are no active cloud runners (nothing is run).\n' +
267
+ 'IF THE USER HAS NOT SAID local, remote, or cloud, ASK โ€” this tool refuses when `mode` is omitted. For cloud, ' +
268
+ 'if no runner is online it reports that there are no active cloud runners (nothing is run).\n' +
163
269
  '`when` decides WHEN it runs (this tool REFUSES when `when` is omitted โ€” ASK the user):\n' +
164
270
  " โ€ข 'immediate' โ€” dispatch now; returns the run id โ€” poll run_status for the result.\n" +
165
271
  " โ€ข 'schedule' โ€” create a schedule instead (requires `scheduledFor`); ASK the start time, recurrence, " +
@@ -167,8 +273,8 @@ export function registerRunTools(server, deps) {
167
273
  'NOTE: MANUAL test cases cannot be run or scheduled by an agent โ€” the backend rejects them.',
168
274
  inputSchema: {
169
275
  tcId: z.string().describe('The test case id (or business key) to run.'),
170
- mode: z.enum(['local', 'cloud']).optional().describe("Where to run. REQUIRED โ€” ASK the user if unstated (the tool refuses when omitted)."),
171
- agentId: z.string().optional().describe("For local: a specific agent credential_id (from local_agents). when:'immediate' โ†’ must be an ONLINE agent (or omit to auto-pick your own online agent); when:'schedule' โ†’ REQUIRED, and may be an offline agent (it runs when it fires)."),
276
+ mode: z.enum(['local', 'remote', 'cloud']).optional().describe("Where to run. REQUIRED โ€” ASK the user if unstated (the tool refuses when omitted)."),
277
+ agentId: z.string().optional().describe("For local: a specific agent credential_id from `local_agents` (must be THIS machine's row; omit to auto-pick). For remote: REQUIRED โ€” a credential_id from `remote_agents` (a teammate's row). when:'immediate' โ†’ the picked agent must be ONLINE; when:'schedule' โ†’ offline agents are OK (they run when they fire) and `agentId` is REQUIRED for both local and remote."),
172
278
  releaseId: z.string().optional().describe('Tag the run with a RELEASE (from list_releases). Optional in the UI; omit to leave untagged.'),
173
279
  sprintId: z.string().optional().describe('Tag the run with a SPRINT (from list_sprints). Optional in the UI; omit to leave untagged.'),
174
280
  versionId: z.string().optional().describe("Override the TC's currently-selected VERSION for this run (an id from the TC's `versions[]`). Omit to run the current version โ€” that's what the UI's Version chip defaults to."),
@@ -176,7 +282,10 @@ export function registerRunTools(server, deps) {
176
282
  browser: z.string().optional().describe("Browser, default 'chromium' (bundled + isolated). Others: 'chrome'/'msedge' (installed channel), 'firefox', 'webkit'. NOTE: a schedule accepts only chromium/msedge/firefox/webkit."),
177
283
  headless: z.boolean().optional().describe('Headless. Defaults: cloud=true, local=false. (Immediate runs only.)'),
178
284
  cloudRunnerId: z.string().optional().describe("For cloud: a specific ONLINE runner id (from cloud_runners). For when:'schedule' omit to auto-pick a live VM at fire time."),
179
- deviceId: z.string().optional().describe("REQUIRED for ANDROID/IOS test cases โ€” the target device id. LOCAL: adb serial or emulator name. CLOUD: the marketplace device name. Enumerate with android_list_devices."),
285
+ deviceId: z.string().optional().describe("REQUIRED for ANDROID/IOS test cases โ€” the target device id. LOCAL: adb serial or emulator name. CLOUD: the marketplace device name. Enumerate with `mobile_list_devices`."),
286
+ deviceType: z.enum(['real_device', 'emulator', 'simulator', 'cloud_device', 'cloud_emulator', 'cloud_simulator']).optional().describe("REQUIRED for ANDROID/IOS test cases โ€” the KIND of device the picked `deviceId` is (read off the matching row from `mobile_list_devices`). Drives the wire execution platform's `type` field."),
287
+ cloudDeviceType: z.enum(['lambda_test', 'browser_stack', 'sauce_labs', 'simplify_qa_device_form']).optional().describe("REQUIRED when `deviceType` is cloud_* โ€” which cloud farm the device came from (read off the matching row's `cloudDeviceType` from `mobile_list_devices`). Note SIMPLIFY_QA_DEVICE_FORM's legacy 'FORM' spelling โ€” mirrors the SQA wire schema exactly."),
288
+ osVersion: z.string().optional().describe("Optional OS version to stamp on the wire platform object for ANDROID/IOS โ€” read off the matching row from `mobile_list_devices`. Ignored for non-mobile tcTypes."),
180
289
  when: z.enum(['immediate', 'schedule']).optional().describe("When to run โ€” REQUIRED, ASK the user if unstated (the tool refuses when omitted). 'immediate' dispatches now; 'schedule' creates a schedule (needs scheduledFor)."),
181
290
  scheduledFor: z.string().optional().describe("REQUIRED for when:'schedule' โ€” ISO datetime of the first/only run (e.g. '2026-08-20T09:00:00Z')."),
182
291
  scheduleType: z.enum(['once', 'hourly', 'daily', 'weekdays', 'weekly', 'monthly']).optional().describe("Recurrence for a schedule. Default 'once'. hourly/daily/weekdays require an endDate."),
@@ -184,7 +293,7 @@ export function registerRunTools(server, deps) {
184
293
  timezone: z.string().optional().describe("IANA timezone for a schedule (e.g. 'Asia/Kolkata'). Default 'UTC'."),
185
294
  },
186
295
  annotations: { readOnlyHint: false },
187
- }, async ({ tcId, mode, agentId, environment, browser, headless, cloudRunnerId, deviceId, when, scheduledFor, scheduleType, endDate, timezone, releaseId, sprintId, versionId }) => {
296
+ }, async ({ tcId, mode, agentId, environment, browser, headless, cloudRunnerId, deviceId, deviceType, cloudDeviceType, osVersion, when, scheduledFor, scheduleType, endDate, timezone, releaseId, sprintId, versionId }) => {
188
297
  const adapter = deps.getAdapter();
189
298
  if (!adapter)
190
299
  return err(NOT_CONNECTED);
@@ -199,25 +308,49 @@ export function registerRunTools(server, deps) {
199
308
  return err("ASK the user which SPRINT this run belongs to (from list_sprints), then call tc_run " +
200
309
  "again with sprintId. If the user wants no sprint, pass sprintId: '' (empty string).");
201
310
  }
311
+ // Pre-flight the TC once โ€” needed both to inline the version list (below) and to know the
312
+ // tcType (WEB vs ANDROID/IOS/โ€ฆ) so the refuse cascade asks the right platform-specific
313
+ // fields: browser for WEB, deviceType + deviceId for mobile. Mirrors tc_get's refusal at
314
+ // discovery.tools.ts:226; without this the model guesses a version NAME and the backend 500s.
315
+ let preview;
316
+ try {
317
+ preview = await adapter.getTestCase(tcId);
318
+ }
319
+ catch (e) {
320
+ return err(describeError(e));
321
+ }
322
+ const tcType = (preview.technologyType ?? 'WEB').toUpperCase();
323
+ const isMobile = tcType === 'ANDROID' || tcType === 'IOS';
202
324
  if (versionId === undefined) {
203
- return err("ASK the user which VERSION of the test case to run (from tc_get's `versions[]`), then " +
204
- "call tc_run again with versionId. To accept the TC's currently-selected version, " +
205
- "pass versionId: '' (empty string).");
325
+ const vs = preview.versions ?? [];
326
+ if (vs.length > 1) {
327
+ const listing = vs
328
+ .map((v) => ` โ€ข \`${v.id}\` โ€” **${v.name}**${v.isCurrent ? ' (current)' : ''}`)
329
+ .join('\n');
330
+ return err(`Test case ${preview.id ?? tcId} has ${vs.length} versions โ€” ASK the user which ` +
331
+ `one to run, then call tc_run again with versionId (pass the version id OR its ` +
332
+ `name โ€” either resolves). Pass versionId: '' to accept the TC's current ` +
333
+ `version.\n\n${listing}`);
334
+ }
335
+ versionId = '';
206
336
  }
207
337
  if (!environment) {
208
338
  return err('ASK the user which ENVIRONMENT to run against (from tc_list_environments), then call ' +
209
339
  'tc_run again with environment set to the chosen env key. Required โ€” the UI marks this ' +
210
340
  'field with a red asterisk.');
211
341
  }
212
- if (!browser) {
342
+ // Browser is a WEB-only field โ€” the SQA Execute dialog doesn't render it for mobile TCs
343
+ // (they show a device picker instead). Skip the refuse for ANDROID/IOS; the adapter's
344
+ // buildRunPlatform will ignore any browser passed alongside a mobile deviceId.
345
+ if (!isMobile && !browser) {
213
346
  return err("ASK the user which BROWSER to run in (chromium / chrome / msedge / firefox / webkit), " +
214
347
  'then call tc_run again with browser set. Required โ€” the UI marks this field with a ' +
215
348
  'red asterisk.');
216
349
  }
217
350
  if (!mode) {
218
- return err("Specify where to run: call tc_run again with mode: 'local' or 'cloud'. " +
219
- 'If the user has not said which, ask them first โ€” local runs on a local agent, ' +
220
- 'cloud runs on an online cloud runner VM.');
351
+ return err("Specify where to run: call tc_run again with mode: 'local', 'remote', or 'cloud'. " +
352
+ "If the user has not said which, ASK them first โ€” local runs on THIS machine's agent, " +
353
+ "remote runs on a teammate's registered agent, cloud runs on an online cloud runner VM.");
221
354
  }
222
355
  // Never assume now-vs-scheduled โ€” it decides which agents are eligible (online-only for now,
223
356
  // online+offline for a local schedule) and, for a schedule, the start time asked next.
@@ -252,13 +385,48 @@ export function registerRunTools(server, deps) {
252
385
  }
253
386
  // when:'schedule' with no cloudRunnerId โ†’ leave unpinned; the backend auto-picks a live VM at fire time.
254
387
  }
255
- // LOCAL run-NOW to a specific agent must be online (can't dispatch to an offline agent). A local
256
- // SCHEDULE may target an offline agent โ€” that path is handled in the schedule branch below.
257
- if (mode === 'local' && when === 'immediate' && agentId) {
388
+ // LOCAL/REMOTE run-NOW to a specific agent must be online (can't dispatch to an offline agent).
389
+ // The SCHEDULE branch below allows an offline agent since it runs when it fires. REMOTE also
390
+ // REQUIRES an explicit agentId at all times โ€” auto-pick across teammate boxes is unsafe.
391
+ if (mode === 'remote' && !agentId) {
392
+ return err("REMOTE runs need an explicit agentId. Call remote_agents to see the teammate machines, " +
393
+ "then pass one as agentId (must be an ONLINE row for when:'immediate').");
394
+ }
395
+ if ((mode === 'local' || mode === 'remote') && when === 'immediate' && agentId) {
258
396
  const offlineMsg = await localAgentOfflineForNow(adapter, agentId);
259
397
  if (offlineMsg)
260
398
  return err(offlineMsg);
261
399
  }
400
+ // Mobile refuse cascade โ€” asked AFTER mode/when so the client can filter the device list by
401
+ // where it's going to run. Order mirrors the UI: pick the KIND of device (Real / Emulator /
402
+ // Cloud โ€ฆ), then the specific device off the matching tab. Cloud tabs show even in LOCAL
403
+ // mode (see device-dropdown.component.html:104-111) โ€” LOCAL orchestrator can drive a cloud
404
+ // device via Appium.
405
+ if (isMobile) {
406
+ if (!deviceType) {
407
+ const platform = tcType.toLowerCase();
408
+ const localKinds = platform === 'ios' ? "'real_device', 'simulator'" : "'real_device', 'emulator'";
409
+ return err(`ASK the user which DEVICE TYPE this ${tcType} run should use: ${localKinds}, or ` +
410
+ `'cloud_device' / 'cloud_emulator' / 'cloud_simulator' for a farm device. Then call ` +
411
+ `tc_run again with deviceType set. (Also call mobile_list_devices({ platform: '${platform}' }) ` +
412
+ `to show the available devices grouped by kind โ€” the user picks a device and passes ` +
413
+ `\`id\` as deviceId, its \`deviceType\`, and โ€” for cloud rows โ€” its \`cloudDeviceType\`.)`);
414
+ }
415
+ const isCloudPick = deviceType.startsWith('cloud_');
416
+ if (isCloudPick && !cloudDeviceType) {
417
+ const platform = tcType.toLowerCase();
418
+ return err(`Cloud device picks need \`cloudDeviceType\` โ€” which farm the device came from. Call ` +
419
+ `mobile_list_devices({ platform: '${platform}' }); the matching row's \`cloudDeviceType\` ` +
420
+ `is one of 'lambda_test' / 'browser_stack' / 'sauce_labs' / 'simplify_qa_device_form'. ` +
421
+ `Pass it alongside deviceType and deviceId.`);
422
+ }
423
+ if (!deviceId) {
424
+ const platform = tcType.toLowerCase();
425
+ return err(`ASK the user which DEVICE to run on. Call mobile_list_devices({ platform: '${platform}' }) ` +
426
+ `and offer the rows whose deviceType matches '${deviceType}' โ€” the user picks one; pass its ` +
427
+ `\`id\` as deviceId (and optionally its \`osVersion\`) to tc_run.`);
428
+ }
429
+ }
262
430
  if (when === 'schedule') {
263
431
  if (!adapter.createSchedule)
264
432
  return err('This product does not support scheduling runs.');
@@ -267,7 +435,8 @@ export function registerRunTools(server, deps) {
267
435
  "ISO datetime (e.g. '2026-08-20T09:00:00Z'). Ask the user when the run should start (and the " +
268
436
  'recurrence, end date and timezone if any).');
269
437
  }
270
- // A schedule fires detached from any live session โ€” a LOCAL schedule must pin a specific agent.
438
+ // A schedule fires detached from any live session โ€” a LOCAL or REMOTE schedule must pin a
439
+ // specific agent (mode:'remote' already fenced above, so this catches LOCAL).
271
440
  if (mode === 'local' && !agentId)
272
441
  return err(await localScheduleNeedsAgent(adapter));
273
442
  // Cloud: pin the runner's CREDENTIAL only when the user explicitly picked one; otherwise leave
@@ -276,7 +445,7 @@ export function registerRunTools(server, deps) {
276
445
  const schedOpts = {
277
446
  mode,
278
447
  scheduledFor,
279
- ...(mode === 'local' && agentId ? { agentId } : {}),
448
+ ...((mode === 'local' || mode === 'remote') && agentId ? { agentId } : {}),
280
449
  ...(pinnedRunner?.credentialId ? { cloudRunnerCredentialId: pinnedRunner.credentialId } : {}),
281
450
  ...(browser !== undefined ? { browser } : {}),
282
451
  ...(environment !== undefined ? { environmentId: environment } : {}),
@@ -300,12 +469,15 @@ export function registerRunTools(server, deps) {
300
469
  }
301
470
  const opts = {
302
471
  mode,
303
- ...(mode === 'local' && agentId ? { agentId } : {}),
472
+ ...((mode === 'local' || mode === 'remote') && agentId ? { agentId } : {}),
304
473
  ...(environment !== undefined ? { environment } : {}),
305
474
  ...(browser !== undefined ? { browser } : {}),
306
475
  ...(headless !== undefined ? { headless } : {}),
307
476
  ...(resolvedRunner ? { cloudRunnerId: resolvedRunner.id } : {}),
308
477
  ...(deviceId !== undefined ? { deviceId } : {}),
478
+ ...(deviceType !== undefined ? { deviceType: deviceType.toUpperCase() } : {}),
479
+ ...(cloudDeviceType !== undefined ? { cloudDeviceType: cloudDeviceType.toUpperCase() } : {}),
480
+ ...(osVersion !== undefined ? { osVersion } : {}),
309
481
  ...(releaseId ? { releaseId } : {}),
310
482
  ...(sprintId ? { sprintId } : {}),
311
483
  ...(versionId ? { versionId } : {}),
@@ -314,7 +486,9 @@ export function registerRunTools(server, deps) {
314
486
  const run = await adapter.startRun({ tcId }, opts);
315
487
  const where = mode === 'cloud'
316
488
  ? `on cloud runner ${resolvedRunner ? `**${resolvedRunner.name}**` : ''}`
317
- : `locally${agentId ? ` on agent \`${agentId}\`` : ' on your online agent'}`;
489
+ : mode === 'remote'
490
+ ? `on remote agent \`${agentId}\``
491
+ : `locally${agentId ? ` on agent \`${agentId}\`` : ' on your online agent'}`;
318
492
  return ok(`Started ${tcId} ${where}. Run id \`${run.runId}\` โ€” poll run_status({ runId: "${run.runId}" }) for progress.`, { run, mode, ...(resolvedRunner ? { runner: resolvedRunner } : {}) });
319
493
  }
320
494
  catch (e) {
@@ -322,20 +496,22 @@ export function registerRunTools(server, deps) {
322
496
  }
323
497
  });
324
498
  server.registerTool('suite_run', {
325
- title: 'Run a test suite (local or cloud; now or scheduled)',
499
+ title: 'Run a test suite (local / remote / cloud; now or scheduled)',
326
500
  description: 'Execute an existing test SUITE โ€” all its member test cases, in order โ€” in the connected product. ' +
327
501
  'ASK the user for EVERY field the UI\'s Execute-suite dialog asks โ€” never assume โ€” and in ORDER ' +
328
502
  '(mirrors the product UI, `_parentComponent==="SU"`): (1) `releaseId` (from list_releases โ€” the UI ' +
329
503
  'marks it required with `*` for a suite), (2) `sprintId` (from list_sprints โ€” also `*` for a suite), ' +
330
- '(3) `environment` (from tc_list_environments โ€” `*`), (4) `mode` local/cloud, (5) `when` immediate/schedule, ' +
504
+ '(3) `environment` (from tc_list_environments โ€” `*`), (4) `mode` local/remote/cloud, (5) `when` immediate/schedule, ' +
331
505
  '(6) for a schedule the start time + recurrence + timezone, (7) THEN offer the eligible agent/runner. ' +
332
506
  'The tool REFUSES when any of (1)โ€“(5) is omitted โ€” that is deliberate; it forces the ask instead of ' +
333
507
  'silently defaulting. UNLIKE `tc_run`, a suite does NOT ask for `versionId` or `browser` โ€” each TC ' +
334
508
  "in the suite carries its own pinned version + execution platform (that's what the suite editor picks per row). " +
335
509
  '`mode` decides WHERE it runs:\n' +
336
- " โ€ข 'local' โ€” a local agent's own browser, always serial. Pass `agentId` from local_agents. For " +
337
- "when:'immediate' only ONLINE agents are eligible (omit to auto-pick your own online agent); for " +
338
- "when:'schedule' ANY agent (online OR offline) is eligible and `agentId` is REQUIRED.\n" +
510
+ " โ€ข 'local' โ€” THIS machine's desktop agent, always serial. Call `local_agents` (0 or 1 row). For " +
511
+ "when:'immediate' the tool probes :4040 and refuses if the desktop agent is down. Omit `agentId` to " +
512
+ "auto-pick this machine's agent; for when:'schedule' `agentId` is REQUIRED.\n" +
513
+ " โ€ข 'remote' โ€” ANOTHER registered machine's desktop agent, always serial. Call `remote_agents`; " +
514
+ "`agentId` is REQUIRED (no auto-pick). when:'immediate' โ†’ ONLINE only; when:'schedule' โ†’ offline OK.\n" +
339
515
  " โ€ข 'cloud' โ€” online cloud runner VM(s) (ONLINE-only whether now or scheduled). Pass `cloudRunnerId` " +
340
516
  'from cloud_runners; for a schedule omit it to auto-pick a live VM at fire time.\n' +
341
517
  'For cloud, if no runner is online it reports that there are no active cloud runners (nothing is run).\n' +
@@ -349,8 +525,8 @@ export function registerRunTools(server, deps) {
349
525
  suiteId: z.string().describe('The suite id to run (from suite_list / suite_get).'),
350
526
  releaseId: z.string().optional().describe('REQUIRED for a suite (UI marks it `*` for _parentComponent==="SU"). A release id from list_releases. Ask the user.'),
351
527
  sprintId: z.string().optional().describe('REQUIRED for a suite (UI marks it `*` for _parentComponent==="SU"). A sprint id from list_sprints (scoped to the picked release). Ask the user.'),
352
- mode: z.enum(['local', 'cloud']).optional().describe('Where to run. REQUIRED โ€” ASK the user if unstated (the tool refuses when omitted).'),
353
- agentId: z.string().optional().describe("For local: a specific agent credential_id (from local_agents). when:'immediate' โ†’ must be ONLINE (or omit to auto-pick your own online agent); when:'schedule' โ†’ REQUIRED, may be offline."),
528
+ mode: z.enum(['local', 'remote', 'cloud']).optional().describe('Where to run. REQUIRED โ€” ASK the user if unstated (the tool refuses when omitted).'),
529
+ agentId: z.string().optional().describe("For local: a credential_id from `local_agents` (this machine only; omit to auto-pick). For remote: REQUIRED โ€” a credential_id from `remote_agents`. when:'immediate' โ†’ picked agent must be ONLINE; when:'schedule' โ†’ offline OK, agentId REQUIRED for both local and remote."),
354
530
  environment: z.string().optional().describe('Environment key (from tc_list_environments). REQUIRED โ€” the UI marks this with a red asterisk for a suite.'),
355
531
  browser: z.string().optional().describe("Optional at the SUITE level โ€” each TC in the suite has its own execution platform (that's the suite-editor pick per row). Provide only to OVERRIDE for every WEB TC. Values: 'chromium' (default), 'chrome'/'msedge', 'firefox', 'webkit'. NOTE: a schedule accepts only chromium/msedge/firefox/webkit."),
356
532
  headless: z.boolean().optional().describe('Headless. Defaults: cloud=true, local=false. (Immediate runs only.)'),
@@ -389,9 +565,9 @@ export function registerRunTools(server, deps) {
389
565
  'field with a red asterisk for a suite.');
390
566
  }
391
567
  if (!mode) {
392
- return err("Specify where to run: call suite_run again with mode: 'local' or 'cloud'. " +
393
- 'If the user has not said which, ask them first โ€” local runs on a local agent, ' +
394
- 'cloud runs on online cloud runner VM(s).');
568
+ return err("Specify where to run: call suite_run again with mode: 'local', 'remote', or 'cloud'. " +
569
+ "If the user has not said which, ASK them first โ€” local runs on THIS machine's agent, " +
570
+ "remote runs on a teammate's registered agent, cloud runs on online cloud runner VM(s).");
395
571
  }
396
572
  // Never assume now-vs-scheduled โ€” it decides which agents are eligible (online-only for now,
397
573
  // online+offline for a local schedule) and, for a schedule, the start time asked next.
@@ -426,9 +602,13 @@ export function registerRunTools(server, deps) {
426
602
  }
427
603
  // when:'schedule' with no cloudRunnerId โ†’ leave unpinned; the backend auto-picks a live VM at fire time.
428
604
  }
429
- // LOCAL run-NOW to a specific agent must be online (can't dispatch to an offline agent). A local
430
- // SCHEDULE may target an offline agent โ€” that path is handled in the schedule branch below.
431
- if (mode === 'local' && when === 'immediate' && agentId) {
605
+ // LOCAL/REMOTE run-NOW to a specific agent must be online. SCHEDULE branch below allows offline.
606
+ // REMOTE always requires an explicit agentId (auto-pick across teammate boxes is unsafe).
607
+ if (mode === 'remote' && !agentId) {
608
+ return err("REMOTE runs need an explicit agentId. Call remote_agents to see the teammate machines, " +
609
+ "then pass one as agentId (must be an ONLINE row for when:'immediate').");
610
+ }
611
+ if ((mode === 'local' || mode === 'remote') && when === 'immediate' && agentId) {
432
612
  const offlineMsg = await localAgentOfflineForNow(adapter, agentId);
433
613
  if (offlineMsg)
434
614
  return err(offlineMsg);
@@ -442,7 +622,8 @@ export function registerRunTools(server, deps) {
442
622
  'recurrence, end date and timezone if any). NOTE: strategy/maxConcurrent/terminateOnFailure are ' +
443
623
  "immediate-run only โ€” a schedule uses the suite's own settings.");
444
624
  }
445
- // A schedule fires detached from any live session โ€” a LOCAL schedule must pin a specific agent.
625
+ // A schedule fires detached from any live session โ€” LOCAL and REMOTE schedules must pin a
626
+ // specific agent. REMOTE was already fenced above; this catches LOCAL.
446
627
  if (mode === 'local' && !agentId)
447
628
  return err(await localScheduleNeedsAgent(adapter));
448
629
  // Cloud: pin the runner's CREDENTIAL only when the user explicitly picked one; otherwise leave
@@ -451,7 +632,7 @@ export function registerRunTools(server, deps) {
451
632
  const schedOpts = {
452
633
  mode,
453
634
  scheduledFor,
454
- ...(mode === 'local' && agentId ? { agentId } : {}),
635
+ ...((mode === 'local' || mode === 'remote') && agentId ? { agentId } : {}),
455
636
  ...(pinnedRunner?.credentialId ? { cloudRunnerCredentialId: pinnedRunner.credentialId } : {}),
456
637
  ...(browser !== undefined ? { browser } : {}),
457
638
  ...(environment !== undefined ? { environmentId: environment } : {}),
@@ -475,7 +656,7 @@ export function registerRunTools(server, deps) {
475
656
  }
476
657
  const opts = {
477
658
  mode,
478
- ...(mode === 'local' && agentId ? { agentId } : {}),
659
+ ...((mode === 'local' || mode === 'remote') && agentId ? { agentId } : {}),
479
660
  ...(environment !== undefined ? { environment } : {}),
480
661
  ...(browser !== undefined ? { browser } : {}),
481
662
  ...(headless !== undefined ? { headless } : {}),
@@ -490,7 +671,9 @@ export function registerRunTools(server, deps) {
490
671
  const run = await adapter.startRun({ suiteId }, opts);
491
672
  const where = mode === 'cloud'
492
673
  ? `on cloud runner ${resolvedRunner ? `**${resolvedRunner.name}**` : ''}`
493
- : `locally${agentId ? ` on agent \`${agentId}\`` : ' on your online agent'}`;
674
+ : mode === 'remote'
675
+ ? `on remote agent \`${agentId}\``
676
+ : `locally${agentId ? ` on agent \`${agentId}\`` : ' on your online agent'}`;
494
677
  return ok(`Started suite ${suiteId} ${where}. Run id \`${run.runId}\` โ€” poll run_status({ runId: "${run.runId}" }) for progress.`, { run, mode, ...(resolvedRunner ? { runner: resolvedRunner } : {}) });
495
678
  }
496
679
  catch (e) {
@@ -499,16 +682,33 @@ export function registerRunTools(server, deps) {
499
682
  });
500
683
  server.registerTool('run_status', {
501
684
  title: 'Get a run status',
502
- description: 'Read the status of a run started by tc_run โ€” state (queued/running/passed/failed/stopped/error), ' +
503
- 'step progress, and a link. Requires a live connection.',
504
- inputSchema: { runId: z.string().describe('The run id returned by tc_run.') },
685
+ description: 'Read the status of a run โ€” state (queued/running/passed/failed/stopped/error), progress, ' +
686
+ 'per-test-case rollup for a SUITE run (capped at 100 shown), and a link. Pass EITHER `runId` ' +
687
+ '(from tc_run / suite_run) or `suiteId` (from suite_list) to read that suite\'s LATEST ' +
688
+ 'execution. Requires a live connection.',
689
+ inputSchema: {
690
+ runId: z.string().optional().describe('The run id returned by tc_run or suite_run.'),
691
+ suiteId: z.string().optional().describe('A suite id (from suite_list) โ€” resolves to the suite\'s latest execution.'),
692
+ },
505
693
  annotations: { readOnlyHint: true },
506
- }, async ({ runId }) => {
694
+ }, async ({ runId, suiteId }) => {
507
695
  const adapter = deps.getAdapter();
508
696
  if (!adapter)
509
697
  return err(NOT_CONNECTED);
698
+ if ((runId && suiteId) || (!runId && !suiteId)) {
699
+ return err('Pass exactly one of `runId` or `suiteId`.');
700
+ }
510
701
  try {
511
- const status = await adapter.getRun(runId);
702
+ let effectiveRunId = runId;
703
+ if (suiteId) {
704
+ if (!adapter.getLatestSuiteRun)
705
+ return err('This product does not support latest-suite-run lookup โ€” pass `runId` instead.');
706
+ const latest = await adapter.getLatestSuiteRun(suiteId);
707
+ if (!latest)
708
+ return ok(`Suite ${suiteId} has no executions yet.`);
709
+ effectiveRunId = latest;
710
+ }
711
+ const status = await adapter.getRun(effectiveRunId);
512
712
  return ok(statusText(status), { run: status });
513
713
  }
514
714
  catch (e) {