halo-agent 2.2.0 → 2.2.2

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.
@@ -286,4 +286,226 @@ async function detectFormErrorsInRoot(page) {
286
286
  });
287
287
  }
288
288
 
289
- module.exports = { detectFormErrors };
289
+ /**
290
+ * Proactive required-field emptiness check.
291
+ *
292
+ * detectFormErrors is REACTIVE — it only reports a problem if the ATS has
293
+ * already rendered an error (aria-invalid, "is required" text, error class).
294
+ * On Ashby, a required combobox the agent failed to fill stays empty WITHOUT
295
+ * the form rendering a visible error until/unless submit is attempted — and
296
+ * sometimes not even then in the DOM snapshot we capture. So a reactive check
297
+ * sees a clean page and the agent auto-submits an incomplete form (the Plaid
298
+ * failure).
299
+ *
300
+ * This is the ground-truth, ATS-agnostic guard: walk every field the form
301
+ * marks REQUIRED (asterisked label, `required` attr, aria-required="true")
302
+ * and return the ones that are empty — using the same isEmptyField logic
303
+ * detectFormErrors uses, so react-select / Ashby comboboxes are judged by
304
+ * their rendered value chip, not the hidden input's .value.
305
+ *
306
+ * The orchestrator calls this BEFORE submitting: if any required field is
307
+ * empty, never auto-submit — route to REVIEWING with the list.
308
+ *
309
+ * Output: { hasEmptyRequired: boolean, emptyFields: [{label, selector?}] }
310
+ */
311
+ async function detectRequiredEmpties(page, opts = {}) {
312
+ const roots = [page, ...page.frames().filter((f) => {
313
+ const u = f.url();
314
+ return f !== page.mainFrame() && u && DFE_EMBED_HOSTS.test(u) && !/google\.com|gstatic\.com|recaptcha/i.test(u);
315
+ })];
316
+ let merged = { hasEmptyRequired: false, emptyFields: [] };
317
+ for (const root of roots) {
318
+ const r = await detectRequiredEmptiesInRoot(root).catch(() => null);
319
+ if (r) {
320
+ merged.hasEmptyRequired = merged.hasEmptyRequired || r.hasEmptyRequired;
321
+ merged.emptyFields.push(...(r.emptyFields || []));
322
+ }
323
+ }
324
+ // Exclude labels the planner deliberately skipped (conditional-not-applicable,
325
+ // honeypots, optional). The planner has dependency context the DOM walk lacks,
326
+ // so a field it intentionally left blank is NOT an unfilled required field.
327
+ const excluded = new Set((opts.excludeLabels || []).map((l) => String(l || '').toLowerCase().trim()).filter(Boolean));
328
+ // Dedup by label, drop excluded.
329
+ const seen = new Set();
330
+ merged.emptyFields = merged.emptyFields.filter((f) => {
331
+ const k = (f.label || '').toLowerCase().trim();
332
+ if (!k || seen.has(k)) return false;
333
+ seen.add(k);
334
+ // If smartFill confirmed a resume upload, a file/resume field reading
335
+ // "empty" is a false positive: Ashby (and similar) keep the native
336
+ // <input type=file> at files.length===0 after upload because they stash
337
+ // the file in their own state and show a filename chip elsewhere. Don't
338
+ // block on it.
339
+ if (opts.resumeUploaded && f.isFile) return false;
340
+ // Match excluded loosely — DOM label vs planner label can differ by
341
+ // trailing punctuation/truncation.
342
+ for (const ex of excluded) {
343
+ if (k === ex || k.startsWith(ex) || ex.startsWith(k) || k.includes(ex) || ex.includes(k)) return false;
344
+ }
345
+ return true;
346
+ });
347
+ merged.hasEmptyRequired = merged.emptyFields.length > 0;
348
+ return merged;
349
+ }
350
+
351
+ async function detectRequiredEmptiesInRoot(page) {
352
+ return await page.evaluate(() => {
353
+ function visibleText(el) {
354
+ if (!el) return '';
355
+ const rect = el.getBoundingClientRect();
356
+ if (rect.width === 0 && rect.height === 0) return '';
357
+ const t = (el.innerText || el.textContent || '').trim();
358
+ return t ? t.replace(/\s+/g, ' ').slice(0, 300) : '';
359
+ }
360
+ function nearestLabel(el) {
361
+ if (el.id) {
362
+ const lbl = document.querySelector(`label[for="${el.id}"]`);
363
+ if (lbl) return visibleText(lbl).replace(/\*$/, '').trim();
364
+ }
365
+ if (el.labels && el.labels[0]) return visibleText(el.labels[0]).replace(/\*$/, '').trim();
366
+ const al = el.getAttribute('aria-labelledby');
367
+ if (al) {
368
+ const t = al.split(/\s+/).map((id) => document.getElementById(id)).filter(Boolean).map(visibleText).join(' ').trim();
369
+ if (t) return t;
370
+ }
371
+ let p = el.parentElement; let hops = 0;
372
+ while (p && hops < 6) {
373
+ const lbl = p.querySelector('label, legend, h3, h4');
374
+ if (lbl && !lbl.contains(el)) {
375
+ const t = visibleText(lbl).replace(/\*$/, '').trim();
376
+ if (t && t.length < 200) return t;
377
+ }
378
+ p = p.parentElement; hops += 1;
379
+ }
380
+ return el.getAttribute('aria-label') || el.placeholder || el.name || el.id || '(unknown)';
381
+ }
382
+ // Same emptiness logic as detectFormErrors (kept in sync deliberately).
383
+ function isEmptyField(el) {
384
+ const tag = el.tagName.toLowerCase();
385
+ const type = (el.type || '').toLowerCase();
386
+ if (type === 'checkbox' || type === 'radio') return !el.checked;
387
+ if (tag === 'select') {
388
+ if (!el.value) return true;
389
+ const selOpt = el.options[el.selectedIndex];
390
+ if (selOpt && !selOpt.value) return true;
391
+ return false;
392
+ }
393
+ if (type === 'file') return !el.files || el.files.length === 0;
394
+ if (el.isContentEditable) return !(el.innerText || '').trim();
395
+ if (el.getAttribute('role') === 'combobox' || tag === 'select') {
396
+ let wrap = el.closest('[class*="select__control"]');
397
+ if (!wrap) { let p = el; for (let i = 0; i < 8 && p; i++, p = p.parentElement) { if (/select__control|select-control/i.test(p.className || '')) { wrap = p; break; } } }
398
+ if (!wrap) wrap = el.closest('[class*="select"], [class*="combobox"], [class*="Select"]') || el.parentElement;
399
+ if (wrap) {
400
+ const valueEl = wrap.querySelector('[class*="single-value"], [class*="multi-value"], [class*="multiValue"], [class*="singleValue"], [class*="chip"], [class*="tag"]');
401
+ if (valueEl && (valueEl.innerText || valueEl.textContent || '').trim()) return false;
402
+ const wrapText = (wrap.innerText || '').trim();
403
+ const placeholderEl = wrap.querySelector('[class*="placeholder"]');
404
+ const placeholderText = placeholderEl ? (placeholderEl.innerText || '').trim() : '';
405
+ if (wrapText && wrapText !== placeholderText && !/^select\b|^choose\b|^start typing|^\.\.\./i.test(wrapText)) return false;
406
+ }
407
+ const v = (el.value || el.innerText || '').trim();
408
+ return !v;
409
+ }
410
+ return !(el.value || '').trim();
411
+ }
412
+
413
+ // A radiogroup / checkbox-set counts as "filled" if ANY member is checked.
414
+ // We track required groups by name so we don't flag every individual radio.
415
+ function isRequired(el) {
416
+ // Programmatic markers are authoritative and field-specific.
417
+ if (el.required) return true;
418
+ if (el.getAttribute('aria-required') === 'true') return true;
419
+
420
+ // Asterisk/"required" in label text is the common ATS convention, BUT
421
+ // it must come from THIS field's OWN label — not a sibling's. The Plaid
422
+ // false-positive ("If yes, please provide further explanation below")
423
+ // was a conditional field flagged because a climb-5-ancestors walk
424
+ // scooped up a neighbouring required field's asterisk. Only trust the
425
+ // directly-associated label:
426
+ // 1. <label for=id>
427
+ // 2. el.labels[0]
428
+ // 3. the SINGLE closest wrapping label/legend that contains ONLY this
429
+ // control (not a shared group container with multiple inputs).
430
+ const labelTextOf = (lblEl) => (lblEl && (lblEl.innerText || lblEl.textContent || '')).trim();
431
+ const marks = (txt) => /\*\s*$/.test(txt) || /\(\s*required\s*\)/i.test(txt) || /\brequired\b\s*\*?$/i.test(txt);
432
+
433
+ if (el.id) {
434
+ const lbl = document.querySelector(`label[for="${el.id}"]`);
435
+ if (lbl) return marks(labelTextOf(lbl));
436
+ }
437
+ if (el.labels && el.labels[0]) return marks(labelTextOf(el.labels[0]));
438
+
439
+ // Closest wrapping label/legend, but ONLY if that container holds a
440
+ // single control (so we don't inherit a group asterisk meant for a
441
+ // different required field in the same fieldset).
442
+ let p = el.parentElement; let hops = 0;
443
+ while (p && hops < 4) {
444
+ const labelEl = p.querySelector(':scope > label, :scope > legend, :scope > [class*="label"]');
445
+ if (labelEl) {
446
+ const controlsInScope = p.querySelectorAll('input:not([type=hidden]), textarea, select, [role="combobox"], [contenteditable="true"]').length;
447
+ if (controlsInScope <= 1) return marks(labelTextOf(labelEl));
448
+ // Shared container with multiple controls — the asterisk isn't
449
+ // necessarily ours. Stop; treat as not-required unless a
450
+ // programmatic marker said otherwise (handled above).
451
+ return false;
452
+ }
453
+ p = p.parentElement; hops += 1;
454
+ }
455
+ return false;
456
+ }
457
+
458
+ const fields = Array.from(document.querySelectorAll(
459
+ 'input:not([type=hidden]):not([type=submit]):not([type=button]), textarea, select, [role="combobox"], [contenteditable="true"]'
460
+ ));
461
+ const emptyFields = [];
462
+ const seen = new Set();
463
+ const radioGroupsChecked = new Map(); // name -> bool any-checked
464
+ const radioGroupRequired = new Map(); // name -> required
465
+
466
+ // First pass: tally radio/checkbox groups by name.
467
+ for (const el of fields) {
468
+ const type = (el.type || '').toLowerCase();
469
+ if ((type === 'radio' || type === 'checkbox') && el.name) {
470
+ if (el.checked) radioGroupsChecked.set(el.name, true);
471
+ else if (!radioGroupsChecked.has(el.name)) radioGroupsChecked.set(el.name, false);
472
+ if (isRequired(el)) radioGroupRequired.set(el.name, true);
473
+ }
474
+ }
475
+
476
+ for (const el of fields) {
477
+ const type = (el.type || '').toLowerCase();
478
+ // Skip non-rendered fields entirely (display:none templates etc).
479
+ const rect = el.getBoundingClientRect();
480
+ const inDom = rect.width > 0 || rect.height > 0 || el.offsetParent !== null;
481
+ if (!inDom && type !== 'file') continue; // file inputs are often 0x0 by design
482
+
483
+ // Radio/checkbox handled at group level.
484
+ if ((type === 'radio' || type === 'checkbox') && el.name) {
485
+ if (!radioGroupRequired.get(el.name)) continue;
486
+ if (radioGroupsChecked.get(el.name)) continue; // satisfied
487
+ const key = 'group:' + el.name;
488
+ if (seen.has(key)) continue;
489
+ seen.add(key);
490
+ if (!radioGroupsChecked.get(el.name)) {
491
+ emptyFields.push({ label: nearestLabel(el).slice(0, 200), selector: `[name="${el.name}"]` });
492
+ }
493
+ continue;
494
+ }
495
+
496
+ if (!isRequired(el)) continue;
497
+ if (!isEmptyField(el)) continue;
498
+ const label = nearestLabel(el).slice(0, 200);
499
+ const key = label.toLowerCase();
500
+ if (seen.has(key)) continue;
501
+ seen.add(key);
502
+ const isFile = type === 'file'
503
+ || /resume|cv\b|cover.?letter|attach|upload/i.test(label);
504
+ emptyFields.push({ label, isFile, selector: el.id ? `#${el.id}` : (el.name ? `[name="${el.name}"]` : null) });
505
+ }
506
+
507
+ return { hasEmptyRequired: emptyFields.length > 0, emptyFields };
508
+ });
509
+ }
510
+
511
+ module.exports = { detectFormErrors, detectRequiredEmpties };
package/orchestrator.js CHANGED
@@ -13,7 +13,7 @@ const path = require('path');
13
13
  const fs = require('fs');
