@enricai/barnacle 1.12.69 → 1.12.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/scraper/browser-click-expr.d.ts +3 -4
  2. package/dist/scraper/browser-click-expr.d.ts.map +1 -1
  3. package/dist/scraper/browser-click-expr.js +3 -4
  4. package/dist/scraper/browser-click-expr.js.map +1 -1
  5. package/dist/scraper/flow-runner.d.ts +27 -6
  6. package/dist/scraper/flow-runner.d.ts.map +1 -1
  7. package/dist/scraper/flow-runner.js +332 -61
  8. package/dist/scraper/flow-runner.js.map +1 -1
  9. package/dist/scraper/frame-target.d.ts +12 -0
  10. package/dist/scraper/frame-target.d.ts.map +1 -1
  11. package/dist/scraper/frame-target.js +1 -0
  12. package/dist/scraper/frame-target.js.map +1 -1
  13. package/dist/scraper/submit-control.d.ts +30 -2
  14. package/dist/scraper/submit-control.d.ts.map +1 -1
  15. package/dist/scraper/submit-control.js +79 -20
  16. package/dist/scraper/submit-control.js.map +1 -1
  17. package/dist/scraper/target-resolution-diagnostic.d.ts +53 -0
  18. package/dist/scraper/target-resolution-diagnostic.d.ts.map +1 -0
  19. package/dist/scraper/target-resolution-diagnostic.js +153 -0
  20. package/dist/scraper/target-resolution-diagnostic.js.map +1 -0
  21. package/dist/scripts/recon-browser.d.ts +32 -2
  22. package/dist/scripts/recon-browser.d.ts.map +1 -1
  23. package/dist/scripts/recon-browser.js +3 -1
  24. package/dist/scripts/recon-browser.js.map +1 -1
  25. package/dist/scripts/recon-generate.d.ts +6 -1
  26. package/dist/scripts/recon-generate.d.ts.map +1 -1
  27. package/dist/scripts/recon-generate.js +191 -70
  28. package/dist/scripts/recon-generate.js.map +1 -1
  29. package/package.json +1 -1
  30. package/dist/scraper/deep-query.d.ts +0 -44
  31. package/dist/scraper/deep-query.d.ts.map +0 -1
  32. package/dist/scraper/deep-query.js +0 -93
  33. package/dist/scraper/deep-query.js.map +0 -1
@@ -25,6 +25,7 @@ exports.findRecentBackendError = findRecentBackendError;
25
25
  exports.findRecentPageTransition = findRecentPageTransition;
26
26
  exports.isWizardExitAction = isWizardExitAction;
27
27
  exports.isAdvanceStep = isAdvanceStep;
28
+ exports.isSubmitIntentStep = isSubmitIntentStep;
28
29
  exports.isCheckboxOrRadioIntentStep = isCheckboxOrRadioIntentStep;
29
30
  exports.shouldCaptureSelectionState = shouldCaptureSelectionState;
30
31
  exports.flowHasSubmitSemantics = flowHasSubmitSemantics;
@@ -128,6 +129,7 @@ const session_teardown_1 = require("./session-teardown");
128
129
  const spa_readiness_1 = require("./spa-readiness");
129
130
  const stagehand_guard_1 = require("./stagehand-guard");
130
131
  const submit_control_1 = require("./submit-control");
132
+ const target_resolution_diagnostic_1 = require("./target-resolution-diagnostic");
131
133
  const watchdog_1 = require("./watchdog");
132
134
  const recon_shared_1 = require("../scripts/recon-shared");
133
135
  const client_1 = require("../testmail/client");
@@ -1195,6 +1197,36 @@ function isAdvanceStep(instruction) {
1195
1197
  const haystack = instruction.toLowerCase();
1196
1198
  return ADVANCE_STEP_PHRASES.some((p) => haystack.includes(p));
1197
1199
  }
1200
+ /**
1201
+ * Phrases (in the ORIGINAL flow instruction) that mark a step whose intent is to
1202
+ * SUBMIT a form — a final "Submit" click — as opposed to a field-answer
1203
+ * or advance step. Matched against the step text, not a resolved element
1204
+ * description. Deliberately narrow (a click-on-submit phrase, not the bare
1205
+ * word "submit") so a field named e.g. "Submit Date" or a verification step
1206
+ * that merely mentions the submit button is never misclassified.
1207
+ */
1208
+ const SUBMIT_INTENT_STEP_PHRASES = [
1209
+ "to submit the",
1210
+ "click 'submit'",
1211
+ 'click "submit"',
1212
+ "click the submit",
1213
+ "click submit",
1214
+ ];
1215
+ /**
1216
+ * Is this flow step a submit click (the final "Submit"/"Apply" action), rather
1217
+ * than a field-answer or advance step? Mirrors `isAdvanceStep` so a step that
1218
+ * unambiguously names a submit *click* in its own instruction is recognized as
1219
+ * submit-shaped even when the flow file has no explicit `submitStep` flag and
1220
+ * the step isn't the flow's last one. Keyed on the ORIGINAL step instruction,
1221
+ * same discipline as `isAdvanceStep`/`isCheckboxOrRadioIntentStep`. Pure;
1222
+ * unit-testable seam paralleling both.
1223
+ */
1224
+ function isSubmitIntentStep(instruction) {
1225
+ if (!instruction)
1226
+ return false;
1227
+ const haystack = instruction.toLowerCase();
1228
+ return SUBMIT_INTENT_STEP_PHRASES.some((p) => haystack.includes(p));
1229
+ }
1198
1230
  /**
1199
1231
  * Is this flow step's intent to check a checkbox or select a radio option?
1200
1232
  * Used to veto a weak page-wide DOM signal (htmlDelta/textChanged/formValueChanged)
@@ -1698,7 +1730,7 @@ function describeAttemptEffectSignals(pre, post, recentCaptureMeta, preMetaLengt
1698
1730
  * keeps healing opportunistically.
1699
1731
  */
