@skyramp/mcp 0.3.8 → 0.4.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/build/commands/commandLibrary.d.ts +1 -1
  2. package/build/commands/commandLibrary.js +3 -3
  3. package/build/commands/recommendTestsAndExecuteCommand.d.ts +1 -1
  4. package/build/commands/recommendTestsAndExecuteCommand.js +35 -20
  5. package/build/commands/testThisEndpointCommand.js +35 -19
  6. package/build/index.js +9 -3
  7. package/build/playwright/blueprintDigest.d.ts +15 -0
  8. package/build/playwright/blueprintDigest.js +152 -0
  9. package/build/playwright/blueprintDigestStore.d.ts +31 -0
  10. package/build/playwright/blueprintDigestStore.js +117 -0
  11. package/build/playwright/registerPlaywrightTools.js +60 -12
  12. package/build/playwright/traceRecordingPrompt.js +8 -7
  13. package/build/prompts/enhance-assertions/sharedAssertionRules.js +9 -8
  14. package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +24 -2
  15. package/build/prompts/promptAssets.d.ts +20 -0
  16. package/build/prompts/promptAssets.js +55 -0
  17. package/build/prompts/sut-setup/modes/dockerComposePrompt.js +19 -5
  18. package/build/prompts/test-maintenance/actionsInstructions.d.ts +4 -0
  19. package/build/prompts/test-maintenance/actionsInstructions.js +14 -2
  20. package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +0 -10
  21. package/build/prompts/test-maintenance/drift-analysis-prompt.js +2 -11
  22. package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +8 -4
  23. package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +5 -22
  24. package/build/prompts/test-recommendation/diffExecutionPlan.js +37 -465
  25. package/build/prompts/test-recommendation/recommendationSections.d.ts +7 -17
  26. package/build/prompts/test-recommendation/recommendationSections.js +67 -309
  27. package/build/prompts/test-recommendation/recommendationShared.d.ts +19 -47
  28. package/build/prompts/test-recommendation/recommendationShared.js +49 -155
  29. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.d.ts +0 -5
  30. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +10 -153
  31. package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +2 -29
  32. package/build/prompts/test-recommendation/test-recommendation-prompt.js +32 -457
  33. package/build/prompts/testbot/planDeclarations.d.ts +6 -0
  34. package/build/prompts/testbot/planDeclarations.js +9 -0
  35. package/build/prompts/testbot/testbot-prompts.d.ts +8 -0
  36. package/build/prompts/testbot/testbot-prompts.js +256 -381
  37. package/build/recommendation/answers.d.ts +35 -0
  38. package/build/recommendation/answers.js +96 -0
  39. package/build/recommendation/registerPlan.d.ts +49 -0
  40. package/build/recommendation/registerPlan.js +117 -0
  41. package/build/recommendation/runVerifiers.d.ts +10 -0
  42. package/build/recommendation/runVerifiers.js +49 -0
  43. package/build/recommendation/subjectStep.d.ts +42 -0
  44. package/build/recommendation/subjectStep.js +86 -0
  45. package/build/recommendation/types.d.ts +163 -0
  46. package/build/recommendation/types.js +20 -0
  47. package/build/recommendation/verifierContracts.d.ts +382 -0
  48. package/build/recommendation/verifierContracts.js +263 -0
  49. package/build/recommendation/verifiers/changedFile.d.ts +2 -0
  50. package/build/recommendation/verifiers/changedFile.js +82 -0
  51. package/build/recommendation/verifiers/citedPath.d.ts +12 -0
  52. package/build/recommendation/verifiers/citedPath.js +35 -0
  53. package/build/recommendation/verifiers/coverage.d.ts +7 -0
  54. package/build/recommendation/verifiers/coverage.js +617 -0
  55. package/build/recommendation/verifiers/deliveredMatchesPlan.d.ts +11 -0
  56. package/build/recommendation/verifiers/deliveredMatchesPlan.js +33 -0
  57. package/build/recommendation/verifiers/endpointGrounded.d.ts +17 -0
  58. package/build/recommendation/verifiers/endpointGrounded.js +128 -0
  59. package/build/recommendation/verifiers/existingCoverage.d.ts +6 -0
  60. package/build/recommendation/verifiers/existingCoverage.js +51 -0
  61. package/build/recommendation/verifiers/expectedOutcome.d.ts +31 -0
  62. package/build/recommendation/verifiers/expectedOutcome.js +105 -0
  63. package/build/recommendation/verifiers/removedElementGuarded.d.ts +2 -0
  64. package/build/recommendation/verifiers/removedElementGuarded.js +57 -0
  65. package/build/recommendation/verifiers/reportedCategory.d.ts +26 -0
  66. package/build/recommendation/verifiers/reportedCategory.js +84 -0
  67. package/build/recommendation/verifiers/screenRoute.d.ts +10 -0
  68. package/build/recommendation/verifiers/screenRoute.js +118 -0
  69. package/build/recommendation/verifiers/statedDifference.d.ts +6 -0
  70. package/build/recommendation/verifiers/statedDifference.js +140 -0
  71. package/build/recommendation/verifiers/uiElementGrounded.d.ts +7 -0
  72. package/build/recommendation/verifiers/uiElementGrounded.js +318 -0
  73. package/build/resources/analysisResources.js +1 -114
  74. package/build/resources/testbotResource.js +23 -13
  75. package/build/services/ModularizationService.js +2 -1
  76. package/build/services/TestDiscoveryService.d.ts +3 -72
  77. package/build/services/TestDiscoveryService.js +10 -303
  78. package/build/services/containerEnv.d.ts +1 -1
  79. package/build/services/containerEnv.js +12 -0
  80. package/build/skills/fixTestImportErrorsSkill.d.ts +13 -0
  81. package/build/skills/fixTestImportErrorsSkill.js +20 -0
  82. package/build/toolNames.d.ts +1 -0
  83. package/build/toolNames.js +1 -0
  84. package/build/tools/code-refactor/enhanceAssertionsTool.js +3 -3
  85. package/build/tools/code-refactor/modularizationTool.js +2 -1
  86. package/build/tools/executeSkyrampTestTool.d.ts +80 -0
  87. package/build/tools/executeSkyrampTestTool.js +246 -19
  88. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +6 -0
  89. package/build/tools/generate-tests/generateContractRestTool.js +3 -3
  90. package/build/tools/generate-tests/planGuard.d.ts +2 -2
  91. package/build/tools/generate-tests/planGuard.js +78 -18
  92. package/build/tools/one-click/oneClickTool.d.ts +0 -1
  93. package/build/tools/one-click/oneClickTool.js +0 -5
  94. package/build/tools/submitReportTool.d.ts +48 -42
  95. package/build/tools/submitReportTool.js +576 -193
  96. package/build/tools/test-management/actionsTool.js +72 -4
  97. package/build/tools/test-management/analyzeChangesTool.d.ts +144 -48
  98. package/build/tools/test-management/analyzeChangesTool.js +212 -1219
  99. package/build/tools/test-management/analyzeTestHealthTool.js +13 -24
  100. package/build/tools/test-management/index.d.ts +1 -0
  101. package/build/tools/test-management/index.js +1 -0
  102. package/build/tools/test-management/registerTestPlanTool.d.ts +795 -172
  103. package/build/tools/test-management/registerTestPlanTool.js +609 -542
  104. package/build/tools/test-management/resolveScreenTool.d.ts +75 -0
  105. package/build/tools/test-management/resolveScreenTool.js +289 -0
  106. package/build/types/BlueprintDigest.d.ts +34 -0
  107. package/build/types/BlueprintDigest.js +1 -0
  108. package/build/types/RepositoryAnalysis.d.ts +20 -1559
  109. package/build/types/RepositoryAnalysis.js +2 -58
  110. package/build/types/StepMethod.d.ts +40 -0
  111. package/build/types/StepMethod.js +77 -0
  112. package/build/types/TestAnalysis.d.ts +12 -0
  113. package/build/types/TestExecution.d.ts +4 -0
  114. package/build/types/TestRecommendation.d.ts +24 -24
  115. package/build/types/TestRecommendation.js +91 -89
  116. package/build/types/TestbotPromptOptions.d.ts +0 -4
  117. package/build/types/TestbotReport.d.ts +64 -2
  118. package/build/utils/AnalysisStateManager.d.ts +79 -113
  119. package/build/utils/AnalysisStateManager.js +147 -57
  120. package/build/utils/assertion-verify/api-shared-lints.js +1 -1
  121. package/build/utils/assertion-verify/metrics.js +85 -36
  122. package/build/utils/assertion-verify/ui-lints.d.ts +0 -5
  123. package/build/utils/assertion-verify/ui-lints.js +32 -0
  124. package/build/utils/branchDiff.d.ts +63 -31
  125. package/build/utils/branchDiff.js +242 -94
  126. package/build/utils/containedPath.d.ts +18 -0
  127. package/build/utils/containedPath.js +73 -0
  128. package/build/utils/dartRouteExtractor.d.ts +18 -34
  129. package/build/utils/dartRouteExtractor.js +101 -173
  130. package/build/utils/featureFlags.d.ts +12 -0
  131. package/build/utils/featureFlags.js +14 -0
  132. package/build/utils/frontendSelectors.d.ts +48 -27
  133. package/build/utils/frontendSelectors.js +241 -80
  134. package/build/utils/pathMatching.d.ts +2 -4
  135. package/build/utils/pathMatching.js +2 -4
  136. package/build/utils/planMatchKeys.d.ts +38 -47
  137. package/build/utils/planMatchKeys.js +143 -81
  138. package/build/utils/rebaselineSnapshots.d.ts +24 -0
  139. package/build/utils/rebaselineSnapshots.js +65 -0
  140. package/build/utils/removedUiElements.d.ts +22 -0
  141. package/build/utils/removedUiElements.js +106 -0
  142. package/build/utils/reportVerification.d.ts +2 -6
  143. package/build/utils/reportVerification.js +61 -2
  144. package/build/utils/screenRoutes.d.ts +66 -0
  145. package/build/utils/screenRoutes.js +727 -0
  146. package/build/utils/sourceRouteExtractor.js +320 -112
  147. package/build/utils/testFileClassification.d.ts +11 -2
  148. package/build/utils/testFileClassification.js +44 -2
  149. package/build/utils/testFixtures.d.ts +5 -0
  150. package/build/utils/testFixtures.js +13 -0
  151. package/build/utils/utils.d.ts +0 -1
  152. package/build/utils/utils.js +0 -11
  153. package/build/utils/versions.d.ts +3 -3
  154. package/build/utils/versions.js +1 -1
  155. package/build/workspace/workspace.d.ts +12 -12
  156. package/node_modules/playwright/lib/mcp/skyramp/assertHiddenTool.js +56 -0
  157. package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +2 -1
  158. package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +10 -0
  159. package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +4 -1
  160. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +160 -1
  161. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +4 -2
  162. package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +1 -0
  163. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderSignalProcessor.js +2 -0
  164. package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder.js +5 -1
  165. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.-Id052Lr.js → index.B7KbSQcC.js} +1 -1
  166. package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
  167. package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
  168. package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
  169. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderSignalProcessor.ts +7 -0
  170. package/node_modules/playwright/node_modules/playwright-core/src/server/recorder.ts +6 -1
  171. package/node_modules/playwright/package.json +1 -1
  172. package/package.json +4 -3
  173. package/plugin/.claude-plugin/plugin.json +8 -0
  174. package/plugin/plugin.json +6 -0
  175. package/plugin/prompts/declaring-a-plan.md +20 -0
  176. package/plugin/prompts/generate-tests/context-fetching.md +4 -0
  177. package/plugin/prompts/generate-tests/execution-plan.md +63 -0
  178. package/plugin/prompts/generate-tests/generation.md +108 -0
  179. package/plugin/prompts/generate-tests/path-parameters.md +1 -0
  180. package/plugin/prompts/generate-tests/reasoning-protocol.md +17 -0
  181. package/plugin/prompts/generate-tests/tool-workflow-variants.md +61 -0
  182. package/plugin/prompts/generate-tests/tool-workflows.md +65 -0
  183. package/plugin/prompts/plan-tests.md +42 -0
  184. package/plugin/prompts/testbot-task1.md +82 -0
  185. package/plugin/skills/fix-test-import-errors/SKILL.md +98 -0
  186. package/build/prompts/test-recommendation/analysisOutputPrompt.d.ts +0 -84
  187. package/build/prompts/test-recommendation/analysisOutputPrompt.js +0 -369
  188. package/build/prompts/test-recommendation/fullRepoCatalog.d.ts +0 -7
  189. package/build/prompts/test-recommendation/fullRepoCatalog.js +0 -283
  190. package/build/prompts/test-recommendation/scopeAssessment.d.ts +0 -81
  191. package/build/prompts/test-recommendation/scopeAssessment.js +0 -359
  192. package/build/recommendation/budgeters/diversityBalancedBudgeter.d.ts +0 -7
  193. package/build/recommendation/budgeters/diversityBalancedBudgeter.js +0 -105
  194. package/build/recommendation/budgeters/fixedNBudgeter.d.ts +0 -7
  195. package/build/recommendation/budgeters/fixedNBudgeter.js +0 -11
  196. package/build/recommendation/budgeters/shared.d.ts +0 -32
  197. package/build/recommendation/budgeters/shared.js +0 -246
  198. package/build/recommendation/discriminators.d.ts +0 -37
  199. package/build/recommendation/discriminators.js +0 -379
  200. package/build/recommendation/diversity.d.ts +0 -47
  201. package/build/recommendation/diversity.js +0 -101
  202. package/build/recommendation/planRanker.d.ts +0 -65
  203. package/build/recommendation/planRanker.js +0 -83
  204. package/build/recommendation/testFixtures.d.ts +0 -25
  205. package/build/recommendation/testFixtures.js +0 -45
  206. package/build/types/FrontendIntegration.d.ts +0 -28
  207. package/build/types/FrontendIntegration.js +0 -22
  208. package/build/types/Recommendation.d.ts +0 -146
  209. package/build/types/Recommendation.js +0 -74
  210. package/build/utils/changedRoutes.d.ts +0 -29
  211. package/build/utils/changedRoutes.js +0 -87
  212. package/build/utils/frontendIntegration.d.ts +0 -9
  213. package/build/utils/frontendIntegration.js +0 -243
  214. package/build/utils/importerHop.d.ts +0 -135
  215. package/build/utils/importerHop.js +0 -489
  216. package/build/utils/pathAffinityClassification.d.ts +0 -49
  217. package/build/utils/pathAffinityClassification.js +0 -180
  218. package/build/utils/pythonMountPrefixes.d.ts +0 -25
  219. package/build/utils/pythonMountPrefixes.js +0 -347
  220. package/build/utils/repoScanner.d.ts +0 -34
  221. package/build/utils/repoScanner.js +0 -300
  222. package/build/utils/routeParsers.d.ts +0 -95
  223. package/build/utils/routeParsers.js +0 -951
  224. package/build/utils/scenarioDrafting.d.ts +0 -92
  225. package/build/utils/scenarioDrafting.js +0 -951
  226. package/build/utils/subjectEndpoints.d.ts +0 -19
  227. package/build/utils/subjectEndpoints.js +0 -98
  228. package/build/utils/uiPageEnumerator.d.ts +0 -172
  229. package/build/utils/uiPageEnumerator.js +0 -474
