common-qa-mcp 0.7.1 → 0.8.1

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 (39) hide show
  1. package/dist/adapters/remoteMcp/adapter.js +1 -1
  2. package/dist/adapters/simplifyqa/adapter.d.ts +24 -0
  3. package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -1
  4. package/dist/adapters/simplifyqa/adapter.js +370 -28
  5. package/dist/adapters/simplifyqa/adapter.js.map +1 -1
  6. package/dist/adapters/simplifyqa/mapper.d.ts +2 -2
  7. package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -1
  8. package/dist/adapters/simplifyqa/mapper.js +257 -64
  9. package/dist/adapters/simplifyqa/mapper.js.map +1 -1
  10. package/dist/domain/types.d.ts +36 -6
  11. package/dist/domain/types.d.ts.map +1 -1
  12. package/dist/domain/validate.d.ts.map +1 -1
  13. package/dist/domain/validate.js +5 -1
  14. package/dist/domain/validate.js.map +1 -1
  15. package/dist/initAgents.d.ts.map +1 -1
  16. package/dist/initAgents.js +18 -11
  17. package/dist/initAgents.js.map +1 -1
  18. package/dist/server.d.ts.map +1 -1
  19. package/dist/server.js +6 -1
  20. package/dist/server.js.map +1 -1
  21. package/dist/tools/recording.tools.d.ts.map +1 -1
  22. package/dist/tools/recording.tools.js +12 -1
  23. package/dist/tools/recording.tools.js.map +1 -1
  24. package/dist/tools/run.tools.d.ts +4 -0
  25. package/dist/tools/run.tools.d.ts.map +1 -1
  26. package/dist/tools/run.tools.js +228 -116
  27. package/dist/tools/run.tools.js.map +1 -1
  28. package/dist/tools/schemas.d.ts +41 -49
  29. package/dist/tools/schemas.d.ts.map +1 -1
  30. package/dist/tools/schemas.js +19 -1
  31. package/dist/tools/schemas.js.map +1 -1
  32. package/dist/tools/tcEditPublisher.d.ts +20 -0
  33. package/dist/tools/tcEditPublisher.d.ts.map +1 -0
  34. package/dist/tools/tcEditPublisher.js +46 -0
  35. package/dist/tools/tcEditPublisher.js.map +1 -0
  36. package/dist/tools/testcase.tools.d.ts.map +1 -1
  37. package/dist/tools/testcase.tools.js +314 -12
  38. package/dist/tools/testcase.tools.js.map +1 -1
  39. package/package.json +1 -1
@@ -1,6 +1,27 @@
1
1
  import { z } from 'zod';
2
2
  import { ok, err } from '../mcpResult.js';
3
3
  const NOT_CONNECTED = 'Not connected to a product. Use qa_connect({ url, token, projectId }) first — running a test needs a live backend connection.';
4
+ const lastRun = new Map();
5
+ function summarizeLastRun(c) {
6
+ const parts = [];
7
+ if (c.when)
8
+ parts.push(`when=${c.when}`);
9
+ if (c.mode)
10
+ parts.push(`mode=${c.mode}`);
11
+ if (c.releaseId !== undefined)
12
+ parts.push(`release=${c.releaseId || '(none)'}`);
13
+ if (c.sprintId !== undefined)
14
+ parts.push(`sprint=${c.sprintId || '(none)'}`);
15
+ if (c.environment)
16
+ parts.push(`env=${c.environment}`);
17
+ if (c.browser)
18
+ parts.push(`browser=${c.browser}`);
19
+ if (c.agentId)
20
+ parts.push(`agent=${c.agentId}`);
21
+ if (c.cloudRunnerId)
22
+ parts.push(`runner=${c.cloudRunnerId}`);
23
+ return parts.join(', ');
24
+ }
4
25
  function describeError(e) {
5
26
  return e instanceof Error ? e.message : String(e);
6
27
  }
@@ -82,18 +103,32 @@ async function localAgentOfflineForNow(adapter, agentId) {
82
103
  function statusText(s) {
83
104
  const unit = s.children ? 'test cases' : 'steps';
84
105
  const prog = s.progress ? ` · ${s.progress.done}/${s.progress.total} ${unit}` : '';
106
+ const when = s.startedAt ? ` · started ${s.startedAt}${s.endedAt ? ` · ended ${s.endedAt}` : ''}` : '';
85
107
  const link = s.reportUrl ? `\n${s.reportUrl}` : '';
86
- const icon = (state) => (state === 'passed' ? '✅' : state === 'failed' ? '❌' : state === 'running' ? '⏳' : '•');
87
- // Per-state tally across children (suite runs only). Kept concise — only non-zero buckets, in a
88
- // stable order so the header reads the same every poll.
108
+ const icon = (state) => state === 'passed' ? '✅'
109
+ : state === 'failed' ? '❌'
110
+ : state === 'running' ? '⏳'
111
+ : state === 'skipped' ? '⏭️'
112
+ : state === 'notExecuted' ? '➖'
113
+ : state === 'stopped' ? '⏹️'
114
+ : state === 'error' ? '⚠️'
115
+ : '•';
116
+ // Per-state tally across children (suite runs only) — EVERY state SQA actually reports gets its
117
+ // own bucket (skipped/notExecuted/error included, not folded into another), so the sum below is
118
+ // guaranteed to equal `total` by construction (every child has exactly one state). Kept concise —
119
+ // only non-zero buckets, in a stable order so the header reads the same every poll.
120
+ const isProvisional = s.state === 'running' || s.state === 'queued';
89
121
  const tally = s.children?.length
90
122
  ? (() => {
91
- const order = ['passed', 'failed', 'running', 'queued', 'stopped', 'error'];
123
+ const order = ['passed', 'failed', 'skipped', 'notExecuted', 'running', 'queued', 'stopped', 'error'];
92
124
  const counts = new Map();
93
125
  for (const c of s.children)
94
126
  counts.set(c.state, (counts.get(c.state) ?? 0) + 1);
95
127
  const parts = order.filter((k) => counts.get(k)).map((k) => `${counts.get(k)} ${k}`);
96
- return parts.length ? ` · ${parts.join(', ')}` : '';
128
+ // One row per SUITE MEMBER (test case), never per iteration — counts below are per test
129
+ // case, stated explicitly since a member with multiple testdata iterations still counts once.
130
+ const label = isProvisional ? ' (provisional — run still in progress)' : '';
131
+ return parts.length ? ` · ${parts.join(', ')} [per test case]${label}` : '';
97
132
  })()
98
133
  : '';
99
134
  const CHILD_CAP = 100;
@@ -112,7 +147,7 @@ function statusText(s) {
112
147
  return `${stIcon} step ${st.n}: ${st.description} (${st.status})${errLine}`;
113
148
  }).join('\n')
114
149
  : '';
115
- return `Run ${s.runId} · **${s.state}**${prog}${tally}${link}${kids}${steps}`;
150
+ return `Run ${s.runId} · **${s.state}**${prog}${when}${tally}${link}${kids}${steps}`;
116
151
  }
