@enricai/barnacle 1.12.2 → 1.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,6 +24,7 @@ exports.findRecentPageTransition = findRecentPageTransition;
24
24
  exports.isWizardExitAction = isWizardExitAction;
25
25
  exports.isAdvanceStep = isAdvanceStep;
26
26
  exports.shouldCaptureSelectionState = shouldCaptureSelectionState;
27
+ exports.flowHasSubmitSemantics = flowHasSubmitSemantics;
27
28
  exports.shouldWarnMissingAdvancePattern = shouldWarnMissingAdvancePattern;
28
29
  exports.parseCaptureIndex = parseCaptureIndex;
29
30
  exports.latestCaptureIndex = latestCaptureIndex;
@@ -58,6 +59,7 @@ exports.normalizeDateValue = normalizeDateValue;
58
59
  exports.fillHtml5DateTimeInput = fillHtml5DateTimeInput;
59
60
  exports.fillTextDatepickerInput = fillTextDatepickerInput;
60
61
  exports.verifyFillReadback = verifyFillReadback;
62
+ exports.verifyPromptSelectorCommitted = verifyPromptSelectorCommitted;
61
63
  exports.isUploadAffordanceLabel = isUploadAffordanceLabel;
62
64
  exports.writeFixtureToTempFile = writeFixtureToTempFile;
63
65
  exports.attachToSurfacedInput = attachToSurfacedInput;
@@ -396,6 +398,18 @@ const PROMPT_SELECTOR_SETTLE_MS = 400;
396
398
  const PROMPT_WIDGET_MARK_ATTR = "data-bcl-prompt-idx";
397
399
  /** Same role as {@link PROMPT_WIDGET_MARK_ATTR}, but for the popup's rendered option entries. */
398
400
  const PROMPT_OPTION_MARK_ATTR = "data-bcl-prompt-opt-idx";
401
+ /**
402
+ * Cross-vendor selector for a genuine focusable form control — the only kind
403
+ * of element a native click event reliably opens. Phase 1's container walk
404
+ * marks the OUTERMOST widget-kit container so one widget resolves to one
405
+ * candidate, but that container is often a layout wrapper with no click
406
+ * handler of its own (e.g. a typeahead/chip widget's real open+filter control
407
+ * is an `<input>` nested a level deeper, inside its own `*InputContainer`
408
+ * wrapper). Phase 2 prefers clicking this interactive descendant over the
409
+ * marked container itself, falling back to the container only when it has
410
+ * none — no vendor branch, just "click the real control if one exists".
411
+ */
412
+ const PROMPT_INTERACTIVE_CONTROL_SELECTORS = "button,[role='combobox'],[role='button'],input";
399
413
  /**
400
414
  * Cross-vendor selector union that identifies the TRIGGER of a native-control-less
401
415
  * popup-dropdown widget (a combobox that opens a listbox popup and renders no
@@ -486,6 +500,30 @@ const PROMPT_EMPTY_VALUE_RX_FLAGS = PROMPT_EMPTY_VALUE_RX.flags;
486
500
  * the remaining `textContent`.
487
501
  */
488
502
  const BUTTON_VALUE_EXPR = "((el) => { const c = el.cloneNode(true); for (const n of c.querySelectorAll(\"[role='option'],[role='listbox'],[aria-hidden='true'],abbr,svg\")) n.remove(); return c.textContent || \"\"; })";
