diff --git a/crates/hook/tests/hook_tests.rs b/crates/hook/tests/hook_tests.rs index af61f9559..1a26c681b 100644 --- a/crates/hook/tests/hook_tests.rs +++ b/crates/hook/tests/hook_tests.rs @@ -2279,5 +2279,6 @@ fn run_hook_stands_down_for_the_whole_edit_when_the_primary_carries_live_markers let skipped = hook::run_hook(&r, &edit_event(&cwd, &wrapped, "s2")); assert_eq!(skipped.stdout, "", "nothing is emitted while the edited file is in a live session"); assert_eq!(skipped.audit["skipped"], json!("live-preview")); - assert!(audit_str(&skipped.audit, "file").unwrap_or("").ends_with("src/App.jsx"), "the audit names the edited file, not the companion"); + let audited = audit_str(&skipped.audit, "file").unwrap_or("").replace('\\', "/"); + assert!(audited.ends_with("src/App.jsx"), "the audit names the edited file, not the companion: {audited}"); } diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 055dafa0c..a12c7ec64 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -7291,13 +7291,36 @@ // another page has. The server prefers a busy report (a tab that could // serve later) over these, and returns the resolution verdict only when // no connected page can serve. + // + // An element can be momentarily absent (a route still rendering, an HMR + // commit mid-swap), so a failed resolution is not this page's final word: + // it is re-checked a few times over about two seconds, claiming the + // moment the element mounts, and only the last miss is reported. The + // server's timeout still bounds the whole exchange. + const AGENT_TARGET_RESOLVE_RETRY_MS = [300, 700, 1500]; + function declineAgentTargetUnresolvable(msg) { const probe = resolveAgentTargetElement(msg); if (!probe.error) return false; - claimAgentTarget(msg.targetId, { eligible: false, state, reason: 'no_match', result: probe.error }); + retryAgentTargetResolution(msg, 0, probe.error); return true; } + function retryAgentTargetResolution(msg, attempt, lastError) { + if (attempt >= AGENT_TARGET_RESOLVE_RETRY_MS.length) { + claimAgentTarget(msg.targetId, { eligible: false, state, reason: 'no_match', result: lastError }); + return; + } + setTimeout(() => { + if (agentTargetOverlayGone()) return; + const busy = agentTargetBusyReason(); + if (busy) { declineAgentTargetBusy(msg, busy); return; } + const probe = resolveAgentTargetElement(msg); + if (!probe.error) { claimAndActOnAgentTarget(msg); return; } + retryAgentTargetResolution(msg, attempt + 1, probe.error); + }, AGENT_TARGET_RESOLVE_RETRY_MS[attempt]); + } + function handleAgentTarget(msg) { if (!msg || typeof msg.targetId !== 'string') return; if (agentTargetsSeen.includes(msg.targetId)) return; diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index 2338a862f..9585e006c 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -866,8 +866,13 @@ describe('live-browser source contracts', () => { ); assert.match( SOURCE, - /function declineAgentTargetUnresolvable\(msg\) \{[\s\S]{0,200}?resolveAgentTargetElement\(msg\)[\s\S]{0,200}?reason: 'no_match', result: probe\.error/, - 'the decline carries the resolution verdict for the server to return when no page can serve', + /function declineAgentTargetUnresolvable\(msg\) \{[\s\S]{0,200}?resolveAgentTargetElement\(msg\)[\s\S]{0,120}?retryAgentTargetResolution\(msg, 0, probe\.error\)/, + 'a failed resolution is re-checked before it becomes this page\'s word', + ); + assert.match( + SOURCE, + /function retryAgentTargetResolution\(msg, attempt, lastError\) \{[\s\S]{0,200}?reason: 'no_match', result: lastError[\s\S]{0,600}?if \(!probe\.error\) \{ claimAndActOnAgentTarget\(msg\); return; \}/, + 'the page claims the moment the element mounts, and reports only the last miss', ); // The per-origin session cache must not let a tab on another page of // the app resume this page's session (it would sit in GENERATING for a @@ -885,8 +890,8 @@ describe('live-browser source contracts', () => { ); assert.equal( (SOURCE.match(/claimAndActOnAgentTarget\(msg\)/g) || []).length, - 4, - 'the first claim and the busy-to-idle re-claim must share the rescue path (definition, two call sites, the retry)', + 5, + 'the first claim, the busy-to-idle re-claim, and the resolution re-check must share the rescue path (definition, three call sites, the retry)', ); });