@enricai/barnacle 1.12.70 → 1.12.72

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 (37) hide show
  1. package/dist/recon/capture-filters.d.ts +8 -1
  2. package/dist/recon/capture-filters.d.ts.map +1 -1
  3. package/dist/recon/capture-filters.js +97 -26
  4. package/dist/recon/capture-filters.js.map +1 -1
  5. package/dist/scraper/browser-click-expr.d.ts +3 -4
  6. package/dist/scraper/browser-click-expr.d.ts.map +1 -1
  7. package/dist/scraper/browser-click-expr.js +3 -4
  8. package/dist/scraper/browser-click-expr.js.map +1 -1
  9. package/dist/scraper/flow-runner.d.ts +27 -6
  10. package/dist/scraper/flow-runner.d.ts.map +1 -1
  11. package/dist/scraper/flow-runner.js +328 -70
  12. package/dist/scraper/flow-runner.js.map +1 -1
  13. package/dist/scraper/frame-target.d.ts +12 -0
  14. package/dist/scraper/frame-target.d.ts.map +1 -1
  15. package/dist/scraper/frame-target.js +1 -0
  16. package/dist/scraper/frame-target.js.map +1 -1
  17. package/dist/scraper/submit-control.d.ts +52 -2
  18. package/dist/scraper/submit-control.d.ts.map +1 -1
  19. package/dist/scraper/submit-control.js +117 -21
  20. package/dist/scraper/submit-control.js.map +1 -1
  21. package/dist/scraper/target-resolution-diagnostic.d.ts +53 -0
  22. package/dist/scraper/target-resolution-diagnostic.d.ts.map +1 -0
  23. package/dist/scraper/target-resolution-diagnostic.js +153 -0
  24. package/dist/scraper/target-resolution-diagnostic.js.map +1 -0
  25. package/dist/scripts/recon-browser.d.ts +32 -2
  26. package/dist/scripts/recon-browser.d.ts.map +1 -1
  27. package/dist/scripts/recon-browser.js +2 -0
  28. package/dist/scripts/recon-browser.js.map +1 -1
  29. package/dist/scripts/recon-generate.d.ts +148 -6
  30. package/dist/scripts/recon-generate.d.ts.map +1 -1
  31. package/dist/scripts/recon-generate.js +575 -93
  32. package/dist/scripts/recon-generate.js.map +1 -1
  33. package/package.json +1 -1
  34. package/dist/scraper/deep-query.d.ts +0 -44
  35. package/dist/scraper/deep-query.d.ts.map +0 -1
  36. package/dist/scraper/deep-query.js +0 -93
  37. 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)