503
+ /**
504
+ * Browser-side expression reading a prompt-selector widget's OWN committed-value
505
+ * text — the single source of truth for "is this widget filled?", shared by the
506
+ * widget-enumeration phase ({@link tryPromptSelectorPrimitive}'s
507
+ * `enumerateWidgetsExpr`) and the act-success committed-value gate
508
+ * ({@link verifyPromptSelectorCommitted}) so the two never drift into separate
509
+ * ideas of "committed". Value via the union: `aria-activedescendant` → the
510
+ * referenced option's text, else the widget's own value (an `<input>`'s
511
+ * `value`, or a `<button>`-trigger's own label text via {@link BUTTON_VALUE_EXPR}),
512
+ * else a selection-label node ({@link PROMPT_VALUE_SELECTORS}) — treating the
513
+ * widget-kit empty-state phrase ({@link PROMPT_EMPTY_VALUE_RX}) as no value.
514
+ */
515
+ const PROMPT_CURRENT_TEXT_EXPR = `((w, valueSel, emptyRx) => {
516
+ const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
517
+ const buttonValue = ${BUTTON_VALUE_EXPR};
518
+ const adid = w.getAttribute && w.getAttribute("aria-activedescendant");
519
+ if (adid) { const opt = document.getElementById(adid); if (opt && opt.textContent.trim()) return norm(opt.textContent); }
520
+ if (w.tagName === "INPUT" && (w.value || "").trim()) return norm(w.value);
521
+ if (w.tagName === "BUTTON" && buttonValue(w).trim()) return norm(buttonValue(w));
522
+ const lbl = w.matches(valueSel) ? w : w.querySelector(valueSel);
523
+ const raw = lbl ? (lbl.textContent || "") : "";
524
+ if (raw.trim() && !emptyRx.test(raw.trim())) return norm(raw);
525
+ return "";
526
+ })`;
489
527
  /**
490
528
  * Browser-side expression resolving the DOM root within which a chosen widget's
491
529
  * popup options / filter input live. Popup placement is vendor-split: the ARIA
@@ -923,8 +961,20 @@ function isAdvanceStep(instruction) {
923
961
  * cascade depends on is unit-testable, not buried in `executeStepWithHealing`.
924
962
  */
925
963
  function shouldCaptureSelectionState(params) {
926
- const { step, isFinalStep, submitStep } = params;
927
- return !(isFinalStep || submitStep || isAdvanceStep(step));
964
+ const { step, isFinalStep, submitStep, flowHasSubmitSemantics } = params;
965
+ return !(submitStep || (isFinalStep && flowHasSubmitSemantics) || isAdvanceStep(step));
966
+ }
967
+ /**
968
+ * Whether a flow has ANY submit semantics at all — a step flagged
969
+ * `submitStep: true`, a `submitEndpointPattern`, or `requireSubmitEndpointMatch`.
970
+ * A read-only flow (none of the three) has no submit shape anywhere, so its
971
+ * final step is an ordinary read/click, not a submit. Pure + exported so
972
+ * callers can stop inferring submit-shape from `isFinalStep` alone on flows
973
+ * that never declared a submit.
974
+ */
975
+ function flowHasSubmitSemantics(params) {
976
+ const { steps, submitEndpointPattern, requireSubmitEndpointMatch } = params;
977
+ return (steps.some((s) => s.submitStep) || submitEndpointPattern !== null || requireSubmitEndpointMatch);
928
978
  }
