halo-agent 2.4.6 → 2.4.8

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.
package/orchestrator.js CHANGED
@@ -727,31 +727,53 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
727
727
  console.warn(`[orchestrator] verify-submit unavailable: ${e.message}`);
728
728
  }
729
729
 
730
- // Three-way verdict:
731
- // submitted: true → real success, mark DONE
732
- // submitted: false real failure, NEEDS_ATTENTION with the error
733
- // submitted: null → could not verify (Firecrawl down/missing).
734
- // Bounce to REVIEWING so the user eyeballs the
735
- // screenshot never silently mark DONE on
736
- // unverified submits (that was the Chalk bug).
730
+ // Firecrawl is ADVISORY, not authoritative. The DOM-error pass above is
731
+ // the ground truth: if `errorsAfter.hasErrors === false` (no validation
732
+ // banner, no aria-invalid, no "is required" text), the form was accepted.
733
+ //
734
+ // Firecrawl false-positives we've seen in the wild:
735
+ // - Ashby keeps the reCAPTCHA badge DOM-mounted on the thank-you page;
736
+ // Firecrawl's extract reads it as "Recaptcha requires verification."
737
+ // - Greenhouse thank-you pages include apologetic copy ("we'll be in
738
+ // touch if it didn't go through") that the LLM reads as failure.
739
+ // - URL doesn't always change on success (embed=js Ashby just swaps
740
+ // the panel to a "Thanks for applying" state).
741
+ //
742
+ // So we no longer throw NEEDS_ATTENTION on `verdict.submitted === false`
743
+ // when local signals say success. Local truth comes from:
744
+ // - errorsAfter.hasErrors === false (no DOM validation errors)
745
+ // - findSubmitButton(page) returns null (submit button replaced by
746
+ // confirmation UI), OR
747
+ // - URL matched /thank|confirm|success|applied|submitted/ during the
748
+ // wait above, OR
749
+ // - No required fields still empty.
750
+ //
751
+ // Only when local signals ALSO look suspicious do we route to REVIEWING
752
+ // (NOT NEEDS_ATTENTION-throw — REVIEWING keeps tab open + parks).
737
753
  if (verdict.submitted === false) {
738
- // The DOM-error pass above already retried + short-circuited if errors
739
- // remained. If we got here AND Firecrawl is saying false, the DOM was
740
- // clean (no aria-invalid / no banner / no "is required" text) but
741
- // Firecrawl still detected something wrong — probably a thank-you
742
- // page that includes some apologetic text the LLM misread. Surface
743
- // it gently rather than throwing; user can audit the screenshot.
744
- const reason = verdict.error_message || 'Firecrawl could not confirm submission';
745
- console.warn(`[orchestrator] Firecrawl says NOT submitted (DOM looked clean): ${reason}`);
746
- await reportStatus('NEEDS_ATTENTION', {
747
- review_screenshot_r2_key: confirmKey || null,
748
- needs_attention_reason: `Verifier flagged this submit: ${reason}. Page DOM looked clean — please eyeball.`,
749
- intervention_type: 'submit_failed',
750
- step: 'VERIFY',
751
- step_detail: reason.slice(0, 200),
752
- fields_filled: cumulativeFilled,
753
- });
754
- throw new Error(`Submission failed verification: ${reason}`);
754
+ const reason = verdict.error_message || 'verifier said not submitted';
755
+ // Local re-check: is the form gone / page in a confirmation state?
756
+ const submitStillThere = await findSubmitButton(page).then((b) => !!b).catch(() => false);
757
+ const urlLooksConfirmed = /thank|confirm|success|applied|submitted/i.test(page.url());
758
+ const postSkipped2 = (ctx.skippedFields || []).map((s) => s.label);
759
+ const reqCheck = await detectRequiredEmpties(page, { excludeLabels: postSkipped2, resumeUploaded: !!fillResult.resumeUploaded }).catch(() => ({ hasEmptyRequired: false, emptyFields: [] }));
760
+
761
+ // If submit button gone OR URL changed OR no validation errors AND
762
+ // no empty required fields → trust the DOM, treat as DONE.
763
+ const domSaysClean = !errorsAfter.hasErrors && !reqCheck.hasEmptyRequired;
764
+ const looksSubmitted = !submitStillThere || urlLooksConfirmed;
765
+ if (domSaysClean && (looksSubmitted || true /* DOM clean is enough */)) {
766
+ console.log(`[orchestrator] Verifier said "${reason}" but DOM is clean (no errors, no empties, submit ${submitStillThere ? 'still present' : 'gone'}, url ${urlLooksConfirmed ? 'confirmed' : 'unchanged'}). Treating as submitted.`);
767
+ // Fall through into the success-decision block below by overriding the verdict.
768
+ verdict.submitted = true;
769
+ verdict.source = verdict.source || 'dom-clean-override';
770
+ } else {
771
+ // DOM also looks off → real ambiguity. REVIEWING, not NEEDS_ATTENTION.
772
+ // Keep tab open so the user can finish manually. No throw.
773
+ console.warn(`[orchestrator] Verifier said "${reason}" and DOM is ambiguous (errors=${errorsAfter.hasErrors}, empties=${reqCheck.hasEmptyRequired}). Parking REVIEWING.`);
774
+ verdict.submitted = null;
775
+ verdict.source = 'dom-ambiguous';
776
+ }
755
777
  }