@@ -1556,9 +1588,10 @@ function isClickViewSwapVerified(params) {
1556
1588
  // a real network/URL transition, leaving it structurally unverifiable.
1557
1589
  // `resolvedElementIsSubmitShaped` closes the same gap from the OTHER
1558
1590
  // direction: a step the flow never flagged `submitStep` can still resolve
1559
- // onto an objectively submit-shaped control (an HTML `type="submit"`
1560
- // affordance, or an unmarked `<button>` owned by a `<form>`, whose default
1561
- // type IS submit) — that control needs the same real-transition proof
1591
+ // onto an objectively submit-shaped control per submit-control.ts's
1592
+ // SUBMIT_SHAPE_EXPR (an explicit `type="submit"` affordance, or a
1593
+ // non-native control that clears the same name/negative-text
1594
+ // corroboration bar) — that control needs the same real-transition proof
1562
1595
  // regardless of flow authoring, so a byte-positive form reset can't be
1563
1596
  // mistaken for a byte-positive real submit.
1564
1597
  if (submitStep || resolvedElementIsSubmitShaped)
@@ -1698,7 +1731,7 @@ function describeAttemptEffectSignals(pre, post, recentCaptureMeta, preMetaLengt
1698
1731
  * keeps healing opportunistically.
1699
1732
  */
1700
1733
  function shouldSkipTechnique(params) {
1701
- const { technique, priorAttempts, advanceUnmovedAfterAttempt1, phantomClickAfterAttempt1, submitShapedStep, } = params;
1734
+ const { technique, priorAttempts, advanceUnmovedAfterAttempt1, attempt1UnreachableViaLightDom, submitShapedStep, } = params;
1702
1735
  // Unmoved-advance short-circuit (measured: attempts 2-4 recovered a stuck
1703
1736
  // advance 0 times in 289 steps). When attempt-1's act-string clicked the Next
1704
1737
  // and the wizard did NOT move forward (non-advancing POST, or no effect),
@@ -1719,14 +1752,16 @@ function shouldSkipTechnique(params) {
1719
1752
  reason: "advance step did not move the wizard on attempt 1; re-observe/re-click cannot advance it — skipping to rephrase/replan",
1720
1753
  };
1721
1754
  }
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
1755
+ // Unreachable-via-light-DOM short-circuit: attempt 1 either phantom-clicked
1756
+ // (Stagehand reported success but pre/post shows zero network, zero URL
1757
+ // change, and no real DOM growth) or came back "unresolved" (act() found
1758
+ // zero candidates at all) — the recon-submit-phantom-click bug report's
1759
+ // light-DOM resolver can't see into a shadow root / web component in
1760
+ // either case, and "unresolved" is if anything stronger evidence of that
1761
+ // than "phantom" (there was no candidate to even click). Repeating
1762
+ // observe-act / structured-click / observe-act-exclude re-resolves the
1763
+ // SAME light-DOM-only view of the page and would no-op identically, so
1764
+ // skip straight to deep-submit-locator (attempt 2) instead. llm-rephrase
1730
1765
  // (attempt 4) is never skipped — a differently-worded instruction is still
1731
1766
  // a distinct attempt worth trying if the deep locator also fails. Gated on
1732
1767
  // submitShapedStep: the deep submit-control locator ranks submit-shaped
@@ -1734,14 +1769,14 @@ function shouldSkipTechnique(params) {
1734
1769
  // is a guaranteed no-op — leave the normal ladder intact so
1735
1770
  // structured-click / observe-act-exclude, the techniques that can
1736
1771
  // actually click it, still run.
1737
- if (phantomClickAfterAttempt1 === true &&
1772
+ if (attempt1UnreachableViaLightDom === true &&
1738
1773
  submitShapedStep === true &&
1739
1774
  (technique === "observe-act" ||
1740
1775
  technique === "structured-click" ||
1741
1776
  technique === "observe-act-exclude")) {
1742
1777
  return {
1743
1778
  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",
1779
+ 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
1780
  };
1746
1781
  }
1747
1782
  if (technique === "structured-click") {
@@ -3237,6 +3272,37 @@ function xpathTailForRetarget(xpath) {
3237
3272
  const tail = steps.slice(-2).filter(Boolean);
3238
3273
  return tail.length > 0 ? tail.join("/") : null;
3239
3274
  }
3275
+ /**
3276
+ * In-page source for `(tail) => Element | null`: resolves
3277
+ * {@link xpathTailForRetarget}'s loose `//`+tail re-anchor against the live
3278
+ * DOM, disambiguating when the loose pattern matches more than one element.
3279
+ * The tail deliberately drops every attribute above the leaf+parent tag
3280
+ * pair, so on a page with several same-tag-shaped siblings (e.g. a repeated
3281
+ * "sign in" control sitting where a "create account" submit button used to
3282
+ * resolve) `FIRST_ORDERED_NODE_TYPE` silently accepts whichever one happens
3283
+ * to come first in document order — not necessarily the element the primary
3284
+ * xpath was tracking. Snapshotting every match and preferring one that is
3285
+ * objectively submit-shaped — reusing submit-control.ts's exported
3286
+ * {@link SUBMIT_SHAPE_EXPR}, the single site-agnostic predicate every submit-
3287
+ * shape consumer now shares — and not negative-text (reuses submit-
3288
+ * control.ts's own {@link NEGATIVE_TEXT_EXPR} vocabulary rather than a
3289
+ * second copy) recovers 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 isSubmitShaped = ${submit_control_1.SUBMIT_SHAPE_EXPR};
3299
+ const r = document.evaluate("//" + tail, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
3300
+ const nodes = [];
3301
+ for (let i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
3302
+ if (nodes.length <= 1) return nodes[0] || null;
3303
+ const preferred = nodes.find((el) => isSubmitShaped(el) && !isNegative(accessibleName(el)));
3304
+ return preferred || nodes[0];
3305
+ })`;
3240
3306
  /**
3241
3307
  * Resolve any selector a caller might hold into a bare XPath body for
3242
3308
  * `document.evaluate`. `verifyFillReadback` is shared across call sites that
@@ -3487,17 +3553,26 @@ async function resolvedClickTargetStillPresent(target, selector) {
3487
3553
  }
3488
3554
  }
3489
3555
  /**
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.
3556
+ * Site-agnostic submit-shape signal read directly from the resolved click
3557
+ * target — the counterpart to the flow-authored `submitStep` flag. A step
3558
+ * the flow never flagged can still resolve onto an objectively submit-shaped
3559
+ * control, per submit-control.ts's exported {@link SUBMIT_SHAPE_EXPR}: an
3560
+ * `<input type="submit">` / `<input type="image">`, a `<button>`/`<input>`
3561
+ * carrying an EXPLICIT `type="submit"` attribute, or — the same
3562
+ * corroboration-requiring path a non-native control must clear — a
3563
+ * tag/role-agnostic control (e.g. a `<div>`/`<a>`) whose accessible name
3564
+ * reads a generic action verb, or is the sole actionable control in its
3565
+ * form. A default-type `<button>` merely owned by a `<form>` no longer
3566
+ * earns unconditional credit from that structural fact alone: the HTML
3567
+ * spec makes a button's default type submit, but structure alone is not
3568
+ * proof of submit intent (e.g. a "Show contact form" reveal toggle a page
3569
+ * happens to nest inside a `<form>`), so it must clear the same
3570
+ * corroboration bar as a non-native control. Reuses submit-control.ts's
3571
+ * own single predicate instead of a hand-duplicated tag/type-only check,
3572
+ * so a legitimately-clicked non-native submit control is not
3573
+ * mis-classified as "not submit-shaped" here. Returns `false` — never
3574
+ * manufactures a submit-shape veto — on a non-xpath selector, a miss, or an
3575
+ * evaluate failure.
3501
3576
  *
3502
3577
  * `xpathTail` (optional): the n+16 fallback's own `xpathTailForRetarget`
3503
3578
  * re-anchor. Its `clickExpr` resolves primary-xpath-then-tail before
@@ -3516,15 +3591,10 @@ async function resolvedClickTargetIsSubmitShaped(target, selector, xpathTail = n
3516
3591
  const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
3517
3592
  let el = r.singleNodeValue;
3518
3593
  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;
3521
- }
3522
- if (!el || !el.tagName) return false;
3523
- const tag = el.tagName.toUpperCase();
3524
- const type = (el.getAttribute("type") || "").toLowerCase();
3525
- if (tag === "INPUT" && (type === "submit" || type === "image")) return true;
3526
- if (tag === "BUTTON" && (type === "submit" || type === "") && el.closest("form")) return true;
3527
- return false;
3594
+ el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)});
3595
+ }
3596
+ const isSubmitShaped = ${submit_control_1.SUBMIT_SHAPE_EXPR};
3597
+ return isSubmitShaped(el);
3528
3598
  })()`;
3529
3599
  try {
3530
3600
  return (await target.evaluate(expr)) === true;
@@ -7741,6 +7811,118 @@ function spliceEmailStepFillValue(step, code) {
7741
7811
  function redactIfSensitive(text, sensitiveValue) {
7742
7812
  return sensitiveValue && text === sensitiveValue ? "[redacted]" : text;
7743
7813
  }
7814
+ /**
7815
+ * Last-resort escalation for the cascade-exhaustion path: when every
7816
+ * resolution technique has already failed against the resolved (declared-or-
7817
+ * main) frame and no `frameSelector` was declared for this step, probe every
7818
+ * same-origin CHILD frame Stagehand's CDP layer has attached to
7819
+ * (`page.frames()`) for a submit-shaped candidate, using the same
7820
+ * tag/role-agnostic ranking (`buildRankSubmitCandidatesExpr`) and click
7821
+ * primitive (`buildClickByDeepIndexExpr`) the declared-frame cascade already
7822
+ * trusts. This closes the "target lives inside an undeclared iframe" gap for
7823
+ * the ONE case that's safe to probe blindly: a same-origin child, where
7824
+ * `location.href` is readable and its origin can be compared directly against
7825
+ * the top document's. A cross-origin child (e.g. a checkout iframe payment
7826
+ * widget) is excluded on purpose — CDP's `Runtime.evaluate` can still reach a
7827
+ * cross-origin OOPIF's document, so an origin check (not a thrown evaluate)
7828
+ * is the only thing that would stop this function from guessing which
7829
+ * cross-origin iframe hosts the target with no declared selector to anchor
7830
+ * on. That guess is exactly what `frameSelector` exists so a flow author
7831
+ * never has to make; this fallback stays within the same-origin case the
7832
+ * gap report calls out and leaves cross-origin as the flow author's declared
7833
+ * `frameSelector` responsibility, unchanged.
7834
+ *
7835
+ * Every candidate frame is ranked and, on a hit, clicked and verified through
7836
+ * the SAME `snapshotPage`/`classifyPhantomClick` pre/post pair the cascade's
7837
+ * own `deep-submit-locator` escalation uses (including the single runner-up
7838
+ * retry on a phantom top pick) — there is no new unverified success path,
7839
+ * only a new place to look for the candidate before giving up.
7840
+ *
7841
+ * Returns the clicked `FrameTarget` on a verified (non-phantom) click, or
7842
+ * `null` if no same-origin child frame yielded a submit-shaped candidate, no
7843
+ * candidate was actually clickable, or every click phantomed.
7844
+ */
7845
+ async function probeChildFrameSubmitFallback(params) {
7846
+ const { page, signalCounter } = params;
7847
+ // `page.mainFrameId`/`page.frames` are read defensively: some call sites
7848
+ // (and their test fakes) model a `Page` that never attaches child frames at
7849
+ // all and doesn't implement this pair, which is indistinguishable here from
7850
+ // "no child frames exist" — either way there is nothing for this fallback
7851
+ // to probe, so it degrades to its normal "nothing found" return rather than
7852
+ // throwing out of an otherwise-best-effort escalation.
7853
+ let candidateFrames;
7854
+ try {
7855
+ const mainFrameId = page.mainFrameId();
7856
+ candidateFrames = page.frames().filter((frame) => frame.frameId !== mainFrameId);
7857
+ }
7858
+ catch {
7859
+ return null;
7860
+ }
7861
+ if (candidateFrames.length === 0)
7862
+ return null;
7863
+ const pageOrigin = (() => {
7864
+ try {
7865
+ return new URL(page.url()).origin;
7866
+ }
7867
+ catch {
7868
+ return null;
7869
+ }
7870
+ })();
7871
+ if (!pageOrigin)
7872
+ return null;
7873
+ for (const frame of candidateFrames) {
7874
+ const frameUrl = await Promise.resolve(frame.evaluate("location.href")).catch(() => null);
7875
+ if (!frameUrl)
7876
+ continue;
7877
+ const frameOrigin = (() => {
7878
+ try {
7879
+ return new URL(frameUrl).origin;
7880
+ }
7881
+ catch {
7882
+ return null;
7883
+ }
7884
+ })();
7885
+ // Cross-origin: documented rule-out, not a code defect. See docblock.
7886
+ if (frameOrigin !== pageOrigin)
7887
+ continue;
7888
+ const target = (0, frame_target_1.childFrameTarget)(page, frame, "(auto-discovered same-origin iframe)");
7889
+ const rankResult = await Promise.resolve(target.evaluate((0, submit_control_1.buildRankSubmitCandidatesExpr)())).catch(() => null);
7890
+ const ranked = Array.isArray(rankResult) ? rankResult : [];
7891
+ if (ranked.length === 0)
7892
+ continue;
7893
+ // biome-ignore lint/style/noNonNullAssertion: guarded by the length check above
7894
+ const top = ranked[0];
7895
+ const pre = await snapshotPage(target, signalCounter, page);
7896
+ const clickResult = (await Promise.resolve(target.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(top.deepIndex))).catch(() => ({ clicked: false })));
7897
+ if (!clickResult.clicked)
7898
+ continue;
7899
+ const post = await snapshotPage(target, signalCounter, page);
7900
+ const verdict = (0, phantom_click_1.classifyPhantomClick)({
7901
+ actResultSuccess: true,
7902
+ pre,
7903
+ post,
7904
+ isSubmitShapedStep: true,
7905
+ });
7906
+ if (verdict !== "phantom")
7907
+ return target;
7908
+ const runnerUp = ranked[1];
7909
+ if (!runnerUp)
7910
+ continue;
7911
+ const runnerUpClickResult = (await Promise.resolve(target.evaluate((0, submit_control_1.buildClickByDeepIndexExpr)(runnerUp.deepIndex))).catch(() => ({ clicked: false })));
7912
+ if (!runnerUpClickResult.clicked)
7913
+ continue;
7914
+ const runnerUpPost = await snapshotPage(target, signalCounter, page);
7915
+ const runnerUpVerdict = (0, phantom_click_1.classifyPhantomClick)({
7916
+ actResultSuccess: true,
7917
+ pre,
7918
+ post: runnerUpPost,
7919
+ isSubmitShapedStep: true,
7920
+ });
7921
+ if (runnerUpVerdict !== "phantom")
7922
+ return target;
7923
+ }
7924
+ return null;
7925
+ }
7744
7926
  async function executeStepWithHealing(params) {
7745
7927
  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
7928
  // Mutable: the `emailStep` code-extract path splices the extracted code
@@ -8068,7 +8250,7 @@ async function executeStepWithHealing(params) {
8068
8250
  // when no solver key is configured or the solve otherwise fails — that
8069
8251
  // propagates out of this function unchanged, so the step fails cleanly
8070
8252
  // instead of silently falling through as if unverified-but-passing.
8071
- const isSubmitOrFinalStep = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag);
8253
+ const isSubmitOrFinalStep = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step);
8072
8254
  if (captchaGated && isSubmitOrFinalStep) {
8073
8255
  let captchaTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
8074
8256
  const sitekeyProbeExpr = `(() => {
@@ -8410,6 +8592,25 @@ async function executeStepWithHealing(params) {
8410
8592
  logger.error(`${formatStepPrefix(stepIndex, totalSteps)} backend error detected (submit endpoint returned 5xx: ${backendErrorUrl}); aborting cascade`);
8411
8593
  throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) backend 5xx at ${backendErrorUrl} — unrecoverable`, "backend-error-unrecoverable");
8412
8594
  }
8595
+ // Bounded same-origin child-iframe fallback: the probe above only ever
8596
+ // looked at the resolved (declared-or-main) frame's own light+shadow DOM,
8597
+ // so a target genuinely inside an undeclared same-origin iframe is
8598
+ // invisible to it. Only tried when this step never declared its own
8599
+ // frameSelector — a declared-and-used frame means the flow author already
8600
+ // told us where to look, and guessing elsewhere would second-guess that —
8601
+ // and only when this step is itself submit-shaped, since the probe ranks
8602
+ // for a submit-shaped candidate and firing it on a non-submit step (e.g.
8603
+ // a radio/select answer) could complete the step by clicking an unrelated
8604
+ // submit control in an unrelated same-origin child iframe.
8605
+ if (!frameTarget?.declaredFrameSelector &&
8606
+ (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step))) {
8607
+ const fallbackTarget = await probeChildFrameSubmitFallback({ page, signalCounter });
8608
+ if (fallbackTarget) {
8609
+ 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`);
8610
+ trajectory?.push({ stepIndex, verifiedBy: "dom" });
8611
+ return "completed";
8612
+ }
8613
+ }
8413
8614
  // Capture diagnostics + write a failure dump BEFORE throwing so the
8414
8615
  // global replan path's `readFailureDumpEvidence` can populate the
8415
8616
  // prompt's CURRENTLY VISIBLE / UNFOCUSED OBSERVE / PAGE BODY HTML
@@ -8433,6 +8634,9 @@ async function executeStepWithHealing(params) {
8433
8634
  const unfocusedObserve = probeAbsentObservedUnfocused.length === 0 && frameTarget?.frame
8434
8635
  ? await deepLocatorCandidatesAsActions(page, frameTarget)
8435
8636
  : probeAbsentObservedUnfocused;
8637
+ const targetResolutionDiagnostic = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step)
8638
+ ? await (0, target_resolution_diagnostic_1.captureTargetResolutionDiagnosticSnapshot)(frameTarget, page)
8639
+ : null;
8436
8640
  const dumpPath = onStepFailure?.({
8437
8641
  stepIndex,
8438
8642
  phase,
@@ -8444,6 +8648,7 @@ async function executeStepWithHealing(params) {
8444
8648
  recentCaptures,
8445
8649
  bodyOuterHtml,
8446
8650
  unfocusedObserve,
8651
+ targetResolutionDiagnostic,
8447
8652
  }) ?? null;
8448
8653
  throw new errors_2.StepVerificationError(`${formatStepPrefix(stepIndex, totalSteps)} (${step.slice(0, 60)}) probe found no candidates on page${dumpPath ? `; see ${dumpPath}` : ""}`, "probe-absent");
8449
8654
  }
@@ -8705,6 +8910,13 @@ async function executeStepWithHealing(params) {
8705
8910
  }
8706
8911
  };
8707
8912
  let phantomClickAfterAttempt1 = false;
8913
+ let attempt1UnreachableViaLightDom = false;
8914
+ // Last candidate list the deep-submit-locator itself ranked this step, if
8915
+ // any — reused by the cascade-exhausted diagnostic snapshot below so it
8916
+ // doesn't re-run buildRankSubmitCandidatesExpr (and inflate the rank-call
8917
+ // count the phantom-click-escalation tests assert on) when the cascade
8918
+ // already has an up-to-date ranking to report from.
8919
+ let lastRankedSubmitCandidates = null;
8708
8920
  for (let attempt = 1; attempt <= MAX_STEP_ATTEMPTS; attempt++) {
8709
8921
  // Telemetry-driven technique-skip: when a cascade technique's
8710
8922
  // preconditions cannot be met by the prior attempts' state, running
@@ -8713,7 +8925,8 @@ async function executeStepWithHealing(params) {
8713
8925
  // techniques faster.
8714
8926
  if (attempt > 1) {
8715
8927
  const wouldBeTechnique = attempt === 2
8716
- ? phantomClickAfterAttempt1 && (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))
8928
+ ? attempt1UnreachableViaLightDom &&
8929
+ (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step))
8717
8930
  ? "deep-submit-locator"
8718
8931
  : phantomClickAfterAttempt1
8719
8932
  ? "trusted-click-retry"
@@ -8731,8 +8944,8 @@ async function executeStepWithHealing(params) {
8731
8944
  errorMessage: a.errorMessage,
8732
8945
  })),
8733
8946
  advanceUnmovedAfterAttempt1,
8734
- phantomClickAfterAttempt1,
8735
- submitShapedStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
8947
+ attempt1UnreachableViaLightDom,
8948
+ submitShapedStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step),
8736
8949
  });
8737
8950
  if (decision.skip) {
8738
8951
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt} (${wouldBeTechnique}) skipped: ${decision.reason}`);
@@ -8869,13 +9082,14 @@ async function executeStepWithHealing(params) {
8869
9082
  }
8870
9083
  }
8871
9084
  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
9085
+ attempt1UnreachableViaLightDom &&
9086
+ (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step))) {
9087
+ // Deep submit-control locator: attempt 1 either phantom-clicked
9088
+ // (Stagehand reported success but pre/post showed zero effect) or
9089
+ // came back "unresolved" (act() found zero candidates at all), so the
9090
+ // target is almost certainly unreachable via document.querySelectorAll
9091
+ // — most likely rendered inside an open shadow root by a web-component
9092
+ // / framework-native submit control (see recon-submit-phantom-click bug
8879
9093
  // report). Rank every submit-shaped candidate the deep traversal can
8880
9094
  // reach and click the top-ranked one; the ranking already excludes
8881
9095
  // Back/Cancel/Save-draft-shaped controls, so a false-positive submit
@@ -8902,6 +9116,7 @@ async function executeStepWithHealing(params) {
8902
9116
  record.errorMessage = `deep-submit-locator: rank evaluate threw ${(0, errors_1.toErrorMessage)(err)}`;
8903
9117
  break;
8904
9118
  }
9119
+ lastRankedSubmitCandidates = ranked;
8905
9120
  if (ranked.length === 0) {
8906
9121
  record.errorMessage = "deep-submit-locator: no submit-shaped candidate found";
8907
9122
  break;
@@ -8994,7 +9209,7 @@ async function executeStepWithHealing(params) {
8994
9209
  }
8995
9210
  else if (attempt === 2 &&
8996
9211
  phantomClickAfterAttempt1 &&
8997
- !(submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))) {
9212
+ !(submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step))) {
8998
9213
  // Trusted-click retry: attempt 1 phantom-clicked a NON-submit control —
8999
9214
  // Stagehand reported success but pre/post showed zero effect. On a
9000
9215
  // design-system widget (React synthetic-event delegation, custom
@@ -9674,7 +9889,7 @@ async function executeStepWithHealing(params) {
9674
9889
  // `advanceTransitionBodyPattern` are unaffected.
9675
9890
  const domVerifiedForStep = isDomOnlyAdvanceVerified({
9676
9891
  hasPattern: advanceTransitionBodyPattern !== null,
9677
- isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
9892
+ isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step),
9678
9893
  isAdvance: isAdvanceStep(step),
9679
9894
  domVerified,
9680
9895
  networkIsRealAdvance,
@@ -9762,7 +9977,8 @@ async function executeStepWithHealing(params) {
9762
9977
  // gate (requireSubmitEndpoint) will re-litigate the credit below.
9763
9978
  const formValueWeakSignalAllowed = !(submitStep ||
9764
9979
  (isFinalStep && flowHasSubmitSemanticsFlag) ||
9765
- resolvedElementIsSubmitShaped) || requireSubmitEndpoint;
9980
+ resolvedElementIsSubmitShaped ||
9981
+ isSubmitIntentStep(step)) || requireSubmitEndpoint;
9766
9982
  const formValueVerified = isStateClass &&
9767
9983
  formValueWeakSignalAllowed &&
9768
9984
  post.formValueSignature !== pre.formValueSignature;
@@ -9868,7 +10084,7 @@ async function executeStepWithHealing(params) {
9868
10084
  pre,
9869
10085
  post,
9870
10086
  elementStateChanged: domVerified,
9871
- isSubmitShapedStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
10087
+ isSubmitShapedStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step),
9872
10088
  });
9873
10089
  // An `"effective"` verdict driven purely by the page-wide byte-delta
9874
10090
  // floor (`TRIVIAL_DOM_DELTA_BYTES`, 500B) is intentionally NOT trusted
@@ -9889,7 +10105,8 @@ async function executeStepWithHealing(params) {
9889
10105
  // respects the SAME DOM-only-advance veto as the block above: a wizard
9890
10106
  // "Next" step on a pattern-configured site whose only signal is a field
9891
10107
  // toggle must stay unverified, not get waved through by the verdict.
9892
- const domEffectiveVerdict = !(submitStep || (isFinalStep && flowHasSubmitSemanticsFlag)) && domVerifiedForStep;
10108
+ const domEffectiveVerdict = !(submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step)) &&
10109
+ domVerifiedForStep;
9893
10110
  let verified = networkIsRealAdvance ||
9894
10111
  urlChanged ||
9895
10112
  domVerifiedForStep ||
@@ -10140,7 +10357,7 @@ async function executeStepWithHealing(params) {
10140
10357
  const n16TrustedClickFailure = !n16TrustedDelivered && "reason" in n16TrustedClickResult
10141
10358
  ? n16TrustedClickResult
10142
10359
  : 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" }; })()`;
10360
+ 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
10361
  const n16FallbackTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
10145
10362
  const probeResult = (await n16FallbackTarget.evaluate(clickExpr));
10146
10363
  const fired = probeResult.fired;
@@ -10159,8 +10376,7 @@ async function executeStepWithHealing(params) {
10159
10376
  const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
10160
10377
  let node = r.singleNodeValue;
10161
10378
  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;
10379
+ node = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)});
10164
10380
  }
