@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
@@ -7,11 +7,13 @@ import { TEST_CATEGORIES, externalCategory } from "../types/TestRecommendation.j
7
7
  import { TestType, HttpMethod } from "../types/TestTypes.js";
8
8
  import { DriftAction } from "../types/TestAnalysis.js";
9
9
  import { TestExecutionStatus } from "../types/TestExecution.js";
10
- import { StateManager } from "../utils/AnalysisStateManager.js";
10
+ import { IssueFoundCategory } from "../types/TestbotReport.js";
11
+ import { StateManager, runArtifactDir, getTestsRepoDir } from "../utils/AnalysisStateManager.js";
11
12
  import { toolError, testFileMatches } from "../utils/utils.js";
12
13
  import { matchesApprovedPlan } from "../utils/planMatchKeys.js";
13
14
  import { isTestbotEnabled } from "../utils/featureFlags.js";
14
- import { findUnbackedClaims, listChangedFiles } from "../utils/reportVerification.js";
15
+ import { findUnbackedClaims, listChangedFiles, listChangedFilesAcross } from "../utils/reportVerification.js";
16
+ import { getReportLanguage, isEnforcedReportLanguage, findLanguageViolations, findLanguageNearMisses, reportLanguageDisplayName, } from "../utils/reportLanguage.js";
15
17
  import { rederiveReuseOutcome } from "./code-refactor/reuse-state.js";
16
18
  // SKYR-3879 Path B: which testTypes the register-plan checkpoint gates. Mirrors
17
19
  // the generation tools actually wired to planGuard (batch-scenario/integration,
@@ -19,13 +21,49 @@ import { rederiveReuseOutcome } from "./code-refactor/reuse-state.js";
19
21
  // NOT gated at generation time (see planGuard.ts wiring), so they are excluded
20
22
  // here too rather than surprising the agent with a report-time-only gate.
21
23
  const PLAN_GATED_TEST_TYPES = new Set([TestType.CONTRACT, TestType.INTEGRATION]);
22
- /** Split an `endpoint` field ("GET /api/v1/products") into method + path. */
24
+ /**
25
+ * Filename of the report, written beside the state file. SKYR-4147: the report path is
26
+ * derived here rather than accepted as a parameter. The caller builds the state file and
27
+ * the report path from a single temp dir (testbot's `skyrampTempDir()`), so the state
28
+ * file's directory IS the directory the caller reads the report back from. Taking the
29
+ * path from the agent instead meant the agent had to retype it out of a 600-character
30
+ * encoded prompt URI; one wrong character wrote the report somewhere nobody reads, and
31
+ * the tool still reported success, throwing away the whole run without a word.
32
+ *
33
+ * Must stay in sync with testbot, which hardcodes the same string in main.ts, post.ts and
34
+ * eval-entry.ts — the same duplication across the two repos that already exists for
35
+ * `RUN_STATE_FILE_NAME` / testbot's `STATE_FILE_NAME`.
36
+ */
37
+ export const REPORT_FILE_NAME = "testbot-result.txt";
38
+ /**
39
+ * The names to match a report entry against the approved plan's `name:` keys.
40
+ * `testId` is documented as `<testType>-<scenario-slug>`, so the plan's bare
41
+ * scenarioName only matches once that prefix is dropped — but agents also submit
42
+ * the bare name (both forms appear in SKYR-4123's runs), so try each.
43
+ */
44
+ function planNameCandidates(testId, testType) {
45
+ const id = (testId ?? "").trim();
46
+ if (!id)
47
+ return [];
48
+ const prefix = `${testType}-`;
49
+ return id.toLowerCase().startsWith(prefix) ? [id, id.slice(prefix.length)] : [id];
50
+ }
51
+ /**
52
+ * Split an `endpoint` field into one {method, path} per endpoint it names.
53
+ * A multi-step test writes them comma-separated ("POST /orders, DELETE
54
+ * /orders/{id}"); read as a single endpoint the whole string becomes one
55
+ * unmatchable path, so such an entry could match nothing (SKYR-4123).
56
+ */
23
57
  function parseEndpointField(endpoint) {
24
- const trimmed = (endpoint ?? "").trim();
25
- const spaceIdx = trimmed.indexOf(" ");
26
- if (spaceIdx <= 0)
27
- return { path: trimmed || undefined };
28
- return { method: trimmed.slice(0, spaceIdx), path: trimmed.slice(spaceIdx + 1).trim() };
58
+ const parts = (endpoint ?? "").split(",").map((p) => p.trim()).filter(Boolean);
59
+ if (parts.length === 0)
60
+ return [{}];
61
+ return parts.map((part) => {
62
+ const spaceIdx = part.indexOf(" ");
63
+ if (spaceIdx <= 0)
64
+ return { path: part };
65
+ return { method: part.slice(0, spaceIdx), path: part.slice(spaceIdx + 1).trim() };
66
+ });
29
67
  }
