@enricai/barnacle 1.12.2 → 1.12.4

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;
@@ -386,6 +388,13 @@ const SELECT_SETTLE_MS = 400;
386
388
  * triggered it.
387
389
  */
388
390
  const PROMPT_SELECTOR_SETTLE_MS = 400;
391
+ /**
392
+ * Cap on the category->leaf drill loop in {@link commitPromptOption}: a
393
+ * two-level cascading multiselect (category click re-renders the popup to
394
+ * leaves) only ever drills one level deep in the wild, so 3 gives headroom
395
+ * for a rarer 2-level cascade without letting a genuinely broken widget spin.
396
+ */
397
+ const PROMPT_SELECTOR_MAX_DRILL_DEPTH = 3;
389
398
  /**
390
399
  * Temporary attribute {@link tryPromptSelectorPrimitive} stamps onto each
391
400
  * candidate widget during its read-only enumerate pass, so the Node-side click
@@ -396,6 +405,18 @@ const PROMPT_SELECTOR_SETTLE_MS = 400;
396
405
  const PROMPT_WIDGET_MARK_ATTR = "data-bcl-prompt-idx";
397
406
  /** Same role as {@link PROMPT_WIDGET_MARK_ATTR}, but for the popup's rendered option entries. */
398
407
  const PROMPT_OPTION_MARK_ATTR = "data-bcl-prompt-opt-idx";
408
+ /**
409
+ * Cross-vendor selector for a genuine focusable form control — the only kind
410
+ * of element a native click event reliably opens. Phase 1's container walk
411
+ * marks the OUTERMOST widget-kit container so one widget resolves to one
412
+ * candidate, but that container is often a layout wrapper with no click
413
+ * handler of its own (e.g. a typeahead/chip widget's real open+filter control
414
+ * is an `<input>` nested a level deeper, inside its own `*InputContainer`
415
+ * wrapper). Phase 2 prefers clicking this interactive descendant over the
416
+ * marked container itself, falling back to the container only when it has
417
+ * none — no vendor branch, just "click the real control if one exists".
418
+ */
419
+ const PROMPT_INTERACTIVE_CONTROL_SELECTORS = "button,[role='combobox'],[role='button'],input";
399
420
  /**
400
421
  * Cross-vendor selector union that identifies the TRIGGER of a native-control-less
401
422
  * popup-dropdown widget (a combobox that opens a listbox popup and renders no
@@ -486,6 +507,30 @@ const PROMPT_EMPTY_VALUE_RX_FLAGS = PROMPT_EMPTY_VALUE_RX.flags;
486
507
  * the remaining `textContent`.
487
508
  */
488
509
  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 || \"\"; })";
510
+ /**
511
+ * Browser-side expression reading a prompt-selector widget's OWN committed-value
512
+ * text — the single source of truth for "is this widget filled?", shared by the
513
+ * widget-enumeration phase ({@link tryPromptSelectorPrimitive}'s
514
+ * `enumerateWidgetsExpr`) and the act-success committed-value gate
515
+ * ({@link verifyPromptSelectorCommitted}) so the two never drift into separate
516
+ * ideas of "committed". Value via the union: `aria-activedescendant` → the
517
+ * referenced option's text, else the widget's own value (an `<input>`'s
518
+ * `value`, or a `<button>`-trigger's own label text via {@link BUTTON_VALUE_EXPR}),
519
+ * else a selection-label node ({@link PROMPT_VALUE_SELECTORS}) — treating the
520
+ * widget-kit empty-state phrase ({@link PROMPT_EMPTY_VALUE_RX}) as no value.
521
+ */
522
+ const PROMPT_CURRENT_TEXT_EXPR = `((w, valueSel, emptyRx) => {
523
+ const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
524
+ const buttonValue = ${BUTTON_VALUE_EXPR};
525
+ const adid = w.getAttribute && w.getAttribute("aria-activedescendant");
526
+ if (adid) { const opt = document.getElementById(adid); if (opt && opt.textContent.trim()) return norm(opt.textContent); }
527
+ if (w.tagName === "INPUT" && (w.value || "").trim()) return norm(w.value);
528
+ if (w.tagName === "BUTTON" && buttonValue(w).trim()) return norm(buttonValue(w));
529
+ const lbl = w.matches(valueSel) ? w : w.querySelector(valueSel);
530
+ const raw = lbl ? (lbl.textContent || "") : "";
531
+ if (raw.trim() && !emptyRx.test(raw.trim())) return norm(raw);
532
+ return "";
533
+ })`;
489
534
  /**
490
535
  * Browser-side expression resolving the DOM root within which a chosen widget's
491
536
  * popup options / filter input live. Popup placement is vendor-split: the ARIA
@@ -923,8 +968,20 @@ function isAdvanceStep(instruction) {
923
968
  * cascade depends on is unit-testable, not buried in `executeStepWithHealing`.
924
969
  */
925
970
  function shouldCaptureSelectionState(params) {
926
- const { step, isFinalStep, submitStep } = params;
927
- return !(isFinalStep || submitStep || isAdvanceStep(step));
971
+ const { step, isFinalStep, submitStep, flowHasSubmitSemantics } = params;
972
+ return !(submitStep || (isFinalStep && flowHasSubmitSemantics) || isAdvanceStep(step));
973
+ }
974
+ /**
975
+ * Whether a flow has ANY submit semantics at all — a step flagged
976
+ * `submitStep: true`, a `submitEndpointPattern`, or `requireSubmitEndpointMatch`.
977
+ * A read-only flow (none of the three) has no submit shape anywhere, so its
978
+ * final step is an ordinary read/click, not a submit. Pure + exported so
979
+ * callers can stop inferring submit-shape from `isFinalStep` alone on flows
980
+ * that never declared a submit.
981
+ */
982
+ function flowHasSubmitSemantics(params) {
983
+ const { steps, submitEndpointPattern, requireSubmitEndpointMatch } = params;
984
+ return (steps.some((s) => s.submitStep) || submitEndpointPattern !== null || requireSubmitEndpointMatch);
928
985
  }