1700
1732
  function shouldSkipTechnique(params) {
1701
- const { technique, priorAttempts, advanceUnmovedAfterAttempt1, phantomClickAfterAttempt1, submitShapedStep, } = params;
1733
+ const { technique, priorAttempts, advanceUnmovedAfterAttempt1, attempt1UnreachableViaLightDom, submitShapedStep, } = params;
1702
1734
  // Unmoved-advance short-circuit (measured: attempts 2-4 recovered a stuck
1703
1735
  // advance 0 times in 289 steps). When attempt-1's act-string clicked the Next
1704
1736
  // and the wizard did NOT move forward (non-advancing POST, or no effect),
@@ -1719,14 +1751,16 @@ function shouldSkipTechnique(params) {
1719
1751
  reason: "advance step did not move the wizard on attempt 1; re-observe/re-click cannot advance it — skipping to rephrase/replan",
1720
1752
  };
1721
1753
  }
1722
- // Phantom-click short-circuit: attempt 1 clicked something Stagehand
1723
- // believes exists, but pre/post shows zero network, zero URL change, and
1724
- // no real DOM growth — the click almost certainly landed on nothing (the
1725
- // recon-submit-phantom-click bug report's light-DOM resolver can't see
1726
- // into a shadow root / web component). Repeating observe-act /
1727
- // structured-click / observe-act-exclude re-resolves the SAME
1728
- // light-DOM-only view of the page and would no-op identically, so skip
1729
- // straight to deep-submit-locator (attempt 2) instead. llm-rephrase
1754
+ // Unreachable-via-light-DOM short-circuit: attempt 1 either phantom-clicked
1755
+ // (Stagehand reported success but pre/post shows zero network, zero URL
1756
+ // change, and no real DOM growth) or came back "unresolved" (act() found
1757
+ // zero candidates at all) — the recon-submit-phantom-click bug report's
1758
+ // light-DOM resolver can't see into a shadow root / web component in
1759
+ // either case, and "unresolved" is if anything stronger evidence of that
1760
+ // than "phantom" (there was no candidate to even click). Repeating
1761
+ // observe-act / structured-click / observe-act-exclude re-resolves the
1762
+ // SAME light-DOM-only view of the page and would no-op identically, so
1763
+ // skip straight to deep-submit-locator (attempt 2) instead. llm-rephrase
1730
1764
  // (attempt 4) is never skipped — a differently-worded instruction is still
1731
1765
  // a distinct attempt worth trying if the deep locator also fails. Gated on
1732
1766
  // submitShapedStep: the deep submit-control locator ranks submit-shaped