14
14
  const { fillFields: legacyFillFields, uploadFile, findNextButton, findSubmitButton, waitForStableDOM, snapshotFieldLabels } = require('./filler');
15
15
  const { smartFillPage } = require('./smartFill');
16
- const { detectFormErrors } = require('./detectFormErrors');
16
+ const { detectFormErrors, detectRequiredEmpties } = require('./detectFormErrors');
17
17
 
18
18
  // Switchable filler — smart by default, can be killed via config.useSmartFill=false.
19
19
  // smartFill.js internally falls back to legacyFillFields if /smartfill/plan-fill
@@ -47,6 +47,7 @@ async function fillFields(page, aep, opts) {
47
47
  askUserReasons: result.askUserReasons || [],
48
48
  plannedActions: result.planned || 0,
49
49
  fellBackToLegacy: !!result.fallback,
50
+ resumeUploaded: !!result.resumeUploaded,
50
51
  };
51
52
  }
52
53
  const { detectCaptcha, solveCaptcha, injectCaptchaToken } = require('./captcha');
@@ -217,8 +218,14 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
217
218
  }
218
219
  }
219
220
 
220
- // Upload resume if we have a temp file
221
- if (tempResumeFile) {
221
+ // Upload resume ONLY if smartFill didn't already place it. This blanket
222
+ // fallback predates smartFill's per-field upload_file action; running it
223
+ // unconditionally dumped the resume into the FIRST file input it found —
224
+ // which on Greenhouse (resume + cover-letter inputs) put the resume into
225
+ // the cover-letter slot too. smartFill now reports resumeUploaded, so we
226
+ // only fall back when the planner genuinely missed the resume field.
227
+ if (tempResumeFile && !fillResult.resumeUploaded) {
228
+ console.log('[orchestrator] smartFill did not upload a resume — running fallback uploader.');
222
229
  const uploaded = await uploadFile(page, 'input[type="file"], [data-testid*="resume"], button:has-text("Upload")', tempResumeFile);
223
230
  if (uploaded) fillResult.filled = (fillResult.filled || 0) + 1;
224
231
  }
@@ -432,23 +439,65 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
432
439
 
433
440
  console.log(`[orchestrator] Reached review/submit page. Waiting for confirmation...`);
434
441
 
442
+ // ─────────────────────────────────────────────────────────────────────
443
+ // HONESTY GUARD (the Plaid/Ashby fix). Before we even consider
444
+ // auto-submitting, check — proactively, not waiting for the ATS to
445
+ // render an error — whether any REQUIRED field is still empty. The
446
+ // Plaid run auto-submitted with Location + dates blank because:
447
+ // (a) the comboboxes never filled (element-not-visible, now fixed), and
448
+ // (b) detectFormErrors is reactive — a clean-looking DOM let
449
+ // auto-submit "trust the click."
450
+ // If required fields are empty, auto-submit is OFF for this job no matter
451
+ // what the config/agent_config says: we route to REVIEWING so the human
452
+ // sees exactly which fields are blank. Never silently submit incomplete.
453
+ // ─────────────────────────────────────────────────────────────────────
454
+ const skippedLabels = (ctx.skippedFields || []).map((s) => s.label);
455
+ const requiredCheck = await detectRequiredEmpties(page, { excludeLabels: skippedLabels, resumeUploaded: !!fillResult.resumeUploaded }).catch(() => ({ hasEmptyRequired: false, emptyFields: [] }));
456
+ let forceReview = false;
457
+ if (requiredCheck.hasEmptyRequired) {
458
+ const list = requiredCheck.emptyFields.map((f) => f.label).slice(0, 8).join(', ');
459
+ console.warn(`[orchestrator] ${requiredCheck.emptyFields.length} required field(s) still EMPTY: ${list}`);
460
+ console.warn(`[orchestrator] Overriding auto-submit → REVIEWING. Will not submit an incomplete form.`);
461
+ forceReview = true;
462
+ }
463
+
435
464
  // Take a screenshot of the review page
436
465
  const reviewScreenshot = await page.screenshot({ type: 'jpeg', quality: 70 });
437
466
  const reviewKey = await uploadScreenshot(config, reviewScreenshot, `review_${queueId}.jpg`);
438
467
 
439
- await reportStatus('REVIEWING', {
440
- review_screenshot_r2_key: reviewKey || null,
441
- step: 'REVIEWING',
442
- step_detail: `${cumulativeFilled} fields filled · awaiting your confirm`,
443
- });
444
-
445
- // Wait for user to confirm submission from dashboard
446
- // OR auto-submit if config.autoSubmit is true
447
- if (config.autoSubmit || aep.agent_config?.auto_submit) {
448
- const timeout = (aep.agent_config?.review_timeout_seconds || 30) * 1000;
449
- await page.waitForTimeout(timeout);
450
- } else {
468
+ if (forceReview) {
469
+ const list = requiredCheck.emptyFields.map((f) => f.label).slice(0, 8).join(', ');
470
+ // Report REVIEWING (not NEEDS_ATTENTION): the agent is going to WAIT on
471
+ // the submit-confirmed flag, and POST /apply-queue/submit/:id only
472
+ // accepts rows in REVIEWING state. Using NEEDS_ATTENTION here meant the
473
+ // dashboard's "Resolved" click 400'd and the agent waited forever. The
474
+ // reason text still tells the user exactly which fields to fill; the
475
+ // "Submit" button then wakes the agent once they've filled them in Chrome.
476
+ await reportStatus('REVIEWING', {
477
+ review_screenshot_r2_key: reviewKey || null,
478
+ step: 'REVIEWING',
479
+ step_detail: `${requiredCheck.emptyFields.length} required field(s) need you: ${list}. Fill them in Chrome, then click Submit.`.slice(0, 200),
480
+ needs_attention_reason: `${requiredCheck.emptyFields.length} required field(s) could not be auto-filled: ${list}. Fill them in your Chrome window, then click Submit.`,
481
+ fields_filled: cumulativeFilled,
482
+ });
483
+ // Wait for the human to fill + confirm. They resolve the blanks in
484
+ // Chrome, then click Submit on the dashboard.
451
485
  await waitForSubmitConfirmation(config, queueId);
486
+ } else {
487
+ await reportStatus('REVIEWING', {
488
+ review_screenshot_r2_key: reviewKey || null,
489
+ step: 'REVIEWING',
490
+ step_detail: `${cumulativeFilled} fields filled · awaiting your confirm`,
491
+ });
492
+
493
+ // Wait for user to confirm submission from dashboard
494
+ // OR auto-submit if config.autoSubmit is true AND the form is complete.
495
+ if (config.autoSubmit || aep.agent_config?.auto_submit) {
496
+ const timeout = (aep.agent_config?.review_timeout_seconds || 30) * 1000;
497
+ await page.waitForTimeout(timeout);
498
+ } else {
499
+ await waitForSubmitConfirmation(config, queueId);
500
+ }
452
501
  }
453
502
 
454
503
  // STEP 6: SUBMITTING
@@ -601,10 +650,32 @@ async function runJob(queueItem, chromeConn, config, reportStatus) {
601
650
  let finalState = 'DONE';
602
651
  if (verdict.submitted === null) {
603
652
  const autoSubmit = config.autoSubmit || aep.agent_config?.auto_submit;
604
- if (autoSubmit) {
605
- // Auto-submit ON + verifier unavailable: trust the click; the
606
- // screenshot becomes the audit trail.
607
- console.log(`[orchestrator] Verifier unavailable (source: ${verdict.source}); auto-submit ON trusting click, screenshot is the receipt.`);
653
+
654
+ // Even with auto-submit ON, don't "trust the click" blindly. Two cheap
655
+ // local signals tell us the submit probably did NOT go through:
656
+ // 1. The URL never changed to a confirmation pattern (still on the
657
+ // form). A successful ATS submit almost always redirects.
658
+ // 2. Required fields are STILL empty after the click — proof the form
659
+ // bounced us (this is the Plaid case: blank Location/dates, no
660
+ // redirect, verifier down → it had said "trusting click").
661
+ // If either holds, we refuse the silent DONE and route to REVIEWING.
662
+ const stillOnForm = !/thank|confirm|success|applied|submitted/i.test(verdictUrl);
663
+ const postSkipped = (ctx.skippedFields || []).map((s) => s.label);
664
+ const postCheck = await detectRequiredEmpties(page, { excludeLabels: postSkipped, resumeUploaded: !!fillResult.resumeUploaded }).catch(() => ({ hasEmptyRequired: false, emptyFields: [] }));
665
+
666
+ if (postCheck.hasEmptyRequired) {
667
+ const list = postCheck.emptyFields.map((f) => f.label).slice(0, 8).join(', ');
668
+ console.warn(`[orchestrator] After submit, required field(s) STILL empty: ${list}. Not a real submit — REVIEWING.`);
669
+ finalState = 'REVIEWING';
670
+ } else if (autoSubmit && !stillOnForm) {
671
+ // Form complete + URL redirected away + verifier just unavailable:
672
+ // this is the defensible "trust the click" case. Screenshot is the receipt.
673
+ console.log(`[orchestrator] Verifier unavailable (source: ${verdict.source}); form complete + redirected — trusting click, screenshot is the receipt.`);
674
+ } else if (autoSubmit && stillOnForm) {
675
+ // Auto-submit ON but no redirect and no obvious empties: ambiguous.
676
+ // Don't claim DONE on a page that still looks like the form.
677
+ console.warn(`[orchestrator] Auto-submit ON but still on form URL and verifier unavailable — REVIEWING rather than a blind DONE.`);
678
+ finalState = 'REVIEWING';
608
679
  } else {
609
680
  // No auto-submit → REVIEWING so the user eyeballs first.
610
681
  console.warn(`[orchestrator] Could not verify submission (source: ${verdict.source}). REVIEWING — please eyeball the screenshot + click Submit.`);
@@ -1267,7 +1338,7 @@ async function runExtensionFill({
1267
1338
  fillResult.filled += precision.filled || 0;
1268
1339
  }
1269
1340
 
1270
- if (tempResumeFile) {
1341
+ if (tempResumeFile && !fillResult.resumeUploaded) {
1271
1342
  const uploaded = await uploadFile(page, 'input[type="file"], [data-testid*="resume"], button:has-text("Upload")', tempResumeFile);
1272
1343
  if (uploaded) fillResult.filled = (fillResult.filled || 0) + 1;
1273
1344
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "halo-agent",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
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
@@ -58,7 +58,50 @@ async function executePlanItem(page, item, fieldByMmid, ctx) {
58
58
  visible = await locator.isVisible({ timeout: 800 }).catch(() => false);
59
59
  }
60
60
  if (!visible) {
61
- return { ok: false, reason: `element not visible (mmid=${item.mmid}${field.frameUrl ? ', in-frame' : ''})` };
61
+ // The scanned element being invisible doesn't always mean "give up." Many
62
+ // ATSes (Ashby, react-select) render the REAL combobox <input> at 0x0 and
63
+ // expose only a clickable WRAPPER (".select__control", a styled div, the
64
+ // value-display button). scanAccessibility tags the inner input, so the
65
+ // executor would FAIL "element not visible" and the required field stays
66
+ // empty — exactly the Ashby Location/School/date failure. Recover by
67
+ // re-pointing the locator at the nearest visible interactive ancestor and
68
+ // letting the dropdown handlers below open it.
69
+ const looksLikeDropdown = field.role === 'combobox' || field.role === 'listbox'
70
+ || /select|combobox|dropdown|month|year|listbox/i.test(String(field.selectorHint || ''))
71
+ || (Array.isArray(field.options) && field.options.length > 0);
72
+ const isOpenable = looksLikeDropdown
73
+ || item.action === 'click_option' || item.action === 'select_option';
74
+ if (isOpenable) {
75
+ const wrapperSel = await locator.evaluate((el) => {
76
+ // Climb to the closest ancestor that's actually rendered (non-zero box)
77
+ // and looks interactive (select-control class, role, or just a sized
78
+ // container holding this input). Tag it so Playwright can target it.
79
+ const sized = (n) => { const r = n.getBoundingClientRect?.(); return r && (r.width > 0 || r.height > 0); };
80
+ let p = el;
81
+ for (let i = 0; i < 8 && p; i++, p = p.parentElement) {
82
+ const cls = String(p.className || '');
83
+ const looksControl = /select__control|select-control|control|combobox|dropdown|trigger|css-/i.test(cls)
84
+ || p.getAttribute?.('role') === 'combobox'
85
+ || p.getAttribute?.('aria-haspopup');
86
+ if (sized(p) && (looksControl || p !== el)) {
87
+ const cid = 'halo-wrap-' + Math.random().toString(36).slice(2, 8);
88
+ p.setAttribute('data-halo-wrap', cid);
89
+ return cid;
90
+ }
91
+ }
92
+ return null;
93
+ }).catch(() => null);
94
+ if (wrapperSel) {
95
+ const wrap = root.locator(`[data-halo-wrap="${wrapperSel}"]`).first();
96
+ if (await wrap.isVisible({ timeout: 600 }).catch(() => false)) {
97
+ locator = wrap;
98
+ visible = true;
99
+ }
100
+ }
101
+ }
102
+ if (!visible) {
103
+ return { ok: false, reason: `element not visible (mmid=${item.mmid}${field.frameUrl ? ', in-frame' : ''})` };
104
+ }
62
105
  }
63
106
 
64
107
  switch (item.action) {
@@ -738,6 +781,7 @@ async function smartFillPage(page, aep, options) {
738
781
  const fieldByMmid = new Map(fields.map((f) => [String(f.mmid), f]));
739
782
  const planByMmid = new Map(plan.map((p) => [String(p.mmid), p]));
740
783
  let filled = 0, skipped = 0, failed = 0;
784
+ let resumeUploaded = false; // so the orchestrator can skip its blanket fallback
741
785
  const askUserReasons = [];
742
786
 
743
787
  for (const f of fields) {
@@ -764,9 +808,24 @@ async function smartFillPage(page, aep, options) {
764
808
  if (item.action === 'skip') {
765
809
  console.log(`[smartFill] skip "${labelShort}" — ${result.reason}`);
766
810
  skipped += 1;
811
+ // Record the planner's deliberate skips so the orchestrator's
812
+ // required-empty guard can EXCLUDE them. The planner has full
813
+ // context (it saw "If yes…" conditional dependencies); the guard is
814
+ // a blind DOM walk. A field the planner intentionally left blank
815
+ // (conditional-not-applicable, honeypot, optional) must not be
816
+ // mistaken for an unfilled required field.
817
+ if (ctx && Array.isArray(ctx.skippedFields) && f.label) {
818
+ ctx.skippedFields.push({ label: f.label, reason: (item.reasoning || result.reason || '').slice(0, 160) });
819
+ }
767
820
  } else {
768
821
  console.log(`[smartFill] ${item.action} "${labelShort}" — ${result.reason}${item.reasoning ? ` (why: ${item.reasoning.slice(0, 80)})` : ''}`);
769
822
  filled += 1;
823
+ // Note a successful resume upload so the orchestrator doesn't run its
824
+ // legacy blanket "upload resume to the first file input" fallback,
825
+ // which was dumping the resume into the COVER-LETTER slot too.
826
+ if (item.action === 'upload_file' && String(item.value).toLowerCase().trim() !== 'cover_letter') {
827
+ resumeUploaded = true;
828
+ }
770
829
  // Track in ctx for cross-page dedup
771
830
  if (ctx && ctx.answeredFields) {
772
831
  ctx.answeredFields.set(f.label || f.selectorHint, {
@@ -784,7 +843,7 @@ async function smartFillPage(page, aep, options) {
784
843
  }
785
844
  }
786
845
 
787
- return { filled, skipped, failed, askUserReasons, planned: plan.length, fallback: false };
846
+ return { filled, skipped, failed, askUserReasons, planned: plan.length, fallback: false, resumeUploaded };
788
847
  }
789
848
 
790
849
  module.exports = { smartFillPage };