Skip to content

Commit b26072f

Browse files
committed
Make the browser tools agree with each other
An audit of the module found the frame-descent bug was one instance of a pattern: six independent definitions of 'is this editable' and seven of 'what is focused', disagreeing with each other. A tool refusing what its sibling accepts on identical page state is invisible at runtime — the agent follows a snapshot that says one thing into a tool that says another. - browser_type now accepts role="textbox" like browser_insert_text does. The snapshot advertises those elements as [textbox] with a ref, so refusing them meant rejecting exactly what the outline told the model to type into. Both the native and synthetic paths, and their descendant scans. - pressKeyOnPage descends shadow roots and frames like every other focus reader. It was dispatching synthetic keys at the shadow host or <iframe> element, where they bubble but never reach the editor, while reporting success — and contradicting the activeElement reported beside it. - not-editable and ambiguous-editable name what was found: the element's tag and role, and the candidate fields. Both had the data and discarded it, which is what turns one blocked step into twenty rounds of guessing. - obstructedAfterNavigation requires a dialog that ARRIVED with the navigation. It compared against nothing, so every SPA route change under a persistent role=dialog reported a successful click as obstructed. The test that covered this asserted the false positive; it now pins both directions. - browser_insert_text observes the top document when typing inside a frame, like every other input tool. A submit that navigates the top page was invisible to its frame-scoped observation.
1 parent 4e6e53d commit b26072f

4 files changed

Lines changed: 186 additions & 22 deletions

File tree

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

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1660,7 +1660,11 @@ describe('credential protection', () => {
16601660
expect(mainFrame.executeJavaScript).not.toHaveBeenCalled()
16611661
})
16621662

