From 4d9ecb9a16b50efd0209d10ace29d84e3962b7e1 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 2 Sep 2026 00:02:58 -0700 Subject: [PATCH] [eric] browser: a load Chromium refuses is a failed navigation, not "Navigated to" an empty page; exp.3 changelog names the popup, self-heal and navigation fixes Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012G8kyALnPjsA7aJFmMBq3R --- backend/apps/help/changelog.py | 4 ++++ frontend/src/shared/browserCommandHandler.ts | 21 ++++++++++++++----- frontend/src/shared/navigationOutcome.test.ts | 10 ++++++++- frontend/src/shared/navigationOutcome.ts | 8 +++++++ 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/backend/apps/help/changelog.py b/backend/apps/help/changelog.py index 8c4b142a..e9c4f1fc 100644 --- a/backend/apps/help/changelog.py +++ b/backend/apps/help/changelog.py @@ -45,6 +45,10 @@ P_RELEASES: List[ReleaseNote] = [ "Picking a very long chat back up no longer restarts it at the edge of the context window. The recap it rebuilds from is now bounded (the newest steps in full, older ones as one-line stubs, the rest counted), so a chat with hundreds of tool calls resumes at a fraction of the size and stops compacting on its first step.", "A chat that was still running when OpenSwarm's engine shut down now says so in the chat, with a line to send a message to continue, instead of looking like you stopped it yourself.", "If something on your computer stops the agent's engine process mid-task, OpenSwarm restarts it and continues the same conversation, instead of showing an error and starting the chat over from a summary.", + "Links and downloads inside an App card work again. Every target=_blank link and window.open used to be dropped silently; they now open in their own window that shares the app's session, so an app's PDF or report export actually opens.", + "Self-heals show themselves. A stuck built-in tool being restarted, the model summarizing its own history, and a run waiting on a lost connection or a rate limit are now visible on the card, and a raw runtime error shows a card with the details behind a disclosure instead of a blank transcript.", + "A page the browser refuses to load (a blocked port, a dead address) is reported as a failed navigation instead of being described as a blank page.", + "A collapsed chat card never previews an internal prompt as if you had typed it, and a chat interrupted by a restart keeps its Resume chip.", ], ), ReleaseNote( diff --git a/frontend/src/shared/browserCommandHandler.ts b/frontend/src/shared/browserCommandHandler.ts index 02913e62..cf588834 100644 --- a/frontend/src/shared/browserCommandHandler.ts +++ b/frontend/src/shared/browserCommandHandler.ts @@ -11,7 +11,7 @@ import { resolveInput } from './resolveUrl'; import { rankAndCapInteractives, type RankItem } from './interactiveRanking'; import { shouldStopWaiting, SETTLE_POLL_MS, settleProbeJs } from './browserSettle'; import { unwrapCdpEval } from './cdpEval'; -import { navigationOutcome, sameDoc } from './navigationOutcome'; +import { loadFailureError, navigationOutcome, sameDoc } from './navigationOutcome'; import { handleCanvasCommand } from './canvasCommandHandler'; import { typeChars, type TypedKeys } from './typeChars'; @@ -381,17 +381,28 @@ async function handleNavigate(wv: BrowserWebview, params: Record): removeReady = () => wv.removeEventListener('dom-ready', onReady); }); let aborted = false; - const fullyLoaded = wv.loadURL(url).catch((err: any) => { + // A load Chromium refuses (unsafe port, DNS, connection refused) still fires dom-ready for its empty error document, which used to win the race and turn the refusal into "Navigated to" (ENG-404 drill, 2026-09-01). + let loadFailure: string | null = null; + const onFail = (e: Event) => { + const f = e as Event & { errorCode?: number; errorDescription?: string; isMainFrame?: boolean }; + if (f.isMainFrame !== false && f.errorCode !== undefined && f.errorCode !== -3) loadFailure = `${f.errorDescription || 'load failed'} (${f.errorCode})`; + }; + wv.addEventListener('did-fail-load', onFail); + const fullyLoaded = wv.loadURL(url).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); // ERR_ABORTED can be a benign supersede OR a nav the guest refused outright; the landed-URL check below tells them apart. - if (err?.message?.includes('ERR_ABORTED')) { aborted = true; return; } - throw err; + if (msg.includes('ERR_ABORTED')) { aborted = true; return; } + loadFailure = loadFailure || msg; }); - fullyLoaded.catch(() => {}); // a late load failure shouldn't throw once dom-ready returned try { await Promise.race([fullyLoaded, domReady]); + // dom-ready can beat the rejection by a few ms; give the refusal its say before trusting the document. + await Promise.race([fullyLoaded, new Promise((r) => setTimeout(r, 300))]); } finally { removeReady(); + wv.removeEventListener('did-fail-load', onFail); } + if (loadFailure) return loadFailureError(url, loadFailure); // A navigate that lands on a raw JSON/API document (Instagram's topsearch, any /api/... GET) // paints an unreadable wall in the card and reads as a crash to the user. Hand the data to the // agent as the result instead, and quietly get the card off the wall, so a person never sees diff --git a/frontend/src/shared/navigationOutcome.test.ts b/frontend/src/shared/navigationOutcome.test.ts index 7f588cf6..d923bdf5 100644 --- a/frontend/src/shared/navigationOutcome.test.ts +++ b/frontend/src/shared/navigationOutcome.test.ts @@ -2,7 +2,7 @@ // so a nav that leaves the document parked on the old URL can only ever read as a failure. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { navigationOutcome, sameDoc } from './navigationOutcome'; +import { loadFailureError, navigationOutcome, sameDoc } from './navigationOutcome'; test('a landed URL equal to the old page is stuck, never ok', () => { const v = navigationOutcome('https://en.wikipedia.org/wiki/Helium', 'https://en.wikipedia.org/wiki/Argon', 'https://en.wikipedia.org/wiki/Argon'); @@ -39,3 +39,11 @@ test('sameDoc ignores only the trailing slash', () => { assert.equal(sameDoc('https://a.com', 'https://a.com/'), true); assert.equal(sameDoc('https://a.com/x', 'https://a.com/x#frag'), false); }); + +test('a refused load is an error that names the failure and the empty document, never "Navigated to"', () => { + const r = loadFailureError('http://127.0.0.1:1/', 'ERR_UNSAFE_PORT (-312)'); + assert.match(r.error, /FAILED \(ERR_UNSAFE_PORT \(-312\)\)/); + assert.match(r.error, /empty error document/); + assert.match(r.error, /report the failure instead of describing the page/); + assert.equal(r.url, 'http://127.0.0.1:1/'); +}); diff --git a/frontend/src/shared/navigationOutcome.ts b/frontend/src/shared/navigationOutcome.ts index b54aed89..5ad16356 100644 --- a/frontend/src/shared/navigationOutcome.ts +++ b/frontend/src/shared/navigationOutcome.ts @@ -16,3 +16,11 @@ export function navigationOutcome(requested: string, before: string, landed: str if (!sameDoc(landed, requested)) return { kind: 'redirected', url: landed, requested }; return { kind: 'ok', url: landed }; } + +/** The error a navigate returns when Chromium refused the load outright (unsafe port, DNS, connection refused): the guest then shows its own empty document, which reads as "a blank page" unless the agent is told. */ +export function loadFailureError(requested: string, failure: string): { error: string; url: string } { + return { + error: `Navigation to ${requested} FAILED (${failure}). The browser is showing its empty error document, not the site; retry once, and if it fails again report the failure instead of describing the page.`, + url: requested, + }; +}