@@ -0,0 +1,140 @@
1
+ import { callSteps, isUIPlannedTest, stepRouteKey, subjectStep } from "../subjectStep.js";
2
+ import { STATED_DIFFERENCE_CONTRACT } from "../verifierContracts.js";
3
+ /** What two planned tests must share before this asks how they differ: the method and
4
+ * path of their subject step. UI planned tests are OUT — two tests of one screen are
5
+ * the normal shape of UI coverage. */
6
+ function subjectIdentity(plannedTest) {
7
+ if (isUIPlannedTest(plannedTest))
8
+ return undefined;
9
+ return stepRouteKey(subjectStep(plannedTest));
10
+ }
11
+ /** A reference reduced to the spelling the id is stored in: trimmed, and exact
12
+ * otherwise. Case is NOT folded — the report joins on the exact id, so folding
13
+ * here would make two ids one name. */
14
+ function nameKey(raw) {
15
+ return typeof raw === "string" ? raw.trim() : "";
16
+ }
17
+ /** Whether either side states a real difference from the other. A planned test id IS
18
+ * its scenario name and the schema refuses a repeated one, so a reference either
19
+ * names exactly one planned test or names none. A reference naming none states no
20
+ * difference, and the pair's own objection is the question about it — naming the
21
+ * bad reference too would ask twice about one mistake. */
22
+ function differenceStated(a, b) {
23
+ const states = (from, to) => (from.declarations?.differsFrom ?? []).some((entry) => nameKey(entry?.plannedTestId) === nameKey(to.plannedTestId) && (entry?.difference ?? "").trim().length > 0);
24
+ return states(a, b) || states(b, a);
25
+ }
26
+ /** One objection per `differsFrom` entry naming no planned test in the plan. A
27
+ * reference is how the agent states that two tests differ, so a misspelled one
28
+ * has to come back AS a misspelling: the pair objection on its own reads as a
29
+ * question the agent believes it already answered, and it would answer it again
30
+ * the same way. A blank reference names nothing either and is one of these. */
31
+ function unknownPartnerObjections(plannedTests) {
32
+ const known = new Set(plannedTests.map((plannedTest) => nameKey(plannedTest?.plannedTestId)).filter(Boolean));
33
+ const seen = new Set();
34
+ const objections = [];
35
+ for (const plannedTest of plannedTests) {
36
+ for (const entry of plannedTest?.declarations?.differsFrom ?? []) {
37
+ const raw = entry?.plannedTestId;
38
+ if (typeof raw !== "string")
39
+ continue;
40
+ const reference = nameKey(raw);
41
+ if (reference && known.has(reference))
42
+ continue;
43
+ // One objection per (planned test, reference): the same bad name written by
44
+ // two planned tests is two mistakes, and each owes its own answer.
45
+ const key = `${nameKey(plannedTest?.plannedTestId)}\u0000${reference}`;
46
+ if (seen.has(key))
47
+ continue;
48
+ seen.add(key);
49
+ objections.push({
50
+ objectionId: `statedDifference:unknownPartner:${plannedTest.plannedTestId}:${reference}`,
51
+ verifier: "statedDifference",
52
+ plannedTestId: plannedTest.plannedTestId,
53
+ message: STATED_DIFFERENCE_CONTRACT.objections.unknownPartner.message,
54
+ evidence: `"${raw}" is not the scenarioName of any planned test in this plan`,
55
+ suggestion: STATED_DIFFERENCE_CONTRACT.objections.unknownPartner.suggestion,
56
+ });
57
+ }
58
+ }
59
+ return objections;
60
+ }
61
+ /** One objection per planned test that makes calls but has no subject step: it makes
62
+ * several and names none, or its `stepUnderTest` names a step it does not make.
63
+ * Without this the pair check goes SILENT on exactly the planned tests most likely
64
+ * to repeat one another, because a planned test with no subject is never compared. */
65
+ function unstatedSubjectObjections(plannedTests) {
66
+ const objections = [];
67
+ for (const plannedTest of plannedTests) {
68
+ if (isUIPlannedTest(plannedTest))
69
+ continue;
70
+ const calls = callSteps(plannedTest);
71
+ if (calls.length === 0 || subjectStep(plannedTest))
72
+ continue;
73
+ const declared = plannedTest?.declarations?.stepUnderTest;
74
+ objections.push({
75
+ objectionId: `statedDifference:subjectNotStated:${plannedTest.plannedTestId}`,
76
+ verifier: "statedDifference",
77
+ plannedTestId: plannedTest.plannedTestId,
78
+ message: STATED_DIFFERENCE_CONTRACT.objections.subjectNotStated.message,
79
+ evidence: typeof declared === "number"
80
+ ? `stepUnderTest ${declared} names none of the ${calls.length} calls this test makes`
81
+ : `${calls.length} calls and no stepUnderTest`,
82
+ suggestion: STATED_DIFFERENCE_CONTRACT.objections.subjectNotStated.suggestion,
83
+ });
84
+ }
85
+ return objections;
86
+ }
87
+ /** Verifier 3. Two planned tests whose subject steps name the same method and path
88
+ * must say how they differ — ASKING, where the old dedup key removed one of the
89
+ * pair on a string that says nothing about what either asserts. Both sides get an
90
+ * objection; a stated difference is taken at its word. No request, no compare. */
91
+ export const statedDifference = {
92
+ name: "statedDifference",
93
+ run(registration, _ctx) {
94
+ const plannedTests = registration.plannedTests ?? [];
95
+ /** Planned-test id -> route -> the partners on that route it has NOT explained,
96
+ * recorded while the pair is known to be unexplained. Deriving it afterwards
97
+ * from the route would list partners this planned test already explained. */
98
+ const unexplained = new Map();
99
+ const record = (id, route, partnerId) => {
100
+ let routes = unexplained.get(id);
101
+ if (!routes) {
102
+ routes = new Map();
103
+ unexplained.set(id, routes);
104
+ }
105
+ let partners = routes.get(route);
106
+ if (!partners) {
107
+ partners = new Set();
108
+ routes.set(route, partners);
109
+ }
110
+ partners.add(partnerId);
111
+ };
112
+ for (let i = 0; i < plannedTests.length; i++) {
113
+ for (let j = i + 1; j < plannedTests.length; j++) {
114
+ const a = plannedTests[i];
115
+ const b = plannedTests[j];
116
+ const identity = subjectIdentity(a);
117
+ if (!identity || identity !== subjectIdentity(b))
118
+ continue;
119
+ if (differenceStated(a, b))
120
+ continue;
121
+ record(a.plannedTestId, identity, b.plannedTestId);
122
+ record(b.plannedTestId, identity, a.plannedTestId);
123
+ }
124
+ }
125
+ const objections = unknownPartnerObjections(plannedTests).concat(unstatedSubjectObjections(plannedTests));
126
+ return objections.concat([...unexplained.entries()].map(([plannedTestId, routes]) => {
127
+ const shared = [...routes.entries()]
128
+ .map(([route, partners]) => `${route} with ${[...partners].join(", ")}`)
129
+ .join("; ");
130
+ return {
131
+ objectionId: `statedDifference:${plannedTestId}`,
132
+ verifier: "statedDifference",
133
+ plannedTestId,
134
+ message: STATED_DIFFERENCE_CONTRACT.objections.unexplainedPair.message,
135
+ evidence: `shares ${shared}`,
136
+ suggestion: STATED_DIFFERENCE_CONTRACT.objections.unexplainedPair.suggestion,
137
+ };
138
+ }));
139
+ },
140
+ };
@@ -0,0 +1,7 @@
1
+ import { Verifier } from "../types.js";
2
+ /** Verifier 7. A UI planned test's `elements.items` must be COPIES of elements a
3
+ * blueprint captured this run really holds. Captures are EVIDENCE, never an
4
+ * allowlist: a page nobody captured draws nothing, and a missing element is
5
+ * ANSWERABLE, since one behind a modal is legitimately absent. Plan time only,
6
+ * over the pre-scan. */
7
+ export declare const uiElementGrounded: Verifier;
@@ -0,0 +1,318 @@
1
+ import { isUIPlannedTest } from "../subjectStep.js";
2
+ import { UI_ELEMENT_GROUNDED_CONTRACT } from "../verifierContracts.js";
3
+ /** An IDENTIFIER, compared exactly apart from surrounding space: `saveBtn` and
4
+ * `savebtn` are different DOM attributes. Absence has one spelling — the digest
5
+ * omits a field the element does not have and a declaration may send `null` or
6
+ * `""` for the same thing, so those must not read as two different values. */
7
+ function field(value) {
8
+ return typeof value === "string" ? value.trim() : "";
9
+ }
10
+ /** A ROLE or an accessible NAME, reduced to what two honest copies of it share.
11
+ * This much leniency is deliberate and one-directional: it can only ground an
12
+ * element that would otherwise draw an objection, and a false objection teaches
13
+ * the agent that objections are noise. It forgives how a name is SPELLED —
14
+ * letter case, and the repeated spaces an HTML render collapses — and nothing
15
+ * about what it says: a shortened or paraphrased name is not a copy. */
16
+ function folded(value) {
17
+ return typeof value === "string" ? value.trim().replace(/\s+/g, " ").toLowerCase() : "";
18
+ }
19
+ function context(value) {
20
+ return Array.isArray(value) ? value.map(folded).filter(Boolean) : [];
21
+ }
22
+ function fieldsOf(raw) {
23
+ const element = (raw && typeof raw === "object" ? raw : {});
24
+ return {
25
+ role: folded(element.role),
26
+ name: folded(element.accessibleName),
27
+ testId: field(element.testId),
28
+ stableId: field(element.stableId),
29
+ contextText: context(element.contextText),
30
+ };
31
+ }
32
+ /** Whether a captured element is the one the declaration names.
33
+ *
34
+ * An IDENTIFIER decides on its own. `data-testid` and `id` name exactly one
35
+ * element on a page, so a declaration carrying one a capture holds names an
36
+ * element that is really there, whatever else the declaration got wrong. The
37
+ * other fields are not compared then, and deliberately so: objecting that an
38
+ * element "is in no capture" when its test id IS in the capture states
39
+ * something false, and a false objection teaches the agent that objections are
40
+ * noise.
41
+ *
42
+ * With no identifier to go on, the role, the name and a row's context text have
43
+ * to agree — spelling forgiven, content not. Four tiers used to stand here, each
44
+ * with its own leniency and its own rule about when a blank role counted; this
45
+ * is the same reach with one rule per kind of evidence. */
46
+ function names(declared, captured) {
47
+ if (declared.testId)
48
+ return declared.testId === captured.testId;
49
+ if (declared.stableId)
50
+ return declared.stableId === captured.stableId;
51
+ if (declared.role && declared.role !== captured.role)
52
+ return false;
53
+ // A row's own text, which is what separates it from its siblings. Its NAME is
54
+ // not compared here: the capture keeps a repeating element under a template
55
+ // name and again per row with the values filled in, so a row's name really
56
+ // does vary between two honest readings of one page. Row text never grounds an
57
+ // element on its own — the role has to agree, checked above — because a probe
58
+ // got an invented button onto a page by copying a real table row's text.
59
+ if (declared.contextText.length > 0)
60
+ return rowKey(declared) === rowKey(captured);
61
+ return declared.name === captured.name;
62
+ }
63
+ /** `role` plus a row's own text, serialised rather than joined on a separator:
64
+ * row text is page content and can hold any character. */
65
+ function rowKey(element) {
66
+ return JSON.stringify([element.role, element.contextText]);
67
+ }
68
+ function addTo(index, key, element) {
69
+ if (!key)
70
+ return;
71
+ const bucket = index.get(key);
72
+ if (bucket)
73
+ bucket.push(element);
74
+ else
75
+ index.set(key, [element]);
76
+ }
77
+ function poolOf(captured) {
78
+ const pool = { byTestId: new Map(), byStableId: new Map(), byRow: new Map(), byName: new Map() };
79
+ for (const raw of captured) {
80
+ const element = fieldsOf(raw);
81
+ addTo(pool.byTestId, element.testId, element);
82
+ addTo(pool.byStableId, element.stableId, element);
83
+ if (element.contextText.length > 0)
84
+ addTo(pool.byRow, rowKey(element), element);
85
+ addTo(pool.byName, element.name, element);
86
+ }
87
+ return pool;
88
+ }
89
+ /** Narrowed by whatever the declaration can be looked up by, in the order
90
+ * `names` reads them. An entry that states none of the four is UNGROUNDED:
91
+ * there is nothing to look it up by. The schema refuses one, so that only
92
+ * decides the unvalidated path, and refusing is the safe answer there. */
93
+ function grounded(declared, pool) {
94
+ const narrowed = (declared.testId && pool.byTestId.get(declared.testId)) ||
95
+ (declared.stableId && pool.byStableId.get(declared.stableId)) ||
96
+ (declared.contextText.length > 0 && pool.byRow.get(rowKey(declared))) ||
97
+ (declared.name && pool.byName.get(declared.name)) ||
98
+ [];
99
+ return narrowed.some((captured) => names(declared, captured));
100
+ }
101
+ /** Identity for the duplicate check: two entries agreeing on every field name
102
+ * one element, so the second says nothing the first did not. */
103
+ function identityKey(element) {
104
+ return JSON.stringify([element.role, element.name, element.testId, element.stableId, element.contextText]);
105
+ }
106
+ /** How an element reads back in an objection, in the agent's own spelling. */
107
+ function label(raw) {
108
+ const element = (raw && typeof raw === "object" ? raw : {});
109
+ const parts = [
110
+ element.role ? `role "${String(element.role).trim()}"` : "",
111
+ element.accessibleName ? `name "${String(element.accessibleName).trim()}"` : "",
112
+ element.testId ? `testId "${String(element.testId).trim()}"` : "",
113
+ element.stableId ? `stableId "${String(element.stableId).trim()}"` : "",
114
+ ].filter(Boolean);
115
+ return parts.length > 0 ? parts.join(" ") : "an entry with no role, name or identifier";
116
+ }
117
+ /** A url split into the part before the fragment and the fragment WHEN it is a
118
+ * route (`#/…`). An ordinary anchor comes back as an empty route and is
119
+ * dropped: it names a place on a page, not a page. */
120
+ function splitHashRoute(raw) {
121
+ const value = String(raw ?? "").trim();
122
+ const hash = value.indexOf("#");
123
+ if (hash < 0)
124
+ return { base: value, route: "" };
125
+ const fragment = value.slice(hash);
126
+ return { base: value.slice(0, hash), route: fragment.startsWith("#/") ? fragment.replace(/\/+$/, "") : "" };
127
+ }
128
+ /** The path half of a url. The ROOT stays `/` rather than folding to empty, or a
129
+ * page named by its host alone would have no path to match on and the check
130
+ * would go silent on it. The query string is dropped, which only pools MORE
131
+ * captures for one page. */
132
+ function urlPath(raw) {
133
+ const { base, route } = splitHashRoute(raw);
134
+ // A hash route IS the path for these apps; the part before the `#` is the same
135
+ // shell url on every screen.
136
+ if (route)
137
+ return route.slice(1).split("?")[0].replace(/\/+$/, "") || "/";
138
+ if (!base)
139
+ return "";
140
+ try {
141
+ return new URL(base).pathname.replace(/\/+$/, "") || "/";
142
+ }
143
+ catch {
144
+ return base.startsWith("/") ? base.split("?")[0].replace(/\/+$/, "") || "/" : "";
145
+ }
146
+ }
147
+ /** The spellings of the machine the run is on: a capture at `127.0.0.1` and a
148
+ * page url at `localhost` are the same page. */
149
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1", "0.0.0.0"]);
150
+ /** The host half of a url, empty for a path-only one. Read so a wrong host
151
+ * cannot ground an element on another service's capture. */
152
+ function urlHost(raw) {
153
+ const value = String(raw ?? "").trim().split("#")[0];
154
+ if (!value)
155
+ return "";
156
+ try {
157
+ const url = new URL(value);
158
+ const hostname = url.hostname.toLowerCase();
159
+ const host = LOOPBACK_HOSTS.has(hostname) ? "localhost" : hostname;
160
+ return url.port ? `${host}:${url.port}` : host;
161
+ }
162
+ catch {
163
+ return "";
164
+ }
165
+ }
166
+ /** Every element captured for the page the planned test names.
167
+ * UNDEFINED means NO CAPTURE COVERS THE PAGE; a capture that covers it and holds
168
+ * nothing returns an EMPTY pool, so a declared element is ungrounded. Every
169
+ * capture of the page is pooled — later ones hold what an action revealed. */
170
+ function elementsForPage(captures, url) {
171
+ const path = urlPath(url);
172
+ if (!path)
173
+ return undefined;
174
+ const host = urlHost(url);
175
+ const pool = captures.filter((capture) => urlPath(capture.url) === path && (!host || urlHost(capture.url) === host));
176
+ if (pool.length === 0)
177
+ return undefined;
178
+ return poolOf(pool.flatMap((capture) => (Array.isArray(capture.elements) ? capture.elements : [])));
179
+ }
180
+ /** How many elements this run captured, across every page. The exemption below
181
+ * reads this and never `captures.length`: a capture object that holds nothing
182
+ * is not evidence of anything the run saw. */
183
+ function capturedElementCount(captures) {
184
+ return captures.reduce((total, capture) => total + (Array.isArray(capture.elements) ? capture.elements.length : 0), 0);
185
+ }
186
+ function capturedPages(captures) {
187
+ const pages = [];
188
+ for (const capture of captures) {
189
+ const url = String(capture?.url ?? "").trim();
190
+ if (url && !pages.includes(url))
191
+ pages.push(url);
192
+ }
193
+ return pages;
194
+ }
195
+ /** The `data-testid` values the diff removed from a page that survives.
196
+ *
197
+ * A removal-guard element is DECLARED because it is gone: the test asserts it no
198
+ * longer renders, so no capture of the page can hold it and "not in any capture"
199
+ * is not a fault. `testbot-task1.md` asks the agent for exactly this entry, so
200
+ * objecting to it would contradict the instruction it followed.
201
+ *
202
+ * `data-testid` alone: it is the one attribute a declared element has a field
203
+ * for. A guard on `data-cy` or `data-qa` reaches the plan with `testId` null and
204
+ * nothing else that carries the pair, so this cannot recognise it. */
205
+ function removedTestIds(ctx) {
206
+ const removed = Array.isArray(ctx?.removedUiElements) ? ctx.removedUiElements : [];
207
+ return new Set(removed
208
+ .filter((item) => field(item?.attribute).toLowerCase() === "data-testid")
209
+ .map((item) => field(item?.value))
210
+ .filter(Boolean));
211
+ }
212
+ /** An objection names everything it saw. It used to stop at eight and say "and
213
+ * N more", which decided for the agent which pages were worth reading. */
214
+ function list(values) {
215
+ return values.join("; ");
216
+ }
217
+ /** Verifier 7. A UI planned test's `elements.items` must be COPIES of elements a
218
+ * blueprint captured this run really holds. Captures are EVIDENCE, never an
219
+ * allowlist: a page nobody captured draws nothing, and a missing element is
220
+ * ANSWERABLE, since one behind a modal is legitimately absent. Plan time only,
221
+ * over the pre-scan. */
222
+ export const uiElementGrounded = {
223
+ name: "uiElementGrounded",
224
+ run(registration, ctx) {
225
+ const objections = [];
226
+ const captures = Array.isArray(ctx.uiCaptures) ? ctx.uiCaptures.filter((capture) => capture && typeof capture === "object") : [];
227
+ const pools = new Map();
228
+ const removedIds = removedTestIds(ctx);
229
+ for (const plannedTest of registration.plannedTests ?? []) {
230
+ if (!isUIPlannedTest(plannedTest))
231
+ continue;
232
+ const raw = plannedTest.declarations?.elements?.items;
233
+ const declared = Array.isArray(raw) ? raw : [];
234
+ const url = String(plannedTest.declarations?.elements?.pageUrl ?? "").trim();
235
+ if (declared.length === 0) {
236
+ // A run that saw nothing cannot ask this: standalone and IDE use store no
237
+ // digest and a backend-only run takes none. The boundary is the ELEMENT
238
+ // count, not the capture count — a capture holding no elements shows the
239
+ // agent nothing to copy, and the suggestion has no element to name.
240
+ if (capturedElementCount(captures) === 0)
241
+ continue;
242
+ objections.push({
243
+ objectionId: `uiElementGrounded:nogrounding:${plannedTest.plannedTestId}`,
244
+ verifier: "uiElementGrounded",
245
+ plannedTestId: plannedTest.plannedTestId,
246
+ message: UI_ELEMENT_GROUNDED_CONTRACT.objections.nogrounding.message,
247
+ evidence: `this run captured ${captures.length} blueprint(s), covering: ${list(capturedPages(captures))}`,
248
+ suggestion: UI_ELEMENT_GROUNDED_CONTRACT.objections.nogrounding.suggestion,
249
+ });
250
+ continue;
251
+ }
252
+ const seen = new Set();
253
+ const duplicates = declared.filter((element) => {
254
+ const key = identityKey(fieldsOf(element));
255
+ if (seen.has(key))
256
+ return true;
257
+ seen.add(key);
258
+ return false;
259
+ });
260
+ if (duplicates.length > 0) {
261
+ objections.push({
262
+ objectionId: `uiElementGrounded:duplicate:${plannedTest.plannedTestId}`,
263
+ verifier: "uiElementGrounded",
264
+ plannedTestId: plannedTest.plannedTestId,
265
+ message: UI_ELEMENT_GROUNDED_CONTRACT.objections.duplicate.message,
266
+ evidence: `repeated: ${list(duplicates.map(label))}`,
267
+ suggestion: UI_ELEMENT_GROUNDED_CONTRACT.objections.duplicate.suggestion,
268
+ });
269
+ }
270
+ if (!url) {
271
+ objections.push({
272
+ objectionId: `uiElementGrounded:pagecontext:${plannedTest.plannedTestId}`,
273
+ verifier: "uiElementGrounded",
274
+ plannedTestId: plannedTest.plannedTestId,
275
+ message: UI_ELEMENT_GROUNDED_CONTRACT.objections.pagecontext.message,
276
+ evidence: `${declared.length} element(s) declared, \`elements.pageUrl\` empty`,
277
+ suggestion: UI_ELEMENT_GROUNDED_CONTRACT.objections.pagecontext.suggestion,
278
+ });
279
+ continue;
280
+ }
281
+ // Pooled once per page, not once per planned test: two planned tests on one page
282
+ // are the normal shape of a UI plan.
283
+ if (!pools.has(url))
284
+ pools.set(url, elementsForPage(captures, url));
285
+ const pool = pools.get(url);
286
+ // A page no capture covers is SILENT: the pre-scan captures the plan is
287
+ // checked against are taken before generation's action-gated states, so a
288
+ // page can be legitimately missing from all of them.
289
+ if (pool === undefined)
290
+ continue;
291
+ const ungrounded = declared.filter((element) => {
292
+ const fields = fieldsOf(element);
293
+ // The removal guard: gone from the page on purpose, so the capture is
294
+ // the wrong place to look for it.
295
+ if (fields.testId && removedIds.has(fields.testId))
296
+ return false;
297
+ return !grounded(fields, pool);
298
+ });
299
+ if (ungrounded.length === 0)
300
+ continue;
301
+ objections.push({
302
+ objectionId: `uiElementGrounded:${plannedTest.plannedTestId}`,
303
+ verifier: "uiElementGrounded",
304
+ plannedTestId: plannedTest.plannedTestId,
305
+ message: UI_ELEMENT_GROUNDED_CONTRACT.objections.ungrounded.message,
306
+ // A capture the element cap cut is a PARTIAL view of that page, so "not
307
+ // in any capture" is not a fact about it. The objection still fires — it
308
+ // is answerable — but it says which it is.
309
+ evidence: `not in any capture of ${url}: ${list(ungrounded.map(label))}` +
310
+ (captures.some((capture) => capture.url === url && capture.elementsTruncated)
311
+ ? " (a capture of this page was cut at the element cap, so it holds only part of the page)"
312
+ : ""),
313
+ suggestion: UI_ELEMENT_GROUNDED_CONTRACT.objections.ungrounded.suggestion,
314
+ });
315
+ }
316
+ return objections;
317
+ },
318
+ };
@@ -96,14 +96,7 @@ export function registerAnalysisResources(server) {
96
96
  },
