diff --git a/src/lib/version-keyword.ts b/src/lib/version-keyword.ts new file mode 100644 index 00000000..669d35a0 --- /dev/null +++ b/src/lib/version-keyword.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +/** + * Version fields (OS, browser, platform) accept a version number or one of two keywords, + * "latest" and "oldest", and the code downstream compares against those exact strings. + * Models and users say the same thing in other words — "newest", "most recent", + * "earliest", "first", "Latest" — so the mapping lives here, in the schema, rather than in + * description prose a model may or may not follow. + */ +const LATEST = new Set(["latest", "newest", "most recent", "most-recent"]); +const OLDEST = new Set(["oldest", "earliest", "first"]); + +export function normalizeVersionKeyword(value: string): string { + const key = value.trim().toLowerCase().replace(/\s+/g, " "); + if (LATEST.has(key)) return "latest"; + if (OLDEST.has(key)) return "oldest"; + return value.trim(); +} + +/** A version string whose keyword synonyms are normalised to "latest" / "oldest". */ +export function versionString() { + return z.string().transform(normalizeVersionKeyword); +} diff --git a/src/tools/accessibility.ts b/src/tools/accessibility.ts index 85e7d927..2936bc04 100644 --- a/src/tools/accessibility.ts +++ b/src/tools/accessibility.ts @@ -1624,7 +1624,7 @@ export default function addAccessibilityTools( .enum(Object.keys(WCAG_VERSIONS) as [keyof typeof WCAG_VERSIONS]) .optional() .describe( - 'WCAG version and level to check against, e.g. "wcag22aa", or "rgaa41" for RGAA 4.1 (default "wcag21aa").', + 'WCAG level, e.g. "wcag22aa" or "rgaa41" (default "wcag21aa").', ), bestPractices: z .boolean() @@ -1747,8 +1747,7 @@ export default function addAccessibilityTools( tools.getAccessibilityAuthConfig = server.tool( "getAccessibilityAuthConfig", - "Retrieve an existing authentication configuration by ID." + - NEEDS_A11Y_CONFIG_ID, + "Get an accessibility auth config by ID." + NEEDS_A11Y_CONFIG_ID, { configId: z.number().describe("ID of the auth configuration to retrieve"), }, @@ -1773,26 +1772,26 @@ export default function addAccessibilityTools( .string() .optional() .describe( - "Website Scanner scan id: from startAccessibilityScan or list_website_scanner_scans (searchCapability).", + "Website Scanner scan id (startAccessibilityScan or searchCapability).", ), scanRunId: z .string() .optional() .describe( - "Website Scanner run id: from startAccessibilityScan, or list_website_scanner_scan_runs (searchCapability).", + "Website Scanner run id (startAccessibilityScan or searchCapability).", ), buildId: z .string() .regex(/^[a-z0-9]{40}$/, "40 lowercase letters and digits") .optional() .describe( - "Web or App Accessibility build id: the SDK's testhub id, a link's thBuildId, or one listed via searchCapability.", + "Web/App Accessibility build id: SDK testhub id or a link's thBuildId.", ), workflowReportId: z .string() .optional() .describe( - "Workflow Analyzer report id: from its link, or list_workflow_analyzer_reports (searchCapability).", + "Workflow Analyzer report id, from its link or searchCapability.", ), appReportId: z .string() @@ -1802,38 +1801,34 @@ export default function addAccessibilityTools( ) .optional() .describe( - "Manual App Accessibility report id (a UUID), from its /report/ link or a report listed via searchCapability.", + "Manual App Accessibility report UUID, from its /report/ link.", ), reportUrl: z .string() .optional() .describe( - "A web or app report dashboard link (scan, build, manual report), a scanner share link, or an (App) Automate link.", + "Any web/app accessibility report, scanner share, or Automate link.", ), cursor: z .number() .optional() .describe( - "Character offset for pagination (default: 0); with compareWith, how many issues are already listed.", + "Pagination offset (default 0); with compareWith, issues already listed.", ), pageUrl: z .string() .optional() - .describe( - "Only the issues on this page URL, counted as the dashboard's page view counts them.", - ), + .describe("Only the issues on this page URL."), compareWith: z .string() .optional() .describe( - '"previous", or an older scanRunId of the same scan, or an earlier app build\'s id or link; lists new and resolved issues.', + '"previous", an older scanRunId, or an earlier app build id/link; lists new and resolved issues.', ), exportTaskId: z .string() .optional() - .describe( - "Continue a CSV export that an earlier call said is still being generated.", - ), + .describe("Continue a CSV export that is still being generated."), }, { title: "Fetch Accessibility Issues", diff --git a/src/tools/appautomate-utils/appium-sdk/constants.ts b/src/tools/appautomate-utils/appium-sdk/constants.ts index 9e5516b0..ed6a3c91 100644 --- a/src/tools/appautomate-utils/appium-sdk/constants.ts +++ b/src/tools/appautomate-utils/appium-sdk/constants.ts @@ -47,40 +47,34 @@ export const MobileDeviceSchema = z.object({ // Tool description and schema for setupBrowserStackAppAutomateTests export const SETUP_APP_AUTOMATE_DESCRIPTION = - "Set up BrowserStack App Automate SDK integration for Appium-based mobile app testing. ONLY for Appium based framework . This tool configures SDK for various languages with appium. For pre-built Espresso or XCUITest test suites, use 'runAppTestsOnBrowserStack' instead."; + "Set up the App Automate SDK for Appium tests. For pre-built Espresso or XCUITest suites use runAppTestsOnBrowserStack."; export const SETUP_APP_AUTOMATE_SCHEMA = { detectedFramework: z .nativeEnum(AppSDKSupportedFrameworkEnum) - .describe( - "The mobile automation framework configured in the project. Example: 'appium'", - ), + .describe("Mobile automation framework, e.g. 'appium'."), detectedTestingFramework: z .nativeEnum(AppSDKSupportedTestingFrameworkEnum) .describe( - "The testing framework used in the project. Be precise with framework selection Example: 'testng', 'behave', 'pytest', 'robot'", + "Test framework in the project, e.g. testng, behave, pytest, robot.", ), detectedLanguage: z .nativeEnum(AppSDKSupportedLanguageEnum) - .describe( - "The programming language used in the project. Supports Java and C#. Example: 'java', 'csharp'", - ), + .describe("Project language, e.g. 'java', 'csharp'."), devices: z .array(MobileDeviceSchema) .max(3) .default([]) .describe( - "Tuples describing target mobile devices. Add device only when user asks explicitly for it. Defaults to [] . Example: [['android', 'Samsung Galaxy S24', '14'], ['ios', 'iPhone 15', '17']]", + "Only devices the user asked for, e.g. [['android', 'Samsung Galaxy S24', '14']].", ), appPath: z .string() - .describe( - "Path to the mobile app file (.apk for Android, .ipa for iOS). Can be a local file path or a BrowserStack app URL (bs://)", - ), + .describe("Local .apk/.ipa path or a BrowserStack app URL (bs://)."), project: z .string() .optional() @@ -90,7 +84,5 @@ export const SETUP_APP_AUTOMATE_SCHEMA = { .boolean() .optional() .default(false) - .describe( - "Run App Accessibility (WCAG) checks on the screens the tests touch; Android 11+ or iOS 14+.", - ), + .describe("Run App Accessibility (WCAG) checks; Android 11+ or iOS 14+."), }; diff --git a/src/tools/appautomate-utils/native-execution/constants.ts b/src/tools/appautomate-utils/native-execution/constants.ts index abb07f34..16781a06 100644 --- a/src/tools/appautomate-utils/native-execution/constants.ts +++ b/src/tools/appautomate-utils/native-execution/constants.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { AppTestPlatform } from "./types.js"; -export const RUN_APP_AUTOMATE_DESCRIPTION = `Execute pre-built native mobile test suites (Espresso for Android, XCUITest for iOS) by direct upload to BrowserStack. ONLY for compiled .apk/.ipa test files. This is NOT for SDK integration or Appium tests. For Appium-based testing with SDK setup, use 'setupBrowserStackAppAutomateTests' instead.`; +export const RUN_APP_AUTOMATE_DESCRIPTION = `Run pre-built Espresso (Android) or XCUITest (iOS) suites on BrowserStack by upload. Not for Appium: use setupBrowserStackAppAutomateTests.`; export const MobileDeviceSchema = z.object({ platform: z @@ -19,35 +19,19 @@ export const RUN_APP_AUTOMATE_SCHEMA = { appPath: z .string() .describe( - "Path to your application file:\n" + - "If in development IDE directory:\n" + - "• For Android: 'gradle assembleDebug'\n" + - "• For iOS:\n" + - " xcodebuild clean -scheme YOUR_SCHEME && \\\n" + - " xcodebuild archive -scheme YOUR_SCHEME -configuration Release -archivePath build/app.xcarchive && \\\n" + - " xcodebuild -exportArchive -archivePath build/app.xcarchive -exportPath build/ipa -exportOptionsPlist exportOptions.plist\n\n" + - "If in other directory, provide existing app path.\n" + - "The resolved file must be located inside the directory set in MCP_UPLOAD_BASE_DIR.", + "Built .apk/.ipa inside MCP_UPLOAD_BASE_DIR (gradle assembleDebug, or xcodebuild archive and export).", ), testSuitePath: z .string() .describe( - "Path to your test suite file:\n" + - "If in development IDE directory:\n" + - "• For Android: 'gradle assembleAndroidTest'\n" + - "• For iOS:\n" + - " xcodebuild test-without-building -scheme YOUR_SCHEME -destination 'generic/platform=iOS' && \\\n" + - " cd ~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug-iphonesimulator/ && \\\n" + - " zip -r Tests.zip *.xctestrun *-Runner.app\n\n" + - "If in other directory, provide existing test file path.\n" + - "The resolved file must be located inside the directory set in MCP_UPLOAD_BASE_DIR.", + "Built suite inside MCP_UPLOAD_BASE_DIR (gradle assembleAndroidTest, or zipped .xctestrun + Runner.app).", ), devices: z .array(MobileDeviceSchema) .max(3) .default([]) .describe( - "Tuples describing target mobile devices. Add device only when user asks explicitly for it. Defaults to [] . Example: [['android', 'Samsung Galaxy S24', '14'], ['ios', 'iPhone 15', '17']]", + "Only devices the user asked for, e.g. [['android', 'Samsung Galaxy S24', '14']].", ), project: z .string() diff --git a/src/tools/appautomate.ts b/src/tools/appautomate.ts index 59114cab..79d41cf3 100644 --- a/src/tools/appautomate.ts +++ b/src/tools/appautomate.ts @@ -10,6 +10,7 @@ import { remote } from "webdriverio"; import { AppTestPlatform } from "./appautomate-utils/native-execution/types.js"; import { setupAppAutomateHandler } from "./appautomate-utils/appium-sdk/handler.js"; import { validateAppAutomateDevices } from "./sdk-utils/common/device-validator.js"; +import { versionString } from "../lib/version-keyword.js"; import { SETUP_APP_AUTOMATE_DESCRIPTION, @@ -380,26 +381,20 @@ export default function addAppAutomationTools( tools.takeAppScreenshot = server.tool( "takeAppScreenshot", - "Use this tool to take a screenshot of an app running on a BrowserStack device. This is useful for visual testing and debugging.", + "Take a screenshot of an app running on a BrowserStack device.", { desiredPhone: z .string() - .describe( - "The full name of the device to run the app on. Example: 'iPhone 12 Pro' or 'Samsung Galaxy S20'. Always ask the user for the device they want to use.", - ), - desiredPlatformVersion: z - .string() - .describe( - "The platform version to run the app on. Use 'latest' or 'oldest' for dynamic resolution.", - ), + .describe("Exact device name, e.g. 'iPhone 15 Pro Max'. Ask the user."), + desiredPlatformVersion: versionString().describe( + "Version number, or 'latest' / 'oldest'.", + ), desiredPlatform: z .enum([Platform.ANDROID, Platform.IOS]) .describe("Platform to run the app on. Either 'android' or 'ios'."), appPath: z .string() - .describe( - "The path to the .apk or .ipa file. Required for app installation. Must be located inside the directory set in MCP_UPLOAD_BASE_DIR.", - ), + .describe("Local .apk/.ipa path inside MCP_UPLOAD_BASE_DIR."), }, { title: "Take App Screenshot", diff --git a/src/tools/applive.ts b/src/tools/applive.ts index c1829b80..ce47e17f 100644 --- a/src/tools/applive.ts +++ b/src/tools/applive.ts @@ -7,6 +7,7 @@ import logger from "../logger.js"; import { trackMCP } from "../lib/instrumentation.js"; import { BrowserStackConfig } from "../lib/types.js"; import { isEnabled as capabilityRegistryEnabled } from "./capability-registry/config.js"; +import { versionString } from "../lib/version-keyword.js"; /** * Launches an App Live Session on BrowserStack. @@ -99,13 +100,11 @@ export default function addAppLiveTools( desiredPhone: z .string() .describe( - "The full name of the device to run the app on. Example: 'iPhone 12 Pro' or 'Samsung Galaxy S20' or 'Google Pixel 6'. Always ask the user for the device they want to use, do not assume it. ", - ), - desiredPlatformVersion: z - .string() - .describe( - "Specifies the platform version to run the app on. For example, use '12.0' for Android or '16.0' for iOS. If the user says 'latest', 'newest', or similar, normalize it to 'latest'. Likewise, convert terms like 'earliest' or 'oldest' to 'oldest'.", + "Exact device name, e.g. 'iPhone 15 Pro Max'. Ask the user; never assume.", ), + desiredPlatformVersion: versionString().describe( + "Version number, or 'latest' / 'oldest'.", + ), desiredPlatform: z .enum(["android", "ios"]) .describe( @@ -114,7 +113,7 @@ export default function addAppLiveTools( appPath: z .string() .describe( - "The path to the .ipa or .apk file to install on the device. Always ask the user for the app path, do not assume it. Must be located inside the directory set in MCP_UPLOAD_BASE_DIR.", + "Local .apk/.ipa path inside MCP_UPLOAD_BASE_DIR. Ask the user; never assume.", ), }, { diff --git a/src/tools/ask-browserstack/register.ts b/src/tools/ask-browserstack/register.ts index 85f06389..b30984fb 100644 --- a/src/tools/ask-browserstack/register.ts +++ b/src/tools/ask-browserstack/register.ts @@ -133,19 +133,13 @@ const DESCRIPTION = // tool that is not enabled for the account cannot do the job at all — so "there is a // per-account gate, fall back to the individual tools" is the most useful thing to say // first. Parenthesised so it reads as a status note, not as the tool's purpose. - "(Alpha, limited availability. Enabled per account and per product; if it is not enabled " + - "the call returns an entitlement error, nothing runs, and you should complete the task " + - "with the individual tools instead. To request access, the user should contact their " + - "BrowserStack account owner.) " + - "Use this when no other BrowserStack tool here fits the task, or when the ones you tried " + - "did not get you there. Prefer a specific tool whenever one fits: it is faster and more " + - "predictable than handing the job to an agent. " + - "Otherwise, describe what you want in plain language and BrowserStack's agent decides " + - "which calls to make, then returns its answer plus the steps it took. Anything that would " + - "change data pauses and asks you to confirm it first; deletes are refused outright. " + - "Put that confirmation to the user and answer with what THEY say — never decide on " + - "their behalf. If it cannot be put to anyone, the run is read-only and everything it " + - "wanted to change comes back in `needs_approval` instead. One task per call."; + "(Alpha, limited availability. If not enabled it returns an entitlement error; complete " + + "the task with the individual tools instead.) Use this when no other BrowserStack tool " + + "here fits, or when the ones you tried did not get you there. Prefer a specific tool " + + "whenever one fits. Describe the task in plain language. A data change asks you to " + + "confirm first: put it to the user, never decide for them; deletes are refused " + + "outright. If no one can confirm, the run is read-only and changes come back in " + + "`needs_approval`. One task per call."; /** * `isError` marks a call that FAILED, not one that was refused. @@ -551,8 +545,7 @@ export function addAskBrowserStackAITool( product: z .enum(PRODUCTS) .describe( - "Which product to work in: tm (Test Management), a11y (Accessibility), " + - "tra (Test Reporting & Analytics).", + "Product: tm (Test Management), a11y (Accessibility), tra (Test Reporting & Analytics).", ), // OPTIONAL, because a resume does not carry one. `next_step` tells the model to // call again "with exactly these params" — `product`, `run_id`, `perm_id`, diff --git a/src/tools/capability-registry/register.ts b/src/tools/capability-registry/register.ts index 349138ae..fd9a76e2 100644 --- a/src/tools/capability-registry/register.ts +++ b/src/tools/capability-registry/register.ts @@ -305,26 +305,13 @@ export function addCapabilityRegistryTools( tools.listProducts = server.tool( "listProducts", - "List the BrowserStack products this surface can reach: what each one does, the " + - "entities it models, and a line saying what each entity is. START HERE — " + - "searchCapability needs a product, and this is what tells you which one. If two " + - "products could both fit the task, ask the user rather than choosing for them. " + - "ALWAYS pass the user's request, in their own words, as `query`: the answer then " + - "says outright whether the products clash over the words in it. WHEN IT REPORTS A " + - "CLASH — two products claiming the same word for different things — ASK THE USER " + - "WHICH PRODUCT THEY MEAN AND WAIT FOR THEIR ANSWER BEFORE SEARCHING OR INVOKING " + - "ANYTHING. The response carries the question to put to them and what the shared " + - "word means in each product. Choosing for them, or searching each product in turn " + - "and merging the results, is the failure this call exists to prevent.", + "List BrowserStack products and their entities. START HERE: searchCapability needs a product name from this. Pass the user's request as `query`; if it reports a clash, ask the user which product they mean and wait. Never pick for them or merge products.", { query: z .string() .optional() .describe( - "The user's request, in their own words. With it the response carries a " + - "`routing` block saying whether the products share the vocabulary this " + - "request uses — and, when they do, the question to put to the user before " + - "searching either product.", + "The user's request, in their own words; enables clash detection.", ), }, { @@ -498,9 +485,7 @@ export function addCapabilityRegistryTools( tools.describeEntity = server.tool( "describeEntity", - "Describe one entity: what it is, what identifies it, what it relates to, and the " + - "vocabulary the product uses for it. Read this before filtering or writing, because " + - "ids and field values usually have to be resolved first.", + "Describe an entity: identifiers, relations and product vocabulary. Read before filtering or writing; ids often need resolving first.", { product: productArg().describe( `Which product the entity belongs to: ${productList}.`, @@ -537,41 +522,11 @@ export function addCapabilityRegistryTools( tools.searchCapability = server.tool( "searchCapability", - "Find endpoints this surface can call, by plain language, within ONE product. " + - "CALL listProducts FIRST, passing the user's request as its `query`: it names the " + - "products, says whether they clash over the words in that request, and gives you " + - "the question to ask when they do. You need it anyway — `product` here takes a name " + - "exactly as listProducts spells it. Where two products use the same word for " + - "different things, ask the USER and wait for an answer rather than picking. " + - "Search matches the product's OWN words, not synonyms. When your words are not the " + - "product's, the response says so: `weak_match: true` with a `suggested_vocabulary` " + - "map of the product's entities and their aliases. Results are still returned, but " + - "treat them as unconfirmed — pick the closest entity from that map and search again " + - "using its vocabulary, or call describeEntity on it for the fuller picture. That one " + - "extra round trip is far cheaper than invoking the wrong capability. " + - "Narrowing further with `entity` sharpens results. " + - "THIS IS A SHORTLIST, NOT A CONTRACT. Each result carries only what you need to " + - "CHOOSE: `name` (the handle), `product`, `mode` (whether it writes), `intent` and " + - "`guidance` (what it does and what goes wrong), and `method`/`path` for products " + - "that publish no name yet. " + - "It does NOT carry parameters or response shapes. Once you have picked one, call " + - "describeCapability for its full contract, then invokeCapability. Fetching the " + - "contract only for the one you chose is the difference between ~1k and ~8.6k tokens " + - "a search. Results are ranked and capped. " + - "TO SEE EVERYTHING rather than search, pass query='*' — that lists the product " + - "(honouring `entity` and `mode`) in a stable order instead of ranking it, which is " + - "what you want when the task is 'what can this product do' rather than a lookup. " + - "TO PAGE, send `offset`: every response carries `offset` and, when more matched, " + - "`next_offset` — resend the SAME query with that value. `next_offset` is absent on " + - "the last page, so walk until it stops rather than comparing counts.", + "Find capabilities in ONE product by plain language. CALL listProducts FIRST; `product` must be spelled exactly as listProducts spells it. If `weak_match` is true, search again with `suggested_vocabulary` before invoking. Results are a shortlist: call describeCapability for the contract, then invokeCapability. query='*' lists all; page with `next_offset` until it is absent.", { query: z .string() - .describe( - "What you are trying to do, in plain language. Pass '*' (or 'all') to BROWSE " + - "instead of search: every capability in the product, in a stable order, " + - "paginated — use it when you want to see the surface rather than find one thing.", - ), + .describe("Goal in plain language, or '*' to list every capability."), entity: z .string() .optional() @@ -599,11 +554,7 @@ export function addCapabilityRegistryTools( // refused here instead, by a message that names listProducts. product: z .string() - .describe( - "Which product to search, named exactly as listProducts spells it. Call " + - "listProducts with the user's request to get the name — and to find out " + - "whether the products clash over it, in which case ask the user first.", - ), + .describe("Product name, exactly as listProducts spells it."), // `product_choice` USED TO LIVE HERE, AND WAS ITS OWN DEFEAT. // // A required enum of 'user_confirmed' | 'not_asked' publishes, in the schema every @@ -617,20 +568,13 @@ export function addCapabilityRegistryTools( .string() .optional() .describe( - "The `routing_token` from the last listProducts call or product_ambiguous " + - "refusal — it carries what that step concluded about the user's request, " + - "signed. Always pass the most recent one. When it reports a clash, send the " + - "user's answer in `user_words` alongside it; neither works alone.", + "Latest `routing_token` from listProducts or a clash refusal; send with `user_words` after a clash.", ), user_words: z .string() .optional() .describe( - "The USER'S OWN words, verbatim, that settle which product this is — either " + - "the product's name or a term only one product uses ('quality gate', 'test " + - "plan'). Quote them; do not paraphrase, and do not write words the user did " + - "not say. When a query could mean either product this is what lifts the " + - "refusal, because it is checked against the index rather than taken on trust.", + "The user's own words, verbatim, naming the product or a term only it uses. Never paraphrase.", ), mode: z .enum(["read", "write", "destructive"]) @@ -640,10 +584,7 @@ export function addCapabilityRegistryTools( offset: z .number() .optional() - .describe( - "Skip this many results — send the `next_offset` from the previous response " + - "with the SAME query to get the next page. Omit for the first page.", - ), + .describe("`next_offset` from the previous page, with the same query."), }, { title: "Search Capabilities", @@ -948,16 +889,7 @@ export function addCapabilityRegistryTools( tools.describeCapability = server.tool( "describeCapability", - "The full contract for ONE capability you picked from searchCapability: its " + - "parameters grouped into path_params / query / body under the spec's own names, " + - "what it returns, and its declared response shapes fully expanded. Call this after " + - "search and before invokeCapability — search deliberately omits all of it, because " + - "it is only needed for the one capability you actually call. " + - "Identify it by `name`, exactly as search returned it; only when a result carries " + - "no `name` (some products publish none yet) pass `method` and `path` instead. " + - "Every parameter lists its type, whether it is required, its allowed values where " + - "the set is closed, and any limits the product declares — obey those before calling " + - "rather than discovering them from a rejected request.", + "Full contract (parameters, returns, response shapes) for ONE capability from searchCapability. Call it before invokeCapability. Identify by `name`; use `method` + `path` only when no name was returned.", { name: z .string() @@ -979,17 +911,12 @@ export function addCapabilityRegistryTools( ), product: productArg() .optional() - .describe( - `Which product owns it (${productList}). searchCapability returns it on every ` + - "result; required only when two products share a name or a path.", - ), + .describe("Owning product; needed only when names or paths collide."), include_responses: z .enum(["success", "all", "none"]) .optional() .describe( - "Which declared responses to expand: 'success' (default, the 2xx shape), 'all' " + - "(adds the error shapes — several times larger, and near-identical across " + - "endpoints), or 'none'.", + "'success' (default), 'all' (adds error shapes, larger) or 'none'.", ), }, { @@ -1115,23 +1042,13 @@ export function addCapabilityRegistryTools( tools.invokeCapability = server.tool( "invokeCapability", - "Call a capability whose contract you have from describeCapability. Pass `name` exactly a" + - "s given — that is the handle; only when a result carries no `name` pass `method` and `pa" + - "th` instead. Arguments go in path_params / query / body under the spec's own names. One " + - "call makes one request and returns the product's response as sent; `select` narrows it o" + - "nly where the contract carries `narrowable`, and reports what it narrowed. When `complet" + - "ed` is false there is another page, fetched with the capability's own page parameter. If" + - " the mode is 'write' you MUST ask the user first, then resend with user_permission='gran" + - "ted' and a change_summary; both are recorded. Capabilities whose mode is 'destructive' a" + - "re refused outright — archiving, closing and merging are ordinary writes and DO run, so " + - "read the mode and intent before confirming with the user.", + "Run a capability after describeCapability, by `name` (or `method` + `path` if unnamed); args go in path_params / query / body. `select` narrows the response only where the contract carries `narrowable`. If `completed` is false, fetch the next page with the capability's own page parameter, not `offset`. Writes: ask the user first, then resend with user_permission='granted' and a change_summary. Deletes are refused; archive, close and merge are writes.", { name: z .string() .optional() .describe( - "The capability's published name, exactly as returned (e.g. 'create_test_run_by_integer_id'). " + - "Preferred over method/path.", + "Capability name exactly as returned. Preferred over method/path.", ), method: z .string() @@ -1159,10 +1076,7 @@ export function addCapabilityRegistryTools( .describe("Body fields, under the spec's names."), product: productArg() .optional() - .describe( - `Which product owns the capability (${productList}). searchCapability returns it ` + - "on every result; required only when two products share a name or a path.", - ), + .describe("Owning product; needed only when names or paths collide."), user_permission: z .enum(PERMISSION_VALUES) .optional() @@ -1197,8 +1111,7 @@ export function addCapabilityRegistryTools( }) .optional() .describe( - "Only for a capability whose contract carries `narrowable`: narrows the response " + - "to those fields and reports what it narrowed.", + "Fields to keep; only when the contract carries `narrowable`.", ), }, { diff --git a/src/tools/get-failure-logs.ts b/src/tools/get-failure-logs.ts index 267830d2..b0e60141 100644 --- a/src/tools/get-failure-logs.ts +++ b/src/tools/get-failure-logs.ts @@ -188,7 +188,7 @@ export default function registerGetFailureLogs( tools.getFailureLogs = server.tool( "getFailureLogs", - "Fetch logs, or the session video URL, for an Automate/App Automate session." + + "Fetch logs or the video URL for an Automate/App Automate session." + NEEDS_SESSION_ID, { sessionType: z diff --git a/src/tools/live.ts b/src/tools/live.ts index 7306a4d8..be32aa74 100644 --- a/src/tools/live.ts +++ b/src/tools/live.ts @@ -7,6 +7,7 @@ import { trackMCP } from "../lib/instrumentation.js"; import { BrowserStackConfig } from "../lib/types.js"; import { isEnabled as capabilityRegistryEnabled } from "./capability-registry/config.js"; import globalConfig from "../config.js"; +import { versionString } from "../lib/version-keyword.js"; // Define the schema shape const LiveArgsShape = { @@ -19,20 +20,15 @@ const LiveArgsShape = { .describe( "Desktop OS ('Windows' or 'OS X') or mobile OS ('android','ios','winphone')", ), - desiredOSVersion: z - .string() - .describe( - "The OS version must be specified as a version number (e.g., '10', '14.0') or as a keyword such as 'latest' or 'oldest'. Normalize variations like 'newest' or 'most recent' to 'latest', and terms like 'earliest' or 'first' to 'oldest'. For macOS, version names (e.g., 'Sequoia') must be used instead of numeric versions.", - ), + desiredOSVersion: versionString().describe( + "Version number or 'latest'/'oldest'; macOS takes names, e.g. 'Sequoia'.", + ), desiredBrowser: z .enum(["chrome", "firefox", "safari", "edge", "ie"]) .describe("Browser for desktop (Chrome, IE, Firefox, Safari, Edge)"), - desiredBrowserVersion: z - .string() + desiredBrowserVersion: versionString() .optional() - .describe( - "Browser version for desktop (e.g. '133.2', 'latest'). If the user says 'latest', 'newest', or similar, normalize it to 'latest'. Likewise, convert terms like 'earliest' or 'oldest' to 'oldest'.", - ), + .describe("Desktop browser version, or 'latest' / 'oldest'."), desiredDevice: z.string().optional().describe("Device name for mobile"), }; diff --git a/src/tools/rca-agent-utils/constants.ts b/src/tools/rca-agent-utils/constants.ts index 9ecbfebf..d0ace5f9 100644 --- a/src/tools/rca-agent-utils/constants.ts +++ b/src/tools/rca-agent-utils/constants.ts @@ -10,9 +10,7 @@ export const FETCH_RCA_PARAMS = { testId: z .array(z.number().int()) .max(3) - .describe( - "Array of integer test IDs to fetch RCA data for (maximum 3 IDs). These must be numeric test IDs, not session IDs or strings. If not provided, use the listTestIds tool to get all failed testcases. If more than 3 IDs are provided, only the first 3 will be processed.", - ), + .describe("Up to 3 numeric test ids from listTestIds (not session ids)."), }; export const GET_BUILD_ID_PARAMS = { @@ -31,9 +29,7 @@ export const GET_BUILD_ID_PARAMS = { export const LIST_TEST_IDS_PARAMS = { buildId: z .string() - .describe( - "The Browserstack Build ID of the test run. If not known, use the getBuildId tool to fetch it using project and build name", - ), + .describe("Build id; get it with getBuildId or listBuildId."), status: z .nativeEnum(TestStatus) .describe( diff --git a/src/tools/rca-agent.ts b/src/tools/rca-agent.ts index 7a7dc922..3eb2b5e7 100644 --- a/src/tools/rca-agent.ts +++ b/src/tools/rca-agent.ts @@ -194,7 +194,7 @@ export default function addRCATools( tools.fetchRCA = server.tool( "fetchRCA", - "Fetch AI Root Cause Analysis for the current user's failed BrowserStack Automate/App-Automate tests. Suggests fixes only; never auto-apply, require explicit user approval." + + "AI root cause for failed Automate/App Automate tests. Suggests fixes only; never auto-apply, require explicit user approval." + NEEDS_TEST_IDS, FETCH_RCA_PARAMS, { diff --git a/src/tools/sdk-utils/common/constants.ts b/src/tools/sdk-utils/common/constants.ts index 30a68f76..af3e9226 100644 --- a/src/tools/sdk-utils/common/constants.ts +++ b/src/tools/sdk-utils/common/constants.ts @@ -3,7 +3,7 @@ export const IMPORTANT_SETUP_WARNING = "If you cannot run commands or edit files in the user's project (e.g. a chat-only client), present every step below to the user in full — including each shell command, the package.json changes, and the complete browserstack.yml contents — instead of summarizing them."; export const SETUP_PERCY_DESCRIPTION = - "Set up or expand Percy visual testing configuration with comprehensive coverage for existing projects that might have Percy integrated. This supports both Percy Web Standalone and Percy Automate. Example prompts: Expand percy coverage for this project {project_name}"; + "Set up or expand Percy visual testing coverage in an existing project that may already use Percy (Percy Web and Percy Automate)."; export const LIST_TEST_FILES_DESCRIPTION = "Lists all test files for a given set of directories."; @@ -12,9 +12,9 @@ export const PERCY_SNAPSHOT_COMMANDS_DESCRIPTION = "Adds Percy snapshot commands to the specified test files."; export const RUN_ON_BROWSERSTACK_DESCRIPTION = - "Set up and run automated web-based tests on BrowserStack using the BrowserStack SDK. Use this tool for functional or integration test setup on BrowserStack only. For any visual testing or Percy integration, use the dedicated Percy setup tool. Example prompts: run this test on browserstack; set up this project for browserstack."; + "Set up and run web tests on BrowserStack Automate with the SDK (functional or integration). For Percy visual testing use the Percy tool."; -export const SIMULATE_PERCY_CHANGE_DESCRIPTION = `Integrate Percy visual testing into new projects and demonstrate visual change detection through a step-by-step simulation. This tool handles initial Percy integration, setup, and creates a working demonstration for new users. Primary tool for prompts like: "Integrate percy for this project", "Integrate percy in this project {project_name}"`; +export const SIMULATE_PERCY_CHANGE_DESCRIPTION = `Integrate Percy visual testing into a new project and demo visual change detection step by step. Primary tool for "Integrate percy in this project".`; export const PERCY_REPLACE_REGEX = /Invoke listTestFiles\(\) with the provided directories[\s\S]*?- DO NOT STOP until you add commands in all the files or you reach end of the files\./; diff --git a/src/tools/sdk-utils/common/schema.ts b/src/tools/sdk-utils/common/schema.ts index 8d7660d1..ffcd5592 100644 --- a/src/tools/sdk-utils/common/schema.ts +++ b/src/tools/sdk-utils/common/schema.ts @@ -23,7 +23,7 @@ export const MacOSPlatformEnum = { } as const; export const SetUpPercyParamsShape = { - projectName: z.string().describe("A unique name for your Percy project."), + projectName: z.string().describe("Unique Percy project name."), detectedLanguage: z.nativeEnum(SDKSupportedLanguageEnum), detectedBrowserAutomationFramework: z.nativeEnum( SDKSupportedBrowserAutomationFrameworkEnum, @@ -32,19 +32,19 @@ export const SetUpPercyParamsShape = { integrationType: z .nativeEnum(PercyIntegrationTypeEnum) .describe( - "Specify the Percy integration type: web (Percy Web) or automate (Percy Automate). If not provided, always prompt the user with: 'Please specify the Percy integration type.' Do not proceed without an explicit selection. Never use a default.", + "'web' (Percy Web) or 'automate' (Percy Automate). If unspecified, ask the user; never default.", ), folderPaths: z .array(z.string()) .optional() .describe( - "An array of absolute folder paths containing UI test files. If not provided, analyze codebase for UI test folders by scanning for test patterns which contain UI test cases as per framework. Return empty array if none found.", + "Absolute paths of folders with UI tests. If omitted, scan the codebase for them; [] if none.", ), filePaths: z .array(z.string()) .optional() .describe( - "An array of absolute file paths to specific UI test files. Use this when you want to target specific test files rather than entire folders. If not provided, will use folderPaths instead.", + "Absolute paths of specific UI test files, instead of folders. If omitted, folderPaths is used.", ), }; @@ -88,7 +88,7 @@ export const RunTestsOnBrowserStackParamsShape = { .max(3) .default([]) .describe( - "Device objects array. Use the object format directly - no transformation needed. Add only when user explicitly requests devices. Examples: [{ platform: 'windows', osVersion: '11', browser: 'chrome', browserVersion: 'latest' }] or [{ platform: 'android', deviceName: 'Samsung Galaxy S24', osVersion: '14', browser: 'chrome' }].", + "Only devices the user asked for, e.g. [{ platform: 'windows', osVersion: '11', browser: 'chrome', browserVersion: 'latest' }].", ), accessibility: z .boolean() @@ -116,13 +116,11 @@ export const RunPercyScanParamsShape = { .string() .optional() .describe( - "The test command to run with Percy. Optional — the LLM should try to infer it first from project context.", + "Test command to run with Percy; infer from project context first.", ), integrationType: z .nativeEnum(PercyIntegrationTypeEnum) - .describe( - "Specifies whether to integrate with Percy Web or Percy Automate. If not explicitly provided, prompt the user to select the desired integration type.", - ), + .describe("'web' or 'automate'. If unspecified, ask the user."), }; export const FetchPercyChangesParamsShape = { diff --git a/src/tools/selfheal.ts b/src/tools/selfheal.ts index 0795cb48..e74000b5 100644 --- a/src/tools/selfheal.ts +++ b/src/tools/selfheal.ts @@ -568,11 +568,7 @@ export default function addSelfHealTools( tools.fetchSelfHealedSelectors = server.tool( "fetchSelfHealedSelectors", - "Fetch BrowserStack self-healed selectors plus the test source code for " + - "the run. Provide exactly one of `sessionId` (single Automate / " + - "App-Automate session) or `buildUuid` (full self-healing report for a " + - "build). Pass the returned locator pairs to `prepareSelfHealingPlan` " + - "to plan edits." + + "Fetch self-healed selectors and the test source. Pass exactly one of `sessionId` or `buildUuid`; give the pairs to prepareSelfHealingPlan." + NEEDS_SESSION_ID, { sessionId: z @@ -653,17 +649,7 @@ export default function addSelfHealTools( tools.prepareSelfHealingPlan = server.tool( "prepareSelfHealingPlan", - "Build a self-healing edit plan that bundles locator pairs with test " + - "source code for the calling LLM to apply with its own editing tools. " + - "This tool does NOT modify files — it returns structured context so " + - "the LLM can edit only the relevant call sites (avoids blind " + - "find/replace across shared ids/classes). `sessions` accepts the " + - "canonical shape `[{sessionId, locators: [{original, healed, " + - "thought?}]}]`, a single session object, an envelope `{sessions: " + - "[...]}`, the raw report `{healing_logs: [...]}` (with " + - "`healed_selectors` aliasing `locators`), and snake_case keys " + - "(`session_id`, `original_locator`, `healed_locator`, " + - "`healing_thought`)." + + "Build an edit plan from healed locator pairs plus test source; it does NOT modify files. `sessions`: [{sessionId, locators: [{original, healed}]}] or the raw healing report." + NEEDS_SESSION_ID, { sessions: sessionsFieldSchema.describe( diff --git a/src/tools/testmanagement-utils/create-testcase.ts b/src/tools/testmanagement-utils/create-testcase.ts index c87a2c01..f6eaf530 100644 --- a/src/tools/testmanagement-utils/create-testcase.ts +++ b/src/tools/testmanagement-utils/create-testcase.ts @@ -90,28 +90,17 @@ export const customFieldValueSchema = z.union([ export const CreateTestCaseSchema = z.object({ project_identifier: z .string() - .describe( - "The ID of the BrowserStack project where the test case should be created. If no project identifier is provided, ask the user if they would like to create a new project using the createProjectOrFolder tool.", - ), + .describe("Project id (PR-*). If none, offer createProjectOrFolder."), folder_id: z .string() - .describe( - "The ID of the folder within the project where the test case should be created. If not provided, ask the user if they would like to create a new folder using the createProjectOrFolder tool.", - ), + .describe("Folder id. If none, offer createProjectOrFolder."), name: z.string().describe("Name of the test case."), - description: z - .string() - .optional() - .describe("Brief description of the test case."), - owner: z - .string() - .email() - .describe("Email of the test case owner.") - .optional(), + description: z.string().optional().describe("Brief description."), + owner: z.string().email().describe("Owner email.").optional(), preconditions: z .string() .optional() - .describe("Any preconditions (HTML allowed)."), + .describe("Preconditions (HTML allowed)."), test_case_steps: z .array( z.object({ @@ -119,64 +108,50 @@ export const CreateTestCaseSchema = z.object({ result: z.string().describe("Expected result of this step."), }), ) - .describe("List of steps and expected results."), + .describe("Steps and expected results."), issues: z .array(z.string()) .optional() .describe( - "List of the linked Jira, Asana or Azure issues ID's. This should be strictly in array format not the string of json.", + "Linked Jira/Asana/Azure issue IDs, as an array (not a JSON string).", ), issue_tracker: z .object({ - name: z - .string() - .describe( - "Issue tracker name, For example, use jira for Jira, azure for Azure DevOps, or asana for Asana.", - ), + name: z.string().describe("'jira', 'azure' or 'asana'."), host: z.string().url().describe("Base URL of the issue tracker."), }) .optional(), tags: z .array(z.string()) .optional() - .describe( - "Tags to attach to the test case. This should be strictly in array format not the string of json", - ), + .describe("Tags, as an array (not a JSON string)."), custom_fields: z .record(z.string(), customFieldValueSchema) .optional() - .describe( - "Map of custom field NAME to value; use an array for multi-select fields.", - ), + .describe("Custom field NAME to value; array for multi-select."), automation_status: z .string() .optional() - .describe( - "Automation status of the test case. Common values include 'not_automated', 'automated', 'automation_not_required'.", - ), + .describe("e.g. 'not_automated', 'automated', 'automation_not_required'."), priority: z .string() .optional() - .describe( - "Priority of the test case. Accepts either display name (e.g. 'Critical', 'High', 'Medium', 'Low') or internal name (e.g. 'medium'). If omitted, the project default (usually 'Medium') is applied. Valid values are per-project and discoverable via the form-fields endpoint.", - ), + .describe("e.g. 'High', 'medium'; varies per project; default Medium."), case_type: z .string() .optional() - .describe( - "Test case type display or internal name (per-project). Omit for project default.", - ), + .describe("Test case type (varies per project); omit for default."), template: z .string() .optional() .describe( - "System template slug only: 'test_case_steps' or 'test_case_bdd'. For a custom template, use template_id instead.", + "'test_case_steps' or 'test_case_bdd'; for a custom template use template_id.", ), template_id: z .number() .optional() .describe( - "Numeric ID of a custom template (from listTestCaseTemplates); applies that template. Overrides 'template'.", + "Custom template ID (from listTestCaseTemplates); overrides 'template'.", ), }); diff --git a/src/tools/testmanagement-utils/list-testcases.ts b/src/tools/testmanagement-utils/list-testcases.ts index 67d5d4ff..a47e5fbe 100644 --- a/src/tools/testmanagement-utils/list-testcases.ts +++ b/src/tools/testmanagement-utils/list-testcases.ts @@ -18,9 +18,7 @@ export const ListTestCasesSchema = z.object({ folder_id: z .string() .optional() - .describe( - "Optional. If provided, only return test cases in this folder. If omitted, returns all test cases in the project. Folder ids can be discovered via listFolders.", - ), + .describe("Only this folder's cases (ids from listFolders). Omit for all."), case_type: z .string() .optional() diff --git a/src/tools/testmanagement-utils/update-testcase.ts b/src/tools/testmanagement-utils/update-testcase.ts index de5fe339..076596f6 100644 --- a/src/tools/testmanagement-utils/update-testcase.ts +++ b/src/tools/testmanagement-utils/update-testcase.ts @@ -37,73 +37,48 @@ export interface TestCaseUpdateRequest { } export const UpdateTestCaseSchema = z.object({ - project_identifier: z - .string() - .describe( - "The ID of the BrowserStack project containing the test case to update.", - ), + project_identifier: z.string().describe("Project ID of the test case."), test_case_identifier: z .string() - .describe( - "The ID of the test case to update. This can be found using the listTestCases tool.", - ), + .describe("Test case ID (from listTestCases)."), name: z.string().optional().describe("Updated name of the test case."), - description: z - .string() - .optional() - .describe("Updated brief description of the test case."), - preconditions: z - .string() - .optional() - .describe("Updated preconditions for the test case."), + description: z.string().optional().describe("Updated description."), + preconditions: z.string().optional().describe("Updated preconditions."), test_case_steps: z .array( z.object({ - step: z.string().describe("The action to perform in this step."), - result: z.string().describe("The expected result of this step."), + step: z.string().describe("Step action."), + result: z.string().describe("Expected result."), }), ) .optional() - .describe("Updated list of test case steps with expected results."), - owner: z - .string() - .email() - .optional() - .describe("Email of the test case owner."), + .describe("Updated steps and expected results."), + owner: z.string().email().optional().describe("Owner email."), priority: z .string() .optional() - .describe( - "Updated priority. Accepts either display name (e.g. 'Medium', 'Critical', 'High', 'Low') or internal name (e.g. 'medium'). Valid values are per-project and discoverable via the form-fields endpoint.", - ), + .describe("Priority, e.g. 'High' or 'medium'; values vary per project."), case_type: z .string() .optional() .describe( - "Updated test case type. Accepts either display name (e.g. 'Functional', 'Regression', 'Smoke & Sanity') or internal name (e.g. 'functional', 'smoke_sanity'). Valid values are per-project.", + "Type, e.g. 'Functional' or 'smoke_sanity'; values vary per project.", ), automation_status: z .string() .optional() .describe( - "Updated automation status. Use internal name such as 'not_automated', 'automated', 'automation_not_required', 'cannot_be_automated', or 'obsolete'.", + "e.g. not_automated, automated, automation_not_required, cannot_be_automated, obsolete.", ), status: z .string() .optional() - .describe( - "Updated review status of the test case (e.g. 'active', 'draft', 'in_review', 'outdated', 'rejected').", - ), - tags: z - .array(z.string()) - .optional() - .describe("Replacement list of tags for the test case."), + .describe("e.g. 'active', 'draft', 'in_review', 'outdated', 'rejected'."), + tags: z.array(z.string()).optional().describe("Replacement tags."), issues: z .array(z.string()) .optional() - .describe( - "Replacement list of linked Jira/Asana/Azure issue IDs for the test case.", - ), + .describe("Replacement Jira/Asana/Azure issue IDs."), custom_fields: z .record( z.string(), @@ -115,9 +90,7 @@ export const UpdateTestCaseSchema = z.object({ ]), ) .optional() - .describe( - "Map of custom field NAME to value; use an array for multi-select fields.", - ), + .describe("Custom field NAME to value; array for multi-select."), }); /** diff --git a/src/tools/testmanagement.ts b/src/tools/testmanagement.ts index 6b47948c..35189cf4 100644 --- a/src/tools/testmanagement.ts +++ b/src/tools/testmanagement.ts @@ -755,8 +755,7 @@ export default function addTestManagementTools( tools.createTestCase = server.tool( "createTestCase", - "Use this tool to create a test case in BrowserStack Test Management." + - NEEDS_PROJECT_ID, + "Create a test case in BrowserStack Test Management." + NEEDS_PROJECT_ID, CreateTestCaseSchema.shape, { title: "Create Test Case", @@ -770,7 +769,7 @@ export default function addTestManagementTools( tools.updateTestCase = server.tool( "updateTestCase", - "Update an existing test case in BrowserStack Test Management. Any subset of the following fields may be changed: name, description, preconditions, test_case_steps, owner, priority, case_type, automation_status, status, tags, issues, custom_fields. Only the supplied fields are modified." + + "Update an existing test case; only the supplied fields change." + NEEDS_PROJECT_ID, UpdateTestCaseSchema.shape, { @@ -785,7 +784,7 @@ export default function addTestManagementTools( tools.listTestCases = server.tool( "listTestCases", - "List test cases in a project, optionally scoped to a specific folder. Omit folder_id to list all test cases in the project; provide folder_id (discoverable via listFolders) to list only that folder's cases. Supports filters: case_type, priority, pagination." + + "List test cases in a project, optionally one folder (see listFolders). Filters: case_type, priority, paging." + NEEDS_PROJECT_ID, ListTestCasesSchema.shape, { @@ -800,7 +799,7 @@ export default function addTestManagementTools( tools.listFolders = server.tool( "listFolders", - "List folders in a BrowserStack Test Management project, returning each folder's id and name (plus case counts and sub-folder counts). Pass parent_id to list sub-folders under a specific folder instead of top-level folders." + + "List folders (id, name, counts) in a project; parent_id lists sub-folders." + NEEDS_PROJECT_ID, ListFoldersSchema.shape, { @@ -843,8 +842,7 @@ export default function addTestManagementTools( tools.listTestRuns = server.tool( "listTestRuns", - "List test runs in a project with optional filters (date ranges, assignee, state, etc.)" + - NEEDS_PROJECT_ID, + "List test runs with filters (dates, assignee, state)." + NEEDS_PROJECT_ID, ListTestRunsSchema.shape, { title: "List Test Runs", @@ -858,8 +856,7 @@ export default function addTestManagementTools( tools.updateTestRun = server.tool( "updateTestRun", - "Update a test run's metadata and/or add test cases to it." + - NEEDS_PROJECT_ID, + "Update a test run or add test cases to it." + NEEDS_PROJECT_ID, UpdateTestRunSchema.shape, { title: "Update Test Run", @@ -873,8 +870,7 @@ export default function addTestManagementTools( tools.addTestResult = server.tool( "addTestResult", - "Add a test result to a specific test run via BrowserStack Test Management API." + - NEEDS_PROJECT_ID, + "Add a test result to a test run." + NEEDS_PROJECT_ID, AddTestResultSchema.shape, { title: "Add Test Result", @@ -918,7 +914,7 @@ export default function addTestManagementTools( tools.createLCASteps = server.tool( "createLCASteps", - "Generate Low Code Automation (LCA) steps for a test case in BrowserStack Test Management using the Low Code Automation Agent." + + "Generate Low Code Automation (LCA) steps for a test case." + NEEDS_PROJECT_ID, CreateLCAStepsSchema.shape, { @@ -933,7 +929,7 @@ export default function addTestManagementTools( tools.listTestPlans = server.tool( "listTestPlans", - "List test plans in a BrowserStack Test Management project. Returns each plan's identifier (TP-*), name, status, description, dates, and active/closed test-run counts. Supports pagination." + + "List test plans (TP-*) with status, dates and run counts; paginated." + NEEDS_PROJECT_ID + PLAN_WRITES_VIA_CAPABILITIES, ListTestPlansSchema.shape, @@ -949,7 +945,7 @@ export default function addTestManagementTools( tools.getTestPlan = server.tool( "getTestPlan", - "Fetch a test plan by identifier (TP-*) from BrowserStack Test Management. Returns plan metadata, the full list of linked test runs, total test-case count across runs, and a status summary — suitable for generating test documentation or QA status reports." + + "Get a test plan (TP-*): metadata, linked runs, case count and status summary." + NEEDS_PROJECT_ID + NEEDS_TEST_PLAN_ID + PLAN_WRITES_VIA_CAPABILITIES, @@ -966,7 +962,7 @@ export default function addTestManagementTools( tools.listSubTestPlans = server.tool( "listSubTestPlans", - "List sub-test-plans under a parent test plan (TP-*) in a Test Management project. Supports pagination." + + "List sub-plans under a test plan (TP-*); paginated." + NEEDS_PROJECT_ID + NEEDS_TEST_PLAN_ID + PLAN_WRITES_VIA_CAPABILITIES, @@ -983,7 +979,7 @@ export default function addTestManagementTools( tools.getSubTestPlan = server.tool( "getSubTestPlan", - "Fetch a sub-test-plan (STP-*) under a parent plan (TP-*). Returns metadata and linked test runs." + + "Get a sub-plan (STP-*): metadata and linked runs." + NEEDS_PROJECT_ID + NEEDS_TEST_PLAN_ID + PLAN_WRITES_VIA_CAPABILITIES, diff --git a/src/tools/tool-handoff.ts b/src/tools/tool-handoff.ts index f4a5e2d7..36052d9c 100644 --- a/src/tools/tool-handoff.ts +++ b/src/tools/tool-handoff.ts @@ -25,9 +25,7 @@ /** No tool lists projects, so this genuinely has to go to the agent. */ export const NEEDS_PROJECT_ID = - " Requires a project identifier (PR-*). No tool here lists projects, so if you do not " + - "have one, use the capability functions to list the Test Management projects, then " + - "retry this tool with the identifier they return."; + " Needs a project id (PR-*); no tool lists projects, so get it from the capability functions."; /** A sibling tool can produce the id — prefer it over the agent. */ export function needsIdFrom(idLabel: string, sourceTool: string): string { @@ -41,9 +39,8 @@ export function needsIdFrom(idLabel: string, sourceTool: string): string { * "create me a project" through the agent before letting the tool run. */ export const PROJECT_ID_ONLY_FOR_FOLDER = - " Creating a project needs no identifier. Creating a folder inside an EXISTING project " + - "needs that project's identifier (PR-*); no tool here lists projects, so use the " + - "capability functions to look it up."; + " Creating a project needs no identifier; a folder inside an EXISTING project needs " + + "its id (PR-*); no tool lists projects, so use the capability functions."; /** A test plan id (TP-*) comes from listTestPlans. */ export const NEEDS_TEST_PLAN_ID = needsIdFrom( @@ -69,9 +66,8 @@ export const NEEDS_TEST_PLAN_ID = needsIdFrom( * refused rather than performed. */ export const PLAN_WRITES_VIA_CAPABILITIES = - " Creating a test plan or sub-plan, and linking or unlinking test runs on one, are not " + - "available as tools here: use the capability functions, which reach Test Management's " + - "own API for these operations. They ask you to confirm before changing anything."; + " Creating a test plan or sub-plan, and linking or unlinking test runs, are not " + + "available as tools here: use the capability functions (they ask you to confirm first)."; /** A build id comes from either build-lookup tool. */ export const NEEDS_BUILD_ID = needsIdFrom( @@ -85,8 +81,7 @@ export const NEEDS_BUILD_ID = needsIdFrom( * pointing at the agent when a real tool exists is exactly what the header above forbids. */ export const NEEDS_SESSION_ID = - " Requires a session id. listSessions lists them for a build; if you do not have the " + - "build either, getBuildId or listBuildId resolves one from a project and build name."; + " Needs a session id: listSessions lists them; getBuildId or listBuildId finds the build."; /** * A scan run's ids come from startAccessibilityScan; a build's id is printed by the SDK at diff --git a/tests/lib/version-keyword.test.ts b/tests/lib/version-keyword.test.ts new file mode 100644 index 00000000..e9fb92c4 --- /dev/null +++ b/tests/lib/version-keyword.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import { + normalizeVersionKeyword, + versionString, +} from "../../src/lib/version-keyword"; + +// The Live, App Live and takeAppScreenshot version fields used to tell the model, in prose, +// to turn "newest" into "latest" and "earliest" into "oldest". The trimmed descriptions no +// longer say so, and the code downstream only matches the exact strings, so the schema +// does the mapping. +describe("normalizeVersionKeyword", () => { + it.each([ + ["latest", "latest"], + ["Latest", "latest"], + ["newest", "latest"], + ["most recent", "latest"], + [" Most Recent ", "latest"], + ["most-recent", "latest"], + ["oldest", "oldest"], + ["earliest", "oldest"], + ["First", "oldest"], + ])("maps %j to %j", (input, expected) => { + expect(normalizeVersionKeyword(input)).toBe(expected); + }); + + it.each(["14", "14.0", "133.2", "Sequoia", "11"])( + "passes the version %j through unchanged", + (input) => { + expect(normalizeVersionKeyword(input)).toBe(input); + }, + ); + + it("trims surrounding whitespace from a version number", () => { + expect(normalizeVersionKeyword(" 17 ")).toBe("17"); + }); +}); + +describe("versionString schema", () => { + it("normalises when parsed as part of a tool's argument object", () => { + const shape = z.object({ + desiredOSVersion: versionString(), + desiredBrowserVersion: versionString().optional(), + }); + expect( + shape.parse({ + desiredOSVersion: "newest", + desiredBrowserVersion: "earliest", + }), + ).toEqual({ desiredOSVersion: "latest", desiredBrowserVersion: "oldest" }); + expect(shape.parse({ desiredOSVersion: "Sequoia" })).toEqual({ + desiredOSVersion: "Sequoia", + }); + }); + + it("still rejects a non-string", () => { + expect(() => versionString().parse(14)).toThrow(); + }); +}); diff --git a/tests/tools/toolDescriptionBudget.test.ts b/tests/tools/toolDescriptionBudget.test.ts index bf87bbb7..b6fc1178 100644 --- a/tests/tools/toolDescriptionBudget.test.ts +++ b/tests/tools/toolDescriptionBudget.test.ts @@ -14,42 +14,30 @@ vi.mock("../../src/logger.js", () => ({ const CEILING = 200; -/** The tools already over the ceiling, at their length on main (2.2.6). Lower only. */ +/** The tools already over the ceiling, at their trimmed length. Lower only. */ const GRANDFATHERED: Record = { - addTestResult: 293, - askBrowserStackAI: 1006, - createLCASteps: 340, - createProjectOrFolder: 264, - createTestCase: 283, - createTestRun: 265, - describeCapability: 753, - describeEntity: 219, - expandPercyVisualTesting: 266, - fetchRCA: 236, - fetchSelfHealedSelectors: 452, - getFailureLogs: 242, - getSubTestPlan: 649, - getTestPlan: 807, - invokeCapability: 852, - listFolders: 437, - listProducts: 856, - listSubTestPlans: 655, - listTestCases: 472, - listTestPlans: 652, - listTestRuns: 301, - percyVisualTestIntegrationAgent: 342, - prepareSelfHealingPlan: 806, - runAppTestsOnBrowserStack: 297, - searchCapability: 2019, - setupBrowserStackAppAutomateTests: 268, - setupBrowserStackAutomateTests: 328, - updateTestCase: 501, - updateTestRun: 272, - uploadProductRequirementFile: 429, + askBrowserStackAI: 532, + createProjectOrFolder: 217, + describeCapability: 201, + fetchSelfHealedSelectors: 226, + getSubTestPlan: 394, + getTestPlan: 422, + invokeCapability: 454, + listProducts: 252, + listSubTestPlans: 396, + listTestPlans: 325, + prepareSelfHealingPlan: 261, + searchCapability: 375, + uploadProductRequirementFile: 306, }; -/** Each tool's description as a client lists it at connect time. */ -async function descriptions(): Promise<[string, number][]> { +// Tool and parameter descriptions across every tool, in characters. Kiro renders the hosted +// subset of this text into one activation result capped at 30,000; at this total it is about +// 29,600. Lower only. +const TOTAL_CEILING = 21_500; + +/** Each tool as a client lists it at connect time. */ +async function listedTools() { const { BrowserStackMcpServer } = await import("../../src/server-factory.js"); const server = new BrowserStackMcpServer({ "browserstack-username": "u", @@ -63,7 +51,15 @@ async function descriptions(): Promise<[string, number][]> { ]); const { tools } = await client.listTools(); await client.close(); - return tools.map((t) => [t.name, (t.description ?? "").length]); + return tools; +} + +/** Each tool's description length. */ +async function descriptions(): Promise<[string, number][]> { + return (await listedTools()).map((t) => [ + t.name, + (t.description ?? "").length, + ]); } const budget = (name: string) => Math.max(CEILING, GRANDFATHERED[name] ?? 0); @@ -79,4 +75,19 @@ describe("tool descriptions", () => { .map(([name, length]) => `${name}: ${length} > ${budget(name)}`); expect(over).toEqual([]); }); + + it("keeps the total description text within its ceiling", async () => { + const total = (await listedTools()).reduce((sum, t) => { + const props = (t.inputSchema.properties ?? {}) as Record< + string, + { description?: string } + >; + const params = Object.values(props).reduce( + (s, p) => s + (p.description ?? "").length, + 0, + ); + return sum + (t.description ?? "").length + params; + }, 0); + expect(total).toBeLessThanOrEqual(TOTAL_CEILING); + }); });