@browserstack/mcp-server 1.4.0-beta.1 → 1.4.0-beta.3
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.
- package/dist/config.d.ts +4 -1
- package/dist/config.js +23 -2
- package/dist/server-factory.js +2 -2
- package/dist/tools/ask-browserstack/central-oauth.d.ts +7 -1
- package/dist/tools/ask-browserstack/central-oauth.js +26 -20
- package/dist/tools/ask-browserstack/config.d.ts +7 -1
- package/dist/tools/ask-browserstack/config.js +12 -6
- package/dist/tools/ask-browserstack/register.d.ts +4 -4
- package/dist/tools/ask-browserstack/register.js +40 -18
- package/dist/tools/ask-browserstack/relay.js +9 -7
- package/dist/tools/ask-browserstack/stream.js +10 -11
- package/dist/tools/ask-browserstack/types.d.ts +1 -1
- package/dist/tools/ask-browserstack/types.js +5 -1
- package/dist/tools/testmanagement.js +10 -6
- package/dist/tools/tool-handoff.d.ts +26 -1
- package/dist/tools/tool-handoff.js +36 -8
- package/package.json +1 -1
package/dist/config.d.ts
CHANGED
|
@@ -11,7 +11,10 @@ export declare class Config {
|
|
|
11
11
|
readonly O11Y_TFA_RCA_BASE_URL: string;
|
|
12
12
|
readonly BROWSERSTACK_AUTOMATION_BASE_URL: string;
|
|
13
13
|
readonly BROWSERSTACK_O11Y_UI_BASE_URL: string;
|
|
14
|
-
|
|
14
|
+
readonly ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY: boolean;
|
|
15
|
+
readonly ASK_BROWSERSTACK_ATLAS_URL: string | undefined;
|
|
16
|
+
readonly ASK_BROWSERSTACK_AUTH_TOKEN_URL: string | undefined;
|
|
17
|
+
constructor(DEV_MODE: boolean, browserstackLocalOptions: Record<string, any>, USE_OWN_LOCAL_BINARY_PROCESS: boolean, REMOTE_MCP: boolean, UPLOAD_BASE_DIR: string | undefined, O11Y_TFA_RCA_BASE_URL: string, BROWSERSTACK_AUTOMATION_BASE_URL: string, BROWSERSTACK_O11Y_UI_BASE_URL: string, ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY: boolean, ASK_BROWSERSTACK_ATLAS_URL: string | undefined, ASK_BROWSERSTACK_AUTH_TOKEN_URL: string | undefined);
|
|
15
18
|
}
|
|
16
19
|
declare const config: Config;
|
|
17
20
|
export default config;
|
package/dist/config.js
CHANGED
|
@@ -47,7 +47,18 @@ export class Config {
|
|
|
47
47
|
O11Y_TFA_RCA_BASE_URL;
|
|
48
48
|
BROWSERSTACK_AUTOMATION_BASE_URL;
|
|
49
49
|
BROWSERSTACK_O11Y_UI_BASE_URL;
|
|
50
|
-
|
|
50
|
+
ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY;
|
|
51
|
+
ASK_BROWSERSTACK_ATLAS_URL;
|
|
52
|
+
ASK_BROWSERSTACK_AUTH_TOKEN_URL;
|
|
53
|
+
constructor(DEV_MODE, browserstackLocalOptions, USE_OWN_LOCAL_BINARY_PROCESS, REMOTE_MCP, UPLOAD_BASE_DIR, O11Y_TFA_RCA_BASE_URL, BROWSERSTACK_AUTOMATION_BASE_URL, BROWSERSTACK_O11Y_UI_BASE_URL,
|
|
54
|
+
// askBrowserStackAI's process-startup settings. Declared here rather than read from
|
|
55
|
+
// process.env inside src/tools/, per rules/tool-design.md — and so the remote wrapper,
|
|
56
|
+
// which only forwards env it knows about, has one place to look.
|
|
57
|
+
//
|
|
58
|
+
// ASK_BROWSERSTACK_DISABLED is deliberately NOT here: it is a kill switch, and reading
|
|
59
|
+
// it per call keeps it effective without a restart. Fixing it at boot would mean a pod
|
|
60
|
+
// roll to disable the tool, which is slowest exactly when you need it fastest.
|
|
61
|
+
ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY, ASK_BROWSERSTACK_ATLAS_URL, ASK_BROWSERSTACK_AUTH_TOKEN_URL) {
|
|
51
62
|
this.DEV_MODE = DEV_MODE;
|
|
52
63
|
this.browserstackLocalOptions = browserstackLocalOptions;
|
|
53
64
|
this.USE_OWN_LOCAL_BINARY_PROCESS = USE_OWN_LOCAL_BINARY_PROCESS;
|
|
@@ -56,6 +67,9 @@ export class Config {
|
|
|
56
67
|
this.O11Y_TFA_RCA_BASE_URL = O11Y_TFA_RCA_BASE_URL;
|
|
57
68
|
this.BROWSERSTACK_AUTOMATION_BASE_URL = BROWSERSTACK_AUTOMATION_BASE_URL;
|
|
58
69
|
this.BROWSERSTACK_O11Y_UI_BASE_URL = BROWSERSTACK_O11Y_UI_BASE_URL;
|
|
70
|
+
this.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY = ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY;
|
|
71
|
+
this.ASK_BROWSERSTACK_ATLAS_URL = ASK_BROWSERSTACK_ATLAS_URL;
|
|
72
|
+
this.ASK_BROWSERSTACK_AUTH_TOKEN_URL = ASK_BROWSERSTACK_AUTH_TOKEN_URL;
|
|
59
73
|
}
|
|
60
74
|
}
|
|
61
75
|
const config = new Config(process.env.DEV_MODE === "true", browserstackLocalOptions, process.env.USE_OWN_LOCAL_BINARY_PROCESS === "true", process.env.REMOTE_MCP === "true", process.env.MCP_UPLOAD_BASE_DIR && process.env.MCP_UPLOAD_BASE_DIR.length > 0
|
|
@@ -69,5 +83,12 @@ const config = new Config(process.env.DEV_MODE === "true", browserstackLocalOpti
|
|
|
69
83
|
: DEFAULT_BROWSERSTACK_AUTOMATION_BASE_URL, process.env.BROWSERSTACK_O11Y_UI_BASE_URL &&
|
|
70
84
|
process.env.BROWSERSTACK_O11Y_UI_BASE_URL.length > 0
|
|
71
85
|
? process.env.BROWSERSTACK_O11Y_UI_BASE_URL
|
|
72
|
-
: DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL)
|
|
86
|
+
: DEFAULT_BROWSERSTACK_O11Y_UI_BASE_URL, (process.env.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY || "").toLowerCase() ===
|
|
87
|
+
"true", process.env.ASK_BROWSERSTACK_ATLAS_URL &&
|
|
88
|
+
process.env.ASK_BROWSERSTACK_ATLAS_URL.trim().length > 0
|
|
89
|
+
? process.env.ASK_BROWSERSTACK_ATLAS_URL
|
|
90
|
+
: undefined, process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL &&
|
|
91
|
+
process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL.trim().length > 0
|
|
92
|
+
? process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL
|
|
93
|
+
: undefined);
|
|
73
94
|
export default config;
|
package/dist/server-factory.js
CHANGED
|
@@ -16,7 +16,7 @@ import addAppLiveTools from "./tools/applive.js";
|
|
|
16
16
|
import addBuildInsightsTools from "./tools/build-insights.js";
|
|
17
17
|
import { setupOnInitialized } from "./oninitialized.js";
|
|
18
18
|
import addRCATools from "./tools/rca-agent.js";
|
|
19
|
-
import
|
|
19
|
+
import addAskBrowserStackAITool from "./tools/ask-browserstack/register.js";
|
|
20
20
|
/**
|
|
21
21
|
* Wrapper class for BrowserStack MCP Server
|
|
22
22
|
* Stores a map of registered tools by name
|
|
@@ -55,7 +55,7 @@ export class BrowserStackMcpServer {
|
|
|
55
55
|
// Hands a plain-language task to BrowserStack's agent and relays its mid-run
|
|
56
56
|
// permission asks back to this client, so a write can be confirmed by the human
|
|
57
57
|
// sitting in front of it rather than refused for want of anyone to ask.
|
|
58
|
-
|
|
58
|
+
addAskBrowserStackAITool,
|
|
59
59
|
];
|
|
60
60
|
toolAdders.forEach((adder) => {
|
|
61
61
|
// Each adder now returns a Record<string, Tool>
|
|
@@ -101,7 +101,13 @@ export declare const AUTH_SERVER_ERROR_DETAIL: (status: number) => string;
|
|
|
101
101
|
export declare const AUTH_UNUSABLE_DETAIL: (status: number) => string;
|
|
102
102
|
/** Drop every cached token. For tests, and for a credential rotation. */
|
|
103
103
|
export declare function resetTokenCache(): void;
|
|
104
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* The token endpoint, through `apiClient` per rules/security.md — no bare `fetch`.
|
|
106
|
+
*
|
|
107
|
+
* `raise_error: false` keeps the status-first contract this transport has always had: the
|
|
108
|
+
* caller distinguishes a 400 scope refusal from a 401 rejection from an unreachable host,
|
|
109
|
+
* so a thrown AxiosError on any non-2xx would destroy the only signal it reads.
|
|
110
|
+
*/
|
|
105
111
|
export declare function fetchTokenTransport(timeoutMs?: number): TokenTransport;
|
|
106
112
|
/** The exact form body of the `client_credentials` grant. */
|
|
107
113
|
export declare function mintForm(credentials: Credentials): Record<string, string>;
|
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* logged, returned, or put in an error message. Only a status code is.
|
|
15
15
|
*/
|
|
16
16
|
import { createHash } from "node:crypto";
|
|
17
|
+
import { apiClient } from "../../lib/apiClient.js";
|
|
18
|
+
import appConfig from "../../config.js";
|
|
17
19
|
import logger from "../../logger.js";
|
|
18
20
|
import { AGENT_TIMEOUT_MS, AskError } from "./config.js";
|
|
19
21
|
/**
|
|
@@ -157,40 +159,33 @@ function cacheKey(url, credentials) {
|
|
|
157
159
|
.digest("hex");
|
|
158
160
|
return `${url} ${credentials.username} ${CENTRAL_SCOPE} ${digest}`;
|
|
159
161
|
}
|
|
160
|
-
/**
|
|
162
|
+
/**
|
|
163
|
+
* The token endpoint, through `apiClient` per rules/security.md — no bare `fetch`.
|
|
164
|
+
*
|
|
165
|
+
* `raise_error: false` keeps the status-first contract this transport has always had: the
|
|
166
|
+
* caller distinguishes a 400 scope refusal from a 401 rejection from an unreachable host,
|
|
167
|
+
* so a thrown AxiosError on any non-2xx would destroy the only signal it reads.
|
|
168
|
+
*/
|
|
161
169
|
export function fetchTokenTransport(timeoutMs = TOKEN_TIMEOUT_MS) {
|
|
162
170
|
return async (url, form) => {
|
|
163
|
-
const controller = new AbortController();
|
|
164
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
165
171
|
try {
|
|
166
|
-
const response = await
|
|
167
|
-
|
|
172
|
+
const response = await apiClient.post({
|
|
173
|
+
url,
|
|
168
174
|
headers: {
|
|
169
175
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
170
176
|
Accept: "application/json",
|
|
171
177
|
},
|
|
172
178
|
body: new URLSearchParams(form).toString(),
|
|
173
|
-
|
|
174
|
-
|
|
179
|
+
timeout: timeoutMs,
|
|
180
|
+
raise_error: false,
|
|
175
181
|
});
|
|
176
|
-
|
|
177
|
-
try {
|
|
178
|
-
parsed = await response.json();
|
|
179
|
-
}
|
|
180
|
-
catch {
|
|
181
|
-
// An HTML error page behind any status. The caller only reads the status.
|
|
182
|
-
parsed = null;
|
|
183
|
-
}
|
|
184
|
-
return { status: response.status, body: parsed };
|
|
182
|
+
return { status: response.status, body: response.data ?? null };
|
|
185
183
|
}
|
|
186
184
|
catch {
|
|
187
185
|
// DNS, TLS, timeout — all of them mean "no token". The reason is deliberately not
|
|
188
186
|
// carried: it can name the URL and, on some stacks, echo the request body.
|
|
189
187
|
return { status: 0, body: null, error: "auth could not be reached" };
|
|
190
188
|
}
|
|
191
|
-
finally {
|
|
192
|
-
clearTimeout(timer);
|
|
193
|
-
}
|
|
194
189
|
};
|
|
195
190
|
}
|
|
196
191
|
/** The exact form body of the `client_credentials` grant. */
|
|
@@ -247,6 +242,17 @@ export async function mintCentralToken(url, credentials, transport, now = Date.n
|
|
|
247
242
|
throw new AskError("BrowserStack AI is not authenticated: BROWSERSTACK_USERNAME and " +
|
|
248
243
|
"BROWSERSTACK_ACCESS_KEY are required to sign in");
|
|
249
244
|
}
|
|
245
|
+
// NOT CACHED IN HOSTED MODE. These tokens are per-user, attested credentials, and the
|
|
246
|
+
// process is shared by every tenant — `rules/multi-tenant-safety.md` forbids holding user
|
|
247
|
+
// data in module-level state there, so remote mode mints per call. Keying on
|
|
248
|
+
// username + sha256(accessKey) already means one user can never be SERVED another's token,
|
|
249
|
+
// but containment is not the contract; not holding it at all is.
|
|
250
|
+
if (appConfig.REMOTE_MCP) {
|
|
251
|
+
return mintOnce(url, credentials, transport).then(({ token }) => {
|
|
252
|
+
logger.info("askBrowserStackAI: signed in as %s", credentials.username);
|
|
253
|
+
return token;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
250
256
|
const key = cacheKey(url, credentials);
|
|
251
257
|
const entry = cache.get(key);
|
|
252
258
|
if (entry && entry.token && now < entry.expiresAt - REFRESH_SKEW_MS) {
|
|
@@ -258,7 +264,7 @@ export async function mintCentralToken(url, credentials, transport, now = Date.n
|
|
|
258
264
|
const pending = mintOnce(url, credentials, transport)
|
|
259
265
|
.then(({ token, lifetimeMs }) => {
|
|
260
266
|
cache.set(key, { token, expiresAt: now + lifetimeMs });
|
|
261
|
-
logger.info("
|
|
267
|
+
logger.info("askBrowserStackAI: signed in as %s (lifetime %ss)", credentials.username, Math.round(lifetimeMs / 1000));
|
|
262
268
|
return token;
|
|
263
269
|
})
|
|
264
270
|
.catch((error) => {
|
|
@@ -22,7 +22,13 @@ export declare const ELICITATION_TIMEOUT_MS = 270000;
|
|
|
22
22
|
/** Thrown for anything this tool refuses to attempt. Never carries a credential. */
|
|
23
23
|
export declare class AskError extends Error {
|
|
24
24
|
}
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Off by default is wrong for a shipped feature, but a kill switch is not.
|
|
27
|
+
*
|
|
28
|
+
* The only setting here still read from `process.env` per call, and deliberately: a kill
|
|
29
|
+
* switch that needs a process restart is slowest exactly when it is needed fastest. The
|
|
30
|
+
* other three are on the config singleton (rules/tool-design.md).
|
|
31
|
+
*/
|
|
26
32
|
export declare function isEnabled(): boolean;
|
|
27
33
|
/**
|
|
28
34
|
* May the relay be offered in the hosted (`REMOTE_MCP`) deployment?
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import appConfig from "../../config.js";
|
|
1
2
|
import logger from "../../logger.js";
|
|
2
3
|
/**
|
|
3
4
|
* Where Atlas lives, and the timeout ladder.
|
|
@@ -23,7 +24,13 @@ export const ELICITATION_TIMEOUT_MS = 270_000;
|
|
|
23
24
|
/** Thrown for anything this tool refuses to attempt. Never carries a credential. */
|
|
24
25
|
export class AskError extends Error {
|
|
25
26
|
}
|
|
26
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Off by default is wrong for a shipped feature, but a kill switch is not.
|
|
29
|
+
*
|
|
30
|
+
* The only setting here still read from `process.env` per call, and deliberately: a kill
|
|
31
|
+
* switch that needs a process restart is slowest exactly when it is needed fastest. The
|
|
32
|
+
* other three are on the config singleton (rules/tool-design.md).
|
|
33
|
+
*/
|
|
27
34
|
export function isEnabled() {
|
|
28
35
|
return (process.env.ASK_BROWSERSTACK_DISABLED || "").toLowerCase() !== "true";
|
|
29
36
|
}
|
|
@@ -41,8 +48,7 @@ export function isEnabled() {
|
|
|
41
48
|
* run. This flag only removes the blanket refusal.
|
|
42
49
|
*/
|
|
43
50
|
export function allowRemoteRelay() {
|
|
44
|
-
return
|
|
45
|
-
"true");
|
|
51
|
+
return appConfig.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY;
|
|
46
52
|
}
|
|
47
53
|
/**
|
|
48
54
|
* ============================================================================
|
|
@@ -98,7 +104,7 @@ function announce(what, url, source) {
|
|
|
98
104
|
if (announced.has(line))
|
|
99
105
|
return;
|
|
100
106
|
announced.add(line);
|
|
101
|
-
logger.info("
|
|
107
|
+
logger.info("askBrowserStackAI: %s is %s (source: %s)", what, url, source);
|
|
102
108
|
}
|
|
103
109
|
/**
|
|
104
110
|
* Resolve Atlas's base URL:
|
|
@@ -111,7 +117,7 @@ function announce(what, url, source) {
|
|
|
111
117
|
* no selector: one default, one override.
|
|
112
118
|
*/
|
|
113
119
|
export function atlasBaseUrl() {
|
|
114
|
-
const explicit =
|
|
120
|
+
const explicit = appConfig.ASK_BROWSERSTACK_ATLAS_URL;
|
|
115
121
|
const url = explicit && explicit.trim() ? trimUrl(explicit) : DEFAULT_ATLAS_URL;
|
|
116
122
|
announce("Atlas", url, explicit && explicit.trim() ? "env" : "default");
|
|
117
123
|
return url;
|
|
@@ -127,7 +133,7 @@ export function agentUrl() {
|
|
|
127
133
|
* the only way in. Same two rungs as the host, and the same staging default.
|
|
128
134
|
*/
|
|
129
135
|
export function authTokenUrl() {
|
|
130
|
-
const explicit =
|
|
136
|
+
const explicit = appConfig.ASK_BROWSERSTACK_AUTH_TOKEN_URL;
|
|
131
137
|
const url = explicit && explicit.trim() ? trimUrl(explicit) : DEFAULT_AUTH_TOKEN_URL;
|
|
132
138
|
announce("auth token endpoint", url, explicit && explicit.trim() ? "env" : "default");
|
|
133
139
|
return url;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `
|
|
2
|
+
* `askBrowserStackAI` — one tool call in, one tool result out, with a human's approval
|
|
3
3
|
* relayed through the middle of it.
|
|
4
4
|
*
|
|
5
5
|
* The shape, and why:
|
|
@@ -55,7 +55,7 @@ export interface AskDeps {
|
|
|
55
55
|
decisionTransport?: DecisionTransport;
|
|
56
56
|
}
|
|
57
57
|
export declare function relayMode(server: McpServer): RelayMode;
|
|
58
|
-
export declare function
|
|
58
|
+
export declare function addAskBrowserStackAITool(server: McpServer, deps: AskDeps, config?: BrowserStackConfig): Record<string, RegisteredTool>;
|
|
59
59
|
/** The tool-adder the server factory calls. */
|
|
60
|
-
export declare function
|
|
61
|
-
export default
|
|
60
|
+
export declare function addAskBrowserStackAIToolFromConfig(server: McpServer, config: BrowserStackConfig): Record<string, RegisteredTool>;
|
|
61
|
+
export default addAskBrowserStackAIToolFromConfig;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `
|
|
2
|
+
* `askBrowserStackAI` — one tool call in, one tool result out, with a human's approval
|
|
3
3
|
* relayed through the middle of it.
|
|
4
4
|
*
|
|
5
5
|
* The shape, and why:
|
|
@@ -47,7 +47,16 @@ import { PRODUCTS, } from "./types.js";
|
|
|
47
47
|
* words alone, before any call is made — so the ordering is deliberate: when to reach for it
|
|
48
48
|
* first, what it does second, and the consent behaviour last.
|
|
49
49
|
*/
|
|
50
|
-
const DESCRIPTION =
|
|
50
|
+
const DESCRIPTION =
|
|
51
|
+
// Alpha status leads, deliberately. The model reads this before deciding to call, and a
|
|
52
|
+
// tool that is not enabled for the account cannot do the job at all — so "there is a
|
|
53
|
+
// per-account gate, fall back to the individual tools" is the most useful thing to say
|
|
54
|
+
// first. Parenthesised so it reads as a status note, not as the tool's purpose.
|
|
55
|
+
"(Alpha, limited availability. Enabled per account and per product; if it is not enabled " +
|
|
56
|
+
"the call returns an entitlement error, nothing runs, and you should complete the task " +
|
|
57
|
+
"with the individual tools instead. To request access, the user should contact their " +
|
|
58
|
+
"BrowserStack account owner.) " +
|
|
59
|
+
"Use this when no other BrowserStack tool here fits the task, or when the ones you tried " +
|
|
51
60
|
"did not get you there. Prefer a specific tool whenever one fits: it is faster and more " +
|
|
52
61
|
"predictable than handing the job to an agent. " +
|
|
53
62
|
"Otherwise, describe what you want in plain language and BrowserStack's agent decides " +
|
|
@@ -147,7 +156,7 @@ async function relayOneAsk(server, ask, approvals, relatedRequestId) {
|
|
|
147
156
|
// The SHAPE of the answer only — a fixed action enum and a boolean, never the description
|
|
148
157
|
// or anything a user typed. Logged so that what a client actually submits can be read next
|
|
149
158
|
// time rather than inferred from a compiled binary.
|
|
150
|
-
logger.info("
|
|
159
|
+
logger.info("askBrowserStackAI: elicitation answered %s", elicitationShape(answer));
|
|
151
160
|
const { decision, reason } = decide(answer);
|
|
152
161
|
approvals.push({ description: ask.description, decision, reason });
|
|
153
162
|
return { perm_id: ask.perm_id, decision, reason };
|
|
@@ -224,14 +233,14 @@ async function runStreamed(server, streamTransport, decisionTransport, url, head
|
|
|
224
233
|
// description cannot produce an answerable prompt, so it must not produce a prompt.
|
|
225
234
|
const ask = parseAsk(event.data);
|
|
226
235
|
if (!ask) {
|
|
227
|
-
logger.error("
|
|
236
|
+
logger.error("askBrowserStackAI: unusable permission ask on the stream; ignoring");
|
|
228
237
|
continue;
|
|
229
238
|
}
|
|
230
239
|
if (!runId) {
|
|
231
240
|
// Atlas emits `run` before any ask precisely so this cannot happen. If it does,
|
|
232
241
|
// there is nowhere to send a decision — so do not prompt a human for an answer
|
|
233
242
|
// that could never be delivered.
|
|
234
|
-
logger.error("
|
|
243
|
+
logger.error("askBrowserStackAI: permission ask arrived before run_id; cannot answer");
|
|
235
244
|
continue;
|
|
236
245
|
}
|
|
237
246
|
// `relayOneAsk` RETHROWS on an unexpected elicitation failure. Under A2 that was
|
|
@@ -246,7 +255,7 @@ async function runStreamed(server, streamTransport, decisionTransport, url, head
|
|
|
246
255
|
decision = await relayOneAsk(server, ask, approvals, relatedRequestId);
|
|
247
256
|
}
|
|
248
257
|
catch (error) {
|
|
249
|
-
logger.warn("
|
|
258
|
+
logger.warn("askBrowserStackAI: elicitation failed, denying explicitly: %s", error instanceof Error ? error.message : String(error));
|
|
250
259
|
decision = { perm_id: ask.perm_id, decision: "deny", reason: "error" };
|
|
251
260
|
}
|
|
252
261
|
const status = await decisionTransport(decisionUrl(url, runId), headers, {
|
|
@@ -259,7 +268,7 @@ async function runStreamed(server, streamTransport, decisionTransport, url, head
|
|
|
259
268
|
// own expiry, so a lost decision is safe — it can only cost an approval, never
|
|
260
269
|
// grant one. Retrying risks the opposite: a duplicate that 409s, or worse, an
|
|
261
270
|
// approval applied to a step the run has already moved past.
|
|
262
|
-
logger.warn("
|
|
271
|
+
logger.warn("askBrowserStackAI: decision for %s was not accepted (HTTP %s)", decision.perm_id, status);
|
|
263
272
|
}
|
|
264
273
|
}
|
|
265
274
|
if (!sawResult) {
|
|
@@ -283,7 +292,7 @@ export function relayMode(server) {
|
|
|
283
292
|
? "offered"
|
|
284
293
|
: "no_human";
|
|
285
294
|
}
|
|
286
|
-
export function
|
|
295
|
+
export function addAskBrowserStackAITool(server, deps, config) {
|
|
287
296
|
// A1 (CONTRACT v2) is the only path; A2 is gone. No version flag is needed to talk to
|
|
288
297
|
// an Atlas that predates the stream: such a server answers `POST /agent` with ordinary
|
|
289
298
|
// JSON, the parser sees no `text/event-stream`, and the run degrades to a read-only
|
|
@@ -300,10 +309,10 @@ export function addAskBrowserstackAITool(server, deps, config) {
|
|
|
300
309
|
// Telemetry must not decide whether a tool call succeeds.
|
|
301
310
|
}
|
|
302
311
|
};
|
|
303
|
-
tools.
|
|
312
|
+
tools.askBrowserStackAI = server.tool("askBrowserStackAI", DESCRIPTION, {
|
|
304
313
|
product: z
|
|
305
314
|
.enum(PRODUCTS)
|
|
306
|
-
.describe("Which product to work in: tm (Test Management),
|
|
315
|
+
.describe("Which product to work in: tm (Test Management), " +
|
|
307
316
|
"tra (Test Reporting & Analytics)."),
|
|
308
317
|
query: z
|
|
309
318
|
.string()
|
|
@@ -314,9 +323,14 @@ export function addAskBrowserstackAITool(server, deps, config) {
|
|
|
314
323
|
// sets it false: consent is not a licence to delete.
|
|
315
324
|
readOnlyHint: false,
|
|
316
325
|
destructiveHint: false,
|
|
317
|
-
|
|
326
|
+
// openWorldHint: the agent fans out to product APIs chosen at runtime, so the set of
|
|
327
|
+
// effects is not knowable from this schema. idempotentHint false because a repeated
|
|
328
|
+
// call can create a second record — the relay asks again, it does not dedupe.
|
|
329
|
+
openWorldHint: true,
|
|
330
|
+
idempotentHint: false,
|
|
331
|
+
title: "Ask BrowserStack AI (Alpha)",
|
|
318
332
|
}, async ({ product, query }, extra) => {
|
|
319
|
-
track("
|
|
333
|
+
track("askBrowserStackAI");
|
|
320
334
|
const approvals = [];
|
|
321
335
|
// Negotiated before anything else so the failure paths below report the mode they
|
|
322
336
|
// would have run in.
|
|
@@ -347,7 +361,7 @@ export function addAskBrowserstackAITool(server, deps, config) {
|
|
|
347
361
|
else {
|
|
348
362
|
// Omitted ENTIRELY, not sent empty: its absence is what selects Atlas's
|
|
349
363
|
// read-only HeadlessGate.
|
|
350
|
-
logger.info("
|
|
364
|
+
logger.info("askBrowserStackAI: no permission relay (%s); running read-only", mode);
|
|
351
365
|
}
|
|
352
366
|
// `product` reaches the result so an entitlement refusal can name it: the flags
|
|
353
367
|
// are per product, and a bare "not enabled" sends the user to their admin
|
|
@@ -361,7 +375,15 @@ export function addAskBrowserstackAITool(server, deps, config) {
|
|
|
361
375
|
const message = error instanceof AskError || error instanceof Error
|
|
362
376
|
? error.message
|
|
363
377
|
: String(error);
|
|
364
|
-
logger.error("
|
|
378
|
+
logger.error("askBrowserStackAI failed: %s", message);
|
|
379
|
+
// Error telemetry, in the same never-fatal shape as the success-path `track()`:
|
|
380
|
+
// a failing tool call must not be made worse by a failing analytics call.
|
|
381
|
+
try {
|
|
382
|
+
trackMCP("askBrowserStackAI", server.server.getClientVersion(), error, config);
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
/* ignore */
|
|
386
|
+
}
|
|
365
387
|
// No `canElicit` argument: the request never left this process, so whether the
|
|
366
388
|
// client could have been prompted is not what the reader needs to know.
|
|
367
389
|
return toResult(errorResult(message, approvals));
|
|
@@ -373,9 +395,9 @@ export function addAskBrowserstackAITool(server, deps, config) {
|
|
|
373
395
|
return tools;
|
|
374
396
|
}
|
|
375
397
|
/** The tool-adder the server factory calls. */
|
|
376
|
-
export function
|
|
398
|
+
export function addAskBrowserStackAIToolFromConfig(server, config) {
|
|
377
399
|
if (!isEnabled()) {
|
|
378
|
-
logger.info("
|
|
400
|
+
logger.info("askBrowserStackAI disabled by ASK_BROWSERSTACK_DISABLED");
|
|
379
401
|
return {};
|
|
380
402
|
}
|
|
381
403
|
const credentials = () => ({
|
|
@@ -383,7 +405,7 @@ export function addAskBrowserstackAIToolFromConfig(server, config) {
|
|
|
383
405
|
accessKey: config["browserstack-access-key"],
|
|
384
406
|
});
|
|
385
407
|
const tokenTransport = fetchTokenTransport();
|
|
386
|
-
return
|
|
408
|
+
return addAskBrowserStackAITool(server, {
|
|
387
409
|
// Both resolved per call. An unconfigured host surfaces as a named error from the
|
|
388
410
|
// tool rather than as a missing tool, so the cause is visible to whoever hits it.
|
|
389
411
|
agentUrl,
|
|
@@ -391,4 +413,4 @@ export function addAskBrowserstackAIToolFromConfig(server, config) {
|
|
|
391
413
|
credentialsFor: credentials,
|
|
392
414
|
}, config);
|
|
393
415
|
}
|
|
394
|
-
export default
|
|
416
|
+
export default addAskBrowserStackAIToolFromConfig;
|
|
@@ -58,10 +58,11 @@ export const RELAY_OFF_DETAILS = {
|
|
|
58
58
|
// Not a refusal by anyone and not a relay problem at all: the account is not on the
|
|
59
59
|
// product's agent flag. The product-specific sentence and what to do about it live in
|
|
60
60
|
// `error`, so this one points there rather than duplicating the plumbing.
|
|
61
|
-
not_entitled: "NOBODY DECLINED THIS AND NOTHING RAN.
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"credentials and not a decision anyone made about
|
|
61
|
+
not_entitled: "NOBODY DECLINED THIS AND NOTHING RAN. Ask AI (Alpha) is not enabled on this account. " +
|
|
62
|
+
"Ask AI is in limited alpha and available only to enrolled accounts, so the request was " +
|
|
63
|
+
"refused before the agent started. `error` names the product. This is an entitlement on " +
|
|
64
|
+
"the account, not a problem with your credentials and not a decision anyone made about " +
|
|
65
|
+
"your request.",
|
|
65
66
|
// The request never got as far as the agent. Distinct from `disabled` (the agent ran, with
|
|
66
67
|
// the relay switched off) and from a decline (someone was asked and said no), because the
|
|
67
68
|
// three call for completely different things from whoever reads them.
|
|
@@ -137,9 +138,10 @@ export function isNotEntitled(response) {
|
|
|
137
138
|
*/
|
|
138
139
|
export const NOT_ENTITLED_DETAIL = (product) => {
|
|
139
140
|
const scope = product && product.trim() ? ` for \`${product.trim()}\`` : "";
|
|
140
|
-
return (`
|
|
141
|
-
`
|
|
142
|
-
`
|
|
141
|
+
return (`Ask AI (Alpha) is not enabled${scope} on this account. Ask AI is in limited alpha and ` +
|
|
142
|
+
`available only to enrolled accounts. Authentication succeeded and nothing was run. ` +
|
|
143
|
+
`To request access, contact your BrowserStack account owner, or reach out at ` +
|
|
144
|
+
`https://www.browserstack.com/contact-sales`);
|
|
143
145
|
};
|
|
144
146
|
export function neverReachedAgent(response) {
|
|
145
147
|
// No response at all: nothing could have run.
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* is a pipe. Keeping the judgement out of the transport is why swapping A2 for A1 does
|
|
23
23
|
* not risk the fail-closed behaviour.
|
|
24
24
|
*/
|
|
25
|
+
import { apiClient } from "../../lib/apiClient.js";
|
|
25
26
|
import logger from "../../logger.js";
|
|
26
27
|
import { AskError } from "./config.js";
|
|
27
28
|
/**
|
|
@@ -120,7 +121,7 @@ export function parseFrame(frame) {
|
|
|
120
121
|
// A frame we cannot read is not a frame we may guess at. Dropping it is safe
|
|
121
122
|
// because the only consequence is that an ask goes unanswered and the gate denies
|
|
122
123
|
// on its own expiry — never that something is approved.
|
|
123
|
-
logger.warn("
|
|
124
|
+
logger.warn("askBrowserStackAI: unparseable stream frame, ignoring");
|
|
124
125
|
return null;
|
|
125
126
|
}
|
|
126
127
|
}
|
|
@@ -208,15 +209,16 @@ export function fetchAgentStreamTransport(timeoutMs = WHOLE_RUN_TIMEOUT_MS) {
|
|
|
208
209
|
/** The decision POST. 30s, because it is an ordinary short request. */
|
|
209
210
|
export function fetchDecisionTransport(timeoutMs = 30_000) {
|
|
210
211
|
return async (url, headers, body) => {
|
|
211
|
-
const controller = new AbortController();
|
|
212
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
213
212
|
try {
|
|
214
|
-
|
|
215
|
-
|
|
213
|
+
// Through `apiClient` per rules/security.md. `raise_error: false` because the caller
|
|
214
|
+
// reads the STATUS: a 404 (run gone) and a 409 (already decided) are both answers,
|
|
215
|
+
// and a thrown AxiosError would collapse them into the unreachable case below.
|
|
216
|
+
const response = await apiClient.post({
|
|
217
|
+
url,
|
|
216
218
|
headers,
|
|
217
|
-
body
|
|
218
|
-
|
|
219
|
-
|
|
219
|
+
body,
|
|
220
|
+
timeout: timeoutMs,
|
|
221
|
+
raise_error: false,
|
|
220
222
|
});
|
|
221
223
|
return response.status;
|
|
222
224
|
}
|
|
@@ -226,9 +228,6 @@ export function fetchDecisionTransport(timeoutMs = 30_000) {
|
|
|
226
228
|
// the caller can say that rather than implying a human refused.
|
|
227
229
|
return 0;
|
|
228
230
|
}
|
|
229
|
-
finally {
|
|
230
|
-
clearTimeout(timer);
|
|
231
|
-
}
|
|
232
231
|
};
|
|
233
232
|
}
|
|
234
233
|
/** `POST /agent/{run_id}/permission`, built from the base URL the tool already resolved. */
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* different repo, at the same time. A field renamed here to read better is a field the
|
|
7
7
|
* other half will never send. Nothing here changes without changing that document first.
|
|
8
8
|
*/
|
|
9
|
-
export declare const PRODUCTS: readonly ["tm", "
|
|
9
|
+
export declare const PRODUCTS: readonly ["tm", "tra"];
|
|
10
10
|
export type Product = (typeof PRODUCTS)[number];
|
|
11
11
|
/** CONTRACT §2 — what Atlas emits on the run's stream when its gate needs a human. */
|
|
12
12
|
export interface PermissionAsk {
|
|
@@ -6,5 +6,9 @@
|
|
|
6
6
|
* different repo, at the same time. A field renamed here to read better is a field the
|
|
7
7
|
* other half will never send. Nothing here changes without changing that document first.
|
|
8
8
|
*/
|
|
9
|
-
|
|
9
|
+
// a11y is deliberately ABSENT while Ask AI is in limited alpha. Atlas itself serves the
|
|
10
|
+
// product; this tool just does not offer it yet. Re-adding it means this list, the `product`
|
|
11
|
+
// describe text in register.ts, and the two a11y handoffs in tool-handoff.ts — which stop
|
|
12
|
+
// pointing here precisely because the call would now be rejected.
|
|
13
|
+
export const PRODUCTS = ["tm", "tra"];
|
|
10
14
|
export const ASK_STATUSES = ["ok", "blocked", "error", "rate_limited"];
|
|
@@ -19,7 +19,7 @@ import { getTestPlan, GetTestPlanSchema, } from "./testmanagement-utils/get-test
|
|
|
19
19
|
import { listSubTestPlans, ListSubTestPlansSchema, } from "./testmanagement-utils/list-sub-testplans.js";
|
|
20
20
|
import { getSubTestPlan, GetSubTestPlanSchema, } from "./testmanagement-utils/get-sub-testplan.js";
|
|
21
21
|
import { elicitCredentialsIfSupported } from "../lib/elicit-credentials.js";
|
|
22
|
-
import { NEEDS_PROJECT_ID, NEEDS_TEST_PLAN_ID } from "./tool-handoff.js";
|
|
22
|
+
import { NEEDS_PROJECT_ID, NEEDS_TEST_PLAN_ID, PLAN_WRITES_VIA_AGENT, PROJECT_ID_ONLY_FOR_FOLDER, } from "./tool-handoff.js";
|
|
23
23
|
//TODO: Moving the traceMCP and catch block to the parent(server) function
|
|
24
24
|
/**
|
|
25
25
|
* Wrapper to call createProjectOrFolder util.
|
|
@@ -434,7 +434,7 @@ export async function getSubTestPlanTool(args, config, server) {
|
|
|
434
434
|
export default function addTestManagementTools(server, config) {
|
|
435
435
|
const tools = {};
|
|
436
436
|
tools.createProjectOrFolder = server.tool("createProjectOrFolder", "Create a project and/or folder in BrowserStack Test Management." +
|
|
437
|
-
|
|
437
|
+
PROJECT_ID_ONLY_FOR_FOLDER, CreateProjFoldSchema.shape, {
|
|
438
438
|
title: "Create Project or Folder",
|
|
439
439
|
readOnlyHint: false,
|
|
440
440
|
openWorldHint: false,
|
|
@@ -535,7 +535,8 @@ export default function addTestManagementTools(server, config) {
|
|
|
535
535
|
idempotentHint: false,
|
|
536
536
|
}, (args, context) => createLCAStepsTool(args, context, config, server));
|
|
537
537
|
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." +
|
|
538
|
-
NEEDS_PROJECT_ID
|
|
538
|
+
NEEDS_PROJECT_ID +
|
|
539
|
+
PLAN_WRITES_VIA_AGENT, ListTestPlansSchema.shape, {
|
|
539
540
|
title: "List Test Plans",
|
|
540
541
|
readOnlyHint: true,
|
|
541
542
|
openWorldHint: false,
|
|
@@ -544,7 +545,8 @@ export default function addTestManagementTools(server, config) {
|
|
|
544
545
|
}, (args) => listTestPlansTool(args, config, server));
|
|
545
546
|
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." +
|
|
546
547
|
NEEDS_PROJECT_ID +
|
|
547
|
-
NEEDS_TEST_PLAN_ID
|
|
548
|
+
NEEDS_TEST_PLAN_ID +
|
|
549
|
+
PLAN_WRITES_VIA_AGENT, GetTestPlanSchema.shape, {
|
|
548
550
|
title: "Get Test Plan",
|
|
549
551
|
readOnlyHint: true,
|
|
550
552
|
openWorldHint: false,
|
|
@@ -553,7 +555,8 @@ export default function addTestManagementTools(server, config) {
|
|
|
553
555
|
}, (args) => getTestPlanTool(args, config, server));
|
|
554
556
|
tools.listSubTestPlans = server.tool("listSubTestPlans", "List sub-test-plans under a parent test plan (TP-*) in a Test Management project. Supports pagination." +
|
|
555
557
|
NEEDS_PROJECT_ID +
|
|
556
|
-
NEEDS_TEST_PLAN_ID
|
|
558
|
+
NEEDS_TEST_PLAN_ID +
|
|
559
|
+
PLAN_WRITES_VIA_AGENT, ListSubTestPlansSchema.shape, {
|
|
557
560
|
title: "List Sub Test Plans",
|
|
558
561
|
readOnlyHint: true,
|
|
559
562
|
openWorldHint: false,
|
|
@@ -562,7 +565,8 @@ export default function addTestManagementTools(server, config) {
|
|
|
562
565
|
}, (args) => listSubTestPlansTool(args, config, server));
|
|
563
566
|
tools.getSubTestPlan = server.tool("getSubTestPlan", "Fetch a sub-test-plan (STP-*) under a parent plan (TP-*). Returns metadata and linked test runs." +
|
|
564
567
|
NEEDS_PROJECT_ID +
|
|
565
|
-
NEEDS_TEST_PLAN_ID
|
|
568
|
+
NEEDS_TEST_PLAN_ID +
|
|
569
|
+
PLAN_WRITES_VIA_AGENT, GetSubTestPlanSchema.shape, {
|
|
566
570
|
title: "Get Sub Test Plan",
|
|
567
571
|
readOnlyHint: true,
|
|
568
572
|
openWorldHint: false,
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* where the missing identifier comes from.
|
|
9
9
|
*
|
|
10
10
|
* Point at a sibling tool whenever one can produce the id — it is faster and more
|
|
11
|
-
* predictable than an agent. Point at `
|
|
11
|
+
* predictable than an agent. Point at `askBrowserStackAI` only when NO tool here can.
|
|
12
12
|
*
|
|
13
13
|
* The one that matters most: 15 of the 17 Test Management tools require a project
|
|
14
14
|
* identifier and NONE of them accepts its absence, yet no tool in this server lists
|
|
@@ -23,8 +23,33 @@
|
|
|
23
23
|
export declare const NEEDS_PROJECT_ID: string;
|
|
24
24
|
/** A sibling tool can produce the id — prefer it over the agent. */
|
|
25
25
|
export declare function needsIdFrom(idLabel: string, sourceTool: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* createProjectOrFolder must NOT carry NEEDS_PROJECT_ID: `project_identifier` is optional
|
|
28
|
+
* there, and the create-a-PROJECT half needs no id at all. With the generic constant the
|
|
29
|
+
* tool read "Requires a project identifier ... call askBrowserStackAI", which routed
|
|
30
|
+
* "create me a project" through the agent before letting the tool run.
|
|
31
|
+
*/
|
|
32
|
+
export declare const PROJECT_ID_ONLY_FOR_FOLDER: string;
|
|
26
33
|
/** A test plan id (TP-*) comes from listTestPlans. */
|
|
27
34
|
export declare const NEEDS_TEST_PLAN_ID: string;
|
|
35
|
+
/**
|
|
36
|
+
* The ONLY capability handoff here: every other constant points at a tool that produces a
|
|
37
|
+
* missing *id*, but plan WRITES have no tool at all — the surface is `listTestPlans`,
|
|
38
|
+
* `getTestPlan`, `listSubTestPlans`, `getSubTestPlan` and nothing else. Atlas can do them
|
|
39
|
+
* (the tm harness allows POST /api/v1/projects/{id}/test-plans plus /update, /delete,
|
|
40
|
+
* /clone, /test-runs and /test-runs/unlink), so without this line the model reads the four
|
|
41
|
+
* read tools, finds no create, and reports the capability as absent — which is exactly what
|
|
42
|
+
* a QA eval concluded.
|
|
43
|
+
*
|
|
44
|
+
* Deliberately narrow: it names the specific operations that are missing rather than
|
|
45
|
+
* inviting the model to route plan work to the agent generally, because the tool
|
|
46
|
+
* descriptions otherwise say to prefer a specific tool whenever one fits.
|
|
47
|
+
*
|
|
48
|
+
* Caveat worth knowing: askBrowserStackAI pins every write to human approval, so this path
|
|
49
|
+
* only completes on a client that can show a prompt. On one that cannot, the intended write
|
|
50
|
+
* comes back in `needs_approval` instead of happening.
|
|
51
|
+
*/
|
|
52
|
+
export declare const PLAN_WRITES_VIA_AGENT: string;
|
|
28
53
|
/** A build id comes from either build-lookup tool. */
|
|
29
54
|
export declare const NEEDS_BUILD_ID: string;
|
|
30
55
|
/** Session ids are not listable by any tool here. */
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* where the missing identifier comes from.
|
|
9
9
|
*
|
|
10
10
|
* Point at a sibling tool whenever one can produce the id — it is faster and more
|
|
11
|
-
* predictable than an agent. Point at `
|
|
11
|
+
* predictable than an agent. Point at `askBrowserStackAI` only when NO tool here can.
|
|
12
12
|
*
|
|
13
13
|
* The one that matters most: 15 of the 17 Test Management tools require a project
|
|
14
14
|
* identifier and NONE of them accepts its absence, yet no tool in this server lists
|
|
@@ -21,27 +21,55 @@
|
|
|
21
21
|
*/
|
|
22
22
|
/** No tool lists projects, so this genuinely has to go to the agent. */
|
|
23
23
|
export const NEEDS_PROJECT_ID = " Requires a project identifier (PR-*). No tool here lists projects, so if you do not " +
|
|
24
|
-
'have one, call
|
|
24
|
+
'have one, call askBrowserStackAI with product "tm" and ask which projects exist, then ' +
|
|
25
25
|
"retry this tool with the identifier it returns.";
|
|
26
26
|
/** A sibling tool can produce the id — prefer it over the agent. */
|
|
27
27
|
export function needsIdFrom(idLabel, sourceTool) {
|
|
28
28
|
return ` Requires ${idLabel}. Call ${sourceTool} first if you do not have it.`;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* createProjectOrFolder must NOT carry NEEDS_PROJECT_ID: `project_identifier` is optional
|
|
32
|
+
* there, and the create-a-PROJECT half needs no id at all. With the generic constant the
|
|
33
|
+
* tool read "Requires a project identifier ... call askBrowserStackAI", which routed
|
|
34
|
+
* "create me a project" through the agent before letting the tool run.
|
|
35
|
+
*/
|
|
36
|
+
export const PROJECT_ID_ONLY_FOR_FOLDER = " Creating a project needs no identifier. Creating a folder inside an EXISTING project " +
|
|
37
|
+
"needs that project's identifier (PR-*); no tool here lists projects, so ask " +
|
|
38
|
+
'askBrowserStackAI with product "tm" for it.';
|
|
30
39
|
/** A test plan id (TP-*) comes from listTestPlans. */
|
|
31
40
|
export const NEEDS_TEST_PLAN_ID = needsIdFrom("a test plan identifier (TP-*)", "listTestPlans");
|
|
41
|
+
/**
|
|
42
|
+
* The ONLY capability handoff here: every other constant points at a tool that produces a
|
|
43
|
+
* missing *id*, but plan WRITES have no tool at all — the surface is `listTestPlans`,
|
|
44
|
+
* `getTestPlan`, `listSubTestPlans`, `getSubTestPlan` and nothing else. Atlas can do them
|
|
45
|
+
* (the tm harness allows POST /api/v1/projects/{id}/test-plans plus /update, /delete,
|
|
46
|
+
* /clone, /test-runs and /test-runs/unlink), so without this line the model reads the four
|
|
47
|
+
* read tools, finds no create, and reports the capability as absent — which is exactly what
|
|
48
|
+
* a QA eval concluded.
|
|
49
|
+
*
|
|
50
|
+
* Deliberately narrow: it names the specific operations that are missing rather than
|
|
51
|
+
* inviting the model to route plan work to the agent generally, because the tool
|
|
52
|
+
* descriptions otherwise say to prefer a specific tool whenever one fits.
|
|
53
|
+
*
|
|
54
|
+
* Caveat worth knowing: askBrowserStackAI pins every write to human approval, so this path
|
|
55
|
+
* only completes on a client that can show a prompt. On one that cannot, the intended write
|
|
56
|
+
* comes back in `needs_approval` instead of happening.
|
|
57
|
+
*/
|
|
58
|
+
export const PLAN_WRITES_VIA_AGENT = " Creating a test plan or sub-plan, and linking or unlinking test runs on one, are not " +
|
|
59
|
+
'available as tools here: call askBrowserStackAI with product "tm" and describe what you ' +
|
|
60
|
+
"want. It asks you to confirm before changing anything.";
|
|
32
61
|
/** A build id comes from either build-lookup tool. */
|
|
33
62
|
export const NEEDS_BUILD_ID = needsIdFrom("a BrowserStack build id", "getBuildId or listBuildId");
|
|
34
63
|
/** Session ids are not listable by any tool here. */
|
|
35
64
|
export const NEEDS_SESSION_ID = " Requires a session id, which no tool here lists. If you only know the build, call " +
|
|
36
|
-
"getBuildId or listBuildId; if you have neither, call
|
|
65
|
+
"getBuildId or listBuildId; if you have neither, call askBrowserStackAI with product " +
|
|
37
66
|
'"tra" and describe the run you mean.';
|
|
38
67
|
/** A completed scan's ids come from startAccessibilityScan, or from the agent. */
|
|
39
|
-
export const NEEDS_A11Y_SCAN_ID = " Requires the ids of a completed scan
|
|
40
|
-
|
|
41
|
-
"
|
|
68
|
+
export const NEEDS_A11Y_SCAN_ID = " Requires the ids of a completed scan, which are returned by startAccessibilityScan. " +
|
|
69
|
+
"No tool here lists past scans, so if you do not have the ids, start a new scan rather " +
|
|
70
|
+
"than guessing.";
|
|
42
71
|
/** Auth-config ids are not listable by any tool here. */
|
|
43
72
|
export const NEEDS_A11Y_CONFIG_ID = " Requires the numeric id returned by createAccessibilityAuthConfig. No tool here lists " +
|
|
44
|
-
"existing configurations, so if you do not have the id,
|
|
45
|
-
'product "a11y".';
|
|
73
|
+
"existing configurations, so if you do not have the id, create one rather than guessing.";
|
|
46
74
|
/** Test ids come from listTestIds, which itself needs a build id. */
|
|
47
75
|
export const NEEDS_TEST_IDS = needsIdFrom("test ids", "listTestIds");
|