117
152
  export function registerRunTools(server, deps) {
118
153
  server.registerTool('cloud_runners', {
@@ -252,18 +287,25 @@ export function registerRunTools(server, deps) {
252
287
  });
253
288
  server.registerTool('tc_run', {
254
289
  title: 'Run a test case (local / remote / cloud; now or scheduled)',
255
- description: "Execute ONE existing test case in the connected product. ASK the user for EVERY field the " +
256
- "Execute dialog asks — never assume — and in ORDER: (1) `releaseId` (from list_releases; " +
257
- "pass '' if the user skips), (2) `sprintId` (from list_sprints; pass '' if the user skips), " +
258
- "(3) `versionId` (from tc_get's `versions[]`; pass '' to accept the TC's current version), " +
259
- '(4) `environment` (from tc_list_environments — required), (5) FOR WEB TCs: `browser` ' +
260
- '(required); FOR ANDROID/IOS TCs: skip browser — `deviceType` + `deviceId` are asked in step ' +
261
- "(9) instead (call `mobile_list_devices` to enumerate), (6) `mode` local/remote/cloud, (7) " +
262
- "`when` immediate/schedule, (8) for a schedule the start time + recurrence + timezone, (9) " +
263
- 'THEN offer the eligible agent/runner and (for mobile) the device — pass the picks. The tool ' +
264
- 'REFUSES when any of (1)-(7) is omitted, and additionally when `deviceType`/`deviceId` are ' +
265
- 'omitted on a mobile TC — that is deliberate; it forces the ask instead of silently ' +
266
- 'defaulting. `mode` decides WHERE it runs:\n' +
290
+ description: "★ ORDER OF QUESTIONS (STRICT — applies whether you ask one at a time OR bundle several into a " +
291
+ "single message): when → mode → release → sprint → version → environment → browser/device → " +
292
+ "schedule fields → agent. Ask in THIS order, always — never schema-declaration order, never " +
293
+ "refusal-arrival order, never a 'natural UI' order (release first is WRONG). Skip a step only " +
294
+ "when the flag below says it's skippable (sprint when release='', version when TC has one " +
295
+ "version, browser vs. device by TC type). ★\n\n" +
296
+ "Execute ONE existing test case in the connected product. ASK the user for EVERY field the " +
297
+ "Execute dialog asks — never assume — in this DEPENDENCY ORDER: (0) if a previous run in this " +
298
+ "session left a config for the project, the tool refuses first asking `reuseConfig` (true=merge " +
299
+ "remembered mode/release/sprint/env/browser/agent, false=start fresh); (1) `when` " +
300
+ "immediate/schedule, (2) `mode` local/remote/cloud, (3) `releaseId` (from list_releases; pass " +
301
+ "'' to skip — no release), (4) `sprintId` (from list_sprints; skipped entirely when " +
302
+ "releaseId=''), (5) `versionId` (from tc_get's `versions[]`; pass '' for current), (6) " +
303
+ "`environment` (from tc_list_environments), (7) FOR WEB TCs: `browser`; FOR ANDROID/IOS TCs: " +
304
+ "`deviceType` + `deviceId` (via `mobile_list_devices`), (8) for a schedule the start time + " +
305
+ "recurrence + timezone, (9) THEN offer the eligible agent/runner and pass the pick. IF YOU " +
306
+ "BATCH SEVERAL REFUSED FIELDS INTO ONE AskUserQuestion, ORDER the questions EXACTLY as above " +
307
+ "(when → mode → release → sprint → version → environment → browser/device → schedule fields " +
308
+ "→ agent) — never in schema order or refusal order. `mode` decides WHERE it runs:\n" +
267
309
  " • 'local' — THIS machine's desktop agent (its own browser). Call `local_agents` (0 or 1 row per box). " +
268
310
  "For when:'immediate' the tool first probes the desktop agent (:4040) and refuses cleanly if it's not up. " +
269
311
  "Omit `agentId` to auto-pick this machine's agent (or pass it explicitly — must be this machine's row). " +
@@ -273,54 +315,91 @@ export function registerRunTools(server, deps) {
273
315
  "remote agents are eligible; for when:'schedule' ANY remote agent works (it runs when it fires).\n" +
274
316
  " • 'cloud' — an online cloud runner VM (ONLINE-only whether now or scheduled). Pass `cloudRunnerId` " +
275
317
  'from cloud_runners; for a schedule omit it to let the backend auto-pick a live VM at fire time.\n' +
276
- 'IF THE USER HAS NOT SAID local, remote, or cloud, ASK — this tool refuses when `mode` is omitted. For cloud, ' +
277
- 'if no runner is online it reports that there are no active cloud runners (nothing is run).\n' +
278
- '`when` decides WHEN it runs (this tool REFUSES when `when` is omitted — ASK the user):\n' +
279
- " • 'immediate' — dispatch now; returns the run id — poll run_status for the result.\n" +
280
- " • 'schedule' — create a schedule instead (requires `scheduledFor`); ASK the start time, recurrence, " +
281
- 'end date and timezone BEFORE offering an agent. Returns the schedule id.\n' +
318
+ 'For cloud, if no runner is online it reports that there are no active cloud runners (nothing is run).\n' +
282
319
  'NOTE: MANUAL test cases cannot be run or scheduled by an agent — the backend rejects them.',
283
320
  inputSchema: {
284
321
  tcId: z.string().describe('The test case id (or business key) to run.'),
285
- mode: z.enum(['local', 'remote', 'cloud']).optional().describe("Where to run. REQUIRED — ASK the user if unstated (the tool refuses when omitted)."),
286
- 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."),
287
- releaseId: z.string().optional().describe('Tag the run with a RELEASE (from list_releases). Optional in the UI; omit to leave untagged.'),
288
- sprintId: z.string().optional().describe('Tag the run with a SPRINT (from list_sprints). Optional in the UI; omit to leave untagged.'),
289
- 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."),
290
- environment: z.string().optional().describe('Environment key (from tc_list_environments). ASK the user before running — the UI marks this required.'),
291
- 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."),
322
+ reuseConfig: z.boolean().optional().describe("Set only when the tool refused asking about REUSE. true = merge the previous run's mode/release/sprint/env/browser/agent/runner into any unset field; false = ignore prior config and walk the full cascade. Omit on a first run."),
323
+ when: z.enum(['immediate', 'schedule']).optional().describe("STEP 1 — when to run. REQUIRED, ASK the user if unstated (the tool refuses when omitted). 'immediate' dispatches now; 'schedule' creates a schedule (needs scheduledFor)."),
324
+ mode: z.enum(['local', 'remote', 'cloud']).optional().describe("STEP 2 — where to run. REQUIRED, ASK the user if unstated."),
325
+ releaseId: z.string().optional().describe("STEP 3 — tag the run with a RELEASE (from list_releases). Optional in the UI; pass '' after asking to skip. The tool refuses when undefined so the user is asked."),
326
+ sprintId: z.string().optional().describe("STEP 4 — tag the run with a SPRINT (from list_sprints, scoped to release). Only asked when releaseId is non-empty; skipped entirely when releaseId=''. Pass '' after asking to skip."),
327
+ versionId: z.string().optional().describe("STEP 5 — override the TC's currently-selected VERSION for this run (an id or name from the TC's `versions[]`). Pass '' to accept the TC's current version."),
328
+ environment: z.string().optional().describe('STEP 6 — environment key (from tc_list_environments). ASK the user; the UI marks this required.'),
329
+ browser: z.string().optional().describe("STEP 7 (WEB TCs) — browser, default 'chromium' (bundled + isolated). Others: 'chrome'/'msedge' (installed channel), 'firefox', 'webkit'. NOTE: a schedule accepts only chromium/msedge/firefox/webkit."),
330
+ deviceId: z.string().optional().describe("STEP 7 (ANDROID/IOS TCs) — the target device id. LOCAL: adb serial or emulator name. CLOUD: the marketplace device name. Enumerate with `mobile_list_devices`."),
331
+ deviceType: z.enum(['real_device', 'emulator', 'simulator', 'cloud_device', 'cloud_emulator', 'cloud_simulator']).optional().describe("STEP 7 (ANDROID/IOS TCs) — the KIND of device the picked `deviceId` is (read off the matching row from `mobile_list_devices`)."),
332
+ 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`)."),
333
+ 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`."),
334
+ agentId: z.string().optional().describe("STEP 9 — for local: a credential_id from `local_agents` (must be THIS machine's row; omit to auto-pick). For remote: REQUIRED — a credential_id from `remote_agents`. when:'immediate' → the picked agent must be ONLINE; when:'schedule' → offline OK and `agentId` is REQUIRED for both local and remote."),
335
+ cloudRunnerId: z.string().optional().describe("STEP 9 (cloud) — a specific ONLINE runner id (from cloud_runners). For when:'schedule' omit to auto-pick a live VM at fire time."),
292
336
  headless: z.boolean().optional().describe('Headless. Defaults: cloud=true, local=false. (Immediate runs only.)'),
293
- 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."),
294
- 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`."),
295
- 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."),
296
- 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."),
297
- 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."),
298
- 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)."),
299
- scheduledFor: z.string().optional().describe("REQUIRED for when:'schedule' — ISO datetime of the first/only run (e.g. '2026-08-20T09:00:00Z')."),
337
+ scheduledFor: z.string().optional().describe("STEP 8 — REQUIRED for when:'schedule'; ISO datetime of the first/only run (e.g. '2026-08-20T09:00:00Z')."),
300
338
  scheduleType: z.enum(['once', 'hourly', 'daily', 'weekdays', 'weekly', 'monthly']).optional().describe("Recurrence for a schedule. Default 'once'. hourly/daily/weekdays require an endDate."),
301
339
  endDate: z.string().optional().describe('For a recurring schedule — ISO end date (required for hourly/daily/weekdays).'),
302
340
  timezone: z.string().optional().describe("IANA timezone for a schedule (e.g. 'Asia/Kolkata'). Default 'UTC'."),
303
341
  },
304
342
  annotations: { readOnlyHint: false },
305
- }, async ({ tcId, mode, agentId, environment, browser, headless, cloudRunnerId, deviceId, deviceType, cloudDeviceType, osVersion, when, scheduledFor, scheduleType, endDate, timezone, releaseId, sprintId, versionId }) => {
343
+ }, async ({ tcId, reuseConfig, when, mode, agentId, environment, browser, headless, cloudRunnerId, deviceId, deviceType, cloudDeviceType, osVersion, scheduledFor, scheduleType, endDate, timezone, releaseId, sprintId, versionId }) => {
306
344
  const adapter = deps.getAdapter();
307
345
  if (!adapter)
308
346
  return err(NOT_CONNECTED);
309
- // Mirror the Execute-dialog required-field enforcement — ASK the user for every field the UI
310
- // asks. Release/sprint/version are optional in the UI; pass '' after asking to accept the
311
- // default (untagged / TC's current version). Environment and browser have red asterisks.
347
+ // STEP 0 — reuse-last-config prompt. Fires only when the caller wired getProjectId AND there
348
+ // is a remembered config for that project AND the caller has not answered yet (undefined).
349
+ // Merging when `reuseConfig: true` fills only fields left unset by this call — the user can
350
+ // still override any single field explicitly. TC-specific fields (versionId, deviceId,
351
+ // schedule cadence) are never remembered — they belong to the run, not the environment.
352
+ const projectId = deps.getProjectId?.() ?? null;
353
+ const remembered = projectId ? lastRun.get(projectId) : undefined;
354
+ if (remembered && reuseConfig === undefined) {
355
+ return err(`Previous run in this project used: ${summarizeLastRun(remembered)}. ` +
356
+ 'ASK the user whether to reuse it — call tc_run again with `reuseConfig: true` to merge ' +
357
+ 'those defaults, or `reuseConfig: false` to start fresh and be asked each field.');
358
+ }
359
+ if (reuseConfig === true && remembered) {
360
+ if (when === undefined)
361
+ when = remembered.when;
362
+ if (mode === undefined)
363
+ mode = remembered.mode;
364
+ if (releaseId === undefined)
365
+ releaseId = remembered.releaseId;
366
+ if (sprintId === undefined)
367
+ sprintId = remembered.sprintId;
368
+ if (environment === undefined)
369
+ environment = remembered.environment;
370
+ if (browser === undefined)
371
+ browser = remembered.browser;
372
+ if (agentId === undefined)
373
+ agentId = remembered.agentId;
374
+ if (cloudRunnerId === undefined)
375
+ cloudRunnerId = remembered.cloudRunnerId;
376
+ }
377
+ // STEP 1 — when. Decides schedule vs immediate; gates the schedule fields and the agent
378
+ // online-check downstream, so ask before anything else.
379
+ if (!when) {
380
+ return err("Specify WHEN to run: call tc_run again with when: 'immediate' (run now) or when: 'schedule' " +
381
+ '(create a schedule). If the user has not said, ask them first — never assume.');
382
+ }
383
+ // STEP 2 — mode. Decides the agent/runner pool and constrains the browser set for a schedule.
384
+ if (!mode) {
385
+ return err("Specify where to run: call tc_run again with mode: 'local', 'remote', or 'cloud'. " +
386
+ "If the user has not said which, ASK them first — local runs on THIS machine's agent, " +
387
+ "remote runs on a teammate's registered agent, cloud runs on an online cloud runner VM.");
388
+ }
389
+ // STEP 3 — release. Optional in the UI; the tool asks anyway (pass '' to skip).
312
390
  if (releaseId === undefined) {
313
391
  return err("ASK the user which RELEASE this run belongs to (from list_releases), then call tc_run " +
314
392
  "again with releaseId. If the user wants no release, pass releaseId: '' (empty string).");
315
393
  }
316
- if (sprintId === undefined) {
317
- return err("ASK the user which SPRINT this run belongs to (from list_sprints), then call tc_run " +
318
- "again with sprintId. If the user wants no sprint, pass sprintId: '' (empty string).");
394
+ // STEP 4 — sprint. Only asked when a release was picked; when release is skipped, sprint is
395
+ // skipped entirely (sprints scope to a release in the UI).
396
+ if (releaseId !== '' && sprintId === undefined) {
397
+ return err("ASK the user which SPRINT this run belongs to (from list_sprints, filtered by releaseId), " +
398
+ "then call tc_run again with sprintId. If the user wants no sprint, pass sprintId: '' (empty string).");
319
399
  }
320
- // Pre-flight the TC once — needed both to inline the version list (below) and to know the
321
- // tcType (WEB vs ANDROID/IOS/…) so the refuse cascade asks the right platform-specific
322
- // fields: browser for WEB, deviceType + deviceId for mobile. Mirrors tc_get's refusal at
323
- // discovery.tools.ts:226; without this the model guesses a version NAME and the backend 500s.
400
+ if (releaseId === '')
401
+ sprintId = '';
402
+ // STEP 5 — version. Needs the TC's tcType and version list, so pre-fetch once here.
324
403
  let preview;
325
404
  try {
326
405
  preview = await adapter.getTestCase(tcId);
@@ -343,30 +422,19 @@ export function registerRunTools(server, deps) {
343
422
  }
344
423
  versionId = '';
345
424
  }
425
+ // STEP 6 — environment.
346
426
  if (!environment) {
347
427
  return err('ASK the user which ENVIRONMENT to run against (from tc_list_environments), then call ' +
348
428
  'tc_run again with environment set to the chosen env key. Required — the UI marks this ' +
349
429
  'field with a red asterisk.');
350
430
  }
351
- // Browser is a WEB-only field — the SQA Execute dialog doesn't render it for mobile TCs
352
- // (they show a device picker instead). Skip the refuse for ANDROID/IOS; the adapter's
353
- // buildRunPlatform will ignore any browser passed alongside a mobile deviceId.
431
+ // STEP 7 (web only) — browser. Mobile branch handled after mode-dependent cloud/agent
432
+ // resolution, since the device roster is filtered by where it will run.
354
433
  if (!isMobile && !browser) {
355
434
  return err("ASK the user which BROWSER to run in (chromium / chrome / msedge / firefox / webkit), " +
356
435
  'then call tc_run again with browser set. Required — the UI marks this field with a ' +
357
436
  'red asterisk.');
358
437
  }
359
- if (!mode) {
360
- return err("Specify where to run: call tc_run again with mode: 'local', 'remote', or 'cloud'. " +
361
- "If the user has not said which, ASK them first — local runs on THIS machine's agent, " +
362
- "remote runs on a teammate's registered agent, cloud runs on an online cloud runner VM.");
363
- }
364
- // Never assume now-vs-scheduled — it decides which agents are eligible (online-only for now,
365
- // online+offline for a local schedule) and, for a schedule, the start time asked next.
366
- if (!when) {
367
- return err("Specify WHEN to run: call tc_run again with when: 'immediate' (run now) or when: 'schedule' " +
368
- '(create a schedule). If the user has not said, ask them first — never assume.');
369
- }
370
438
  let resolvedRunner;
371
439
  if (mode === 'cloud') {
372
440
  if (!adapter.listCloudRunners)
@@ -470,6 +538,8 @@ export function registerRunTools(server, deps) {
470
538
  const where = mode === 'cloud'
471
539
  ? (pinnedRunner ? `cloud runner **${pinnedRunner.name}**` : 'cloud (auto-pick a live VM at run time)')
472
540
  : `agent \`${agentId}\``;
541
+ if (projectId)
542
+ lastRun.set(projectId, { when, mode, releaseId, sprintId, environment, browser, agentId, cloudRunnerId });
473
543
  return ok(`Scheduled ${tcId} (${scheduleType ?? 'once'}, ${mode}) for ${scheduledFor}${next} → ${where}. Schedule id \`${sched.id}\`.`, { schedule: sched, mode, when: 'schedule', ...(pinnedRunner ? { runner: pinnedRunner } : {}) });
474
544
  }
475
545
  catch (e) {
@@ -498,6 +568,8 @@ export function registerRunTools(server, deps) {
498
568
  : mode === 'remote'
499
569
  ? `on remote agent \`${agentId}\``
500
570
  : `locally${agentId ? ` on agent \`${agentId}\`` : ' on your online agent'}`;
571
+ if (projectId)
572
+ lastRun.set(projectId, { when, mode, releaseId, sprintId, environment, browser, agentId, cloudRunnerId: resolvedRunner?.id ?? cloudRunnerId });
501
573
  return ok(`Started ${tcId} ${where}. Run id \`${run.runId}\` — poll run_status({ runId: "${run.runId}" }) for progress.`, { run, mode, ...(resolvedRunner ? { runner: resolvedRunner } : {}) });
502
574
  }
503
575
  catch (e) {
@@ -506,15 +578,24 @@ export function registerRunTools(server, deps) {
506
578
  });
507
579
  server.registerTool('suite_run', {
508
580
  title: 'Run a test suite (local / remote / cloud; now or scheduled)',
509
- description: 'Execute an existing test SUITE — all its member test cases, in order — in the connected product. ' +
510
- 'ASK the user for EVERY field the UI\'s Execute-suite dialog asks — never assume — and in ORDER ' +
511
- '(mirrors the product UI, `_parentComponent==="SU"`): (1) `releaseId` (from list_releases — the UI ' +
512
- 'marks it required with `*` for a suite), (2) `sprintId` (from list_sprints — also `*` for a suite), ' +
513
- '(3) `environment` (from tc_list_environments — `*`), (4) `mode` local/remote/cloud, (5) `when` immediate/schedule, ' +
514
- '(6) for a schedule the start time + recurrence + timezone, (7) THEN offer the eligible agent/runner. ' +
515
- 'The tool REFUSES when any of (1)–(5) is omitted — that is deliberate; it forces the ask instead of ' +
516
- 'silently defaulting. UNLIKE `tc_run`, a suite does NOT ask for `versionId` or `browser` — each TC ' +
517
- "in the suite carries its own pinned version + execution platform (that's what the suite editor picks per row). " +
581
+ description: "★ ORDER OF QUESTIONS (STRICT — applies whether you ask one at a time OR bundle several into a " +
582
+ "single message): when → mode → release → sprint → environment → schedule fields → agent. Ask " +
583
+ "in THIS order, always — never schema-declaration order, never refusal-arrival order, never " +
584
+ "a 'natural UI' order (release first is WRONG). Skip sprint when release=''. Suites do NOT " +
585
+ "ask version / browser / device — each TC in the suite carries its own. ★\n\n" +
586
+ 'Execute an existing test SUITE — all its member test cases, in order — in the connected product. ' +
587
+ "ASK the user for EVERY field the UI's Execute-suite dialog asks — never assume — in this " +
588
+ "DEPENDENCY ORDER: (0) if a previous run in this session left a config for the project, the tool " +
589
+ "refuses first asking `reuseConfig` (true=merge remembered mode/release/sprint/env/agent, " +
590
+ "false=start fresh); (1) `when` immediate/schedule, (2) `mode` local/remote/cloud, (3) " +
591
+ "`releaseId` (from list_releases; pass '' to skip), (4) `sprintId` (from list_sprints; skipped " +
592
+ "entirely when releaseId=''), (5) `environment` (from tc_list_environments), (6) for a schedule " +
593
+ "the start time + recurrence + timezone, (7) THEN offer the eligible agent/runner. IF YOU " +
594
+ "BATCH SEVERAL REFUSED FIELDS INTO ONE AskUserQuestion, ORDER the questions EXACTLY as above " +
595
+ "(when → mode → release → sprint → environment → schedule fields → agent) — never in schema " +
596
+ "order or refusal order. UNLIKE " +
597
+ "`tc_run`, a suite does NOT ask for `versionId` or `browser` — each TC in the suite carries its " +
598
+ "own pinned version + execution platform (that's what the suite editor picks per row). " +
518
599
  '`mode` decides WHERE it runs:\n' +
519
600
  " • 'local' — THIS machine's desktop agent, always serial. Call `local_agents` (0 or 1 row). For " +
520
601
  "when:'immediate' the tool probes :4040 and refuses if the desktop agent is down. Omit `agentId` to " +
@@ -524,66 +605,90 @@ export function registerRunTools(server, deps) {
524
605
  " • 'cloud' — online cloud runner VM(s) (ONLINE-only whether now or scheduled). Pass `cloudRunnerId` " +
525
606
  'from cloud_runners; for a schedule omit it to auto-pick a live VM at fire time.\n' +
526
607
  'For cloud, if no runner is online it reports that there are no active cloud runners (nothing is run).\n' +
527
- '`when` decides WHEN it runs:\n' +
528
- " • 'immediate' — dispatch now; returns the suite-run id — poll run_status for progress " +
529
- '(per-test-case results under `children`) and run_stop to cancel.\n' +
530
- " • 'schedule' — create a schedule instead (requires `scheduledFor`); ASK the start time, recurrence, " +
531
- 'end date and timezone BEFORE offering an agent. Returns the schedule id.\n' +
532
- 'NOTE: a suite of only MANUAL test cases cannot be run or scheduled by an agent — the backend rejects it.',
608
+ 'NOTE: manual suites go through the same dispatch — the backend stores them as an execution (no agent picks them up until steps are marked from the UI).',
533
609
  inputSchema: {
534
610
  suiteId: z.string().describe('The suite id to run (from suite_list / suite_get).'),
535
- releaseId: z.string().optional().describe('REQUIRED for a suite (UI marks it `*` for _parentComponent==="SU"). A release id from list_releases. Ask the user.'),
536
- 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.'),
537
- mode: z.enum(['local', 'remote', 'cloud']).optional().describe('Where to run. REQUIRED — ASK the user if unstated (the tool refuses when omitted).'),
538
- 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."),
539
- environment: z.string().optional().describe('Environment key (from tc_list_environments). REQUIRED — the UI marks this with a red asterisk for a suite.'),
611
+ reuseConfig: z.boolean().optional().describe("Set only when the tool refused asking about REUSE. true = merge the previous run's mode/release/sprint/env/browser/agent/runner into any unset field; false = ignore prior config. Omit on a first run."),
612
+ when: z.enum(['immediate', 'schedule']).optional().describe("STEP 1 — when to run. REQUIRED, ASK the user if unstated."),
613
+ mode: z.enum(['local', 'remote', 'cloud']).optional().describe('STEP 2 — where to run. REQUIRED, ASK the user if unstated.'),
614
+ releaseId: z.string().optional().describe("STEP 3 — release from list_releases. ASK the user; pass '' to skip (no release / no sprint asked)."),
615
+ sprintId: z.string().optional().describe("STEP 4 — sprint from list_sprints (scoped to release). Only asked when releaseId is non-empty; skipped when releaseId=''. Pass '' to skip."),
616
+ environment: z.string().optional().describe('STEP 5 — environment key (from tc_list_environments). ASK the user; the UI marks this required for a suite.'),
540
617
  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."),
541
618
  headless: z.boolean().optional().describe('Headless. Defaults: cloud=true, local=false. (Immediate runs only.)'),
542
- 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."),
543
- strategy: z.enum(['serial', 'batch', 'dynamic']).optional().describe('Cloud dispatch order across the suite (immediate runs). Default serial; local is always serial.'),
544
- maxConcurrent: z.number().int().positive().optional().describe('Cloud batch/dynamic only — max test cases running at once (immediate runs).'),
545
- terminateOnFailure: z.boolean().optional().describe('Stop dispatching remaining test cases after the first failure (immediate runs).'),
546
- 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)."),
547
- scheduledFor: z.string().optional().describe("REQUIRED for when:'schedule' — ISO datetime of the first/only run (e.g. '2026-08-20T09:00:00Z')."),
619
+ agentId: z.string().optional().describe("STEP 7 — 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."),
620
+ cloudRunnerId: z.string().optional().describe("STEP 7 (cloud) — a specific ONLINE runner id (from cloud_runners). For when:'schedule' omit to auto-pick a live VM at fire time."),
621
+ strategy: z.enum(['serial', 'parallel']).optional().describe("Dispatch order across the suite's test cases. Wire only accepts SERIAL / PARALLEL (backend validator). Default 'serial'; LOCAL is always serial regardless."),
622
+ maxConcurrent: z.number().int().positive().optional().describe('Cloud parallel only — maps to globalConfiguration.MAXIMUM_CONTAINER_COUNT on the wire.'),
623
+ terminateOnFailure: z.boolean().optional().describe('Stop dispatching remaining test cases after the first failure. Maps to globalConfiguration.TERMINATE_ON_FAIL on the wire.'),
624
+ scheduledFor: z.string().optional().describe("STEP 6 — REQUIRED for when:'schedule'; ISO datetime of the first/only run (e.g. '2026-08-20T09:00:00Z')."),
548
625
  scheduleType: z.enum(['once', 'hourly', 'daily', 'weekdays', 'weekly', 'monthly']).optional().describe("Recurrence for a schedule. Default 'once'. hourly/daily/weekdays require an endDate."),
549
626
  endDate: z.string().optional().describe('For a recurring schedule — ISO end date (required for hourly/daily/weekdays).'),
550
627
  timezone: z.string().optional().describe("IANA timezone for a schedule (e.g. 'Asia/Kolkata'). Default 'UTC'."),
551
628
  },
552
629
  annotations: { readOnlyHint: false },
553
- }, async ({ suiteId, releaseId, sprintId, mode, agentId, environment, browser, headless, cloudRunnerId, strategy, maxConcurrent, terminateOnFailure, when, scheduledFor, scheduleType, endDate, timezone }) => {
630
+ }, async ({ suiteId, reuseConfig, releaseId, sprintId, mode, agentId, environment, browser, headless, cloudRunnerId, strategy, maxConcurrent, terminateOnFailure, when, scheduledFor, scheduleType, endDate, timezone }) => {
554
631
  const adapter = deps.getAdapter();
555
632
  if (!adapter)
556
633
  return err(NOT_CONNECTED);
557
- // Mirror the Execute-suite dialog required-field enforcement (_parentComponent==='SU' branch of
558
- // execution-popup-modal.component.html) — release, sprint, environment all carry a red `*` for
559
- // a suite. UNLIKE tc_run, empty-string is NOT accepted: the UI blocks form submit until each is
560
- // picked. `versionId` and `browser` are TC-level in a suite (each row has its own) so we
561
- // don't ask for them.
562
- if (!releaseId) {
634
+ // STEP 0 — reuse-last-config prompt. Same shape as tc_run: fires only when the caller wired
635
+ // getProjectId AND there is a remembered config AND the user hasn't answered yet. Merges the
636
+ // environment-level fields (mode/release/sprint/env/browser/agent/runner) into any left unset.
637
+ const projectId = deps.getProjectId?.() ?? null;
638
+ const remembered = projectId ? lastRun.get(projectId) : undefined;
639
+ if (remembered && reuseConfig === undefined) {
640
+ return err(`Previous run in this project used: ${summarizeLastRun(remembered)}. ` +
641
+ 'ASK the user whether to reuse it — call suite_run again with `reuseConfig: true` to merge ' +
642
+ 'those defaults, or `reuseConfig: false` to start fresh.');
643
+ }
644
+ if (reuseConfig === true && remembered) {
645
+ if (when === undefined)
646
+ when = remembered.when;
647
+ if (mode === undefined)
648
+ mode = remembered.mode;
649
+ if (releaseId === undefined)
650
+ releaseId = remembered.releaseId;
651
+ if (sprintId === undefined)
652
+ sprintId = remembered.sprintId;
653
+ if (environment === undefined)
654
+ environment = remembered.environment;
655
+ if (browser === undefined)
656
+ browser = remembered.browser;
657
+ if (agentId === undefined)
658
+ agentId = remembered.agentId;
659
+ if (cloudRunnerId === undefined)
660
+ cloudRunnerId = remembered.cloudRunnerId;
661
+ }
662
+ // STEP 1 — when.
663
+ if (!when) {
664
+ return err("Specify WHEN to run: call suite_run again with when: 'immediate' (run now) or when: 'schedule' " +
665
+ '(create a schedule). If the user has not said, ask them first — never assume.');
666
+ }
667
+ // STEP 2 — mode.
668
+ if (!mode) {
669
+ return err("Specify where to run: call suite_run again with mode: 'local', 'remote', or 'cloud'. " +
670
+ "If the user has not said which, ASK them first — local runs on THIS machine's agent, " +
671
+ "remote runs on a teammate's registered agent, cloud runs on online cloud runner VM(s).");
672
+ }
673
+ // STEP 3 — release. Unified with tc_run: pass '' to skip (matches the UI's optional field).
674
+ if (releaseId === undefined) {
563
675
  return err("ASK the user which RELEASE this suite run belongs to (from list_releases), then call " +
564
- 'suite_run again with releaseId. Required for a suite — the UI marks it with a red asterisk.');
676
+ "suite_run again with releaseId. If the user wants no release, pass releaseId: '' (empty string).");
565
677
  }
566
- if (!sprintId) {
678
+ // STEP 4 — sprint. Only asked when release is non-empty.
679
+ if (releaseId !== '' && sprintId === undefined) {
567
680
  return err("ASK the user which SPRINT this suite run belongs to (from list_sprints, filtered by " +
568
- 'releaseId), then call suite_run again with sprintId. Required for a suite — the UI marks it ' +
569
- 'with a red asterisk.');
681
+ "releaseId), then call suite_run again with sprintId. If the user wants no sprint, pass " +
682
+ "sprintId: '' (empty string).");
570
683
  }
684
+ if (releaseId === '')
685
+ sprintId = '';
686
+ // STEP 5 — environment.
571
687
  if (!environment) {
572
688
  return err('ASK the user which ENVIRONMENT to run against (from tc_list_environments), then call ' +
573
689
  'suite_run again with environment set to the chosen env key. Required — the UI marks this ' +
574
690
  'field with a red asterisk for a suite.');
575
691
  }
576
- if (!mode) {
577
- return err("Specify where to run: call suite_run again with mode: 'local', 'remote', or 'cloud'. " +
578
- "If the user has not said which, ASK them first — local runs on THIS machine's agent, " +
579
- "remote runs on a teammate's registered agent, cloud runs on online cloud runner VM(s).");
580
- }
581
- // Never assume now-vs-scheduled — it decides which agents are eligible (online-only for now,
582
- // online+offline for a local schedule) and, for a schedule, the start time asked next.
583
- if (!when) {
584
- return err("Specify WHEN to run: call suite_run again with when: 'immediate' (run now) or when: 'schedule' " +
585
- '(create a schedule). If the user has not said, ask them first — never assume.');
586
- }
587
692
  let resolvedRunner;
588
693
  if (mode === 'cloud') {
589
694
  if (!adapter.listCloudRunners)
@@ -657,6 +762,8 @@ export function registerRunTools(server, deps) {
657
762
  const where = mode === 'cloud'
658
763
  ? (pinnedRunner ? `cloud runner **${pinnedRunner.name}**` : 'cloud (auto-pick a live VM at run time)')
659
764
  : `agent \`${agentId}\``;
765
+ if (projectId)
766
+ lastRun.set(projectId, { when, mode, releaseId, sprintId, environment, browser, agentId, cloudRunnerId });
660
767
  return ok(`Scheduled suite ${suiteId} (${scheduleType ?? 'once'}, ${mode}) for ${scheduledFor}${next} → ${where}. Schedule id \`${sched.id}\`.`, { schedule: sched, mode, when: 'schedule', ...(pinnedRunner ? { runner: pinnedRunner } : {}) });
661
768
  }
662
769
  catch (e) {
@@ -683,6 +790,8 @@ export function registerRunTools(server, deps) {
683
790
  : mode === 'remote'
684
791
  ? `on remote agent \`${agentId}\``
685
792
  : `locally${agentId ? ` on agent \`${agentId}\`` : ' on your online agent'}`;
793
+ if (projectId)
794
+ lastRun.set(projectId, { when, mode, releaseId, sprintId, environment, browser, agentId, cloudRunnerId: resolvedRunner?.id ?? cloudRunnerId });
686
795
  return ok(`Started suite ${suiteId} ${where}. Run id \`${run.runId}\` — poll run_status({ runId: "${run.runId}" }) for progress.`, { run, mode, ...(resolvedRunner ? { runner: resolvedRunner } : {}) });
687
796
  }
688
797
  catch (e) {
@@ -691,11 +800,14 @@ export function registerRunTools(server, deps) {
691
800
  });
692
801
  server.registerTool('run_status', {
693
802
  title: 'Get a run status',
694
- description: 'Read the status of a run — state (queued/running/passed/failed/stopped/error), progress, ' +
695
- 'per-test-case rollup for a SUITE run (capped at 100 shown), and a link. Pass EITHER `runId` ' +
696
- '(from tc_run / suite_run) or `suiteId` (from suite_list) to read that suite\'s LATEST ' +
697
- 'execution.\n\n' +
698
- 'The suite rollup only says pass/fail per test case, never WHY — to get one child\'s own ' +
803
+ description: 'Read the status of a run — state (queued/running/passed/failed/skipped/notExecuted/stopped/' +
804
+ 'error), progress, per-test-case rollup for a SUITE run (capped at 100 shown, ONE ROW PER ' +
805
+ 'TEST CASE — not per iteration), and a link. Every state SQA reports is its own bucket in the ' +
806
+ 'per-state tally (skipped and notExecuted are real, distinct outcomes — never counted as ' +
807
+ 'passed or folded into queued). While the run is still queued/running, the tally is labelled ' +
808
+ 'provisional. Pass EITHER `runId` (from tc_run / suite_run) or `suiteId` (from suite_list) to ' +
809
+ 'read that suite\'s LATEST execution.\n\n' +
810
+ 'The suite rollup only says pass/fail/etc per test case, never WHY — to get one child\'s own ' +
699
811
  'step-level detail (which step failed, the error), call again with the SAME `runId`/`suiteId` ' +
700
812
  'plus `tcId` set to one of the ids shown in the rollup. Requires a live connection.',
701
813
  inputSchema: {