97
97
  authentication: analysis.authentication,
98
98
  infrastructure: analysis.infrastructure,
99
- endpointStats: {
100
- totalPaths: analysis.apiEndpoints.endpoints.length,
101
- totalMethods: analysis.apiEndpoints.endpoints.reduce((sum, ep) => sum + ep.methods.length, 0),
102
- totalInteractions: analysis.apiEndpoints.endpoints.reduce((sum, ep) => sum +
103
- ep.methods.reduce((msum, m) => msum + m.interactions.length, 0), 0),
104
- baseUrl: analysis.apiEndpoints.baseUrl,
105
- },
106
- scenarioCount: analysis.businessContext.draftedScenarios.length,
99
+ workspace: analysis.workspace,
107
100
  existingTests: analysis.existingTests,
108
101
  };
109
102
  return {
@@ -116,112 +109,6 @@ export function registerAnalysisResources(server) {
116
109
  ],
117
110
  };
118
111
  });
119
- // ── Endpoints (compact listing) ──
120
- server.registerResource("analysis_endpoints", new ResourceTemplate(`${ANALYSIS_URI_PREFIX}/{sessionId}/endpoints`, {
121
- list: makeListCallback("endpoints"),
122
- }), {
123
- title: "Endpoint Listing",
124
- description: "Compact listing of all endpoints (path, methods, interaction counts). Use path-specific resources for full detail.",
125
- mimeType: "application/json",
126
- }, async (uri, params) => {
127
- const sessionId = params.sessionId;
128
- const { analysis } = await loadAnalysis(sessionId);
129
- const compact = analysis.apiEndpoints.endpoints.map((ep) => ({
130
- path: ep.path,
131
- resourceGroup: ep.resourceGroup,
132
- pathParams: ep.pathParams.map((p) => p.name),
133
- methods: ep.methods.map((m) => ({
134
- method: m.method,
135
- authRequired: m.authRequired,
136
- interactionCount: m.interactions.length,
137
- interactionTypes: m.interactions.map((i) => i.type),
138
- hasCookies: m.interactions.some((i) => (i.response.cookies?.length ?? 0) > 0),
139
- hasResponseHeaders: m.interactions.some((i) => Object.keys(i.response.headers ?? {}).length > 0),
140
- createsResource: m.createsResource,
141
- })),
142
- }));
143
- return {
144
- contents: [
145
- {
146
- uri: uri.href,
147
- mimeType: "application/json",
148
- text: JSON.stringify({ baseUrl: analysis.apiEndpoints.baseUrl, endpoints: compact }, null, 2),
149
- },
150
- ],
151
- };
152
- });
153
- // ── Single Path Detail ──
154
- // No list callback — these are parameterized drilldowns, not top-level resources.
155
- server.registerResource("analysis_endpoint_path", new ResourceTemplate(`${ANALYSIS_URI_PREFIX}/{sessionId}/endpoints/{+path}`, { list: undefined }), {
156
- title: "Endpoint Path Detail",
157
- description: "Full detail for a specific path: all methods, interactions, params.",
158
- mimeType: "application/json",
159
- }, async (uri, params) => {
160
- const sessionId = params.sessionId;
161
- const endpointPath = "/" + params.path;
162
- const { analysis } = await loadAnalysis(sessionId);
163
- const ep = analysis.apiEndpoints.endpoints.find((e) => e.path === endpointPath);
164
- if (!ep) {
165
- throw new Error(`Endpoint path "${endpointPath}" not found in session "${sessionId}".`);
166
- }
167
- return {
168
- contents: [
169
- {
170
- uri: uri.href,
171
- mimeType: "application/json",
172
- text: JSON.stringify(ep, null, 2),
173
- },
174
- ],
175
- };
176
- });
177
- // ── Single Method Detail ──
178
- server.registerResource("analysis_endpoint_method", new ResourceTemplate(`${ANALYSIS_URI_PREFIX}/{sessionId}/endpoints/{+path}/{method}`, { list: undefined }), {
179
- title: "Endpoint Method Detail",
180
- description: "Full detail for a specific method on a path: interactions, params, auth.",
181
- mimeType: "application/json",
182
- }, async (uri, params) => {
183
- const sessionId = params.sessionId;
184
- const endpointPath = "/" + params.path;
185
- const method = params.method.toUpperCase();
186
- const { analysis } = await loadAnalysis(sessionId);
187
- const ep = analysis.apiEndpoints.endpoints.find((e) => e.path === endpointPath);
188
- if (!ep) {
189
- throw new Error(`Endpoint path "${endpointPath}" not found in session "${sessionId}".`);
190
- }
191
- const m = ep.methods.find((em) => em.method.toUpperCase() === method);
192
- if (!m) {
193
- throw new Error(`Method "${method}" not found on "${endpointPath}" in session "${sessionId}".`);
194
- }
195
- return {
196
- contents: [
197
- {
198
- uri: uri.href,
199
- mimeType: "application/json",
200
- text: JSON.stringify({ path: ep.path, resourceGroup: ep.resourceGroup, pathParams: ep.pathParams, ...m }, null, 2),
201
- },
202
- ],
203
- };
204
- });
205
- // ── Scenarios ──
206
- server.registerResource("analysis_scenarios", new ResourceTemplate(`${ANALYSIS_URI_PREFIX}/{sessionId}/scenarios`, {
207
- list: makeListCallback("scenarios"),
208
- }), {
209
- title: "Drafted Scenarios",
210
- description: "All drafted user-flow scenarios with steps, chaining, and priority.",
211
- mimeType: "application/json",
212
- }, async (uri, params) => {
213
- const sessionId = params.sessionId;
214
- const { analysis } = await loadAnalysis(sessionId);
215
- return {
216
- contents: [
217
- {
218
- uri: uri.href,
219
- mimeType: "application/json",
220
- text: JSON.stringify(analysis.businessContext.draftedScenarios, null, 2),
221
- },
222
- ],
223
- };
224
- });
225
112
  // ── Branch Diff ──
