Skip to content

Commit 91c5fb3

Browse files
authored
feat(desktop): improve semantic browser tool reliability (#7494)
* fix(desktop): expose browser control state to agents * feat(desktop): add semantic browser controls * fix(desktop): harden semantic browser control edge cases * feat(browser): add scoped observations and shared wait budgets * fix(browser): preserve observation identity and control semantics
1 parent e2c42c5 commit 91c5fb3

16 files changed

Lines changed: 2364 additions & 223 deletions

File tree

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

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -501,18 +501,25 @@ describe('browser-agent screenshot capture', () => {
501501
return Promise.resolve(undefined)
502502
})
503503
const resized = {
504+
getSize: vi.fn(() => ({ width: 1024, height: 512 })),
504505
toJPEG: vi.fn(() => Buffer.from('resized')),
505506
}
507+
const cropped = {
508+
getSize: vi.fn(() => ({ width: 400, height: 200 })),
509+
resize: vi.fn(() => resized),
510+
toJPEG: vi.fn(() => Buffer.from('cropped')),
511+
}
506512
// Shared module-level mock: without this, a later fixture reads the
507513
// earlier test's decoded image.
508514
vi.mocked(nativeImage.createFromBuffer).mockReset()
509515
vi.mocked(nativeImage.createFromBuffer).mockReturnValue({
510516
isEmpty: vi.fn(() => imageSize === null),
511517
getSize: vi.fn(() => imageSize ?? { width: 0, height: 0 }),
518+
crop: vi.fn(() => cropped),
512519
resize: vi.fn(() => resized),
513520
toJPEG: vi.fn(() => Buffer.alloc(0)),
514521
} as unknown as ReturnType<typeof nativeImage.createFromBuffer>)
515-
return { contents, resized }
522+
return { contents, resized, cropped }
516523
}
517524