929
979
  /**
930
980
  * Whether a flow should be WARNed that its DOM-only advance guard is disarmed.
@@ -1200,10 +1250,10 @@ function isDomOnlyAdvanceVerified(params) {
1200
1250
  function isClickViewSwapVerified(params) {
1201
1251
  const VIEW_SWAP_MIN_BYTES = config_1.config.scraper.viewSwapMinBytesThreshold;
1202
1252
  const VIEW_SWAP_REVEAL_MIN_BYTES = config_1.config.scraper.viewSwapRevealMinBytesThreshold;
1203
- const { resolvedAction, isFinalStep, submitStep, isAdvanceWithPattern, networkDelta, bytesDelta, textChanged, invalidMarkerDelta = 0, } = params;
1253
+ const { resolvedAction, isFinalStep, submitStep, flowHasSubmitSemantics, isAdvanceWithPattern, networkDelta, bytesDelta, textChanged, invalidMarkerDelta = 0, } = params;
1204
1254
  if (resolvedAction?.method !== "click")
1205
1255
  return false;
1206
- if (isFinalStep || submitStep)
1256
+ if (submitStep || (isFinalStep && flowHasSubmitSemantics))
1207
1257
  return false;
1208
1258
  if (isAdvanceWithPattern)
1209
1259
  return false;
@@ -2715,6 +2765,50 @@ async function verifyFillReadback(target, selector, expectedValue) {
2715
2765
  return null;
2716
2766
  }
2717
2767
  }
2768
+ /**
2769
+ * Committed-value guard for the prompt-selector widget family
2770
+ * (`data-uxi-widget-type='multiselect'`/`selectinput`, `role=combobox`, …),
2771
+ * mirroring {@link verifyFillReadback}'s role for plain inputs but reading the
2772
+ * widget's OWN committed-value node ({@link PROMPT_CURRENT_TEXT_EXPR}) instead
2773
+ * of a bare `<input>.value` — a chip multiselect's committed state lives on
2774
+ * `aria-activedescendant`/a selection-label node, not the trigger's own value.
2775
+ * Walks UP from the resolved element to the nearest {@link PROMPT_TRIGGER_SELECTORS}
2776
+ * ancestor (the resolved element is often the inner filter input or icon, not
2777
+ * the widget container itself — same resolution discipline as
2778
+ * `tryPromptSelectorPrimitive`'s widget-container walk). Returns
2779
+ * `isPromptWidget: false` for a resolved element that isn't part of this
2780
+ * widget family at all, so a non-prompt click/fill is untouched.
2781
+ */
2782
+ async function verifyPromptSelectorCommitted(target, selector) {
2783
+ const xpath = xpathBodyForEvaluate(selector);
2784
+ if (xpath === null)
2785
+ return null;
2786
+ const expr = `(() => {
2787
+ const triggerSel = ${JSON.stringify(PROMPT_TRIGGER_SELECTORS)};
2788
+ const valueSel = ${JSON.stringify(PROMPT_VALUE_SELECTORS)};
2789
+ const emptyRx = new RegExp(${JSON.stringify(PROMPT_EMPTY_VALUE_RX_SRC)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_FLAGS)});
2790
+ const currentText = ${PROMPT_CURRENT_TEXT_EXPR};
2791
+ const xpath = ${JSON.stringify(xpath)};
2792
+ const result = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
2793
+ const el = result.singleNodeValue;
2794
+ if (!el) return { isPromptWidget: false, committed: false };
2795
+ const widget = el.matches && el.matches(triggerSel) ? el : el.closest(triggerSel);
2796
+ if (!widget) return { isPromptWidget: false, committed: false };
2797
+ return { isPromptWidget: true, committed: currentText(widget, valueSel, emptyRx) !== "" };
2798
+ })()`;
2799
+ try {
2800
+ const raw = await target.evaluate(expr);
2801
+ if (raw === null || typeof raw !== "object")
2802
+ return null;
2803
+ const r = raw;
2804
+ if (typeof r.isPromptWidget !== "boolean" || typeof r.committed !== "boolean")
2805
+ return null;
2806
+ return { isPromptWidget: r.isPromptWidget, committed: r.committed };
2807
+ }
2808
+ catch {
2809
+ return null;
2810
+ }
2811
+ }
2718
2812
  /**
2719
2813
  * Pull field-level errors out of an arbitrary JSON response body. Walks a
2720
2814
  * few of the conventional ATS shapes; falls through to `[]` so the caller
@@ -3521,11 +3615,54 @@ async function setFilesViaCdp(params) {
3521
3615
  * Recognizes the flow's conventional phrasings, all quoted:
3522
3616
  * "select 'Yes'", "select or check 'BLS'",
3523
3617
  * "for 'What is your highest level…?' select 'BSN completed'",
3524
- * "select 'Texas' in the State/Region dropdown".
3618
+ * "select 'Texas' in the State/Region dropdown",
3619
+ * "…then select the option 'Job Boards' from the popup list" (a compound
3620
+ * step that opens the widget in one clause and selects in another —
3621
+ * "select" is followed by a noun phrase like "the option"/"the value"
3622
+ * before the quoted option itself, rather than the quote directly).
3525
3623
  * Returns null when the step is not a single-dropdown select (e.g. generic
3526
3624
  * "for any remaining question…" catch-alls, or radio/checkbox-only steps) so
3527
3625
  * the caller falls through to the normal cascade.
3528
3626
  */