226
113
  server.registerResource("analysis_diff", new ResourceTemplate(`${ANALYSIS_URI_PREFIX}/{sessionId}/diff`, {
227
114
  list: makeListCallback("diff"),
@@ -1,7 +1,6 @@
1
1
  import { ResourceTemplate, } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { logger } from "../utils/logger.js";
3
3
  import { AnalyticsService } from "../services/AnalyticsService.js";
4
- import { MAX_TESTS_TO_GENERATE, MAX_RECOMMENDATIONS, MAX_CRITICAL_TESTS, } from "../prompts/test-recommendation/recommendationSections.js";
5
4
  import { getTestbotPrompt, parseRelatedRepositories, } from "../prompts/testbot/testbot-prompts.js";
6
5
  import { readWorkspaceServices } from "../prompts/prompt-utils.js";
7
6
  export function registerTestbotResource(server) {
@@ -20,10 +19,21 @@ export function registerTestbotResource(server) {
20
19
  mimeType: "text/plain",
21
20
  }, async (uri) => {
22
21
  const param = (name, fallback) => uri.searchParams.get(name) ?? fallback;
23
- const maxRec = parseInt(uri.searchParams.get("maxRecommendations") || "", 10);
24
- const maxGen = parseInt(uri.searchParams.get("maxGenerate") || "", 10);
25
- const prNum = parseInt(uri.searchParams.get("prNumber") || "", 10);
26
- const maxCrit = parseInt(uri.searchParams.get("maxCritical") || "", 10);
22
+ // Absent or unparseable leaves these NaN, so the knobs below pass undefined
23
+ // rather than the MCP's own default. WHOLE STRING OR NOTHING, where `parseInt`
24
+ // read `8abc` as 8 and `1e3` as 1. NONNEGATIVE AND SAFE, because every reader
25
+ // is an `.int().min(0)` field: out of range is treated as unset.
26
+ const intParam = (name) => {
27
+ const raw = (uri.searchParams.get(name) ?? "").trim();
28
+ // No leading zeros, so `00` cannot arrive as a ceiling of zero from
29
+ // something that reads like a typo. A leading `+` is still accepted — it
30
+ // is a deliberate spelling of a real number, pinned by its own test.
31
+ if (!/^\+?(?:0|[1-9]\d*)$/.test(raw))
32
+ return NaN;
33
+ const value = Number(raw);
34
+ return Number.isSafeInteger(value) ? value : NaN;
35
+ };
36
+ const prNum = intParam("prNumber");
27
37
  const repositoryPath = param("repositoryPath", ".");
28
38
  const services = await readWorkspaceServices(repositoryPath);
29
39
  const prompt = getTestbotPrompt({
@@ -31,9 +41,6 @@ export function registerTestbotResource(server) {
31
41
  prDescription: param("prDescription", ""),
32
42
  repositoryPath,
33
43
  baseBranch: uri.searchParams.get("baseBranch") || undefined,
34
- maxRecommendations: isNaN(maxRec) ? MAX_RECOMMENDATIONS : maxRec,
35
- maxGenerate: isNaN(maxGen) ? MAX_TESTS_TO_GENERATE : maxGen,
36
- maxCritical: isNaN(maxCrit) ? MAX_CRITICAL_TESTS : maxCrit,
37
44
  prNumber: isNaN(prNum) ? undefined : prNum,
38
45
  userPrompt: uri.searchParams.get("userPrompt") || undefined,
39
46
  services: services.length ? services : undefined,
@@ -47,14 +54,17 @@ export function registerTestbotResource(server) {
47
54
  language: uri.searchParams.get("language") || undefined,
48
55
  });
49
56
  AnalyticsService.pushMCPToolEvent("skyramp_testbot_prompt", undefined, {}).catch(() => { });
50
- // Return the original URI — clients may use it to re-fetch the resource,
51
- // and the caller already has these params. Credentials never appear in
52
- // the rendered prompt text (they are base64-encoded inside the
53
- // <ui-credentials> block; see getTestbotPrompt).
57
+ // The URI WITHOUT its query string. The agent reads this line above the
58
+ // prompt text, and a legacy count argument in the query was reaching it as
59
+ // a number to plan against even though the server applies none of them —
60
+ // plans cited it back as a ceiling. Stripping the query also keeps
61
+ // `uiCredentials` out of this line. `uri.origin` is the string "null" for a
62
+ // non-special scheme like `skyramp:`, so the parts are joined by hand.
63
+ const uriWithoutQuery = `${uri.protocol}//${uri.host}${uri.pathname}`;
54
64
  return {
55
65
  contents: [
56
66
  {
57
- uri: uri.toString(),
67
+ uri: uriWithoutQuery,
58
68
  mimeType: "text/plain",
59
69
  text: prompt,
60
70
  },