From eafb623613cafa633c14332ca6845f573526a2df Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 12 Nov 2026 11:42:22 -0800 Subject: [PATCH 01/10] fix(desktop-browser): keep a ref whose name grew when its own menu opened --- .../main/browser-agent/page-functions.test.ts | 20 +++++++++++++++++++ .../src/main/browser-agent/page-functions.ts | 11 +++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/browser-agent/page-functions.test.ts b/apps/desktop/src/main/browser-agent/page-functions.test.ts index c9b6ef73e33..00311859c11 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.test.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.test.ts @@ -673,6 +673,26 @@ describe('collectSnapshot', () => { expect(clickElement(ref)).toMatchObject({ error: 'stale' }) }) + it('keeps a connected ref whose name grew when its own submenu opened', () => { + document.body.innerHTML = '
Products ▾
' + const menu = visible(document.querySelector('[role="button"]') as HTMLDivElement) + const ref = refFor(outlineOf(collectSnapshot()), 'Products ▾') + + ;(menu.querySelector('ul') as HTMLUListElement).innerHTML = '
  • Starter
  • Business
  • ' + + expect(clickElement(ref)).toMatchObject({ dispatched: true }) + }) + + it('invalidates a connected ref whose name changed to a longer, different word', () => { + document.body.innerHTML = '
    Item 1
    ' + const row = visible(document.querySelector('[role="listitem"]') as HTMLDivElement) + const ref = refFor(outlineOf(collectSnapshot()), 'Item 1') + + row.textContent = 'Item 12' + + expect(clickElement(ref)).toMatchObject({ error: 'stale' }) + }) + it('never recycles numeric refs across snapshots', () => { document.body.innerHTML = '' visible(document.querySelector('button') as HTMLButtonElement) diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts index 3cfa32e0a8a..3b82bf439cc 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.ts @@ -897,7 +897,16 @@ export function collectSnapshot( ) { return false } - if (nameFor(candidate) !== locator.name) return false + // A still-mounted control whose own menu or disclosure opened (often by + // the hover or click being validated) appends its new content to its + // name; a recycled row replaces the name instead. + const name = nameFor(candidate) + const nameGrewInPlace = + connected && + locator.name.length > 0 && + name.startsWith(locator.name) && + (locator.name.length >= 120 || /\s/.test(name.charAt(locator.name.length))) + if (name !== locator.name && !nameGrewInPlace) return false const candidateAttributes = locatorAttributes(candidate) const attributes = connected ? connectedIdentityAttributes : stableAttributes const genericName = From c59e83f216b1d205688c1e7ec481e609ba866fb3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 12 Nov 2026 11:45:18 -0800 Subject: [PATCH 02/10] fix(desktop-browser): give text lines the identity name the resolver recomputes --- .../main/browser-agent/page-functions.test.ts | 17 +++++++++++++++++ .../src/main/browser-agent/page-functions.ts | 4 +++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/browser-agent/page-functions.test.ts b/apps/desktop/src/main/browser-agent/page-functions.test.ts index 00311859c11..782b7992210 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.test.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.test.ts @@ -683,6 +683,23 @@ describe('collectSnapshot', () => { expect(clickElement(ref)).toMatchObject({ dispatched: true }) }) + it('acts on a text line whose accessible name differs from its rendered text', () => { + document.body.innerHTML = '

    i

    ' + visible(document.querySelector('span') as HTMLSpanElement) + const ref = refFor(outlineOf(collectSnapshot()), 'i') + + expect(clickElement(ref)).toMatchObject({ dispatched: true }) + }) + + it('acts on a text line longer than the identity name budget', () => { + const text = 'Terms of service '.repeat(10).trim() + document.body.innerHTML = `

    ${text}

    ` + visible(document.querySelector('p') as HTMLParagraphElement) + const ref = refFor(outlineOf(collectSnapshot()), text.slice(0, 160)) + + expect(clickElement(ref)).toMatchObject({ dispatched: true }) + }) + it('invalidates a connected ref whose name changed to a longer, different word', () => { document.body.innerHTML = '
    Item 1
    ' const row = visible(document.querySelector('[role="listitem"]') as HTMLDivElement) diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts index 3b82bf439cc..c7b67f1e195 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.ts @@ -691,7 +691,9 @@ export function collectSnapshot( 160 ) if (!text) return - const id = registerElement(el, roleFor(el), text) + // Identity uses the name the resolver recomputes, not the displayed text, + // which differs for aria-labelled leaves and past the name's length cap. + const id = registerElement(el, roleFor(el), nameFor(el)) textLineCount++ const marker = isNew(el) ? ' new' : '' const lineIndex = lines.length From f69d049f009b439cee5977b293961c52e4284030 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 12 Nov 2026 11:50:04 -0800 Subject: [PATCH 03/10] improvement(desktop-browser): keep an element on the same ref across snapshots --- .../src/main/browser-agent/driver.test.ts | 25 ++++++++++++++- apps/desktop/src/main/browser-agent/driver.ts | 32 +++++++++++++++---- .../main/browser-agent/page-functions.test.ts | 31 ++++++++++++++++-- .../src/main/browser-agent/page-functions.ts | 27 +++++++++++++--- 4 files changed, 101 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts index fccd280a9ec..60193378703 100644 --- a/apps/desktop/src/main/browser-agent/driver.test.ts +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -3098,6 +3098,29 @@ describe('credential protection', () => { }) }) + it('accepts a later snapshot that keeps refs this scope issued for the same page', async () => { + const contents = await openPage() + const snapshot = (refIds: number[], nextElementId: number) => ({ + url: 'https://example.com/', + title: 'Example', + outline: refIds.map((ref) => `- button "B${ref}" [ref=${ref}]`).join('\n'), + truncated: false, + refIds, + refLineIndexes: Object.fromEntries(refIds.map((ref, index) => [ref, index])), + nextElementId, + }) + respondWith(contents, { collectSnapshot: snapshot([4, 5], 6) }) + await driver.executeTool('chat-test', 'browser_snapshot', {}) + respondWith(contents, { collectSnapshot: snapshot([4, 6], 7) }) + + const kept = await driver.executeTool('chat-test', 'browser_snapshot', {}) + expect(kept).toMatchObject({ ok: true }) + respondWith(contents, { collectSnapshot: snapshot([2], 7) }) + + const forged = await driver.executeTool('chat-test', 'browser_snapshot', {}) + expect(forged).toMatchObject({ ok: false }) + }) + it.each([ ['browser_snapshot', 'true'], ['browser_find', 'false'], @@ -3125,7 +3148,7 @@ describe('credential protection', () => { .mock.calls.some( ([expression]) => isPageCall(expression, 'collectSnapshot') && - expression.includes(`.apply(null, [1,0,${markNew}])`) + expression.includes(`.apply(null, [1,0,${markNew},`) ) ).toBe(true) const stale = await driver.executeTool('chat-test', 'browser_click', { elementId: 0 }) diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index d0ada80bf5f..7c1fddf1f57 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -144,6 +144,13 @@ function isBrowserWaitElementState(value: string): value is BrowserWaitElementSt type PageExecutionTarget = WebContents | WebFrameMain +/** Identifies a frame across the separate WebFrameMain wrappers Electron may hand out for it. */ +function pageTargetKey(target: PageExecutionTarget): string { + return 'routingId' in target + ? `frame:${target.processId}:${target.routingId}` + : `contents:${target.id}` +} + type BrowserActionOutcome = { status: 'pending' } | { status: 'acknowledged'; result: unknown } type FormField = @@ -326,6 +333,8 @@ interface DriverScopeState { snapshotTargets: Map /** Monotonic ref floor shared by every frame in this browser scope. */ nextElementRefId: number + /** Every ref this scope issued and the frame it belongs to, so a later snapshot may reuse it there. */ + issuedRefTargets: Map /** Invalidates captures that finish after a tab/navigation/timeout race. */ snapshotCaptureEpoch: number /** Cancels a timed-out multi-step action before any later native dispatch. */ @@ -367,6 +376,7 @@ function createDriverScopeState(): DriverScopeState { snapshotTabId: null, snapshotTargets: new Map(), nextElementRefId: 0, + issuedRefTargets: new Map(), snapshotCaptureEpoch: 0, toolExecutionEpoch: 0, } @@ -2338,7 +2348,8 @@ async function readWholePageText( function validateSnapshotRefs( snapshot: Record, - startingElementId: number + startingElementId: number, + issuedHere: (ref: number) => boolean ): { refs: number[]; nextElementId: number; lineIndexes: Map } | null { if (typeof snapshot.outline !== 'string' || snapshot.outline.length > 500_000) return null if (!Array.isArray(snapshot.refIds) || snapshot.refIds.length > 300) return null @@ -2362,7 +2373,7 @@ function validateSnapshotRefs( if ( typeof value !== 'number' || !Number.isSafeInteger(value) || - value < startingElementId || + (value < startingElementId && !issuedHere(value)) || value >= nextElementId || unique.has(value) ) { @@ -2436,7 +2447,7 @@ async function captureSnapshot( await execInPage( contents, collectSnapshot, - [mainStartingElementId, elementId ?? null, markNew], + [mainStartingElementId, elementId ?? null, markNew, state.generation], false, notAfter ) @@ -2451,7 +2462,11 @@ async function captureSnapshot( ) { throw new ToolError('The page returned an incomplete snapshot. Try browser_snapshot again.') } - const mainRefs = validateSnapshotRefs(mainSnapshot, mainStartingElementId) + const mainRefs = validateSnapshotRefs( + mainSnapshot, + mainStartingElementId, + (ref) => state.issuedRefTargets.get(ref) === pageTargetKey(contents) + ) if (!mainRefs) { throw new ToolError('The page returned invalid element ids. Try browser_snapshot again.') } @@ -2493,7 +2508,7 @@ async function captureSnapshot( const frameSnapshot = await execInPage( frame, collectSnapshot, - [frameStartingElementId, null, markNew], + [frameStartingElementId, null, markNew, state.generation], false, notAfter ) @@ -2512,7 +2527,11 @@ async function captureSnapshot( unreadableCrossOriginFrames++ continue } - const frameRefs = validateSnapshotRefs(frameSnapshot, frameStartingElementId) + const frameRefs = validateSnapshotRefs( + frameSnapshot, + frameStartingElementId, + (ref) => state.issuedRefTargets.get(ref) === pageTargetKey(frame) + ) if (!frameRefs) { unreadableCrossOriginFrames++ continue @@ -2565,6 +2584,7 @@ async function captureSnapshot( (targetLineIndexes.get(elementId) ?? Number.POSITIVE_INFINITY) < visibleOutlineLines.length ) ) + for (const [ref, target] of targets) state.issuedRefTargets.set(ref, pageTargetKey(target)) state.snapshotTabId = capturedTabId return { ...omit(mainSnapshot, ['refIds', 'refLineIndexes', 'nextElementId']), diff --git a/apps/desktop/src/main/browser-agent/page-functions.test.ts b/apps/desktop/src/main/browser-agent/page-functions.test.ts index 782b7992210..66e6aca7077 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.test.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.test.ts @@ -710,16 +710,41 @@ describe('collectSnapshot', () => { expect(clickElement(ref)).toMatchObject({ error: 'stale' }) }) - it('never recycles numeric refs across snapshots', () => { + it('keeps an unchanged element on the same ref across snapshots', () => { + document.body.innerHTML = '' + for (const button of Array.from(document.querySelectorAll('button'))) visible(button) + const first = outlineOf(collectSnapshot()) + const second = outlineOf(collectSnapshot()) + + expect(refFor(second, 'Pins')).toBe(refFor(first, 'Pins')) + expect(refFor(second, 'Files')).toBe(refFor(first, 'Files')) + expect(clickElement(refFor(first, 'Pins'))).toMatchObject({ dispatched: true }) + }) + + it('never gives a ref to a different element across snapshots', () => { document.body.innerHTML = '' - visible(document.querySelector('button') as HTMLButtonElement) + const original = visible(document.querySelector('button') as HTMLButtonElement) const firstRef = refFor(outlineOf(collectSnapshot()), 'Pins') - const secondRef = refFor(outlineOf(collectSnapshot()), 'Pins') + original.replaceWith(visible(document.createElement('button'))) + ;(document.querySelector('button') as HTMLButtonElement).textContent = 'Files' + const secondRef = refFor(outlineOf(collectSnapshot()), 'Files') expect(secondRef).toBeGreaterThan(firstRef) expect(clickElement(firstRef)).toMatchObject({ error: 'stale' }) expect(clickElement(secondRef)).toMatchObject({ dispatched: true }) }) + + it('gives a recycled row a new ref when its identity changed in place', () => { + document.body.innerHTML = '
    eng-bugs
    ' + const row = visible(document.querySelector('[role="listitem"]') as HTMLDivElement) + const firstRef = refFor(outlineOf(collectSnapshot()), 'eng-bugs') + row.textContent = 'random' + row.dataset.key = 'channel-2' + const secondRef = refFor(outlineOf(collectSnapshot()), 'random') + + expect(secondRef).not.toBe(firstRef) + expect(clickElement(firstRef)).toMatchObject({ error: 'stale' }) + }) }) describe('semantic control state', () => { diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts index c7b67f1e195..d3fbb9f21b3 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.ts @@ -43,6 +43,8 @@ declare global { observedRoots: WeakSet }> __simAgentNextElementId?: number + /** Each element's ref and the identity it was issued for, so a later snapshot keeps the ref. */ + __simAgentIssuedRefs?: WeakMap /** Elements an earlier snapshot of this document listed; a later snapshot marks the rest `new`. */ __simAgentShownElements?: WeakSet /** Installed by {@link installPageHelpers} before every page function; cached per call. */ @@ -210,7 +212,8 @@ export function installPageHelpers(): void { export function collectSnapshot( startingElementId = 0, elementId: number | null = null, - markNew = true + markNew = true, + refNamespace = 0 ): unknown { const resolver = window.__simAgentResolveElement const scopedRoot = @@ -588,10 +591,14 @@ export function collectSnapshot( return '' } + // A ref the model already holds stays valid while its element keeps the + // identity it was issued for; a changed or replaced element gets a new ref, + // so an id never comes to mean a different element. Refs are reused only + // within the browser scope (refNamespace) that issued them. + const issuedRefs = window.__simAgentIssuedRefs ?? new WeakMap() + window.__simAgentIssuedRefs = issuedRefs const registerElement = (el: Element, role: string, name: string): number => { - const id = nextElementId++ - registry[id] = el - locators[id] = { + const locator = { url: pageUrlFor(el), tag: el.tagName.toUpperCase(), role, @@ -600,6 +607,18 @@ export function collectSnapshot( ancestor: ancestorSignature(el), context: contextSignature(el), } + const identity = JSON.stringify([locator.tag, locator.role, locator.name, locator.attributes]) + const issued = issuedRefs.get(el) + const id = + issued && + issued.namespace === refNamespace && + issued.identity === identity && + registry[issued.id] === undefined + ? issued.id + : nextElementId++ + issuedRefs.set(el, { id, identity, namespace: refNamespace }) + registry[id] = el + locators[id] = locator refCount++ window.__simAgentNextElementId = nextElementId return id From 795911519b663fcc67a5c533f471b501587faddd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 12 Nov 2026 11:54:02 -0800 Subject: [PATCH 04/10] improvement(desktop-browser): show the page at the end of every browser_wait_for --- apps/desktop/src/main/browser-agent/driver.ts | 9 ++++++--- .../post-action-observation.test.ts | 20 +++++++++++++++++++ .../browser-agent/post-action-observation.ts | 11 +++++++++- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index 7c1fddf1f57..1459a72f8ec 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -88,6 +88,7 @@ import { } from '@/main/browser-agent/page-prompts' import { isPanelVisible, panelWindow } from '@/main/browser-agent/panel' import { + observesAfterAction, withFailedPostActionObservation, withPostActionObservation, } from '@/main/browser-agent/post-action-observation' @@ -5367,7 +5368,9 @@ export async function executeTool( actionOutcome = outcome } ) - if (params.observe !== undefined) actionOutcome = { status: 'acknowledged', result } + if (observesAfterAction(tool, params)) { + actionOutcome = { status: 'acknowledged', result } + } return result }, (query) => @@ -5392,7 +5395,7 @@ export async function executeTool( tool === 'browser_snapshot' || tool === 'browser_open_url' || tool === 'browser_find' || - params.observe !== undefined + observesAfterAction(tool, params) ) { invalidateSnapshot(state) } @@ -5402,7 +5405,7 @@ export async function executeTool( observedResult = await guardedExecution } catch (error) { if (!actionOutcome) { - if (params.observe !== undefined) cancelPendingSnapshotCapture(state) + if (observesAfterAction(tool, params)) cancelPendingSnapshotCapture(state) throw error } invalidateSnapshot(state) diff --git a/apps/desktop/src/main/browser-agent/post-action-observation.test.ts b/apps/desktop/src/main/browser-agent/post-action-observation.test.ts index 3938df6c7fb..77c95c027cd 100644 --- a/apps/desktop/src/main/browser-agent/post-action-observation.test.ts +++ b/apps/desktop/src/main/browser-agent/post-action-observation.test.ts @@ -2,6 +2,26 @@ import { describe, expect, it, vi } from 'vitest' import { withPostActionObservation } from '@/main/browser-agent/post-action-observation' describe('post-action observation', () => { + it('shows the page after a wait without being asked, since a wait exists to see what changed', async () => { + const result = await withPostActionObservation( + 'browser_wait_for', + { timeoutMs: 500 }, + async (params) => { + expect(params).toEqual({ timeoutMs: 500 }) + return { found: false } + }, + async (query) => { + expect(query).toBeUndefined() + return { outline: '- heading "Order confirmed"' } + }, + vi.fn() + ) + expect(result).toMatchObject({ + found: false, + observation: { ok: true, result: { outline: '- heading "Order confirmed"' } }, + }) + }) + it('observes after the action, preserving partial form results and replacing refs', async () => { const order: string[] = [] const result = await withPostActionObservation( diff --git a/apps/desktop/src/main/browser-agent/post-action-observation.ts b/apps/desktop/src/main/browser-agent/post-action-observation.ts index 3b2d7554d64..7911214d9e9 100644 --- a/apps/desktop/src/main/browser-agent/post-action-observation.ts +++ b/apps/desktop/src/main/browser-agent/post-action-observation.ts @@ -17,8 +17,17 @@ const OBSERVABLE_ACTIONS: ReadonlySet = new Set([ 'browser_hover', 'browser_drag', 'browser_batch', + 'browser_wait_for', ]) +/** Waits exist to see what the page became, so they observe even when the model omits observe. */ +const OBSERVES_BY_DEFAULT: ReadonlySet = new Set(['browser_wait_for']) + +/** Whether this call ends with a post-action observation, requested or by default. */ +export function observesAfterAction(tool: BrowserToolName, params: Record): boolean { + return params.observe !== undefined || OBSERVES_BY_DEFAULT.has(tool) +} + /** Preserves a dispatched action when its acknowledgement or observation is interrupted. */ export function withFailedPostActionObservation(result: unknown, error: unknown): unknown { const observation = { @@ -38,7 +47,7 @@ export async function withPostActionObservation( observe: (query: string | undefined) => Promise, assertCurrent: () => void ): Promise { - const request = params.observe + const request = params.observe ?? (OBSERVES_BY_DEFAULT.has(tool) ? {} : undefined) if (request === undefined) return action(params) if ( !OBSERVABLE_ACTIONS.has(tool) || From 91aa3d5c2684f0211e791672aefdb1a87b886bed Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 12 Nov 2026 12:01:15 -0800 Subject: [PATCH 05/10] improvement(desktop-browser): resolve refs from earlier snapshots while their element lives --- .../src/main/browser-agent/driver.test.ts | 35 ++++++++++++++-- apps/desktop/src/main/browser-agent/driver.ts | 41 +++++++++++++++---- .../main/browser-agent/page-functions.test.ts | 20 +++++++++ .../src/main/browser-agent/page-functions.ts | 37 ++++++++++++----- 4 files changed, 112 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts index 60193378703..4a03943b634 100644 --- a/apps/desktop/src/main/browser-agent/driver.test.ts +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -3121,11 +3121,42 @@ describe('credential protection', () => { expect(forged).toMatchObject({ ok: false }) }) + it('acts on a ref an earlier snapshot of the same page issued', async () => { + const contents = await openPage() + respondWith(contents, { + collectSnapshot: { + url: 'https://example.com/', + title: 'Example', + outline: '- button "Pins" [ref=4]\n- button "Files" [ref=5]', + truncated: false, + refIds: [4, 5], + refLineIndexes: { 4: 0, 5: 1 }, + nextElementId: 6, + }, + }) + await driver.executeTool('chat-test', 'browser_snapshot', {}) + respondWith(contents, { + collectSnapshot: { + url: 'https://example.com/', + title: 'Example', + outline: '- dialog "Pick a time" [ref=6]', + truncated: false, + refIds: [6], + refLineIndexes: { 6: 0 }, + nextElementId: 7, + }, + }) + await driver.executeTool('chat-test', 'browser_find', { query: 'time' }) + + const clicked = await driver.executeTool('chat-test', 'browser_click', { elementId: 4 }) + expect(clicked).toMatchObject({ ok: true, result: { dispatched: true } }) + }) + it.each([ ['browser_snapshot', 'true'], ['browser_find', 'false'], ] as const)( - 'passes the current root ref to %s and invalidates previous refs', + 'passes the current root ref to %s', async (tool, markNew) => { const contents = await openPage() respondWith(contents, { @@ -3151,8 +3182,6 @@ describe('credential protection', () => { expression.includes(`.apply(null, [1,0,${markNew},`) ) ).toBe(true) - const stale = await driver.executeTool('chat-test', 'browser_click', { elementId: 0 }) - expect(stale).toMatchObject({ ok: false }) } ) diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index 1459a72f8ec..99abbd9b77b 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -334,8 +334,8 @@ interface DriverScopeState { snapshotTargets: Map /** Monotonic ref floor shared by every frame in this browser scope. */ nextElementRefId: number - /** Every ref this scope issued and the frame it belongs to, so a later snapshot may reuse it there. */ - issuedRefTargets: Map + /** Every ref this scope issued and its frame: a later snapshot may reuse it there, and an action may still name it. */ + issuedRefTargets: Map /** Invalidates captures that finish after a tab/navigation/timeout race. */ snapshotCaptureEpoch: number /** Cancels a timed-out multi-step action before any later native dispatch. */ @@ -1777,9 +1777,28 @@ function requireSnapshotForElementAction(): void { ) } +/** + * The frame that owns a ref: the latest snapshot's, or for a ref an earlier + * snapshot issued, its frame while that frame still belongs to the tab the + * latest snapshot covered. The page then confirms the element itself. + */ +function elementTarget(elementId: number): PageExecutionTarget | undefined { + const state = driverScopeState() + const latest = state.snapshotTargets.get(elementId) + if (latest) return latest + const issued = state.issuedRefTargets.get(elementId) + const contents = session.automationTab()?.view.webContents + if (!issued || !contents || contents.isDestroyed()) return undefined + if (!('routingId' in issued.target)) return issued.target === contents ? contents : undefined + const frame = issued.target + return contents.mainFrame.framesInSubtree.find( + (candidate) => !candidate.isDestroyed() && sameWebFrame(candidate, frame) + ) +} + function pageTargetForElement(elementId: number): PageExecutionTarget { requireSnapshotForElementAction() - const target = driverScopeState().snapshotTargets.get(elementId) + const target = elementTarget(elementId) if (!target || ('isDestroyed' in target && target.isDestroyed())) { throw new ToolError( 'That element id is not present in the current snapshot. Call browser_snapshot and use a visible ref from that result.' @@ -1876,7 +1895,13 @@ function assertElementActionCurrent( assertActiveContents(contents) const state = driverScopeState() const active = session.automationTab() - if (state.snapshotTabId !== active?.id || state.snapshotTargets.get(elementId) !== target) { + const owner = elementTarget(elementId) + if ( + state.snapshotTabId !== active?.id || + !owner || + (owner !== target && + !('routingId' in owner && 'routingId' in target && sameWebFrame(owner, target))) + ) { throw new ToolError( 'The page changed before input could be dispatched. Take a fresh browser_snapshot and try again.' ) @@ -2466,7 +2491,7 @@ async function captureSnapshot( const mainRefs = validateSnapshotRefs( mainSnapshot, mainStartingElementId, - (ref) => state.issuedRefTargets.get(ref) === pageTargetKey(contents) + (ref) => state.issuedRefTargets.get(ref)?.key === pageTargetKey(contents) ) if (!mainRefs) { throw new ToolError('The page returned invalid element ids. Try browser_snapshot again.') @@ -2531,7 +2556,7 @@ async function captureSnapshot( const frameRefs = validateSnapshotRefs( frameSnapshot, frameStartingElementId, - (ref) => state.issuedRefTargets.get(ref) === pageTargetKey(frame) + (ref) => state.issuedRefTargets.get(ref)?.key === pageTargetKey(frame) ) if (!frameRefs) { unreadableCrossOriginFrames++ @@ -2585,7 +2610,9 @@ async function captureSnapshot( (targetLineIndexes.get(elementId) ?? Number.POSITIVE_INFINITY) < visibleOutlineLines.length ) ) - for (const [ref, target] of targets) state.issuedRefTargets.set(ref, pageTargetKey(target)) + for (const [ref, target] of targets) { + state.issuedRefTargets.set(ref, { key: pageTargetKey(target), target }) + } state.snapshotTabId = capturedTabId return { ...omit(mainSnapshot, ['refIds', 'refLineIndexes', 'nextElementId']), diff --git a/apps/desktop/src/main/browser-agent/page-functions.test.ts b/apps/desktop/src/main/browser-agent/page-functions.test.ts index 66e6aca7077..dfa5d46a89c 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.test.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.test.ts @@ -721,6 +721,26 @@ describe('collectSnapshot', () => { expect(clickElement(refFor(first, 'Pins'))).toMatchObject({ dispatched: true }) }) + it('acts on a ref from an earlier snapshot that a later scoped snapshot left out', () => { + document.body.innerHTML = '
    ' + for (const button of Array.from(document.querySelectorAll('button'))) visible(button) + const pins = refFor(outlineOf(collectSnapshot()), 'Pins') + const section = refFor(outlineOf(collectSnapshot()), 'Files') + collectSnapshot(100, section) + + expect(clickElement(pins)).toMatchObject({ dispatched: true }) + }) + + it('refuses a ref from an earlier snapshot once its element left the DOM', () => { + document.body.innerHTML = '' + for (const button of Array.from(document.querySelectorAll('button'))) visible(button) + const pins = refFor(outlineOf(collectSnapshot()), 'Pins') + ;(document.querySelector('button') as HTMLButtonElement).remove() + collectSnapshot() + + expect(clickElement(pins)).toMatchObject({ error: 'stale' }) + }) + it('never gives a ref to a different element across snapshots', () => { document.body.innerHTML = '' const original = visible(document.querySelector('button') as HTMLButtonElement) diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts index d3fbb9f21b3..789f5a6bed2 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.ts @@ -28,6 +28,17 @@ * the keystroke refusal that read it. */ +/** What a snapshot recorded about an element, so a later action can confirm it is the same one. */ +interface SnapshotRefLocator { + url: string + tag: string + role: string + name: string + attributes: Record + ancestor: string + context: string +} + declare global { interface Window { __simAgentElements?: Element[] @@ -45,6 +56,8 @@ declare global { __simAgentNextElementId?: number /** Each element's ref and the identity it was issued for, so a later snapshot keeps the ref. */ __simAgentIssuedRefs?: WeakMap + /** Refs issued by earlier snapshots of this document, resolvable while their element lives. */ + __simAgentEarlierRefs?: Map; locator: SnapshotRefLocator }> /** Elements an earlier snapshot of this document listed; a later snapshot marks the rest `new`. */ __simAgentShownElements?: WeakSet /** Installed by {@link installPageHelpers} before every page function; cached per call. */ @@ -311,15 +324,7 @@ export function collectSnapshot( const registry: Element[] = [] const refLineIndexes: Record = {} - const locators: Array<{ - url: string - tag: string - role: string - name: string - attributes: Record - ancestor: string - context: string - }> = [] + const locators: SnapshotRefLocator[] = [] window.__simAgentElements = registry const previouslyShown = window.__simAgentShownElements const shown = previouslyShown ?? new WeakSet() @@ -597,6 +602,11 @@ export function collectSnapshot( // within the browser scope (refNamespace) that issued them. const issuedRefs = window.__simAgentIssuedRefs ?? new WeakMap() window.__simAgentIssuedRefs = issuedRefs + // A ref from an earlier snapshot still resolves while its element lives, + // even when a later scoped, query, or modal-covered snapshot left it out. + const earlierRefs = window.__simAgentEarlierRefs ?? new Map() + window.__simAgentEarlierRefs = earlierRefs + const earlierRefCap = 5000 const registerElement = (el: Element, role: string, name: string): number => { const locator = { url: pageUrlFor(el), @@ -619,6 +629,10 @@ export function collectSnapshot( issuedRefs.set(el, { id, identity, namespace: refNamespace }) registry[id] = el locators[id] = locator + earlierRefs.delete(id) + earlierRefs.set(id, { element: new WeakRef(el), locator }) + const oldest = earlierRefs.keys().next() + if (earlierRefs.size > earlierRefCap && !oldest.done) earlierRefs.delete(oldest.value) refCount++ window.__simAgentNextElementId = nextElementId return id @@ -875,7 +889,8 @@ export function collectSnapshot( window.__simAgentStaleReason = 'the scoped snapshot root left the DOM' return null } - const locator = locators[id] + const earlier = locators[id] ? undefined : earlierRefs.get(id) + const locator = locators[id] ?? earlier?.locator if (!locator) { window.__simAgentStaleReason = `id ${id} is not in the current snapshot's registry` return null @@ -1006,7 +1021,7 @@ export function collectSnapshot( return true } - const current = registry[id] + const current = registry[id] ?? earlier?.element.deref() if (current?.isConnected) { if (!identityMatches(current, true)) { window.__simAgentStaleReason = `the node is still mounted but its identity drifted (now <${String(current.tagName || '').toLowerCase()}> role=${roleFor(current) || 'none'} name="${nameFor(current).slice(0, 60)}")` From adca744e460c6d3487caffb2c76e4e289eae51b8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 12 Nov 2026 12:06:25 -0800 Subject: [PATCH 06/10] chore(desktop-browser): compare issued-ref frames by identity instead of a string key --- apps/desktop/src/main/browser-agent/driver.ts | 32 +++++++------------ 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index 99abbd9b77b..e165c1fd2ab 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -145,11 +145,10 @@ function isBrowserWaitElementState(value: string): value is BrowserWaitElementSt type PageExecutionTarget = WebContents | WebFrameMain -/** Identifies a frame across the separate WebFrameMain wrappers Electron may hand out for it. */ -function pageTargetKey(target: PageExecutionTarget): string { - return 'routingId' in target - ? `frame:${target.processId}:${target.routingId}` - : `contents:${target.id}` +/** Whether two targets are the same page or frame; Electron may hand out separate wrappers for one frame. */ +function samePageTarget(left: PageExecutionTarget | undefined, right: PageExecutionTarget): boolean { + if (left === right) return true + return left !== undefined && 'routingId' in left && 'routingId' in right && sameWebFrame(left, right) } type BrowserActionOutcome = { status: 'pending' } | { status: 'acknowledged'; result: unknown } @@ -335,7 +334,7 @@ interface DriverScopeState { /** Monotonic ref floor shared by every frame in this browser scope. */ nextElementRefId: number /** Every ref this scope issued and its frame: a later snapshot may reuse it there, and an action may still name it. */ - issuedRefTargets: Map + issuedRefTargets: Map /** Invalidates captures that finish after a tab/navigation/timeout race. */ snapshotCaptureEpoch: number /** Cancels a timed-out multi-step action before any later native dispatch. */ @@ -1789,10 +1788,9 @@ function elementTarget(elementId: number): PageExecutionTarget | undefined { const issued = state.issuedRefTargets.get(elementId) const contents = session.automationTab()?.view.webContents if (!issued || !contents || contents.isDestroyed()) return undefined - if (!('routingId' in issued.target)) return issued.target === contents ? contents : undefined - const frame = issued.target + if (!('routingId' in issued)) return issued === contents ? contents : undefined return contents.mainFrame.framesInSubtree.find( - (candidate) => !candidate.isDestroyed() && sameWebFrame(candidate, frame) + (candidate) => !candidate.isDestroyed() && sameWebFrame(candidate, issued) ) } @@ -1895,13 +1893,7 @@ function assertElementActionCurrent( assertActiveContents(contents) const state = driverScopeState() const active = session.automationTab() - const owner = elementTarget(elementId) - if ( - state.snapshotTabId !== active?.id || - !owner || - (owner !== target && - !('routingId' in owner && 'routingId' in target && sameWebFrame(owner, target))) - ) { + if (state.snapshotTabId !== active?.id || !samePageTarget(elementTarget(elementId), target)) { throw new ToolError( 'The page changed before input could be dispatched. Take a fresh browser_snapshot and try again.' ) @@ -2491,7 +2483,7 @@ async function captureSnapshot( const mainRefs = validateSnapshotRefs( mainSnapshot, mainStartingElementId, - (ref) => state.issuedRefTargets.get(ref)?.key === pageTargetKey(contents) + (ref) => samePageTarget(state.issuedRefTargets.get(ref), contents) ) if (!mainRefs) { throw new ToolError('The page returned invalid element ids. Try browser_snapshot again.') @@ -2556,7 +2548,7 @@ async function captureSnapshot( const frameRefs = validateSnapshotRefs( frameSnapshot, frameStartingElementId, - (ref) => state.issuedRefTargets.get(ref)?.key === pageTargetKey(frame) + (ref) => samePageTarget(state.issuedRefTargets.get(ref), frame) ) if (!frameRefs) { unreadableCrossOriginFrames++ @@ -2610,9 +2602,7 @@ async function captureSnapshot( (targetLineIndexes.get(elementId) ?? Number.POSITIVE_INFINITY) < visibleOutlineLines.length ) ) - for (const [ref, target] of targets) { - state.issuedRefTargets.set(ref, { key: pageTargetKey(target), target }) - } + for (const [ref, target] of targets) state.issuedRefTargets.set(ref, target) state.snapshotTabId = capturedTabId return { ...omit(mainSnapshot, ['refIds', 'refLineIndexes', 'nextElementId']), From 9d14981bc504428b74a777800419ef7cbb7191b3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 12 Nov 2026 12:10:59 -0800 Subject: [PATCH 07/10] fix(desktop-browser): type into a top-page field that a non-modal overlay covers --- apps/desktop/src/main/browser-agent/driver.ts | 5 +++ .../main/browser-agent/page-functions.test.ts | 39 ++++++++++++++++--- .../src/main/browser-agent/page-functions.ts | 13 +++++++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index e165c1fd2ab..b1a68920ae4 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -4080,6 +4080,11 @@ async function executeToolInner( }, } const notes: string[] = [] + if (typeof initialSurface.coveredBy === 'string') { + notes.push( + `The field is covered by ${initialSurface.coveredBy}, so the text went in through keyboard focus.` + ) + } if (submitNote) notes.push(submitNote) if (!combinedObservation.effectObserved) { notes.push( diff --git a/apps/desktop/src/main/browser-agent/page-functions.test.ts b/apps/desktop/src/main/browser-agent/page-functions.test.ts index dfa5d46a89c..9db4f3de135 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.test.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.test.ts @@ -334,8 +334,8 @@ describe('combobox typing surfaces', () => { expect(input.value).toBe('Mondu') }) - it('refuses mixed or unrelated blockers instead of treating them as suggestions', () => { - const { option } = composeField() + it('does not treat mixed or unrelated blockers as suggestions, and types through keyboard focus', () => { + const { input, option } = composeField() const overlay = visible(document.createElement('div')) overlay.setAttribute('aria-label', 'Unrelated overlay') document.body.append(overlay) @@ -344,11 +344,38 @@ describe('combobox typing surfaces', () => { value: (x: number) => (x > 70 ? overlay : option), }) - expect(focusElementForTyping(0)).toEqual({ - error: 'obstructed', - blocker: 'Mondu', - blockerControls: [], + expect(focusElementForTyping(0)).toMatchObject({ + focused: true, + coveredBy: 'Mondu', + coveredByRelatedPopup: false, }) + expect(document.activeElement).toBe(input) + }) + + it('types into a top-page field a promo card covers, naming the cover', () => { + document.body.innerHTML = '
    Shop for your home
    ' + const input = visible(document.querySelector('input') as HTMLInputElement) + const card = visible(document.querySelector('div') as HTMLDivElement) + register(input) + Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: () => card }) + + expect(focusElementForTyping(0)).toMatchObject({ focused: true, coveredBy: 'Shop for your home' }) + expect(typeIntoElement(0, 'cooking pot', false)).toMatchObject({ dispatched: true }) + expect(input.value).toBe('cooking pot') + }) + + it('still refuses a covered field inside a frame', () => { + const frame = document.createElement('iframe') + document.body.append(frame) + const inner = frame.contentDocument as Document + inner.body.innerHTML = '
    Cover
    ' + const field = visible(inner.querySelector('input') as HTMLInputElement) + const cover = visible(inner.querySelector('div') as HTMLDivElement) + field.scrollIntoView = () => {} + register(field) + Object.defineProperty(inner, 'elementFromPoint', { configurable: true, value: () => cover }) + + expect(focusElementForTyping(0)).toMatchObject({ error: 'obstructed', blocker: 'Cover' }) }) it('refuses ambiguous composite fields and descendant passwords', () => { diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts index 789f5a6bed2..5b10ebcff4c 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.ts @@ -1911,6 +1911,18 @@ export function focusElementForTyping(id: number, moveFocus = true): unknown { coveredByRelatedPopup = true } } + // Typing reaches a focused top-page field through the keyboard, never the + // pointer, so an overlay over it (a promo card, a sticky banner) does not + // stop the text, as Playwright's fill does not hit-test either. Modal + // dialogs still refuse: their background is aria-hidden or inert, which the + // visibility and focus checks above reject. Framed fields keep the strict + // check because the driver maps their point through the parent page. + const topPageField = view === window && window.top === window + let coveredBy: string | undefined + if (!chosenPoint && topPageField && blockedPoints.length > 0) { + chosenPoint = { x: blockedPoints[0].x, y: blockedPoints[0].y } + coveredBy = blockerLabel(firstBlocker) + } if (!chosenPoint) { return { error: 'obstructed', @@ -1921,6 +1933,7 @@ export function focusElementForTyping(id: number, moveFocus = true): unknown { return { focused: true, + ...(coveredBy ? { coveredBy } : {}), kind: editableTag === 'INPUT' ? 'input' From 385d4d6131e4dd4c5918734a499c2cdfb0c4393d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 12 Nov 2026 12:46:53 -0800 Subject: [PATCH 08/10] chore(desktop-browser): one bounded ref store per page and driver, shared frame and observe helpers --- .../src/main/browser-agent/driver.test.ts | 55 +++++++------- apps/desktop/src/main/browser-agent/driver.ts | 56 +++++++------- .../main/browser-agent/page-functions.test.ts | 11 ++- .../src/main/browser-agent/page-functions.ts | 74 +++++++++---------- .../browser-agent/post-action-observation.ts | 11 +-- 5 files changed, 106 insertions(+), 101 deletions(-) diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts index 4a03943b634..4f13cb477cf 100644 --- a/apps/desktop/src/main/browser-agent/driver.test.ts +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -3155,35 +3155,32 @@ describe('credential protection', () => { it.each([ ['browser_snapshot', 'true'], ['browser_find', 'false'], - ] as const)( - 'passes the current root ref to %s', - async (tool, markNew) => { - const contents = await openPage() - respondWith(contents, { - collectSnapshot: { - url: 'https://example.com/login', - title: 'Scoped', - scoped: true, - outline: '- button "Save" [ref=1]', - truncated: false, - refIds: [1], - refLineIndexes: { 1: 0 }, - nextElementId: 2, - }, - }) - const result = await driver.executeTool('chat-test', tool, { elementId: 0, query: 'Save' }) - expect(result).toMatchObject({ ok: true, result: { scoped: true } }) - expect( - vi - .mocked(contents.executeJavaScript) - .mock.calls.some( - ([expression]) => - isPageCall(expression, 'collectSnapshot') && - expression.includes(`.apply(null, [1,0,${markNew},`) - ) - ).toBe(true) - } - ) + ] as const)('passes the current root ref to %s', async (tool, markNew) => { + const contents = await openPage() + respondWith(contents, { + collectSnapshot: { + url: 'https://example.com/login', + title: 'Scoped', + scoped: true, + outline: '- button "Save" [ref=1]', + truncated: false, + refIds: [1], + refLineIndexes: { 1: 0 }, + nextElementId: 2, + }, + }) + const result = await driver.executeTool('chat-test', tool, { elementId: 0, query: 'Save' }) + expect(result).toMatchObject({ ok: true, result: { scoped: true } }) + expect( + vi + .mocked(contents.executeJavaScript) + .mock.calls.some( + ([expression]) => + isPageCall(expression, 'collectSnapshot') && + expression.includes(`.apply(null, [1,0,${markNew},`) + ) + ).toBe(true) + }) it.each([ { kind: 'input:checkbox', checked: true, disabled: false, readOnly: false }, diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index b1a68920ae4..3968edc8b60 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -37,6 +37,7 @@ import { interruptibleSleep, sleep } from '@sim/utils/helpers' import { isRecordLike, omit, toArray, toRecord } from '@sim/utils/object' import type { BrowserWindow, MenuItemConstructorOptions, WebContents, WebFrameMain } from 'electron' import { Menu } from 'electron' +import { LRUCache } from 'lru-cache' import * as cdp from '@/main/browser-agent/cdp' import { steppedZoomFactor, zoomPercentOf } from '@/main/browser-agent/context-menu' import { ToolError } from '@/main/browser-agent/errors' @@ -88,7 +89,7 @@ import { } from '@/main/browser-agent/page-prompts' import { isPanelVisible, panelWindow } from '@/main/browser-agent/panel' import { - observesAfterAction, + observeRequest, withFailedPostActionObservation, withPostActionObservation, } from '@/main/browser-agent/post-action-observation' @@ -145,10 +146,18 @@ function isBrowserWaitElementState(value: string): value is BrowserWaitElementSt type PageExecutionTarget = WebContents | WebFrameMain +/** Matches the page's own memory of issued refs; an evicted ref simply gets a new id. */ +const ISSUED_REF_MEMORY = 5000 + /** Whether two targets are the same page or frame; Electron may hand out separate wrappers for one frame. */ -function samePageTarget(left: PageExecutionTarget | undefined, right: PageExecutionTarget): boolean { +function samePageTarget( + left: PageExecutionTarget | undefined, + right: PageExecutionTarget +): boolean { if (left === right) return true - return left !== undefined && 'routingId' in left && 'routingId' in right && sameWebFrame(left, right) + return ( + left !== undefined && 'routingId' in left && 'routingId' in right && sameWebFrame(left, right) + ) } type BrowserActionOutcome = { status: 'pending' } | { status: 'acknowledged'; result: unknown } @@ -334,7 +343,7 @@ interface DriverScopeState { /** Monotonic ref floor shared by every frame in this browser scope. */ nextElementRefId: number /** Every ref this scope issued and its frame: a later snapshot may reuse it there, and an action may still name it. */ - issuedRefTargets: Map + issuedRefTargets: LRUCache /** Invalidates captures that finish after a tab/navigation/timeout race. */ snapshotCaptureEpoch: number /** Cancels a timed-out multi-step action before any later native dispatch. */ @@ -376,7 +385,7 @@ function createDriverScopeState(): DriverScopeState { snapshotTabId: null, snapshotTargets: new Map(), nextElementRefId: 0, - issuedRefTargets: new Map(), + issuedRefTargets: new LRUCache({ max: ISSUED_REF_MEMORY }), snapshotCaptureEpoch: 0, toolExecutionEpoch: 0, } @@ -1789,9 +1798,7 @@ function elementTarget(elementId: number): PageExecutionTarget | undefined { const contents = session.automationTab()?.view.webContents if (!issued || !contents || contents.isDestroyed()) return undefined if (!('routingId' in issued)) return issued === contents ? contents : undefined - return contents.mainFrame.framesInSubtree.find( - (candidate) => !candidate.isDestroyed() && sameWebFrame(candidate, issued) - ) + return liveFrameInTab(contents, issued) } function pageTargetForElement(elementId: number): PageExecutionTarget { @@ -1905,6 +1912,13 @@ function focusedPageTarget(contents: WebContents): PageExecutionTarget { return focused && focused !== contents.mainFrame && !focused.isDestroyed() ? focused : contents } +/** The tab's current wrapper for a frame, if the frame is still live in that tab. */ +function liveFrameInTab(contents: WebContents, frame: WebFrameMain): WebFrameMain | undefined { + return contents.mainFrame.framesInSubtree.find( + (candidate) => !candidate.isDestroyed() && sameWebFrame(candidate, frame) + ) +} + function sameWebFrame(left: WebFrameMain, right: WebFrameMain): boolean { if (left === right) return true if (Number.isSafeInteger(left.frameTreeNodeId) && Number.isSafeInteger(right.frameTreeNodeId)) { @@ -1927,11 +1941,7 @@ function assertFocusedTargetUnchanged( expectedFrameNavigationEpoch?: number ): void { const focused = focusedPageTarget(contents) - const unchanged = - target === contents - ? focused === contents - : focused !== contents && sameWebFrame(focused as WebFrameMain, target as WebFrameMain) - if (!unchanged) { + if (!samePageTarget(focused, target)) { throw new ToolError( 'Focus moved to a different page or frame before the keystroke. No retry was attempted; inspect the page first.' ) @@ -1943,7 +1953,7 @@ function assertFocusedTargetUnchanged( frame.detached || (expectedFrameNavigationEpoch !== undefined && frameNavigationEpoch(contents, frame) !== expectedFrameNavigationEpoch) || - !contents.mainFrame.framesInSubtree.some((candidate) => sameWebFrame(candidate, frame)) + !liveFrameInTab(contents, frame) ) { throw new ToolError( 'The focused frame navigated or was replaced before the keystroke. Inspect the page before continuing.' @@ -2480,10 +2490,8 @@ async function captureSnapshot( ) { throw new ToolError('The page returned an incomplete snapshot. Try browser_snapshot again.') } - const mainRefs = validateSnapshotRefs( - mainSnapshot, - mainStartingElementId, - (ref) => samePageTarget(state.issuedRefTargets.get(ref), contents) + const mainRefs = validateSnapshotRefs(mainSnapshot, mainStartingElementId, (ref) => + samePageTarget(state.issuedRefTargets.get(ref), contents) ) if (!mainRefs) { throw new ToolError('The page returned invalid element ids. Try browser_snapshot again.') @@ -2545,10 +2553,8 @@ async function captureSnapshot( unreadableCrossOriginFrames++ continue } - const frameRefs = validateSnapshotRefs( - frameSnapshot, - frameStartingElementId, - (ref) => samePageTarget(state.issuedRefTargets.get(ref), frame) + const frameRefs = validateSnapshotRefs(frameSnapshot, frameStartingElementId, (ref) => + samePageTarget(state.issuedRefTargets.get(ref), frame) ) if (!frameRefs) { unreadableCrossOriginFrames++ @@ -5390,7 +5396,7 @@ export async function executeTool( actionOutcome = outcome } ) - if (observesAfterAction(tool, params)) { + if (observeRequest(tool, params) !== undefined) { actionOutcome = { status: 'acknowledged', result } } return result @@ -5417,7 +5423,7 @@ export async function executeTool( tool === 'browser_snapshot' || tool === 'browser_open_url' || tool === 'browser_find' || - observesAfterAction(tool, params) + observeRequest(tool, params) !== undefined ) { invalidateSnapshot(state) } @@ -5427,7 +5433,7 @@ export async function executeTool( observedResult = await guardedExecution } catch (error) { if (!actionOutcome) { - if (observesAfterAction(tool, params)) cancelPendingSnapshotCapture(state) + if (observeRequest(tool, params) !== undefined) cancelPendingSnapshotCapture(state) throw error } invalidateSnapshot(state) diff --git a/apps/desktop/src/main/browser-agent/page-functions.test.ts b/apps/desktop/src/main/browser-agent/page-functions.test.ts index 9db4f3de135..38eec7d00ba 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.test.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.test.ts @@ -353,13 +353,17 @@ describe('combobox typing surfaces', () => { }) it('types into a top-page field a promo card covers, naming the cover', () => { - document.body.innerHTML = '
    Shop for your home
    ' + document.body.innerHTML = + '
    Shop for your home
    ' const input = visible(document.querySelector('input') as HTMLInputElement) const card = visible(document.querySelector('div') as HTMLDivElement) register(input) Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: () => card }) - expect(focusElementForTyping(0)).toMatchObject({ focused: true, coveredBy: 'Shop for your home' }) + expect(focusElementForTyping(0)).toMatchObject({ + focused: true, + coveredBy: 'Shop for your home', + }) expect(typeIntoElement(0, 'cooking pot', false)).toMatchObject({ dispatched: true }) expect(input.value).toBe('cooking pot') }) @@ -705,7 +709,8 @@ describe('collectSnapshot', () => { const menu = visible(document.querySelector('[role="button"]') as HTMLDivElement) const ref = refFor(outlineOf(collectSnapshot()), 'Products ▾') - ;(menu.querySelector('ul') as HTMLUListElement).innerHTML = '
  • Starter
  • Business
  • ' + ;(menu.querySelector('ul') as HTMLUListElement).innerHTML = + '
  • Starter
  • Business
  • ' expect(clickElement(ref)).toMatchObject({ dispatched: true }) }) diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts index 5b10ebcff4c..f954940f8e0 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.ts @@ -54,10 +54,13 @@ declare global { observedRoots: WeakSet }> __simAgentNextElementId?: number - /** Each element's ref and the identity it was issued for, so a later snapshot keeps the ref. */ - __simAgentIssuedRefs?: WeakMap - /** Refs issued by earlier snapshots of this document, resolvable while their element lives. */ - __simAgentEarlierRefs?: Map; locator: SnapshotRefLocator }> + /** Every ref this document issued, resolvable while its element keeps that identity. */ + __simAgentIssuedRefs?: Map< + number, + { element: WeakRef; locator: SnapshotRefLocator; namespace: number } + > + /** The ref each element was last issued, so an unchanged element keeps it. */ + __simAgentRefOfElement?: WeakMap /** Elements an earlier snapshot of this document listed; a later snapshot marks the rest `new`. */ __simAgentShownElements?: WeakSet /** Installed by {@link installPageHelpers} before every page function; cached per call. */ @@ -596,17 +599,13 @@ export function collectSnapshot( return '' } - // A ref the model already holds stays valid while its element keeps the - // identity it was issued for; a changed or replaced element gets a new ref, - // so an id never comes to mean a different element. Refs are reused only - // within the browser scope (refNamespace) that issued them. - const issuedRefs = window.__simAgentIssuedRefs ?? new WeakMap() + // An unchanged element keeps its ref, and a ref stays resolvable after a + // later snapshot leaves it out; a changed element gets a new ref. + const issuedRefs = window.__simAgentIssuedRefs ?? new Map() window.__simAgentIssuedRefs = issuedRefs - // A ref from an earlier snapshot still resolves while its element lives, - // even when a later scoped, query, or modal-covered snapshot left it out. - const earlierRefs = window.__simAgentEarlierRefs ?? new Map() - window.__simAgentEarlierRefs = earlierRefs - const earlierRefCap = 5000 + const refOfElement = window.__simAgentRefOfElement ?? new WeakMap() + window.__simAgentRefOfElement = refOfElement + const issuedRefCap = 5000 const registerElement = (el: Element, role: string, name: string): number => { const locator = { url: pageUrlFor(el), @@ -617,22 +616,28 @@ export function collectSnapshot( ancestor: ancestorSignature(el), context: contextSignature(el), } - const identity = JSON.stringify([locator.tag, locator.role, locator.name, locator.attributes]) - const issued = issuedRefs.get(el) + const priorId = refOfElement.get(el) + const prior = priorId === undefined ? undefined : issuedRefs.get(priorId) + const unchanged = + prior !== undefined && + prior.namespace === refNamespace && + prior.locator.tag === locator.tag && + prior.locator.role === locator.role && + prior.locator.name === locator.name && + Object.keys({ ...prior.locator.attributes, ...locator.attributes }).every( + (key) => prior.locator.attributes[key] === locator.attributes[key] + ) const id = - issued && - issued.namespace === refNamespace && - issued.identity === identity && - registry[issued.id] === undefined - ? issued.id + unchanged && priorId !== undefined && registry[priorId] === undefined + ? priorId : nextElementId++ - issuedRefs.set(el, { id, identity, namespace: refNamespace }) + refOfElement.set(el, id) registry[id] = el locators[id] = locator - earlierRefs.delete(id) - earlierRefs.set(id, { element: new WeakRef(el), locator }) - const oldest = earlierRefs.keys().next() - if (earlierRefs.size > earlierRefCap && !oldest.done) earlierRefs.delete(oldest.value) + issuedRefs.delete(id) + issuedRefs.set(id, { element: new WeakRef(el), locator, namespace: refNamespace }) + const oldest = issuedRefs.keys().next() + if (issuedRefs.size > issuedRefCap && !oldest.done) issuedRefs.delete(oldest.value) refCount++ window.__simAgentNextElementId = nextElementId return id @@ -724,8 +729,7 @@ export function collectSnapshot( 160 ) if (!text) return - // Identity uses the name the resolver recomputes, not the displayed text, - // which differs for aria-labelled leaves and past the name's length cap. + // The resolver recomputes nameFor, which differs from the shown text for aria-labelled or long leaves. const id = registerElement(el, roleFor(el), nameFor(el)) textLineCount++ const marker = isNew(el) ? ' new' : '' @@ -889,8 +893,8 @@ export function collectSnapshot( window.__simAgentStaleReason = 'the scoped snapshot root left the DOM' return null } - const earlier = locators[id] ? undefined : earlierRefs.get(id) - const locator = locators[id] ?? earlier?.locator + const issued = issuedRefs.get(id) + const locator = issued?.locator if (!locator) { window.__simAgentStaleReason = `id ${id} is not in the current snapshot's registry` return null @@ -1021,7 +1025,7 @@ export function collectSnapshot( return true } - const current = registry[id] ?? earlier?.element.deref() + const current = registry[id] ?? issued?.element.deref() if (current?.isConnected) { if (!identityMatches(current, true)) { window.__simAgentStaleReason = `the node is still mounted but its identity drifted (now <${String(current.tagName || '').toLowerCase()}> role=${roleFor(current) || 'none'} name="${nameFor(current).slice(0, 60)}")` @@ -1911,12 +1915,8 @@ export function focusElementForTyping(id: number, moveFocus = true): unknown { coveredByRelatedPopup = true } } - // Typing reaches a focused top-page field through the keyboard, never the - // pointer, so an overlay over it (a promo card, a sticky banner) does not - // stop the text, as Playwright's fill does not hit-test either. Modal - // dialogs still refuse: their background is aria-hidden or inert, which the - // visibility and focus checks above reject. Framed fields keep the strict - // check because the driver maps their point through the parent page. + // Top-page typing goes through keyboard focus, so a non-modal overlay does not block it (modals + // fail the aria-hidden/inert checks above); framed fields keep the check for their mapped point. const topPageField = view === window && window.top === window let coveredBy: string | undefined if (!chosenPoint && topPageField && blockedPoints.length > 0) { diff --git a/apps/desktop/src/main/browser-agent/post-action-observation.ts b/apps/desktop/src/main/browser-agent/post-action-observation.ts index 7911214d9e9..f8fa8fa64c1 100644 --- a/apps/desktop/src/main/browser-agent/post-action-observation.ts +++ b/apps/desktop/src/main/browser-agent/post-action-observation.ts @@ -20,12 +20,9 @@ const OBSERVABLE_ACTIONS: ReadonlySet = new Set([ 'browser_wait_for', ]) -/** Waits exist to see what the page became, so they observe even when the model omits observe. */ -const OBSERVES_BY_DEFAULT: ReadonlySet = new Set(['browser_wait_for']) - -/** Whether this call ends with a post-action observation, requested or by default. */ -export function observesAfterAction(tool: BrowserToolName, params: Record): boolean { - return params.observe !== undefined || OBSERVES_BY_DEFAULT.has(tool) +/** The call's post-action observation request; a wait observes by default, to show what the page became. */ +export function observeRequest(tool: BrowserToolName, params: Record): unknown { + return params.observe ?? (tool === 'browser_wait_for' ? {} : undefined) } /** Preserves a dispatched action when its acknowledgement or observation is interrupted. */ @@ -47,7 +44,7 @@ export async function withPostActionObservation( observe: (query: string | undefined) => Promise, assertCurrent: () => void ): Promise { - const request = params.observe ?? (OBSERVES_BY_DEFAULT.has(tool) ? {} : undefined) + const request = observeRequest(tool, params) if (request === undefined) return action(params) if ( !OBSERVABLE_ACTIONS.has(tool) || From b98c1a8d449a0e5d762438d43e6a883ef09a94ef Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 12 Nov 2026 13:20:08 -0800 Subject: [PATCH 09/10] fix(desktop-browser): tighten ref reuse, name growth, and covered typing per review --- apps/desktop/e2e/browser-tools.spec.ts | 2 +- apps/desktop/src/main/browser-agent/driver.ts | 13 ++-- .../main/browser-agent/page-functions.test.ts | 33 +++++++++- .../src/main/browser-agent/page-functions.ts | 61 ++++++++++++++++--- .../post-action-observation.test.ts | 8 +++ .../browser-agent/post-action-observation.ts | 3 +- 6 files changed, 103 insertions(+), 17 deletions(-) diff --git a/apps/desktop/e2e/browser-tools.spec.ts b/apps/desktop/e2e/browser-tools.spec.ts index 92e5e18d429..bf3d323751f 100644 --- a/apps/desktop/e2e/browser-tools.spec.ts +++ b/apps/desktop/e2e/browser-tools.spec.ts @@ -1365,7 +1365,7 @@ test.describe('browser tools', () => { expect(await formState()).toMatchObject({ name: 'Observed value' }) const result = fill.result as { observation: { result: { matches: { elementId: number }[] } } } const freshId = result.observation.result.matches[0].elementId - expect(freshId).not.toBe(ref('Name')) + expect(freshId).toBe(ref('Name')) const clear = await execute('browser_type', { elementId: freshId, text: '', observe: {} }) expect(clear.ok, clear.error).toBe(true) expect(clear.result).toMatchObject({ observation: { ok: true } }) diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index 3968edc8b60..b61a88cf717 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -37,7 +37,6 @@ import { interruptibleSleep, sleep } from '@sim/utils/helpers' import { isRecordLike, omit, toArray, toRecord } from '@sim/utils/object' import type { BrowserWindow, MenuItemConstructorOptions, WebContents, WebFrameMain } from 'electron' import { Menu } from 'electron' -import { LRUCache } from 'lru-cache' import * as cdp from '@/main/browser-agent/cdp' import { steppedZoomFactor, zoomPercentOf } from '@/main/browser-agent/context-menu' import { ToolError } from '@/main/browser-agent/errors' @@ -146,9 +145,6 @@ function isBrowserWaitElementState(value: string): value is BrowserWaitElementSt type PageExecutionTarget = WebContents | WebFrameMain -/** Matches the page's own memory of issued refs; an evicted ref simply gets a new id. */ -const ISSUED_REF_MEMORY = 5000 - /** Whether two targets are the same page or frame; Electron may hand out separate wrappers for one frame. */ function samePageTarget( left: PageExecutionTarget | undefined, @@ -343,7 +339,7 @@ interface DriverScopeState { /** Monotonic ref floor shared by every frame in this browser scope. */ nextElementRefId: number /** Every ref this scope issued and its frame: a later snapshot may reuse it there, and an action may still name it. */ - issuedRefTargets: LRUCache + issuedRefTargets: Map /** Invalidates captures that finish after a tab/navigation/timeout race. */ snapshotCaptureEpoch: number /** Cancels a timed-out multi-step action before any later native dispatch. */ @@ -385,7 +381,7 @@ function createDriverScopeState(): DriverScopeState { snapshotTabId: null, snapshotTargets: new Map(), nextElementRefId: 0, - issuedRefTargets: new LRUCache({ max: ISSUED_REF_MEMORY }), + issuedRefTargets: new Map(), snapshotCaptureEpoch: 0, toolExecutionEpoch: 0, } @@ -2608,6 +2604,11 @@ async function captureSnapshot( (targetLineIndexes.get(elementId) ?? Number.POSITIVE_INFINITY) < visibleOutlineLines.length ) ) + // Never forget a ref a live page may still hand back (the page itself + // decides when to reissue); only refs of closed tabs and frames go. + for (const [ref, target] of state.issuedRefTargets) { + if ('isDestroyed' in target && target.isDestroyed()) state.issuedRefTargets.delete(ref) + } for (const [ref, target] of targets) state.issuedRefTargets.set(ref, target) state.snapshotTabId = capturedTabId return { diff --git a/apps/desktop/src/main/browser-agent/page-functions.test.ts b/apps/desktop/src/main/browser-agent/page-functions.test.ts index 38eec7d00ba..7f30104ff9d 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.test.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.test.ts @@ -368,6 +368,17 @@ describe('combobox typing surfaces', () => { expect(input.value).toBe('cooking pot') }) + it('still refuses a top-page field a modal dialog covers', () => { + document.body.innerHTML = + '

    Sign in

    ' + const input = visible(document.querySelector('input') as HTMLInputElement) + const cover = visible(document.querySelector('p') as HTMLParagraphElement) + register(input) + Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: () => cover }) + + expect(focusElementForTyping(0)).toMatchObject({ error: 'obstructed' }) + }) + it('still refuses a covered field inside a frame', () => { const frame = document.createElement('iframe') document.body.append(frame) @@ -705,7 +716,8 @@ describe('collectSnapshot', () => { }) it('keeps a connected ref whose name grew when its own submenu opened', () => { - document.body.innerHTML = '
    Products ▾
      ' + document.body.innerHTML = + '
      Products ▾
        ' const menu = visible(document.querySelector('[role="button"]') as HTMLDivElement) const ref = refFor(outlineOf(collectSnapshot()), 'Products ▾') @@ -732,6 +744,25 @@ describe('collectSnapshot', () => { expect(clickElement(ref)).toMatchObject({ dispatched: true }) }) + it('invalidates a recycled row whose new name merely extends the old one', () => { + document.body.innerHTML = '
        Alice
        ' + const row = visible(document.querySelector('[role="listitem"]') as HTMLDivElement) + const ref = refFor(outlineOf(collectSnapshot()), 'Alice') + + row.textContent = 'Alice Smith' + + expect(clickElement(ref)).toMatchObject({ error: 'stale' }) + }) + + it('gives a row control a new ref when its row now shows a different record', () => { + document.body.innerHTML = '
        Order 1
        ' + for (const el of Array.from(document.querySelectorAll('span, button'))) visible(el) + const first = refFor(outlineOf(collectSnapshot()), 'Delete') + ;(document.querySelector('span') as HTMLSpanElement).textContent = 'Order 2' + + expect(refFor(outlineOf(collectSnapshot()), 'Delete')).not.toBe(first) + }) + it('invalidates a connected ref whose name changed to a longer, different word', () => { document.body.innerHTML = '
        Item 1
        ' const row = visible(document.querySelector('[role="listitem"]') as HTMLDivElement) diff --git a/apps/desktop/src/main/browser-agent/page-functions.ts b/apps/desktop/src/main/browser-agent/page-functions.ts index f954940f8e0..1fe3dc93da3 100644 --- a/apps/desktop/src/main/browser-agent/page-functions.ts +++ b/apps/desktop/src/main/browser-agent/page-functions.ts @@ -37,6 +37,8 @@ interface SnapshotRefLocator { attributes: Record ancestor: string context: string + /** Hash of the element's DOM text, to tell revealed content from replaced content. */ + textHash: number } declare global { @@ -606,6 +608,14 @@ export function collectSnapshot( const refOfElement = window.__simAgentRefOfElement ?? new WeakMap() window.__simAgentRefOfElement = refOfElement const issuedRefCap = 5000 + const textHashOf = (el: Element): number => { + const text = el.textContent || '' + let hash = 0 + for (let index = 0; index < text.length; index++) { + hash = (hash * 31 + text.charCodeAt(index)) | 0 + } + return hash + } const registerElement = (el: Element, role: string, name: string): number => { const locator = { url: pageUrlFor(el), @@ -615,6 +625,7 @@ export function collectSnapshot( attributes: locatorAttributes(el), ancestor: ancestorSignature(el), context: contextSignature(el), + textHash: textHashOf(el), } const priorId = refOfElement.get(el) const prior = priorId === undefined ? undefined : issuedRefs.get(priorId) @@ -624,6 +635,8 @@ export function collectSnapshot( prior.locator.tag === locator.tag && prior.locator.role === locator.role && prior.locator.name === locator.name && + prior.locator.ancestor === locator.ancestor && + prior.locator.context === locator.context && Object.keys({ ...prior.locator.attributes, ...locator.attributes }).every( (key) => prior.locator.attributes[key] === locator.attributes[key] ) @@ -889,7 +902,10 @@ export function collectSnapshot( * real stale ref, never permission to click something nearby. */ window.__simAgentResolveElement = (id: number, allowRecovery = true) => { - if (scopedRoot && !scopedRoot.isConnected) { + // A ref from an earlier snapshot resolves only to its own live node: no + // lookalike recovery, and the latest snapshot's scope does not apply to it. + const inLatestSnapshot = locators[id] !== undefined + if (inLatestSnapshot && scopedRoot && !scopedRoot.isConnected) { window.__simAgentStaleReason = 'the scoped snapshot root left the DOM' return null } @@ -937,12 +953,22 @@ export function collectSnapshot( ) { return false } - // A still-mounted control whose own menu or disclosure opened (often by - // the hover or click being validated) appends its new content to its - // name; a recycled row replaces the name instead. + // A mounted control whose own menu opened (often by the hover or click + // being validated) appends that menu to its name: either hidden content + // was revealed (its DOM text is unchanged) or it declares a popup. A + // recycled row changes its DOM text with neither. const name = nameFor(candidate) + const opensPopup = + textHashOf(candidate) === locator.textHash || + ['aria-haspopup', 'aria-expanded', 'aria-controls'].some( + (attribute) => (candidate.getAttribute(attribute) || 'false') !== 'false' + ) || + candidate.querySelector( + '[role="menu"], [role="menubar"], [role="listbox"], [role="tooltip"], [role="dialog"], [role="tree"]' + ) !== null const nameGrewInPlace = connected && + opensPopup && locator.name.length > 0 && name.startsWith(locator.name) && (locator.name.length >= 120 || /\s/.test(name.charAt(locator.name.length))) @@ -1037,7 +1063,7 @@ export function collectSnapshot( } } - if (!allowRecovery) { + if (!allowRecovery || !inLatestSnapshot) { window.__simAgentStaleReason = 'the original snapshot node is detached or hidden' return null } @@ -1915,11 +1941,30 @@ export function focusElementForTyping(id: number, moveFocus = true): unknown { coveredByRelatedPopup = true } } - // Top-page typing goes through keyboard focus, so a non-modal overlay does not block it (modals - // fail the aria-hidden/inert checks above); framed fields keep the check for their mapped point. + // Top-page typing goes through keyboard focus, so a non-modal overlay does not block it; a modal + // or dialog cover still refuses, and framed fields keep the check for their mapped point. const topPageField = view === window && window.top === window + const insideModal = (node: Element | null): boolean => { + for (let current = node; current; current = composedParent(current)) { + const role = current.getAttribute('role') + if ( + current.getAttribute('aria-modal') === 'true' || + role === 'dialog' || + role === 'alertdialog' || + tagFor(current) === 'DIALOG' + ) { + return true + } + } + return false + } let coveredBy: string | undefined - if (!chosenPoint && topPageField && blockedPoints.length > 0) { + if ( + !chosenPoint && + topPageField && + blockedPoints.length > 0 && + !blockedPoints.some((point) => insideModal(point.hit)) + ) { chosenPoint = { x: blockedPoints[0].x, y: blockedPoints[0].y } coveredBy = blockerLabel(firstBlocker) } diff --git a/apps/desktop/src/main/browser-agent/post-action-observation.test.ts b/apps/desktop/src/main/browser-agent/post-action-observation.test.ts index 77c95c027cd..cff8f98322a 100644 --- a/apps/desktop/src/main/browser-agent/post-action-observation.test.ts +++ b/apps/desktop/src/main/browser-agent/post-action-observation.test.ts @@ -22,6 +22,14 @@ describe('post-action observation', () => { }) }) + it('rejects an explicit null observe on a wait instead of treating it as omitted', async () => { + const action = vi.fn(async () => ({ found: true })) + await expect( + withPostActionObservation('browser_wait_for', { observe: null }, action, vi.fn(), vi.fn()) + ).rejects.toThrow('observe must be') + expect(action).not.toHaveBeenCalled() + }) + it('observes after the action, preserving partial form results and replacing refs', async () => { const order: string[] = [] const result = await withPostActionObservation( diff --git a/apps/desktop/src/main/browser-agent/post-action-observation.ts b/apps/desktop/src/main/browser-agent/post-action-observation.ts index f8fa8fa64c1..39a6df35d79 100644 --- a/apps/desktop/src/main/browser-agent/post-action-observation.ts +++ b/apps/desktop/src/main/browser-agent/post-action-observation.ts @@ -22,7 +22,8 @@ const OBSERVABLE_ACTIONS: ReadonlySet = new Set([ /** The call's post-action observation request; a wait observes by default, to show what the page became. */ export function observeRequest(tool: BrowserToolName, params: Record): unknown { - return params.observe ?? (tool === 'browser_wait_for' ? {} : undefined) + if (params.observe !== undefined) return params.observe + return tool === 'browser_wait_for' ? {} : undefined } /** Preserves a dispatched action when its acknowledgement or observation is interrupted. */ From a49506ebfdc9ca6a2b14b822d0eca161eb49ec27 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 12 Nov 2026 13:29:03 -0800 Subject: [PATCH 10/10] fix(desktop-browser): bound remembered refs per page above the page's own store --- .../src/main/browser-agent/driver.test.ts | 31 +++++++++++++++ apps/desktop/src/main/browser-agent/driver.ts | 39 ++++++++++++++++--- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts index 4f13cb477cf..2bac3457ae9 100644 --- a/apps/desktop/src/main/browser-agent/driver.test.ts +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -3121,6 +3121,37 @@ describe('credential protection', () => { expect(forged).toMatchObject({ ok: false }) }) + it("forgets a page's oldest refs once its history passes the retention bound", async () => { + const contents = await openPage() + const snapshot = (refIds: number[], nextElementId: number) => ({ + url: 'https://example.com/', + title: 'Example', + outline: refIds.map((ref) => `- button "B${ref}" [ref=${ref}]`).join('\n'), + truncated: false, + refIds, + refLineIndexes: Object.fromEntries(refIds.map((ref, index) => [ref, index])), + nextElementId, + }) + let next = 0 + for (let round = 0; round < 19; round++) { + const refIds = Array.from({ length: 300 }, (_, index) => next + index) + next += 300 + respondWith(contents, { collectSnapshot: snapshot(refIds, next) }) + expect(await driver.executeTool('chat-test', 'browser_snapshot', {})).toMatchObject({ + ok: true, + }) + } + respondWith(contents, { collectSnapshot: snapshot([next - 1], next) }) + expect(await driver.executeTool('chat-test', 'browser_snapshot', {})).toMatchObject({ + ok: true, + }) + respondWith(contents, { collectSnapshot: snapshot([0], next) }) + + expect(await driver.executeTool('chat-test', 'browser_snapshot', {})).toMatchObject({ + ok: false, + }) + }) + it('acts on a ref an earlier snapshot of the same page issued', async () => { const contents = await openPage() respondWith(contents, { diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index b61a88cf717..da2a3b8918e 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -145,6 +145,13 @@ function isBrowserWaitElementState(value: string): value is BrowserWaitElementSt type PageExecutionTarget = WebContents | WebFrameMain +/** + * Refs remembered per page or frame. Above the page's own 5000-ref store by + * one snapshot's 300 refs, and refreshed by the same snapshots, so the driver + * never forgets a ref the page can still hand back. + */ +const ISSUED_REFS_PER_TARGET = 5300 + /** Whether two targets are the same page or frame; Electron may hand out separate wrappers for one frame. */ function samePageTarget( left: PageExecutionTarget | undefined, @@ -340,6 +347,8 @@ interface DriverScopeState { nextElementRefId: number /** Every ref this scope issued and its frame: a later snapshot may reuse it there, and an action may still name it. */ issuedRefTargets: Map + /** The same refs per page or frame, oldest first, so each page's history stays bounded. */ + issuedRefsByTarget: Map> /** Invalidates captures that finish after a tab/navigation/timeout race. */ snapshotCaptureEpoch: number /** Cancels a timed-out multi-step action before any later native dispatch. */ @@ -382,6 +391,7 @@ function createDriverScopeState(): DriverScopeState { snapshotTargets: new Map(), nextElementRefId: 0, issuedRefTargets: new Map(), + issuedRefsByTarget: new Map(), snapshotCaptureEpoch: 0, toolExecutionEpoch: 0, } @@ -1781,6 +1791,25 @@ function requireSnapshotForElementAction(): void { ) } +function rememberIssuedRef( + state: DriverScopeState, + ref: number, + target: PageExecutionTarget +): void { + const previous = state.issuedRefTargets.get(ref) + if (previous && previous !== target) state.issuedRefsByTarget.get(previous)?.delete(ref) + state.issuedRefTargets.set(ref, target) + const refs = state.issuedRefsByTarget.get(target) ?? new Set() + state.issuedRefsByTarget.set(target, refs) + refs.delete(ref) + refs.add(ref) + const oldest = refs.values().next() + if (refs.size > ISSUED_REFS_PER_TARGET && !oldest.done) { + refs.delete(oldest.value) + state.issuedRefTargets.delete(oldest.value) + } +} + /** * The frame that owns a ref: the latest snapshot's, or for a ref an earlier * snapshot issued, its frame while that frame still belongs to the tab the @@ -2604,12 +2633,12 @@ async function captureSnapshot( (targetLineIndexes.get(elementId) ?? Number.POSITIVE_INFINITY) < visibleOutlineLines.length ) ) - // Never forget a ref a live page may still hand back (the page itself - // decides when to reissue); only refs of closed tabs and frames go. - for (const [ref, target] of state.issuedRefTargets) { - if ('isDestroyed' in target && target.isDestroyed()) state.issuedRefTargets.delete(ref) + for (const [target, refs] of state.issuedRefsByTarget) { + if (!('isDestroyed' in target && target.isDestroyed())) continue + for (const ref of refs) state.issuedRefTargets.delete(ref) + state.issuedRefsByTarget.delete(target) } - for (const [ref, target] of targets) state.issuedRefTargets.set(ref, target) + for (const [ref, target] of targets) rememberIssuedRef(state, ref, target) state.snapshotTabId = capturedTabId return { ...omit(mainSnapshot, ['refIds', 'refLineIndexes', 'nextElementId']),