30
68
  // Drift actions that actually modify a test file. VERIFY and IGNORE are
31
69
  // no-ops (the test was assessed but left unchanged), so they must not count
@@ -53,12 +91,22 @@ function normalizeRepository(item) {
53
91
  const trimmed = item.repository?.trim();
54
92
  return trimmed ? { ...item, repository: trimmed } : { ...item, repository: undefined };
55
93
  }
94
+ // videoPath is deliberately absent from this input contract: it is attached server-side
95
+ // from the run's execution records (see attachVideoPath), and zod strips any the model
96
+ // supplies anyway. SKYR-4156 is what happens when the agent owns that field instead.
56
97
  const testResultSchema = z.object({
57
98
  testType: z.nativeEnum(TestType).describe("Type of test. Do not include priority or other metadata in this field."),
58
99
  endpoint: z.string().describe("HTTP verb and path, e.g. 'GET /api/v1/products'"),
59
100
  status: z.enum(["Pass", "Fail", "Skipped"]).describe("Test execution result"),
60
101
  details: z.string().describe("One sentence — no embedded newlines, no markdown. e.g. '10.8s, products_contract_test.py' or 'failed: <one-line error summary>, products_contract_test.py'"),
61
- videoPath: z.string().optional().describe("Path to video recording (UI/E2E tests only)"),
102
+ // Required for every row: each one reports a specific test file the agent ran, so it
103
+ // can always name it. It is what identifies the row server-side — `endpoint` cannot,
104
+ // since several tests routinely exercise one endpoint — and for ui/e2e it is what
105
+ // attaches the recorded video (SKYR-4156). Not included in the report itself.
106
+ testFilePath: z
107
+ .string()
108
+ .refine((p) => path.isAbsolute(p), { message: "testFilePath must be an absolute path" })
109
+ .describe("Absolute path of the test file this result is for — the same path you passed to skyramp_execute_test's testFile param. Consumers basename it for display."),
62
110
  repository: repositoryField,
63
111
  });
64
112
  // Structured grounding for UI recommendations. Lifted verbatim from a
@@ -85,11 +133,39 @@ export const pageContextSchema = z.object({
85
133
  url: z.string().describe("URL of the page where the test runs. Lifted from BlueprintCapture.url."),
86
134
  pageHash: z.string().optional().describe("Opaque hash of the captured page state (BlueprintCapture.pageHash). Lets the verifier confirm the recommendation was grounded in a still-current capture."),
87
135
  });
136
+ /**
137
+ * SKYR-4193: LLMs habitually emit every key a schema declares, using a
138
+ * literal null for "not applicable". A null is stripped (normalized to
139
+ * omitted) before validation wherever null carries no meaning, instead of
140
+ * costing the agent a rejection/rewrite round-trip (run 32182635458 lost
141
+ * ~2m40s to exactly this):
142
+ * - `pageContext: null` is always stripped — the field is optional but never
143
+ * nullable, so null is never a valid value, UI or not.
144
+ * - `targetElements: null` is stripped only on non-UI entries — on UI tests
145
+ * null is load-bearing ("blueprint capture failed", requires the
146
+ * '[no-blueprint-data]' markers) and must reach the refinements intact.
147
+ * Non-null values on non-UI entries still reach the refinements and are
148
+ * still rejected.
149
+ */
150
+ function stripNullGroundingFields(val) {
151
+ if (!val || typeof val !== "object" || Array.isArray(val))
152
+ return val;
153
+ const entry = val;
154
+ const stripTargetElements = entry.targetElements === null && entry.testType !== TestType.UI;
155
+ if (!stripTargetElements && entry.pageContext !== null)
156
+ return val;
157
+ const copy = { ...entry };
158
+ if (stripTargetElements)
159
+ delete copy.targetElements;
160
+ if (copy.pageContext === null)
161
+ delete copy.pageContext;
162
+ return copy;
163
+ }
88
164
  // TODO: Unify newTestSchema and additionalRecommendationSchema into a single
89
165
  // interface that adds an `implemented: boolean` field. Both describe the same
90
166
  // concept (a test recommendation) — the only difference is whether it was
91
167
  // generated in this run or left for later. Tracked per Archit's review comment.