3627
+ // Nouns that name a form widget/question, used by `pickQuestionLabel` to tell
3628
+ // a genuine field label ("'How Did You Hear About Us?' prompt selector")
3629
+ // apart from an unrelated leading quoted phrase that just happens to precede
3630
+ // it in the instruction (e.g. a page/step-context quote like "'My
3631
+ // Information' step").
3632
+ const WIDGET_NOUN_RE = /\b(prompt\s+selector|multiselect|typeahead|dropdown|field|question|checkbox|radio\s+button|radio)\b/i;
3633
+ /**
3634
+ * Pick the QUESTION LABEL out of an instruction's quoted phrases, given the
3635
+ * already-extracted OPTION.
3636
+ *
3637
+ * Why this exists: an instruction can carry more than one quoted phrase that
3638
+ * is not the option — e.g. a page/step-context phrase ("On the authenticated
3639
+ * 'My Information' step, open the 'How Did You Hear About Us?' prompt
3640
+ * selector…") — so naively taking the first non-option quote picks the
3641
+ * context phrase instead of the actual widget label. This prefers a quote
3642
+ * that sits immediately next to a widget noun (`multiselect`/`typeahead`/
3643
+ * `dropdown`/`field`/`question`/`checkbox`/`radio button`/`prompt selector`)
3644
+ * or is introduced
3645
+ * by "for"/"for the"/"for the question"/"for the answer" (e.g. "click the
3646
+ * 'Yes' answer for the question '…'"), before falling back to the first
3647
+ * non-option quote so existing un-adorned phrasings keep working.
3648
+ */
3649
+ function pickQuestionLabel(instruction, option) {
3650
+ // biome-ignore lint/style/noNonNullAssertion: capture group 1 is required by the pattern, so it is present on every match
3651
+ const candidates = [...instruction.matchAll(/'([^']+)'/g)].filter((m) => m[1].trim() !== option);
3652
+ if (candidates.length === 0)
3653
+ return null;
3654
+ const adjacentToWidgetNoun = candidates.find((m) => {
3655
+ const start = m.index ?? 0;
3656
+ const end = start + m[0].length;
3657
+ const before = instruction.slice(Math.max(0, start - 25), start);
3658
+ const after = instruction.slice(end, end + 40);
3659
+ return (/\bfor(?:\s+the)?(?:\s+(?:question|answer))?\s*$/i.test(before) || WIDGET_NOUN_RE.test(after));
3660
+ });
3661
+ // biome-ignore lint/style/noNonNullAssertion: candidates is guarded non-empty above, so the fallback element is always present
3662
+ const picked = (adjacentToWidgetNoun ?? candidates[0]);
3663
+ // biome-ignore lint/style/noNonNullAssertion: capture group 1 is required by the pattern, so it is present on every match
3664
+ return picked[1].trim();
3665
+ }
3529
3666
  function parseSelectStep(instruction) {
3530
3667
  const lower = instruction.toLowerCase();
3531
3668
  // Must look like a dropdown selection, not a radio/checkbox click. "select
@@ -3541,16 +3678,20 @@ function parseSelectStep(instruction) {
3541
3678
  const quoted = [...instruction.matchAll(/'([^']+)'/g)].map((m) => m[1]);
3542
3679
  if (quoted.length === 0)
3543
3680
  return null;
3544
- // The OPTION is the quoted string immediately following the word "select".
3545
- const selMatch = instruction.match(/\bselect(?:\s+or\s+check)?\s+'([^']+)'/i);
3681
+ // The OPTION is the quoted string following the word "select", allowing a
3682
+ // short filler ("the option", "the answer") between the verb and the
3683
+ // quote — real flow phrasing like "select the option 'Job Boards'" or
3684
+ // "select the answer 'X'" otherwise fails to parse and the caller
3685
+ // silently no-ops before touching the DOM.
3686
+ const selMatch = instruction.match(/\bselect(?:\s+or\s+check)?\s+(?:the\s+\S+\s+)?'([^']+)'/i);
3546
3687
  if (!selMatch)
3547
3688
  return null;
3548
3689
  // biome-ignore lint/style/noNonNullAssertion: guarded by the !selMatch early-return; group 1 is required by the pattern
3549
3690
  const option = selMatch[1].trim();
3550
3691
  // The QUESTION LABEL, when present, is a DIFFERENT quoted string — the one
3551
- // introduced by "for '…'" or "in the '…' dropdown". Pick the first quoted
3552
- // string that is not the option.
3553
- const questionLabel = quoted.find((q) => q.trim() !== option)?.trim() ?? null;
3692
+ // introduced by "for '…'" or adjacent to a widget noun like "'…' dropdown"
3693
+ // or "'…' prompt selector". See `pickQuestionLabel`.
3694
+ const questionLabel = pickQuestionLabel(instruction, option);
3554
3695
  return { option, questionLabel };
3555
3696
  }