@@ -1734,14 +1768,14 @@ function shouldSkipTechnique(params) {
1734
1768
  // is a guaranteed no-op — leave the normal ladder intact so
1735
1769
  // structured-click / observe-act-exclude, the techniques that can
1736
1770
  // actually click it, still run.
1737
- if (phantomClickAfterAttempt1 === true &&
1771
+ if (attempt1UnreachableViaLightDom === true &&
1738
1772
  submitShapedStep === true &&
1739
1773
  (technique === "observe-act" ||
1740
1774
  technique === "structured-click" ||
1741
1775
  technique === "observe-act-exclude")) {
1742
1776
  return {
1743
1777
  skip: true,
1744
- reason: "attempt 1 was a phantom click (reported success, zero observable effect) on a submit-shaped step; re-observe/re-click cannot reach a target the light-DOM resolver can't see — escalating to the deep submit-control locator",
1778
+ reason: "attempt 1 could not reach the target via the light-DOM resolver (phantom click or zero candidates resolved) on a submit-shaped step; re-observe/re-click cannot reach a target the light-DOM resolver can't see — escalating to the deep submit-control locator",
1745
1779
  };
1746
1780
  }
1747
1781
  if (technique === "structured-click") {
@@ -3237,6 +3271,46 @@ function xpathTailForRetarget(xpath) {
3237
3271
  const tail = steps.slice(-2).filter(Boolean);
3238
3272
  return tail.length > 0 ? tail.join("/") : null;
3239
3273
  }
3274
+ /**
3275
+ * In-page source for `(tail) => Element | null`: resolves
3276
+ * {@link xpathTailForRetarget}'s loose `//`+tail re-anchor against the live
3277
+ * DOM, disambiguating when the loose pattern matches more than one element.
3278
+ * The tail deliberately drops every attribute above the leaf+parent tag
3279
+ * pair, so on a page with several same-tag-shaped siblings (e.g. a repeated
3280
+ * "sign in" control sitting where a "create account" submit button used to
3281
+ * resolve) `FIRST_ORDERED_NODE_TYPE` silently accepts whichever one happens
3282
+ * to come first in document order — not necessarily the element the primary
3283
+ * xpath was tracking. Snapshotting every match and preferring one that is
3284
+ * objectively submit-shaped — native `type="submit"`/`type="image"`/
3285
+ * form-owned button, or (reusing submit-control.ts's exported
3286
+ * {@link SUBMIT_SHAPE_FALLBACK_EXPR}) a tag/role-agnostic generic-action
3287
+ * control — and not negative-text (reuses submit-control.ts's own
3288
+ * {@link NEGATIVE_TEXT_EXPR} vocabulary rather than a second copy) recovers
3289
+ * the plausible candidate instead of an arbitrary one.
3290
+ * Falls back to the first document-order match when no candidate clears that
3291
+ * bar, preserving the previous behavior for every single-match (the common)
3292
+ * case.
3293
+ */
3294
+ const XPATH_TAIL_RETARGET_RESOLVE_FN_SRC = `((tail) => {
3295
+ const isNegative = ${submit_control_1.NEGATIVE_TEXT_EXPR};
3296
+ const accessibleName = (el) =>
3297
+ (el.getAttribute("aria-label") || el.textContent || "").replace(/\\s+/g, " ").trim().toLowerCase();
3298
+ const isFallbackShaped = ${submit_control_1.SUBMIT_SHAPE_FALLBACK_EXPR};
3299
+ const isSubmitShaped = (el) => {
3300
+ if (!el || !el.tagName) return false;
3301
+ const tag = el.tagName.toUpperCase();
3302
+ const type = (el.getAttribute("type") || "").toLowerCase();
3303
+ if (tag === "INPUT" && (type === "submit" || type === "image")) return true;
3304
+ if (tag === "BUTTON" && (type === "submit" || type === "") && el.closest("form")) return true;
3305
+ return isFallbackShaped(el, accessibleName(el), isNegative);
3306
+ };
3307
+ const r = document.evaluate("//" + tail, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
3308
+ const nodes = [];
3309
+ for (let i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
3310
+ if (nodes.length <= 1) return nodes[0] || null;
3311
+ const preferred = nodes.find((el) => isSubmitShaped(el) && !isNegative(accessibleName(el)));
3312
+ return preferred || nodes[0];
3313
+ })`;
3240
3314
  /**
3241
3315
  * Resolve any selector a caller might hold into a bare XPath body for
3242
3316
  * `document.evaluate`. `verifyFillReadback` is shared across call sites that
@@ -3487,17 +3561,21 @@ async function resolvedClickTargetStillPresent(target, selector) {
3487
3561
  }
3488
3562
  }
3489
3563
  /**
3490
- * Site-agnostic, attribute-based submit-shape signal read directly from the
3491
- * resolved click target — the counterpart to the flow-authored `submitStep`
3492
- * flag. A step the flow never flagged can still resolve onto an objectively
3493
- * submit-shaped control: `<input type="submit">` / `<input type="image">`, or
3494
- * a `<button>` with no `type` (or `type="submit"`) owned by a `<form>` — per
3495
- * the HTML spec a button's default type IS submit, so an unmarked in-form
3496
- * button commits the form exactly like an explicit `type="submit"` one.
3497
- * Deliberately NOT a CTA-wording word list (fragile, and site-specific) — only
3498
- * the element's own tag/type/form-ownership, which is the same shape on any
3499
- * site. Returns `false` — never manufactures a submit-shape veto — on a
3500
- * non-xpath selector, a miss, or an evaluate failure.
3564
+ * Site-agnostic submit-shape signal read directly from the resolved click
3565
+ * target — the counterpart to the flow-authored `submitStep` flag. A step
3566
+ * the flow never flagged can still resolve onto an objectively submit-shaped
3567
+ * control: `<input type="submit">` / `<input type="image">`, a `<button>`
3568
+ * with no `type` (or `type="submit"`) owned by a `<form>` — per the HTML
3569
+ * spec a button's default type IS submit, so an unmarked in-form button
3570
+ * commits the form exactly like an explicit `type="submit"` one — or, via
3571
+ * {@link SUBMIT_SHAPE_FALLBACK_EXPR}, a tag/role-agnostic control (e.g. a
3572
+ * `<div>`/`<a>`) whose accessible name reads a generic action verb, or is the
3573
+ * sole actionable control in its form. Reuses submit-control.ts's own
3574
+ * widened predicate instead of a narrower, hand-duplicated tag/type-only
3575
+ * check, so a legitimately-clicked non-native submit control is not
3576
+ * mis-classified as "not submit-shaped" here. Returns `false` — never
3577
+ * manufactures a submit-shape veto — on a non-xpath selector, a miss, or an
3578
+ * evaluate failure.
3501
3579
  *
3502
3580
  * `xpathTail` (optional): the n+16 fallback's own `xpathTailForRetarget`
3503
3581
  * re-anchor. Its `clickExpr` resolves primary-xpath-then-tail before
@@ -3516,15 +3594,20 @@ async function resolvedClickTargetIsSubmitShaped(target, selector, xpathTail = n
3516
3594
  const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
3517
3595
  let el = r.singleNodeValue;
3518
3596
  if (!el && ${JSON.stringify(xpathTail)}) {
3519
- const r2 = document.evaluate("//" + ${JSON.stringify(xpathTail)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
3520
- el = r2.singleNodeValue;
3597
+ el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)});
3521
3598
  }
3522
3599
  if (!el || !el.tagName) return false;
3523
3600
  const tag = el.tagName.toUpperCase();
3524
3601
  const type = (el.getAttribute("type") || "").toLowerCase();
3525
3602
  if (tag === "INPUT" && (type === "submit" || type === "image")) return true;
3526
3603
  if (tag === "BUTTON" && (type === "submit" || type === "") && el.closest("form")) return true;
3527
- return false;
3604
+ const isNegative = ${submit_control_1.NEGATIVE_TEXT_EXPR};
3605
+ const accessibleName = (node) =>
3606
+ (node.getAttribute("aria-label") || node.textContent || "").replace(/\\s+/g, " ").trim().toLowerCase();
3607
+ const isFallbackShaped = ${submit_control_1.SUBMIT_SHAPE_FALLBACK_EXPR};
3608
+ const name = accessibleName(el);
3609
+ if (isNegative(name)) return false;
3610
+ return isFallbackShaped(el, name, isNegative);
3528
3611
  })()`;
3529
3612
  try {
3530
3613
  return (await target.evaluate(expr)) === true;
@@ -7741,6 +7824,118 @@ function spliceEmailStepFillValue(step, code) {
7741
7824
  function redactIfSensitive(text, sensitiveValue) {
7742
7825
  return sensitiveValue && text === sensitiveValue ? "[redacted]" : text;
7743
7826
  }
7827
+ /**
7828
+ * Last-resort escalation for the cascade-exhaustion path: when every
7829
+ * resolution technique has already failed against the resolved (declared-or-
7830
+ * main) frame and no `frameSelector` was declared for this step, probe every
7831
+ * same-origin CHILD frame Stagehand's CDP layer has attached to
7832
+ * (`page.frames()`) for a submit-shaped candidate, using the same
7833
+ * tag/role-agnostic ranking (`buildRankSubmitCandidatesExpr`) and click
7834
+ * primitive (`buildClickByDeepIndexExpr`) the declared-frame cascade already
7835
+ * trusts. This closes the "target lives inside an undeclared iframe" gap for
7836
+ * the ONE case that's safe to probe blindly: a same-origin child, where
7837
+ * `location.href` is readable and its origin can be compared directly against
7838
+ * the top document's. A cross-origin child (e.g. a checkout iframe payment
7839
+ * widget) is excluded on purpose — CDP's `Runtime.evaluate` can still reach a
7840
+ * cross-origin OOPIF's document, so an origin check (not a thrown evaluate)
7841
+ * is the only thing that would stop this function from guessing which
7842
+ * cross-origin iframe hosts the target with no declared selector to anchor
7843
+ * on. That guess is exactly what `frameSelector` exists so a flow author
7844
+ * never has to make; this fallback stays within the same-origin case the
7845
+ * gap report calls out and leaves cross-origin as the flow author's declared
7846
+ * `frameSelector` responsibility, unchanged.
7847
+ *
7848
+ * Every candidate frame is ranked and, on a hit, clicked and verified through
7849
+ * the SAME `snapshotPage`/`classifyPhantomClick` pre/post pair the cascade's
7850
+ * own `deep-submit-locator` escalation uses (including the single runner-up
7851
+ * retry on a phantom top pick) — there is no new unverified success path,
7852
+ * only a new place to look for the candidate before giving up.
7853
+ *
7854
+ * Returns the clicked `FrameTarget` on a verified (non-phantom) click, or
7855
+ * `null` if no same-origin child frame yielded a submit-shaped candidate, no
7856
+ * candidate was actually clickable, or every click phantomed.
7857
+ */
7858
+ async function probeChildFrameSubmitFallback(params) {
7859
+ const { page, signalCounter } = params;
7860
+ // `page.mainFrameId`/`page.frames` are read defensively: some call sites
7861
+ // (and their test fakes) model a `Page` that never attaches child frames at
7862
+ // all and doesn't implement this pair, which is indistinguishable here from
7863
+ // "no child frames exist" — either way there is nothing for this fallback
7864
+ // to probe, so it degrades to its normal "nothing found" return rather than
7865
+ // throwing out of an otherwise-best-effort escalation.
7866
+ let candidateFrames;
7867
+ try {
7868
+ const mainFrameId = page.mainFrameId();
7869
+ candidateFrames = page.frames().filter((frame) => frame.frameId !== mainFrameId);
7870
+ }
7871
+ catch {
7872
+ return null;
7873
+ }
7874
+ if (candidateFrames.length === 0)
7875
+ return null;
7876
+ const pageOrigin = (() => {
7877
+ try {
7878
+ return new URL(page.url()).origin;
7879
+ }
7880
+ catch {
7881
+ return null;
7882
+ }
7883
+ })();
7884
+ if (!pageOrigin)
7885
+ return null;
7886
+ for (const frame of candidateFrames) {
7887
+ const frameUrl = await Promise.resolve(frame.evaluate("location.href")).catch(() => null);
7888
+ if (!frameUrl)
7889
+ continue;
7890
+ const frameOrigin = (() => {
7891
+ try {
7892
+ return new URL(frameUrl).origin;
7893
+ }
7894
+ catch {
7895
+ return null;
7896
+ }
7897
+ })();
7898
+ // Cross-origin: documented rule-out, not a code defect. See docblock.
7899
+ if (frameOrigin !== pageOrigin)
7900
+ continue;
7901
+ const target = (0, frame_target_1.childFrameTarget)(page, frame, "(auto-discovered same-origin iframe)");
7902
+ const rankResult = await Promise.resolve(target.evaluate((0, submit_control_1.buildRankSubmitCandidatesExpr)())).catch(() => null);
7903
+ const ranked = Array.isArray(rankResult) ? rankResult : [];
7904
+ if (ranked.length === 0)
7905
+ continue;
7906
+ // biome-ignore lint/style/noNonNullAssertion: guarded by the length check above
7907
+ const top = ranked[0];
7908
+ const pre = await snapshotPage(target, signalCounter, page);
7909
+ const clickResult = (await Promise.resolve(target.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(top.deepIndex))).catch(() => ({ clicked: false })));
7910
+ if (!clickResult.clicked)
7911
+ continue;
7912
+ const post = await snapshotPage(target, signalCounter, page);
7913
+ const verdict = (0, phantom_click_1.classifyPhantomClick)({
7914
+ actResultSuccess: true,
7915
+ pre,
7916
+ post,
7917
+ isSubmitShapedStep: true,
7918
+ });
7919
+ if (verdict !== "phantom")
7920
+ return target;
7921
+ const runnerUp = ranked[1];
7922
+ if (!runnerUp)
7923
+ continue;
7924
+ const runnerUpClickResult = (await Promise.resolve(target.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(runnerUp.deepIndex))).catch(() => ({ clicked: false })));
7925
+ if (!runnerUpClickResult.clicked)
7926
+ continue;
7927
+ const runnerUpPost = await snapshotPage(target, signalCounter, page);
7928
+ const runnerUpVerdict = (0, phantom_click_1.classifyPhantomClick)({
7929
+ actResultSuccess: true,
7930
+ pre,
7931
+ post: runnerUpPost,
7932
+ isSubmitShapedStep: true,
7933
+ });
7934
+ if (runnerUpVerdict !== "phantom")
7935
+ return target;
7936
+ }
7937
+ return null;
7938
+ }
7744
7939
  async function executeStepWithHealing(params) {
7745
7940
  const { stagehand, page, optional, upload, submitStep, captchaGated = false, emailStep = false, emailStepConfig, allocatedInbox = null, sessionProxy = null, flowHasSubmitSemantics: flowHasSubmitSemanticsFlag, stepIndex, totalSteps, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, rephraseModel, logger, captureFn, uploadFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, getSuppressedAisdkElementIdErrorCount, trajectory, onStepFailure, onStepHeal, } = params;
7746
7941
  // Mutable: the `emailStep` code-extract path splices the extracted code
@@ -8068,7 +8263,7 @@ async function executeStepWithHealing(params) {
8068
8263
  // when no solver key is configured or the solve otherwise fails — that
8069
8264
  // propagates out of this function unchanged, so the step fails cleanly
8070
8265
  // instead of silently falling through as if unverified-but-passing.
8071
- const isSubmitOrFinalStep = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag);
8266
+ const isSubmitOrFinalStep = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step);
8072
8267
  if (captchaGated && isSubmitOrFinalStep) {
8073
8268
  let captchaTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
8074
8269
  const sitekeyProbeExpr = `(() => {
@@ -8410,6 +8605,25 @@ async function executeStepWithHealing(params) {
8410
8605
  logger.error(`${formatStepPrefix(stepIndex, totalSteps)} backend error detected (submit endpoint returned 5xx: ${backendErrorUrl}); aborting cascade`);
8411
8606
  throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) backend 5xx at ${backendErrorUrl} — unrecoverable`, "backend-error-unrecoverable");
8412
8607
  }