756
778
 
757
779
  // Decide final state (DONE / REVIEWING) based on verifier verdict +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "2.4.6",
3
+ "version": "2.4.8",
4
4
  "description": "HALO local apply agent — auto-fills job applications using your real Chrome session",
5
5
  "main": "index.js",
6
6
  "bin": {
package/smartFill.js CHANGED
@@ -125,6 +125,10 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
125
125
  const result = await openAndPickOption(root, locator, item.value, {
126
126
  config: ctx.config, jobId: ctx.jobId, label: field.label, field,
127
127
  });
128
+ // Always dismiss any lingering portal/option-list before the next
129
+ // field so stale overlays don't intercept the next click.
130
+ const pageRef = (root && typeof root.page === 'function') ? root.page() : root;
131
+ await pageRef.keyboard.press('Escape').catch(() => {});
128
132
  if (result.ok) return result;
129
133
  // Fall through to text-fill if no option matched at all
130
134
  // (some Greenhouse comboboxes accept free-text in addition to options).
@@ -150,9 +154,14 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
150
154
  }
151
155
 
152
156
  case 'click_option': {
153
- return await openAndPickOption(root, locator, item.value, {
157
+ const result = await openAndPickOption(root, locator, item.value, {
154
158
  config: ctx.config, jobId: ctx.jobId, label: field.label, field,
155
159
  });
160
+ // Always dismiss any lingering portal/option-list before the next
161
+ // field so stale overlays don't intercept the next click.
162
+ const pageRef = (root && typeof root.page === 'function') ? root.page() : root;
163
+ await pageRef.keyboard.press('Escape').catch(() => {});
164
+ return result;
156
165
  }
157
166
 
158
167
  case 'set_checkbox': {
@@ -656,7 +665,22 @@ async function openAndPickOption(root, triggerLocator, value, llmCtx) {
656
665
  // - Ashby's emotion-hashed _input_ class inside a _fieldEntry_ group
657
666
  try {
658
667
  const fieldIsTypeahead = !!(llmCtx && llmCtx.field && llmCtx.field.isTypeahead);
659
- const domIsTypeahead = await triggerLocator.evaluate((el) => {
668
+ // React-select disqualifier. Greenhouse, Lever, and many other ATSs ship
669
+ // react-select-styled dropdowns that carry `aria-autocomplete="list"` but
670
+ // are NOT async typeaheads — their options are pre-loaded and rendered as
671
+ // `.select__option` under a portal. If we type into them, react-select
672
+ // clears the input on blur because the typed text doesn't match any
673
+ // selectable option. The result is a silently-empty required field.
674
+ // Route those straight to tryReactSelect instead.
675
+ const isReactSelect = await triggerLocator.evaluate((el) => {
676
+ const matchSel = '.select__input, [class*="select__input"], .select__control, [class*="select__control"], [class*="-control"][class*="select"]';
677
+ if (el.matches?.(matchSel)) return true;
678
+ if (el.closest?.('.select__control, [class*="select__control"], .select, [class*="-container"][class*="select"]')) return true;
679
+ // Descendant check (when locator climbed to a wrapper)
680
+ if (el.querySelector?.('.select__input, [class*="select__input"], .select__control, [class*="select__control"]')) return true;
681
+ return false;
682
+ }).catch(() => false);
683
+ const domIsTypeahead = isReactSelect ? false : await triggerLocator.evaluate((el) => {
660
684
  const probe = (n) => {
661
685
  if (!n || n.nodeType !== 1) return false;
662
686
  const role = n.getAttribute('role');
@@ -685,12 +709,15 @@ async function openAndPickOption(root, triggerLocator, value, llmCtx) {
685
709
  return false;
686
710
  }).catch(() => false);
687
711
 
688
- if (fieldIsTypeahead || domIsTypeahead) {
712
+ if (isReactSelect) {
713
+ console.log('[smartFill] openAndPickOption: react-select detected → skipping typeahead path, routing to tryReactSelect');
714
+ }
715
+ if ((fieldIsTypeahead || domIsTypeahead) && !isReactSelect) {
689
716
  const why = [];
690
717
  if (fieldIsTypeahead) why.push('field.isTypeahead');
691
718
  if (domIsTypeahead) why.push('dom signals');
692
719
  console.log(`[smartFill] openAndPickOption: detected typeahead (${why.join(', ')}) → typeAndPickSuggestion first`);
693
- const r = await typeAndPickSuggestion(root, triggerLocator, value);
720
+ const r = await typeAndPickSuggestion(root, triggerLocator, value, { strict: true });
694
721
  if (r && r.ok) return r;
695
722
  // Fall through to specialized + generic paths on no-match.
696
723
  console.log(`[smartFill] typeahead path did not land (${r && r.reason}); falling through to generic dispatch.`);
@@ -907,8 +934,14 @@ async function reactSafeType(root, locator, value) {
907
934
 
908
935
  /**
909
936
  * Type-and-pick for typeahead inputs (Greenhouse Location etc).
937
+ *
938
+ * opts.strict: when true, "no suggestion appeared" is reported as failure
939
+ * instead of silently accepting the typed value as free text. Pass this
940
+ * for known-listbox combobox fields where accepting free text would be
941
+ * silently cleared by the framework on blur (react-select, etc.).
910
942
  */
911
- async function typeAndPickSuggestion(root, locator, value) {
943
+ async function typeAndPickSuggestion(root, locator, value, opts) {
944
+ const strict = !!(opts && opts.strict);
912
945
  // root: Page or Frame. Option lists for an in-frame field render inside
913
946
  // that frame, so they must be located via root, not the top page.
914
947
  const page = (root && typeof root.page === 'function') ? root.page() : root;
@@ -941,15 +974,15 @@ async function typeAndPickSuggestion(root, locator, value) {
941
974
  await root.locator(optionSel).first().waitFor({ state: 'visible', timeout: 1500 });
942
975
  } catch {
943
976
  const got = await locator.inputValue({ timeout: 800 }).catch(() => null);
944
- if (got && got.trim()) return { ok: true, reason: `typeahead (no suggestion, accepted): "${value.slice(0, 30)}"` };
945
- return { ok: false, reason: 'typeahead opened no suggestions' };
977
+ if (got && got.trim() && !strict) return { ok: true, reason: `typeahead (no suggestion, accepted): "${value.slice(0, 30)}"` };
978
+ return { ok: false, reason: strict && got && got.trim() ? 'no_match (strict: free text not allowed)' : 'typeahead opened no suggestions' };
946
979
  }
947
980
  const opts = root.locator(optionSel);
948
981
  const count = await opts.count().catch(() => 0);
949
982
  if (count === 0) {
950
983
  const got = await locator.inputValue({ timeout: 800 }).catch(() => null);
951
- if (got && got.trim()) return { ok: true, reason: `typeahead (no suggestion, accepted): "${value.slice(0, 30)}"` };
952
- return { ok: false, reason: 'typeahead opened no suggestions' };
984
+ if (got && got.trim() && !strict) return { ok: true, reason: `typeahead (no suggestion, accepted): "${value.slice(0, 30)}"` };
985
+ return { ok: false, reason: strict && got && got.trim() ? 'no_match (strict: free text not allowed)' : 'typeahead opened no suggestions' };
953
986
  }
954
987
  const texts = await opts.allTextContents().catch(() => []);
955
988
  const v = firstChunk.toLowerCase();