929
986
  /**
930
987
  * Whether a flow should be WARNed that its DOM-only advance guard is disarmed.
@@ -1200,10 +1257,10 @@ function isDomOnlyAdvanceVerified(params) {
1200
1257
  function isClickViewSwapVerified(params) {
1201
1258
  const VIEW_SWAP_MIN_BYTES = config_1.config.scraper.viewSwapMinBytesThreshold;
1202
1259
  const VIEW_SWAP_REVEAL_MIN_BYTES = config_1.config.scraper.viewSwapRevealMinBytesThreshold;
1203
- const { resolvedAction, isFinalStep, submitStep, isAdvanceWithPattern, networkDelta, bytesDelta, textChanged, invalidMarkerDelta = 0, } = params;
1260
+ const { resolvedAction, isFinalStep, submitStep, flowHasSubmitSemantics, isAdvanceWithPattern, networkDelta, bytesDelta, textChanged, invalidMarkerDelta = 0, } = params;
1204
1261
  if (resolvedAction?.method !== "click")
1205
1262
  return false;
1206
- if (isFinalStep || submitStep)
1263
+ if (submitStep || (isFinalStep && flowHasSubmitSemantics))
1207
1264
  return false;
1208
1265
  if (isAdvanceWithPattern)
1209
1266
  return false;
@@ -2715,6 +2772,50 @@ async function verifyFillReadback(target, selector, expectedValue) {
2715
2772
  return null;
2716
2773
  }
2717
2774
  }
2775
+ /**
2776
+ * Committed-value guard for the prompt-selector widget family
2777
+ * (`data-uxi-widget-type='multiselect'`/`selectinput`, `role=combobox`, …),
2778
+ * mirroring {@link verifyFillReadback}'s role for plain inputs but reading the
2779
+ * widget's OWN committed-value node ({@link PROMPT_CURRENT_TEXT_EXPR}) instead
2780
+ * of a bare `<input>.value` — a chip multiselect's committed state lives on
2781
+ * `aria-activedescendant`/a selection-label node, not the trigger's own value.
2782
+ * Walks UP from the resolved element to the nearest {@link PROMPT_TRIGGER_SELECTORS}
2783
+ * ancestor (the resolved element is often the inner filter input or icon, not
2784
+ * the widget container itself — same resolution discipline as
2785
+ * `tryPromptSelectorPrimitive`'s widget-container walk). Returns
2786
+ * `isPromptWidget: false` for a resolved element that isn't part of this
2787
+ * widget family at all, so a non-prompt click/fill is untouched.
2788
+ */
2789
+ async function verifyPromptSelectorCommitted(target, selector) {
2790
+ const xpath = xpathBodyForEvaluate(selector);
2791
+ if (xpath === null)
2792
+ return null;
2793
+ const expr = `(() => {
2794
+ const triggerSel = ${JSON.stringify(PROMPT_TRIGGER_SELECTORS)};
2795
+ const valueSel = ${JSON.stringify(PROMPT_VALUE_SELECTORS)};
2796
+ const emptyRx = new RegExp(${JSON.stringify(PROMPT_EMPTY_VALUE_RX_SRC)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_FLAGS)});
2797
+ const currentText = ${PROMPT_CURRENT_TEXT_EXPR};
2798
+ const xpath = ${JSON.stringify(xpath)};
2799
+ const result = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
2800
+ const el = result.singleNodeValue;
2801
+ if (!el) return { isPromptWidget: false, committed: false };
2802
+ const widget = el.matches && el.matches(triggerSel) ? el : el.closest(triggerSel);
2803
+ if (!widget) return { isPromptWidget: false, committed: false };
2804
+ return { isPromptWidget: true, committed: currentText(widget, valueSel, emptyRx) !== "" };
2805
+ })()`;
2806
+ try {
2807
+ const raw = await target.evaluate(expr);
2808
+ if (raw === null || typeof raw !== "object")
2809
+ return null;
2810
+ const r = raw;
2811
+ if (typeof r.isPromptWidget !== "boolean" || typeof r.committed !== "boolean")
2812
+ return null;
2813
+ return { isPromptWidget: r.isPromptWidget, committed: r.committed };
2814
+ }
2815
+ catch {
2816
+ return null;
2817
+ }
2818
+ }
2718
2819
  /**
2719
2820
  * Pull field-level errors out of an arbitrary JSON response body. Walks a
2720
2821
  * few of the conventional ATS shapes; falls through to `[]` so the caller
@@ -3521,11 +3622,54 @@ async function setFilesViaCdp(params) {
3521
3622
  * Recognizes the flow's conventional phrasings, all quoted:
3522
3623
  * "select 'Yes'", "select or check 'BLS'",
3523
3624
  * "for 'What is your highest level…?' select 'BSN completed'",
3524
- * "select 'Texas' in the State/Region dropdown".
3625
+ * "select 'Texas' in the State/Region dropdown",
3626
+ * "…then select the option 'Job Boards' from the popup list" (a compound
3627
+ * step that opens the widget in one clause and selects in another —
3628
+ * "select" is followed by a noun phrase like "the option"/"the value"
3629
+ * before the quoted option itself, rather than the quote directly).
3525
3630
  * Returns null when the step is not a single-dropdown select (e.g. generic
3526
3631
  * "for any remaining question…" catch-alls, or radio/checkbox-only steps) so
3527
3632
  * the caller falls through to the normal cascade.
3528
3633
  */
