Skip to content

Commit a49506e

Browse files
committed
fix(desktop-browser): bound remembered refs per page above the page's own store
1 parent b98c1a8 commit a49506e

2 files changed

Lines changed: 65 additions & 5 deletions

File tree

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3121,6 +3121,37 @@ describe('credential protection', () => {
31213121
expect(forged).toMatchObject({ ok: false })
31223122
})
31233123

3124+
it("forgets a page's oldest refs once its history passes the retention bound", async () => {
3125+
const contents = await openPage()
3126+
const snapshot = (refIds: number[], nextElementId: number) => ({
3127+
url: 'https://example.com/',
3128+
title: 'Example',
3129+
outline: refIds.map((ref) => `- button "B${ref}" [ref=${ref}]`).join('\n'),
3130+
truncated: false,
3131+
refIds,
3132+
refLineIndexes: Object.fromEntries(refIds.map((ref, index) => [ref, index])),
3133+
nextElementId,
3134+
})
3135+
let next = 0
3136+
for (let round = 0; round < 19; round++) {
3137+
const refIds = Array.from({ length: 300 }, (_, index) => next + index)
3138+
next += 300
3139+
respondWith(contents, { collectSnapshot: snapshot(refIds, next) })
3140+
expect(await driver.executeTool('chat-test', 'browser_snapshot', {})).toMatchObject({
3141+
ok: true,
3142+
})
3143+
}
3144+
respondWith(contents, { collectSnapshot: snapshot([next - 1], next) })
3145+
expect(await driver.executeTool('chat-test', 'browser_snapshot', {})).toMatchObject({
3146+
ok: true,
3147+
})
3148+
respondWith(contents, { collectSnapshot: snapshot([0], next) })
3149+
3150+
expect(await driver.executeTool('chat-test', 'browser_snapshot', {})).toMatchObject({
3151+
ok: false,
3152+
})
3153+
})
3154+
31243155
it('acts on a ref an earlier snapshot of the same page issued', async () => {
31253156
const contents = await openPage()
31263157
respondWith(contents, {

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

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,13 @@ function isBrowserWaitElementState(value: string): value is BrowserWaitElementSt
145145

146146
type PageExecutionTarget = WebContents | WebFrameMain
147147

148+
/**
149+
* Refs remembered per page or frame. Above the page's own 5000-ref store by
150+
* one snapshot's 300 refs, and refreshed by the same snapshots, so the driver
151+
* never forgets a ref the page can still hand back.
152+
*/
153+
const ISSUED_REFS_PER_TARGET = 5300
154+
148155
/** Whether two targets are the same page or frame; Electron may hand out separate wrappers for one frame. */
149156
function samePageTarget(
150157
left: PageExecutionTarget | undefined,
@@ -340,6 +347,8 @@ interface DriverScopeState {
340347
nextElementRefId: number
341348
/** Every ref this scope issued and its frame: a later snapshot may reuse it there, and an action may still name it. */
342349
issuedRefTargets: Map<number, PageExecutionTarget>
350+
/** The same refs per page or frame, oldest first, so each page's history stays bounded. */
351+
issuedRefsByTarget: Map<PageExecutionTarget, Set<number>>
343352
/** Invalidates captures that finish after a tab/navigation/timeout race. */
344353
snapshotCaptureEpoch: number
345354
/** Cancels a timed-out multi-step action before any later native dispatch. */
@@ -382,6 +391,7 @@ function createDriverScopeState(): DriverScopeState {
382391
snapshotTargets: new Map(),
383392
nextElementRefId: 0,
384393
issuedRefTargets: new Map(),
394+
issuedRefsByTarget: new Map(),
385395
snapshotCaptureEpoch: 0,
386396
toolExecutionEpoch: 0,
387397
}
@@ -1781,6 +1791,25 @@ function requireSnapshotForElementAction(): void {
17811791
)
17821792
}
17831793

1794+
function rememberIssuedRef(
1795+
state: DriverScopeState,
1796+
ref: number,
1797+
target: PageExecutionTarget
1798+
): void {
1799+
const previous = state.issuedRefTargets.get(ref)
1800+
if (previous && previous !== target) state.issuedRefsByTarget.get(previous)?.delete(ref)
1801+
state.issuedRefTargets.set(ref, target)
1802+
const refs = state.issuedRefsByTarget.get(target) ?? new Set<number>()
1803+
state.issuedRefsByTarget.set(target, refs)
1804+
refs.delete(ref)
1805+
refs.add(ref)
1806+
const oldest = refs.values().next()
1807+
if (refs.size > ISSUED_REFS_PER_TARGET && !oldest.done) {
1808+
refs.delete(oldest.value)
1809+
state.issuedRefTargets.delete(oldest.value)
1810+
}
1811+
}
1812+
17841813
/**
17851814
* The frame that owns a ref: the latest snapshot's, or for a ref an earlier
17861815
* snapshot issued, its frame while that frame still belongs to the tab the
@@ -2604,12 +2633,12 @@ async function captureSnapshot(
26042633
(targetLineIndexes.get(elementId) ?? Number.POSITIVE_INFINITY) < visibleOutlineLines.length
26052634
)
26062635
)
2607-
// Never forget a ref a live page may still hand back (the page itself
2608-
// decides when to reissue); only refs of closed tabs and frames go.
2609-
for (const [ref, target] of state.issuedRefTargets) {
2610-
if ('isDestroyed' in target && target.isDestroyed()) state.issuedRefTargets.delete(ref)
2636+
for (const [target, refs] of state.issuedRefsByTarget) {
2637+
if (!('isDestroyed' in target && target.isDestroyed())) continue
2638+
for (const ref of refs) state.issuedRefTargets.delete(ref)
2639+
state.issuedRefsByTarget.delete(target)
26112640
}
2612-
for (const [ref, target] of targets) state.issuedRefTargets.set(ref, target)
2641+
for (const [ref, target] of targets) rememberIssuedRef(state, ref, target)
26132642
state.snapshotTabId = capturedTabId
26142643
return {
26152644
...omit(mainSnapshot, ['refIds', 'refLineIndexes', 'nextElementId']),

0 commit comments

Comments
 (0)