8608
+ // Bounded same-origin child-iframe fallback: the probe above only ever
8609
+ // looked at the resolved (declared-or-main) frame's own light+shadow DOM,
8610
+ // so a target genuinely inside an undeclared same-origin iframe is
8611
+ // invisible to it. Only tried when this step never declared its own
8612
+ // frameSelector — a declared-and-used frame means the flow author already
8613
+ // told us where to look, and guessing elsewhere would second-guess that —
8614
+ // and only when this step is itself submit-shaped, since the probe ranks
8615
+ // for a submit-shaped candidate and firing it on a non-submit step (e.g.
8616
+ // a radio/select answer) could complete the step by clicking an unrelated
8617
+ // submit control in an unrelated same-origin child iframe.
8618
+ if (!frameTarget?.declaredFrameSelector &&
8619
+ (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step))) {
8620
+ const fallbackTarget = await probeChildFrameSubmitFallback({ page, signalCounter });
8621
+ if (fallbackTarget) {
8622
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} probe-absent: no candidate in the resolved frame, but a same-origin child iframe surfaced a submit-shaped candidate — clicked and verified there instead of failing`);
8623
+ trajectory?.push({ stepIndex, verifiedBy: "dom" });
8624
+ return "completed";
8625
+ }
8626
+ }
8413
8627
  // Capture diagnostics + write a failure dump BEFORE throwing so the
8414
8628
  // global replan path's `readFailureDumpEvidence` can populate the
8415
8629
  // prompt's CURRENTLY VISIBLE / UNFOCUSED OBSERVE / PAGE BODY HTML
@@ -8433,6 +8647,9 @@ async function executeStepWithHealing(params) {
8433
8647
  const unfocusedObserve = probeAbsentObservedUnfocused.length === 0 && frameTarget?.frame
8434
8648
  ? await deepLocatorCandidatesAsActions(page, frameTarget)
8435
8649
  : probeAbsentObservedUnfocused;
8650
+ const targetResolutionDiagnostic = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step)
8651
+ ? await (0, target_resolution_diagnostic_1.captureTargetResolutionDiagnosticSnapshot)(frameTarget, page)
8652
+ : null;
8436
8653
  const dumpPath = onStepFailure?.({
8437
8654
  stepIndex,
8438
8655
  phase,
@@ -8444,6 +8661,7 @@ async function executeStepWithHealing(params) {
8444
8661
  recentCaptures,
8445
8662
  bodyOuterHtml,
8446
8663
  unfocusedObserve,
8664
+ targetResolutionDiagnostic,
8447
8665
  }) ?? null;
8448
8666
  throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) probe found no candidates on page${dumpPath ? `; see ${dumpPath}` : ""}`, "probe-absent");