3634
+ // Nouns that name a form widget/question, used by `pickQuestionLabel` to tell
3635
+ // a genuine field label ("'How Did You Hear About Us?' prompt selector")
3636
+ // apart from an unrelated leading quoted phrase that just happens to precede
3637
+ // it in the instruction (e.g. a page/step-context quote like "'My
3638
+ // Information' step").
3639
+ const WIDGET_NOUN_RE = /\b(prompt\s+selector|multiselect|typeahead|dropdown|field|question|checkbox|radio\s+button|radio)\b/i;
3640
+ /**
3641
+ * Pick the QUESTION LABEL out of an instruction's quoted phrases, given the
3642
+ * already-extracted OPTION.
3643
+ *
3644
+ * Why this exists: an instruction can carry more than one quoted phrase that
3645
+ * is not the option — e.g. a page/step-context phrase ("On the authenticated
3646
+ * 'My Information' step, open the 'How Did You Hear About Us?' prompt
3647
+ * selector…") — so naively taking the first non-option quote picks the
3648
+ * context phrase instead of the actual widget label. This prefers a quote
3649
+ * that sits immediately next to a widget noun (`multiselect`/`typeahead`/
3650
+ * `dropdown`/`field`/`question`/`checkbox`/`radio button`/`prompt selector`)
3651
+ * or is introduced
3652
+ * by "for"/"for the"/"for the question"/"for the answer" (e.g. "click the
3653
+ * 'Yes' answer for the question '…'"), before falling back to the first
3654
+ * non-option quote so existing un-adorned phrasings keep working.
3655
+ */
3656
+ function pickQuestionLabel(instruction, option) {
3657
+ // biome-ignore lint/style/noNonNullAssertion: capture group 1 is required by the pattern, so it is present on every match
3658
+ const candidates = [...instruction.matchAll(/'([^']+)'/g)].filter((m) => m[1].trim() !== option);
3659
+ if (candidates.length === 0)
3660
+ return null;
3661
+ const adjacentToWidgetNoun = candidates.find((m) => {
3662
+ const start = m.index ?? 0;
3663
+ const end = start + m[0].length;
3664
+ const before = instruction.slice(Math.max(0, start - 25), start);
3665
+ const after = instruction.slice(end, end + 40);
3666
+ return (/\bfor(?:\s+the)?(?:\s+(?:question|answer))?\s*$/i.test(before) || WIDGET_NOUN_RE.test(after));
3667
+ });
3668
+ // biome-ignore lint/style/noNonNullAssertion: candidates is guarded non-empty above, so the fallback element is always present
3669
+ const picked = (adjacentToWidgetNoun ?? candidates[0]);
3670
+ // biome-ignore lint/style/noNonNullAssertion: capture group 1 is required by the pattern, so it is present on every match
3671
+ return picked[1].trim();
3672
+ }
3529
3673
  function parseSelectStep(instruction) {
3530
3674
  const lower = instruction.toLowerCase();
3531
3675
  // Must look like a dropdown selection, not a radio/checkbox click. "select
@@ -3541,16 +3685,20 @@ function parseSelectStep(instruction) {
3541
3685
  const quoted = [...instruction.matchAll(/'([^']+)'/g)].map((m) => m[1]);
3542
3686
  if (quoted.length === 0)
3543
3687
  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);
3688
+ // The OPTION is the quoted string following the word "select", allowing a
3689
+ // short filler ("the option", "the answer") between the verb and the
3690
+ // quote — real flow phrasing like "select the option 'Job Boards'" or
3691
+ // "select the answer 'X'" otherwise fails to parse and the caller
3692
+ // silently no-ops before touching the DOM.
3693
+ const selMatch = instruction.match(/\bselect(?:\s+or\s+check)?\s+(?:the\s+\S+\s+)?'([^']+)'/i);
3546
3694
  if (!selMatch)
3547
3695
  return null;
3548
3696
  // biome-ignore lint/style/noNonNullAssertion: guarded by the !selMatch early-return; group 1 is required by the pattern
3549
3697
  const option = selMatch[1].trim();
3550
3698
  // 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;
3699
+ // introduced by "for '…'" or adjacent to a widget noun like "'…' dropdown"
3700
+ // or "'…' prompt selector". See `pickQuestionLabel`.
3701
+ const questionLabel = pickQuestionLabel(instruction, option);
3554
3702
  return { option, questionLabel };
3555
3703
  }
