common-qa-mcp 0.8.0 → 0.9.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 (48) hide show
  1. package/dist/adapters/remoteMcp/adapter.js +1 -1
  2. package/dist/adapters/simplifyqa/adapter.d.ts +48 -4
  3. package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -1
  4. package/dist/adapters/simplifyqa/adapter.js +585 -63
  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/adapters/types.d.ts +10 -1
  11. package/dist/adapters/types.d.ts.map +1 -1
  12. package/dist/domain/types.d.ts +46 -4
  13. package/dist/domain/types.d.ts.map +1 -1
  14. package/dist/domain/validate.d.ts.map +1 -1
  15. package/dist/domain/validate.js +5 -1
  16. package/dist/domain/validate.js.map +1 -1
  17. package/dist/drafts/store.d.ts +9 -0
  18. package/dist/drafts/store.d.ts.map +1 -1
  19. package/dist/drafts/store.js +12 -0
  20. package/dist/drafts/store.js.map +1 -1
  21. package/dist/initAgents.d.ts.map +1 -1
  22. package/dist/initAgents.js +48 -16
  23. package/dist/initAgents.js.map +1 -1
  24. package/dist/server.d.ts.map +1 -1
  25. package/dist/server.js +6 -1
  26. package/dist/server.js.map +1 -1
  27. package/dist/tools/recording.tools.d.ts.map +1 -1
  28. package/dist/tools/recording.tools.js +234 -0
  29. package/dist/tools/recording.tools.js.map +1 -1
  30. package/dist/tools/run.tools.d.ts +4 -0
  31. package/dist/tools/run.tools.d.ts.map +1 -1
  32. package/dist/tools/run.tools.js +360 -114
  33. package/dist/tools/run.tools.js.map +1 -1
  34. package/dist/tools/schemas.d.ts +41 -49
  35. package/dist/tools/schemas.d.ts.map +1 -1
  36. package/dist/tools/schemas.js +19 -1
  37. package/dist/tools/schemas.js.map +1 -1
  38. package/dist/tools/tcEditPublisher.d.ts +20 -0
  39. package/dist/tools/tcEditPublisher.d.ts.map +1 -0
  40. package/dist/tools/tcEditPublisher.js +46 -0
  41. package/dist/tools/tcEditPublisher.js.map +1 -0
  42. package/dist/tools/testcase.tools.d.ts.map +1 -1
  43. package/dist/tools/testcase.tools.js +303 -10
  44. package/dist/tools/testcase.tools.js.map +1 -1
  45. package/dist/tools/testdata.tools.d.ts.map +1 -1
  46. package/dist/tools/testdata.tools.js +60 -6
  47. package/dist/tools/testdata.tools.js.map +1 -1
  48. 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
  }
@@ -119,14 +140,87 @@ function statusText(s) {
119
140
  (total > 0 ? '\n\nTo see WHY a specific one passed/failed, call run_status again with the same runId ' +
120
141
  'plus `tcId` set to one of the ids above.' : '')
121
142
  : '';
143
+ // First 5 non-blank lines of the raw error string — the previous single-line slice usually
144
+ // showed a "[ERROR] session …" log prefix instead of the actual exception. Enough context for
145
+ // the LLM to name the failure ("NoSuchElement on #foo") without dumping a full stack.
146
+ const errBlock = (raw) => {
147
+ if (!raw)
148
+ return '';
149
+ const lines = raw.split('\n').map((l) => l.trim()).filter((l) => l.length > 0).slice(0, 5);
150
+ return lines.map((l) => ` ${l}`).join('\n');
151
+ };
152
+ const stepIcon = (st) => st === 'passed' ? '✅' : st === 'failed' ? '❌' : st === 'skipped' ? '⏭️' : '•';
122
153
  const steps = s.stepResults?.length
123
154
  ? '\n\n' + s.stepResults.map((st) => {
124
- const stIcon = st.status === 'passed' ? '✅' : st.status === 'failed' ? '❌' : '•';
125
- const errLine = st.error ? `\n ${st.error.split('\n')[0]}` : '';
126
- return `${stIcon} step ${st.n}: ${st.description} (${st.status})${errLine}`;
155
+ const desc = st.description?.trim() || '(no description)';
156
+ const err = errBlock(st.error);
157
+ return `${stepIcon(st.status)} step ${st.n}: ${desc} (${st.status})${err ? '\n' + err : ''}`;
158
+ }).join('\n')
159
+ : '';
160
+ // Failed-step summary: hoist FAILED rows to the top so the LLM sees "which step, why" without
161
+ // scanning the full list. Only fires when the run itself is failed AND at least one step row is
162
+ // failed (a run can be marked FAILED before any step logs, in which case the reportUrl is the
163
+ // only lead). Multi-step failures are all listed — cascading failures often clarify the root.
164
+ const failedSteps = (s.stepResults ?? []).filter((st) => st.status === 'failed');
165
+ const failedSummary = s.state === 'failed' && failedSteps.length
166
+ ? '\n\n**Failed at:**\n' + failedSteps.map((st) => {
167
+ const desc = st.description?.trim() || '(no description)';
168
+ const err = errBlock(st.error);
169
+ return `- step ${st.n}: ${desc}${err ? '\n' + err : ''}`;
127
170
  }).join('\n')
128
171
  : '';
129
- return `Run ${s.runId} · **${s.state}**${prog}${when}${tally}${link}${kids}${steps}`;
172
+ return `Run ${s.runId} · **${s.state}**${prog}${when}${tally}${link}${failedSummary}${kids}${steps}`;
173
+ }
174
+ /** Compact per-run bullet list for `run_history`. Matches the icon vocabulary `statusText` uses so
175
+ * the two outputs feel like one surface. Duration only shows when both timestamps are present
176
+ * (running/queued rows will read as `-> (running)` without a `(53s)` suffix). */
177
+ function renderRunHistory(type, entityId, runs) {
178
+ const stateIcon = (st) => st === 'passed' ? '✅'
179
+ : st === 'failed' ? '❌'
180
+ : st === 'running' ? '⏳'
181
+ : st === 'skipped' ? '⏭️'
182
+ : st === 'notExecuted' ? '➖'
183
+ : st === 'stopped' ? '⏹️'
184
+ : st === 'error' ? '⚠️'
185
+ : '•';
186
+ const durationOf = (r) => {
187
+ if (!r.startedAt || !r.endedAt)
188
+ return '';
189
+ const start = Date.parse(r.startedAt);
190
+ const end = Date.parse(r.endedAt);
191
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end < start)
192
+ return '';
193
+ const secs = Math.round((end - start) / 1000);
194
+ if (secs < 60)
195
+ return ` (${secs}s)`;
196
+ const m = Math.floor(secs / 60);
197
+ const s = secs % 60;
198
+ return ` (${m}m ${s.toString().padStart(2, '0')}s)`;
199
+ };
200
+ const when = (r) => {
201
+ if (r.startedAt && r.endedAt)
202
+ return ` · ${r.startedAt} → ${r.endedAt}${durationOf(r)}`;
203
+ if (r.startedAt)
204
+ return ` · started ${r.startedAt}`;
205
+ return '';
206
+ };
207
+ const tally = (r) => {
208
+ if (type !== 'SUITE' || r.totalTestCases === undefined)
209
+ return '';
210
+ const parts = [`${r.passed ?? 0}✅`];
211
+ parts.push(`${r.failed ?? 0}❌`);
212
+ parts.push(`${r.skipped ?? 0}⏭`);
213
+ if ((r.notExecuted ?? 0) > 0)
214
+ parts.push(`${r.notExecuted}➖`);
215
+ return ` · ${parts.join('/')} of ${r.totalTestCases}`;
216
+ };
217
+ const label = type === 'SUITE' ? 'suite' : 'test case';
218
+ const header = `Recent executions of ${label} ${entityId} (${runs.length} shown):\n`;
219
+ const lines = runs.map((r) => `- ${stateIcon(r.state)} ${r.runId} · ${r.state}${tally(r)}${when(r)}`);
220
+ const hint = type === 'SUITE'
221
+ ? '\n\nCall run_status({ runId: "<id>" }) for the per-test-case rollup, then again with tcId for a child\'s steps.'
222
+ : '\n\nCall run_status({ runId: "<id>" }) for step-level detail on any one.';
223
+ return header + '\n' + lines.join('\n') + hint;
130
224
  }