8449
8667
  }
@@ -8705,6 +8923,13 @@ async function executeStepWithHealing(params) {
8705
8923
  }
8706
8924
  };
8707
8925
  let phantomClickAfterAttempt1 = false;
8926
+ let attempt1UnreachableViaLightDom = false;
8927
+ // Last candidate list the deep-submit-locator itself ranked this step, if
8928
+ // any — reused by the cascade-exhausted diagnostic snapshot below so it
8929
+ // doesn't re-run buildRankSubmitCandidatesExpr (and inflate the rank-call
8930
+ // count the phantom-click-escalation tests assert on) when the cascade
8931
+ // already has an up-to-date ranking to report from.
8932
+ let lastRankedSubmitCandidates = null;
8708
8933
  for (let attempt = 1; attempt <= MAX_STEP_ATTEMPTS; attempt++) {
8709
8934
  // Telemetry-driven technique-skip: when a cascade technique's
8710
8935
  // preconditions cannot be met by the prior attempts' state, running
@@ -8713,7 +8938,8 @@ async function executeStepWithHealing(params) {
8713
8938
  // techniques faster.
8714
8939
  if (attempt > 1) {
8715
8940
  const wouldBeTechnique = attempt === 2
8716
- ? phantomClickAfterAttempt1 && (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))
8941
+ ? attempt1UnreachableViaLightDom &&
8942
+ (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step))
8717
8943
  ? "deep-submit-locator"
8718
8944
  : phantomClickAfterAttempt1
8719
8945
  ? "trusted-click-retry"
@@ -8731,8 +8957,8 @@ async function executeStepWithHealing(params) {
8731
8957
  errorMessage: a.errorMessage,
8732
8958
  })),
8733
8959
  advanceUnmovedAfterAttempt1,
8734
- phantomClickAfterAttempt1,
8735
- submitShapedStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
8960
+ attempt1UnreachableViaLightDom,
8961
+ submitShapedStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step),
8736
8962
  });