3556
3704
  /**
@@ -3668,8 +3816,9 @@ function parseRadioStep(instruction) {
3668
3816
  // introduced by "for the question '…'" / "for the '…' question". Some steps
3669
3817
  // phrase the question un-quoted ("…about requiring visa sponsorship"); in
3670
3818
  // 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;
3819
+ // which the primitive handles via LLM group-matching. See
3820
+ // `pickQuestionLabel`.
3821
+ const questionLabel = pickQuestionLabel(instruction, option);
3673
3822
  return { option, questionLabel };
3674
3823
  }
3675
3824
  /**
@@ -4969,28 +5118,15 @@ async function tryPromptSelectorPrimitive(params) {
4969
5118
  }
4970
5119
  return "";
4971
5120
  };
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
- };
5121
+ const currentText = ${PROMPT_CURRENT_TEXT_EXPR};
5122
+ const currentTextOf = (w) => currentText(w, valueSel, emptyRx);
4987
5123
  const isUnfilled = (w) => {
4988
5124
  let node = w;
4989
5125
  for (let d = 0; d < 6 && node; d++) {
4990
5126
  if (node.getAttribute && isInvalid(node)) return true;
4991
5127
  node = node.parentElement;
4992
5128
  }
4993
- return currentText(w) === "";
5129
+ return currentTextOf(w) === "";
4994
5130
  };
4995
5131
  // Clear stale marks from a prior call on this same page (this primitive
4996
5132
  // runs once per "select 'X'" step, and an application wizard answers several
@@ -5038,17 +5174,6 @@ async function tryPromptSelectorPrimitive(params) {
5038
5174
  logger.info(`prompt-selector primitive: no unambiguous widget match for ${optLabel}; falling through to cascade`);
5039
5175
  return null;
5040
5176
  }
5041
- // Phase 2 (real gesture): open the popup. These widgets' trigger requires a
5042
- // genuine click — a synthetic dispatchEvent does not fire its handler.
5043
- const triggerSel = `[${PROMPT_WIDGET_MARK_ATTR}="${chosen.wIdx}"]`;
5044
- try {
5045
- await target.locator(triggerSel).first().click();
5046
- }
5047
- catch (err) {
5048
- logger.info(`prompt-selector primitive: trigger click failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
5049
- return null;
5050
- }
5051
- await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
5052
5177
  const enumerateOptionsExpr = `((widgetMarkAttr, wIdx, markAttr, optionSel, searchSel) => {
5053
5178
  const w = document.querySelector("[" + widgetMarkAttr + '="' + wIdx + '"]');
5054
5179
  if (!w) return { optionsPresent: false };
@@ -5087,9 +5212,65 @@ async function tryPromptSelectorPrimitive(params) {
5087
5212
  const label = opts[i].getAttribute("data-automation-label") || opts[i].getAttribute("aria-label") || opts[i].textContent || "";
5088
5213
  options.push({ oIdx: i, text: label.replace(/\\s+/g, " ").trim() });
5089
5214
  }
5090
- return { optionsPresent: true, searchable: !!searchInput, options };
5215
+ // scopeIsDocument distinguishes a genuinely resolved (portal or inline)
5216
+ // popup from the document-wide last-resort fallback in
5217
+ // PROMPT_SCOPE_ROOT_EXPR — the pre-open pre-check below must NOT treat a
5218
+ // still-unopened widget as "already open" on the strength of some OTHER
5219
+ // widget's stale/leftover popup elsewhere in the document; only a scope
5220
+ // that resolved to THIS widget's own inline subtree or its own
5221
+ // aria-controls/aria-owns target counts.
5222
+ return { optionsPresent: true, searchable: !!searchInput, options, scopeIsDocument: scope === document };
5091
5223
  })(${JSON.stringify(PROMPT_WIDGET_MARK_ATTR)}, ${JSON.stringify(chosen.wIdx)}, ${JSON.stringify(PROMPT_OPTION_MARK_ATTR)}, ${JSON.stringify(PROMPT_OPTION_SELECTORS)}, ${JSON.stringify(PROMPT_SEARCH_SELECTORS)})`;
5092
- const optionsInitial = await pollEnumerate(page, target, enumerateOptionsExpr, (r) => r?.optionsPresent === true);
5224
+ // Pre-check (single evaluate, no polling): a PRIOR primitive call on this
5225
+ // same unreloaded page may have already opened/drilled this exact widget's
5226
+ // popup (a two-level cascading multiselect authored as two flow steps —
5227
+ // category, then leaf — re-enters here for the leaf with the popup already
5228
+ // open). Re-clicking the trigger in that case perturbs/closes the
5229
+ // already-rendered popup instead of reading it. Skip the open-click
5230
+ // entirely when options are already present AND resolved to THIS widget's
5231
+ // OWN scope (not the document-wide fallback, which could otherwise credit
5232
+ // an unrelated widget's stale/leftover popup elsewhere on the page as
5233
+ // "already open"); a genuinely closed popup falls through to the normal
5234
+ // open-click + poll path below unchanged.
5235
+ const precheck = (await target.evaluate(enumerateOptionsExpr));
5236
+ const alreadyOpen = precheck?.optionsPresent === true &&
5237
+ (precheck.options?.length ?? 0) > 0 &&
5238
+ precheck.scopeIsDocument !== true;
5239
+ if (!alreadyOpen) {
5240
+ // Phase 2 (real gesture): open the popup. These widgets' trigger requires a
5241
+ // genuine click — a synthetic dispatchEvent does not fire its handler.
5242
+ // Prefer the marked widget's own interactive descendant (a real form
5243
+ // control) over the marked container itself, since the container is
5244
+ // frequently a non-interactive layout wrapper for widget shapes whose
5245
+ // actual trigger sits a level deeper (see PROMPT_INTERACTIVE_CONTROL_SELECTORS).
5246
+ const containerTriggerSel = `[${PROMPT_WIDGET_MARK_ATTR}="${chosen.wIdx}"]`;
5247
+ const innerTriggerSel = PROMPT_INTERACTIVE_CONTROL_SELECTORS.split(",")
5248
+ .map((s) => `${containerTriggerSel} ${s}`)
5249
+ .join(",");
5250
+ let triggerSel = containerTriggerSel;
5251
+ try {
5252
+ const innerCount = await target.locator(innerTriggerSel).count();
5253
+ if (innerCount > 0)
5254
+ triggerSel = innerTriggerSel;
5255
+ }
5256
+ catch {
5257
+ // Fall back to the container selector.
5258
+ }
5259
+ try {
5260
+ await target.locator(triggerSel).first().click();
5261
+ }
5262
+ catch (err) {
5263
+ logger.info(`prompt-selector primitive: trigger click failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
5264
+ return null;
5265
+ }
5266
+ await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
5267
+ }
5268
+ else {
5269
+ logger.info(`prompt-selector primitive: popup for ${optLabel} already open with rendered options; skipping trigger click`);
5270
+ }
5271
+ const optionsInitial = alreadyOpen
5272
+ ? precheck
5273
+ : await pollEnumerate(page, target, enumerateOptionsExpr, (r) => r?.optionsPresent === true);
5093
5274
  if (!optionsInitial?.optionsPresent) {
5094
5275
  logger.info(`prompt-selector primitive: popup for ${optLabel} did not render options; falling through`);
5095
5276
  return null;
@@ -5112,6 +5293,7 @@ async function tryPromptSelectorPrimitive(params) {
5112
5293
  questionLabel,
5113
5294
  chosen,
5114
5295
  optionsResult,
5296
+ enumerateOptionsExpr,
5115
5297
  });
5116
5298
  }
5117
5299
  try {
@@ -5145,6 +5327,7 @@ async function tryPromptSelectorPrimitive(params) {
5145
5327
  questionLabel,
5146
5328
  chosen,
5147
5329
  optionsResult: optionsFiltered,
5330
+ enumerateOptionsExpr,
5148
5331
  });
5149
5332
  }
5150
5333
  catch (err) {
@@ -5161,82 +5344,115 @@ async function tryPromptSelectorPrimitive(params) {
5161
5344
  * by the value union, or its invalid marker cleared). Split out from the main
5162
5345
  * function because both the static and searchable-then-filtered branches need
5163
5346
  * the identical match/click/verify sequence.
5347
+ *
5348
+ * Bounded (see {@link PROMPT_SELECTOR_MAX_DRILL_DEPTH}) category->leaf drill:
5349
+ * when a click's readback fails, that is ambiguous between a genuine
5350
+ * non-commit and a category click that swapped the popup to a deeper level
5351
+ * (a two-level cascading multiselect authored as a SINGLE step naming only
5352
+ * the leaf). Re-enumerating after a failed readback and comparing the
5353
+ * option set disambiguates the two: an unchanged set is a real failure and
5354
+ * must still fall through to the cascade; a changed set means the drill
5355
+ * fired, so re-match/re-click continues at the new level instead of
5356
+ * abandoning the primitive.
5164
5357
  */