1663-
it('reports navigation that remains obstructed by a DOM dialog', async () => {
1663+
// A dialog that was ALREADY open before the click is not obstructing the
1664+
// navigation it survived — reporting it made every SPA route change under a
1665+
// persistent role=dialog (cookie banner, side drawer, picker) read as a
1666+
// failed click. Only a dialog that arrives with the navigation obstructs it.
1667+
it('ignores a dialog that was already open before the click', async () => {
16641668
const contents = await openPage()
16651669
let actionReads = 0
16661670
vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => {
@@ -1695,13 +1699,52 @@ describe('credential protection', () => {
16951699

16961700
const result = await driver.executeTool('chat-test', 'browser_click', { elementId: 0 })
16971701

1702+
expect(result).toMatchObject({
1703+
ok: true,
1704+
result: { effectObserved: true, obstructedAfterNavigation: false, dialogs: ['Search'] },
1705+
})
1706+
})
1707+
1708+
it('reports navigation obstructed by a dialog that opened with it', async () => {
1709+
const contents = await openPage()
1710+
let actionReads = 0
1711+
vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => {
1712+
if (isPageCall(expression, 'clickElement')) {
1713+
return Promise.resolve({ dispatched: false, x: 24, y: 48, element: 'Search result' })
1714+
}
1715+
if (isPageCall(expression, 'readActiveElementState')) return Promise.resolve({})
1716+
if (isPageCall(expression, 'readPageActionState')) {
1717+
actionReads++
1718+
return Promise.resolve(
1719+
actionReads === 1
1720+
? {
1721+
url: 'https://example.com/search',
1722+
title: 'Search',
1723+
focus: 'body',
1724+
mutationRevision: 0,
1725+
dialogs: [],
1726+
scroll: [0],
1727+
}
1728+
: {
1729+
url: 'https://example.com/channel/eng-bugs',
1730+
title: 'eng-bugs',
1731+
focus: 'body',
1732+
mutationRevision: 1,
1733+
dialogs: ['Open in the Slack app?'],
1734+
scroll: [0],
1735+
}
1736+
)
1737+
}
1738+
return Promise.resolve(undefined)
1739+
})
1740+
1741+
const result = await driver.executeTool('chat-test', 'browser_click', { elementId: 0 })
1742+
16981743
expect(result).toMatchObject({
16991744
ok: true,
17001745
result: {
1701-
effectObserved: true,
17021746
obstructedAfterNavigation: true,
1703-
dialogs: ['Search'],
1704-
note: expect.stringContaining('dialog is still open'),
1747+
note: expect.stringContaining('Open in the Slack app?'),
17051748
},
17061749
})
17071750
})

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

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -892,16 +892,29 @@ function unwrapPageResult(result: unknown): unknown {
892892
)
893893
}
894894
if (code === 'not-editable') {
895-
throw new ToolError('That element is not a text input — pick an editable element.')
895+
const tag = isRecordLike(result) ? String(result.elementTag ?? '') : ''
896+
const role = isRecordLike(result) ? String(result.elementRole ?? '') : ''
897+
const described = [tag ? `<${tag}>` : '', role ? `role="${role}"` : '']
898+
.filter(Boolean)
899+
.join(' ')
900+
throw new ToolError(
901+
`That element is not a text input${described ? ` (it is ${described})` : ''} — take a fresh browser_snapshot and target the editable field itself.`
902+
)
896903
}
897904
if (code === 'outside-viewport') {
898905
throw new ToolError(
899906
'That point is outside the visible viewport. Coordinates are CSS pixels within the current viewport — when reading them off a browser_screenshot, divide image pixels by its scale, and scroll the target into view first.'
900907
)
901908
}
902909
if (code === 'ambiguous-editable') {
910+
const candidates =
911+
isRecordLike(result) && Array.isArray(result.candidates)
912+
? result.candidates.map(String).filter(Boolean)
913+
: []
903914
throw new ToolError(
904-
'That composite control contains multiple editable fields. Take a fresh browser_snapshot and target the exact field.'
915+
`That composite control contains multiple editable fields${
916+
candidates.length > 0 ? ` (${candidates.join(', ')})` : ''
917+
}. Take a fresh browser_snapshot and target the exact field.`
905918
)
906919
}
907920
if (code === 'different') {
@@ -2344,10 +2357,21 @@ async function executeToolInner(
23442357
)
23452358
const navigated =
23462359
observation.effect.urlChanged || topObservation.effect.urlChanged || tabChanged
2347-
const obstructedAfterNavigation = navigated && dialogs.length > 0
2360+
// Only a dialog that ARRIVED with the navigation obstructs it. Comparing
2361+
// against the union of what was already open stops the false positive
2362+
// that fires on every SPA route change under a persistent `role=dialog`
2363+
// (a cookie banner, a side drawer, an emoji picker) — those are not
2364+
// blocking anything, and reporting them made successful clicks read as
2365+
// failures.
2366+
const dialogsBefore = new Set([
2367+
...(Array.isArray(beforePage.dialogs) ? beforePage.dialogs.map(String) : []),
2368+
...(Array.isArray(beforeTopPage.dialogs) ? beforeTopPage.dialogs.map(String) : []),
2369+
])
2370+
const newDialogs = dialogs.filter((dialog) => !dialogsBefore.has(dialog))
2371+
const obstructedAfterNavigation = navigated && newDialogs.length > 0
23482372
const notes: string[] = []
23492373
if (obstructedAfterNavigation) {
2350-
notes.push('The page navigated, but a dialog is still open above it.')
2374+
notes.push(`The page navigated, but a dialog opened above it (${newDialogs.join(', ')}).`)
23512375
}
23522376
if (!effectObserved) {
23532377
notes.push(
@@ -3249,6 +3273,12 @@ async function executeToolInner(
32493273
}
32503274
const beforePage = await pageActionState(target, true)
32513275
const beforeElement = await activeElementState(target)
3276+
// Observe the TOP document too when typing inside a frame. A submit that
3277+
// navigates the top page is invisible to a frame-scoped observation, so a
3278+
// successful send reported effectObserved: false. Newly reachable now
3279+
// that the focus check descends into frames at all.
3280+
const insertInFrame = target !== contents
3281+
const beforeTopPage = insertInFrame ? await pageActionState(contents, true) : beforePage
32523282
assertCurrentExecution()
32533283
assertActiveContents(contents, insertNavigationEpoch)
32543284
assertFocusedTargetUnchanged(contents, target)
@@ -3275,11 +3305,16 @@ async function executeToolInner(
32753305
const state = await activeElementState(target)
32763306
const afterPage = await pageActionState(target)
32773307
const observation = pageEffect(beforePage, afterPage, beforeElement, state)
3308+
const topObservation = insertInFrame
3309+
? pageEffect(beforeTopPage, await pageActionState(contents, true), beforeElement, state)
3310+
: observation
32783311
const effectObserved =
32793312
observation.effect.fieldChanged ||
32803313
observation.effect.urlChanged ||
32813314
observation.effect.dialogChanged ||
3282-
observation.effect.targetChanged
3315+
observation.effect.targetChanged ||
3316+
topObservation.effect.urlChanged ||
3317+
topObservation.effect.dialogChanged
32833318
return {
32843319
dispatched: true,
32853320
trusted: true,

apps/desktop/src/main/browser-agent/page-functions.test.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -261,8 +261,11 @@ describe('secret-field detection', () => {
261261
expect(typeIntoElement(0, 'change', false)).toEqual({ error: 'readonly' })
262262
expect(focusElementForTyping(1)).toEqual({ error: 'disabled' })
263263
expect(typeIntoElement(1, 'change', false)).toEqual({ error: 'disabled' })
264-
expect(focusElementForTyping(2)).toEqual({ error: 'not-editable' })
265-
expect(typeIntoElement(2, 'change', false)).toEqual({ error: 'not-editable' })
264+
expect(focusElementForTyping(2)).toMatchObject({ error: 'not-editable', elementTag: 'input' })
265+
expect(typeIntoElement(2, 'change', false)).toMatchObject({
266+
error: 'not-editable',
267+
elementTag: 'input',
268+
})
266269
})
267270

268271
it('detects a password field reached through a same-origin iframe', () => {
@@ -383,7 +386,12 @@ describe('combobox typing surfaces', () => {
383386
for (const input of Array.from(document.querySelectorAll('input'))) visible(input)
384387
register(ambiguous, secret)
385388

386-
expect(focusElementForTyping(0)).toEqual({ error: 'ambiguous-editable' })
389+
// The candidate list is the whole point of this error — it is the only
390+
// thing that lets the agent pick a narrower target.
391+
expect(focusElementForTyping(0)).toMatchObject({
392+
error: 'ambiguous-editable',
393+
candidates: expect.arrayContaining([expect.stringContaining('input')]),
394+
})
387395
expect(focusElementForTyping(1)).toEqual({ error: 'password' })
388396
expect(typeIntoElement(1, 'nope', false)).toEqual({ error: 'password' })
389397
})

apps/desktop/src/main/browser-agent/page-functions.ts

Lines changed: 88 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,7 +1042,7 @@ export function clickElement(
10421042
addCandidate(el)
10431043
for (const candidate of Array.from(
10441044
el.querySelectorAll<HTMLElement>(
1045-
'input, textarea, [contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"]'
1045+
'input, textarea, [contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"], [role="textbox"]'
10461046
)
10471047
)) {
10481048
addCandidate(candidate)
@@ -1250,22 +1250,47 @@ export function focusElementForTyping(id: number, moveFocus = true): unknown {
12501250
tag === 'TEXTAREA' ||
12511251
(tag === 'INPUT' &&
12521252
['text', 'search', 'email', 'url', 'tel', 'number', 'password'].includes(inputType)) ||
1253-
(node as HTMLElement).isContentEditable
1253+
(node as HTMLElement).isContentEditable ||
1254+
// An ARIA-only textbox. The snapshot already advertises these as
1255+
// `[textbox]` with a ref, and browser_insert_text accepts them, so
1256+
// refusing here meant one tool rejecting exactly what the outline told
1257+
// the model to type into and what its sibling would have accepted.
1258+
node.getAttribute('role') === 'textbox'
12541259
) {
12551260
potentialEditables.push(node as HTMLElement)
12561261
}
12571262
}
12581263
addEditable(el)
12591264
for (const candidate of Array.from(
12601265
el.querySelectorAll<HTMLElement>(
1261-
'input, textarea, [contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"]'
1266+
'input, textarea, [contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"], [role="textbox"]'
12621267
)
12631268
)) {
12641269
addEditable(candidate)
12651270
}
12661271
const editables = Array.from(new Set(potentialEditables))
1267-
if (editables.length === 0) return { error: 'not-editable' }
1268-
if (editables.length > 1) return { error: 'ambiguous-editable' }
1272+
if (editables.length === 0) {
1273+
// Describe the element instead of only refusing it. Without this the agent
1274+
// cannot tell "wrong ref" from "this tool cannot type here" and retries
1275+
// variations of the same failing call.
1276+
return {
1277+
error: 'not-editable',
1278+
elementTag: String(el.tagName || '').toLowerCase(),
1279+
...(el.getAttribute('role') ? { elementRole: el.getAttribute('role') } : {}),
1280+
}
1281+
}
1282+
if (editables.length > 1) {
1283+
// The candidate list is right here; discarding it left the agent unable to
1284+
// pick a narrower target, which is the only recovery this error allows.
1285+
return {
1286+
error: 'ambiguous-editable',
1287+
candidates: editables.slice(0, 5).map((field) => {
1288+
const fieldTag = String(field.tagName || '').toLowerCase()
1289+
const label = field.getAttribute('aria-label') || field.getAttribute('placeholder') || ''
1290+
return label ? `${fieldTag} "${label}"` : fieldTag
1291+
}),
1292+
}
1293+
}
12691294
const editable = editables[0]
12701295
const editableTag = tagFor(editable)
12711296

@@ -1776,22 +1801,47 @@ export function typeIntoElement(id: number, text: string, submit: boolean): unkn
17761801
candidateTag === 'TEXTAREA' ||
17771802
(candidateTag === 'INPUT' &&
17781803
['text', 'search', 'email', 'url', 'tel', 'number', 'password'].includes(inputType)) ||
1779-
(node as HTMLElement).isContentEditable
1804+
(node as HTMLElement).isContentEditable ||
1805+
// An ARIA-only textbox. The snapshot already advertises these as
1806+
// `[textbox]` with a ref, and browser_insert_text accepts them, so
1807+
// refusing here meant one tool rejecting exactly what the outline told
1808+
// the model to type into and what its sibling would have accepted.
1809+
node.getAttribute('role') === 'textbox'
17801810
) {
17811811
potentialEditables.push(node as HTMLElement)
17821812
}
17831813
}
17841814
addEditable(el)
17851815
for (const candidate of Array.from(
17861816
el.querySelectorAll<HTMLElement>(
1787-
'input, textarea, [contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"]'
1817+
'input, textarea, [contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"], [role="textbox"]'
17881818
)
17891819
)) {
17901820
addEditable(candidate)
17911821
}
17921822
const editables = Array.from(new Set(potentialEditables))
1793-
if (editables.length === 0) return { error: 'not-editable' }
1794-
if (editables.length > 1) return { error: 'ambiguous-editable' }
1823+
if (editables.length === 0) {
1824+
// Describe the element instead of only refusing it. Without this the agent
1825+
// cannot tell "wrong ref" from "this tool cannot type here" and retries
1826+
// variations of the same failing call.
1827+
return {
1828+
error: 'not-editable',
1829+
elementTag: String(el.tagName || '').toLowerCase(),
1830+
...(el.getAttribute('role') ? { elementRole: el.getAttribute('role') } : {}),
1831+
}
1832+
}
1833+
if (editables.length > 1) {
1834+
// The candidate list is right here; discarding it left the agent unable to
1835+
// pick a narrower target, which is the only recovery this error allows.
1836+
return {
1837+
error: 'ambiguous-editable',
1838+
candidates: editables.slice(0, 5).map((field) => {
1839+
const fieldTag = String(field.tagName || '').toLowerCase()
1840+
const label = field.getAttribute('aria-label') || field.getAttribute('placeholder') || ''
1841+
return label ? `${fieldTag} "${label}"` : fieldTag
1842+
}),
1843+
}
1844+
}
17951845
const editable = editables[0]
17961846
const tag = String(editable.tagName || '').toUpperCase()
17971847
editable.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' })
@@ -1873,9 +1923,37 @@ export function pressKeyOnPage(
18731923
.some((token) => token === 'current-password' || token === 'new-password')
18741924
}
18751925

1876-
const target = (document.activeElement as HTMLElement | null) ?? document.body
1926+
// Descend to what is really focused, like every other focus reader here.
1927+
// Without this the synthetic key lands on the shadow HOST or the <iframe>
1928+
// element: the event bubbles from there but never enters the shadow tree or
1929+
// the frame document, so the editor's keymap never sees it — and the result
1930+
// still reports success. It also made this function's `target` disagree with
1931+
// the `activeElement` reported alongside it in the same tool result.
1932+
let target = (document.activeElement as HTMLElement | null) ?? document.body
1933+
for (let depth = 0; depth < 10; depth++) {
1934+
const shadow = target.shadowRoot
1935+
if (shadow?.activeElement) {
1936+
target = shadow.activeElement as HTMLElement
1937+
continue
1938+
}
1939+
const targetTag = String(target.tagName || '').toUpperCase()
1940+
if (targetTag === 'IFRAME' || targetTag === 'FRAME') {
1941+
try {
1942+
const inner = (target as HTMLIFrameElement).contentDocument
1943+
if (inner?.activeElement && inner.activeElement !== inner.body) {
1944+
target = inner.activeElement as HTMLElement
1945+
continue
1946+
}
1947+
} catch {
1948+
// Cross-origin frame — not inspectable; dispatch to the frame itself.
1949+
}
1950+
}
1951+
break
1952+
}
18771953
// The driver checks focus before taking the trusted CDP path; this covers
18781954
// the synthetic fallback, which is reached independently when CDP is down.
1955+
// Descending first is what makes this guard reach a password field nested in
1956+
// a shadow root or frame, rather than only inspecting the host.
18791957
if (isSecretField(target)) return { error: 'password' }
18801958
const opts = {
18811959
bubbles: true,

0 commit comments

Comments
 (0)