131
225
  export function registerRunTools(server, deps) {
132
226
  server.registerTool('cloud_runners', {
@@ -266,18 +360,25 @@ export function registerRunTools(server, deps) {
266
360
  });
267
361
  server.registerTool('tc_run', {
268
362
  title: 'Run a test case (local / remote / cloud; now or scheduled)',
269
- description: "Execute ONE existing test case in the connected product. ASK the user for EVERY field the " +
270
- "Execute dialog asks — never assume — and in ORDER: (1) `releaseId` (from list_releases; " +
271
- "pass '' if the user skips), (2) `sprintId` (from list_sprints; pass '' if the user skips), " +
272
- "(3) `versionId` (from tc_get's `versions[]`; pass '' to accept the TC's current version), " +
273
- '(4) `environment` (from tc_list_environments — required), (5) FOR WEB TCs: `browser` ' +
274
- '(required); FOR ANDROID/IOS TCs: skip browser — `deviceType` + `deviceId` are asked in step ' +
275
- "(9) instead (call `mobile_list_devices` to enumerate), (6) `mode` local/remote/cloud, (7) " +
276
- "`when` immediate/schedule, (8) for a schedule the start time + recurrence + timezone, (9) " +
277
- 'THEN offer the eligible agent/runner and (for mobile) the device — pass the picks. The tool ' +
278
- 'REFUSES when any of (1)-(7) is omitted, and additionally when `deviceType`/`deviceId` are ' +
279
- 'omitted on a mobile TC — that is deliberate; it forces the ask instead of silently ' +
280
- 'defaulting. `mode` decides WHERE it runs:\n' +
363
+ description: "★ ORDER OF QUESTIONS (STRICT — applies whether you ask one at a time OR bundle several into a " +
364
+ "single message): when → mode → release → sprint → version → environment → browser/device → " +
365
+ "schedule fields → agent. Ask in THIS order, always — never schema-declaration order, never " +
366
+ "refusal-arrival order, never a 'natural UI' order (release first is WRONG). Skip a step only " +
367
+ "when the flag below says it's skippable (sprint when release='', version when TC has one " +
368
+ "version, browser vs. device by TC type). ★\n\n" +
369
+ "Execute ONE existing test case in the connected product. ASK the user for EVERY field the " +
370
+ "Execute dialog asks — never assume — in this DEPENDENCY ORDER: (0) if a previous run in this " +
371
+ "session left a config for the project, the tool refuses first asking `reuseConfig` (true=merge " +
372
+ "remembered mode/release/sprint/env/browser/agent, false=start fresh); (1) `when` " +
373
+ "immediate/schedule, (2) `mode` local/remote/cloud, (3) `releaseId` (from list_releases; pass " +
374
+ "'' to skip — no release), (4) `sprintId` (from list_sprints; skipped entirely when " +
375
+ "releaseId=''), (5) `versionId` (from tc_get's `versions[]`; pass '' for current), (6) " +
376
+ "`environment` (from tc_list_environments), (7) FOR WEB TCs: `browser`; FOR ANDROID/IOS TCs: " +
377
+ "`deviceType` + `deviceId` (via `mobile_list_devices`), (8) for a schedule the start time + " +
378
+ "recurrence + timezone, (9) THEN offer the eligible agent/runner and pass the pick. IF YOU " +
379
+ "BATCH SEVERAL REFUSED FIELDS INTO ONE AskUserQuestion, ORDER the questions EXACTLY as above " +
380
+ "(when → mode → release → sprint → version → environment → browser/device → schedule fields " +
381
+ "→ agent) — never in schema order or refusal order. `mode` decides WHERE it runs:\n" +
281
382
  " • 'local' — THIS machine's desktop agent (its own browser). Call `local_agents` (0 or 1 row per box). " +
282
383
  "For when:'immediate' the tool first probes the desktop agent (:4040) and refuses cleanly if it's not up. " +
283
384
  "Omit `agentId` to auto-pick this machine's agent (or pass it explicitly — must be this machine's row). " +
@@ -287,54 +388,91 @@ export function registerRunTools(server, deps) {
287
388
  "remote agents are eligible; for when:'schedule' ANY remote agent works (it runs when it fires).\n" +
288
389
  " • 'cloud' — an online cloud runner VM (ONLINE-only whether now or scheduled). Pass `cloudRunnerId` " +
289
390
  'from cloud_runners; for a schedule omit it to let the backend auto-pick a live VM at fire time.\n' +
290
- 'IF THE USER HAS NOT SAID local, remote, or cloud, ASK — this tool refuses when `mode` is omitted. For cloud, ' +
291
- 'if no runner is online it reports that there are no active cloud runners (nothing is run).\n' +
292
- '`when` decides WHEN it runs (this tool REFUSES when `when` is omitted — ASK the user):\n' +
293
- " • 'immediate' — dispatch now; returns the run id — poll run_status for the result.\n" +
294
- " • 'schedule' — create a schedule instead (requires `scheduledFor`); ASK the start time, recurrence, " +
295
- 'end date and timezone BEFORE offering an agent. Returns the schedule id.\n' +
391
+ 'For cloud, if no runner is online it reports that there are no active cloud runners (nothing is run).\n' +
296
392
  'NOTE: MANUAL test cases cannot be run or scheduled by an agent — the backend rejects them.',
297
393
  inputSchema: {
298
394
  tcId: z.string().describe('The test case id (or business key) to run.'),
299
- mode: z.enum(['local', 'remote', 'cloud']).optional().describe("Where to run. REQUIRED — ASK the user if unstated (the tool refuses when omitted)."),
300
- 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."),
301
- releaseId: z.string().optional().describe('Tag the run with a RELEASE (from list_releases). Optional in the UI; omit to leave untagged.'),
302
- sprintId: z.string().optional().describe('Tag the run with a SPRINT (from list_sprints). Optional in the UI; omit to leave untagged.'),
303
- 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."),
304
- environment: z.string().optional().describe('Environment key (from tc_list_environments). ASK the user before running — the UI marks this required.'),
305
- 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."),
395
+ 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."),
396
+ 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)."),
397
+ mode: z.enum(['local', 'remote', 'cloud']).optional().describe("STEP 2 — where to run. REQUIRED, ASK the user if unstated."),
398
+ 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."),
399
+ 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."),
400
+ 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."),
401
+ environment: z.string().optional().describe('STEP 6 — environment key (from tc_list_environments). ASK the user; the UI marks this required.'),
402
+ 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."),
403
+ 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`."),
404
+ 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`)."),
405
+ 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`)."),
406
+ 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`."),
407
+ 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."),
408
+ 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."),
306
409
  headless: z.boolean().optional().describe('Headless. Defaults: cloud=true, local=false. (Immediate runs only.)'),
307
- 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."),
308
- 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`."),
309
- 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."),
310
- 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."),
311
- 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."),
312
- 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)."),
313
- scheduledFor: z.string().optional().describe("REQUIRED for when:'schedule' — ISO datetime of the first/only run (e.g. '2026-08-20T09:00:00Z')."),
410
+ 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')."),
314
411
  scheduleType: z.enum(['once', 'hourly', 'daily', 'weekdays', 'weekly', 'monthly']).optional().describe("Recurrence for a schedule. Default 'once'. hourly/daily/weekdays require an endDate."),
315
412
  endDate: z.string().optional().describe('For a recurring schedule — ISO end date (required for hourly/daily/weekdays).'),
316
413
  timezone: z.string().optional().describe("IANA timezone for a schedule (e.g. 'Asia/Kolkata'). Default 'UTC'."),
317
414
  },
318
415
  annotations: { readOnlyHint: false },
319
- }, async ({ tcId, mode, agentId, environment, browser, headless, cloudRunnerId, deviceId, deviceType, cloudDeviceType, osVersion, when, scheduledFor, scheduleType, endDate, timezone, releaseId, sprintId, versionId }) => {
416
+ }, async ({ tcId, reuseConfig, when, mode, agentId, environment, browser, headless, cloudRunnerId, deviceId, deviceType, cloudDeviceType, osVersion, scheduledFor, scheduleType, endDate, timezone, releaseId, sprintId, versionId }) => {
320
417
  const adapter = deps.getAdapter();
321
418
  if (!adapter)
322
419
  return err(NOT_CONNECTED);
323
- // Mirror the Execute-dialog required-field enforcement — ASK the user for every field the UI
324
- // asks. Release/sprint/version are optional in the UI; pass '' after asking to accept the
325
- // default (untagged / TC's current version). Environment and browser have red asterisks.
420
+ // STEP 0 — reuse-last-config prompt. Fires only when the caller wired getProjectId AND there
421
+ // is a remembered config for that project AND the caller has not answered yet (undefined).
422
+ // Merging when `reuseConfig: true` fills only fields left unset by this call — the user can
423
+ // still override any single field explicitly. TC-specific fields (versionId, deviceId,
424
+ // schedule cadence) are never remembered — they belong to the run, not the environment.
425
+ const projectId = deps.getProjectId?.() ?? null;
426
+ const remembered = projectId ? lastRun.get(projectId) : undefined;
427
+ if (remembered && reuseConfig === undefined) {
428
+ return err(`Previous run in this project used: ${summarizeLastRun(remembered)}. ` +
429
+ 'ASK the user whether to reuse it — call tc_run again with `reuseConfig: true` to merge ' +
430
+ 'those defaults, or `reuseConfig: false` to start fresh and be asked each field.');
431
+ }
432
+ if (reuseConfig === true && remembered) {
433
+ if (when === undefined)
434
+ when = remembered.when;
435
+ if (mode === undefined)
436
+ mode = remembered.mode;
437
+ if (releaseId === undefined)
438
+ releaseId = remembered.releaseId;
439
+ if (sprintId === undefined)
440
+ sprintId = remembered.sprintId;
441
+ if (environment === undefined)
442
+ environment = remembered.environment;
443
+ if (browser === undefined)
444
+ browser = remembered.browser;
445
+ if (agentId === undefined)
446
+ agentId = remembered.agentId;
447
+ if (cloudRunnerId === undefined)
448
+ cloudRunnerId = remembered.cloudRunnerId;
449
+ }
450
+ // STEP 1 — when. Decides schedule vs immediate; gates the schedule fields and the agent
451
+ // online-check downstream, so ask before anything else.
452
+ if (!when) {
453
+ return err("Specify WHEN to run: call tc_run again with when: 'immediate' (run now) or when: 'schedule' " +
454
+ '(create a schedule). If the user has not said, ask them first — never assume.');
455
+ }
456
+ // STEP 2 — mode. Decides the agent/runner pool and constrains the browser set for a schedule.
457
+ if (!mode) {
458
+ return err("Specify where to run: call tc_run again with mode: 'local', 'remote', or 'cloud'. " +
459
+ "If the user has not said which, ASK them first — local runs on THIS machine's agent, " +
460
+ "remote runs on a teammate's registered agent, cloud runs on an online cloud runner VM.");
461
+ }
462
+ // STEP 3 — release. Optional in the UI; the tool asks anyway (pass '' to skip).
326
463
  if (releaseId === undefined) {
327
464
  return err("ASK the user which RELEASE this run belongs to (from list_releases), then call tc_run " +
328
465
  "again with releaseId. If the user wants no release, pass releaseId: '' (empty string).");
329
466
  }
330
- if (sprintId === undefined) {
331
- return err("ASK the user which SPRINT this run belongs to (from list_sprints), then call tc_run " +
332
- "again with sprintId. If the user wants no sprint, pass sprintId: '' (empty string).");
467
+ // STEP 4 — sprint. Only asked when a release was picked; when release is skipped, sprint is
468
+ // skipped entirely (sprints scope to a release in the UI).
469
+ if (releaseId !== '' && sprintId === undefined) {
470
+ return err("ASK the user which SPRINT this run belongs to (from list_sprints, filtered by releaseId), " +
471
+ "then call tc_run again with sprintId. If the user wants no sprint, pass sprintId: '' (empty string).");
333
472
  }
334
- // Pre-flight the TC once — needed both to inline the version list (below) and to know the
335
- // tcType (WEB vs ANDROID/IOS/…) so the refuse cascade asks the right platform-specific
336
- // fields: browser for WEB, deviceType + deviceId for mobile. Mirrors tc_get's refusal at
337
- // discovery.tools.ts:226; without this the model guesses a version NAME and the backend 500s.
473
+ if (releaseId === '')
474
+ sprintId = '';
475
+ // STEP 5 — version. Needs the TC's tcType and version list, so pre-fetch once here.
338
476
  let preview;
339
477
  try {
340
478
  preview = await adapter.getTestCase(tcId);
@@ -357,30 +495,19 @@ export function registerRunTools(server, deps) {
357
495
  }
358
496
  versionId = '';
359
497
  }
498
+ // STEP 6 — environment.
360
499
  if (!environment) {
361
500
  return err('ASK the user which ENVIRONMENT to run against (from tc_list_environments), then call ' +
362
501
  'tc_run again with environment set to the chosen env key. Required — the UI marks this ' +
363
502
  'field with a red asterisk.');
364
503
  }
365
- // Browser is a WEB-only field — the SQA Execute dialog doesn't render it for mobile TCs
366
- // (they show a device picker instead). Skip the refuse for ANDROID/IOS; the adapter's
367
- // buildRunPlatform will ignore any browser passed alongside a mobile deviceId.
504
+ // STEP 7 (web only) — browser. Mobile branch handled after mode-dependent cloud/agent
505
+ // resolution, since the device roster is filtered by where it will run.
368
506
  if (!isMobile && !browser) {
369
507
  return err("ASK the user which BROWSER to run in (chromium / chrome / msedge / firefox / webkit), " +
370
508
  'then call tc_run again with browser set. Required — the UI marks this field with a ' +
371
509
  'red asterisk.');
372
510
  }
373
- if (!mode) {
374
- return err("Specify where to run: call tc_run again with mode: 'local', 'remote', or 'cloud'. " +
375
- "If the user has not said which, ASK them first — local runs on THIS machine's agent, " +
376
- "remote runs on a teammate's registered agent, cloud runs on an online cloud runner VM.");
377
- }
378
- // Never assume now-vs-scheduled — it decides which agents are eligible (online-only for now,
379
- // online+offline for a local schedule) and, for a schedule, the start time asked next.
380
- if (!when) {
381
- return err("Specify WHEN to run: call tc_run again with when: 'immediate' (run now) or when: 'schedule' " +
382
- '(create a schedule). If the user has not said, ask them first — never assume.');
383
- }
384
511
  let resolvedRunner;
385
512
  if (mode === 'cloud') {
386
513
  if (!adapter.listCloudRunners)
@@ -484,6 +611,8 @@ export function registerRunTools(server, deps) {
484
611
  const where = mode === 'cloud'
485
612
  ? (pinnedRunner ? `cloud runner **${pinnedRunner.name}**` : 'cloud (auto-pick a live VM at run time)')
486
613
  : `agent \`${agentId}\``;
614
+ if (projectId)
615
+ lastRun.set(projectId, { when, mode, releaseId, sprintId, environment, browser, agentId, cloudRunnerId });
487
616
  return ok(`Scheduled ${tcId} (${scheduleType ?? 'once'}, ${mode}) for ${scheduledFor}${next} → ${where}. Schedule id \`${sched.id}\`.`, { schedule: sched, mode, when: 'schedule', ...(pinnedRunner ? { runner: pinnedRunner } : {}) });
488
617
  }
489
618
  catch (e) {
@@ -512,6 +641,8 @@ export function registerRunTools(server, deps) {
512
641
  : mode === 'remote'
513
642
  ? `on remote agent \`${agentId}\``
514
643
  : `locally${agentId ? ` on agent \`${agentId}\`` : ' on your online agent'}`;
644
+ if (projectId)
645
+ lastRun.set(projectId, { when, mode, releaseId, sprintId, environment, browser, agentId, cloudRunnerId: resolvedRunner?.id ?? cloudRunnerId });
515
646
  return ok(`Started ${tcId} ${where}. Run id \`${run.runId}\` — poll run_status({ runId: "${run.runId}" }) for progress.`, { run, mode, ...(resolvedRunner ? { runner: resolvedRunner } : {}) });
516
647
  }
517
648
  catch (e) {
@@ -520,15 +651,24 @@ export function registerRunTools(server, deps) {
520
651
  });
521
652
  server.registerTool('suite_run', {
522
653
  title: 'Run a test suite (local / remote / cloud; now or scheduled)',
523
- description: 'Execute an existing test SUITE — all its member test cases, in order — in the connected product. ' +
524
- 'ASK the user for EVERY field the UI\'s Execute-suite dialog asks — never assume — and in ORDER ' +
525
- '(mirrors the product UI, `_parentComponent==="SU"`): (1) `releaseId` (from list_releases — the UI ' +
526
- 'marks it required with `*` for a suite), (2) `sprintId` (from list_sprints — also `*` for a suite), ' +
527
- '(3) `environment` (from tc_list_environments — `*`), (4) `mode` local/remote/cloud, (5) `when` immediate/schedule, ' +
528
- '(6) for a schedule the start time + recurrence + timezone, (7) THEN offer the eligible agent/runner. ' +
529
- 'The tool REFUSES when any of (1)–(5) is omitted — that is deliberate; it forces the ask instead of ' +
530
- 'silently defaulting. UNLIKE `tc_run`, a suite does NOT ask for `versionId` or `browser` — each TC ' +
531
- "in the suite carries its own pinned version + execution platform (that's what the suite editor picks per row). " +
654
+ description: "★ ORDER OF QUESTIONS (STRICT — applies whether you ask one at a time OR bundle several into a " +
655
+ "single message): when → mode → release → sprint → environment → schedule fields → agent. Ask " +
656
+ "in THIS order, always — never schema-declaration order, never refusal-arrival order, never " +
657
+ "a 'natural UI' order (release first is WRONG). Skip sprint when release=''. Suites do NOT " +
658
+ "ask version / browser / device — each TC in the suite carries its own. ★\n\n" +
659
+ 'Execute an existing test SUITE — all its member test cases, in order — in the connected product. ' +
660
+ "ASK the user for EVERY field the UI's Execute-suite dialog asks — never assume — in this " +
661
+ "DEPENDENCY ORDER: (0) if a previous run in this session left a config for the project, the tool " +
662
+ "refuses first asking `reuseConfig` (true=merge remembered mode/release/sprint/env/agent, " +
663
+ "false=start fresh); (1) `when` immediate/schedule, (2) `mode` local/remote/cloud, (3) " +
664
+ "`releaseId` (from list_releases; pass '' to skip), (4) `sprintId` (from list_sprints; skipped " +
665
+ "entirely when releaseId=''), (5) `environment` (from tc_list_environments), (6) for a schedule " +
666
+ "the start time + recurrence + timezone, (7) THEN offer the eligible agent/runner. IF YOU " +
667
+ "BATCH SEVERAL REFUSED FIELDS INTO ONE AskUserQuestion, ORDER the questions EXACTLY as above " +
668
+ "(when → mode → release → sprint → environment → schedule fields → agent) — never in schema " +
669
+ "order or refusal order. UNLIKE " +
670
+ "`tc_run`, a suite does NOT ask for `versionId` or `browser` — each TC in the suite carries its " +
671
+ "own pinned version + execution platform (that's what the suite editor picks per row). " +
532
672
  '`mode` decides WHERE it runs:\n' +
533
673
  " • 'local' — THIS machine's desktop agent, always serial. Call `local_agents` (0 or 1 row). For " +
534
674
  "when:'immediate' the tool probes :4040 and refuses if the desktop agent is down. Omit `agentId` to " +
@@ -538,65 +678,89 @@ export function registerRunTools(server, deps) {
538
678
  " • 'cloud' — online cloud runner VM(s) (ONLINE-only whether now or scheduled). Pass `cloudRunnerId` " +
539
679
  'from cloud_runners; for a schedule omit it to auto-pick a live VM at fire time.\n' +
540
680
  'For cloud, if no runner is online it reports that there are no active cloud runners (nothing is run).\n' +
541
- '`when` decides WHEN it runs:\n' +
542
- " • 'immediate' — dispatch now; returns the suite-run id — poll run_status for progress " +
543
- '(per-test-case results under `children`) and run_stop to cancel.\n' +
544
- " • 'schedule' — create a schedule instead (requires `scheduledFor`); ASK the start time, recurrence, " +
545
- 'end date and timezone BEFORE offering an agent. Returns the schedule id.\n' +
546
- 'NOTE: a suite of only MANUAL test cases cannot be run or scheduled by an agent — the backend rejects it.',
681
+ '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).',
547
682
  inputSchema: {
548
683
  suiteId: z.string().describe('The suite id to run (from suite_list / suite_get).'),
549
- releaseId: z.string().optional().describe('REQUIRED for a suite (UI marks it `*` for _parentComponent==="SU"). A release id from list_releases. Ask the user.'),
550
- 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.'),
551
- mode: z.enum(['local', 'remote', 'cloud']).optional().describe('Where to run. REQUIRED — ASK the user if unstated (the tool refuses when omitted).'),
552
- 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."),
553
- environment: z.string().optional().describe('Environment key (from tc_list_environments). REQUIRED — the UI marks this with a red asterisk for a suite.'),
684
+ 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."),
685
+ when: z.enum(['immediate', 'schedule']).optional().describe("STEP 1 — when to run. REQUIRED, ASK the user if unstated."),
686
+ mode: z.enum(['local', 'remote', 'cloud']).optional().describe('STEP 2 — where to run. REQUIRED, ASK the user if unstated.'),
687
+ releaseId: z.string().optional().describe("STEP 3 — release from list_releases. ASK the user; pass '' to skip (no release / no sprint asked)."),
688
+ 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."),
689
+ environment: z.string().optional().describe('STEP 5 — environment key (from tc_list_environments). ASK the user; the UI marks this required for a suite.'),
554
690
  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."),
555
691
  headless: z.boolean().optional().describe('Headless. Defaults: cloud=true, local=false. (Immediate runs only.)'),
556
- 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."),
557
- strategy: z.enum(['serial', 'batch', 'dynamic']).optional().describe('Cloud dispatch order across the suite (immediate runs). Default serial; local is always serial.'),
558
- maxConcurrent: z.number().int().positive().optional().describe('Cloud batch/dynamic only — max test cases running at once (immediate runs).'),
559
- terminateOnFailure: z.boolean().optional().describe('Stop dispatching remaining test cases after the first failure (immediate runs).'),
560
- 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)."),
561
- scheduledFor: z.string().optional().describe("REQUIRED for when:'schedule' — ISO datetime of the first/only run (e.g. '2026-08-20T09:00:00Z')."),
692
+ 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."),
693
+ 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."),
694
+ 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."),
695
+ maxConcurrent: z.number().int().positive().optional().describe('Cloud parallel only — maps to globalConfiguration.MAXIMUM_CONTAINER_COUNT on the wire.'),
696
+ terminateOnFailure: z.boolean().optional().describe('Stop dispatching remaining test cases after the first failure. Maps to globalConfiguration.TERMINATE_ON_FAIL on the wire.'),
697
+ 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')."),
562
698
  scheduleType: z.enum(['once', 'hourly', 'daily', 'weekdays', 'weekly', 'monthly']).optional().describe("Recurrence for a schedule. Default 'once'. hourly/daily/weekdays require an endDate."),
563
699
  endDate: z.string().optional().describe('For a recurring schedule — ISO end date (required for hourly/daily/weekdays).'),
564
700
  timezone: z.string().optional().describe("IANA timezone for a schedule (e.g. 'Asia/Kolkata'). Default 'UTC'."),
565
701
  },
566
702
  annotations: { readOnlyHint: false },
567
- }, async ({ suiteId, releaseId, sprintId, mode, agentId, environment, browser, headless, cloudRunnerId, strategy, maxConcurrent, terminateOnFailure, when, scheduledFor, scheduleType, endDate, timezone }) => {
703
+ }, async ({ suiteId, reuseConfig, releaseId, sprintId, mode, agentId, environment, browser, headless, cloudRunnerId, strategy, maxConcurrent, terminateOnFailure, when, scheduledFor, scheduleType, endDate, timezone }) => {
568
704
  const adapter = deps.getAdapter();
569
705
  if (!adapter)
570
706
  return err(NOT_CONNECTED);
571
- // Mirror the Execute-suite dialog required-field enforcement (_parentComponent==='SU' branch of
572
- // execution-popup-modal.component.html) — release, sprint, environment all carry a red `*` for
573
- // a suite. UNLIKE tc_run, empty-string is NOT accepted: the UI blocks form submit until each is
574
- // picked. `versionId` and `browser` are TC-level in a suite (each row has its own) so we
575
- // don't ask for them.
576
- if (!releaseId) {
577
- return err("ASK the user which RELEASE this suite run belongs to (from list_releases), then call " +
578
- 'suite_run again with releaseId. Required for a suite — the UI marks it with a red asterisk.');
707
+ // STEP 0 — reuse-last-config prompt. Same shape as tc_run: fires only when the caller wired
708
+ // getProjectId AND there is a remembered config AND the user hasn't answered yet. Merges the
709
+ // environment-level fields (mode/release/sprint/env/browser/agent/runner) into any left unset.
710
+ const projectId = deps.getProjectId?.() ?? null;
711
+ const remembered = projectId ? lastRun.get(projectId) : undefined;
712
+ if (remembered && reuseConfig === undefined) {
713
+ return err(`Previous run in this project used: ${summarizeLastRun(remembered)}. ` +
714
+ 'ASK the user whether to reuse it — call suite_run again with `reuseConfig: true` to merge ' +
715
+ 'those defaults, or `reuseConfig: false` to start fresh.');
579
716
  }
580
- if (!sprintId) {
581
- return err("ASK the user which SPRINT this suite run belongs to (from list_sprints, filtered by " +
582
- 'releaseId), then call suite_run again with sprintId. Required for a suite — the UI marks it ' +
583
- 'with a red asterisk.');
717
+ if (reuseConfig === true && remembered) {
718
+ if (when === undefined)
719
+ when = remembered.when;
720
+ if (mode === undefined)
721
+ mode = remembered.mode;
722
+ if (releaseId === undefined)
723
+ releaseId = remembered.releaseId;
724
+ if (sprintId === undefined)
725
+ sprintId = remembered.sprintId;
726
+ if (environment === undefined)
727
+ environment = remembered.environment;
728
+ if (browser === undefined)
729
+ browser = remembered.browser;
730
+ if (agentId === undefined)
731
+ agentId = remembered.agentId;
732
+ if (cloudRunnerId === undefined)
733
+ cloudRunnerId = remembered.cloudRunnerId;
584
734
  }
585
- if (!environment) {
586
- return err('ASK the user which ENVIRONMENT to run against (from tc_list_environments), then call ' +
587
- 'suite_run again with environment set to the chosen env key. Required — the UI marks this ' +
588
- 'field with a red asterisk for a suite.');
735
+ // STEP 1 — when.
736
+ if (!when) {
737
+ return err("Specify WHEN to run: call suite_run again with when: 'immediate' (run now) or when: 'schedule' " +
738
+ '(create a schedule). If the user has not said, ask them first — never assume.');
589
739
  }
740
+ // STEP 2 — mode.
590
741
  if (!mode) {
591
742
  return err("Specify where to run: call suite_run again with mode: 'local', 'remote', or 'cloud'. " +
592
743
  "If the user has not said which, ASK them first — local runs on THIS machine's agent, " +
593
744
  "remote runs on a teammate's registered agent, cloud runs on online cloud runner VM(s).");
594
745
  }
595
- // Never assume now-vs-scheduled — it decides which agents are eligible (online-only for now,
596
- // online+offline for a local schedule) and, for a schedule, the start time asked next.
597
- if (!when) {
598
- return err("Specify WHEN to run: call suite_run again with when: 'immediate' (run now) or when: 'schedule' " +
599
- '(create a schedule). If the user has not said, ask them first — never assume.');
746
+ // STEP 3 — release. Unified with tc_run: pass '' to skip (matches the UI's optional field).
747
+ if (releaseId === undefined) {
748
+ return err("ASK the user which RELEASE this suite run belongs to (from list_releases), then call " +
749
+ "suite_run again with releaseId. If the user wants no release, pass releaseId: '' (empty string).");
750
+ }
751
+ // STEP 4 — sprint. Only asked when release is non-empty.
752
+ if (releaseId !== '' && sprintId === undefined) {
753
+ return err("ASK the user which SPRINT this suite run belongs to (from list_sprints, filtered by " +
754
+ "releaseId), then call suite_run again with sprintId. If the user wants no sprint, pass " +
755
+ "sprintId: '' (empty string).");
756
+ }
757
+ if (releaseId === '')
758
+ sprintId = '';
759
+ // STEP 5 — environment.
760
+ if (!environment) {
761
+ return err('ASK the user which ENVIRONMENT to run against (from tc_list_environments), then call ' +
762
+ 'suite_run again with environment set to the chosen env key. Required — the UI marks this ' +
763
+ 'field with a red asterisk for a suite.');
600
764
  }
601
765
  let resolvedRunner;
602
766
  if (mode === 'cloud') {
@@ -671,6 +835,8 @@ export function registerRunTools(server, deps) {
671
835
  const where = mode === 'cloud'
672
836
  ? (pinnedRunner ? `cloud runner **${pinnedRunner.name}**` : 'cloud (auto-pick a live VM at run time)')
673
837
  : `agent \`${agentId}\``;
838
+ if (projectId)
839
+ lastRun.set(projectId, { when, mode, releaseId, sprintId, environment, browser, agentId, cloudRunnerId });
674
840
  return ok(`Scheduled suite ${suiteId} (${scheduleType ?? 'once'}, ${mode}) for ${scheduledFor}${next} → ${where}. Schedule id \`${sched.id}\`.`, { schedule: sched, mode, when: 'schedule', ...(pinnedRunner ? { runner: pinnedRunner } : {}) });
675
841
  }
676
842
  catch (e) {
@@ -697,6 +863,8 @@ export function registerRunTools(server, deps) {
697
863
  : mode === 'remote'
698
864
  ? `on remote agent \`${agentId}\``
699
865
  : `locally${agentId ? ` on agent \`${agentId}\`` : ' on your online agent'}`;
866
+ if (projectId)
867
+ lastRun.set(projectId, { when, mode, releaseId, sprintId, environment, browser, agentId, cloudRunnerId: resolvedRunner?.id ?? cloudRunnerId });
700
868
  return ok(`Started suite ${suiteId} ${where}. Run id \`${run.runId}\` — poll run_status({ runId: "${run.runId}" }) for progress.`, { run, mode, ...(resolvedRunner ? { runner: resolvedRunner } : {}) });
701
869
  }
702
870
  catch (e) {
@@ -768,18 +936,96 @@ export function registerRunTools(server, deps) {
768
936
  return err(describeError(e));
769
937
  }
770
938
  });
939
+ server.registerTool('run_history', {
940
+ title: 'Recent execution history for a suite or test case',
941
+ description: 'Return the top-N most recent STANDALONE executions of a suite or test case (newest first), ' +
942
+ 'as a scoreboard — runId, state, timing, reportUrl, plus a per-test-case tally for SUITE ' +
943
+ 'runs (passed/failed/skipped of total). No step-level detail; use `run_status({ runId })` ' +
944
+ 'on any row to drill in. Pass EXACTLY one of `suiteId` or `tcId`. `limit` defaults to 5, ' +
945
+ 'capped at 20. Suite-child TC rows are excluded from a `tcId` query, so a test case that ' +
946
+ 'only runs inside suites returns empty. Requires a live connection.',
947
+ inputSchema: {
948
+ suiteId: z.string().optional().describe('A suite id (from suite_list) — returns the suite\'s recent executions.'),
949
+ tcId: z.string().optional().describe('A test case id — returns the TC\'s recent STANDALONE executions.'),
950
+ limit: z.number().int().min(1).max(20).optional().describe('How many recent runs to return (default 5, max 20).'),
951
+ },
952
+ annotations: { readOnlyHint: true },
953
+ }, async ({ suiteId, tcId, limit }) => {
954
+ const adapter = deps.getAdapter();
955
+ if (!adapter)
956
+ return err(NOT_CONNECTED);
957
+ if ((suiteId && tcId) || (!suiteId && !tcId)) {
958
+ return err('Pass exactly one of `suiteId` or `tcId`.');
959
+ }
960
+ if (!adapter.listRecentRuns) {
961
+ return err('This product does not support run-history lookup.');
962
+ }
963
+ const type = suiteId ? 'SUITE' : 'TC';
964
+ const entityId = (suiteId ?? tcId);
965
+ const n = Math.min(Math.max(limit ?? 5, 1), 20);
966
+ try {
967
+ const runs = await adapter.listRecentRuns(type, entityId, n);
968
+ if (!runs.length) {
969
+ const label = type === 'SUITE' ? 'suite' : 'test case';
970
+ return ok(`No executions found for ${label} ${entityId}.`, { runs: [] });
971
+ }
972
+ const text = renderRunHistory(type, entityId, runs);
973
+ return ok(text, { runs });
974
+ }
975
+ catch (e) {
976
+ return err(describeError(e));
977
+ }
978
+ });
771
979
  server.registerTool('run_stop', {
772
980
  title: 'Stop a run',
773
- description: 'Signal a running execution to stop. Requires a live connection.',
774
- inputSchema: { runId: z.string().describe('The run id to stop.') },
981
+ description: 'Signal a running execution to stop, and poll briefly for the terminal state so the reply ' +
982
+ "reflects reality (not a fire-and-forget). Pass EXACTLY one of: `runId` (from tc_run / " +
983
+ "suite_run), `suiteId` (stops the suite's LATEST execution), or `tcId` (stops the TC's " +
984
+ 'LATEST standalone execution — suite-child rows are excluded). Requires a live connection.',
985
+ inputSchema: {
986
+ runId: z.string().optional().describe('The run id (from tc_run / suite_run).'),
987
+ suiteId: z.string().optional().describe("A suite id (from suite_list) — stops the suite's latest execution."),
988
+ tcId: z.string().optional().describe("A test case id — stops the TC's latest STANDALONE execution."),
989
+ },
775
990
  annotations: { readOnlyHint: false },
776
- }, async ({ runId }) => {
991
+ }, async ({ runId, suiteId, tcId }) => {
777
992
  const adapter = deps.getAdapter();
778
993
  if (!adapter)
779
994
  return err(NOT_CONNECTED);
995
+ if ([runId, suiteId, tcId].filter((v) => v !== undefined && v !== '').length !== 1) {
996
+ return err('Pass exactly one of `runId`, `suiteId`, or `tcId`.');
997
+ }
780
998
  try {
781
- await adapter.stopRun(runId);
782
- return ok(`Requested stop for run ${runId}.`);
999
+ let effectiveRunId = runId;
1000
+ if (suiteId) {
1001
+ if (!adapter.getLatestSuiteRun)
1002
+ return err('This product does not support latest-suite-run lookup — pass `runId` instead.');
1003
+ const latest = await adapter.getLatestSuiteRun(suiteId);
1004
+ if (!latest)
1005
+ return err(`Suite ${suiteId} has no executions to stop.`);
1006
+ effectiveRunId = latest;
1007
+ }
1008
+ else if (tcId) {
1009
+ if (!adapter.getLatestTestcaseRun)
1010
+ return err('This product does not support latest-testcase-run lookup — pass `runId` instead.');
1011
+ const latest = await adapter.getLatestTestcaseRun(tcId);
1012
+ if (!latest)
1013
+ return err(`Test case ${tcId} has no executions to stop.`);
1014
+ effectiveRunId = latest;
1015
+ }
1016
+ await adapter.stopRun(effectiveRunId);
1017
+ // Confirm — the backend accepts the stop async, so getRun is the source of truth. Poll a few
1018
+ // times so the reply reports the actual state instead of lying "requested" and moving on.
1019
+ // ponytail: fixed 3×1.5s poll, upgrade to configurable/backoff if teams hit slow stop paths.
1020
+ const TERMINAL = new Set(['stopped', 'passed', 'failed', 'error', 'skipped', 'notExecuted']);
1021
+ for (let i = 0; i < 3; i++) {
1022
+ await new Promise((r) => setTimeout(r, 1500));
1023
+ const s = await adapter.getRun(effectiveRunId);
1024
+ if (TERMINAL.has(s.state))
1025
+ return ok(`Run ${effectiveRunId} is now **${s.state}**.`, { run: s });
1026
+ }
1027
+ return ok(`Stop request sent for run ${effectiveRunId}. State is still queued/running — poll ` +
1028
+ `run_status({ runId: "${effectiveRunId}" }) to confirm.`);
783
1029
  }
784
1030
  catch (e) {
785
1031
  return err(describeError(e));