5165
5358
  async function commitPromptOption(params) {
5166
- const { page, target, logger, anthropic, captureFn, optLabel, option, questionLabel, chosen, optionsResult, } = params;
5359
+ const { page, target, logger, anthropic, captureFn, optLabel, option, questionLabel, chosen, enumerateOptionsExpr, } = params;
5167
5360
  const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
5168
5361
  const wantOpt = norm(option);
5169
- const options = optionsResult.options ?? [];
5170
- const detMatch = options.find((o) => norm(o.text) === wantOpt) ?? null;
5171
- if (detMatch === null && (anthropic === null || options.length === 0)) {
5172
- logger.info(`prompt-selector primitive: no option match for ${optLabel}${anthropic === null ? " (no LLM client)" : ""}; falling through to cascade`);
5173
- return null;
5174
- }
5175
- const verdict = detMatch === null
5176
- ? await (0, select_option_1.judgeSelectOptionWithLLM)({
5177
- client: anthropic,
5178
- input: {
5179
- questionLabel,
5180
- desiredHint: option,
5181
- candidates: [{ label: chosen.label || null, options: options.map((o) => o.text) }],
5182
- },
5183
- captureFn,
5184
- })
5185
- : null;
5186
- if (detMatch === null &&
5187
- (!verdict || verdict.selectIndex === null || verdict.optionIndex === null)) {
5188
- logger.info(`prompt-selector primitive: LLM found no matching option for ${optLabel}${verdict ? ` (${verdict.reason})` : ""}; falling through to cascade`);
5189
- return null;
5190
- }
5191
- const chosenOption = detMatch ??
5192
- (verdict && verdict.optionIndex !== null ? (options[verdict.optionIndex] ?? null) : null);
5193
- if (!chosenOption) {
5194
- logger.info(`prompt-selector primitive: option match out of range for ${optLabel}; falling through`);
5195
- return null;
5196
- }
5197
- const matchReason = detMatch ? "deterministic" : `LLM: ${(verdict?.reason ?? "").slice(0, 60)}`;
5198
- const optionSel = `[${PROMPT_OPTION_MARK_ATTR}="${chosenOption.oIdx}"]`;
5199
- try {
5200
- await target.locator(optionSel).first().click();
5201
- }
5202
- catch (err) {
5203
- logger.info(`prompt-selector primitive: option click failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
5204
- return null;
5205
- }
5206
- await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
5207
- const readbackExpr = `((wIdx, markAttr, valueSel, emptyRxSrc, emptyRxFlags, wantText) => {
5208
- const isInvalid = ${INVALID_MARKER_EL_EXPR};
5209
- const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
5210
- const buttonValue = ${BUTTON_VALUE_EXPR};
5211
- const emptyRx = new RegExp(emptyRxSrc, emptyRxFlags);
5212
- const w = document.querySelector("[" + markAttr + '="' + wIdx + '"]');
5213
- if (!w) return { ok: false, id: "" };
5214
- // Value via the union: aria-activedescendant, own input value, a <button>'s
5215
- // own value text (popup-pollution-safe, see BUTTON_VALUE_EXPR), or a
5216
- // selection-label node (empty-state phrase treated as no value).
5217
- const adid = w.getAttribute && w.getAttribute("aria-activedescendant");
5218
- const adText = adid && document.getElementById(adid) ? norm(document.getElementById(adid).textContent) : "";
5219
- const own = w.tagName === "INPUT" ? norm(w.value || "") : w.tagName === "BUTTON" ? norm(buttonValue(w)) : "";
5220
- const lbl = w.matches(valueSel) ? w : w.querySelector(valueSel);
5221
- const lblRaw = lbl ? (lbl.textContent || "") : "";
5222
- const lblText = lblRaw.trim() && !emptyRx.test(lblRaw.trim()) ? norm(lblRaw) : "";
5223
- const text = adText || own || lblText;
5224
- const textMatches = wantText ? text.includes(wantText) : text !== "";
5225
- let node = w;
5226
- let stillInvalid = false;
5227
- for (let d = 0; d < 6 && node; d++) {
5228
- if (node.getAttribute && isInvalid(node)) { stillInvalid = true; break; }
5229
- node = node.parentElement;
5230
- }
5231
- return { ok: textMatches || !stillInvalid, id: w.id || "" };
5232
- })(${JSON.stringify(chosen.wIdx)}, ${JSON.stringify(PROMPT_WIDGET_MARK_ATTR)}, ${JSON.stringify(PROMPT_VALUE_SELECTORS)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_SRC)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_FLAGS)}, ${JSON.stringify(norm(chosenOption.text))})`;
5233
- const readback = (await target.evaluate(readbackExpr).catch(() => ({ ok: false, id: "" })));
5234
- if (!readback?.ok) {
5235
- logger.info(`prompt-selector primitive: selection for ${optLabel} did not commit; falling through to cascade`);
5236
- return null;
5362
+ const optionSetKey = (opts) => opts
5363
+ .map((o) => norm(o.text))
5364
+ .sort()
5365
+ .join("");
5366
+ let optionsResult = params.optionsResult;
5367
+ for (let depth = 0; depth <= PROMPT_SELECTOR_MAX_DRILL_DEPTH; depth++) {
5368
+ const options = optionsResult.options ?? [];
5369
+ const detMatch = options.find((o) => norm(o.text) === wantOpt) ?? null;
5370
+ if (detMatch === null && (anthropic === null || options.length === 0)) {
5371
+ logger.info(`prompt-selector primitive: no option match for ${optLabel}${anthropic === null ? " (no LLM client)" : ""}; falling through to cascade`);
5372
+ return null;
5373
+ }
5374
+ const verdict = detMatch === null
5375
+ ? await (0, select_option_1.judgeSelectOptionWithLLM)({
5376
+ client: anthropic,
5377
+ input: {
5378
+ questionLabel,
5379
+ desiredHint: option,
5380
+ candidates: [{ label: chosen.label || null, options: options.map((o) => o.text) }],
5381
+ },
5382
+ captureFn,
5383
+ })
5384
+ : null;
5385
+ if (detMatch === null &&
5386
+ (!verdict || verdict.selectIndex === null || verdict.optionIndex === null)) {
5387
+ logger.info(`prompt-selector primitive: LLM found no matching option for ${optLabel}${verdict ? ` (${verdict.reason})` : ""}; falling through to cascade`);
5388
+ return null;
5389
+ }
5390
+ const chosenOption = detMatch ??
5391
+ (verdict && verdict.optionIndex !== null ? (options[verdict.optionIndex] ?? null) : null);
5392
+ if (!chosenOption) {
5393
+ logger.info(`prompt-selector primitive: option match out of range for ${optLabel}; falling through`);
5394
+ return null;
5395
+ }
5396
+ const matchReason = detMatch ? "deterministic" : `LLM: ${(verdict?.reason ?? "").slice(0, 60)}`;
5397
+ const optionSel = `[${PROMPT_OPTION_MARK_ATTR}="${chosenOption.oIdx}"]`;
5398
+ try {
5399
+ await target.locator(optionSel).first().click();
5400
+ }
5401
+ catch (err) {
5402
+ logger.info(`prompt-selector primitive: option click failed for ${optLabel}: ${(0, errors_1.toErrorMessage)(err)}; falling through`);
5403
+ return null;
5404
+ }
5405
+ await page.waitForTimeout(PROMPT_SELECTOR_SETTLE_MS);
5406
+ const readbackExpr = `((wIdx, markAttr, valueSel, emptyRxSrc, emptyRxFlags, wantText) => {
5407
+ const isInvalid = ${INVALID_MARKER_EL_EXPR};
5408
+ const norm = (s) => (s || "").replace(/\\s+/g, " ").trim().toLowerCase();
5409
+ const buttonValue = ${BUTTON_VALUE_EXPR};
5410
+ const emptyRx = new RegExp(emptyRxSrc, emptyRxFlags);
5411
+ const w = document.querySelector("[" + markAttr + '="' + wIdx + '"]');
5412
+ if (!w) return { ok: false, id: "" };
5413
+ // Value via the union: aria-activedescendant, own input value, a <button>'s
5414
+ // own value text (popup-pollution-safe, see BUTTON_VALUE_EXPR), or a
5415
+ // selection-label node (empty-state phrase treated as no value).
5416
+ const adid = w.getAttribute && w.getAttribute("aria-activedescendant");
5417
+ const adText = adid && document.getElementById(adid) ? norm(document.getElementById(adid).textContent) : "";
5418
+ const own = w.tagName === "INPUT" ? norm(w.value || "") : w.tagName === "BUTTON" ? norm(buttonValue(w)) : "";
5419
+ const lbl = w.matches(valueSel) ? w : w.querySelector(valueSel);
5420
+ const lblRaw = lbl ? (lbl.textContent || "") : "";
5421
+ const lblText = lblRaw.trim() && !emptyRx.test(lblRaw.trim()) ? norm(lblRaw) : "";
5422
+ const text = adText || own || lblText;
5423
+ const textMatches = wantText ? text.includes(wantText) : text !== "";
5424
+ let node = w;
5425
+ let stillInvalid = false;
5426
+ for (let d = 0; d < 6 && node; d++) {
5427
+ if (node.getAttribute && isInvalid(node)) { stillInvalid = true; break; }
5428
+ node = node.parentElement;
5429
+ }
5430
+ return { ok: textMatches || !stillInvalid, id: w.id || "" };
5431
+ })(${JSON.stringify(chosen.wIdx)}, ${JSON.stringify(PROMPT_WIDGET_MARK_ATTR)}, ${JSON.stringify(PROMPT_VALUE_SELECTORS)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_SRC)}, ${JSON.stringify(PROMPT_EMPTY_VALUE_RX_FLAGS)}, ${JSON.stringify(norm(chosenOption.text))})`;
5432
+ const readback = (await target.evaluate(readbackExpr).catch(() => ({ ok: false, id: "" })));
5433
+ if (readback?.ok) {
5434
+ logger.info(`prompt-selector primitive: selected "${chosenOption.text.slice(0, 40)}" for ${optLabel} (${matchReason})`);
5435
+ return readback.id;
5436
+ }
5437
+ if (depth === PROMPT_SELECTOR_MAX_DRILL_DEPTH) {
5438
+ logger.info(`prompt-selector primitive: selection for ${optLabel} did not commit; falling through to cascade`);
5439
+ return null;
5440
+ }
5441
+ // Readback failed — disambiguate a genuine non-commit from a
5442
+ // category->leaf drill by re-enumerating and comparing the option set.
5443
+ const reEnumerated = (await target
5444
+ .evaluate(enumerateOptionsExpr)
5445
+ .catch(() => ({ optionsPresent: false })));
5446
+ const newOptions = reEnumerated?.optionsPresent ? (reEnumerated.options ?? []) : [];
5447
+ const drilled = newOptions.length > 0 && optionSetKey(newOptions) !== optionSetKey(options);
5448
+ if (!drilled) {
5449
+ logger.info(`prompt-selector primitive: selection for ${optLabel} did not commit; falling through to cascade`);
5450
+ return null;
5451
+ }
5452
+ logger.info(`prompt-selector primitive: click for ${optLabel} drilled the popup to a new option set; re-matching at the new level`);
5453
+ optionsResult = { options: newOptions };
5237
5454
  }
5238
- logger.info(`prompt-selector primitive: selected "${chosenOption.text.slice(0, 40)}" for ${optLabel} (${matchReason})`);
5239
- return readback.id;
5455
+ return null;
5240
5456
  }
5241
5457
  /**
5242
5458
  * Guard for the optional-step fast-skip: is there a REQUIRED, still-empty (or
@@ -6146,7 +6362,7 @@ async function probeStepBeforeAttempts(params) {
6146
6362
  }
6147
6363
  }
6148
6364
  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;
6365
+ 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
6366
  // Mutable (not the destructured const above) so a lost frame-attach race
6151
6367
  // can be upgraded in place once the OOPIF attaches later in the cascade —
6152
6368
  // see reresolveFrameTargetIfLost below. Every existing reference in this
@@ -6214,7 +6430,12 @@ async function executeStepWithHealing(params) {
6214
6430
  // false-credit the step, and whose advance/submit verdicts require a real
6215
6431
  // network/URL transition. Also keeps the extra full-DOM evaluate off the
6216
6432
  // submit/advance path. Step-level intent (available before the attempt loop).
6217
- const captureSelectionState = shouldCaptureSelectionState({ step, isFinalStep, submitStep });
6433
+ const captureSelectionState = shouldCaptureSelectionState({
6434
+ step,
6435
+ isFinalStep,
6436
+ submitStep,
6437
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
6438
+ });
6218
6439
  const attempts = [];
6219
6440
  const triedSelectors = [];
6220
6441
  const failureReasons = [];
@@ -6648,7 +6869,7 @@ async function executeStepWithHealing(params) {
6648
6869
  // techniques faster.
6649
6870
  if (attempt > 1) {
6650
6871
  const wouldBeTechnique = attempt === 2
6651
- ? phantomClickAfterAttempt1 && (isFinalStep || submitStep)
6872
+ ? phantomClickAfterAttempt1 && (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))
6652
6873
  ? "deep-submit-locator"
6653
6874
  : phantomClickAfterAttempt1
6654
6875
  ? "trusted-click-retry"
@@ -6667,7 +6888,7 @@ async function executeStepWithHealing(params) {
6667
6888
  })),
6668
6889
  advanceUnmovedAfterAttempt1,
6669
6890
  phantomClickAfterAttempt1,
6670
- submitShapedStep: isFinalStep || submitStep,
6891
+ submitShapedStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
6671
6892
  });
6672
6893
  if (decision.skip) {
6673
6894
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} attempt ${attempt} (${wouldBeTechnique}) skipped: ${decision.reason}`);
@@ -6795,7 +7016,9 @@ async function executeStepWithHealing(params) {
6795
7016
  }
