| /** Runs `tasks` (each a thunk producing a promise) with at most | ||
| * `concurrency` in flight at once, starting the next queued task as soon as | ||
| * a slot frees up. Results are returned in the same order as `tasks` | ||
| * (matching `Promise.all`'s own contract) regardless of which finishes | ||
| * first. No new npm dependency — matches this project's "zero new deps for | ||
| * any CLI feature so far" record; a plain worker-pool over a shared index | ||
| * counter is all bounded concurrency actually requires here. Used by | ||
| * story mode (`--story`/`--concurrency`, cli.ts) to run several independent, | ||
| * split-out goals faster than `--repeat`'s deliberate strict sequencing, | ||
| * without letting every goal's live LLM calls/browser instances fire at | ||
| * once (a real rate-limit/resource-exhaustion risk `--repeat` avoids by | ||
| * being sequential and this needs a middle ground for instead). */ | ||
| export declare function runWithConcurrency<T>(tasks: (() => Promise<T>)[], concurrency: number): Promise<T[]>; |
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.runWithConcurrency = runWithConcurrency; | ||
| /** Runs `tasks` (each a thunk producing a promise) with at most | ||
| * `concurrency` in flight at once, starting the next queued task as soon as | ||
| * a slot frees up. Results are returned in the same order as `tasks` | ||
| * (matching `Promise.all`'s own contract) regardless of which finishes | ||
| * first. No new npm dependency — matches this project's "zero new deps for | ||
| * any CLI feature so far" record; a plain worker-pool over a shared index | ||
| * counter is all bounded concurrency actually requires here. Used by | ||
| * story mode (`--story`/`--concurrency`, cli.ts) to run several independent, | ||
| * split-out goals faster than `--repeat`'s deliberate strict sequencing, | ||
| * without letting every goal's live LLM calls/browser instances fire at | ||
| * once (a real rate-limit/resource-exhaustion risk `--repeat` avoids by | ||
| * being sequential and this needs a middle ground for instead). */ | ||
| async function runWithConcurrency(tasks, concurrency) { | ||
| const results = new Array(tasks.length); | ||
| let nextIndex = 0; | ||
| async function worker() { | ||
| while (nextIndex < tasks.length) { | ||
| const i = nextIndex++; | ||
| results[i] = await tasks[i](); | ||
| } | ||
| } | ||
| const workerCount = Math.max(1, Math.min(concurrency, tasks.length)); | ||
| await Promise.all(Array.from({ length: workerCount }, () => worker())); | ||
| return results; | ||
| } |
| import type { LlmProvider } from '../llm/types'; | ||
| /** Builds the "split this raw user story into independent goals" prompt — | ||
| * a one-time upfront call, same call-shape class as `buildPlanPrompt` | ||
| * (planner.ts), made once per `--story` run before anything else happens. | ||
| * Deliberately never asks the model to invent new acceptance criteria, only | ||
| * to identify and rewrite the ones already present — the whole point is to | ||
| * preserve the developer's own intent, not add coverage they didn't ask for. */ | ||
| export declare function buildStorySplitPrompt(story: string): string; | ||
| export type ParseStorySplitResult = { | ||
| ok: true; | ||
| goals: string[]; | ||
| } | { | ||
| ok: false; | ||
| error: string; | ||
| raw: string; | ||
| }; | ||
| /** Strictly parses a split response — mirrors `parsePlan`'s shape and | ||
| * "malformed is not fatal" posture exactly, but the fallback here is even | ||
| * simpler: the caller (`splitUserStory` below) treats any parse failure as | ||
| * "this story is just one goal," which is *already* today's entire existing | ||
| * behavior with no story-splitting at all. A caller who opts into `--story` | ||
| * for convenience must never end up worse off than not using it. */ | ||
| export declare function parseStorySplit(raw: string): ParseStorySplitResult; | ||
| /** Splits a raw user story into one or more independent, standalone goals — | ||
| * one LLM call, never fatal. Any failure (a network/provider error, a | ||
| * malformed response, an empty result) degrades to `[story]`: the whole | ||
| * story text treated as a single goal, identical to today's existing | ||
| * behavior with no splitting at all. A response naming more than | ||
| * `HARD_MAX_SCENARIOS` goals is clamped, not rejected outright — same | ||
| * "bound worst-case BYOK cost, disclose when clamped" pattern `--repeat` | ||
| * already established for `HARD_MAX_REPEAT`. */ | ||
| export declare function splitUserStory(story: string, provider: LlmProvider, apiKey: string): Promise<{ | ||
| goals: string[]; | ||
| clamped: boolean; | ||
| }>; |
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.buildStorySplitPrompt = buildStorySplitPrompt; | ||
| exports.parseStorySplit = parseStorySplit; | ||
| exports.splitUserStory = splitUserStory; | ||
| const runLoop_1 = require("./runLoop"); | ||
| /** Builds the "split this raw user story into independent goals" prompt — | ||
| * a one-time upfront call, same call-shape class as `buildPlanPrompt` | ||
| * (planner.ts), made once per `--story` run before anything else happens. | ||
| * Deliberately never asks the model to invent new acceptance criteria, only | ||
| * to identify and rewrite the ones already present — the whole point is to | ||
| * preserve the developer's own intent, not add coverage they didn't ask for. */ | ||
| function buildStorySplitPrompt(story) { | ||
| return [ | ||
| `You are preparing a raw user story (which may bundle several acceptance criteria together) to be tested one scenario at a time by a web/API testing agent.`, | ||
| ``, | ||
| `Identify each distinct, independently-testable scenario described in the story below, and rewrite each one as a single, clear, standalone, actionable goal in an imperative style (e.g. "log in with valid credentials and confirm the dashboard is shown"). Treat mutually-exclusive variations of the same flow (e.g. a valid case and an invalid case) as SEPARATE goals, since they cannot both happen in one run. Never invent a new scenario or criterion that isn't actually described or implied by the story — only identify and clarify what's already there. If the story only really describes one scenario, return just that one goal.`, | ||
| ``, | ||
| `Story:`, | ||
| story, | ||
| ``, | ||
| `Respond with exactly one JSON object: {"goals": ["<goal 1>", "<goal 2>", ...]}`, | ||
| ``, | ||
| `Respond with ONLY the JSON object — no markdown fence, no prose before or after it.`, | ||
| ].join('\n'); | ||
| } | ||
| /** Strictly parses a split response — mirrors `parsePlan`'s shape and | ||
| * "malformed is not fatal" posture exactly, but the fallback here is even | ||
| * simpler: the caller (`splitUserStory` below) treats any parse failure as | ||
| * "this story is just one goal," which is *already* today's entire existing | ||
| * behavior with no story-splitting at all. A caller who opts into `--story` | ||
| * for convenience must never end up worse off than not using it. */ | ||
| function parseStorySplit(raw) { | ||
| const stripped = raw | ||
| .trim() | ||
| .replace(/^```(?:json)?\s*/i, '') | ||
| .replace(/\s*```$/, '') | ||
| .trim(); | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(stripped); | ||
| } | ||
| catch { | ||
| return { ok: false, error: 'split response was not valid JSON', raw }; | ||
| } | ||
| if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.goals)) { | ||
| return { ok: false, error: 'split response was not a JSON object with a "goals" array', raw }; | ||
| } | ||
| const goals = parsed.goals.filter((g) => typeof g === 'string' && g.trim().length > 0).map((g) => g.trim()); | ||
| if (goals.length === 0) | ||
| return { ok: false, error: 'split response contained no non-empty goals', raw }; | ||
| return { ok: true, goals }; | ||
| } | ||
| /** Splits a raw user story into one or more independent, standalone goals — | ||
| * one LLM call, never fatal. Any failure (a network/provider error, a | ||
| * malformed response, an empty result) degrades to `[story]`: the whole | ||
| * story text treated as a single goal, identical to today's existing | ||
| * behavior with no splitting at all. A response naming more than | ||
| * `HARD_MAX_SCENARIOS` goals is clamped, not rejected outright — same | ||
| * "bound worst-case BYOK cost, disclose when clamped" pattern `--repeat` | ||
| * already established for `HARD_MAX_REPEAT`. */ | ||
| async function splitUserStory(story, provider, apiKey) { | ||
| const trimmedStory = story.trim(); | ||
| if (!trimmedStory) | ||
| return { goals: [story], clamped: false }; | ||
| let raw; | ||
| try { | ||
| raw = await provider.complete(buildStorySplitPrompt(trimmedStory), apiKey, { maxOutputTokens: runLoop_1.PLAN_MAX_OUTPUT_TOKENS }); | ||
| } | ||
| catch { | ||
| return { goals: [trimmedStory], clamped: false }; | ||
| } | ||
| const result = parseStorySplit(raw); | ||
| if (!result.ok) | ||
| return { goals: [trimmedStory], clamped: false }; | ||
| if (result.goals.length > runLoop_1.HARD_MAX_SCENARIOS) { | ||
| return { goals: result.goals.slice(0, runLoop_1.HARD_MAX_SCENARIOS), clamped: true }; | ||
| } | ||
| return { goals: result.goals, clamped: false }; | ||
| } |
@@ -16,7 +16,6 @@ import type { ApiAction, ApiHistoryEntry, ApiPlan, SafetyMode } from './apiTypes'; | ||
| * has no tool-calling/JSON-mode hook). Tells the model the allowed | ||
| * methods/hosts and the currently-available `{{var}}` names up front, | ||
| * rather than only rejecting a disallowed attempt after the fact — more | ||
| * turn/token-efficient, matching how `buildActionPrompt` already discloses | ||
| * the confirmation-gating requirement up front instead of only after a | ||
| * rejection. */ | ||
| * methods/hosts, the currently-available `{{var}}` names, and the | ||
| * assertion-count requirement up front, rather than only rejecting a | ||
| * disallowed/premature attempt after the fact — more turn/token-efficient | ||
| * than only discovering either after a rejection. */ | ||
| export declare function buildApiActionPrompt(goal: string, history: ApiHistoryEntry[], validVarNames: ReadonlySet<string>, safety: SafetyMode): string; | ||
@@ -23,0 +22,0 @@ /** Builds the upfront "plan the whole goal" prompt for the API engine — |
+43
-10
@@ -12,2 +12,3 @@ "use strict"; | ||
| const apiExecutor_1 = require("./apiExecutor"); | ||
| const planner_1 = require("./planner"); | ||
| const MAX_HISTORY = 10; | ||
@@ -63,11 +64,41 @@ /** Shared with `apiFailureReport.ts` — one place describing what an | ||
| * has no tool-calling/JSON-mode hook). Tells the model the allowed | ||
| * methods/hosts and the currently-available `{{var}}` names up front, | ||
| * rather than only rejecting a disallowed attempt after the fact — more | ||
| * turn/token-efficient, matching how `buildActionPrompt` already discloses | ||
| * the confirmation-gating requirement up front instead of only after a | ||
| * rejection. */ | ||
| * methods/hosts, the currently-available `{{var}}` names, and the | ||
| * assertion-count requirement up front, rather than only rejecting a | ||
| * disallowed/premature attempt after the fact — more turn/token-efficient | ||
| * than only discovering either after a rejection. */ | ||
| function buildApiActionPrompt(goal, history, validVarNames, safety) { | ||
| // Unconditional — not gated on the goal's own wording containing | ||
| // confirm/verify/etc. language. Mirrors planner.ts's buildActionPrompt | ||
| // exactly (added at the same time, for the same live-found reason — see | ||
| // countConfirmationClauses' own doc comment): apiRunner.ts requires this | ||
| // many successful assert_status/assert_json_path_exists/ | ||
| // assert_json_path_equals actions before accepting goal-reached, | ||
| // regardless of whether the goal text says "confirm"/"verify" at all. | ||
| const requiredAssertionCount = Math.max(1, (0, planner_1.countConfirmationClauses)(goal)); | ||
| const confirmationNote = [ | ||
| ``, | ||
| `You must perform at least ${requiredAssertionCount} successful assert_status, assert_json_path_exists, or assert_json_path_equals action(s) before you're allowed to declare "done" with outcome "goal-reached" — this applies even if the goal above doesn't explicitly say "confirm"/"verify": a claimed success must always be backed by a real check, never just your own belief that you're finished. Declaring done without enough successful assertions will be rejected.`, | ||
| ]; | ||
| // API-engine mirror of planner.ts's identical guard — see its own doc | ||
| // comment for the live-found reasoning (the browser-engine version of | ||
| // this exact gap: a persistent, always-true page element satisfying the | ||
| // confirmation requirement above without proving anything). The API | ||
| // analog is asserting against the wrong *response* — e.g. a generic | ||
| // health-check-style endpoint, or a read that happened before the | ||
| // request the goal is actually about — rather than the one response | ||
| // that would only look this way if the goal's real action succeeded. | ||
| // Guidance only, same as planner.ts's version: nothing here rejects a | ||
| // technically-valid-but-unhelpful assertion mechanically. | ||
| const assertionQualityNote = [ | ||
| ``, | ||
| `When choosing what to assert, check the response from the request that actually reflects the goal's own outcome — not an unrelated or incidental response (e.g. a generic health-check-style endpoint, or an earlier read that happened before the action you're trying to verify) that would look the same regardless of whether your real action succeeded.`, | ||
| ]; | ||
| const varsNote = validVarNames.size > 0 | ||
| ? [``, `Values saved so far, referenceable as {{name}} in a request's url/headers/body: ${[...validVarNames].join(', ')}`] | ||
| : []; | ||
| // Same cache-friendly ordering as planner.ts's buildActionPrompt (see its | ||
| // own doc comment for the full reasoning): static-across-every-turn | ||
| // content (opener, goal, describeSafetyMode, confirmationNote) first, | ||
| // per-turn-dynamic content (varsNote, history) last, with a short closing | ||
| // reminder to recover the "recency effect" the reordering trades away. | ||
| return [ | ||
@@ -77,9 +108,7 @@ `You are an API testing agent driving real HTTP requests toward one goal, one request/check at a time.`, | ||
| `Goal: ${goal}`, | ||
| ...confirmationNote, | ||
| ...assertionQualityNote, | ||
| ``, | ||
| describeSafetyMode(safety), | ||
| ...varsNote, | ||
| ``, | ||
| `Steps taken so far:`, | ||
| serializeApiHistory(history), | ||
| ``, | ||
| `Respond with exactly one JSON object describing the next single action to take, one of:`, | ||
@@ -93,4 +122,8 @@ `- {"action":"request","method":"<GET|HEAD|OPTIONS|POST|PUT|PATCH|DELETE>","url":"<url>","headers":{...},"body":"<text>","saveAs":{"name":"<var>","path":"<json.path>"},"reason":"<why>"} ("headers"/"body"/"saveAs" are all optional)`, | ||
| `Example: ${ACTION_SCHEMA_EXAMPLE}`, | ||
| ...varsNote, | ||
| ``, | ||
| `Respond with ONLY the JSON object — no markdown fence, no prose before or after it.`, | ||
| `Steps taken so far:`, | ||
| serializeApiHistory(history), | ||
| ``, | ||
| `Respond with ONLY the JSON object matching one of the schemas above — no markdown fence, no prose before or after it.`, | ||
| ].join('\n'); | ||
@@ -97,0 +130,0 @@ } |
@@ -7,2 +7,3 @@ "use strict"; | ||
| const apiFailureReport_1 = require("./apiFailureReport"); | ||
| const runLoop_1 = require("./runLoop"); | ||
| /** API-engine counterpart to `rootCause.ts`'s `buildRootCausePrompt` — see | ||
@@ -63,3 +64,3 @@ * that file's doc comment for the full exposure-fix reasoning this mirrors. | ||
| try { | ||
| const text = await provider.complete(prompt, apiKey); | ||
| const text = await provider.complete(prompt, apiKey, { maxOutputTokens: runLoop_1.ROOT_CAUSE_MAX_OUTPUT_TOKENS }); | ||
| return text.trim() || '(the LLM returned an empty response for this analysis)'; | ||
@@ -66,0 +67,0 @@ } |
@@ -34,5 +34,11 @@ import type { LlmProvider } from '../llm/types'; | ||
| /** Same meaning as `RunAgentOptions.useStructuredPlan` — see its own doc | ||
| * comment. Default false; the ordinary fully-adaptive loop is completely | ||
| * unchanged unless this is set. */ | ||
| * comment, including the "default false at this engine layer, `cli.ts` | ||
| * defaults it to true for the `api` command, MCP never sets it" nuance. | ||
| * The ordinary fully-adaptive loop is completely unchanged unless this is | ||
| * set. */ | ||
| useStructuredPlan?: boolean; | ||
| /** Same meaning as `RunAgentOptions.useFastSteps` — see its own doc | ||
| * comment. Default false, opt-in via `--fast-steps`, only affects the | ||
| * per-turn live action-decision call, never the upfront plan call. */ | ||
| useFastSteps?: boolean; | ||
| } | ||
@@ -52,3 +58,6 @@ /** Orchestrates the API-testing agentic loop — the same single-shot, | ||
| * is rebuilt fresh every turn for browser actions) — a value saved in step | ||
| * 2 must still resolve in step 8. */ | ||
| * 2 must still resolve in step 8. | ||
| * | ||
| * Same unconditional-floor-of-1 assertion requirement as `runAgent` — see | ||
| * its own doc comment for the full reasoning. */ | ||
| export declare function runApiTest(options: RunApiTestOptions): Promise<ApiTestRun>; |
@@ -69,8 +69,16 @@ "use strict"; | ||
| * is rebuilt fresh every turn for browser actions) — a value saved in step | ||
| * 2 must still resolve in step 8. */ | ||
| * 2 must still resolve in step 8. | ||
| * | ||
| * Same unconditional-floor-of-1 assertion requirement as `runAgent` — see | ||
| * its own doc comment for the full reasoning. */ | ||
| async function runApiTest(options) { | ||
| const runId = (0, runLoop_1.makeRunId)(); | ||
| const maxSteps = Math.min(options.maxSteps ?? runLoop_1.DEFAULT_MAX_STEPS, runLoop_1.HARD_MAX_STEPS); | ||
| const requiredConfirmationCount = (0, planner_1.countConfirmationClauses)(options.goal); | ||
| const needsConfirmation = requiredConfirmationCount > 0; | ||
| // Unconditional floor of 1, not just when the goal's own wording asks | ||
| // for it — see runner.ts's identical fix/doc comment for the real, | ||
| // live-found bug this closes (a goal with zero confirm/verify language | ||
| // got a false goal-reached claim on a real production site). | ||
| // countConfirmationClauses still raises the floor above 1 when the goal | ||
| // explicitly asks for more than one check. | ||
| const requiredConfirmationCount = Math.max(1, (0, planner_1.countConfirmationClauses)(options.goal)); | ||
| const cookieJar = new apiExecutor_1.CookieJar(); | ||
@@ -120,3 +128,3 @@ if (options.storageState) | ||
| try { | ||
| const planRaw = await options.provider.complete((0, apiPlanner_1.buildApiPlanPrompt)(options.goal, options.safety), options.apiKey); | ||
| const planRaw = await options.provider.complete((0, apiPlanner_1.buildApiPlanPrompt)(options.goal, options.safety), options.apiKey, { maxOutputTokens: runLoop_1.PLAN_MAX_OUTPUT_TOKENS }); | ||
| const parsedPlan = (0, apiPlanner_1.parseApiPlan)(planRaw); | ||
@@ -216,3 +224,3 @@ if (parsedPlan.ok) | ||
| try { | ||
| raw = await options.provider.complete(prompt, options.apiKey); | ||
| raw = await options.provider.complete(prompt, options.apiKey, { maxOutputTokens: runLoop_1.API_ACTION_MAX_OUTPUT_TOKENS, fastPath: options.useFastSteps }); | ||
| } | ||
@@ -284,3 +292,3 @@ catch (err) { | ||
| lastActionMadeProgress = false; | ||
| const unverifiedSuccess = action.outcome === 'goal-reached' && needsConfirmation && (!hasSucceededAssertion || succeededAssertionCount < requiredConfirmationCount); | ||
| const unverifiedSuccess = action.outcome === 'goal-reached' && (!hasSucceededAssertion || succeededAssertionCount < requiredConfirmationCount); | ||
| if (unverifiedSuccess) { | ||
@@ -287,0 +295,0 @@ history.push({ |
@@ -85,2 +85,16 @@ import type { PageOutline, AgentAction } from './types'; | ||
| healedSelector?: string; | ||
| /** Present only when `verifyRoleLocator()` confirmed, live, that | ||
| * `page.getByRole(role, { name })` resolves uniquely to the exact same | ||
| * element the (guaranteed-correct) `selector`/`healedSelector` above | ||
| * targeted — never used for the live action itself (which always uses | ||
| * `selector`/`healedSelector`, completely unchanged by this field's | ||
| * presence), only as `generateSpec.ts`'s preferred choice for what to | ||
| * emit in the *exported* spec, since `getByRole` is far more resilient | ||
| * to future DOM restructuring than a positional CSS path. See | ||
| * `verifyRoleLocator`'s own doc comment for why this needs a real, | ||
| * live check rather than a static assumption. */ | ||
| verifiedRoleLocator?: { | ||
| role: string; | ||
| name: string; | ||
| }; | ||
| /** Present only for a `scroll` action — whether the page's scroll | ||
@@ -87,0 +101,0 @@ * position actually changed. `runner.ts` uses this (not just `ok`) to |
@@ -394,2 +394,61 @@ "use strict"; | ||
| const ASSERT_WAIT_MS = 5000; | ||
| /** Live verification, not a static assumption, that `page.getByRole(role, | ||
| * { name })` resolves — uniquely — to the *exact same* element already | ||
| * resolved via `computeSelector()`'s guaranteed-correct preference order | ||
| * (testId → id → positional CSS path, completely unchanged by this | ||
| * function). Only ever changes what `generateSpec.ts` *prefers to emit* | ||
| * in the exported spec — never the live run's own action, which always | ||
| * continues to use the already-resolved selector regardless of this | ||
| * function's result. | ||
| * | ||
| * Exists because a *computed* role/name pair can't always round-trip back | ||
| * into a valid `getByRole` call (`accessibleName()`'s computation has real | ||
| * edge cases this codebase doesn't fully reimplement — see its own doc | ||
| * comment) — rather than assume it round-trips, this checks it for real, | ||
| * against the real page, at the exact moment it matters, closing that | ||
| * exact gap instead of guessing around it. | ||
| * | ||
| * `includeHidden: true` is deliberately broader than a plain `getByRole` | ||
| * call (which excludes elements outside the accessibility tree) — this | ||
| * makes the uniqueness check *stricter* than what a later replay will | ||
| * actually see (a hidden sibling with the same role/name that a real | ||
| * replay, without `includeHidden: true`, would never even consider), so | ||
| * this can only ever under-use the feature (fall back to the existing | ||
| * selector) on an edge case, never produce a false positive. It also means | ||
| * this can run | ||
| * once, uniformly, right after the target element is resolved — before | ||
| * any assert_visible poll or click/fill mutation — without needing to | ||
| * wait for a not-yet-visible element to become visible first. | ||
| * | ||
| * Substring match (`exact: false`), not exact — `accessibleName()` (above) | ||
| * truncates `textContent` to 80 chars, so an exact match would silently | ||
| * fail on exactly the long product-title links this feature exists for. | ||
| * Uniqueness is still enforced via `count() === 1`. Identity is confirmed | ||
| * via Playwright's own documented handle-equality pattern | ||
| * (`page.evaluate(([a, b]) => a === b, [...])`), not a DOM-mutation marker | ||
| * — writing a temporary attribute to verify identity risks tripping a | ||
| * real site's own analytics/MutationObserver, an unacceptable side effect | ||
| * for a verification-only check that must never alter the page. | ||
| * | ||
| * Never throws, and never more than ~1s of real cost — any failure here | ||
| * (an unrecognized role string, a detached handle, a timeout) must never | ||
| * be mistaken for a failure of the actual action; always resolves to | ||
| * `undefined` on anything but a clean, unique, confirmed match. */ | ||
| async function verifyRoleLocator(page, role, name, targetHandle) { | ||
| if (!name) | ||
| return undefined; | ||
| try { | ||
| const candidate = page.getByRole(role, { name, exact: false, includeHidden: true }); | ||
| if ((await candidate.count()) !== 1) | ||
| return undefined; | ||
| const candidateHandle = await candidate.elementHandle({ timeout: 1000 }); | ||
| if (!candidateHandle) | ||
| return undefined; | ||
| const same = await page.evaluate(([a, b]) => a === b, [targetHandle, candidateHandle]); | ||
| return same ? { role, name } : undefined; | ||
| } | ||
| catch { | ||
| return undefined; | ||
| } | ||
| } | ||
| /** Executes one already-parsed, already-ref-validated action against the | ||
@@ -515,2 +574,14 @@ * real page. Any Playwright-level failure (element detached, navigation | ||
| const locator = page.locator(el.selector).first(); | ||
| // Computed once, here, before any type-specific logic below (including | ||
| // click/fill's own mutation) — see `verifyRoleLocator`'s own doc comment | ||
| // for why `includeHidden: true` makes this safe to do uniformly for | ||
| // every action type, regardless of whether the target is visible yet. | ||
| // Best-effort: an element that isn't attached at all yet (a legitimate, | ||
| // common state for assert_visible/assert_text, whose own polling below | ||
| // is what's responsible for waiting it out) just resolves to `undefined` | ||
| // here, same as any other "can't verify" case. | ||
| const verifiedRoleLocator = await locator | ||
| .elementHandle({ timeout: 2000 }) | ||
| .then((handle) => (handle ? verifyRoleLocator(page, el.role, el.name, handle) : undefined)) | ||
| .catch(() => undefined); | ||
| if (action.action === 'assert_visible') { | ||
@@ -526,3 +597,3 @@ // Polls, rather than a single instantaneous check — matches | ||
| await locator.waitFor({ state: 'visible', timeout: ASSERT_WAIT_MS }); | ||
| return { ok: true }; | ||
| return { ok: true, verifiedRoleLocator }; | ||
| } | ||
@@ -550,3 +621,3 @@ catch { | ||
| if (lastText.includes(expected)) | ||
| return { ok: true }; | ||
| return { ok: true, verifiedRoleLocator }; | ||
| } | ||
@@ -594,3 +665,3 @@ catch { | ||
| await attempt(el.selector); | ||
| return { ok: true }; | ||
| return { ok: true, verifiedRoleLocator }; | ||
| } | ||
@@ -637,3 +708,17 @@ catch (err) { | ||
| await attempt(healedSelector); | ||
| return { ok: true, healed: true, healedSelector }; | ||
| // Re-verified against the healed candidate specifically — the | ||
| // original `verifiedRoleLocator` above was computed against the now- | ||
| // stale element (0 matches), so it says nothing about this different, | ||
| // freshly re-matched DOM node. `candidates[0]`'s role/name are | ||
| // identical to `el`'s by construction (the healing filter above only | ||
| // accepts a candidate with the exact same tag/role/name), but the | ||
| // *element* itself is different, so the identity check must be redone | ||
| // against a real handle for it. | ||
| const healedVerifiedRoleLocator = await page | ||
| .locator(healedSelector) | ||
| .first() | ||
| .elementHandle({ timeout: 2000 }) | ||
| .then((handle) => (handle ? verifyRoleLocator(page, candidates[0].role, candidates[0].name, handle) : undefined)) | ||
| .catch(() => undefined); | ||
| return { ok: true, healed: true, healedSelector, verifiedRoleLocator: healedVerifiedRoleLocator }; | ||
| } | ||
@@ -640,0 +725,0 @@ catch (healErr) { |
@@ -23,2 +23,19 @@ "use strict"; | ||
| } | ||
| /** The Playwright locator expression a generated step should use — prefers | ||
| * `page.getByRole(role, { name })` when `browser.ts`'s `verifyRoleLocator()` | ||
| * confirmed live, during the actual run, that it resolves uniquely to the | ||
| * exact element this step acted on (see `ExecutedStep.verifiedRoleLocator`'s | ||
| * own doc comment for the full reasoning); falls back to the raw, always- | ||
| * correct `page.locator(selector)` otherwise — the exact, unchanged | ||
| * behavior from before this feature. A `getByRole` locator is far more | ||
| * resilient to future DOM restructuring than a positional CSS path, since | ||
| * the whole point of a generated spec is to be re-run standalone, for a | ||
| * long time, with no five46 involved. */ | ||
| function locatorExprFor(step, selector) { | ||
| if (step.verifiedRoleLocator) { | ||
| const { role, name } = step.verifiedRoleLocator; | ||
| return `page.getByRole(${JSON.stringify(role)}, { name: ${JSON.stringify(name)} })`; | ||
| } | ||
| return `page.locator(${JSON.stringify(selector)})`; | ||
| } | ||
| // `%` has no special meaning in a regex, so the placeholder tokens | ||
@@ -82,16 +99,16 @@ // (`%%USERNAME%%`/`%%PASSWORD%%`) need no escaping to be used literally here. | ||
| const action = step.action; | ||
| const sel = JSON.stringify(selector); | ||
| const locatorExpr = locatorExprFor(step, selector); | ||
| switch (action.action) { | ||
| case 'click': | ||
| return ` await page.locator(${sel}).click()`; | ||
| return ` await ${locatorExpr}.click()`; | ||
| case 'fill': { | ||
| const lines = [` await page.locator(${sel}).fill(${renderCredentialAwareExpression(action.value)})`]; | ||
| const lines = [` await ${locatorExpr}.fill(${renderCredentialAwareExpression(action.value)})`]; | ||
| if (action.submit) | ||
| lines.push(` await page.locator(${sel}).press('Enter')`); | ||
| lines.push(` await ${locatorExpr}.press('Enter')`); | ||
| return lines.join('\n'); | ||
| } | ||
| case 'assert_visible': | ||
| return ` await expect(page.locator(${sel})).toBeVisible()`; | ||
| return ` await expect(${locatorExpr}).toBeVisible()`; | ||
| case 'assert_text': | ||
| return ` await expect(page.locator(${sel})).toContainText(${renderCredentialAwareExpression(action.expectedText)})`; | ||
| return ` await expect(${locatorExpr}).toContainText(${renderCredentialAwareExpression(action.expectedText)})`; | ||
| } | ||
@@ -98,0 +115,0 @@ } |
+18
-21
@@ -26,25 +26,22 @@ import type { AgentAction, AgentPlan, CredentialAvailability, HistoryEntry, OutlineElement, PageOutline, PlannedStep, PlannedStepTarget } from './types'; | ||
| export declare function serializeHistory(history: HistoryEntry[]): string; | ||
| /** Naming-heuristic detection of "this goal asks for verification," not | ||
| * real NLP — a goal phrased with confirm/verify/ensure/etc. language is | ||
| * asking to be *checked*, not just *done*. Found via a real, live run | ||
| * (a Shopify demo store, goal: "...then confirm the cart shows an item"): | ||
| * the model added a real item to the cart, then declared `goal-reached` | ||
| * without ever calling `assert_visible`/`assert_text` — the outcome | ||
| * happened to be correct, but nothing in the run actually verified it, and | ||
| * the goal explicitly asked for verification. `runner.ts` uses this to | ||
| * require at least one successful assertion before accepting a | ||
| * `goal-reached` claim for a goal shaped this way — same "don't accept an | ||
| * unverified confident claim" posture as everywhere else in this project. */ | ||
| export declare function requiresConfirmation(goal: string): boolean; | ||
| /** How many confirm/verify/ensure/etc. occurrences the goal's own text | ||
| * contains — the same heuristic `requiresConfirmation` uses, just counted | ||
| * instead of merely tested for. Found via a real, live run | ||
| * contains — a naming-heuristic, not real NLP. Originally only used to | ||
| * detect "this goal asks for verification" (via a `requiresConfirmation()` | ||
| * boolean wrapper, since removed) and gate the assertion requirement on | ||
| * that; `runner.ts`/`apiRunner.ts` now use `Math.max(1, ...)` of this count | ||
| * as an *unconditional* floor instead — found via a real, live run (a | ||
| * Shopify demo store, goal: "...then confirm the cart shows an item") that | ||
| * a goal explicitly asking for verification could still have its | ||
| * `goal-reached` claim accepted with zero assertions ever performed, and | ||
| * later, via live testing against real production sites (Flipkart, | ||
| * Amazon.in), that a goal with *no* confirm-language at all got the exact | ||
| * same false-success failure mode, since the count was simply 0 for it — | ||
| * see DEVELOPMENT.md's "Known limitations" section for the full evidence. | ||
| * Also used as a *multi*-clause floor: found via a real, live run | ||
| * (automationintesting.online: "...confirm the price is shown, then click | ||
| * Next, confirm the calendar changed") that `hasSucceededAssertion`'s own | ||
| * "reset when stale" fix (see runner.ts) did not catch: the model jumped | ||
| * straight to one, perfectly *fresh* assertion for the *second* clause and | ||
| * never attempted the first at all. A single boolean has no way to know | ||
| * the goal asked for two checks, not one — `runner.ts`/`apiRunner.ts` use | ||
| * this count as a required minimum number of successful assertions across | ||
| * the whole run, on top of (not instead of) the existing freshness check. */ | ||
| * "reset when stale" fix (see runner.ts) did not catch a model jumping | ||
| * straight to one, perfectly *fresh* assertion for only the *second* | ||
| * clause, skipping the first entirely — a single required-count-of-1 has | ||
| * no way to know the goal asked for two checks, not one. */ | ||
| export declare function countConfirmationClauses(goal: string): number; | ||
@@ -54,3 +51,3 @@ /** True if `name` (an `OutlineElement.name` — the clicked element's | ||
| * match, case-insensitive — same "documented, honest, imperfect proxy" class | ||
| * of heuristic as `requiresConfirmation`, not real NLP. */ | ||
| * of heuristic as `countConfirmationClauses`, not real NLP. */ | ||
| export declare function isDestructiveClickTarget(name: string): boolean; | ||
@@ -57,0 +54,0 @@ /** Builds the "what's the next single action" prompt — the only shape the |
+76
-42
@@ -6,3 +6,2 @@ "use strict"; | ||
| exports.serializeHistory = serializeHistory; | ||
| exports.requiresConfirmation = requiresConfirmation; | ||
| exports.countConfirmationClauses = countConfirmationClauses; | ||
@@ -88,27 +87,22 @@ exports.isDestructiveClickTarget = isDestructiveClickTarget; | ||
| const CONFIRMATION_PATTERN_GLOBAL = new RegExp(CONFIRMATION_PATTERN.source, 'gi'); | ||
| /** Naming-heuristic detection of "this goal asks for verification," not | ||
| * real NLP — a goal phrased with confirm/verify/ensure/etc. language is | ||
| * asking to be *checked*, not just *done*. Found via a real, live run | ||
| * (a Shopify demo store, goal: "...then confirm the cart shows an item"): | ||
| * the model added a real item to the cart, then declared `goal-reached` | ||
| * without ever calling `assert_visible`/`assert_text` — the outcome | ||
| * happened to be correct, but nothing in the run actually verified it, and | ||
| * the goal explicitly asked for verification. `runner.ts` uses this to | ||
| * require at least one successful assertion before accepting a | ||
| * `goal-reached` claim for a goal shaped this way — same "don't accept an | ||
| * unverified confident claim" posture as everywhere else in this project. */ | ||
| function requiresConfirmation(goal) { | ||
| return countConfirmationClauses(goal) > 0; | ||
| } | ||
| /** How many confirm/verify/ensure/etc. occurrences the goal's own text | ||
| * contains — the same heuristic `requiresConfirmation` uses, just counted | ||
| * instead of merely tested for. Found via a real, live run | ||
| * contains — a naming-heuristic, not real NLP. Originally only used to | ||
| * detect "this goal asks for verification" (via a `requiresConfirmation()` | ||
| * boolean wrapper, since removed) and gate the assertion requirement on | ||
| * that; `runner.ts`/`apiRunner.ts` now use `Math.max(1, ...)` of this count | ||
| * as an *unconditional* floor instead — found via a real, live run (a | ||
| * Shopify demo store, goal: "...then confirm the cart shows an item") that | ||
| * a goal explicitly asking for verification could still have its | ||
| * `goal-reached` claim accepted with zero assertions ever performed, and | ||
| * later, via live testing against real production sites (Flipkart, | ||
| * Amazon.in), that a goal with *no* confirm-language at all got the exact | ||
| * same false-success failure mode, since the count was simply 0 for it — | ||
| * see DEVELOPMENT.md's "Known limitations" section for the full evidence. | ||
| * Also used as a *multi*-clause floor: found via a real, live run | ||
| * (automationintesting.online: "...confirm the price is shown, then click | ||
| * Next, confirm the calendar changed") that `hasSucceededAssertion`'s own | ||
| * "reset when stale" fix (see runner.ts) did not catch: the model jumped | ||
| * straight to one, perfectly *fresh* assertion for the *second* clause and | ||
| * never attempted the first at all. A single boolean has no way to know | ||
| * the goal asked for two checks, not one — `runner.ts`/`apiRunner.ts` use | ||
| * this count as a required minimum number of successful assertions across | ||
| * the whole run, on top of (not instead of) the existing freshness check. */ | ||
| * "reset when stale" fix (see runner.ts) did not catch a model jumping | ||
| * straight to one, perfectly *fresh* assertion for only the *second* | ||
| * clause, skipping the first entirely — a single required-count-of-1 has | ||
| * no way to know the goal asked for two checks, not one. */ | ||
| function countConfirmationClauses(goal) { | ||
@@ -148,3 +142,3 @@ return (goal.match(CONFIRMATION_PATTERN_GLOBAL) || []).length; | ||
| * match, case-insensitive — same "documented, honest, imperfect proxy" class | ||
| * of heuristic as `requiresConfirmation`, not real NLP. */ | ||
| * of heuristic as `countConfirmationClauses`, not real NLP. */ | ||
| function isDestructiveClickTarget(name) { | ||
@@ -198,11 +192,29 @@ const lower = name.toLowerCase(); | ||
| : []; | ||
| const confirmationNote = requiresConfirmation(goal) | ||
| ? [ | ||
| ``, | ||
| `This goal asks you to confirm/verify something — you must perform at least one`, | ||
| `successful assert_visible, assert_text, or assert_page_text action before you're`, | ||
| `allowed to declare "done" with outcome "goal-reached". Declaring done without one`, | ||
| `will be rejected.`, | ||
| ] | ||
| : []; | ||
| // Unconditional — not gated on the goal's own wording containing | ||
| // confirm/verify/etc. language. Disclosed up front for the same reason | ||
| // as deleteNote below: telling the model before it wastes a turn is | ||
| // more efficient than only rejecting a blind attempt after the fact. See | ||
| // countConfirmationClauses' own doc comment for the live evidence this | ||
| // floor-of-1 default closes (a goal with zero confirm-language used to | ||
| // get zero forced verification at all). | ||
| const requiredAssertionCount = Math.max(1, countConfirmationClauses(goal)); | ||
| const confirmationNote = [ | ||
| ``, | ||
| `You must perform at least ${requiredAssertionCount} successful assert_visible, assert_text, or assert_page_text action(s) before you're allowed to declare "done" with outcome "goal-reached" — this applies even if the goal above doesn't explicitly say "confirm"/"verify": a claimed success must always be backed by a real check, never just your own belief that you're finished. Declaring done without enough successful assertions will be rejected.`, | ||
| ]; | ||
| // Generalizes the existing tautology guard (never re-assert the exact | ||
| // element you just clicked/filled, in the assert_visible schema line | ||
| // below) to the broader case a real live run against a production site | ||
| // (Flipkart) exposed: the model satisfied the confirmation requirement | ||
| // above by asserting a completely unrelated, always-present element (a | ||
| // site-wide header link reading "Login," true on every single page | ||
| // regardless of whether any of its prior actions actually worked), then | ||
| // never completed the rest of the goal. Nothing rejects this | ||
| // mechanically (unlike the confirmationNote count above) — this is | ||
| // guidance only, since "was this assertion meaningful" is a real | ||
| // semantic judgment, not something a simple rule can verify. | ||
| const assertionQualityNote = [ | ||
| ``, | ||
| `When choosing what to assert, avoid a persistent/ambient element that would already be visible or true regardless of whether your actions actually worked — a site-wide header/nav/footer label, a page title, a generic link that's present on every page. That kind of assertion can pass even when nothing you did actually mattered. Prefer something that specifically changed, newly appeared, or only exists because of the actions you just took.`, | ||
| ]; | ||
| // Disclosed up front, same reasoning as apiPlanner.ts's describeSafetyMode: | ||
@@ -228,2 +240,16 @@ // telling the model before it wastes a turn is more efficient than only | ||
| : []; | ||
| // Ordered so a maximal, byte-identical prefix (the opening sentence, goal, | ||
| // per-run-static notes, and the full schema/instructions block) comes | ||
| // BEFORE anything that changes every turn (history, outline, planNote) — | ||
| // this is deliberate, not incidental. Every provider-native prompt/context | ||
| // caching mechanism (OpenAI/Gemini automatic, Groq automatic where | ||
| // supported, Anthropic's explicit cache_control) works by matching a | ||
| // request's leading prefix against a recently-processed one; putting | ||
| // per-turn-dynamic content in the middle (as an earlier version of this | ||
| // function did) meant the "prefix" never stayed identical past turn one, | ||
| // so none of that ~600-token static block below was ever actually | ||
| // cacheable, despite being identical on every single turn of every run. | ||
| // See DEVELOPMENT.md's "Reordering prompts for provider-native caching" | ||
| // section for the full reasoning, including why this isn't a free lunch | ||
| // for instruction-following (see the closing reminder line below). | ||
| return [ | ||
@@ -234,12 +260,6 @@ `You are a web testing agent driving a real browser toward one goal, one action at a time.`, | ||
| ...confirmationNote, | ||
| ...assertionQualityNote, | ||
| ...deleteNote, | ||
| ...credentialNote, | ||
| ...planNote, | ||
| ``, | ||
| `Steps taken so far:`, | ||
| serializeHistory(history), | ||
| ``, | ||
| `Elements currently visible on the page (pick a ref from this list only — you cannot act on anything not listed here):`, | ||
| serializeOutline(outline), | ||
| ``, | ||
| `Respond with exactly one JSON object describing the next single action to take, one of:`, | ||
@@ -256,4 +276,18 @@ `- {"action":"click","ref":"<ref>","reason":"<why>"}`, | ||
| `Example: ${ACTION_SCHEMA_EXAMPLE}`, | ||
| ...planNote, | ||
| ``, | ||
| `Respond with ONLY the JSON object — no markdown fence, no prose before or after it.`, | ||
| `Steps taken so far:`, | ||
| serializeHistory(history), | ||
| ``, | ||
| `Elements currently visible on the page (pick a ref from this list only — you cannot act on anything not listed here):`, | ||
| serializeOutline(outline), | ||
| ``, | ||
| // A short, cheap repeat of the format instruction, deliberately kept | ||
| // right before generation — moving the full instructions block earlier | ||
| // trades away the "recency effect" (an instruction immediately | ||
| // preceding generation is followed more reliably), so this recovers | ||
| // that reliability for the one instruction most worth repeating, | ||
| // without undoing the reordering above (the bulk of the static block | ||
| // still sits in the cacheable prefix). | ||
| `Respond with ONLY the JSON object matching one of the schemas above — no markdown fence, no prose before or after it.`, | ||
| ].join('\n'); | ||
@@ -393,3 +427,3 @@ } | ||
| // immediate goal-reached with zero real actions is the same category of | ||
| // ungrounded claim the live loop's own requiresConfirmation/ | ||
| // ungrounded claim the live loop's own countConfirmationClauses/ | ||
| // hasSucceededAssertion check already refuses to accept. | ||
@@ -396,0 +430,0 @@ if (steps.every((s) => s.action === 'done')) { |
@@ -7,2 +7,3 @@ "use strict"; | ||
| const failureReport_1 = require("./failureReport"); | ||
| const runLoop_1 = require("./runLoop"); | ||
| /** Builds the "why did this fail" prompt from *only* what the LLM already | ||
@@ -78,3 +79,3 @@ * saw during the run — the failed step's own outline (`serializeOutline`, | ||
| try { | ||
| const text = await provider.complete(prompt, apiKey); | ||
| const text = await provider.complete(prompt, apiKey, { maxOutputTokens: runLoop_1.ROOT_CAUSE_MAX_OUTPUT_TOKENS }); | ||
| return text.trim() || '(the LLM returned an empty response for this analysis)'; | ||
@@ -81,0 +82,0 @@ } |
@@ -17,2 +17,39 @@ /** Shared between `runner.ts` (browser engine) and `apiRunner.ts` (API | ||
| export declare const HARD_MAX_REPEAT = 10; | ||
| /** Hard cap on the number of scenarios `storySplitter.ts` will split a single | ||
| * `--story` into, regardless of what the split-LLM-call returns — bounds | ||
| * worst-case BYOK cost the same way `HARD_MAX_REPEAT` bounds `--repeat`'s (each | ||
| * scenario is a whole extra LLM-driven run), disclosed explicitly when it clamps. */ | ||
| export declare const HARD_MAX_SCENARIOS = 10; | ||
| /** Bounded-concurrency defaults for running a `--story`'s split-out scenarios. | ||
| * Default 3 is faster in aggregate than `--repeat`'s strict sequencing without | ||
| * risking a live-LLM rate-limit storm or exhausting local resources (many real | ||
| * headless browser instances at once); hard-capped at 5 regardless of what a | ||
| * caller passes, mirroring `HARD_MAX_STEPS`/`HARD_MAX_REPEAT`'s own clamps. */ | ||
| export declare const DEFAULT_CONCURRENCY = 3; | ||
| export declare const HARD_MAX_CONCURRENCY = 5; | ||
| /** Output-token caps per LLM call type — see DEVELOPMENT.md's "Bounding LLM | ||
| * output tokens" section for the sizing rationale. A too-generous cap costs | ||
| * nothing (providers stop naturally at a real end-of-response); a too-tight | ||
| * one risks truncating a legitimate response mid-JSON, which fails parsing | ||
| * exactly like any other unparseable response and is NOT caught by any | ||
| * provider's empty-completion diagnostics (those only fire on a genuinely | ||
| * empty completion, not a truncated-but-non-empty one). Chosen with real | ||
| * margin above the actual prompt schemas (planner.ts/apiPlanner.ts), not | ||
| * guessed — see planner.test.ts's sizing sanity check for PLAN_MAX_OUTPUT_TOKENS. */ | ||
| /** Raised from an original 400 after a real live failure: even with Gemini's | ||
| * `thinkingBudget: 1` (gemini.ts), thinking-token usage still has real | ||
| * variance on a realistic prompt (33-60+ tokens observed, confirmed via | ||
| * direct calls) — occasionally enough to truncate a legitimate response mid-JSON | ||
| * at 400. Raised to match the shared 1024 fallback every provider already | ||
| * uses when a caller omits maxOutputTokens entirely, removing the | ||
| * Gemini-fragile special case rather than tuning it tighter. */ | ||
| export declare const ACTION_MAX_OUTPUT_TOKENS = 1024; | ||
| /** Larger than the browser engine's action cap: an API `request` action can | ||
| * carry a real request body (e.g. a multi-field JSON payload for a POST), | ||
| * meaningfully bigger than a browser action's `{"action":"click","ref":"e3",...}`. */ | ||
| export declare const API_ACTION_MAX_OUTPUT_TOKENS = 800; | ||
| /** Covers an upfront plan of up to HARD_MAX_STEPS (50) step objects. */ | ||
| export declare const PLAN_MAX_OUTPUT_TOKENS = 4096; | ||
| /** A "1-3 sentence hypothesis" plus a short suggestion — see rootCause.ts. */ | ||
| export declare const ROOT_CAUSE_MAX_OUTPUT_TOKENS = 600; | ||
| export declare function makeRunId(): string; |
@@ -8,3 +8,3 @@ "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.HARD_MAX_REPEAT = exports.HARD_MAX_STEPS = exports.DEFAULT_MAX_STEPS = void 0; | ||
| exports.ROOT_CAUSE_MAX_OUTPUT_TOKENS = exports.PLAN_MAX_OUTPUT_TOKENS = exports.API_ACTION_MAX_OUTPUT_TOKENS = exports.ACTION_MAX_OUTPUT_TOKENS = exports.HARD_MAX_CONCURRENCY = exports.DEFAULT_CONCURRENCY = exports.HARD_MAX_SCENARIOS = exports.HARD_MAX_REPEAT = exports.HARD_MAX_STEPS = exports.DEFAULT_MAX_STEPS = void 0; | ||
| exports.makeRunId = makeRunId; | ||
@@ -22,4 +22,41 @@ /** Default 15, hard-capped at 50 regardless of what a caller passes — | ||
| exports.HARD_MAX_REPEAT = 10; | ||
| /** Hard cap on the number of scenarios `storySplitter.ts` will split a single | ||
| * `--story` into, regardless of what the split-LLM-call returns — bounds | ||
| * worst-case BYOK cost the same way `HARD_MAX_REPEAT` bounds `--repeat`'s (each | ||
| * scenario is a whole extra LLM-driven run), disclosed explicitly when it clamps. */ | ||
| exports.HARD_MAX_SCENARIOS = 10; | ||
| /** Bounded-concurrency defaults for running a `--story`'s split-out scenarios. | ||
| * Default 3 is faster in aggregate than `--repeat`'s strict sequencing without | ||
| * risking a live-LLM rate-limit storm or exhausting local resources (many real | ||
| * headless browser instances at once); hard-capped at 5 regardless of what a | ||
| * caller passes, mirroring `HARD_MAX_STEPS`/`HARD_MAX_REPEAT`'s own clamps. */ | ||
| exports.DEFAULT_CONCURRENCY = 3; | ||
| exports.HARD_MAX_CONCURRENCY = 5; | ||
| /** Output-token caps per LLM call type — see DEVELOPMENT.md's "Bounding LLM | ||
| * output tokens" section for the sizing rationale. A too-generous cap costs | ||
| * nothing (providers stop naturally at a real end-of-response); a too-tight | ||
| * one risks truncating a legitimate response mid-JSON, which fails parsing | ||
| * exactly like any other unparseable response and is NOT caught by any | ||
| * provider's empty-completion diagnostics (those only fire on a genuinely | ||
| * empty completion, not a truncated-but-non-empty one). Chosen with real | ||
| * margin above the actual prompt schemas (planner.ts/apiPlanner.ts), not | ||
| * guessed — see planner.test.ts's sizing sanity check for PLAN_MAX_OUTPUT_TOKENS. */ | ||
| /** Raised from an original 400 after a real live failure: even with Gemini's | ||
| * `thinkingBudget: 1` (gemini.ts), thinking-token usage still has real | ||
| * variance on a realistic prompt (33-60+ tokens observed, confirmed via | ||
| * direct calls) — occasionally enough to truncate a legitimate response mid-JSON | ||
| * at 400. Raised to match the shared 1024 fallback every provider already | ||
| * uses when a caller omits maxOutputTokens entirely, removing the | ||
| * Gemini-fragile special case rather than tuning it tighter. */ | ||
| exports.ACTION_MAX_OUTPUT_TOKENS = 1024; | ||
| /** Larger than the browser engine's action cap: an API `request` action can | ||
| * carry a real request body (e.g. a multi-field JSON payload for a POST), | ||
| * meaningfully bigger than a browser action's `{"action":"click","ref":"e3",...}`. */ | ||
| exports.API_ACTION_MAX_OUTPUT_TOKENS = 800; | ||
| /** Covers an upfront plan of up to HARD_MAX_STEPS (50) step objects. */ | ||
| exports.PLAN_MAX_OUTPUT_TOKENS = 4096; | ||
| /** A "1-3 sentence hypothesis" plus a short suggestion — see rootCause.ts. */ | ||
| exports.ROOT_CAUSE_MAX_OUTPUT_TOKENS = 600; | ||
| function makeRunId() { | ||
| return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; | ||
| } |
+48
-23
@@ -22,9 +22,2 @@ import type { LlmProvider } from '../llm/types'; | ||
| credentials?: LoginCredentials; | ||
| /** Forces the same unverified-success gating `requiresConfirmation(goal)` | ||
| * would trigger, regardless of the goal's exact wording — used by | ||
| * `five46 login`, where an unverified false success is worse than the | ||
| * original bug this gating was built for: it produces a *reusable* | ||
| * session file that would silently mislead every future `test | ||
| * --storage-state` run, not just this one. */ | ||
| forceConfirmation?: boolean; | ||
| /** Invoked once, only on an accepted `goal-reached` outcome, before the | ||
@@ -61,9 +54,27 @@ * browser closes — given the real `BrowserContext` and the storageState | ||
| recordVideo?: boolean; | ||
| /** Default false. When set: one extra upfront `provider.complete()` call | ||
| * plans the whole goal (see `planner.ts`'s `buildPlanPrompt`), and most | ||
| * planned steps then execute directly against a fresh page snapshot with | ||
| * no further LLM call at all — see the dedicated fast-path section in | ||
| * this file. Off by default; the ordinary fully-adaptive loop (every | ||
| * existing behavior/test) is completely unchanged unless this is set. */ | ||
| /** Default false at this engine layer — `runAgent()` itself doesn't | ||
| * default this to `true`; `cli.ts` is the caller responsible for | ||
| * defaulting it to `true` for the `test` command (see | ||
| * `resolveStructuredPlan`), so every existing test calling `runAgent()` | ||
| * directly without setting this is unaffected by that CLI-level default. | ||
| * MCP (`src/mcp/tools.ts`) also never sets this, so MCP-driven runs stay | ||
| * fully-adaptive regardless — a deliberate scope boundary, not an | ||
| * oversight. When set: one extra upfront `provider.complete()` call plans | ||
| * the whole goal (see `planner.ts`'s `buildPlanPrompt`), and most planned | ||
| * steps then execute directly against a fresh page snapshot with no | ||
| * further LLM call at all — see the dedicated fast-path section in this | ||
| * file. The ordinary fully-adaptive loop (every existing behavior/test) | ||
| * is completely unchanged unless this is set. */ | ||
| useStructuredPlan?: boolean; | ||
| /** Default false — opt-in via `--fast-steps`, deliberately not default-on | ||
| * (unlike `useStructuredPlan`, which earned that status only after this | ||
| * project's own live validation). When set, every per-turn live action | ||
| * decision passes `fastPath: true` to the provider (see | ||
| * `LlmCompleteOptions`), which Groq/Gemini map to a genuinely faster | ||
| * model tier; OpenAI/Anthropic/Bedrock ignore it. The one-time upfront | ||
| * plan call and the root-cause call never set this, regardless of this | ||
| * option — only the high-frequency per-step call is affected. A smaller | ||
| * model is a real, currently-unquantified risk to per-step decision | ||
| * quality, which is exactly why this isn't the default. */ | ||
| useFastSteps?: boolean; | ||
| } | ||
@@ -84,12 +95,26 @@ /** Orchestrates the full agentic loop: launch the browser (before any paid | ||
| * | ||
| * When `requiresConfirmation(goal)` is true, a `done`/`goal-reached` claim | ||
| * is rejected (fed back into history, loop continues) unless at least one | ||
| * `assert_visible`/`assert_text` has already succeeded — found via a real, | ||
| * live run (a Shopify demo store, "...then confirm the cart shows an | ||
| * item"): the model added a real item to the cart, then declared | ||
| * goal-reached without ever calling an assertion. The end state happened to | ||
| * be correct, but nothing in the run actually verified it, despite the | ||
| * goal explicitly asking for verification — the same "don't accept an | ||
| * unverified confident claim" posture as everywhere else in this project, | ||
| * applied to the model's own self-reported success. */ | ||
| * A `done`/`goal-reached` claim is always rejected (fed back into history, | ||
| * loop continues) unless at least one `assert_visible`/`assert_text`/ | ||
| * `assert_page_text` has already succeeded — found via a real, live run (a | ||
| * Shopify demo store, "...then confirm the cart shows an item"): the model | ||
| * added a real item to the cart, then declared goal-reached without ever | ||
| * calling an assertion. The end state happened to be correct, but nothing | ||
| * in the run actually verified it, despite the goal explicitly asking for | ||
| * verification — the same "don't accept an unverified confident claim" | ||
| * posture as everywhere else in this project, applied to the model's own | ||
| * self-reported success. | ||
| * | ||
| * The required count is `Math.max(1, countConfirmationClauses(goal))` — | ||
| * an unconditional floor of 1, not just when the goal's own wording asks | ||
| * for it. Originally this only applied when `requiresConfirmation(goal)` | ||
| * matched confirm/verify/ensure/etc. language in the goal text; found via | ||
| * live testing against real production sites (Flipkart, Amazon.in) that a | ||
| * goal phrased as plain procedure ("add to cart, then open login," no | ||
| * confirm/verify wording at all) got a false `goal-reached` claim after a | ||
| * single incidental click (closing an unrelated popup), since the old | ||
| * clause-count-only threshold computed 0 for it — zero forced | ||
| * verification, by design, for exactly the kind of natural task | ||
| * description a real user is likely to write. `countConfirmationClauses` | ||
| * still raises the floor above 1 when the goal explicitly asks for more | ||
| * than one check — this only changed the *minimum*, never the ceiling. */ | ||
| export declare function runAgent(options: RunAgentOptions): Promise<TestRun>; |
+109
-42
@@ -48,22 +48,30 @@ "use strict"; | ||
| * | ||
| * When `requiresConfirmation(goal)` is true, a `done`/`goal-reached` claim | ||
| * is rejected (fed back into history, loop continues) unless at least one | ||
| * `assert_visible`/`assert_text` has already succeeded — found via a real, | ||
| * live run (a Shopify demo store, "...then confirm the cart shows an | ||
| * item"): the model added a real item to the cart, then declared | ||
| * goal-reached without ever calling an assertion. The end state happened to | ||
| * be correct, but nothing in the run actually verified it, despite the | ||
| * goal explicitly asking for verification — the same "don't accept an | ||
| * unverified confident claim" posture as everywhere else in this project, | ||
| * applied to the model's own self-reported success. */ | ||
| * A `done`/`goal-reached` claim is always rejected (fed back into history, | ||
| * loop continues) unless at least one `assert_visible`/`assert_text`/ | ||
| * `assert_page_text` has already succeeded — found via a real, live run (a | ||
| * Shopify demo store, "...then confirm the cart shows an item"): the model | ||
| * added a real item to the cart, then declared goal-reached without ever | ||
| * calling an assertion. The end state happened to be correct, but nothing | ||
| * in the run actually verified it, despite the goal explicitly asking for | ||
| * verification — the same "don't accept an unverified confident claim" | ||
| * posture as everywhere else in this project, applied to the model's own | ||
| * self-reported success. | ||
| * | ||
| * The required count is `Math.max(1, countConfirmationClauses(goal))` — | ||
| * an unconditional floor of 1, not just when the goal's own wording asks | ||
| * for it. Originally this only applied when `requiresConfirmation(goal)` | ||
| * matched confirm/verify/ensure/etc. language in the goal text; found via | ||
| * live testing against real production sites (Flipkart, Amazon.in) that a | ||
| * goal phrased as plain procedure ("add to cart, then open login," no | ||
| * confirm/verify wording at all) got a false `goal-reached` claim after a | ||
| * single incidental click (closing an unrelated popup), since the old | ||
| * clause-count-only threshold computed 0 for it — zero forced | ||
| * verification, by design, for exactly the kind of natural task | ||
| * description a real user is likely to write. `countConfirmationClauses` | ||
| * still raises the floor above 1 when the goal explicitly asks for more | ||
| * than one check — this only changed the *minimum*, never the ceiling. */ | ||
| async function runAgent(options) { | ||
| const runId = (0, runLoop_1.makeRunId)(); | ||
| const maxSteps = Math.min(options.maxSteps ?? runLoop_1.DEFAULT_MAX_STEPS, runLoop_1.HARD_MAX_STEPS); | ||
| // forceConfirmation (five46 login only) needs the floor of 1: a login | ||
| // goal's own text may contain zero confirm-language, yet a real | ||
| // assertion must still be unconditionally required — deriving the | ||
| // threshold purely from clause-count would silently compute 0 here and | ||
| // defeat the whole point of forceConfirmation. | ||
| const requiredConfirmationCount = options.forceConfirmation ? Math.max(1, (0, planner_1.countConfirmationClauses)(options.goal)) : (0, planner_1.countConfirmationClauses)(options.goal); | ||
| const needsConfirmation = requiredConfirmationCount > 0; | ||
| const requiredConfirmationCount = Math.max(1, (0, planner_1.countConfirmationClauses)(options.goal)); | ||
| const credentialsAvailable = options.credentials | ||
@@ -102,2 +110,13 @@ ? { username: Boolean(options.credentials.username), password: Boolean(options.credentials.password) } | ||
| const baselineState = await browser.context.storageState(); | ||
| // Captured once, before any action ever runs — the mechanical basis | ||
| // for the assert_page_text tautology guard below (see its own doc | ||
| // comment). Best-effort: an unreadable/not-yet-settled body at this | ||
| // exact moment degrades to an empty baseline, not a thrown error — | ||
| // the guard simply can't flag anything against an empty baseline, | ||
| // which is the same "fail open, never fail the whole run" posture | ||
| // every other best-effort capture in this codebase already uses. | ||
| const baselineBodyText = await browser.page | ||
| .locator('body') | ||
| .innerText() | ||
| .catch(() => ''); | ||
| // One extra, independent LLM call — the same second-`complete()`-call | ||
@@ -115,3 +134,3 @@ // pattern `rootCause.ts` already uses, not a change to `LlmProvider` | ||
| const initialOutline = await (0, browser_1.snapshot)(browser.page, undefined, true); | ||
| const planRaw = await options.provider.complete((0, planner_1.buildPlanPrompt)(options.goal, initialOutline), options.apiKey); | ||
| const planRaw = await options.provider.complete((0, planner_1.buildPlanPrompt)(options.goal, initialOutline), options.apiKey, { maxOutputTokens: runLoop_1.PLAN_MAX_OUTPUT_TOKENS }); | ||
| const parsedPlan = (0, planner_1.parsePlan)(planRaw); | ||
@@ -208,12 +227,16 @@ if (parsedPlan.ok) | ||
| else if (plannedStep && (plannedStep.action === 'click' || plannedStep.action === 'fill' || plannedStep.action === 'assert_visible' || plannedStep.action === 'assert_text')) { | ||
| // assert_visible/assert_text never fast-path, full stop — matches | ||
| // self-healing's own explicit, permanent exclusion of assertions | ||
| // exactly, for the identical reason: those are the run's actual | ||
| // verdict, and a structurally-matched-but-wrong target could | ||
| // convert a genuine app regression into a false pass. Only | ||
| // click/fill are attempted below. A planned `assert_page_text` step | ||
| // isn't listed in either branch condition above, so it falls | ||
| // through to the live decision below by construction — the same | ||
| // permanent exclusion, for the same reason. | ||
| if (plannedStep.action === 'click' || plannedStep.action === 'fill') { | ||
| // assert_text never fast-paths, full stop — predicting an exact | ||
| // expected *text value* for a page the plan never actually saw is a | ||
| // categorically bigger risk than predicting an element's role+name: | ||
| // a wrong prediction here can silently produce a false pass or a | ||
| // false assertion-failed, not just "picked a plausible element." | ||
| // assert_visible carries only the resolution-ambiguity risk | ||
| // click/fill fast-pathing already accepts (see below) — no expected | ||
| // *value* to get wrong, only "does this resolved element genuinely | ||
| // exist and is it visible," which the real execution path still | ||
| // checks for real. A planned `assert_page_text` step isn't listed in | ||
| // either branch condition above, so it falls through to the live | ||
| // decision below by construction — same reasoning as assert_text, | ||
| // since it also requires predicting an exact expected substring. | ||
| if (plannedStep.action === 'click' || plannedStep.action === 'fill' || plannedStep.action === 'assert_visible') { | ||
| // prioritizeViewport: false — matches self-healing's own | ||
@@ -253,3 +276,5 @@ // re-match call, not the live loop's per-turn one just below. | ||
| ? { action: 'click', ref: candidate.ref, reason: plannedStep.reason } | ||
| : { action: 'fill', ref: candidate.ref, value: plannedStep.value, submit: plannedStep.submit, reason: plannedStep.reason }; | ||
| : plannedStep.action === 'fill' | ||
| ? { action: 'fill', ref: candidate.ref, value: plannedStep.value, submit: plannedStep.submit, reason: plannedStep.reason } | ||
| : { action: 'assert_visible', ref: candidate.ref, reason: plannedStep.reason }; | ||
| outline = freshOutline; | ||
@@ -264,5 +289,6 @@ fastPathedSteps++; | ||
| // Reached when there's no plan, the plan is exhausted, this step | ||
| // was an assertion (always live), or a click/fill target didn't | ||
| // resolve to exactly one candidate — the exact, unmodified live | ||
| // flow that exists regardless of `useStructuredPlan`. | ||
| // was an assert_text/assert_page_text (always live), or a | ||
| // click/fill/assert_visible target didn't resolve to exactly one | ||
| // candidate — the exact, unmodified live flow that exists | ||
| // regardless of `useStructuredPlan`. | ||
| // | ||
@@ -277,3 +303,3 @@ // prioritizeViewport: true here (and only here) — self-healing's | ||
| try { | ||
| raw = await options.provider.complete(prompt, options.apiKey); | ||
| raw = await options.provider.complete(prompt, options.apiKey, { maxOutputTokens: runLoop_1.ACTION_MAX_OUTPUT_TOKENS, fastPath: options.useFastSteps }); | ||
| } | ||
@@ -352,3 +378,3 @@ catch (err) { | ||
| lastActionMadeProgress = false; | ||
| const unverifiedSuccess = action.outcome === 'goal-reached' && needsConfirmation && (!hasSucceededAssertion || succeededAssertionCount < requiredConfirmationCount); | ||
| const unverifiedSuccess = action.outcome === 'goal-reached' && (!hasSucceededAssertion || succeededAssertionCount < requiredConfirmationCount); | ||
| if (unverifiedSuccess) { | ||
@@ -398,2 +424,18 @@ history.push({ | ||
| : undefined; | ||
| // assert_page_text against text that was ALREADY present before any | ||
| // actions ran proves nothing — found via a real, live run (Flipkart, | ||
| // reproduced twice): the model satisfied the confirmation requirement | ||
| // by asserting a persistent header link ("Login") true on every page | ||
| // regardless of state, then never completed the rest of the goal. | ||
| // Prompt guidance alone (buildActionPrompt's assertionQualityNote) | ||
| // was live-retested and did NOT change this — same "wording alone | ||
| // doesn't reliably work" lesson as parsePlan's immediate-done | ||
| // rejection — so this closes the gap mechanically instead. Scoped to | ||
| // assert_page_text only (the exact reproduced vector): assert_visible/ | ||
| // assert_text are ref-based and already covered by the narrower | ||
| // `recentlyInteracted` tautology guard at parse time. Still recorded | ||
| // as a real, correctly-executed `ok: true` step below — the check | ||
| // genuinely passed, and the generated spec should faithfully replay | ||
| // it — only withheld from the confirmation-gate credit. | ||
| const isTautologicalPageText = action.action === 'assert_page_text' && result.ok && baselineBodyText.includes((0, browser_1.substitutePlaceholders)(action.expectedText, options.credentials)); | ||
| steps.push({ | ||
@@ -410,2 +452,3 @@ step: stepNumber, | ||
| resolvedSelector: result.healedSelector, | ||
| verifiedRoleLocator: result.verifiedRoleLocator, | ||
| }); | ||
@@ -421,6 +464,12 @@ history.push({ | ||
| // response bodies. | ||
| detail: result.ok ? (result.healed ? 'succeeded after healing a stale selector' : '') : (result.failureDetail ?? 'failed'), | ||
| detail: isTautologicalPageText | ||
| ? 'succeeded, but this exact text was already present before any actions ran this run — does not count as confirmation of anything' | ||
| : result.ok | ||
| ? result.healed | ||
| ? 'succeeded after healing a stale selector' | ||
| : '' | ||
| : (result.failureDetail ?? 'failed'), | ||
| }); | ||
| const isAssertion = action.action === 'assert_visible' || action.action === 'assert_text' || action.action === 'assert_page_text'; | ||
| if (isAssertion && result.ok) { | ||
| if (isAssertion && result.ok && !isTautologicalPageText) { | ||
| hasSucceededAssertion = true; | ||
@@ -450,9 +499,27 @@ succeededAssertionCount++; | ||
| finally { | ||
| const { videoPath } = await browser.close(); | ||
| // `result` is `undefined` only if something above threw rather than | ||
| // returning normally — in that case there's no TestRun to attach a | ||
| // video path to, and this is skipped entirely (no new failure mode). | ||
| if (result && videoPath) | ||
| result.videoPath = videoPath; | ||
| // A teardown failure (`context.close()`/`browser.close()` throwing | ||
| // inside `AgentBrowser.close()` — a real, if uncommon, possibility: a | ||
| // crashed browser process, an OS-level resource issue) must never | ||
| // overwrite the `result` already computed above. An exception thrown | ||
| // inside a `finally` block silently *replaces* whatever the `try` was | ||
| // about to return — found via a deliberate review of this exact risk | ||
| // (an untested edge case, not a live incident), the same class of bug | ||
| // the story-mode batch-robustness fix closed elsewhere: a run that | ||
| // genuinely reached `goal-reached` could have that entire, hard-won | ||
| // result discarded and replaced with an uncaught exception, purely | ||
| // because browser teardown — completely unrelated to whether the | ||
| // actual goal was achieved — happened to fail. Best-effort here, same | ||
| // posture as `video.path()`'s own try/catch inside `close()` itself. | ||
| try { | ||
| const { videoPath } = await browser.close(); | ||
| // `result` is `undefined` only if something above threw rather than | ||
| // returning normally — in that case there's no TestRun to attach a | ||
| // video path to, and this is skipped entirely (no new failure mode). | ||
| if (result && videoPath) | ||
| result.videoPath = videoPath; | ||
| } | ||
| catch { | ||
| // Teardown failing must never mask the real run outcome above. | ||
| } | ||
| } | ||
| } |
@@ -224,2 +224,15 @@ /** One visible, interactive element found on the page during a single | ||
| healed?: boolean; | ||
| /** Present only when `browser.ts`'s `verifyRoleLocator()` confirmed, | ||
| * live, that `page.getByRole(role, { name })` resolves uniquely to the | ||
| * exact same element this step actually acted on — mirrors | ||
| * `StepExecutionResult.verifiedRoleLocator` (`browser.ts`) exactly. | ||
| * `generateSpec.ts` prefers this over the raw `selector`/ | ||
| * `resolvedSelector` above when present, since a `getByRole` locator is | ||
| * far more resilient to future DOM restructuring than a positional CSS | ||
| * path — never used for anything during the live run itself, which | ||
| * always already used the guaranteed-correct selector regardless. */ | ||
| verifiedRoleLocator?: { | ||
| role: string; | ||
| name: string; | ||
| }; | ||
| } | ||
@@ -226,0 +239,0 @@ /** The full record of one `runAgent()` call — the single source of truth |
+53
-3
@@ -34,6 +34,45 @@ #!/usr/bin/env node | ||
| * session file, and its goal vocabulary is always "log in" — there's no | ||
| * multi-step ambiguity for an upfront plan to help resolve. */ | ||
| * multi-step ambiguity for an upfront plan to help resolve. Default-on as | ||
| * of the speed-focused pass that added `noStructuredPlan` below — this | ||
| * bare flag is now redundant (kept parseable so an existing script that | ||
| * already passes it keeps working identically) since there's nothing left | ||
| * for it to turn on that isn't already on by default. */ | ||
| structuredPlan?: boolean; | ||
| /** The sole opt-out for structured planning's new default-on behavior — | ||
| * see `resolveStructuredPlan()`. Mirrors `noRootCause`'s polarity for a | ||
| * default-on feature with an escape hatch. */ | ||
| noStructuredPlan?: boolean; | ||
| /** `test`/`api` only — see `RunAgentOptions.useFastSteps`/ | ||
| * `RunApiTestOptions.useFastSteps`. Deliberately opt-in (unlike | ||
| * `structuredPlan`'s now-default-on status) — a smaller model is a real, | ||
| * currently-unquantified risk to per-step decision quality, not | ||
| * something to default on without its own live-validation runway first. | ||
| * Not extended to `login` for the same reason `--structured-plan` isn't. */ | ||
| fastSteps?: boolean; | ||
| /** `test`/`api` only — a path to a raw user-story text file, split into | ||
| * independent goals by `splitUserStory` (storySplitter.ts) and run via | ||
| * `runStoryScenarios`/`runApiStoryScenarios`. Mutually exclusive with | ||
| * `--goal` (exactly one required) — enforced in `main()`, not here, since | ||
| * a shared parser accepting both harmlessly is consistent with every | ||
| * other flag's "parse first, validate meaning after" split in this file. */ | ||
| story?: string; | ||
| /** `test`/`api` only, and only meaningful alongside `--story` — see | ||
| * `DEFAULT_CONCURRENCY`/`HARD_MAX_CONCURRENCY` (runLoop.ts). Harmlessly | ||
| * parsed-but-ignored without `--story`, same "shared parser, unused | ||
| * elsewhere" precedent as `--allow-deletes` on `login`. */ | ||
| concurrency?: number; | ||
| } | ||
| export declare function parseAgentArgs(argv: string[]): ParsedAgentArgs; | ||
| /** Resolves `--no-structured-plan` into a definite boolean immediately after | ||
| * parsing, so every downstream consumer (the "one extra LLM call will | ||
| * plan..." disclosure banners, `RunAgentOptions.useStructuredPlan`/ | ||
| * `RunApiTestOptions.useStructuredPlan`) sees an already-resolved | ||
| * true/false, never undefined. Structured planning is default-ON as of this | ||
| * change; `--no-structured-plan` is the sole opt-out (mirrors | ||
| * `--no-root-cause`'s polarity for a default-on feature). The bare | ||
| * `--structured-plan` flag no longer has any effect of its own — an | ||
| * explicit opt-out always wins if both are somehow passed. */ | ||
| export declare function resolveStructuredPlan(parsed: { | ||
| noStructuredPlan?: boolean; | ||
| }): boolean; | ||
| export interface ParsedApiArgs { | ||
@@ -52,2 +91,6 @@ baseUrl?: string; | ||
| structuredPlan?: boolean; | ||
| noStructuredPlan?: boolean; | ||
| fastSteps?: boolean; | ||
| story?: string; | ||
| concurrency?: number; | ||
| } | ||
@@ -63,2 +106,9 @@ export declare function parseApiArgs(argv: string[]): ParsedApiArgs; | ||
| export declare function insertIterationSuffix(path: string, iteration: number): string; | ||
| /** Story mode's own version of `insertIterationSuffix` above, for the same | ||
| * `--out` collision-avoidance reason — kept as a separate function rather | ||
| * than a parameterized shared one, since "repeat3" naming would misleadingly | ||
| * imply repetition of the same goal here, when each suffixed file is | ||
| * actually a *different*, independent split-out scenario ("foo.spec.ts" + 2 | ||
| * -> "foo.spec.ac2.ts"). */ | ||
| export declare function insertScenarioSuffix(path: string, index: number): string; | ||
| /** The actual single-run work shared by `runE2eTest` (one run) and | ||
@@ -73,3 +123,3 @@ * `runRepeatedE2eTest` (`--repeat N` runs): launches the agent, prints the | ||
| * duplicate banners. */ | ||
| export declare function performOneE2eRun(url: string, goal: string, maxSteps: number | undefined, headed: boolean | undefined, outArg: string | undefined, artifactDir: string, storageState: StorageState | undefined, allowDeletes: boolean | undefined, noRootCause: boolean | undefined, recordVideo: boolean | undefined, structuredPlan: boolean | undefined, provider: LlmProvider, llmApiKey: string, credentials: LoginCredentials, secrets: (string | undefined)[], projectName: string | undefined): Promise<{ | ||
| export declare function performOneE2eRun(url: string, goal: string, maxSteps: number | undefined, headed: boolean | undefined, outArg: string | undefined, artifactDir: string, storageState: StorageState | undefined, allowDeletes: boolean | undefined, noRootCause: boolean | undefined, recordVideo: boolean | undefined, structuredPlan: boolean | undefined, provider: LlmProvider, llmApiKey: string, credentials: LoginCredentials, secrets: (string | undefined)[], projectName: string | undefined, fastSteps: boolean | undefined): Promise<{ | ||
| outcome: RunOutcome; | ||
@@ -82,3 +132,3 @@ specBody: string; | ||
| * engine. */ | ||
| export declare function performOneApiRun(baseUrl: string, goal: string, maxSteps: number | undefined, outArg: string | undefined, storageState: StorageState | undefined, safety: SafetyMode, authHeaders: Record<string, string> | undefined, noRootCause: boolean | undefined, structuredPlan: boolean | undefined, provider: LlmProvider, llmApiKey: string, secrets: (string | undefined)[], projectName: string | undefined): Promise<{ | ||
| export declare function performOneApiRun(baseUrl: string, goal: string, maxSteps: number | undefined, outArg: string | undefined, storageState: StorageState | undefined, safety: SafetyMode, authHeaders: Record<string, string> | undefined, noRootCause: boolean | undefined, structuredPlan: boolean | undefined, provider: LlmProvider, llmApiKey: string, secrets: (string | undefined)[], projectName: string | undefined, fastSteps: boolean | undefined): Promise<{ | ||
| outcome: RunOutcome; | ||
@@ -85,0 +135,0 @@ specBody: string; |
| import type { LlmProvider } from './types'; | ||
| /** Anthropic's Messages API — https://docs.anthropic.com/en/api/messages. | ||
| * Shape verified against public API docs, not a live call (no test key | ||
| * available in this environment). */ | ||
| * available in this environment). | ||
| * | ||
| * `options.fastPath` is deliberately ignored — `claude-3-5-haiku-latest` is | ||
| * already Anthropic's fastest tier; there's no faster alternative that | ||
| * wouldn't risk reliability on the strict JSON-only action schema. A | ||
| * no-op, not an oversight. */ | ||
| export declare const anthropicProvider: LlmProvider; |
@@ -8,6 +8,11 @@ "use strict"; | ||
| * Shape verified against public API docs, not a live call (no test key | ||
| * available in this environment). */ | ||
| * available in this environment). | ||
| * | ||
| * `options.fastPath` is deliberately ignored — `claude-3-5-haiku-latest` is | ||
| * already Anthropic's fastest tier; there's no faster alternative that | ||
| * wouldn't risk reliability on the strict JSON-only action schema. A | ||
| * no-op, not an oversight. */ | ||
| exports.anthropicProvider = { | ||
| id: 'anthropic', | ||
| async complete(prompt, apiKey) { | ||
| async complete(prompt, apiKey, options) { | ||
| const response = await (0, fetchWithTimeout_1.fetchWithTimeout)('https://api.anthropic.com/v1/messages', { | ||
@@ -22,3 +27,3 @@ method: 'POST', | ||
| model: 'claude-3-5-haiku-latest', | ||
| max_tokens: 1024, | ||
| max_tokens: options?.maxOutputTokens ?? 1024, | ||
| messages: [{ role: 'user', content: prompt }], | ||
@@ -25,0 +30,0 @@ }), |
@@ -24,3 +24,7 @@ import type { LlmProvider } from './types'; | ||
| * Amazon, ...), so switching the underlying model doesn't change this code. | ||
| * | ||
| * `options.fastPath` is deliberately ignored — the configured Haiku model | ||
| * is already the fastest tier this codebase trusts for the strict | ||
| * JSON-only action schema; a no-op, not an oversight. | ||
| */ | ||
| export declare const bedrockProvider: LlmProvider; |
@@ -28,6 +28,10 @@ "use strict"; | ||
| * Amazon, ...), so switching the underlying model doesn't change this code. | ||
| * | ||
| * `options.fastPath` is deliberately ignored — the configured Haiku model | ||
| * is already the fastest tier this codebase trusts for the strict | ||
| * JSON-only action schema; a no-op, not an oversight. | ||
| */ | ||
| exports.bedrockProvider = { | ||
| id: 'bedrock', | ||
| async complete(prompt, region) { | ||
| async complete(prompt, region, options) { | ||
| // maxAttempts: 1 — the AWS SDK's own default StandardRetryStrategy would | ||
@@ -49,2 +53,3 @@ // otherwise already retry throttling/5xx internally before send() | ||
| messages: [{ role: 'user', content: [{ text: prompt }] }], | ||
| inferenceConfig: { maxTokens: options?.maxOutputTokens ?? 1024 }, | ||
| }); | ||
@@ -51,0 +56,0 @@ const response = await client.send(command); |
@@ -22,3 +22,10 @@ import type { LlmProvider } from './types'; | ||
| * Unlike OpenAI/Anthropic's Bearer/header auth, the API key goes in the | ||
| * query string per Google's documented scheme. */ | ||
| * query string per Google's documented scheme. | ||
| * | ||
| * `options.fastPath` swaps in `gemini-flash-lite-latest` — confirmed via a | ||
| * real call to resolve to `gemini-3.5-flash-lite`, the same "documented | ||
| * stable alias" shape as the default above, just a genuinely faster tier — | ||
| * used only for the high-frequency per-step decision call, never the | ||
| * one-time upfront plan call. Opt-in via `--fast-steps`, not default-on; | ||
| * see that flag's own doc comment for why. */ | ||
| export declare const geminiProvider: LlmProvider; |
+24
-3
@@ -26,7 +26,15 @@ "use strict"; | ||
| * Unlike OpenAI/Anthropic's Bearer/header auth, the API key goes in the | ||
| * query string per Google's documented scheme. */ | ||
| * query string per Google's documented scheme. | ||
| * | ||
| * `options.fastPath` swaps in `gemini-flash-lite-latest` — confirmed via a | ||
| * real call to resolve to `gemini-3.5-flash-lite`, the same "documented | ||
| * stable alias" shape as the default above, just a genuinely faster tier — | ||
| * used only for the high-frequency per-step decision call, never the | ||
| * one-time upfront plan call. Opt-in via `--fast-steps`, not default-on; | ||
| * see that flag's own doc comment for why. */ | ||
| exports.geminiProvider = { | ||
| id: 'gemini', | ||
| async complete(prompt, apiKey) { | ||
| const response = await (0, fetchWithTimeout_1.fetchWithTimeout)(`https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-latest:generateContent?key=${encodeURIComponent(apiKey)}`, { | ||
| async complete(prompt, apiKey, options) { | ||
| const model = options?.fastPath ? 'gemini-flash-lite-latest' : 'gemini-flash-latest'; | ||
| const response = await (0, fetchWithTimeout_1.fetchWithTimeout)(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(apiKey)}`, { | ||
| method: 'POST', | ||
@@ -36,2 +44,15 @@ headers: { 'Content-Type': 'application/json' }, | ||
| contents: [{ parts: [{ text: prompt }] }], | ||
| // thinkingBudget: 1 (not 0 — confirmed via a real call that 0 is | ||
| // rejected as INVALID_ARGUMENT for this model) — a real, live bug | ||
| // found the moment maxOutputTokens started being capped: this | ||
| // model's "thinking" tokens are drawn from the SAME | ||
| // maxOutputTokens budget as the visible answer (confirmed via a | ||
| // real call: a trivial prompt with maxOutputTokens:400 spent 237 | ||
| // tokens on invisible thinking, leaving too little for the | ||
| // truncated-but-non-empty JSON answer that then failed to parse). | ||
| // Every prompt in this codebase is a rigid, closed-schema JSON | ||
| // classification task, never open-ended reasoning, so extended | ||
| // thinking adds latency and truncation risk for zero benefit — | ||
| // same reasoning as `temperature: 0` on the other providers. | ||
| generationConfig: { maxOutputTokens: options?.maxOutputTokens ?? 1024, thinkingConfig: { thinkingBudget: 1 } }, | ||
| }), | ||
@@ -38,0 +59,0 @@ }); |
@@ -12,3 +12,11 @@ import type { LlmProvider } from './types'; | ||
| * for the same reason `openai.ts` uses it: a consistency check, not | ||
| * creative generation. */ | ||
| * creative generation. | ||
| * | ||
| * `options.fastPath` swaps in `llama-3.1-8b-instant` — a real, meaningfully | ||
| * faster tier on Groq's own LPU hardware (externally documented at roughly | ||
| * 2-8x the token/sec throughput of the 70B default), used only for the | ||
| * high-frequency per-step decision call, never the one-time upfront plan | ||
| * call. Opt-in via `--fast-steps` — a smaller model is a real, | ||
| * unquantified risk to per-step decision quality, not something to default | ||
| * on without live validation first. */ | ||
| export declare const groqProvider: LlmProvider; |
+12
-3
@@ -16,6 +16,14 @@ "use strict"; | ||
| * for the same reason `openai.ts` uses it: a consistency check, not | ||
| * creative generation. */ | ||
| * creative generation. | ||
| * | ||
| * `options.fastPath` swaps in `llama-3.1-8b-instant` — a real, meaningfully | ||
| * faster tier on Groq's own LPU hardware (externally documented at roughly | ||
| * 2-8x the token/sec throughput of the 70B default), used only for the | ||
| * high-frequency per-step decision call, never the one-time upfront plan | ||
| * call. Opt-in via `--fast-steps` — a smaller model is a real, | ||
| * unquantified risk to per-step decision quality, not something to default | ||
| * on without live validation first. */ | ||
| exports.groqProvider = { | ||
| id: 'groq', | ||
| async complete(prompt, apiKey) { | ||
| async complete(prompt, apiKey, options) { | ||
| const response = await (0, fetchWithTimeout_1.fetchWithTimeout)('https://api.groq.com/openai/v1/chat/completions', { | ||
@@ -28,4 +36,5 @@ method: 'POST', | ||
| body: JSON.stringify({ | ||
| model: 'llama-3.3-70b-versatile', | ||
| model: options?.fastPath ? 'llama-3.1-8b-instant' : 'llama-3.3-70b-versatile', | ||
| temperature: 0, | ||
| max_tokens: options?.maxOutputTokens ?? 1024, | ||
| messages: [{ role: 'user', content: prompt }], | ||
@@ -32,0 +41,0 @@ }), |
@@ -6,3 +6,10 @@ import type { LlmProvider } from './types'; | ||
| * consistency check, not creative generation — we want the same AC compared | ||
| * against the same code to give the same answer. */ | ||
| * against the same code to give the same answer. | ||
| * | ||
| * `options.fastPath` is deliberately ignored — `gpt-4o-mini` is already | ||
| * OpenAI's fastest/cheapest general-purpose tier (confirmed: no sunset | ||
| * date, remains fully active on the API), with no faster alternative that | ||
| * wouldn't risk reliability on the strict JSON-only action schema. A | ||
| * no-op, not an oversight — unlike groq.ts/gemini.ts, there's no real | ||
| * faster tier here to swap in. */ | ||
| export declare const openAiProvider: LlmProvider; |
+10
-2
@@ -10,6 +10,13 @@ "use strict"; | ||
| * consistency check, not creative generation — we want the same AC compared | ||
| * against the same code to give the same answer. */ | ||
| * against the same code to give the same answer. | ||
| * | ||
| * `options.fastPath` is deliberately ignored — `gpt-4o-mini` is already | ||
| * OpenAI's fastest/cheapest general-purpose tier (confirmed: no sunset | ||
| * date, remains fully active on the API), with no faster alternative that | ||
| * wouldn't risk reliability on the strict JSON-only action schema. A | ||
| * no-op, not an oversight — unlike groq.ts/gemini.ts, there's no real | ||
| * faster tier here to swap in. */ | ||
| exports.openAiProvider = { | ||
| id: 'openai', | ||
| async complete(prompt, apiKey) { | ||
| async complete(prompt, apiKey, options) { | ||
| const response = await (0, fetchWithTimeout_1.fetchWithTimeout)('https://api.openai.com/v1/chat/completions', { | ||
@@ -24,2 +31,3 @@ method: 'POST', | ||
| temperature: 0, | ||
| max_tokens: options?.maxOutputTokens ?? 1024, | ||
| messages: [{ role: 'user', content: prompt }], | ||
@@ -26,0 +34,0 @@ }), |
@@ -80,7 +80,7 @@ "use strict"; | ||
| id: provider.id, | ||
| async complete(prompt, apiKey) { | ||
| async complete(prompt, apiKey, completeOptions) { | ||
| let lastError; | ||
| for (let attempt = 1; attempt <= maxAttempts; attempt++) { | ||
| try { | ||
| return await provider.complete(prompt, apiKey); | ||
| return await provider.complete(prompt, apiKey, completeOptions); | ||
| } | ||
@@ -87,0 +87,0 @@ catch (err) { |
+31
-1
@@ -8,5 +8,35 @@ /** | ||
| */ | ||
| /** Bounds a single completion's output length — every provider maps this | ||
| * onto its own request shape (see each provider file). Omitted entirely by | ||
| * a caller (including every existing test call site using the 2-argument | ||
| * `complete(prompt, apiKey)` form) falls back to each provider's own | ||
| * conservative default, so nothing existing needs to change to keep | ||
| * working. Anthropic already required *some* value here (`max_tokens` is a | ||
| * mandatory field on its Messages API) — this generalizes that | ||
| * provider-specific requirement into an optional, uniform knob every | ||
| * provider now honors, the same "one shared knob, all providers read it" | ||
| * precedent `DEFAULT_LLM_TIMEOUT_MS` already established for request | ||
| * timeouts. Exists to bound tail latency: every prompt in this codebase | ||
| * asks for a small, tightly-scoped JSON object or a short paragraph, so an | ||
| * unbounded model that free-associates past what's needed adds pure wasted | ||
| * generation time. */ | ||
| export interface LlmCompleteOptions { | ||
| maxOutputTokens?: number; | ||
| /** Signals this call is a high-frequency per-step decision, not the | ||
| * one-time upfront plan or root-cause call — a hint, not a model name. | ||
| * Each provider owns whether/how it responds: Groq and Gemini map this to | ||
| * a genuinely faster model tier (`llama-3.1-8b-instant`, | ||
| * `gemini-flash-lite-latest`); OpenAI/Anthropic/Bedrock deliberately | ||
| * ignore it, since their default model is already the fastest tier that | ||
| * provider offers without risking reliability on the strict JSON-only | ||
| * action schema. Never set on the plan/root-cause calls — those always | ||
| * use each provider's default (higher-quality) model regardless of this | ||
| * flag. Opt-in via `--fast-steps` (`RunAgentOptions.useFastSteps`) — real, | ||
| * currently-unquantified risk to per-step decision quality on Groq/Gemini | ||
| * specifically, so this is not default-on. */ | ||
| fastPath?: boolean; | ||
| } | ||
| export interface LlmProvider { | ||
| id: string; | ||
| complete(prompt: string, apiKey: string): Promise<string>; | ||
| complete(prompt: string, apiKey: string, options?: LlmCompleteOptions): Promise<string>; | ||
| } |
| import * as z from 'zod/v4'; | ||
| /** Deliberately does NOT include `allowWrites`/`allowDeletes`/`allowHosts` | ||
| * or any credential field — see DEVELOPMENT.md's "MCP server integration" | ||
| * section for why. Unlike the CLI (one human, one deliberate flag per | ||
| * invocation), an MCP tool's arguments are chosen by the calling IDE | ||
| * assistant's own reasoning, not a human at the moment of the call — write | ||
| * access is only ever unlockable via an env var the human sets once, when | ||
| * configuring the MCP server itself, never through a tool parameter. */ | ||
| export declare const testToolInputSchema: { | ||
| url: z.ZodString; | ||
| goal: z.ZodString; | ||
| goal: z.ZodOptional<z.ZodString>; | ||
| story: z.ZodOptional<z.ZodString>; | ||
| maxSteps: z.ZodOptional<z.ZodNumber>; | ||
@@ -18,4 +12,65 @@ headed: z.ZodOptional<z.ZodBoolean>; | ||
| baseUrl: z.ZodString; | ||
| goal: z.ZodString; | ||
| goal: z.ZodOptional<z.ZodString>; | ||
| story: z.ZodOptional<z.ZodString>; | ||
| maxSteps: z.ZodOptional<z.ZodNumber>; | ||
| }; | ||
| export declare const testToolOutputSchema: { | ||
| passed: z.ZodBoolean; | ||
| outcome: z.ZodOptional<z.ZodEnum<{ | ||
| "goal-reached": "goal-reached"; | ||
| "goal-unreachable": "goal-unreachable"; | ||
| "stuck-repeating": "stuck-repeating"; | ||
| "stopped-by-cap": "stopped-by-cap"; | ||
| "unparseable-response": "unparseable-response"; | ||
| "assertion-failed": "assertion-failed"; | ||
| "provider-unavailable": "provider-unavailable"; | ||
| "tooling-error": "tooling-error"; | ||
| }>>; | ||
| specPath: z.ZodOptional<z.ZodString>; | ||
| acceptanceCriteria: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| index: z.ZodNumber; | ||
| goal: z.ZodString; | ||
| outcome: z.ZodEnum<{ | ||
| "goal-reached": "goal-reached"; | ||
| "goal-unreachable": "goal-unreachable"; | ||
| "stuck-repeating": "stuck-repeating"; | ||
| "stopped-by-cap": "stopped-by-cap"; | ||
| "unparseable-response": "unparseable-response"; | ||
| "assertion-failed": "assertion-failed"; | ||
| "provider-unavailable": "provider-unavailable"; | ||
| "tooling-error": "tooling-error"; | ||
| }>; | ||
| passed: z.ZodBoolean; | ||
| specPath: z.ZodOptional<z.ZodString>; | ||
| }, z.core.$strip>>>; | ||
| }; | ||
| export declare const apiToolOutputSchema: { | ||
| passed: z.ZodBoolean; | ||
| outcome: z.ZodOptional<z.ZodEnum<{ | ||
| "goal-reached": "goal-reached"; | ||
| "goal-unreachable": "goal-unreachable"; | ||
| "stuck-repeating": "stuck-repeating"; | ||
| "stopped-by-cap": "stopped-by-cap"; | ||
| "unparseable-response": "unparseable-response"; | ||
| "assertion-failed": "assertion-failed"; | ||
| "provider-unavailable": "provider-unavailable"; | ||
| "tooling-error": "tooling-error"; | ||
| }>>; | ||
| specPath: z.ZodOptional<z.ZodString>; | ||
| acceptanceCriteria: z.ZodOptional<z.ZodArray<z.ZodObject<{ | ||
| index: z.ZodNumber; | ||
| goal: z.ZodString; | ||
| outcome: z.ZodEnum<{ | ||
| "goal-reached": "goal-reached"; | ||
| "goal-unreachable": "goal-unreachable"; | ||
| "stuck-repeating": "stuck-repeating"; | ||
| "stopped-by-cap": "stopped-by-cap"; | ||
| "unparseable-response": "unparseable-response"; | ||
| "assertion-failed": "assertion-failed"; | ||
| "provider-unavailable": "provider-unavailable"; | ||
| "tooling-error": "tooling-error"; | ||
| }>; | ||
| passed: z.ZodBoolean; | ||
| specPath: z.ZodOptional<z.ZodString>; | ||
| }, z.core.$strip>>>; | ||
| }; |
+63
-3
@@ -36,3 +36,3 @@ "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.apiToolInputSchema = exports.testToolInputSchema = void 0; | ||
| exports.apiToolOutputSchema = exports.testToolOutputSchema = exports.apiToolInputSchema = exports.testToolInputSchema = void 0; | ||
| const z = __importStar(require("zod/v4")); | ||
@@ -46,5 +46,14 @@ /** Deliberately does NOT include `allowWrites`/`allowDeletes`/`allowHosts` | ||
| * configuring the MCP server itself, never through a tool parameter. */ | ||
| // `goal`/`story` are each optional but exactly one is required — enforced | ||
| // in `tools.ts` (`runTestTool`/`runApiTool`), not expressible in the Zod | ||
| // shape itself without a discriminated union, which would complicate every | ||
| // existing single-goal caller for a rarely-combined pair of fields. | ||
| const storyDescribe = 'A raw, possibly multi-scenario user story/acceptance-criteria text to split into independent goals and run with bounded concurrency (see `concurrency` context, set via FIVE46_MCP_CONCURRENCY on the server, never a per-call argument). Mutually exclusive with `goal` — provide exactly one.'; | ||
| exports.testToolInputSchema = { | ||
| url: z.string().describe('The live http(s) or file:// URL to test'), | ||
| goal: z.string().describe('What the agent should accomplish — required, since a vague default would burn real BYOK cost on an unfocused run'), | ||
| goal: z | ||
| .string() | ||
| .optional() | ||
| .describe('What the agent should accomplish — provide exactly one of `goal`/`story`. A vague goal would burn real BYOK cost on an unfocused run.'), | ||
| story: z.string().optional().describe(storyDescribe), | ||
| maxSteps: z.number().int().positive().optional().describe('Step budget (default 15, hard-capped at 50 regardless of what is passed)'), | ||
@@ -59,4 +68,55 @@ headed: z.boolean().optional().describe('Watch it drive a real visible browser instead of headless (default false)'), | ||
| baseUrl: z.string().describe('The live http(s) base URL to test'), | ||
| goal: z.string().describe('What the agent should accomplish — required, since a vague default would burn real BYOK cost on an unfocused run'), | ||
| goal: z | ||
| .string() | ||
| .optional() | ||
| .describe('What the agent should accomplish — provide exactly one of `goal`/`story`. A vague goal would burn real BYOK cost on an unfocused run.'), | ||
| story: z.string().optional().describe(storyDescribe), | ||
| maxSteps: z.number().int().positive().optional().describe('Step budget (default 15, hard-capped at 50 regardless of what is passed)'), | ||
| }; | ||
| // Shared by both tools' output schemas — `RunOutcome`/`ApiTestRun`'s own | ||
| // outcome union (agent/types.ts, agent/apiTypes.ts), plus a synthetic | ||
| // `tooling-error` value used only here, for an MCP-layer failure with no | ||
| // real run outcome behind it at all (e.g. Playwright unavailable, or a | ||
| // write failure after a run already completed — see `runOneTestScenario`/ | ||
| // `runOneApiScenario` in tools.ts). | ||
| const outcomeEnum = z.enum([ | ||
| 'goal-reached', | ||
| 'goal-unreachable', | ||
| 'stuck-repeating', | ||
| 'stopped-by-cap', | ||
| 'unparseable-response', | ||
| 'assertion-failed', | ||
| 'provider-unavailable', | ||
| 'tooling-error', | ||
| ]); | ||
| const acceptanceCriterionOutputSchema = z.object({ | ||
| index: z.number().int().positive().describe('1-based position, matching the "ACn" label in the free-text report'), | ||
| goal: z.string().describe('The independent goal this scenario was split into'), | ||
| outcome: outcomeEnum, | ||
| passed: z.boolean(), | ||
| specPath: z.string().optional().describe('Absolute path to this scenario\'s own generated spec file, when one was written'), | ||
| }); | ||
| /** The MCP SDK (`@modelcontextprotocol/sdk@^1.30.0`) strictly validates | ||
| * `structuredContent` against this shape on every non-error result — see | ||
| * its own `validateToolOutput()` — so this must exactly describe what | ||
| * `runTestTool`/`runApiTool` actually return. `outcome`/`specPath` are for | ||
| * a plain `goal` call; `acceptanceCriteria` is for a `story` call — never | ||
| * both at once. `passed` is always present regardless of which shape: | ||
| * the one universal field a calling agent can branch on immediately, | ||
| * mirroring the result's own `isError` exactly (`passed === !isError`). | ||
| * This is a purely additive, machine-parseable *parallel* channel — the | ||
| * existing free-text report in `content` is unchanged, still the right | ||
| * format for a human reading raw MCP output. */ | ||
| const toolOutputSchema = { | ||
| passed: z | ||
| .boolean() | ||
| .describe('Whether this call fully succeeded — for a plain goal, outcome === "goal-reached"; for a story, every acceptance criterion reached goal-reached.'), | ||
| outcome: outcomeEnum.optional().describe('Present only for a plain `goal` call (never a `story` call — see acceptanceCriteria instead).'), | ||
| specPath: z.string().optional().describe('Absolute path to the generated spec file — present only for a plain `goal` call.'), | ||
| acceptanceCriteria: z | ||
| .array(acceptanceCriterionOutputSchema) | ||
| .optional() | ||
| .describe('Present only for a `story` call — one entry per split acceptance criterion, in the order they were run.'), | ||
| }; | ||
| exports.testToolOutputSchema = toolOutputSchema; | ||
| exports.apiToolOutputSchema = toolOutputSchema; |
+15
-1
@@ -10,2 +10,3 @@ "use strict"; | ||
| const tools_1 = require("./tools"); | ||
| const runLoop_1 = require("../agent/runLoop"); | ||
| const package_json_1 = __importDefault(require("../../package.json")); | ||
@@ -25,3 +26,14 @@ /** Every file under `src/mcp/` uses normal, static top-level imports | ||
| const allowDeletes = process.env.FIVE46_MCP_ALLOW_DELETES === '1'; | ||
| const context = { projectRoot, allowWrites, allowDeletes, provider: options?.provider, apiKey: options?.apiKey }; | ||
| // Same "human sets an env var once, at server-launch time" posture as | ||
| // allowWrites/allowDeletes above — story mode's concurrency is a | ||
| // cost/rate-limit-affecting setting, never a per-call tool argument (see | ||
| // McpToolContext.concurrency's own doc comment). Resolved to a definite, | ||
| // in-range number here (unlike the booleans above, there's no meaningful | ||
| // "unset" state worth preserving past this point) — an unparseable or | ||
| // out-of-range env var falls back to DEFAULT_CONCURRENCY/is clamped to | ||
| // HARD_MAX_CONCURRENCY, the same silent-but-safe posture `Number.isFinite` | ||
| // checks already use elsewhere for a malformed CLI flag value. | ||
| const parsedConcurrency = Number(process.env.FIVE46_MCP_CONCURRENCY); | ||
| const concurrency = Number.isFinite(parsedConcurrency) && parsedConcurrency > 0 ? Math.min(parsedConcurrency, runLoop_1.HARD_MAX_CONCURRENCY) : runLoop_1.DEFAULT_CONCURRENCY; | ||
| const context = { projectRoot, allowWrites, allowDeletes, concurrency, provider: options?.provider, apiKey: options?.apiKey }; | ||
| const server = new mcp_js_1.McpServer({ name: 'five46', version: package_json_1.default.version }); | ||
@@ -37,2 +49,3 @@ server.registerTool('five46_test', { | ||
| inputSchema: schemas_1.testToolInputSchema, | ||
| outputSchema: schemas_1.testToolOutputSchema, | ||
| }, async (params) => (0, tools_1.runTestTool)(params, context)); | ||
@@ -50,4 +63,5 @@ server.registerTool('five46_api', { | ||
| inputSchema: schemas_1.apiToolInputSchema, | ||
| outputSchema: schemas_1.apiToolOutputSchema, | ||
| }, async (params) => (0, tools_1.runApiTool)(params, context)); | ||
| return { server, context }; | ||
| } |
+76
-23
@@ -22,5 +22,31 @@ import type { LlmProvider } from '../llm/types'; | ||
| allowDeletes: boolean; | ||
| /** Bounded-concurrency cap for `story` mode — see `DEFAULT_CONCURRENCY`/ | ||
| * `HARD_MAX_CONCURRENCY` (runLoop.ts). Sourced only from | ||
| * `FIVE46_MCP_CONCURRENCY` at server construction (`server.ts`), never a | ||
| * per-call tool argument — same "cost/rate-limit-affecting parameter stays | ||
| * off the calling AI's side" precedent as `allowWrites`/`allowDeletes`. | ||
| * Always a resolved number by the time a tool handler sees it (`server.ts` | ||
| * applies the default before constructing this context), unlike those two | ||
| * only in that there's no meaningful "unset" state to preserve here. */ | ||
| concurrency: number; | ||
| provider?: LlmProvider; | ||
| apiKey?: string; | ||
| } | ||
| /** Mirrors `schemas.ts`'s `toolOutputSchema` exactly — the MCP SDK | ||
| * (`@modelcontextprotocol/sdk@^1.30.0`) strictly validates a non-error | ||
| * result's `structuredContent` against that Zod shape, so this TS type and | ||
| * that schema must never drift apart. */ | ||
| export interface StructuredToolOutput { | ||
| passed: boolean; | ||
| outcome?: string; | ||
| specPath?: string; | ||
| acceptanceCriteria?: { | ||
| index: number; | ||
| goal: string; | ||
| outcome: string; | ||
| passed: boolean; | ||
| specPath?: string; | ||
| }[]; | ||
| [key: string]: unknown; | ||
| } | ||
| export interface McpToolResult { | ||
@@ -32,2 +58,9 @@ content: { | ||
| isError?: boolean; | ||
| /** Additive, machine-parseable parallel channel alongside `content`'s | ||
| * free text — see `StructuredToolOutput`'s own doc comment. Omitted | ||
| * entirely on an early validation error (a missing goal/story, a bad | ||
| * baseUrl, ...) — nothing ever ran, so there's honestly nothing | ||
| * structural to report; the MCP SDK only requires it on a non-error | ||
| * result anyway. */ | ||
| structuredContent?: StructuredToolOutput; | ||
| [key: string]: unknown; | ||
@@ -37,3 +70,7 @@ } | ||
| url: string; | ||
| goal: string; | ||
| /** Exactly one of `goal`/`story` is required — validated in | ||
| * `runTestTool` itself (not expressible directly in the Zod input shape; | ||
| * see `schemas.ts`'s comment on the pair). */ | ||
| goal?: string; | ||
| story?: string; | ||
| maxSteps?: number; | ||
@@ -43,29 +80,45 @@ headed?: boolean; | ||
| } | ||
| /** `five46_test`'s MCP handler — mirrors `cli.ts`'s `runE2eTest` in what it | ||
| * does, but never calls `console.log`: a stdio MCP server's stdout is the | ||
| * literal JSON-RPC transport, so every disclosure/progress line that would | ||
| * print live on the CLI is instead collected into the single returned | ||
| * `content` block once the run completes. No `out` parameter — the | ||
| * generated spec's path is always auto-derived under `context.projectRoot`, | ||
| * never caller-chosen, so there's no write-path parameter to validate at | ||
| * all for the output artifact (only `storageStatePath`, a read, goes | ||
| * through `resolveMcpPath`). */ | ||
| /** `five46_test`'s MCP handler — mirrors `cli.ts`'s `runE2eTest`/ | ||
| * `runStoryScenarios` in what it does, but never calls `console.log`: a | ||
| * stdio MCP server's stdout is the literal JSON-RPC transport, so every | ||
| * disclosure/progress line that would print live on the CLI is instead | ||
| * collected into the single returned `content` block once the run(s) | ||
| * complete. No `out` parameter — the generated spec's path is always | ||
| * auto-derived under `context.projectRoot`, never caller-chosen, so | ||
| * there's no write-path parameter to validate at all for the output | ||
| * artifact (only `storageStatePath`, a read, goes through | ||
| * `resolveMcpPath`). | ||
| * | ||
| * `story` mode (params.story set): splits the raw story into independent | ||
| * goals (`splitUserStory`, storySplitter.ts), then runs each one through | ||
| * `runOneTestScenario` with up to `context.concurrency` in flight at once | ||
| * (`runWithConcurrency`, concurrencyPool.ts) — the exact same engine call | ||
| * per scenario as the ordinary `goal` path, so every existing safety/speed | ||
| * mechanism applies automatically. Returns one aggregated per-AC report; | ||
| * `isError` unless every scenario reached `goal-reached`, mirroring the | ||
| * single-goal path's own isError-mirrors-CI-gating rule. */ | ||
| export declare function runTestTool(params: TestToolParams, context: McpToolContext): Promise<McpToolResult>; | ||
| export interface ApiToolParams { | ||
| baseUrl: string; | ||
| goal: string; | ||
| /** Exactly one of `goal`/`story` is required — same rule as | ||
| * `TestToolParams`, validated via the same `validateGoalOrStory`. */ | ||
| goal?: string; | ||
| story?: string; | ||
| maxSteps?: number; | ||
| } | ||
| /** `five46_api`'s MCP handler — mirrors `cli.ts`'s `runApiTestCommand`, with | ||
| * the same "collect, don't print live" adaptation for stdio as | ||
| * `runTestTool`. `allowWrites`/`allowDeletes` come only from `context` | ||
| * (server-startup env vars) — the tool's own input schema has no such | ||
| * fields, so there is no argument path that could set them. `allowedHosts` | ||
| * is always empty: no host-allowlist parameter in v1, closing an | ||
| * SSRF-shaped gap an exposed `allowHosts` parameter would otherwise open | ||
| * (an outer, less-trusted caller directing requests at arbitrary internal | ||
| * hosts even with writes fully closed off). Real-time write visibility | ||
| * (`onWrite`, a live console banner on the CLI) is collected into an | ||
| * ordered list and folded into the final report instead — MCP progress | ||
| * notifications are a named, deferred enhancement, not built here. */ | ||
| /** `five46_api`'s MCP handler — mirrors `cli.ts`'s `runApiTestCommand`/ | ||
| * `runApiStoryScenarios`, with the same "collect, don't print live" | ||
| * adaptation for stdio as `runTestTool`. `allowWrites`/`allowDeletes` come | ||
| * only from `context` (server-startup env vars) — the tool's own input | ||
| * schema has no such fields, so there is no argument path that could set | ||
| * them. `allowedHosts` is always empty: no host-allowlist parameter in v1, | ||
| * closing an SSRF-shaped gap an exposed `allowHosts` parameter would | ||
| * otherwise open (an outer, less-trusted caller directing requests at | ||
| * arbitrary internal hosts even with writes fully closed off). Real-time | ||
| * write visibility (`onWrite`, a live console banner on the CLI) is | ||
| * collected into an ordered list and folded into the final report instead — | ||
| * MCP progress notifications are a named, deferred enhancement, not built | ||
| * here. | ||
| * | ||
| * `story` mode: same design as `runTestTool`'s — see its doc comment. */ | ||
| export declare function runApiTool(params: ApiToolParams, context: McpToolContext): Promise<McpToolResult>; |
+201
-78
@@ -21,8 +21,18 @@ "use strict"; | ||
| const apiRootCause_1 = require("../agent/apiRootCause"); | ||
| function textResult(text) { | ||
| return { content: [{ type: 'text', text }] }; | ||
| const storySplitter_1 = require("../agent/storySplitter"); | ||
| const concurrencyPool_1 = require("../agent/concurrencyPool"); | ||
| const runLoop_1 = require("../agent/runLoop"); | ||
| function textResult(text, structuredContent) { | ||
| return { content: [{ type: 'text', text }], ...(structuredContent ? { structuredContent } : {}) }; | ||
| } | ||
| function errorResult(text) { | ||
| return { content: [{ type: 'text', text }], isError: true }; | ||
| function errorResult(text, structuredContent) { | ||
| return { content: [{ type: 'text', text }], isError: true, ...(structuredContent ? { structuredContent } : {}) }; | ||
| } | ||
| function validateGoalOrStory(params) { | ||
| if (!params.goal && !params.story) | ||
| return { ok: false, error: 'exactly one of "goal"/"story" is required' }; | ||
| if (params.goal && params.story) | ||
| return { ok: false, error: '"goal" and "story" are mutually exclusive — provide exactly one' }; | ||
| return { ok: true }; | ||
| } | ||
| /** Loads and minimally validates a `five46 login`-produced session | ||
@@ -55,12 +65,82 @@ * file, the same shape `cli.ts`'s own `loadStorageStateFile` checks — | ||
| } | ||
| /** `five46_test`'s MCP handler — mirrors `cli.ts`'s `runE2eTest` in what it | ||
| * does, but never calls `console.log`: a stdio MCP server's stdout is the | ||
| * literal JSON-RPC transport, so every disclosure/progress line that would | ||
| * print live on the CLI is instead collected into the single returned | ||
| * `content` block once the run completes. No `out` parameter — the | ||
| * generated spec's path is always auto-derived under `context.projectRoot`, | ||
| * never caller-chosen, so there's no write-path parameter to validate at | ||
| * all for the output artifact (only `storageStatePath`, a read, goes | ||
| * through `resolveMcpPath`). */ | ||
| /** One single-goal browser run — the shared core of `runTestTool`'s | ||
| * `goal` path and its `story` path's per-scenario calls. **Never throws, | ||
| * always resolves** — every error, not just `AgentBrowserUnavailableError`, | ||
| * is caught and returned as a `tooling-error` outcome. This matters far | ||
| * more here than it looks: in `story` mode each scenario's call runs as | ||
| * one task inside `runWithConcurrency`'s shared `Promise.all` — a single | ||
| * rejected task aborts the *entire* batch, discarding every other | ||
| * scenario's already-completed result along with it, not just failing the | ||
| * one scenario that hit the error. Found via a deliberate review of this | ||
| * exact risk (an untested edge case, not a live incident): the original | ||
| * version only caught `AgentBrowserUnavailableError` from the `runAgent` | ||
| * call and left the report-generation/write step after it completely | ||
| * unguarded, so either an unexpected exception from `runAgent` or a write | ||
| * failure afterward (a full disk, an unwritable directory) would silently | ||
| * sink an entire multi-scenario batch. */ | ||
| async function runOneTestScenario(url, goal, maxSteps, headed, storageState, provider, llmApiKey, allowDeletes, projectRoot, artifactSuffix) { | ||
| const artifactDir = (0, path_1.join)(projectRoot, `five46-mcp-agent-${artifactSuffix}`); | ||
| const destructiveClicks = []; | ||
| let run; | ||
| try { | ||
| run = await (0, runner_1.runAgent)({ | ||
| url, | ||
| goal, | ||
| provider, | ||
| apiKey: llmApiKey, | ||
| maxSteps, | ||
| headless: !headed, | ||
| artifactDir, | ||
| storageState, | ||
| allowDeletes, | ||
| onDestructiveClick: (name, reason) => destructiveClicks.push(`clicked "${name}" (${reason})`), | ||
| }); | ||
| } | ||
| catch (err) { | ||
| if (err instanceof browser_1.AgentBrowserUnavailableError) | ||
| return { outcome: 'tooling-error', text: err.message }; | ||
| return { outcome: 'tooling-error', text: (0, redact_1.redactSecrets)(err instanceof Error ? err.message : String(err), [llmApiKey]) }; | ||
| } | ||
| try { | ||
| const destructiveSection = destructiveClicks.length > 0 ? `\n\nDestructive click(s) performed during this run:\n${destructiveClicks.map((w) => ` -> ${w}`).join('\n')}` : ''; | ||
| // Always on for MCP (no per-call flag — see `--no-root-cause`'s CLI-only | ||
| // scope in cli.ts) — one bounded extra call against a run that may | ||
| // already be up to 50 steps, and this tool's parameter surface | ||
| // deliberately carries no cost/safety toggles for the calling AI. | ||
| const rootCauseHypothesis = run.outcome === 'assertion-failed' ? await (0, rootCause_1.generateRootCauseHypothesis)(run, provider, llmApiKey) : undefined; | ||
| const reportText = (0, redact_1.redactSecrets)((0, failureReport_1.formatFailureReport)(run, rootCauseHypothesis) + destructiveSection, [llmApiKey]); | ||
| const outPath = (0, path_1.join)(projectRoot, `five46-agent-${run.runId}.spec.ts`); | ||
| (0, fs_1.writeFileSync)(outPath, (0, redact_1.redactSecrets)((0, generateSpec_1.generateAgentSpec)(run), [llmApiKey]), 'utf8'); | ||
| return { outcome: run.outcome, text: `${reportText}\n\nWrote ${run.steps.filter((s) => s.ok).length} confirmed-working step(s) to ${outPath}`, specPath: outPath }; | ||
| } | ||
| catch (err) { | ||
| return { | ||
| outcome: 'tooling-error', | ||
| text: `run reached outcome "${run.outcome}" but failed while writing its report/spec: ${(0, redact_1.redactSecrets)(err instanceof Error ? err.message : String(err), [llmApiKey])}`, | ||
| }; | ||
| } | ||
| } | ||
| /** `five46_test`'s MCP handler — mirrors `cli.ts`'s `runE2eTest`/ | ||
| * `runStoryScenarios` in what it does, but never calls `console.log`: a | ||
| * stdio MCP server's stdout is the literal JSON-RPC transport, so every | ||
| * disclosure/progress line that would print live on the CLI is instead | ||
| * collected into the single returned `content` block once the run(s) | ||
| * complete. No `out` parameter — the generated spec's path is always | ||
| * auto-derived under `context.projectRoot`, never caller-chosen, so | ||
| * there's no write-path parameter to validate at all for the output | ||
| * artifact (only `storageStatePath`, a read, goes through | ||
| * `resolveMcpPath`). | ||
| * | ||
| * `story` mode (params.story set): splits the raw story into independent | ||
| * goals (`splitUserStory`, storySplitter.ts), then runs each one through | ||
| * `runOneTestScenario` with up to `context.concurrency` in flight at once | ||
| * (`runWithConcurrency`, concurrencyPool.ts) — the exact same engine call | ||
| * per scenario as the ordinary `goal` path, so every existing safety/speed | ||
| * mechanism applies automatically. Returns one aggregated per-AC report; | ||
| * `isError` unless every scenario reached `goal-reached`, mirroring the | ||
| * single-goal path's own isError-mirrors-CI-gating rule. */ | ||
| async function runTestTool(params, context) { | ||
| const validation = validateGoalOrStory(params); | ||
| if (!validation.ok) | ||
| return errorResult(validation.error); | ||
| // Declared here (not inside the inner try) so the outer catch can still | ||
@@ -97,38 +177,30 @@ // redact using whatever was actually resolved by the time an error hit — | ||
| } | ||
| const runId = Date.now().toString(36); | ||
| const artifactDir = (0, path_1.join)(context.projectRoot, `five46-mcp-agent-${runId}`); | ||
| // Same source as `five46_api`'s own delete-gating (FIVE46_MCP_ALLOW_DELETES, | ||
| // read once at server startup) — no new env var, no new tool-schema | ||
| // field, matching the existing "never a per-call argument" posture. | ||
| const destructiveClicks = []; | ||
| let run; | ||
| try { | ||
| run = await (0, runner_1.runAgent)({ | ||
| url: params.url, | ||
| goal: params.goal, | ||
| provider, | ||
| apiKey: llmApiKey, | ||
| maxSteps: params.maxSteps, | ||
| headless: !params.headed, | ||
| artifactDir, | ||
| storageState, | ||
| allowDeletes: context.allowDeletes, | ||
| onDestructiveClick: (name, reason) => destructiveClicks.push(`clicked "${name}" (${reason})`), | ||
| if (params.story) { | ||
| const { goals, clamped } = await (0, storySplitter_1.splitUserStory)(params.story, provider, llmApiKey); | ||
| const clampNote = clamped ? `Note: the story split into more scenarios than the cap of ${runLoop_1.HARD_MAX_SCENARIOS} — running the first ${runLoop_1.HARD_MAX_SCENARIOS} only.\n\n` : ''; | ||
| const batchId = Date.now().toString(36); | ||
| const tasks = goals.map((goal, i) => async () => { | ||
| const result = await runOneTestScenario(params.url, goal, params.maxSteps, params.headed, storageState, provider, llmApiKey, context.allowDeletes, context.projectRoot, `${batchId}-ac${i + 1}`); | ||
| return { goal, result }; | ||
| }); | ||
| const outcomes = await (0, concurrencyPool_1.runWithConcurrency)(tasks, Math.max(1, context.concurrency)); | ||
| let allPassed = true; | ||
| let passedCount = 0; | ||
| const acceptanceCriteria = []; | ||
| const lines = outcomes.map(({ goal, result }, i) => { | ||
| const passed = result.outcome === 'goal-reached'; | ||
| if (passed) | ||
| passedCount++; | ||
| else | ||
| allPassed = false; | ||
| acceptanceCriteria.push({ index: i + 1, goal, outcome: result.outcome, passed, specPath: result.specPath }); | ||
| const header = `AC${i + 1}: ${passed ? 'PASS' : `FAIL (${result.outcome})`} — ${goal}`; | ||
| return passed ? header : `${header}\n${result.text}`; | ||
| }); | ||
| const finalText = `${clampNote}Split into ${goals.length} scenario(s). ${passedCount}/${goals.length} acceptance criteria reached goal-reached.\n\n${lines.join('\n\n')}`; | ||
| const structuredContent = { passed: allPassed, acceptanceCriteria }; | ||
| return allPassed ? textResult(finalText, structuredContent) : errorResult(finalText, structuredContent); | ||
| } | ||
| catch (err) { | ||
| if (err instanceof browser_1.AgentBrowserUnavailableError) | ||
| return errorResult(err.message); | ||
| throw err; | ||
| } | ||
| const destructiveSection = destructiveClicks.length > 0 ? `\n\nDestructive click(s) performed during this run:\n${destructiveClicks.map((w) => ` -> ${w}`).join('\n')}` : ''; | ||
| // Always on for MCP (no per-call flag — see `--no-root-cause`'s CLI-only | ||
| // scope in cli.ts) — one bounded extra call against a run that may | ||
| // already be up to 50 steps, and this tool's parameter surface | ||
| // deliberately carries no cost/safety toggles for the calling AI. | ||
| const rootCauseHypothesis = run.outcome === 'assertion-failed' ? await (0, rootCause_1.generateRootCauseHypothesis)(run, provider, llmApiKey) : undefined; | ||
| const reportText = (0, redact_1.redactSecrets)((0, failureReport_1.formatFailureReport)(run, rootCauseHypothesis) + destructiveSection, [llmApiKey]); | ||
| const outPath = (0, path_1.join)(context.projectRoot, `five46-agent-${run.runId}.spec.ts`); | ||
| (0, fs_1.writeFileSync)(outPath, (0, redact_1.redactSecrets)((0, generateSpec_1.generateAgentSpec)(run), [llmApiKey]), 'utf8'); | ||
| const finalText = `${reportText}\n\nWrote ${run.steps.filter((s) => s.ok).length} confirmed-working step(s) to ${outPath}`; | ||
| const runId = Date.now().toString(36); | ||
| const { outcome, text, specPath } = await runOneTestScenario(params.url, params.goal, params.maxSteps, params.headed, storageState, provider, llmApiKey, context.allowDeletes, context.projectRoot, runId); | ||
| // isError mirrors the CLI's CI-gating exit-code rule exactly (see | ||
@@ -139,3 +211,5 @@ // DEVELOPMENT.md's "CI gating: exit codes" section): the calling IDE | ||
| // gets — not just report text it has to parse to learn the run failed. | ||
| return run.outcome === 'goal-reached' ? textResult(finalText) : errorResult(finalText); | ||
| const passed = outcome === 'goal-reached'; | ||
| const structuredContent = { passed, outcome, specPath }; | ||
| return passed ? textResult(text, structuredContent) : errorResult(text, structuredContent); | ||
| } | ||
@@ -146,15 +220,53 @@ catch (err) { | ||
| } | ||
| /** `five46_api`'s MCP handler — mirrors `cli.ts`'s `runApiTestCommand`, with | ||
| * the same "collect, don't print live" adaptation for stdio as | ||
| * `runTestTool`. `allowWrites`/`allowDeletes` come only from `context` | ||
| * (server-startup env vars) — the tool's own input schema has no such | ||
| * fields, so there is no argument path that could set them. `allowedHosts` | ||
| * is always empty: no host-allowlist parameter in v1, closing an | ||
| * SSRF-shaped gap an exposed `allowHosts` parameter would otherwise open | ||
| * (an outer, less-trusted caller directing requests at arbitrary internal | ||
| * hosts even with writes fully closed off). Real-time write visibility | ||
| * (`onWrite`, a live console banner on the CLI) is collected into an | ||
| * ordered list and folded into the final report instead — MCP progress | ||
| * notifications are a named, deferred enhancement, not built here. */ | ||
| /** One single-goal API run — the shared core of `runApiTool`'s `goal` path | ||
| * and its `story` path's per-scenario calls. Mirrors `runOneTestScenario`'s | ||
| * shape/reasoning for the API engine — **never throws, always resolves**, | ||
| * for the identical "one rejected task aborts the whole `story`-mode batch" | ||
| * reason (see its own doc comment). Previously had no error handling at | ||
| * all here (no browser dependency, so no `AgentBrowserUnavailableError` | ||
| * equivalent was ever added) — an unexpected `runApiTest` exception or a | ||
| * write failure afterward would have propagated straight up uncaught. */ | ||
| async function runOneApiScenario(baseUrl, goal, maxSteps, safety, authHeaders, provider, llmApiKey, projectRoot, secrets) { | ||
| try { | ||
| const writes = []; | ||
| const run = await (0, apiRunner_1.runApiTest)({ | ||
| baseUrl, | ||
| goal, | ||
| provider, | ||
| apiKey: llmApiKey, | ||
| maxSteps, | ||
| safety, | ||
| authHeaders, | ||
| onWrite: (method, url, reason) => writes.push(`${method} ${url} (${reason})`), | ||
| }); | ||
| const writesSection = writes.length > 0 ? `\n\nWrites performed during this run:\n${writes.map((w) => ` -> ${w}`).join('\n')}` : ''; | ||
| const rootCauseHypothesis = run.outcome === 'assertion-failed' ? await (0, apiRootCause_1.generateApiRootCauseHypothesis)(run, provider, llmApiKey) : undefined; | ||
| const reportText = (0, redact_1.redactSecrets)((0, apiFailureReport_1.formatApiFailureReport)(run, rootCauseHypothesis) + writesSection, secrets); | ||
| const outPath = (0, path_1.join)(projectRoot, `five46-api-${run.runId}.test.mjs`); | ||
| (0, fs_1.writeFileSync)(outPath, (0, redact_1.redactSecrets)((0, generateApiSpec_1.generateApiSpec)(run), secrets), 'utf8'); | ||
| return { outcome: run.outcome, text: `${reportText}\n\nWrote ${run.steps.filter((s) => s.ok).length} confirmed-working step(s) to ${outPath}`, specPath: outPath }; | ||
| } | ||
| catch (err) { | ||
| return { outcome: 'tooling-error', text: (0, redact_1.redactSecrets)(err instanceof Error ? err.message : String(err), secrets) }; | ||
| } | ||
| } | ||
| /** `five46_api`'s MCP handler — mirrors `cli.ts`'s `runApiTestCommand`/ | ||
| * `runApiStoryScenarios`, with the same "collect, don't print live" | ||
| * adaptation for stdio as `runTestTool`. `allowWrites`/`allowDeletes` come | ||
| * only from `context` (server-startup env vars) — the tool's own input | ||
| * schema has no such fields, so there is no argument path that could set | ||
| * them. `allowedHosts` is always empty: no host-allowlist parameter in v1, | ||
| * closing an SSRF-shaped gap an exposed `allowHosts` parameter would | ||
| * otherwise open (an outer, less-trusted caller directing requests at | ||
| * arbitrary internal hosts even with writes fully closed off). Real-time | ||
| * write visibility (`onWrite`, a live console banner on the CLI) is | ||
| * collected into an ordered list and folded into the final report instead — | ||
| * MCP progress notifications are a named, deferred enhancement, not built | ||
| * here. | ||
| * | ||
| * `story` mode: same design as `runTestTool`'s — see its doc comment. */ | ||
| async function runApiTool(params, context) { | ||
| const validation = validateGoalOrStory(params); | ||
| if (!validation.ok) | ||
| return errorResult(validation.error); | ||
| // Same reasoning as runTestTool: hoisted above the try so the outer catch | ||
@@ -194,21 +306,32 @@ // can redact with them, and kept in sync with llmApiKey as soon as it's | ||
| const safety = { allowWrites: context.allowWrites, allowDeletes: context.allowDeletes, targetOrigin, allowedHosts: new Set() }; | ||
| const writes = []; | ||
| const run = await (0, apiRunner_1.runApiTest)({ | ||
| baseUrl: params.baseUrl, | ||
| goal: params.goal, | ||
| provider, | ||
| apiKey: llmApiKey, | ||
| maxSteps: params.maxSteps, | ||
| safety, | ||
| authHeaders, | ||
| onWrite: (method, url, reason) => writes.push(`${method} ${url} (${reason})`), | ||
| }); | ||
| const writesSection = writes.length > 0 ? `\n\nWrites performed during this run:\n${writes.map((w) => ` -> ${w}`).join('\n')}` : ''; | ||
| const rootCauseHypothesis = run.outcome === 'assertion-failed' ? await (0, apiRootCause_1.generateApiRootCauseHypothesis)(run, provider, llmApiKey) : undefined; | ||
| const reportText = (0, redact_1.redactSecrets)((0, apiFailureReport_1.formatApiFailureReport)(run, rootCauseHypothesis) + writesSection, secrets); | ||
| const outPath = (0, path_1.join)(context.projectRoot, `five46-api-${run.runId}.test.mjs`); | ||
| (0, fs_1.writeFileSync)(outPath, (0, redact_1.redactSecrets)((0, generateApiSpec_1.generateApiSpec)(run), secrets), 'utf8'); | ||
| const finalText = `${reportText}\n\nWrote ${run.steps.filter((s) => s.ok).length} confirmed-working step(s) to ${outPath}`; | ||
| if (params.story) { | ||
| const { goals, clamped } = await (0, storySplitter_1.splitUserStory)(params.story, provider, llmApiKey); | ||
| const clampNote = clamped ? `Note: the story split into more scenarios than the cap of ${runLoop_1.HARD_MAX_SCENARIOS} — running the first ${runLoop_1.HARD_MAX_SCENARIOS} only.\n\n` : ''; | ||
| const tasks = goals.map((goal) => async () => { | ||
| const result = await runOneApiScenario(params.baseUrl, goal, params.maxSteps, safety, authHeaders, provider, llmApiKey, context.projectRoot, secrets); | ||
| return { goal, result }; | ||
| }); | ||
| const outcomes = await (0, concurrencyPool_1.runWithConcurrency)(tasks, Math.max(1, context.concurrency)); | ||
| let allPassed = true; | ||
| let passedCount = 0; | ||
| const acceptanceCriteria = []; | ||
| const lines = outcomes.map(({ goal, result }, i) => { | ||
| const passed = result.outcome === 'goal-reached'; | ||
| if (passed) | ||
| passedCount++; | ||
| else | ||
| allPassed = false; | ||
| acceptanceCriteria.push({ index: i + 1, goal, outcome: result.outcome, passed, specPath: result.specPath }); | ||
| const header = `AC${i + 1}: ${passed ? 'PASS' : `FAIL (${result.outcome})`} — ${goal}`; | ||
| return passed ? header : `${header}\n${result.text}`; | ||
| }); | ||
| const finalText = `${clampNote}Split into ${goals.length} scenario(s). ${passedCount}/${goals.length} acceptance criteria reached goal-reached.\n\n${lines.join('\n\n')}`; | ||
| const structuredContent = { passed: allPassed, acceptanceCriteria }; | ||
| return allPassed ? textResult(finalText, structuredContent) : errorResult(finalText, structuredContent); | ||
| } | ||
| const { outcome, text, specPath } = await runOneApiScenario(params.baseUrl, params.goal, params.maxSteps, safety, authHeaders, provider, llmApiKey, context.projectRoot, secrets); | ||
| // Same isError-mirrors-CI-gating rule as runTestTool — see its comment. | ||
| return run.outcome === 'goal-reached' ? textResult(finalText) : errorResult(finalText); | ||
| const passed = outcome === 'goal-reached'; | ||
| const structuredContent = { passed, outcome, specPath }; | ||
| return passed ? textResult(text, structuredContent) : errorResult(text, structuredContent); | ||
| } | ||
@@ -215,0 +338,0 @@ catch (err) { |
+1
-1
| { | ||
| "name": "five46", | ||
| "version": "0.1.1", | ||
| "version": "0.2.0", | ||
| "description": "Autonomous AI testing agent that verifies your app or API actually works while you're still building it. Give it a plain-English goal, your own LLM key (OpenAI/Anthropic/Gemini/Groq/Bedrock) drives the real thing locally, and a real standalone Playwright/node:test spec is captured as a permanent regression test afterward. Fully local, BYOK, no cloud sandbox.", | ||
@@ -5,0 +5,0 @@ "homepage": "https://github.com/sekharsdet/five46#readme", |
+143
-16
@@ -12,2 +12,4 @@ # five46 | ||
|  | ||
| > **Status:** early proof of concept, verified end-to-end against real live LLM keys across dozens of real-world sites and APIs. | ||
@@ -40,2 +42,8 @@ | ||
| recovery attempt instead of just failing the step. | ||
| - **Resilient generated specs** — when a real, live check confirms | ||
| Playwright's own `getByRole()` resolves uniquely to the exact element a | ||
| step acted on, the generated spec prefers it over a positional CSS | ||
| selector, since it's far more resistant to future DOM changes. Falls back | ||
| to the always-correct selector automatically wherever that check can't be | ||
| made — never changes what the live run itself does. | ||
| - **Root-cause hypotheses** — a failed assertion gets an LLM-generated | ||
@@ -55,5 +63,14 @@ hypothesis for what likely went wrong and what to check next. | ||
| `.webm`. | ||
| - **Structured planning** — `--structured-plan` plans the whole goal | ||
| upfront with one extra LLM call, then executes most steps directly | ||
| against the real page/response with no further live decision needed. | ||
| - **Structured planning** — on by default, one extra upfront LLM call plans | ||
| the whole goal, then most steps execute directly against the real | ||
| page/response with no further live decision needed; `--no-structured-plan` | ||
| opts back into the fully-adaptive, live-decision-every-step loop. | ||
| - **Fast per-step decisions** (`--fast-steps`, opt-in) — on Groq/Gemini, | ||
| swaps in a genuinely faster model tier for the high-frequency per-step | ||
| decision only; the upfront plan always uses your configured model. No | ||
| effect on OpenAI/Anthropic/Bedrock, already at their fastest reliable | ||
| tier. Opt-in, not default — see "Fast per-step decisions" below. | ||
| - **Story mode** (`--story`) — splits a raw, multi-AC user story into | ||
| independent goals and runs them with bounded concurrency, reporting a | ||
| clear pass/fail per acceptance criterion. See "Story mode" below. | ||
@@ -123,8 +140,14 @@ ## five46 vs. cloud AI testing platforms | ||
| step (never a "flagship" model), so per-run cost is low regardless of | ||
| which one you pick: | ||
| which one you pick. **If wall-clock speed is what you care about most, | ||
| pick Groq** — its whole differentiator is LPU-based inference hardware | ||
| built specifically for fast token generation, meaningfully faster | ||
| round-trips than typical GPU-hosted inference for an equivalent-size | ||
| model. Since a run's time is dominated by LLM round-trip latency (not | ||
| five46's own code), the provider you pick is the single biggest lever | ||
| you control over how fast a run feels: | ||
| | Provider | Get a key at | Notes | | ||
| |---|---|---| | ||
| | **Groq** | [console.groq.com/keys](https://console.groq.com/keys) → "Create API Key" | Free tier, no credit card required, generous rate limits — also the fastest provider here, built on inference-optimized hardware. | | ||
| | **Gemini** | [aistudio.google.com](https://aistudio.google.com/apikey) → "Get API key" | Free tier, no credit card required — the fastest path to a first successful run. | | ||
| | **Groq** | [console.groq.com/keys](https://console.groq.com/keys) → "Create API Key" | Free tier, no credit card required, generous rate limits. | | ||
| | **OpenAI** | [platform.openai.com/api-keys](https://platform.openai.com/api-keys) → "Create new secret key" | Account creation is free, but a key can't make real calls until you add a payment method — no meaningful free tier. | | ||
@@ -163,5 +186,8 @@ | **Anthropic** | [console.anthropic.com/settings/keys](https://console.anthropic.com/settings/keys) → "Create Key" | Same shape as OpenAI — you can browse the console for free, but need billing set up before a key actually works. | | ||
| `.webm` of the whole session), `--project name` (pull defaults from | ||
| `five46.config.json` — see below), `--structured-plan` (plan the whole | ||
| goal upfront, executing most steps with no further live LLM decision — | ||
| see below). | ||
| `five46.config.json` — see below), `--no-structured-plan` (opt out of the | ||
| default upfront-plan-then-fast-path behavior and use the fully-adaptive, | ||
| live-decision-every-step loop instead — see below), `--fast-steps` (opt-in, | ||
| use a faster model for per-step decisions on Groq/Gemini — see below), | ||
| `--story path` (split a raw multi-AC user story into independent goals and | ||
| run them concurrently instead of a single `--goal` — see below). | ||
@@ -285,14 +311,91 @@ A successful run writes a real, human-readable Playwright `.spec.ts` file | ||
| On by default. One extra LLM call plans the whole goal upfront; most steps | ||
| then execute directly against the real page/response with no further live | ||
| decision — falling back to a normal live decision only when a step's | ||
| prediction doesn't resolve cleanly. Same safety guarantees as an ordinary | ||
| run (destructive-click gating, method/host allowlisting) are enforced | ||
| independently at the fast path too, not skipped. This is the single biggest | ||
| lever for cutting a run's wall-clock time, since LLM round-trip latency — | ||
| not five46's own code — is the dominant per-run cost. | ||
| Confirming an outcome fast-paths too, not just navigating to it: a planned | ||
| `assert_visible` step fast-paths under the same rule as clicks/fills (its | ||
| target must resolve to exactly one real element, or it falls back to a live | ||
| decision) — the visibility check itself still runs for real against the | ||
| live page either way, nothing is assumed. `assert_text`/`assert_page_text` | ||
| always make a live decision, since they'd also need to predict an exact | ||
| expected text value for a page the plan never saw, a bigger risk than | ||
| predicting an element's role/name. A well-formed goal can often complete | ||
| with a single LLM call total (the upfront plan) if every step, including | ||
| the final confirmation, fast-paths. | ||
| ```bash | ||
| five46 test http://localhost:3000 --goal "..." --structured-plan | ||
| five46 test http://localhost:3000 --goal "..." --no-structured-plan | ||
| ``` | ||
| One extra LLM call plans the whole goal upfront; most steps then execute | ||
| directly against the real page/response with no further live decision — | ||
| falling back to a normal live decision only when a step's prediction | ||
| doesn't resolve cleanly. Same safety guarantees as an ordinary run | ||
| (destructive-click gating, method/host allowlisting) are enforced | ||
| independently at the fast path too, not skipped. Off by default; works on | ||
| `five46 api` too. | ||
| `--no-structured-plan` opts back into the fully-adaptive loop (a live | ||
| decision every single step) — works the same way on `five46 api`. Note: | ||
| this default applies to the `test`/`api` CLI commands only — MCP-driven | ||
| runs (`five46 mcp`) always use the fully-adaptive loop regardless, since | ||
| structured planning isn't exposed as an MCP tool parameter. | ||
| ## Fast per-step decisions | ||
| ```bash | ||
| five46 test http://localhost:3000 --goal "..." --fast-steps | ||
| ``` | ||
| Opt-in — off by default. On Groq and Gemini, swaps in a genuinely faster | ||
| model tier (`llama-3.1-8b-instant`, `gemini-flash-lite-latest`) for the | ||
| high-frequency per-step action-decision call only; the one-time upfront | ||
| plan (and the root-cause hypothesis call, if triggered) always use your | ||
| configured model, never the fast one. On OpenAI, Anthropic, and Bedrock | ||
| this flag has no effect — each is already at the fastest model tier that | ||
| provider offers without risking reliability on the strict JSON-only action | ||
| schema. | ||
| This is a real tradeoff, not a free win: a smaller/faster model is a | ||
| genuine, currently-unquantified risk to per-step decision quality (more | ||
| wrong-ref picks or retries), which is exactly why it's opt-in rather than | ||
| default like structured planning. If wall-clock speed matters most to you, | ||
| also consider picking Groq as your provider in the first place — see | ||
| "Getting a key" above. | ||
| ## Story mode | ||
| ```bash | ||
| five46 test http://localhost:3000 --story user-story.txt --concurrency 3 | ||
| ``` | ||
| A real user story or Jira ticket often bundles several acceptance criteria | ||
| together — often independent, sometimes mutually-exclusive scenarios | ||
| ("checkout succeeds with valid info," "checkout fails with an invalid | ||
| coupon") that can't coexist in one linear run. `--story <path>` reads the | ||
| raw story text, splits it into independent goals with one extra upfront LLM | ||
| call, then runs each one exactly like an ordinary `--goal` run — same | ||
| safety gating, same structured planning/`--fast-steps` speed path, its own | ||
| fresh browser session/artifact directory, its own generated spec file — | ||
| with up to `--concurrency` (default 3, hard-capped at 5) running at once. | ||
| Mutually exclusive with `--goal` — pass exactly one. Works the same way on | ||
| `five46 api`. | ||
| ``` | ||
| Split into 2 scenario(s): | ||
| AC1: log in with valid credentials and complete checkout, then confirm the order confirmation is shown | ||
| AC2: attempt checkout with no shipping details entered, then confirm a validation error is shown | ||
| === Story mode summary === | ||
| AC1: PASS — log in with valid credentials and complete checkout, then confirm the order confirmation is shown | ||
| AC2: PASS — attempt checkout with no shipping details entered, then confirm a validation error is shown | ||
| 2/2 acceptance criteria reached goal-reached. | ||
| ``` | ||
| Exit code is all-or-nothing, matching `--repeat`'s own CI philosophy: 0 | ||
| only if every acceptance criterion reached `goal-reached`. Splitting is | ||
| itself an LLM call and could occasionally group scenarios in an unintended | ||
| way — a malformed or unusable split response degrades safely to treating | ||
| the whole story as a single goal, never worse than not using `--story` at | ||
| all. | ||
| ## MCP server (IDE-embedded use) | ||
@@ -312,2 +415,26 @@ | ||
| Both tools also accept an optional `story` field alongside `goal` (exactly | ||
| one required) — the same story-mode splitting/bounded-concurrency described | ||
| above, letting a coding agent hand over a whole multi-AC story it just | ||
| implemented a feature against and get back a per-AC pass/fail. Concurrency | ||
| is set via `FIVE46_MCP_CONCURRENCY` in the server's own environment | ||
| (default 3, hard-capped at 5) — never a per-call tool argument, the same | ||
| posture as `allowWrites`/`allowDeletes`. | ||
| Both tools also return `structuredContent` — a machine-parseable | ||
| `{ passed, outcome, specPath }` (or `{ passed, acceptanceCriteria: [...] }` | ||
| for a `story` call) alongside the existing free-text report, so a calling | ||
| coding agent can branch on a real field instead of parsing prose out of the | ||
| report to decide whether to rework or move on. | ||
| ## Known limitations | ||
| - **No iframe or shadow DOM traversal** — elements inside an `<iframe>` or | ||
| a closed/open shadow root aren't visible to the agent's page snapshot | ||
| today. Works fine on pages that don't rely on either. | ||
| - **Chromium only** for browser mode — no Firefox/WebKit yet. | ||
| - A run itself is not deterministic (the same goal against the same page | ||
| can take a different path next time) — the generated spec is the frozen, | ||
| repeatable artifact; see "Quick start" above. | ||
| ## Development | ||
@@ -314,0 +441,0 @@ |
Sorry, the diff of this file is too big to display
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
545273
19.01%95
4.4%9240
15.99%444
40.06%16
6.67%