8737
8963
  if (decision.skip) {
8738
8964
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt} (${wouldBeTechnique}) skipped: ${decision.reason}`);
@@ -8869,13 +9095,14 @@ async function executeStepWithHealing(params) {
8869
9095
  }
8870
9096
  }
8871
9097
  else if (attempt === 2 &&
8872
- phantomClickAfterAttempt1 &&
8873
- (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))) {
8874
- // Deep submit-control locator: attempt 1 phantom-clicked (Stagehand
8875
- // reported success but pre/post showed zero effect), so the target is
8876
- // almost certainly unreachable via document.querySelectorAll — most
8877
- // likely rendered inside an open shadow root by a web-component /
8878
- // framework-native submit control (see recon-submit-phantom-click bug
9098
+ attempt1UnreachableViaLightDom &&
9099
+ (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step))) {
9100
+ // Deep submit-control locator: attempt 1 either phantom-clicked
9101
+ // (Stagehand reported success but pre/post showed zero effect) or
9102
+ // came back "unresolved" (act() found zero candidates at all), so the
9103
+ // target is almost certainly unreachable via document.querySelectorAll
9104
+ // — most likely rendered inside an open shadow root by a web-component
9105
+ // / framework-native submit control (see recon-submit-phantom-click bug
8879
9106
  // report). Rank every submit-shaped candidate the deep traversal can
8880
9107
  // reach and click the top-ranked one; the ranking already excludes
8881
9108
  // Back/Cancel/Save-draft-shaped controls, so a false-positive submit
@@ -8902,6 +9129,7 @@ async function executeStepWithHealing(params) {
8902
9129
  record.errorMessage = `deep-submit-locator: rank evaluate threw ${(0, errors_1.toErrorMessage)(err)}`;
8903
9130
  break;
8904
9131
  }
9132
+ lastRankedSubmitCandidates = ranked;
8905
9133
  if (ranked.length === 0) {
8906
9134
  record.errorMessage = "deep-submit-locator: no submit-shaped candidate found";
8907
9135
  break;
@@ -8994,7 +9222,7 @@ async function executeStepWithHealing(params) {
8994
9222
  }
8995
9223
  else if (attempt === 2 &&
8996
9224
  phantomClickAfterAttempt1 &&
8997
- !(submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))) {
9225
+ !(submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step))) {
8998
9226
  // Trusted-click retry: attempt 1 phantom-clicked a NON-submit control —
8999
9227
  // Stagehand reported success but pre/post showed zero effect. On a
9000
9228
  // design-system widget (React synthetic-event delegation, custom
@@ -9674,7 +9902,7 @@ async function executeStepWithHealing(params) {
9674
9902
  // `advanceTransitionBodyPattern` are unaffected.
9675
9903
  const domVerifiedForStep = isDomOnlyAdvanceVerified({
9676
9904
  hasPattern: advanceTransitionBodyPattern !== null,
9677
- isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
9905
+ isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step),
9678
9906
  isAdvance: isAdvanceStep(step),
9679
9907
  domVerified,
9680
9908
  networkIsRealAdvance,
@@ -9762,7 +9990,8 @@ async function executeStepWithHealing(params) {
9762
9990
  // gate (requireSubmitEndpoint) will re-litigate the credit below.
9763
9991
  const formValueWeakSignalAllowed = !(submitStep ||
9764
9992
  (isFinalStep && flowHasSubmitSemanticsFlag) ||
9765
- resolvedElementIsSubmitShaped) || requireSubmitEndpoint;
9993
+ resolvedElementIsSubmitShaped ||
9994
+ isSubmitIntentStep(step)) || requireSubmitEndpoint;
9766
9995
  const formValueVerified = isStateClass &&
9767
9996
  formValueWeakSignalAllowed &&
9768
9997
  post.formValueSignature !== pre.formValueSignature;
@@ -9868,7 +10097,7 @@ async function executeStepWithHealing(params) {
9868
10097
  pre,
9869
10098
  post,
9870
10099
  elementStateChanged: domVerified,
9871
- isSubmitShapedStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
10100
+ isSubmitShapedStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step),
9872
10101
  });
9873
10102
  // An `"effective"` verdict driven purely by the page-wide byte-delta
9874
10103
  // floor (`TRIVIAL_DOM_DELTA_BYTES`, 500B) is intentionally NOT trusted
@@ -9889,7 +10118,8 @@ async function executeStepWithHealing(params) {
9889
10118
  // respects the SAME DOM-only-advance veto as the block above: a wizard
9890
10119
  // "Next" step on a pattern-configured site whose only signal is a field
9891
10120
  // toggle must stay unverified, not get waved through by the verdict.
9892
- const domEffectiveVerdict = !(submitStep || (isFinalStep && flowHasSubmitSemanticsFlag)) && domVerifiedForStep;
10121
+ const domEffectiveVerdict = !(submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step)) &&
10122
+ domVerifiedForStep;
9893
10123
  let verified = networkIsRealAdvance ||
9894
10124
  urlChanged ||
9895
10125
  domVerifiedForStep ||
@@ -10140,7 +10370,7 @@ async function executeStepWithHealing(params) {
10140
10370
  const n16TrustedClickFailure = !n16TrustedDelivered && "reason" in n16TrustedClickResult
10141
10371
  ? n16TrustedClickResult
10142
10372
  : null;
10143
- const clickExpr = `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); let el = r.singleNodeValue; if (!el && ${JSON.stringify(xpathTail)}) { const r2 = document.evaluate("//" + ${JSON.stringify(xpathTail)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); el = r2.singleNodeValue; } if (!el || typeof el.click !== "function") return { fired: false }; if (el.tagName === "LABEL") { const wrapped = el.querySelector("input[type=checkbox], input[type=radio]"); if (wrapped) el = wrapped; } if (el.type === "checkbox" || el.type === "radio") { el.checked = true; el.dispatchEvent(new Event("click", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); return { fired: true, kind: "checkbox", checked: el.checked }; } let __n16SmMatched = false; ${(0, browser_click_expr_1.retargetToSelectionMarkerExpr)("el", "__n16SmMatched")} ${n16TrustedDelivered ? "" : (0, browser_click_expr_1.clickActivationExpr)("el")} if (__n16SmMatched) { el.dispatchEvent(new Event("change", { bubbles: true })); } return { fired: true, kind: "click" }; })()`;
10373
+ const clickExpr = `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); let el = r.singleNodeValue; if (!el && ${JSON.stringify(xpathTail)}) { el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)}); } if (!el || typeof el.click !== "function") return { fired: false }; if (el.tagName === "LABEL") { const wrapped = el.querySelector("input[type=checkbox], input[type=radio]"); if (wrapped) el = wrapped; } if (el.type === "checkbox" || el.type === "radio") { el.checked = true; el.dispatchEvent(new Event("click", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); return { fired: true, kind: "checkbox", checked: el.checked }; } let __n16SmMatched = false; ${(0, browser_click_expr_1.retargetToSelectionMarkerExpr)("el", "__n16SmMatched")} ${n16TrustedDelivered ? "" : (0, browser_click_expr_1.clickActivationExpr)("el")} if (__n16SmMatched) { el.dispatchEvent(new Event("change", { bubbles: true })); } return { fired: true, kind: "click" }; })()`;
10144
10374
  const n16FallbackTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
10145
10375
  const probeResult = (await n16FallbackTarget.evaluate(clickExpr));
10146
10376
  const fired = probeResult.fired;
@@ -10159,8 +10389,7 @@ async function executeStepWithHealing(params) {
10159
10389
  const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
10160
10390
  let node = r.singleNodeValue;
10161
10391
  if (!node && ${JSON.stringify(xpathTail)}) {
10162
- const r2 = document.evaluate("//" + ${JSON.stringify(xpathTail)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
10163
- node = r2.singleNodeValue;
10392
+ node = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)});
10164
10393
  }
10165
10394
  if (!node) return false;
10166
10395
  for (let depth = 0; depth < 6 && node; depth++) {
@@ -10243,8 +10472,7 @@ async function executeStepWithHealing(params) {
10243
10472
  const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
10244
10473
  let el = r.singleNodeValue;
10245
10474
  if (!el && ${JSON.stringify(xpathTail)}) {
10246
- const r2 = document.evaluate("//" + ${JSON.stringify(xpathTail)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
10247
- el = r2.singleNodeValue;
10475
+ el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)});
10248
10476
  }
10249
10477
  return el ? isDisabled(el) : false;
10250
10478
  })()`;