3556
3697
  /**
@@ -3668,8 +3809,9 @@ function parseRadioStep(instruction) {
3668
3809
  // introduced by "for the question '…'" / "for the '…' question". Some steps
3669
3810
  // phrase the question un-quoted ("…about requiring visa sponsorship"); in
3670
3811
  // that case there is no second quoted string and questionLabel stays null,
3671
- // which the primitive handles via LLM group-matching.
3672
- const questionLabel = quoted.find((q) => q.trim() !== option)?.trim() ?? null;
3812
+ // which the primitive handles via LLM group-matching. See
3813
+ // `pickQuestionLabel`.
3814
+ const questionLabel = pickQuestionLabel(instruction, option);
3673
3815
  return { option, questionLabel };
3674
3816
  }
3675
3817
  /**
@@ -4969,28 +5111,15 @@ async function tryPromptSelectorPrimitive(params) {
4969
5111
  }
4970
5112
  return "";
4971
5113
  };
4972
- const currentText = (w) => {
4973
- // Value via the union: aria-activedescendant → option text, the widget's
4974
- // own value (an <input>'s value, or a <button>-trigger's own label text —
4975
- // a button that IS the trigger carries the committed choice as its own
4976
- // text), else a selection-label node — treating the widget-kit empty-state
4977
- // phrase as no value.
4978
- const adid = w.getAttribute && w.getAttribute("aria-activedescendant");
4979
- if (adid) { const opt = document.getElementById(adid); if (opt && opt.textContent.trim()) return norm(opt.textContent); }
4980
- if (w.tagName === "INPUT" && (w.value || "").trim()) return norm(w.value);
4981
- if (w.tagName === "BUTTON" && buttonValue(w).trim()) return norm(buttonValue(w));
4982
- const lbl = w.matches(valueSel) ? w : w.querySelector(valueSel);
4983
- const raw = lbl ? (lbl.textContent || "") : "";
4984
- if (raw.trim() && !emptyRx.test(raw.trim())) return norm(raw);
4985
- return "";
4986
- };
5114
+ const currentText = ${PROMPT_CURRENT_TEXT_EXPR};
5115
+ const currentTextOf = (w) => currentText(w, valueSel, emptyRx);
4987
5116
  const isUnfilled = (w) => {
4988
5117
  let node = w;
4989
5118
  for (let d = 0; d < 6 && node; d++) {
4990
5119
  if (node.getAttribute && isInvalid(node)) return true;
4991
5120
  node = node.parentElement;
4992
5121
  }
4993
- return currentText(w) === "";
5122
+ return currentTextOf(w) === "";
4994
5123
  };
4995
5124
  // Clear stale marks from a prior call on this same page (this primitive
4996
5125
  // runs once per "select 'X'" step, and an application wizard answers several
@@ -5040,7 +5169,23 @@ async function tryPromptSelectorPrimitive(params) {
5040
5169
  }
5041
5170
  // Phase 2 (real gesture): open the popup. These widgets' trigger requires a
5042
5171
  // genuine click — a synthetic dispatchEvent does not fire its handler.
5043
- const triggerSel = `[${PROMPT_WIDGET_MARK_ATTR}="${chosen.wIdx}"]`;
5172
+ // Prefer the marked widget's own interactive descendant (a real form
5173
+ // control) over the marked container itself, since the container is
5174
+ // frequently a non-interactive layout wrapper for widget shapes whose
5175
+ // actual trigger sits a level deeper (see PROMPT_INTERACTIVE_CONTROL_SELECTORS).
5176
+ const containerTriggerSel = `[${PROMPT_WIDGET_MARK_ATTR}="${chosen.wIdx}"]`;
5177
+ const innerTriggerSel = PROMPT_INTERACTIVE_CONTROL_SELECTORS.split(",")
5178
+ .map((s) => `${containerTriggerSel} ${s}`)
5179
+ .join(",");
5180
+ let triggerSel = containerTriggerSel;
5181
+ try {
5182
+ const innerCount = await target.locator(innerTriggerSel).count();
5183
+ if (innerCount > 0)
5184
+ triggerSel = innerTriggerSel;
5185
+ }
5186
+ catch {
5187
+ // Fall back to the container selector.
5188
+ }
5044
5189
  try {
5045
5190
  await target.locator(triggerSel).first().click();
5046
5191
  }
@@ -6146,7 +6291,7 @@ async function probeStepBeforeAttempts(params) {
6146
6291
  }
6147
6292
  }
6148
6293
  async function executeStepWithHealing(params) {
6149
- const { stagehand, page, step, optional, upload, submitStep, stepIndex, totalSteps, phase, signalCounter, recentCaptures, recentCaptureMeta, anthropic, rephraseModel, logger, captureFn, uploadFixture, isFinalStep, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, getSuppressedAisdkElementIdErrorCount, trajectory, onStepFailure, } = params;
6294
+ const { stagehand, page, step, optional, upload, submitStep, 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, } = params;
6150
6295
  // Mutable (not the destructured const above) so a lost frame-attach race
6151
6296
  // can be upgraded in place once the OOPIF attaches later in the cascade —
6152
6297
  // see reresolveFrameTargetIfLost below. Every existing reference in this
@@ -6214,7 +6359,12 @@ async function executeStepWithHealing(params) {
6214
6359
  // false-credit the step, and whose advance/submit verdicts require a real
6215
6360
  // network/URL transition. Also keeps the extra full-DOM evaluate off the
6216
6361
  // submit/advance path. Step-level intent (available before the attempt loop).
6217
- const captureSelectionState = shouldCaptureSelectionState({ step, isFinalStep, submitStep });
6362
+ const captureSelectionState = shouldCaptureSelectionState({
6363
+ step,
6364
+ isFinalStep,
6365
+ submitStep,
6366
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
6367
+ });
6218
6368
  const attempts = [];
6219
6369
  const triedSelectors = [];
6220
6370
  const failureReasons = [];
@@ -6648,7 +6798,7 @@ async function executeStepWithHealing(params) {
6648
6798
  // techniques faster.
6649
6799
  if (attempt > 1) {
6650
6800
  const wouldBeTechnique = attempt === 2
6651
- ? phantomClickAfterAttempt1 && (isFinalStep || submitStep)
6801
+ ? phantomClickAfterAttempt1 && (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))
6652
6802
  ? "deep-submit-locator"
6653
6803
  : phantomClickAfterAttempt1
6654
6804
  ? "trusted-click-retry"
@@ -6667,7 +6817,7 @@ async function executeStepWithHealing(params) {
6667
6817
  })),
6668
6818
  advanceUnmovedAfterAttempt1,
6669
6819
  phantomClickAfterAttempt1,
6670
- submitShapedStep: isFinalStep || submitStep,
6820
+ submitShapedStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
6671
6821
  });
6672
6822
  if (decision.skip) {
6673
6823
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt} (${wouldBeTechnique}) skipped: ${decision.reason}`);
@@ -6795,7 +6945,9 @@ async function executeStepWithHealing(params) {
6795
6945
  }
6796
6946
  }
6797
6947
  }
6798
- else if (attempt === 2 && phantomClickAfterAttempt1 && (isFinalStep || submitStep)) {
6948
+ else if (attempt === 2 &&
6949
+ phantomClickAfterAttempt1 &&
6950
+ (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))) {
6799
6951
  // Deep submit-control locator: attempt 1 phantom-clicked (Stagehand
6800
6952
  // reported success but pre/post showed zero effect), so the target is
6801
6953
  // almost certainly unreachable via document.querySelectorAll — most
@@ -6917,7 +7069,9 @@ async function executeStepWithHealing(params) {
6917
7069
  }
6918
7070
  }
6919
7071
  }
6920
- else if (attempt === 2 && phantomClickAfterAttempt1 && !(isFinalStep || submitStep)) {
7072
+ else if (attempt === 2 &&
7073
+ phantomClickAfterAttempt1 &&
7074
+ !(submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))) {
6921
7075
  // Trusted-click retry: attempt 1 phantom-clicked a NON-submit control —
6922
7076
  // Stagehand reported success but pre/post showed zero effect. On a
6923
7077
  // design-system widget (React synthetic-event delegation, custom
@@ -7595,7 +7749,7 @@ async function executeStepWithHealing(params) {
7595
7749
  // `advanceTransitionBodyPattern` are unaffected.
7596
7750
  const domVerifiedForStep = isDomOnlyAdvanceVerified({
7597
7751
  hasPattern: advanceTransitionBodyPattern !== null,
7598
- isFinalOrSubmit: isFinalStep || submitStep,
7752
+ isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
7599
7753
  isAdvance: isAdvanceStep(step),
7600
7754
  domVerified,
7601
7755
  networkIsRealAdvance,
@@ -7628,6 +7782,7 @@ async function executeStepWithHealing(params) {
7628
7782
  resolvedAction,
7629
7783
  isFinalStep,
7630
7784
  submitStep,
7785
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
7631
7786
  isAdvanceWithPattern: isAdvanceStep(step) && advanceTransitionBodyPattern !== null,
7632
7787
  networkDelta: post.networkCount - pre.networkCount,
7633
7788
  bytesDelta: post.bodyHtmlLength - pre.bodyHtmlLength,
@@ -7714,11 +7869,44 @@ async function executeStepWithHealing(params) {
7714
7869
  }
7715
7870
  }
7716
7871
  }
7872
+ // Committed-value guard for the prompt-selector widget family (popup
7873
+ // multiselect/typeahead trigger widgets). The primitive's own picking
7874
+ // gesture ({@link tryPromptSelectorPrimitive}) only runs on the observe-act
7875
+ // fallback path; a resolved-as-click act against the SAME widget shape can
7876
+ // open its popup, report success, and — since opening a popup mutates the
7877
+ // DOM (bytes changed) — ride `clickViewSwapVerified` to a phantom success
7878
+ // without ever committing an option. Gate it the same way as the datepicker
7879
+ // guard above: readback the widget's own committed-value node
7880
+ // ({@link verifyPromptSelectorCommitted}) and, when the resolved element IS
7881
+ // this widget shape but nothing committed, suppress the weak signals.
7882
+ // `isPromptWidget: false` for anything outside this widget family, so a
7883
+ // plain click/fill is untouched.
7884
+ let promptSelectorRejected = false;
7885
+ // Exempt only on a genuine network-advance or URL-change signal, not the
7886
+ // broader `hasStrongSignal` (which the sibling datepicker gate above
7887
+ // legitimately relies on) — `domVerifiedForStep` is true here simply
7888
+ // because opening the widget's popup mutates the DOM, so reusing it would
7889
+ // let that popup-open alone skip the readback below.
7890
+ const hasNetworkOrUrlSignal = networkIsRealAdvance || urlChanged;
7891
+ if (record.actResultSuccess === true && !hasNetworkOrUrlSignal) {
7892
+ const readbackSelector = resolvedAction?.selector ?? triedSelectors[triedSelectors.length - 1] ?? null;
7893
+ if (readbackSelector) {
7894
+ const readbackTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
7895
+ const promptReadback = await verifyPromptSelectorCommitted(readbackTarget, readbackSelector);
7896
+ if (promptReadback?.isPromptWidget === true && promptReadback.committed === false) {
7897
+ promptSelectorRejected = true;
7898
+ record.actResultSuccess = false;
7899
+ record.errorMessage = `prompt-selector-fill-rejected: resolved element <${readbackSelector}> is a prompt-selector-shaped widget but no committed value landed`;
7900
+ }
7901
+ }
7902
+ }
7717
7903
  let verified = networkIsRealAdvance ||
7718
7904
  urlChanged ||
7719
7905
  domVerifiedForStep ||
7720
7906
  datepickerCommitted ||
7721
- (!datepickerRejected && (clickViewSwapVerified || formValueVerified));
7907
+ (!datepickerRejected &&
7908
+ !promptSelectorRejected &&
7909
+ (clickViewSwapVerified || formValueVerified));
7722
7910
  // Final-step submit-verification gate. Replaces the deterministic
7723
7911
  // submitEndpointPattern regex with a Haiku 4.5 LLM judgment over
7724
7912
  // multi-signal evidence (network captures, page URL/title, DOM
@@ -8004,7 +8192,7 @@ async function executeStepWithHealing(params) {
8004
8192
  }));
8005
8193
  const fallbackDomOnlyAdvance = shouldVetoFallbackAdvance({
8006
8194
  hasPattern: advanceTransitionBodyPattern !== null,
8007
- isFinalOrSubmit: isFinalStep || submitStep,
8195
+ isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
8008
8196
  isAdvance: isAdvanceStep(step),
8009
8197
  retryUrlChanged,
8010
8198
  retryNetworkIsRealAdvance,
@@ -8159,7 +8347,7 @@ async function executeStepWithHealing(params) {
8159
8347
  // checked) into `domVerified`. A registered selection toggle no longer
8160
8348
  // reads as a phantom just because it moved no network/URL/bytes.
8161
8349
  elementStateChanged: domVerified,
8162
- isSubmitShapedStep: isFinalStep || submitStep,
8350
+ isSubmitShapedStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
8163
8351
  });
8164
8352
  const reason = record.errorMessage
8165
8353
  ? effectSignals
@@ -8178,7 +8366,8 @@ async function executeStepWithHealing(params) {
8178
8366
  // Empirically grounded: 22 of 22 JSON-envelope ATS Continue/Submit step-failure
8179
8367
  // dumps in a 2026-06-10 survey had the paired touched+dirty + visible
8180
8368
  // error text pattern with 3 distinct rejection messages.
8181
- if (record.resolvedMethod === "click" && (isFinalStep || submitStep)) {
8369
+ if (record.resolvedMethod === "click" &&
8370
+ (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))) {
8182
8371
  const live = await extractLivePageFormEvidence(page, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), {
8183
8372
  client: anthropic,
8184
8373
  knownErrorClassPrefixes,
@@ -8223,7 +8412,7 @@ async function executeStepWithHealing(params) {
8223
8412
  phantomClickAfterAttempt1 = record.phantomClickVerdict === "phantom";
8224
8413
  if (phantomClickAfterAttempt1) {
8225
8414
  const suppressedCount = getSuppressedAisdkElementIdErrorCount?.();
8226
- const escalationTarget = isFinalStep || submitStep
8415
+ const escalationTarget = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag)
8227
8416
  ? "escalating attempt 2 to deep-submit-locator"
8228
8417
  : "non-submit step — escalating attempt 2 to trusted-click-retry (trusted CDP click on the resolved target)";
8229
8418
  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}`);
@@ -8233,7 +8422,7 @@ async function executeStepWithHealing(params) {
8233
8422
  // Treat the canonical submit click as "final" for this predicate
8234
8423
  // even when it lives mid-flow. See requireSubmitEndpoint derivation
8235
8424
  // above for the same gate-widening rationale.
8236
- isFinalStep: isFinalStep || submitStep,
8425
+ isFinalStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
8237
8426
  requireSubmitEndpoint,
8238
8427
  resolvedMethod: record.resolvedMethod,
8239
8428
  effectSignals,
@@ -8253,7 +8442,7 @@ async function executeStepWithHealing(params) {
8253
8442
  // can reorder a later step forward — instead of burning the cascade.
8254
8443
  const advanceStalled = isAdvanceStalled({
8255
8444
  isAdvance: isAdvanceStep(step),
8256
- isFinalOrSubmit: isFinalStep || submitStep,
8445
+ isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
8257
8446
  hasPattern: advanceTransitionBodyPattern !== null,
8258
8447
  clickFired: record.resolvedMethod === "click" && record.actResultSuccess === true,
8259
8448
  networkFired,
@@ -8399,6 +8588,11 @@ async function runHealingFlow(deps) {
8399
8588
  }
8400
8589
  },
8401
8590
  });
8591
+ const flowHasSubmitSemanticsFlag = flowHasSubmitSemantics({
8592
+ steps,
8593
+ submitEndpointPattern: deps.submitEndpointPattern ?? null,
8594
+ requireSubmitEndpointMatch: deps.requireSubmitEndpointMatch ?? false,
8595
+ });
8402
8596
  if (shouldWarnMissingAdvancePattern(steps.map((s) => s.instruction), deps.advanceTransitionBodyPattern ?? null)) {
8403
8597
  logger.warn("flow has advance steps but no advanceTransitionBodyPattern — DOM-only advance guard is disarmed; pointer/page desync is possible on validation-re-render wizards");
8404
8598
  }
@@ -8423,6 +8617,7 @@ async function runHealingFlow(deps) {
8423
8617
  optional: s.optional,
8424
8618
  upload: s.upload,
8425
8619
  submitStep: s.submitStep,
8620
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
8426
8621
  stepIndex: i,
8427
8622
  totalSteps: () => steps.length,
8428
8623
  phase: "flow",