@skyramp/mcp 0.3.3 → 0.3.5

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 (59) hide show
  1. package/build/playwright/registerPlaywrightTools.js +42 -1
  2. package/build/prompts/enhance-assertions/sharedAssertionRules.js +19 -0
  3. package/build/prompts/test-maintenance/actionsInstructions.js +2 -2
  4. package/build/prompts/test-recommendation/analysisOutputPrompt.js +1 -4
  5. package/build/prompts/test-recommendation/recommendationSections.d.ts +1 -1
  6. package/build/prompts/test-recommendation/recommendationSections.js +5 -5
  7. package/build/prompts/test-recommendation/test-recommendation-prompt.js +13 -7
  8. package/build/prompts/testbot/testbot-prompts.js +16 -14
  9. package/build/recommendation/discriminators.d.ts +7 -1
  10. package/build/recommendation/discriminators.js +16 -3
  11. package/build/resources/testbotResource.js +0 -1
  12. package/build/services/ScenarioGenerationService.js +5 -2
  13. package/build/services/TestExecutionService.js +25 -1
  14. package/build/services/TestGenerationService.js +24 -9
  15. package/build/services/containerEnv.d.ts +12 -1
  16. package/build/services/containerEnv.js +94 -1
  17. package/build/tools/executeSkyrampTestTool.d.ts +9 -0
  18. package/build/tools/executeSkyrampTestTool.js +20 -6
  19. package/build/tools/execution-video-state.d.ts +21 -0
  20. package/build/tools/execution-video-state.js +51 -0
  21. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +31 -11
  22. package/build/tools/generate-tests/planGuard.d.ts +5 -5
  23. package/build/tools/generate-tests/planGuard.js +5 -17
  24. package/build/tools/submitReportTool.d.ts +83 -10
  25. package/build/tools/submitReportTool.js +257 -30
  26. package/build/tools/test-management/actionsTool.js +40 -39
  27. package/build/tools/test-management/analyzeChangesTool.d.ts +11 -0
  28. package/build/tools/test-management/analyzeChangesTool.js +41 -35
  29. package/build/tools/test-management/analyzeTestHealthTool.js +3 -3
  30. package/build/tools/test-management/registerTestPlanTool.js +191 -41
  31. package/build/types/TestExecution.d.ts +14 -0
  32. package/build/types/TestTypes.js +3 -2
  33. package/build/types/TestbotPromptOptions.d.ts +0 -1
  34. package/build/types/TestbotReport.d.ts +24 -1
  35. package/build/types/TestbotReport.js +10 -1
  36. package/build/types/index.d.ts +1 -0
  37. package/build/types/index.js +1 -0
  38. package/build/utils/AnalysisStateManager.d.ts +48 -2
  39. package/build/utils/AnalysisStateManager.js +49 -13
  40. package/build/utils/reportLanguage.d.ts +43 -0
  41. package/build/utils/reportLanguage.js +125 -0
  42. package/build/utils/reportVerification.d.ts +10 -0
  43. package/build/utils/reportVerification.js +31 -0
  44. package/build/utils/scenarioDrafting.js +7 -1
  45. package/build/utils/skyrampMdContent.d.ts +1 -1
  46. package/build/utils/skyrampMdContent.js +1 -1
  47. package/build/utils/urlPath.d.ts +37 -0
  48. package/build/utils/urlPath.js +55 -0
  49. package/build/utils/utils.d.ts +45 -0
  50. package/build/utils/utils.js +50 -0
  51. package/build/utils/versions.d.ts +3 -3
  52. package/build/utils/versions.js +1 -1
  53. package/build/utils/workspaceAuth.d.ts +15 -15
  54. package/build/utils/workspaceAuth.js +32 -17
  55. package/build/workspace/queryParamResolution.d.ts +93 -0
  56. package/build/workspace/queryParamResolution.js +201 -0
  57. package/build/workspace/workspace.d.ts +104 -0
  58. package/build/workspace/workspace.js +24 -0
  59. package/package.json +3 -2
@@ -6,10 +6,11 @@ import * as path from "path";
6
6
  import yaml from "js-yaml";
7
7
  import { simpleGit } from "simple-git";
8
8
  import { logger } from "../../utils/logger.js";
9
+ import { dualChannelResult } from "../../utils/utils.js";
9
10
  import { parseWorkspaceAuthType, getDefaultAuthHeader, WorkspaceAuthType, readWorkspaceConfigRaw } from "../../utils/workspaceAuth.js";
10
11
  import { AnalyticsService } from "../../services/AnalyticsService.js";
11
12
  import { makeProgressReporter } from "../../utils/progress.js";
12
- import { StateManager, registerSession, storeSessionData, setTestsRepoDir, getActiveRunStatePath, setActiveRunStatePath, } from "../../utils/AnalysisStateManager.js";
13
+ import { StateManager, registerSession, storeSessionData, rememberTestsRepoDir, getActiveRunStatePath, setActiveRunStatePath, } from "../../utils/AnalysisStateManager.js";
13
14
  import { buildRecommendationPrompt, computeScoredCandidates } from "../../prompts/test-recommendation/test-recommendation-prompt.js";