@@ -10284,7 +10512,7 @@ async function executeStepWithHealing(params) {
10284
10512
  }));
10285
10513
  const fallbackDomOnlyAdvance = shouldVetoFallbackAdvance({
10286
10514
  hasPattern: advanceTransitionBodyPattern !== null,
10287
- isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
10515
+ isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step),
10288
10516
  isAdvance: isAdvanceStep(step),
10289
10517
  retryUrlChanged,
10290
10518
  retryNetworkIsRealAdvance,
@@ -10345,7 +10573,8 @@ async function executeStepWithHealing(params) {
10345
10573
  // value is read below.
10346
10574
  const retrySubmitShaped = submitStep ||
10347
10575
  (isFinalStep && flowHasSubmitSemanticsFlag) ||
10348
- retryResolvedElementIsSubmitShaped;
10576
+ retryResolvedElementIsSubmitShaped ||
10577
+ isSubmitIntentStep(step);
10349
10578
  const weakDomSignalsAllowed = ((!isFinalStep && !submitStep && !retryResolvedElementIsSubmitShaped) ||
10350
10579
  requireSubmitEndpoint) &&
10351
10580
  !isCheckboxOrRadioIntentStep(step) &&
@@ -10568,7 +10797,7 @@ async function executeStepWithHealing(params) {
10568
10797
  // dumps in a 2026-06-10 survey had the paired touched+dirty + visible
10569
10798
  // error text pattern with 3 distinct rejection messages.
10570
10799
  if (record.resolvedMethod === "click" &&
10571
- (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))) {
10800
+ (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step))) {
10572
10801
  const live = await extractLivePageFormEvidence(page, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), {
10573
10802
  client: anthropic,
10574
10803
  knownErrorClassPrefixes,
@@ -10604,26 +10833,44 @@ async function executeStepWithHealing(params) {
10604
10833
  isAdvanceStep(step) &&
10605
10834
  !urlChanged &&
10606
10835
  !networkIsRealAdvance;
10607
- // Attempt 1 phantom-clicked: Stagehand reported success but pre/post
10608
- // shows zero observable effect. The zero-effect delta is the primary
10609
- // signal (classifyPhantomClick above); the live AISDK elementId
10610
- // suppression counter is corroborating evidence only — logged, never
10611
- // gating, since a nonzero count alone is too weak a signal on a run
10612
- // that sees dozens of suppressions across hundreds of unrelated steps.
10836
+ // Attempt 1's classifyPhantomClick verdict: "phantom" means Stagehand
10837
+ // reported success but pre/post shows zero observable effect; the
10838
+ // zero-effect delta is the primary signal there (classifyPhantomClick
10839
+ // above). "unresolved" means act() found zero candidates at all — a
10840
+ // strictly stronger signal that the target is unreachable via the
10841
+ // light-DOM resolver, since there wasn't even a candidate to click. The
10842
+ // live AISDK elementId suppression counter is corroborating evidence
10843
+ // only — logged, never gating, since a nonzero count alone is too weak
10844
+ // a signal on a run that sees dozens of suppressions across hundreds of
10845
+ // unrelated steps.
10613
10846
  phantomClickAfterAttempt1 = record.phantomClickVerdict === "phantom";
10614
- if (phantomClickAfterAttempt1) {
10847
+ const attempt1Unresolved = record.phantomClickVerdict === "unresolved";
10848
+ attempt1UnreachableViaLightDom = phantomClickAfterAttempt1 || attempt1Unresolved;
10849
+ if (attempt1UnreachableViaLightDom) {
10615
10850
  const suppressedCount = getSuppressedAisdkElementIdErrorCount?.();
10616
- const escalationTarget = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag)
10851
+ const verdictDescription = phantomClickAfterAttempt1
10852
+ ? "phantom click detected"
10853
+ : "no candidates resolved (unresolved verdict)";
10854
+ const outcomeDescription = phantomClickAfterAttempt1
10855
+ ? "reported success with no network/url/dom change"
10856
+ : "act() found zero candidates";
10857
+ // trusted-click-retry needs attempt 1's resolved selector
10858
+ // (triedSelectors), which is empty on an "unresolved" verdict, so a
10859
+ // non-submit step with that verdict falls through to the normal
10860
+ // ladder instead of escalating.
10861
+ const escalationTarget = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step)
10617
10862
  ? "escalating attempt 2 to deep-submit-locator"
10618
- : "non-submit step — escalating attempt 2 to trusted-click-retry (trusted CDP click on the resolved target)";
10619
- logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} phantom click detected on attempt 1 (${record.technique}): reported success with no network/url/dom change${suppressedCount !== undefined ? `; ${suppressedCount} AISDK elementId errors suppressed this session (corroborating, not causal)` : ""} — ${escalationTarget}`);
10863
+ : phantomClickAfterAttempt1
10864
+ ? "non-submit step — escalating attempt 2 to trusted-click-retry (trusted CDP click on the resolved target)"
10865
+ : "non-submit step — no resolved target for trusted-click-retry; attempt 2 continues the normal ladder";
10866
+ logger.warn(`${formatStepPrefix(stepIndex, totalSteps)} ${verdictDescription} on attempt 1 (${record.technique}): ${outcomeDescription}${suppressedCount !== undefined ? `; ${suppressedCount} AISDK elementId errors suppressed this session (corroborating, not causal)` : ""} — ${escalationTarget}`);
10620
10867
  }
10621
10868
  const postAttemptInvalidCount = await countNgInvalidContainers(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page));
10622
10869
  const earlyExit = isSubmitRevealedInvalid({
10623
10870
  // Treat the canonical submit click as "final" for this predicate
10624
10871
  // even when it lives mid-flow. See requireSubmitEndpoint derivation
10625
10872
  // above for the same gate-widening rationale.
10626
- isFinalStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
10873
+ isFinalStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step),
10627
10874
  requireSubmitEndpoint,
10628
10875
  resolvedMethod: record.resolvedMethod,
10629
10876
  effectSignals,
@@ -10643,7 +10890,7 @@ async function executeStepWithHealing(params) {
10643
10890
  // can reorder a later step forward — instead of burning the cascade.
10644
10891
  const advanceStalled = isAdvanceStalled({
10645
10892
  isAdvance: isAdvanceStep(step),
10646
- isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
10893
+ isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step),
10647
10894
  hasPattern: advanceTransitionBodyPattern !== null,
10648
10895
  clickFired: record.resolvedMethod === "click" && record.actResultSuccess === true,
10649
10896
  networkFired,
@@ -10658,6 +10905,26 @@ async function executeStepWithHealing(params) {
10658
10905
  }
10659
10906
  }
10660
10907
  }
10908
+ // Bounded same-origin child-iframe fallback: every attempt above only ever
10909
+ // operated against the resolved (declared-or-main) frame, so a target
10910
+ // genuinely inside an undeclared same-origin iframe was invisible to all of
10911
+ // them. Only tried when this step never declared its own frameSelector — a
10912
+ // declared-and-used frame means the flow author already told us where to
10913
+ // look, so this can only ever fire on a case that today always fails
10914
+ // (no regression risk to any currently-passing path) — and only when this
10915
+ // step is itself submit-shaped, matching every other
10916
+ // buildRankSubmitCandidatesExpr call site's guard in this file, since
10917
+ // firing on a non-submit step could complete it by clicking an unrelated
10918
+ // submit control in an unrelated same-origin child iframe.
10919
+ if (!frameTarget?.declaredFrameSelector &&
10920
+ (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step))) {
10921
+ const fallbackTarget = await probeChildFrameSubmitFallback({ page, signalCounter });
10922
+ if (fallbackTarget) {
10923
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} cascade-exhausted: no candidate resolvable in the declared/main frame, but a same-origin child iframe surfaced a submit-shaped candidate — clicked and verified there instead of failing`);
10924
+ trajectory?.push({ stepIndex, verifiedBy: "dom" });
10925
+ return "completed";
10926
+ }
10927
+ }
10661
10928
  const cascadeExhaustObservedFinal = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