6796
7017
  }
6797
7018
  }
6798
- else if (attempt === 2 && phantomClickAfterAttempt1 && (isFinalStep || submitStep)) {
7019
+ else if (attempt === 2 &&
7020
+ phantomClickAfterAttempt1 &&
7021
+ (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))) {
6799
7022
  // Deep submit-control locator: attempt 1 phantom-clicked (Stagehand
6800
7023
  // reported success but pre/post showed zero effect), so the target is
6801
7024
  // almost certainly unreachable via document.querySelectorAll — most
@@ -6917,7 +7140,9 @@ async function executeStepWithHealing(params) {
6917
7140
  }
6918
7141
  }
6919
7142
  }
6920
- else if (attempt === 2 && phantomClickAfterAttempt1 && !(isFinalStep || submitStep)) {
7143
+ else if (attempt === 2 &&
7144
+ phantomClickAfterAttempt1 &&
7145
+ !(submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))) {
6921
7146
  // Trusted-click retry: attempt 1 phantom-clicked a NON-submit control —
6922
7147
  // Stagehand reported success but pre/post showed zero effect. On a
6923
7148
  // design-system widget (React synthetic-event delegation, custom
@@ -7595,7 +7820,7 @@ async function executeStepWithHealing(params) {
7595
7820
  // `advanceTransitionBodyPattern` are unaffected.
7596
7821
  const domVerifiedForStep = isDomOnlyAdvanceVerified({
7597
7822
  hasPattern: advanceTransitionBodyPattern !== null,
7598
- isFinalOrSubmit: isFinalStep || submitStep,
7823
+ isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
7599
7824
  isAdvance: isAdvanceStep(step),
7600
7825
  domVerified,
7601
7826
  networkIsRealAdvance,
@@ -7628,6 +7853,7 @@ async function executeStepWithHealing(params) {
7628
7853
  resolvedAction,
7629
7854
  isFinalStep,
7630
7855
  submitStep,
7856
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
7631
7857
  isAdvanceWithPattern: isAdvanceStep(step) && advanceTransitionBodyPattern !== null,
7632
7858
  networkDelta: post.networkCount - pre.networkCount,
7633
7859
  bytesDelta: post.bodyHtmlLength - pre.bodyHtmlLength,
@@ -7714,11 +7940,44 @@ async function executeStepWithHealing(params) {
7714
7940
  }
7715
7941
  }
7716
7942
  }
7943
+ // Committed-value guard for the prompt-selector widget family (popup
7944
+ // multiselect/typeahead trigger widgets). The primitive's own picking
7945
+ // gesture ({@link tryPromptSelectorPrimitive}) only runs on the observe-act
7946
+ // fallback path; a resolved-as-click act against the SAME widget shape can
7947
+ // open its popup, report success, and — since opening a popup mutates the
7948
+ // DOM (bytes changed) — ride `clickViewSwapVerified` to a phantom success
7949
+ // without ever committing an option. Gate it the same way as the datepicker
7950
+ // guard above: readback the widget's own committed-value node
7951
+ // ({@link verifyPromptSelectorCommitted}) and, when the resolved element IS
7952
+ // this widget shape but nothing committed, suppress the weak signals.
7953
+ // `isPromptWidget: false` for anything outside this widget family, so a
7954
+ // plain click/fill is untouched.
7955
+ let promptSelectorRejected = false;
7956
+ // Exempt only on a genuine network-advance or URL-change signal, not the
7957
+ // broader `hasStrongSignal` (which the sibling datepicker gate above
7958
+ // legitimately relies on) — `domVerifiedForStep` is true here simply
7959
+ // because opening the widget's popup mutates the DOM, so reusing it would
7960
+ // let that popup-open alone skip the readback below.
7961
+ const hasNetworkOrUrlSignal = networkIsRealAdvance || urlChanged;
7962
+ if (record.actResultSuccess === true && !hasNetworkOrUrlSignal) {
7963
+ const readbackSelector = resolvedAction?.selector ?? triedSelectors[triedSelectors.length - 1] ?? null;
7964
+ if (readbackSelector) {
7965
+ const readbackTarget = frameTarget ?? (0, frame_target_1.mainFrameTarget)(page);
7966
+ const promptReadback = await verifyPromptSelectorCommitted(readbackTarget, readbackSelector);
7967
+ if (promptReadback?.isPromptWidget === true && promptReadback.committed === false) {
7968
+ promptSelectorRejected = true;
7969
+ record.actResultSuccess = false;
7970
+ record.errorMessage = `prompt-selector-fill-rejected: resolved element <${readbackSelector}> is a prompt-selector-shaped widget but no committed value landed`;
7971
+ }
7972
+ }
7973
+ }
7717
7974
  let verified = networkIsRealAdvance ||
7718
7975
  urlChanged ||
7719
7976
  domVerifiedForStep ||
7720
7977
  datepickerCommitted ||
7721
- (!datepickerRejected && (clickViewSwapVerified || formValueVerified));
7978
+ (!datepickerRejected &&
7979
+ !promptSelectorRejected &&
7980
+ (clickViewSwapVerified || formValueVerified));
7722
7981
  // Final-step submit-verification gate. Replaces the deterministic
7723
7982
  // submitEndpointPattern regex with a Haiku 4.5 LLM judgment over
7724
7983
  // multi-signal evidence (network captures, page URL/title, DOM
@@ -8004,7 +8263,7 @@ async function executeStepWithHealing(params) {
8004
8263
  }));
8005
8264
  const fallbackDomOnlyAdvance = shouldVetoFallbackAdvance({
8006
8265
  hasPattern: advanceTransitionBodyPattern !== null,
8007
- isFinalOrSubmit: isFinalStep || submitStep,
8266
+ isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
8008
8267
  isAdvance: isAdvanceStep(step),
8009
8268
  retryUrlChanged,
8010
8269
  retryNetworkIsRealAdvance,
@@ -8159,7 +8418,7 @@ async function executeStepWithHealing(params) {
8159
8418
  // checked) into `domVerified`. A registered selection toggle no longer
8160
8419
  // reads as a phantom just because it moved no network/URL/bytes.
8161
8420
  elementStateChanged: domVerified,
8162
- isSubmitShapedStep: isFinalStep || submitStep,
8421
+ isSubmitShapedStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
8163
8422
  });
8164
8423
  const reason = record.errorMessage
8165
8424
  ? effectSignals
@@ -8178,7 +8437,8 @@ async function executeStepWithHealing(params) {
8178
8437
  // Empirically grounded: 22 of 22 JSON-envelope ATS Continue/Submit step-failure
8179
8438
  // dumps in a 2026-06-10 survey had the paired touched+dirty + visible
8180
8439
  // error text pattern with 3 distinct rejection messages.
8181
- if (record.resolvedMethod === "click" && (isFinalStep || submitStep)) {
8440
+ if (record.resolvedMethod === "click" &&
8441
+ (submitStep || (isFinalStep && flowHasSubmitSemanticsFlag))) {
8182
8442
  const live = await extractLivePageFormEvidence(page, frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), {
8183
8443
  client: anthropic,
8184
8444
  knownErrorClassPrefixes,
@@ -8223,7 +8483,7 @@ async function executeStepWithHealing(params) {
8223
8483
  phantomClickAfterAttempt1 = record.phantomClickVerdict === "phantom";
8224
8484
  if (phantomClickAfterAttempt1) {
8225
8485
  const suppressedCount = getSuppressedAisdkElementIdErrorCount?.();
8226
- const escalationTarget = isFinalStep || submitStep
8486
+ const escalationTarget = submitStep || (isFinalStep && flowHasSubmitSemanticsFlag)
8227
8487
  ? "escalating attempt 2 to deep-submit-locator"
8228
8488
  : "non-submit step — escalating attempt 2 to trusted-click-retry (trusted CDP click on the resolved target)";
8229
8489
  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 +8493,7 @@ async function executeStepWithHealing(params) {
8233
8493
  // Treat the canonical submit click as "final" for this predicate
8234
8494
  // even when it lives mid-flow. See requireSubmitEndpoint derivation
8235
8495
  // above for the same gate-widening rationale.
8236
- isFinalStep: isFinalStep || submitStep,
8496
+ isFinalStep: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
8237
8497
  requireSubmitEndpoint,
8238
8498
  resolvedMethod: record.resolvedMethod,
8239
8499
  effectSignals,
@@ -8253,7 +8513,7 @@ async function executeStepWithHealing(params) {
8253
8513
  // can reorder a later step forward — instead of burning the cascade.
8254
8514
  const advanceStalled = isAdvanceStalled({
8255
8515
  isAdvance: isAdvanceStep(step),
8256
- isFinalOrSubmit: isFinalStep || submitStep,
8516
+ isFinalOrSubmit: submitStep || (isFinalStep && flowHasSubmitSemanticsFlag),
8257
8517
  hasPattern: advanceTransitionBodyPattern !== null,
8258
8518
  clickFired: record.resolvedMethod === "click" && record.actResultSuccess === true,
8259
8519
  networkFired,
@@ -8399,6 +8659,11 @@ async function runHealingFlow(deps) {
8399
8659
  }
8400
8660
  },
8401
8661
  });
8662
+ const flowHasSubmitSemanticsFlag = flowHasSubmitSemantics({
8663
+ steps,
8664
+ submitEndpointPattern: deps.submitEndpointPattern ?? null,
8665
+ requireSubmitEndpointMatch: deps.requireSubmitEndpointMatch ?? false,
8666
+ });
8402
8667
  if (shouldWarnMissingAdvancePattern(steps.map((s) => s.instruction), deps.advanceTransitionBodyPattern ?? null)) {
8403
8668
  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
8669
  }
@@ -8407,6 +8672,24 @@ async function runHealingFlow(deps) {
8407
8672
  if (maxFlowMs !== undefined && Date.now() - start > maxFlowMs) {
8408
8673
  throw new errors_2.StepVerificationError(`${formatStepPrefix(i, () => steps.length)} flow exceeded its maxFlowMs budget (${maxFlowMs}ms)`, "flow-timeout");
8409
8674
  }
8675
+ // Liveness gate: a closed/crashed Stagehand session (e.g. the
8676
+ // shutdown-supervisor force-releasing mid-flow) makes `page.url()`
8677
+ // throw synchronously — the one call every Page implementation
8678
+ // answers without touching the DOM. Everything downstream (observe/
8679
+ // act probes) treats a thrown error as "page has no candidates right
8680
+ // now" and, for an optional step, quietly skips it — so without this
8681
+ // check the loop runs to completion and `runHealingFlow` resolves as
8682
+ // if the flow finished, even though the session died partway through.
8683
+ // Checking here, before any step-specific handling, means a dead
8684
+ // session is reported as a `SessionTimeoutError` distinct from a
8685
+ // legitimately-absent optional step, and the flow's own step count
8686
+ // never quietly reaches `steps.length` past the point of death.
8687
+ try {
8688
+ page.url();
8689
+ }
8690
+ catch (err) {
8691
+ throw new errors_2.SessionTimeoutError(`${formatStepPrefix(i, () => steps.length)} session appears closed/dead (page.url() threw: ${(0, errors_1.toErrorMessage)(err)}) — aborting after ${i} of ${steps.length} steps completed`);
8692
+ }
8410
8693
  lastStepIndex = i;
8411
8694
  // Resolved fresh per step (not cached across the run) so a cross-origin
8412
8695
  // iframe that attaches mid-flow (e.g. after an "Apply" click reveals a
@@ -8423,6 +8706,7 @@ async function runHealingFlow(deps) {
8423
8706
  optional: s.optional,
8424
8707
  upload: s.upload,
8425
8708
  submitStep: s.submitStep,
8709
+ flowHasSubmitSemantics: flowHasSubmitSemanticsFlag,
8426
8710
  stepIndex: i,
8427
8711
  totalSteps: () => steps.length,
8428
8712
  phase: "flow",