14
15
  import { hasFlutterSdkDep, isFrontendFile, isTestFile } from "../../prompts/test-recommendation/scopeAssessment.js";
15
16
  import { buildExternalCoverageSet } from "../../prompts/test-recommendation/recommendationShared.js";
@@ -367,6 +368,14 @@ These components have no DOM presence in the running app: no route, page, or oth
367
368
  - Log an \`issuesFound\` entry (high severity) naming the unintegrated file(s) above and stating that the changed code is unreachable in the running app.
368
369
  - Backend/other work in this PR is unaffected — proceed with it normally.`;
369
370
  }
371
+ /**
372
+ * Assemble the tool result. See `dualChannelResult` for why the payload is
373
+ * carried on both result channels.
374
+ */
375
+ export function buildAnalyzeChangesResult(parts) {
376
+ const executionPlan = `\`\`\`json\n${parts.structuredSummary}\n\`\`\`\n\n## UI Blueprint Capture — do this BEFORE writing UI recommendation reasoning\n${parts.uiInstructions}\n\n${parts.outputText}\n\n---\n\n## Pre-built Test Catalog — Fill in placeholders from source code, then display verbatim\n⚠️ Do NOT reformat, rename sections, or generate a new catalog. Replace \`<…from source>\` values, then show this output exactly as-is, grouped by test type.\n\n${parts.recommendationPrompt}`;
377
+ return dualChannelResult({ executionPlan });
378
+ }
370
379
  export function registerAnalyzeChangesTool(server) {
371
380
  server.registerTool(TOOL_NAME, {
372
381
  annotations: {
@@ -376,8 +385,14 @@ export function registerAnalyzeChangesTool(server) {
376
385
  openWorldHint: true, // may fetch PR comments from GitHub
377
386
  },
378
387
  description: `Scan repository API endpoints and discover existing tests — first step of the unified Test Health Analysis Flow. Returns a stateFile path and ranked test recommendations. Pass stateFile to skyramp_analyze_test_health and skyramp_actions.`,
379
- // TODO: Replace description-embedded output format with outputSchema structural
380
- // output schema reduces token usage vs natural language in description.
388
+ // SKYR-4182: the payload is returned via structuredContent, which requires a
389
+ // declared outputSchema. This also keeps the result out of the single-line
390
+ // `.json` spill shape that Read cannot paginate — see buildAnalyzeChangesResult.
391
+ outputSchema: {
392
+ executionPlan: z
393
+ .string()
394
+ .describe("Analysis summary, UI blueprint capture instructions, and the ranked Execution Plan. Read and follow this field."),
395
+ },
381
396
  inputSchema: analyzeChangesInputSchema,
382
397
  }, async (params, extra) => {
383
398
  let errorResult;
@@ -419,12 +434,9 @@ export function registerAnalyzeChangesTool(server) {
419
434
  logger.info("All user-changed files are non-application — skipping analysis", {
420
435
  changedFiles: filesToCheck,
421
436
  });
422
- return {
423
- content: [{
424
- type: "text",
425
- text: `All ${filesToCheck.length} changed file(s) are non-application (CI/CD, docs, lock files, config). No test analysis needed for this diff.\n\nChanged files: ${filesToCheck.join(", ")}`,
426
- }],
427
- };
437
+ return dualChannelResult({
438
+ executionPlan: `All ${filesToCheck.length} changed file(s) are non-application (CI/CD, docs, lock files, config). No test analysis needed for this diff.\n\nChanged files: ${filesToCheck.join(", ")}`,
439
+ });
428
440
  }
429
441
  }
430
442
  // ── Step 2: Scan endpoints ──
@@ -569,22 +581,17 @@ export function registerAnalyzeChangesTool(server) {
569
581
  logger.info("GraphQL-only diff detected — REST testing not supported", {
570
582
  changedFiles: diffData.changedFiles,
571
583
  });
572
- return {
573
- content: [{
574
- type: "text",
575
- text: [
576
- "**GraphQL-only diff detected.**",
577
- "",
578
- "The changed files appear to be GraphQL schema, artifact, or endpoint implementation files.",
579
- "Skyramp currently supports REST API testing only GraphQL introspection,",
580
- "query validation, and type-name grounding are not yet supported.",
581
- "",
582
- "No test recommendations can be generated for this diff.",
583
- "",
584
- `Changed files: ${diffData.changedFiles.join(", ")}`,
585
- ].join("\n"),
586
- }],
587
- };
584
+ return dualChannelResult({ executionPlan: [
585
+ "**GraphQL-only diff detected.**",
586
+ "",
587
+ "The changed files appear to be GraphQL schema, artifact, or endpoint implementation files.",
588
+ "Skyramp currently supports REST API testing only GraphQL introspection,",
589
+ "query validation, and type-name grounding are not yet supported.",
590
+ "",
591
+ "No test recommendations can be generated for this diff.",
592
+ "",
593
+ `Changed files: ${diffData.changedFiles.join(", ")}`,
594
+ ].join("\n") });
588
595
  }
589
596
  }
590
597
  await sendProgress(50, 100, "Discovering existing tests...");
@@ -729,7 +736,9 @@ export function registerAnalyzeChangesTool(server) {
729
736
  let discoveredRelevantExternalPaths = [];
730
737
  try {
731
738
  const testDiscoveryService = new TestDiscoveryService();
732
- setTestsRepoDir(params.testsRepoDir);
739
+ // Run-scoped: only the primary call carries testsRepoDir, so never
740
+ // let a related repo's call clear it (SKYR-4204).
741
+ rememberTestsRepoDir(params.testsRepoDir);
733
742
  const discoveryResult = await testDiscoveryService.discoverTests(testDir ?? params.repositoryPath, { changedResources, changedSymbols, preciseResources: (changedSymbols?.length ?? 0) > 0, changedFrontendFiles, changedSelectors });
734
743
  existingTests = discoveryResult.tests.map((test) => ({
735
744
  testFile: test.testFile,
@@ -1601,15 +1610,12 @@ export function registerAnalyzeChangesTool(server) {
1601
1610
  unmatchedFiles: classifiedEndpoints?.unmatchedFiles,
1602
1611
  nextTool: "skyramp_analyze_test_health",
1603
1612
  });
1604
- return {
1605
- content: [
1606
- {
1607
- type: "text",
1608
- text: `\`\`\`json\n${structuredSummary}\n\`\`\`\n\n## UI Blueprint Capture — do this BEFORE writing UI recommendation reasoning\n${uiInstructions}\n\n${outputText}\n\n---\n\n## Pre-built Test Catalog — Fill in placeholders from source code, then display verbatim\n⚠️ Do NOT reformat, rename sections, or generate a new catalog. Replace \`<…from source>\` values, then show this output exactly as-is, grouped by test type.\n\n${recommendationPrompt}`,
1609
- },
1610
- ],
1611
- isError: false,
1612
- };
1613
+ return buildAnalyzeChangesResult({
1614
+ structuredSummary,
1615
+ uiInstructions,
1616
+ outputText,
1617
+ recommendationPrompt,
1618
+ });
1613
1619
  }
1614
1620
  catch (error) {
1615
1621
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -62,7 +62,7 @@ export function registerAnalyzeTestHealthTool(server) {
62
62
  inputSchema: {
63
63
  stateFile: z
64
64
  .string()
65
- .describe("Path to the analyze-changes-state.json file returned by skyramp_analyze_changes (the stateFile field in its output). Do NOT pass summaryOutputFile or testbot-result.txt here."),
65
+ .describe("Path to the analyze-changes-state.json file returned by skyramp_analyze_changes (the stateFile field in its output). Do NOT pass testbot-result.txt here."),
66
66
  blueprintCaptured: z
67
67
  .boolean()
68
68
  .optional()
@@ -88,10 +88,10 @@ export function registerAnalyzeTestHealthTool(server) {
88
88
  return toolError(`State file is empty or invalid: ${args.stateFile}${args.repository ? ` (no section for repository ${args.repository})` : ""}. Call skyramp_analyze_changes first to generate a valid state file.`);
89
89
  }
90
90
  if (!repositoryPath || typeof repositoryPath !== "string") {
91
- return toolError(`repositoryPath not found in state file metadata. The stateFile parameter must be the analyze-changes-state.json path returned by skyramp_analyze_changes — not summaryOutputFile or testbot-result.txt. Re-run skyramp_analyze_changes to regenerate it.`);
91
+ return toolError(`repositoryPath not found in state file metadata. The stateFile parameter must be the analyze-changes-state.json path returned by skyramp_analyze_changes — not testbot-result.txt. Re-run skyramp_analyze_changes to regenerate it.`);
92
92
  }
93
93
  if (!Array.isArray(stateData.existingTests)) {
94
- return toolError(`stateFile does not contain test discovery data (existingTests missing). The stateFile parameter must be the analyze-changes-state.json path returned by skyramp_analyze_changes — not summaryOutputFile or testbot-result.txt.`);
94
+ return toolError(`stateFile does not contain test discovery data (existingTests missing). The stateFile parameter must be the analyze-changes-state.json path returned by skyramp_analyze_changes — not testbot-result.txt.`);
95
95
  }
96
96
  // External tests: UPDATE only — REGENERATE/DELETE are report-only (enforced in
97
97
  // skyramp_actions). Already scoped to relevant ones by discovery.
@@ -10,6 +10,8 @@ import { SCENARIO_CATEGORIES, CATEGORY_PRIORITY, Novelty, PriorityTier } from ".
10
10
  import { HttpMethod, TestType } from "../../types/TestTypes.js";
11
11
  import { CandidateSource, computeCandidateId, scenarioMergeKey, DiscriminatorKind } from "../../types/Recommendation.js";
12
12
  import { selectPlan } from "../../recommendation/planRanker.js";
13
+ import { reservedUISlots } from "../../recommendation/budgeters/shared.js";
14
+ import { inferScenarioType } from "../../recommendation/diversity.js";
13
15
  import { validateDiscriminator } from "../../recommendation/discriminators.js";
14
16
  import { isAttackSurfaceSecurityBoundary } from "../../prompts/test-recommendation/recommendationShared.js";
15
17
  import { MAX_RECOMMENDATIONS, MAX_TESTS_TO_GENERATE } from "../../prompts/test-recommendation/recommendationSections.js";
@@ -84,10 +86,12 @@ const registerTestPlanSchema = {
84
86
  .string()
85
87
  .refine((p) => path.isAbsolute(p), { message: "stateFile must be an absolute path" })
86
88
  .describe("Path to state file from skyramp_analyze_changes"),
87
- repository: z
88
- .string()
89
- .optional()
90
- .describe("The owner/repo whose analysis section to register against (e.g. 'letsramp/api-insight'). In a multi-repo run, ALWAYS set it. Omit only for a single-repo run."),
89
+ // No `repository` argument, deliberately (SKYR-4204 reopen). The approved
90
+ // plan is run-wide — one pooled registration covers every repository in the
91
+ // run — so unlike the per-repo tools (analyze_test_health, actions,
92
+ // execute_test) there is no section to select: the tool reads the root and
93
+ // derives run-wide facts (diff union, budget flags, scenario lookup) itself.
94
+ // A legacy caller still passing `repository` has it stripped harmlessly.
91
95
  candidates: z
92
96
  .array(registerCandidateSchema)
93
97
  .default([])
@@ -128,38 +132,57 @@ function toDraftedScenario(input) {
128
132
  testType: input.testType,
129
133
  };
130
134
  }
131
- /** Build the agent-submitted candidates, running discriminator verification
132
- * against the persisted diff text. Failed claims demote (never reject). */
133
- function buildAgentCandidates(candidates, diffText) {
134
- const demotions = [];
135
- const built = candidates.map((input) => {
135
+ /** Build the agent-submitted candidates. Discriminator claims are verified
136
+ * later, by {@link applyDiscriminatorClaims}, once the merge has settled which
137
+ * scenario each name resolves to. */
138
+ function buildAgentCandidates(candidates) {
139
+ return candidates.map((input) => {
136
140
  const scenario = toDraftedScenario(input);
137
- const candidateId = computeCandidateId(scenario);
138
- let verifiedDiscriminator;
139
- if (input.discriminator) {
140
- const claim = input.discriminator;
141
- const result = validateDiscriminator(scenario, claim, diffText);
142
- if (result.verified) {
143
- verifiedDiscriminator = claim.kind;
144
- }
145
- else {
146
- demotions.push({
147
- candidateId,
148
- reason: result.reason ?? `${claim.kind} discriminator claim could not be verified.`,
149
- });
150
- }
151
- }
152
- const candidate = {
141
+ return {
153
142
  scenario,
154
143
  priority: derivePriorityTier(scenario),
155
144
  novelty: Novelty.NEW,
156
145
  source: CandidateSource.AGENT,
157
- candidateId,
158
- ...(verifiedDiscriminator ? { verifiedDiscriminator } : {}),
146
+ candidateId: computeCandidateId(scenario),
159
147
  };
160
- return candidate;
161
148
  });
162
- return { candidates: built, demotions };
149
+ }
150
+ /**
151
+ * Verify each declared discriminator against the candidate that actually won
152
+ * the merge, and mark it. Failed claims demote (never reject).
153
+ *
154
+ * Runs AFTER the merge, not while building the agent's candidates: a server
155
+ * pre-seeded candidate wins the name, so verifying the agent's copy first
156
+ * attaches the result to a candidate that is about to be discarded — silently
157
+ * stripping the verification. Verification is against the WINNER's
158
+ * steps, so a claim that only held for the agent's rewrite is correctly refused
159
+ * rather than carried across.
160
+ *
161
+ * The marker is cleared before the current claim is applied. Each call replaces
162
+ * the agent's previous submission (see the `candidates` schema), and
163
+ * recoverServerCandidates restores whatever the LAST plan recorded — so keeping
164
+ * it would let a boost earned by an earlier registration outlive the claim that
165
+ * earned it, on a submission that no longer makes that claim or whose new claim
166
+ * fails.
167
+ */
168
+ function applyDiscriminatorClaims(candidates, claimsByKey, diffText, reposWithoutDiff = []) {
169
+ const demotions = [];
170
+ const marked = candidates.map((candidate) => {
171
+ const { verifiedDiscriminator: _stale, ...unmarked } = candidate;
172
+ const claim = claimsByKey.get(scenarioMergeKey(candidate.scenario.scenarioName) || candidate.candidateId);
173
+ if (!claim)
174
+ return unmarked;
175
+ const result = validateDiscriminator(candidate.scenario, claim, diffText, reposWithoutDiff);
176
+ if (result.verified) {
177
+ return { ...unmarked, verifiedDiscriminator: claim.kind };
178
+ }
179
+ demotions.push({
180
+ candidateId: candidate.candidateId,
181
+ reason: result.reason ?? `${claim.kind} discriminator claim could not be verified.`,
182
+ });
183
+ return unmarked;
184
+ });
185
+ return { candidates: marked, demotions };
163
186
  }
164
187
  /** Recover full Candidates for pre-seeded ("server") plan items by matching
165
188
  * candidateId back to the analyzed scenarios (repositoryAnalysis.scenarios) —
@@ -190,15 +213,54 @@ function recoverServerCandidates(priorPlan, allScenarios) {
190
213
  }
191
214
  return recovered;
192
215
  }
193
- function resolveBudgetContext(stateData) {
216
+ /** Whether a repo section's persisted diff classification contains any new,
217
+ * modified, or removed endpoint — the same signal `computeScoredCandidates`
218
+ * derives `hasApiChanges` from at analyze time. */
219
+ function sectionHasApiChanges(data) {
220
+ const diff = data?.repositoryAnalysis?.diff;
221
+ return ((diff?.newEndpoints?.length ?? 0) > 0 ||
222
+ (diff?.modifiedEndpoints?.length ?? 0) > 0 ||
223
+ (diff?.removedEndpoints?.length ?? 0) > 0);
224
+ }
225
+ function resolveBudgetContext(stateData, fullState) {
194
226
  const pbc = stateData.planBudgetContext;
195
- return {
227
+ const base = {
196
228
  maxGenerate: pbc?.maxGenerate ?? MAX_TESTS_TO_GENERATE,
197
229
  maxTotal: pbc?.maxTotal ?? MAX_RECOMMENDATIONS,
198
230
  isUIOnlyPR: pbc?.isUIOnlyPR ?? false,
199
231
  hasFrontendChanges: pbc?.hasFrontendChanges ?? false,
200
232
  externalCoverage: new Set(pbc?.externalCoverageKeys ?? []),
201
233
  };
234
+ const relatedSections = Object.values(fullState?.relatedRepos ?? {}).map((section) => section.data);
235
+ if (relatedSections.length === 0)
236
+ return base;
237
+ // SKYR-4204: isUIOnlyPR/hasFrontendChanges are per-SECTION facts — each
238
+ // analyze call computes them from its own repo's diff. The approved plan is
239
+ // one run-wide decision, so a frontend-only PRIMARY diff must not put the
240
+ // budgeter on its all-UI branch (backendGenerateCount() = 0) while a related
241
+ // repo carries endpoint changes; that pinned GENERATE to UI placeholders and
242
+ // left every API candidate in ADDITIONAL regardless of maxGenerate. Derive
243
+ // the flags across ALL sections. A related repo checked out on its default
244
+ // branch classifies no endpoint changes, so it leaves a UI-only run UI-only.
245
+ // Budget numbers come from the root (primary) section: the testbot prompt
246
+ // passes topN/maxGenerate only to the primary analyze call, so related
247
+ // sections carry defaults.
248
+ const sections = [fullState ?? undefined, ...relatedSections];
249
+ const hasFrontendChanges = sections.some((s) => s?.planBudgetContext?.hasFrontendChanges ?? false);
250
+ const hasApiChanges = sections.some(sectionHasApiChanges);
251
+ const rootPbc = fullState?.planBudgetContext;
252
+ // SKYR-4211: external-test coverage is also a per-section fact — a backend
253
+ // candidate already covered by an external test recorded in a RELATED repo's
254
+ // section must dedup the same as one covered in the primary. Union the keys
255
+ // across all sections, like the flags above.
256
+ const externalCoverage = new Set(sections.flatMap((s) => s?.planBudgetContext?.externalCoverageKeys ?? []));
257
+ return {
258
+ maxGenerate: rootPbc?.maxGenerate ?? base.maxGenerate,
259
+ maxTotal: rootPbc?.maxTotal ?? base.maxTotal,
260
+ isUIOnlyPR: hasFrontendChanges && !hasApiChanges,
261
+ hasFrontendChanges,
262
+ externalCoverage,
263
+ };
202
264
  }
203
265
  function describeGenerationCall(item) {
204
266
  switch (item.testType) {
@@ -306,20 +368,52 @@ export function registerRegisterTestPlanTool(server) {
306
368
  }, async (params) => {
307
369
  let errorResult;
308
370
  try {
309
- logger.info(`Registering test plan${params.repository ? ` for repository ${params.repository}` : ""}`, {
371
+ logger.info("Registering run-wide test plan", {
310
372
  candidateCount: params.candidates?.length ?? 0,
311
373
  });
312
374
  const stateManager = StateManager.fromStatePath(params.stateFile);
313
- const stateData = await stateManager.readRepoData(params.repository);
314
- const repositoryPath = (await stateManager.getRepoRepositoryPath(params.repository)) || "";
375
+ // The plan is run-wide, so register always reads the ROOT (primary)
376
+ // section there is no section selector (routing by one is what let a
377
+ // related-repo registration run with that section's context —
378
+ // SKYR-4204 reopen). Run-wide facts (diff union, budget flags,
379
+ // scenario lookup) come from fullState below.
380
+ const stateData = await stateManager.readData();
315
381
  if (!stateData) {
316
382
  errorResult = toolError(`State file is empty or invalid: ${params.stateFile}. Call skyramp_analyze_changes first to generate a valid state file.`);
317
383
  return errorResult;
318
384
  }
319
- const diffText = stateData.diffText ?? "";
320
- const allScenarios = stateData.repositoryAnalysis?.scenarios ?? [];
321
- const { candidates: agentCandidates, demotions } = buildAgentCandidates(params.candidates ?? [], diffText);
322
- const serverCandidates = recoverServerCandidates(stateData.approvedPlan, allScenarios);
385
+ // SKYR-4203: one register call carries candidates for EVERY repo of the
386
+ // run, but `stateData` is a single repo's section — and the candidate
387
+ // schema has no per-candidate repository, so a related repo's anchors
388
+ // were unverifiable by construction. Verify anchors (and rank diff-hunk
389
+ // proximity) against the union of ALL sections' diffs: an anchor
390
+ // grounded in a related repo's change is as real as one in the
391
+ // primary's. Sections with no diff (a related repo checked out on its
392
+ // default branch) are collected so an unmatched anchor reads as
393
+ // uncheckable rather than ungrounded.
394
+ const fullState = await stateManager.readFullState();
395
+ const diffSections = [
396
+ { repo: fullState?.metadata?.repository ?? "the primary repository", diff: fullState?.diffText },
397
+ ...Object.entries(fullState?.relatedRepos ?? {}).map(([repo, section]) => ({
398
+ repo,
399
+ diff: section.data?.diffText,
400
+ })),
401
+ ];
402
+ const diffText = diffSections.map((s) => s.diff).filter(Boolean).join("\n");
403
+ const reposWithoutDiff = diffSections.filter((s) => !s.diff).map((s) => s.repo);
404
+ // Scenario lookup for server-candidate recovery spans ALL sections —
405
+ // the primary's drafted scenarios must resolve even when `repository`
406
+ // targets a related repo (SKYR-4204 reopen: the same single-section
407
+ // shape as the diff union above).
408
+ const allScenarios = [
409
+ ...(fullState?.repositoryAnalysis?.scenarios ?? []),
410
+ ...Object.values(fullState?.relatedRepos ?? {}).flatMap((section) => section.data?.repositoryAnalysis?.scenarios ?? []),
411
+ ];
412
+ const agentCandidates = buildAgentCandidates(params.candidates ?? []);
413
+ // The approved plan is run-wide and persists at the ROOT (see the
414
+ // persist step below), so prior-plan recovery reads the root first;
415
+ // the section fallback covers state written by older builds.
416
+ const serverCandidates = recoverServerCandidates(fullState?.approvedPlan ?? stateData.approvedPlan, allScenarios);
323
417
  // Merge by scenario-name identity, NOT the full content-hashed candidateId:
324
418
  // the server (analyze_changes) and the agent frequently draft their own
325
419
  // independent steps[] for "the same" scenario (identical scenarioName),
@@ -341,7 +435,18 @@ export function registerRegisterTestPlanTool(server) {
341
435
  merged.set(mergeKey(candidate), candidate);
342
436
  for (const candidate of serverCandidates)
343
437
  merged.set(mergeKey(candidate), candidate);
344
- const allCandidates = [...merged.values()];
438
+ // The agent declares a claim per scenario NAME, so key the claims the
439
+ // same way the merge does and verify against whichever copy survived.
440
+ const claimsByKey = new Map();
441
+ // Keyed through mergeKey, not scenarioMergeKey alone: a name that
442
+ // slugifies to nothing (e.g. an all-non-ASCII name) would be stored
443
+ // under "" while the lookup falls back to the candidateId, so the claim
444
+ // would be neither verified nor demoted.
445
+ (params.candidates ?? []).forEach((input, i) => {
446
+ if (input.discriminator)
447
+ claimsByKey.set(mergeKey(agentCandidates[i]), input.discriminator);
448
+ });
449
+ const { candidates: allCandidates, demotions } = applyDiscriminatorClaims([...merged.values()], claimsByKey, diffText, reposWithoutDiff);
345
450
  // An empty union would persist an authoritative plan with an empty
346
451
  // GENERATE list, which the generation gate then enforces — bricking
347
452
  // the run until re-registration. Reject instead so the agent can
@@ -351,7 +456,44 @@ export function registerRegisterTestPlanTool(server) {
351
456
  "Submit your complete candidate list (every test you would generate or recommend) in `candidates` — an empty plan would block all generation.");
352
457
  return errorResult;
353
458
  }
354
- const budgetContext = resolveBudgetContext(stateData);
459
+ // Review (mcp#774): enforce ONE run-wide plan at the tool boundary. A
460
+ // re-registration whose pool shares no scenario with the active plan
461
+ // is the per-repo-partial signature (the demoshop rig's second,
462
+ // backend-only call) — accepting it would silently REPLACE the run's
463
+ // plan with a subset and drop every earlier agent candidate. Genuine
464
+ // full-list updates overlap the active plan and replace it as before.
465
+ // The overlap test spans GENERATE ∪ ADDITIONAL: with maxGenerate=0 the
466
+ // active plan's GENERATE is empty while ADDITIONAL carries every
467
+ // candidate, and a disjoint partial could otherwise still replace it
468
+ // silently (Copilot review on #774).
469
+ const priorItems = [
470
+ ...(fullState?.approvedPlan?.generate ?? []),
471
+ ...(fullState?.approvedPlan?.additional ?? []),
472
+ ];
473
+ if (priorItems.length > 0) {
474
+ const poolKeys = new Set(allCandidates.map(mergeKey));
475
+ const overlaps = priorItems.some((item) => poolKeys.has(scenarioMergeKey(item.scenarioName) || item.candidateId));
476
+ if (!overlaps) {
477
+ errorResult = toolError(`A run-wide plan is already active (${fullState.approvedPlan.planId}: ` +
478
+ `${priorItems.map((i) => i.scenarioName).join(", ")}) and this submission shares none of its ` +
479
+ "scenarios. skyramp_register_test_plan REPLACES the whole run's plan — resubmit ONE pooled candidate " +
480
+ "list for the entire run (your previous candidates plus these changes), not a per-repo subset.");
481
+ return errorResult;
482
+ }
483
+ }
484
+ const budgetContext = resolveBudgetContext(stateData, fullState);
485
+ // SKYR-4204 reopen: a reserved UI slot is only fillable from the
486
+ // candidate pool. A pool with no UI candidate on a run with frontend
487
+ // changes would silently plan zero UI tests (runs 32419025266,
488
+ // 32431042133, 32431106864) — reject so the agent self-corrects in one
489
+ // step, exactly like the empty-candidates rejection above.
490
+ if (reservedUISlots(budgetContext) > 0 &&
491
+ !allCandidates.some((c) => inferScenarioType(c.scenario) === TestType.UI)) {
492
+ errorResult = toolError("This run has frontend changes, so the plan reserves a UI slot — but no submitted or recovered " +
493
+ "candidate has testType 'ui'. Resubmit your full candidate list including at least one UI candidate " +
494
+ "for the changed frontend (see uiContext.changedFrontendFiles).");
495
+ return errorResult;
496
+ }
355
497
  const result = selectPlan(allCandidates, { ...budgetContext, demotions, diffText });
356
498
  const approvedPlan = {
357
499
  planId: crypto.randomUUID(),
@@ -360,8 +502,16 @@ export function registerRegisterTestPlanTool(server) {
360
502
  additional: result.additional.map(buildApprovedPlanItem),
361
503
  demotions: result.demotions,
362
504
  };
505
+ // The approved plan is the ONE run-wide authority (the prompt mandates
506
+ // a single pooled registration on multi-repo runs), so it persists at
507
+ // the state-file ROOT regardless of `repository`. Per-section plans
508
+ // blinded every root-reading consumer — planGuard let generation run
509
+ // unguarded, submitReportTool's reconciliation was inert, and the eval
510
+ // extractor saw "no plan" (SKYR-4204 reopen). writeData preserves the
511
+ // relatedRepos sections and the root metadata.
363
512
  try {
364
- await stateManager.writeRepoData({ ...stateData, approvedPlan }, { repo: params.repository, repositoryPath, step: TOOL_NAME });
513
+ const { metadata: _meta, relatedRepos: _sections, ...rootData } = fullState ?? {};
514
+ await stateManager.writeData({ ...rootData, approvedPlan }, { step: TOOL_NAME });
365
515
  }
366
516
  catch (error) {
367
517
  errorResult = toolError(`Failed to persist approved test plan to state file: ${error.message}. Retry this call.`);
@@ -26,6 +26,20 @@ export interface TestExecutionResult extends TestExecutionData {
26
26
  testFile: string;
27
27
  videoPath?: string;
28
28
  }
29
+ /**
30
+ * One browser execution's recorded video, as persisted in run state by
31
+ * skyramp_execute_test and read back by skyramp_submit_report (SKYR-4156).
32
+ *
33
+ * `status`/`executedAt` are not rendered in the report — they identify WHICH
34
+ * execution the referenced recording belongs to, which matters because a retried
35
+ * test leaves a video directory behind for every attempt.
36
+ */
37
+ export interface VideoRecord {
38
+ /** Host path to the recorded video.webm. */
39
+ videoPath: string;
40
+ status: TestExecutionStatus;
41
+ executedAt: string;
42
+ }
29
43
  export interface BatchExecutionResult {
30
44
  totalTests: number;
31
45
  passed: number;
@@ -216,8 +216,9 @@ export const baseTestSchema = {
216
216
  .string()
217
217
  .default("")
218
218
  .describe("MUST be string of comma separated values like 'id=1,name=John' for URL query parameters. "
219
- + "Workspace-configured api.defaultQueryParams (if set) are merged in automatically "
220
- + "no need to repeat them here. An explicit value for the same key here overrides the workspace default."),
219
+ + "Workspace-configured api.defaultQueryParams (if set) are merged in automatically, with any "
220
+ + "api.queryParamOverrides entry whose pathPattern matches this endpoint layered on top "
221
+ + "no need to repeat them here. An explicit value for the same key here overrides both."),
221
222
  formParams: z
222
223
  .string()
223
224
  .default("")
@@ -16,7 +16,6 @@ export interface RelatedRepository {
16
16
  export interface TestbotPromptOptions {
17
17
  prTitle: string;
18
18
  prDescription: string;
19
- summaryOutputFile: string;
20
19
  repositoryPath: string;
21
20
  baseBranch?: string;
22
21
  maxRecommendations?: number;
@@ -2,10 +2,21 @@ import type { DriftAction } from "./TestAnalysis.js";
2
2
  import type { TestExecutionStatus } from "./TestExecution.js";
3
3
  import type { HttpMethod, TestType } from "./TestTypes.js";
4
4
  import type { ReuseOutcome } from "./ReuseOutcome.js";
5
+ /** Classification of an issuesFound entry. Bug is a product/code defect; the
6
+ * other values are tooling noise that the Testbot report renders in its
7
+ * Configuration Errors section instead of Issues Found. */
8
+ export declare enum IssueFoundCategory {
9
+ Bug = "bug",
10
+ Lint = "lint",
11
+ Type = "type",
12
+ Config = "config"
13
+ }
5
14
  /**
6
15
  * Shape of the JSON report written by skyramp_submit_report and read by testbot
7
16
  * for rendering as Markdown. All fields mirror the corresponding Zod schemas in
8
- * submitReportTool.ts — keep the two in sync.
17
+ * submitReportTool.ts — keep the two in sync. This type describes what a READER
18
+ * may encounter across MCP versions; the Zod schemas define what the current
19
+ * producer must submit, so a field can be required on submit but optional here.
9
20
  */
10
21
  export interface TestbotReport {
11
22
  businessCaseAnalysis: string;
@@ -16,6 +27,9 @@ export interface TestbotReport {
16
27
  fileName: string;
17
28
  reasoning: string;
18
29
  description: string;
30
+ /** `owner/repo` attribution in multi-repo runs (SKYR-3786). Absent = the
31
+ * primary repo, or a single-repo run. */
32
+ repository?: string;
19
33
  scenarioFile?: string;
20
34
  traceFile?: string;
21
35
  frontendTrace?: string;
@@ -44,6 +58,8 @@ export interface TestbotReport {
44
58
  status: "Pass" | "Fail" | "Skipped";
45
59
  details: string;
46
60
  videoPath?: string;
61
+ /** See newTestsCreated[].repository. */
62
+ repository?: string;
47
63
  }[];
48
64
  additionalRecommendations?: {
49
65
  testId: string;
@@ -65,10 +81,17 @@ export interface TestbotReport {
65
81
  openApiSpec?: string;
66
82
  backendTrace?: string;
67
83
  frontendTrace?: string;
84
+ /** See newTestsCreated[].repository. */
85
+ repository?: string;
68
86
  }[];
69
87
  issuesFound: {
70
88
  description: string;
71
89
  severity?: "critical" | "high" | "medium" | "low";
90
+ /** Required by the submit_report schema since 0.3.4; absent in reports
91
+ * written by older MCP versions. Readers treat absence as Bug. */
92
+ category?: IssueFoundCategory;
93
+ /** See newTestsCreated[].repository. */
94
+ repository?: string;
72
95
  }[];
73
96
  nextSteps: string[];
74
97
  commitMessage: string;
@@ -1 +1,10 @@
1
- export {};
1
+ /** Classification of an issuesFound entry. Bug is a product/code defect; the
2
+ * other values are tooling noise that the Testbot report renders in its
3
+ * Configuration Errors section instead of Issues Found. */
4
+ export var IssueFoundCategory;
5
+ (function (IssueFoundCategory) {
6
+ IssueFoundCategory["Bug"] = "bug";
7
+ IssueFoundCategory["Lint"] = "lint";
8
+ IssueFoundCategory["Type"] = "type";
9
+ IssueFoundCategory["Config"] = "config";
10
+ })(IssueFoundCategory || (IssueFoundCategory = {}));
@@ -2,6 +2,7 @@ export { TestExecutionStatus } from "./TestExecution.js";
2
2
  export { DriftAction } from "./TestAnalysis.js";
3
3
  export { TestType, HttpMethod } from "./TestTypes.js";
4
4
  export type { TestbotReport } from "./TestbotReport.js";
5
+ export { IssueFoundCategory } from "./TestbotReport.js";
5
6
  export { ReuseDeclinedBy, ReuseVerificationOutcome } from "./ReuseOutcome.js";
6
7
  export type { ReuseOutcome, ReuseSkippedEntry } from "./ReuseOutcome.js";
7
8
  export type { RelatedRepository, TestbotPromptOptions, } from "./TestbotPromptOptions.js";
@@ -1,4 +1,5 @@
1
1
  export { TestExecutionStatus } from "./TestExecution.js";
2
2
  export { DriftAction } from "./TestAnalysis.js";
3
3
  export { TestType, HttpMethod } from "./TestTypes.js";
4
+ export { IssueFoundCategory } from "./TestbotReport.js";
4
5
  export { ReuseDeclinedBy, ReuseVerificationOutcome } from "./ReuseOutcome.js";