10662
10929
  // observe() is blind to a cross-origin OOPIF, so a frame-scoped empty
10663
10930
  // result degrades to the deep-locator resolver for dump evidence — this
@@ -10680,6 +10947,9 @@ async function executeStepWithHealing(params) {
10680
10947
  const unfocusedObserve = cascadeExhaustObservedUnfocused.length === 0 && frameTarget?.frame
10681
10948
  ? await deepLocatorCandidatesAsActions(page, frameTarget)
10682
10949
  : cascadeExhaustObservedUnfocused;
10950
+ const targetResolutionDiagnostic = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step)
10951
+ ? await (0, target_resolution_diagnostic_1.captureTargetResolutionDiagnosticSnapshot)(frameTarget, page, lastRankedSubmitCandidates)
10952
+ : null;
10683
10953
  const dumpPath = onStepFailure?.({
10684
10954
  stepIndex,
10685
10955
  phase,
@@ -10691,6 +10961,7 @@ async function executeStepWithHealing(params) {
10691
10961
  recentCaptures,
10692
10962
  bodyOuterHtml,
10693
10963
  unfocusedObserve,
10964
+ targetResolutionDiagnostic,
10694
10965
  }) ?? null;
10695
10966
  if (dumpPath !== null) {
10696
10967
  logger.error(`${formatStepPrefix(stepIndex, totalSteps)} failed after ${MAX_STEP_ATTEMPTS} attempts; diagnostic bundle: ${dumpPath}`);