10165
10381
  if (!node) return false;
10166
10382
  for (let depth = 0; depth < 6 && node; depth++) {
@@ -10243,8 +10459,7 @@ async function executeStepWithHealing(params) {
10243
10459
  const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
10244
10460
  let el = r.singleNodeValue;
10245
10461
  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;
10462
+ el = ${XPATH_TAIL_RETARGET_RESOLVE_FN_SRC}(${JSON.stringify(xpathTail)});
10248
10463
  }
10249
10464
  return el ? isDisabled(el) : false;
10250
10465
  })()`;
@@ -10284,7 +10499,7 @@ async function executeStepWithHealing(params) {
10284
10499
  }));
10285
10500
  const fallbackDomOnlyAdvance = shouldVetoFallbackAdvance({
10286
10501
  hasPattern: advanceTransitionBodyPattern !== null,
10287
- isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
10502
+ isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step),
10288
10503
  isAdvance: isAdvanceStep(step),
10289
10504
  retryUrlChanged,
10290
10505
  retryNetworkIsRealAdvance,
@@ -10345,7 +10560,8 @@ async function executeStepWithHealing(params) {
10345
10560
  // value is read below.
10346
10561
  const retrySubmitShaped = submitStep ||
10347
10562
  (isFinalStep && flowHasSubmitSemanticsFlag) ||
10348
- retryResolvedElementIsSubmitShaped;
10563
+ retryResolvedElementIsSubmitShaped ||
10564
+ isSubmitIntentStep(step);
10349
10565
  const weakDomSignalsAllowed = ((!isFinalStep && !submitStep && !retryResolvedElementIsSubmitShaped) ||
10350
10566
  requireSubmitEndpoint) &&
10351
10567
  !isCheckboxOrRadioIntentStep(step) &&
@@ -10568,7 +10784,7 @@ async function executeStepWithHealing(params) {
10568
10784
  // dumps in a 2026-06-10 survey had the paired touched+dirty + visible
10569
10785
  // error text pattern with 3 distinct rejection messages.
10570
10786
  if (record.resolvedMethod === "click" &&
10571
- (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))) {
10787
+ (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step))) {
10572
10788
  const live = await extractLivePageFormEvidence(page, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), {
10573
10789
  client: anthropic,
10574
10790
  knownErrorClassPrefixes,
@@ -10604,26 +10820,44 @@ async function executeStepWithHealing(params) {
10604
10820
  isAdvanceStep(step) &&
10605
10821
  !urlChanged &&
10606
10822
  !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.
10823
+ // Attempt 1's classifyPhantomClick verdict: "phantom" means Stagehand
10824
+ // reported success but pre/post shows zero observable effect; the
10825
+ // zero-effect delta is the primary signal there (classifyPhantomClick
10826
+ // above). "unresolved" means act() found zero candidates at all — a
10827
+ // strictly stronger signal that the target is unreachable via the
10828
+ // light-DOM resolver, since there wasn't even a candidate to click. The
10829
+ // live AISDK elementId suppression counter is corroborating evidence
10830
+ // only — logged, never gating, since a nonzero count alone is too weak
10831
+ // a signal on a run that sees dozens of suppressions across hundreds of
10832
+ // unrelated steps.
10613
10833
  phantomClickAfterAttempt1 = record.phantomClickVerdict === "phantom";
10614
- if (phantomClickAfterAttempt1) {
10834
+ const attempt1Unresolved = record.phantomClickVerdict === "unresolved";
10835
+ attempt1UnreachableViaLightDom = phantomClickAfterAttempt1 || attempt1Unresolved;
10836
+ if (attempt1UnreachableViaLightDom) {
10615
10837
  const suppressedCount = getSuppressedAisdkElementIdErrorCount?.();
10616
- const escalationTarget = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag)
10838
+ const verdictDescription = phantomClickAfterAttempt1
10839
+ ? "phantom click detected"
10840
+ : "no candidates resolved (unresolved verdict)";
10841
+ const outcomeDescription = phantomClickAfterAttempt1
10842
+ ? "reported success with no network/url/dom change"
10843
+ : "act() found zero candidates";
10844
+ // trusted-click-retry needs attempt 1's resolved selector
10845
+ // (triedSelectors), which is empty on an "unresolved" verdict, so a
10846
+ // non-submit step with that verdict falls through to the normal
10847
+ // ladder instead of escalating.
10848
+ const escalationTarget = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step)
10617
10849
  ? "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}`);