92
- export const newTestSchema = z.object({
168
+ export const newTestSchema = z.preprocess(stripNullGroundingFields, z.object({
93
169
  testId: z.string().describe("Human-readable kebab-case identifier, e.g. 'contract-get-products' or 'integration-users-orders-workflow'. Format: '<testType>-<method>-<resource>' for single-endpoint tests or '<testType>-<scenario-slug>' for multi-step tests. Must be unique within the report."),
94
170
  testType: z.nativeEnum(TestType).describe("Type of test created. Do not include priority or other metadata in this field."),
95
171
  category: z.preprocess((val) => externalCategory(val), z.enum(TEST_CATEGORIES)).describe("Test category — critical categories (security_boundary, business_rule, data_integrity, breaking_change) get generation priority over workflow"),
@@ -166,7 +242,7 @@ export const newTestSchema = z.object({
166
242
  }
167
243
  }
168
244
  }
169
- });
245
+ }));
170
246
  const issueFoundSchema = z.object({
171
247
  description: z.string().describe("One-line description. Do NOT prefix with the severity level — severity is a separate field. Include code logic bugs from the diff, test generation/execution failures, and environment misconfiguration."),
172
248
  severity: z
@@ -175,6 +251,13 @@ const issueFoundSchema = z.object({
175
251
  .describe("Issue severity. critical = feature broken/unusable (e.g. page doesn't load, data corruption). " +
176
252
  "high = incorrect behavior (e.g. wrong calculation, stale data returned). " +
177
253
  "medium = minor functional gap. low = cosmetic or informational."),
254
+ category: z
255
+ .nativeEnum(IssueFoundCategory)
256
+ .describe("Issue classification. bug = a product/code defect, e.g. found by a test or in the diff. " +
257
+ "lint = a linter or formatter finding (eslint, flake8, prettier). " +
258
+ "type = a type-check failure (tsc, mypy). " +
259
+ "config = environment or tooling misconfiguration (wrong workspace auth type, missing env var, setup command failure). " +
260
+ "The report renders lint/type/config entries in a separate 'Configuration Errors' section so product bugs stay prominent under 'Issues Found'."),
178
261
  repository: repositoryField,
179
262
  });
180
263
  const scenarioStepSchema = z.object({
@@ -185,7 +268,7 @@ const scenarioStepSchema = z.object({
185
268
  requestBody: z.record(z.any()).optional().describe("Example request body with realistic field values"),
186
269
  responseBody: z.record(z.any()).optional().describe("Key response fields to verify, e.g. { id: 'number', name: 'string', in_stock: 'boolean?' }"),
187
270
  });
188
- export const additionalRecommendationSchema = z.object({
271
+ export const additionalRecommendationSchema = z.preprocess(stripNullGroundingFields, z.object({
189
272
  testId: z.string().describe("Human-readable kebab-case identifier, e.g. 'integration-products-orders-workflow' or 'e2e-checkout-flow'. Format: '<testType>-<scenario-slug>'. Must be unique within the report."),
190
273
  testType: z.nativeEnum(TestType).describe("Type of test. Do not include priority or other metadata in this field."),
191
274
  category: z.preprocess((val) => externalCategory(val), z.enum(TEST_CATEGORIES)).describe("Test category — critical categories get generation priority over workflow"),
@@ -261,7 +344,7 @@ export const additionalRecommendationSchema = z.object({
261
344
  }
262
345
  }
263
346
  }
264
- });
347
+ }));
265
348
  // TODO(multi-repo maintenance): no `repository` field yet — see readData() TODO below.
266
349
  const testMaintenanceSchema = z.object({
267
350
  testType: z.nativeEnum(TestType).describe("Type of test."),
@@ -366,6 +449,20 @@ async function attachReuseOutcome(test, outcomes) {
366
449
  const reuse = await rederiveReuseOutcome(found);
367
450
  return reuse ? { ...test, reuse } : test;
368
451
  }
452
+ /**
453
+ * Attach the video recorded for this execution, matched by the row's testFilePath
454
+ * basename — the key skyramp_execute_test records under.
455
+ *
456
+ * Server-derived, never taken from the LLM, and the recorded value always wins: any
457
+ * videoPath the model supplied is discarded. That is the whole point of SKYR-4156 —
458
+ * the field used to be the agent's job, so it decayed to nothing on every row and
459
+ * testbot (which uploads only the video directories the report references) shipped
460
+ * no recordings at all. A row with no recorded execution gets no video, which
461
+ * consumers already treat as "no recording for this test".
462
+ */
463
+ function attachVideoPath(row, videos) {
464
+ return { ...row, videoPath: videos?.[path.basename(row.testFilePath)]?.videoPath };
465
+ }
369
466
  function deduplicateById(items) {
370
467
  const seen = new Set();
371
468
  const result = [];
@@ -387,16 +484,13 @@ export function registerSubmitReportTool(server) {
387
484
  server.registerTool(TOOL_NAME, {
388
485
  annotations: {
389
486
  readOnlyHint: false,
390
- destructiveHint: true, // overwrites summaryOutputFile on disk
487
+ destructiveHint: true, // overwrites the report file on disk
391
488
  idempotentHint: false,
392
489
  openWorldHint: false,
393
490
  },
394
491
  description: "Submit the final testbot report. Call this tool once after completing all test analysis, generation, and execution. " +
395
492
  "This is the ONLY way to submit the report — do NOT write the report to a file manually.",
396
493
  inputSchema: {
397
- summaryOutputFile: z
398
- .string()
399
- .describe("The file path where the report should be written (provided in the task instructions)"),
400
494
  businessCaseAnalysis: z
401
495
  .string()
402
496
  .min(1, "businessCaseAnalysis is required — describe what the PR does, even for no-test runs (config-only, non-application, Testbot onboarding). It is never optional and must not be empty.")
@@ -440,8 +534,9 @@ export function registerSubmitReportTool(server) {
440
534
  .string()
441
535
  .describe("Path to the state file returned by skyramp_analyze_changes. Required — " +
442
536
  "contains execution results for maintenance scoring (existingTests), " +
443
- "discovered endpoints for coverage validation (newEndpoints), and " +
444
- "UI page context for frontend test grounding (uiContext)."),
537
+ "discovered endpoints for coverage validation (newEndpoints), " +
538
+ "UI page context for frontend test grounding (uiContext), and the " +
539
+ "videos recorded for each executed browser test (executionVideos)."),
445
540
  },
446
541
  _meta: {
447
542
  keywords: ["report", "summary", "testbot", "submit"],
@@ -449,6 +544,98 @@ export function registerSubmitReportTool(server) {
449
544
  }, async (params) => {
450
545
  const startTime = Date.now();
451
546
  let errorResult;
547
+ // Residual-leak telemetry (SKYR-4185): filled by the language guardrail
548
+ // below when a report language is enforced, spread into the analytics
549
+ // event in the finally. Empty (no properties) for non-enforced runs.
550
+ const languageTelemetry = {};
551
+ // The report goes next to the state file, so in a Testbot run the state file has to
552
+ // be in the run directory — that is where the action reads the report back from.
553
+ // Two ways that breaks, both ending in a report nobody reads while this tool says
554
+ // it succeeded, which is the failure SKYR-4147 fixed: no run directory at all (the
555
+ // state file then falls back to a temp path of its own), or a readable but stale
556
+ // state file from somewhere else. Reject both rather than write. Checked only in a
557
+ // Testbot run; local and IDE use have no run directory and no consumer waiting.
558
+ if (isTestbotEnabled()) {
559
+ const runDir = runArtifactDir();
560
+ if (!runDir) {
561
+ return toolError("Cannot write the report: this is a Testbot run but RUNNER_TEMP is not set, so " +
562
+ "there is no run directory to write it to. The Testbot action reads the report " +
563
+ "from $RUNNER_TEMP/skyramp. Set RUNNER_TEMP for the run. Retrying or changing " +
564
+ "the report will not help — this is an environment problem.");
565
+ }
566
+ const stateDir = path.dirname(params.stateFile);
567
+ if (path.resolve(stateDir) !== path.resolve(runDir)) {
568
+ return toolError(`Cannot write the report: stateFile is in ${stateDir}, but this run's directory ` +
569
+ `is ${runDir}. The report is written next to the state file and the Testbot ` +
570
+ `action reads it from the run directory, so writing it would lose it. Pass the ` +
571
+ `stateFile from this run's skyramp_analyze_changes output — not a path from an ` +
572
+ `earlier run or one typed by hand.`);
573
+ }
574
+ }
575
+ // SKYR-4185: report-language guardrail. The language was captured when the
576
+ // testbot prompt was rendered (see reportLanguage.ts) — the instruction to
577
+ // write free-text fields in it is prose the agent sometimes ignores
578
+ // (letsramp/api-insight#215 shipped English testResults[].details in an
579
+ // otherwise-Japanese report). Reject rather than ship a half-translated
580
+ // report; the listed paths tell the agent exactly what to rewrite. Fields
581
+ // the agent does not author here (testMaintenance description from
582
+ // skyramp_actions verdicts, the server default commitMessage) are exempt —
583
+ // rejecting those would loop, since resubmitting cannot change them.
584
+ const reportLanguage = getReportLanguage();
585
+ if (reportLanguage && !isEnforcedReportLanguage(reportLanguage)) {
586
+ logger.warning(`Report language '${reportLanguage}' has no enforcement rules — skipping the language guardrail`);
587
+ }
588
+ if (isEnforcedReportLanguage(reportLanguage)) {
589
+ const textFields = [
590
+ { path: "businessCaseAnalysis", text: params.businessCaseAnalysis },
591
+ ];
592
+ params.newTestsCreated.forEach((t, i) => {
593
+ textFields.push({ path: `newTestsCreated[${i}].description`, text: t.description });
594
+ textFields.push({ path: `newTestsCreated[${i}].reasoning`, text: t.reasoning });
595
+ });
596
+ (params.additionalRecommendations ?? []).forEach((r, i) => {
597
+ textFields.push({ path: `additionalRecommendations[${i}].description`, text: r.description });
598
+ textFields.push({ path: `additionalRecommendations[${i}].reasoning`, text: r.reasoning });
599
+ r.steps.forEach((s, j) => {
600
+ textFields.push({ path: `additionalRecommendations[${i}].steps[${j}].description`, text: s.description });
601
+ });
602
+ });
603
+ params.testResults.forEach((t, i) => {
604
+ textFields.push({ path: `testResults[${i}].details`, text: t.details });
605
+ });
606
+ params.issuesFound.forEach((f, i) => {
607
+ textFields.push({ path: `issuesFound[${i}].description`, text: f.description });
608
+ });
609
+ (params.nextSteps ?? []).forEach((s, i) => {
610
+ textFields.push({ path: `nextSteps[${i}]`, text: s });
611
+ });
612
+ (params.testMaintenanceDetails ?? []).forEach((d, i) => {
613
+ textFields.push({ path: `testMaintenanceDetails[${i}].beforeDetails`, text: d.beforeDetails });
614
+ textFields.push({ path: `testMaintenanceDetails[${i}].afterDetails`, text: d.afterDetails });
615
+ });
616
+ if (params.commitMessage && params.commitMessage !== DEFAULT_COMMIT_MESSAGE) {
617
+ textFields.push({ path: "commitMessage", text: params.commitMessage });
618
+ }
619
+ const languageViolations = findLanguageViolations(textFields, reportLanguage);
620
+ if (languageViolations.length > 0) {
621
+ const languageName = reportLanguageDisplayName(reportLanguage);
622
+ errorResult = toolError(`This run's report language is ${languageName}, but ${languageViolations.length} free-text field(s) are written in English:\n` +
623
+ languageViolations.map((v) => ` - ${v}`).join("\n") +
624
+ `\nRewrite ONLY the listed fields in ${languageName} and resubmit the report. ` +
625
+ `Keep code identifiers, endpoint paths, file names, test IDs, enum values ` +
626
+ `(Pass/Fail/Skipped, severity values, testType), and anything inside backticks untranslated.`);
627
+ return errorResult;
628
+ }
629
+ // The sub-threshold band (a single stray English word) is accepted by
630
+ // design; count what ships so the leniency's real-world leak rate is
631
+ // visible in Amplitude before anyone tightens the threshold.
632
+ const nearMisses = findLanguageNearMisses(textFields, reportLanguage);
633
+ languageTelemetry.reportLanguage = reportLanguage;
634
+ languageTelemetry.languageNearMissCount = String(nearMisses.length);
635
+ if (nearMisses.length > 0) {
636
+ logger.info(`Report language ${reportLanguage}: accepting ${nearMisses.length} field(s) with a single stray English word`, { fields: nearMisses });
637
+ }
638
+ }
452
639
  const dedupedNewTests = deduplicateById([...params.newTestsCreated]);
453
640
  const dedupedRecommendations = deduplicateById([...(params.additionalRecommendations ?? [])]);
454
641
  const stateManager = StateManager.fromStatePath(params.stateFile);
@@ -483,17 +670,31 @@ export function registerSubmitReportTool(server) {
483
670
  // is non-empty), so a generation the gate permitted is never rejected at
484
671
  // report time. Absent approvedPlan (register tool never ran / never
485
672
  // pre-seeded anything) → no check, back-compat.
673
+ //
674
+ // SKYR-4123: the name must be part of the query for that parity to hold.
675
+ // Every generation tool passes scenarioName; this side did not, so the plan
676
+ // item's `name:` key was dead here and a test the gate had already let
677
+ // through was rejected while being listed as approved in the same message.
486
678
  if (stateData.approvedPlan) {
487
679
  const approvedPlan = stateData.approvedPlan;
488
680
  const unapproved = dedupedNewTests.filter((t) => {
489
681
  if (!PLAN_GATED_TEST_TYPES.has(t.testType))
490
682
  return false;
491
- const { method, path } = parseEndpointField(t.endpoint);
492
- return !matchesApprovedPlan(approvedPlan, { testType: t.testType, method, path });
683
+ const endpoints = parseEndpointField(t.endpoint);
684
+ const names = planNameCandidates(t.testId, t.testType);
685
+ return !(names.length > 0 ? names : [undefined]).some((scenarioName) => endpoints.some(({ method, path }) => matchesApprovedPlan(approvedPlan, { scenarioName, testType: t.testType, method, path })));
493
686
  });
494
687
  if (unapproved.length > 0) {
495
688
  const approvedList = approvedPlan.generate.length > 0
496
- ? approvedPlan.generate.map((item) => `[${item.testType}] ${item.scenarioName}`).join(", ")
689
+ // Show each item's endpoint keys: without them a rejection that turns on
690
+ // the endpoint reads as self-contradicting, because the entry's own name
691
+ // is printed in this same list (SKYR-4123).
692
+ ? approvedPlan.generate
693
+ .map((item) => {
694
+ const eps = item.matchKeys.filter((k) => k.startsWith("ep:"));
695
+ return `[${item.testType}] ${item.scenarioName}${eps.length > 0 ? ` covering ${eps.join(", ")}` : ""}`;
696
+ })
697
+ .join("; ")
497
698
  : "(none)";
498
699
  errorResult = toolError(`${unapproved.length} newTestsCreated entr${unapproved.length === 1 ? "y" : "ies"} not in the approved plan from ` +
499
700
  `skyramp_register_test_plan (plan ${approvedPlan.planId}) — neither its GENERATE list nor its ADDITIONAL backfill pool: ` +
@@ -578,7 +779,23 @@ export function registerSubmitReportTool(server) {
578
779
  const repoRoot = fullState?.metadata?.repositoryPath;
579
780
  if (repoRoot && repoRoot !== "unknown") {
580
781
  try {
581
- const changedFiles = await listChangedFiles(repoRoot);
782
+ // A run writes tests into up to three kinds of trees: the primary
783
+ // checkout, the tests-repo checkout (testRepoPath), and related
784
+ // repos. Scanning only the primary rejected a real, executed UI
785
+ // test in the tests-repo checkout as "unbacked", forcing the agent
786
+ // to demote it to additionalRecommendations (SKYR-4204 reopen,
787
+ // run 32510028428).
788
+ // The primary scan keeps its throwing form: an unscannable primary
789
+ // degrades the whole check to a pass (SKYR-3883 semantics) via the
790
+ // catch below. The extra trees are best-effort — a missing or
791
+ // non-git one contributes nothing.
792
+ const changedFiles = [
793
+ ...(await listChangedFiles(repoRoot)),
794
+ ...(await listChangedFilesAcross([
795
+ getTestsRepoDir(),
796
+ ...Object.values(fullState?.relatedRepos ?? {}).map((section) => section.repositoryPath),
797
+ ])),
798
+ ];
582
799
  const unbacked = findUnbackedClaims({
583
800
  repoRoot,
584
801
  changedFiles,
@@ -633,30 +850,39 @@ export function registerSubmitReportTool(server) {
633
850
  ...row,
634
851
  fileName: path.basename(testFilePath),
635
852
  })),
636
- testResults: params.testResults.map(normalizeRepository),
853
+ // videoPath is filled from the run's execution records; testFilePath is the
854
+ // match-only key and is stripped from the wire format, the same line drawn for
855
+ // testMaintenance's own testFilePath above (downstream scoring scripts traverse
856
+ // these objects and don't expect it).
857
+ testResults: params.testResults.map((row) => {
858
+ const { testFilePath: _tfp, ...wire } = attachVideoPath(normalizeRepository(row), stateData.executionVideos);
859
+ return wire;
860
+ }),
637
861
  issuesFound: params.issuesFound.map(normalizeRepository),
638
862
  nextSteps: params.nextSteps ?? [],
639
863
  commitMessage: (params.commitMessage ?? "").replace(/[\r\n]+/g, " ").trim() || DEFAULT_COMMIT_MESSAGE,
640
864
  };
641
865
  const reportJson = JSON.stringify(report, null, 2);
866
+ // Beside the state file, which was read successfully above — so this directory is
867
+ // known to exist and no mkdir is needed. See REPORT_FILE_NAME.
868
+ const reportPath = path.join(path.dirname(params.stateFile), REPORT_FILE_NAME);
642
869
  logger.info("Submitting testbot report", {
643
- outputFile: params.summaryOutputFile,
870
+ outputFile: reportPath,
644
871
  payloadBytes: reportJson.length,
645
872
  testResultCount: params.testResults.length,
646
873
  });
647
874
  try {
648
- await fs.mkdir(path.dirname(params.summaryOutputFile), { recursive: true });
649
- await fs.writeFile(params.summaryOutputFile, reportJson, "utf-8");
875
+ await fs.writeFile(reportPath, reportJson, "utf-8");
650
876
  const elapsed = Date.now() - startTime;
651
877
  logger.info("Testbot report written successfully", {
652
- outputFile: params.summaryOutputFile,
878
+ outputFile: reportPath,
653
879
  elapsedMs: elapsed,
654
880
  });
655
881
  return {
656
882
  content: [
657
883
  {
658
884
  type: "text",
659
- text: `Report submitted successfully to ${params.summaryOutputFile}`,
885
+ text: `Report submitted successfully to ${reportPath}`,
660
886
  },
661
887
  ],
662
888
  };
@@ -670,10 +896,11 @@ export function registerSubmitReportTool(server) {
670
896
  }
671
897
  finally {
672
898
  AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, {
673
- summary_output_file: params.summaryOutputFile,
899
+ summary_output_file: reportPath,
674
900
  testResultCount: String(params.testResults.length),
675
901
  payloadBytes: String(reportJson.length),
676
902
  ...computeReportMetrics({ ...params, testMaintenance }),
903
+ ...languageTelemetry,
677
904
  }).catch(() => { });
678
905
  }
679
906
  });
@@ -6,7 +6,7 @@ import { TestType } from "../../types/TestTypes.js";
6
6
  import * as fs from "fs";
7
7
  import * as path from "path";
8
8
  import { AnalyticsService } from "../../services/AnalyticsService.js";
9
- import { toolError } from "../../utils/utils.js";
9
+ import { dualChannelResult, toolError } from "../../utils/utils.js";
10
10
  import { isTestFile } from "../../prompts/test-recommendation/scopeAssessment.js";
11
11
  import { buildRenameStrategy, buildFileRenameStrategy, buildUpdateStrategy, buildRegenerateStrategy, buildDeleteStrategy, buildUpdateFileInstruction, buildRegenerateFileInstruction, } from "../../prompts/test-maintenance/actionsInstructions.js";
12
12
  /**
@@ -136,6 +136,16 @@ export function registerActionsTool(server) {
136
136
  openWorldHint: true,
137
137
  },
138
138
  description: `Execute test maintenance actions — final step of the unified Test Health Analysis Flow. Call this once per run even when no tests need action (pass \`recommendations: []\`) — it is where the maintenance verdicts are persisted before the run moves on. Supply only the tests that need an action; any discovered test you omit from \`recommendations\` is treated as needing no action and recorded automatically, so you never enumerate the no-action tests yourself.`,
139
+ // SKYR-4194: the payload is returned via structuredContent, which requires a
140
+ // declared outputSchema — see dualChannelResult.
141
+ outputSchema: {
142
+ summary: z
143
+ .string()
144
+ .describe("Human-readable report of the maintenance actions to take."),
145
+ instructions: z
146
+ .string()
147
+ .describe("JSON instructions for carrying out those actions — file lists, per-file edit context, and the update/regenerate/delete strategies. Read and follow this field."),
148
+ },
139
149
  inputSchema: actionsSchema,
140
150
  }, async (args) => {
141
151
  let errorResult;
@@ -166,6 +176,7 @@ export function registerActionsTool(server) {
166
176
  const externalTestFiles = new Set(testAnalysisResults
167
177
  .filter((t) => t.source === TestSource.External)
168
178
  .map((t) => t.testFile));
179
+ const catalogByFile = new Map(testAnalysisResults.map((t) => [t.testFile, t]));
169
180
  // ── Build recommendations from LLM-supplied drift assessment ──
170
181
  // The LLM performs the drift assessment in context after skyramp_analyze_test_health
171
182
  // and passes results here directly — analyzeTestHealthTool never writes assessment
@@ -403,8 +414,7 @@ export function registerActionsTool(server) {
403
414
  const testFilesToDelete = deleteRecommendations.map((rec) => rec.testFilePath);
404
415
  const fileInstructions = [];
405
416
  const testFilesToUpdate = [];
406
- const testFileContentMap = new Map();
407
- // ── UPDATE: read file, emit targeted edit instructions ──
417
+ // ── UPDATE: emit targeted edit instructions ──
408
418
  for (const rec of updateRecommendations) {
409
419
  if (!rec.testFilePath) {
410
420
  logger.warning("Recommendation missing testFilePath", rec);
@@ -414,11 +424,12 @@ export function registerActionsTool(server) {
414
424
  // rather than the spec. testFilePath (the spec) remains in the report.
415
425
  const editTarget = rec.pomFile ?? rec.testFilePath;
416
426
  testFilesToUpdate.push(editTarget);
417
- try {
418
- testFileContentMap.set(editTarget, fs.readFileSync(editTarget, "utf-8"));
419
- }
420
- catch (error) {
421
- logger.error(`Failed to read ${rec.pomFile ? "page object" : "test"} file ${editTarget}: ${error.message}`);
427
+ // Existence check only — the agent reads the file itself before editing.
428
+ // Skips this file's instruction block exactly as the readFileSync failure did
429
+ // before. The path still reaches files_to_update and update_context; that was
430
+ // true before this change too.
431
+ if (!fs.existsSync(editTarget)) {
432
+ logger.error(`Missing ${rec.pomFile ? "page object" : "test"} file: ${editTarget}`);
422
433
  continue;
423
434
  }
424
435
  const renames = rec.renamedEndpoints || [];
@@ -440,22 +451,15 @@ export function registerActionsTool(server) {
440
451
  rationale: rec.rationale,
441
452
  }));
442
453
  }
443
- // ── REGENERATE: read file for context, emit overwrite instructions ──
454
+ // ── REGENERATE: emit overwrite instructions ──
444
455
  const regenerateInstructions = [];
445
456
  const testFilesToRegenerate = [];
446
- const regenerateContentMap = new Map();
447
457
  for (const rec of regenerateRecommendations) {
448
458
  if (!rec.testFilePath) {
449
459
  logger.warning("Recommendation missing testFilePath", rec);
450
460
  continue;
451
461
  }
452
462
  testFilesToRegenerate.push(rec.testFilePath);
453
- try {
454
- regenerateContentMap.set(rec.testFilePath, fs.readFileSync(rec.testFilePath, "utf-8"));
455
- }
456
- catch (error) {
457
- logger.warning(`Could not read file for REGENERATE context ${rec.testFilePath}: ${error.message}`);
458
- }
459
463
  regenerateInstructions.push(buildRegenerateFileInstruction({
460
464
  testFile: rec.testFilePath,
461
465
  updateInstructions: rec.updateInstructions,
@@ -533,12 +537,10 @@ export function registerActionsTool(server) {
533
537
  .filter((rec) => !!rec.updateInstructions)
534
538
  .map((rec) => {
535
539
  const editTarget = rec.pomFile ?? rec.testFilePath;
536
- const current_content = testFileContentMap.get(editTarget);
537
540
  return {
538
541
  file: editTarget,
539
542
  context: rec.updateInstructions,
540
543
  ...(rec.editSites?.length ? { edit_sites: rec.editSites } : {}),
541
- ...(current_content !== undefined && { current_content }),
542
544
  };
543
545
  });
544
546
  if (updateContext.length > 0) {
@@ -549,8 +551,22 @@ export function registerActionsTool(server) {
549
551
  // endpoint URL, auth pattern, test type, and language to replicate.
550
552
  if (regenerateRecommendations.length > 0) {
551
553
  llmInstructionsObj.regenerate_context = regenerateRecommendations.map((rec) => {
552
- const existing_content = regenerateContentMap.get(rec.testFilePath);
553
- return { file: rec.testFilePath, rationale: rec.updateInstructions || rec.rationale, ...(existing_content !== undefined && { existing_content }) };
554
+ // The generation tool needs test type, language, framework and endpoint to
555
+ // replicate the file. Those come from the discovery catalog, so the file
556
+ // text is not needed to carry them.
557
+ const cataloged = catalogByFile.get(rec.testFilePath);
558
+ return {
559
+ file: rec.testFilePath,
560
+ rationale: rec.updateInstructions || rec.rationale,
561
+ // test_type and endpoint are always emitted — the strategy tells the agent to
562
+ // use them instead of reading the file, so an absent one leaves the generation
563
+ // call without a target. The catalog's apiEndpoint is optional and can be an
564
+ // empty string; both fields are required on the recommendation itself.
565
+ test_type: cataloged?.testType ?? rec.testType,
566
+ endpoint: cataloged?.apiEndpoint || rec.endpoint,
567
+ ...(cataloged?.language && { language: cataloged.language }),
568
+ ...(cataloged?.framework && { framework: cataloged.framework }),
569
+ };
554
570
  });
555
571
  llmInstructionsObj.regenerate_strategy = buildRegenerateStrategy();
556
572
  }
@@ -558,25 +574,10 @@ export function registerActionsTool(server) {
558
574
  llmInstructionsObj.delete_strategy = buildDeleteStrategy();
559
575
  }
560
576
  const llmInstructions = JSON.stringify(llmInstructionsObj, null, 2);
561
- const contentBlocks = [
562
- {
563
- type: "text",
564
- text: responseText,
565
- annotations: {
566
- audience: ["user"],
567
- },
568
- },
569
- {
570
- type: "text",
571
- text: llmInstructions,
572
- annotations: {
573
- audience: ["assistant"],
574
- },
575
- },
576
- ];
577
- return {
578
- content: contentBlocks,
579
- };
577
+ // The two payloads were previously separate content[] blocks annotated
578
+ // audience: ["user"] and ["assistant"]. Nothing in this repo read those
579
+ // annotations, and the field names carry the same distinction.
580
+ return dualChannelResult({ summary: responseText, instructions: llmInstructions });
580
581
  }
581
582
  catch (error) {
582
583
  logger.error(`Actions tool failed: ${error.message}`, error);
@@ -1,5 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
+ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3
4
  import { CandidateUiPage } from "../../utils/uiPageEnumerator.js";
4
5
  import type { FrontendFileIntegration } from "../../types/FrontendIntegration.js";
5
6
  import { TraceFile } from "../../types/RepositoryAnalysis.js";
@@ -34,4 +35,14 @@ export declare function buildCaptureInstructions(pages: CandidateUiPage[]): stri
34
35
  * (`buildCaptureInstructions` still runs for the rest in that case).
35
36
  */
36
37
  export declare function buildUnintegratedInstructions(results: FrontendFileIntegration[]): string;
38
+ /**
39
+ * Assemble the tool result. See `dualChannelResult` for why the payload is
40
+ * carried on both result channels.
41
+ */
42
+ export declare function buildAnalyzeChangesResult(parts: {
43
+ structuredSummary: string;
44
+ uiInstructions: string;
45
+ outputText: string;
46
+ recommendationPrompt: string;
47
+ }): CallToolResult;
37
48
  export declare function registerAnalyzeChangesTool(server: McpServer): void;