Skip to content

Commit ea93f6b

Browse files
authored
fix(desktop-browser): add recoverable page failure states (#7142)
* fix(desktop-browser): add recoverable page failure states * fix(desktop-browser): align recovery interaction paths * fix(desktop-browser): expire stale recovery history
1 parent 4e1e291 commit ea93f6b

14 files changed

Lines changed: 951 additions & 109 deletions

File tree

apps/desktop/src/main/browser-agent/driver.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,59 @@ describe('executeTool', () => {
412412
)
413413
})
414414

415+
it('publishes main-frame load failures and retries their uncommitted URL', async () => {
416+
const onPageState = vi.fn()
417+
const win = new BrowserWindow()
418+
driver.initDriver(
419+
{
420+
onPageState,
421+
onTabsState: vi.fn(),
422+
onSessionStatus: vi.fn(),
423+
onFillAvailability: vi.fn(),
424+
},
425+
() => win
426+
)
427+
driver.activateBrowserScope('chat-test')
428+
await driver.executeTool('chat-test', 'browser_open_tab', {})
429+
const contents = session.requireTab().view.webContents
430+
const eventHandlers = (contents.on as unknown as ReturnType<typeof vi.fn>).mock.calls
431+
const failLoad = eventHandlers.find(([eventName]) => eventName === 'did-fail-load')?.[1] as
432+
| ((...args: unknown[]) => void)
433+
| undefined
434+
const failedUrl = 'http://localhost:3004/login'
435+
436+
onPageState.mockClear()
437+
failLoad?.({}, -102, 'ERR_CONNECTION_REFUSED', failedUrl, false)
438+
failLoad?.({}, -3, 'ERR_ABORTED', failedUrl, true)
439+
expect(onPageState).not.toHaveBeenCalled()
440+
441+
failLoad?.({}, -102, 'ERR_CONNECTION_REFUSED', failedUrl, true)
442+
443+
expect(onPageState).toHaveBeenLastCalledWith(
444+
expect.objectContaining({
445+
url: failedUrl,
446+
issue: {
447+
kind: 'load-error',
448+
code: -102,
449+
description: 'ERR_CONNECTION_REFUSED',
450+
url: failedUrl,
451+
},
452+
})
453+
)
454+
455+
vi.mocked(contents.loadURL).mockClear()
456+
await driver.handlePanelAction('chat-test', { action: 'reload' })
457+
expect(contents.loadURL).toHaveBeenCalledWith(failedUrl)
458+
459+
vi.mocked(contents.loadURL).mockClear()
460+
await driver.executeTool('chat-test', 'browser_go_back', {})
461+
expect(session.pageIssueForContents(contents)).toBeUndefined()
462+
expect(session.canGoForward(contents)).toBe(true)
463+
464+
await driver.executeTool('chat-test', 'browser_go_forward', {})
465+
expect(contents.loadURL).toHaveBeenCalledWith(failedUrl)
466+
})
467+
415468
it('forces fill availability to replay on scope activation and tab switches', async () => {
416469
const refreshAvailability = vi
417470
.spyOn(fillCoordinator()!, 'refreshAvailability')

apps/desktop/src/main/browser-agent/driver.ts

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -264,14 +264,16 @@ function recordNotice(notice: string): void {
264264
* navigations and tab switches.
265265
*/
266266
function pageStateFor(contents: WebContents, tabId: string): BrowserPageState {
267+
const issue = session.pageIssueForContents(contents)
267268
return {
268269
scopeId: session.getBrowserScopeId(),
269270
tabId,
270-
url: contents.getURL(),
271-
title: contents.getTitle(),
272-
loading: contents.isLoadingMainFrame(),
273-
canGoBack: contents.navigationHistory.canGoBack(),
274-
canGoForward: contents.navigationHistory.canGoForward(),
271+
url: issue?.url ?? contents.getURL(),
272+
title: issue?.kind === 'load-error' ? '' : contents.getTitle(),
273+
loading: issue ? false : contents.isLoadingMainFrame(),
274+
canGoBack: session.canGoBack(contents),
275+
canGoForward: session.canGoForward(contents),
276+
...(issue ? { issue } : {}),
275277
}
276278
}
277279

@@ -346,6 +348,18 @@ function instrumentTab(contents: WebContents): void {
346348
pushTabsState()
347349
})
348350
)
351+
contents.on(
352+
'did-fail-load',
353+
inScope((_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
354+
if (!isMainFrame || errorCode === 0 || errorCode === -3) return
355+
session.recordPageLoadFailure(contents, {
356+
kind: 'load-error',
357+
code: errorCode,
358+
description: errorDescription,
359+
url: validatedURL || contents.getURL(),
360+
})
361+
})
362+
)
349363
contents.on(
350364
'did-frame-navigate',
351365
inScope(
@@ -364,7 +378,6 @@ function instrumentTab(contents: WebContents): void {
364378
for (const event of [
365379
'did-navigate-in-page',
366380
'page-title-updated',
367-
'did-start-loading',
368381
'did-finish-load',
369382
'did-stop-loading',
370383
] as const) {
@@ -376,6 +389,14 @@ function instrumentTab(contents: WebContents): void {
376389
})
377390
)
378391
}
392+
contents.on(
393+
'did-start-loading',
394+
inScope(() => {
395+
session.notePageLoadStarted(contents)
396+
pushPageState(contents)
397+
pushTabsState()
398+
})
399+
)
379400
driverCallbacks?.onSessionStatus(true, scopeId)
380401
}
381402

@@ -435,6 +456,7 @@ export function initDriver(
435456
// The fill affordance belongs to whichever page is in front.
436457
void fillCoordinator()?.refreshAvailability(true)
437458
},
459+
onPageStateChanged: pushPageState,
438460
onTabsChanged: pushTabsState,
439461
onTabThemeChanged: (contents, theme) => {
440462
void cdp.setColorScheme(contents, theme).catch((error) => {
@@ -1963,19 +1985,20 @@ async function executeToolInner(
19631985
case 'browser_go_forward': {
19641986
invalidateSnapshot()
19651987
const contents = session.requireAutomationTab().view.webContents
1966-
const history = contents.navigationHistory
19671988
assertCurrentExecution()
19681989
let completion: Promise<void>
19691990
if (tool === 'browser_go_back') {
1970-
if (!history.canGoBack()) throw new ToolError('Cannot go back — no earlier history entry.')
1991+
if (!session.canGoBack(contents)) {
1992+
throw new ToolError('Cannot go back — no earlier history entry.')
1993+
}
19711994
completion = waitForLoadComplete(contents, NAVIGATION_TIMEOUT_MS)
1972-
history.goBack()
1995+
session.goBack(contents)
19731996
} else {
1974-
if (!history.canGoForward()) {
1997+
if (!session.canGoForward(contents)) {
19751998
throw new ToolError('Cannot go forward — no later history entry.')
19761999
}
19772000
completion = waitForLoadComplete(contents, NAVIGATION_TIMEOUT_MS)
1978-
history.goForward()
2001+
session.goForward(contents)
19792002
}
19802003
return await navigationResult(contents, completion)
19812004
}
@@ -3814,13 +3837,13 @@ export async function handlePanelAction(
38143837
const contents = tab.view.webContents
38153838
switch (action.action) {
38163839
case 'reload':
3817-
contents.reload()
3840+
session.reloadPage(contents)
38183841
return
38193842
case 'back':
3820-
if (contents.navigationHistory.canGoBack()) contents.navigationHistory.goBack()
3843+
session.goBack(contents)
38213844
return
38223845
case 'forward':
3823-
if (contents.navigationHistory.canGoForward()) contents.navigationHistory.goForward()
3846+
session.goForward(contents)
38243847
return
38253848
case 'print':
38263849
contents.print({ printBackground: true })

0 commit comments

Comments
 (0)