10850
+ : phantomClickAfterAttempt1
10851
+ ? "non-submit step — escalating attempt 2 to trusted-click-retry (trusted CDP click on the resolved target)"
10852
+ : "non-submit step — no resolved target for trusted-click-retry; attempt 2 continues the normal ladder";
10853
+ 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
10854
  }
10621
10855
  const postAttemptInvalidCount = await countNgInvalidContainers(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page));
10622
10856
  const earlyExit = isSubmitRevealedInvalid({
10623
10857
  // Treat the canonical submit click as "final" for this predicate
10624
10858
  // even when it lives mid-flow. See requireSubmitEndpoint derivation
10625
10859
  // above for the same gate-widening rationale.
10626
- isFinalStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
10860
+ isFinalStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step),
10627
10861
  requireSubmitEndpoint,
10628
10862
  resolvedMethod: record.resolvedMethod,
10629
10863
  effectSignals,
@@ -10643,7 +10877,7 @@ async function executeStepWithHealing(params) {
10643
10877
  // can reorder a later step forward — instead of burning the cascade.
10644
10878
  const advanceStalled = isAdvanceStalled({
10645
10879
  isAdvance: isAdvanceStep(step),
10646
- isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
10880
+ isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step),
10647
10881
  hasPattern: advanceTransitionBodyPattern !== null,
10648
10882
  clickFired: record.resolvedMethod === "click" && record.actResultSuccess === true,
10649
10883
  networkFired,
@@ -10658,6 +10892,26 @@ async function executeStepWithHealing(params) {
10658
10892
  }
10659
10893
  }
10660
10894
  }
10895
+ // Bounded same-origin child-iframe fallback: every attempt above only ever
10896
+ // operated against the resolved (declared-or-main) frame, so a target
10897
+ // genuinely inside an undeclared same-origin iframe was invisible to all of
10898
+ // them. Only tried when this step never declared its own frameSelector — a
10899
+ // declared-and-used frame means the flow author already told us where to
10900
+ // look, so this can only ever fire on a case that today always fails
10901
+ // (no regression risk to any currently-passing path) — and only when this
10902
+ // step is itself submit-shaped, matching every other
10903
+ // buildRankSubmitCandidatesExpr call site's guard in this file, since
10904
+ // firing on a non-submit step could complete it by clicking an unrelated
10905
+ // submit control in an unrelated same-origin child iframe.
10906
+ if (!frameTarget?.declaredFrameSelector &&
10907
+ (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step))) {
10908
+ const fallbackTarget = await probeChildFrameSubmitFallback({ page, signalCounter });
10909
+ if (fallbackTarget) {
10910
+ 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`);
10911
+ trajectory?.push({ stepIndex, verifiedBy: "dom" });
10912
+ return "completed";
10913
+ }
10914
+ }
10661
10915
  const cascadeExhaustObservedFinal = await (0, stagehand_guard_1.guardedObserve)(stagehand, step, { timeout: exports.STEP_WATCHDOG_MS }, captureFn, frameTarget).catch(() => []);
10662
10916
  // observe() is blind to a cross-origin OOPIF, so a frame-scoped empty
10663
10917
  // result degrades to the deep-locator resolver for dump evidence — this
@@ -10680,6 +10934,9 @@ async function executeStepWithHealing(params) {
10680
10934
  const unfocusedObserve = cascadeExhaustObservedUnfocused.length === 0 && frameTarget?.frame
10681
10935
  ? await deepLocatorCandidatesAsActions(page, frameTarget)
10682
10936
  : cascadeExhaustObservedUnfocused;
10937
+ const targetResolutionDiagnostic = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag) || isSubmitIntentStep(step)
10938
+ ? await (0, target_resolution_diagnostic_1.captureTargetResolutionDiagnosticSnapshot)(frameTarget, page, lastRankedSubmitCandidates)
10939
+ : null;
10683
10940
  const dumpPath = onStepFailure?.({
10684
10941
  stepIndex,
10685
10942
  phase,
@@ -10691,6 +10948,7 @@ async function executeStepWithHealing(params) {
10691
10948
  recentCaptures,
10692
10949
  bodyOuterHtml,
10693
10950
  unfocusedObserve,
10951
+ targetResolutionDiagnostic,
10694
10952
  }) ?? null;
10695
10953
  if (dumpPath !== null) {
10696
10954
  logger.error(`${formatStepPrefix(stepIndex, totalSteps)} failed after ${MAX_STEP_ATTEMPTS} attempts; diagnostic bundle: ${dumpPath}`);