518525
function screenshotParams(contents: WebContents): Record<string, unknown> {
@@ -531,6 +538,23 @@ describe('browser-agent screenshot capture', () => {
531538
expect(screenshotParams(contents)).not.toHaveProperty('clip')
532539
})
533540

541+
it('crops the decoded image in memory without sending a CDP clip', async () => {
542+
const { contents, cropped } = captureFixture({ width: 4096, height: 2048 })
543+
544+
const shot = await captureScreenshot(contents, { x: 100, y: 50, width: 200, height: 100 })
545+
546+
const image = vi.mocked(nativeImage.createFromBuffer).mock.results[0].value
547+
expect(screenshotParams(contents)).not.toHaveProperty('clip')
548+
expect(image.crop).toHaveBeenCalledWith({ x: 200, y: 100, width: 400, height: 200 })
549+
expect(cropped.resize).not.toHaveBeenCalled()
550+
expect(shot).toEqual({
551+
dataUrl: `data:image/jpeg;base64,${Buffer.from('cropped').toString('base64')}`,
552+
scale: 2,
553+
viewport: { width: 2048, height: 1024 },
554+
imageSize: { width: 400, height: 200 },
555+
})
556+
})
557+
534558
/**
535559
* A 2048px CSS viewport bounded to 1024px is scale 0.5, and the capture
536560
* arrives at device resolution (4096px on a 2x display). The resize is what
@@ -597,6 +621,24 @@ describe('browser-agent screenshot capture', () => {
597621
expect(shot.imageSize).toEqual({ width: 1024, height: 512 })
598622
})
599623

624+
it('refuses element cropping without verified CSS viewport metrics', async () => {
625+
const { contents } = captureFixture({ width: 1024, height: 512 })
626+
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {
627+
if (method === 'Page.getLayoutMetrics') {
628+
return Promise.resolve({ layoutViewport: { clientWidth: 2048, clientHeight: 1024 } })
629+
}
630+
return Promise.resolve(undefined)
631+
})
632+
633+
await expect(
634+
captureScreenshot(contents, { x: 10, y: 10, width: 100, height: 50 })
635+
).rejects.toThrow(/CSS viewport/)
636+
expect(contents.debugger.sendCommand).not.toHaveBeenCalledWith(
637+
'Page.captureScreenshot',
638+
expect.anything()
639+
)
640+
})
641+
600642
it('accepts stable finite scroll offsets around the capture', async () => {
601643
const { contents } = captureFixture({ width: 1024, height: 512 })
602644
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {

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

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,13 @@ export interface ScreenshotCapture {
404404
imageSize: ScreenshotSize | null
405405
}
406406

407+
export interface ScreenshotClip {
408+
x: number
409+
y: number
410+
width: number
411+
height: number
412+
}
413+
407414
function screenshotViewportMetrics(
408415
metrics: {
409416
cssLayoutViewport?: CdpViewport
@@ -461,12 +468,13 @@ function sameScreenshotViewport(
461468
* snapshot capture refuses to scale a visible surface for the same reason.
462469
*
463470
* Bounding resolution therefore happens here instead, on the returned image.
464-
* The output keeps the dimensions the clipped capture produced, so `scale`
465-
* still maps image pixels back to CSS pixels for the coordinate tools
466-
* (cssX = imageX / scale) — including on a 2x display, where an unclipped
467-
* capture arrives at device resolution and this is what brings it back down.
471+
* Optional element crops also happen in memory. Convert output coordinates
472+
* with cssX = (clip?.x ?? 0) + imageX / scale, and the equivalent Y formula.
468473
*/
469-
export async function captureScreenshot(contents: WebContents): Promise<ScreenshotCapture> {
474+
export async function captureScreenshot(
475+
contents: WebContents,
476+
clip?: ScreenshotClip
477+
): Promise<ScreenshotCapture> {
470478
const metrics = await send<{
471479
cssLayoutViewport?: CdpViewport
472480
layoutViewport?: CdpViewport
@@ -478,6 +486,9 @@ export async function captureScreenshot(contents: WebContents): Promise<Screensh
478486
const cssWidth = metrics?.cssLayoutViewport?.clientWidth ?? 0
479487
const cssHeight = metrics?.cssLayoutViewport?.clientHeight ?? 0
480488
const cssViewport = cssWidth > 0 && cssHeight > 0 ? { width: cssWidth, height: cssHeight } : null
489+
if (clip && !cssViewport) {
490+
throw new Error('A CSS viewport is required for element screenshot cropping')
491+
}
481492
const scale =
482493
width > 0 && height > 0 ? Math.min(1, MAX_SCREENSHOT_EDGE / Math.max(width, height)) : 1
483494

@@ -499,8 +510,49 @@ export async function captureScreenshot(contents: WebContents): Promise<Screensh
499510
const image = nativeImage.createFromBuffer(Buffer.from(result.data, 'base64'))
500511
const size = image.isEmpty() ? { width: 0, height: 0 } : image.getSize()
501512
if (size.width === 0 || size.height === 0) {
513+
if (clip) throw new Error('The screenshot could not be decoded for element cropping')
502514
return { dataUrl: captured, scale, viewport: cssViewport, imageSize: null }
503515
}
516+
if (clip && cssViewport) {
517+
const xScale = size.width / cssViewport.width
518+
const yScale = size.height / cssViewport.height
519+
const cropX = Math.max(0, Math.floor(clip.x * xScale))
520+
const cropY = Math.max(0, Math.floor(clip.y * yScale))
521+
const cropRight = Math.min(size.width, Math.ceil((clip.x + clip.width) * xScale))
522+
const cropBottom = Math.min(size.height, Math.ceil((clip.y + clip.height) * yScale))
523+
if (cropRight <= cropX || cropBottom <= cropY) {
524+
throw new Error('The requested screenshot element is outside the current viewport')
525+
}
526+
const cropped = image.crop({
527+
x: cropX,
528+
y: cropY,
529+
width: cropRight - cropX,
530+
height: cropBottom - cropY,
531+
})
532+
const croppedSize = cropped.getSize()
533+
if (croppedSize.width === 0 || croppedSize.height === 0) {
534+
throw new Error('The requested screenshot element produced an empty crop')
535+
}
536+
const cropScale = Math.min(
537+
1,
538+
MAX_SCREENSHOT_EDGE / Math.max(croppedSize.width, croppedSize.height)
539+
)
540+
const output =
541+
cropScale < 1
542+
? cropped.resize({
543+
width: Math.round(croppedSize.width * cropScale),
544+
height: Math.round(croppedSize.height * cropScale),
545+
quality: 'good',
546+
})
547+
: cropped
548+
const outputSize = output.getSize()
549+
return {
550+
dataUrl: `data:image/jpeg;base64,${output.toJPEG(SCREENSHOT_QUALITY).toString('base64')}`,
551+
scale: outputSize.width / clip.width,
552+
viewport: cssViewport,
553+
imageSize: outputSize,
554+
}
555+
}
504556
if (size.width === targetWidth && size.height === targetHeight) {
505557
return { dataUrl: captured, scale, viewport: cssViewport, imageSize: size }
506558
}

0 commit comments

Comments
 (0)