| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent de4464a commit 6029ab6
12 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -597,6 +597,29 @@ describe('browser-agent screenshot capture', () => { | |||
| 597 | 597 | expect(shot.imageSize).toEqual({ width: 1024, height: 512 }) | |
| 598 | 598 | }) | |
| 599 | 599 | ||
| 600 | + it('accepts stable finite scroll offsets around the capture', async () => { | ||
| 601 | + const { contents } = captureFixture({ width: 1024, height: 512 }) | ||
| 602 | + vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => { | ||
| 603 | + if (method === 'Page.getLayoutMetrics') { | ||
| 604 | + return Promise.resolve({ | ||
| 605 | + cssLayoutViewport: { | ||
| 606 | + clientWidth: 2048, | ||
| 607 | + clientHeight: 1024, | ||
| 608 | + pageX: 12, | ||
| 609 | + pageY: 34, | ||
| 610 | + }, | ||
| 611 | + }) | ||
| 612 | + } | ||
| 613 | + if (method === 'Page.captureScreenshot') return Promise.resolve({ data: 'c2lt' }) | ||
| 614 | + return Promise.resolve(undefined) | ||
| 615 | + }) | ||
| 616 | + | ||
| 617 | + await expect(captureScreenshot(contents)).resolves.toMatchObject({ | ||
| 618 | + viewport: { width: 2048, height: 1024 }, | ||
| 619 | + imageSize: { width: 1024, height: 512 }, | ||
| 620 | + }) | ||
| 621 | + }) | ||
| 622 | + | ||
| 600 | 623 | it.each([ | |
| 601 | 624 | [ | |
| 602 | 625 | 'dimensions', | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -501,13 +501,6 @@ export async function captureScreenshot(contents: WebContents): Promise<Screensh | |||
| 501 | 501 | if (size.width === 0 || size.height === 0) { | |
| 502 | 502 | return { dataUrl: captured, scale, viewport: cssViewport, imageSize: null } | |
| 503 | 503 | } | |
| 504 | - // Without layout metrics there is no CSS frame of reference to resize | ||
| 505 | - // against, so the raw capture is the honest answer — the same fallback the | ||
| 506 | - // clipped path took. Its decoded size still lets the driver establish the | ||
| 507 | - // coordinate scale after obtaining the CSS viewport in-page. | ||
| 508 | - if (targetWidth <= 0 || targetHeight <= 0) { | ||
| 509 | - return { dataUrl: captured, scale, viewport: cssViewport, imageSize: size } | ||
| 510 | - } | ||
| 511 | 504 | if (size.width === targetWidth && size.height === targetHeight) { | |
| 512 | 505 | return { dataUrl: captured, scale, viewport: cssViewport, imageSize: size } | |
| 513 | 506 | } | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -2151,8 +2151,11 @@ async function executeToolInner( | |||
| 2151 | 2151 | 'The page changed while its screenshot was being captured. Retry browser_screenshot before using image coordinates.' | |
| 2152 | 2152 | ) | |
| 2153 | 2153 | } | |
| 2154 | - const shot = await cdp.captureScreenshot(contents).catch(() => null) | ||
| 2155 | - if (shot === null) { | ||
| 2154 | + const shot = await cdp.captureScreenshot(contents).catch((error) => { | ||
| 2155 | + logger.warn('Browser screenshot capture failed', { error: getErrorMessage(error) }) | ||
| 2156 | + return null | ||
| 2157 | + }) | ||
| 2158 | + if (!shot) { | ||
| 2156 | 2159 | throw new ToolError( | |
| 2157 | 2160 | 'Could not capture the page. Use browser_snapshot or browser_read_text instead.' | |
| 2158 | 2161 | ) | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -9,6 +9,7 @@ const { | |||
| 9 | 9 | getAsyncToolCall, | |
| 10 | 10 | getRunSegment, | |
| 11 | 11 | completeAsyncToolCall, | |
| 12 | + completeClaimedAsyncToolCall, | ||
| 12 | 13 | completePendingAsyncToolCall, | |
| 13 | 14 | detachAsyncToolCall, | |
| 14 | 15 | publishToolConfirmation, | |
@@ -18,6 +19,7 @@ const { | |||
| 18 | 19 | getAsyncToolCall: vi.fn(), | |
| 19 | 20 | getRunSegment: vi.fn(), | |
| 20 | 21 | completeAsyncToolCall: vi.fn(), | |
| 22 | + completeClaimedAsyncToolCall: vi.fn(), | ||
| 21 | 23 | completePendingAsyncToolCall: vi.fn(), | |
| 22 | 24 | detachAsyncToolCall: vi.fn(), | |
| 23 | 25 | publishToolConfirmation: vi.fn(), | |
@@ -31,6 +33,7 @@ vi.mock('@/lib/copilot/async-runs/repository', () => ({ | |||
| 31 | 33 | getAsyncToolCall, | |
| 32 | 34 | getRunSegment, | |
| 33 | 35 | completeAsyncToolCall, | |
| 36 | + completeClaimedAsyncToolCall, | ||
| 34 | 37 | completePendingAsyncToolCall, | |
| 35 | 38 | detachAsyncToolCall, | |
| 36 | 39 | getClaimedWorkflowExecutionId: (claimedBy?: string | null) => | |
@@ -75,6 +78,7 @@ describe('Copilot Confirm API Route', () => { | |||
| 75 | 78 | workflowId: 'workflow-from-run', | |
| 76 | 79 | }) | |
| 77 | 80 | completeAsyncToolCall.mockResolvedValue(existingRow) | |
| 81 | + completeClaimedAsyncToolCall.mockResolvedValue(existingRow) | ||
| 78 | 82 | completePendingAsyncToolCall.mockResolvedValue(existingRow) | |
| 79 | 83 | detachAsyncToolCall.mockResolvedValue(existingRow) | |
| 80 | 84 | encryptSecret.mockResolvedValue({ encrypted: 'sealed-client-result', iv: 'iv' }) | |
@@ -328,12 +332,95 @@ describe('Copilot Confirm API Route', () => { | |||
| 328 | 332 | expect(response.status).toBe(404) | |
| 329 | 333 | expect(await response.json()).toEqual({ error: 'Pending client tool call not found' }) | |
| 330 | 334 | expect(completePendingAsyncToolCall).toHaveBeenCalledOnce() | |
| 335 | + expect(completeClaimedAsyncToolCall).not.toHaveBeenCalled() | ||
| 331 | 336 | expect(completeAsyncToolCall).not.toHaveBeenCalled() | |
| 332 | 337 | expect(detachAsyncToolCall).not.toHaveBeenCalled() | |
| 333 | 338 | expect(publishToolConfirmation).not.toHaveBeenCalled() | |
| 334 | 339 | } | |
| 335 | 340 | ) | |
| 336 | 341 | ||
| 342 | + it.each([ | ||
| 343 | + ['browser_snapshot', 'desktop-browser'], | ||
| 344 | + ['terminal', 'desktop-terminal'], | ||
| 345 | + ] as const)( | ||
| 346 | + 'settles an indeterminate pending %s result when the exact %s claim wins the race', | ||
| 347 | + async (toolName, claimOwner) => { | ||
| 348 | + getAsyncToolCall.mockResolvedValue({ | ||
| 349 | + ...existingRow, | ||
| 350 | + toolName, | ||
| 351 | + status: 'pending', | ||
| 352 | + }) | ||
| 353 | + completePendingAsyncToolCall.mockResolvedValueOnce(null) | ||
| 354 | + | ||
| 355 | + const response = await POST( | ||
| 356 | + createMockPostRequest({ | ||
| 357 | + toolCallId: 'tool-call-123', | ||
| 358 | + status: 'error', | ||
| 359 | + message: 'untrusted page-exit message', | ||
| 360 | + data: { outcomeUnknown: true, doNotRetry: true, untrusted: 'discard me' }, | ||
| 361 | + }) | ||
| 362 | + ) | ||
| 363 | + | ||
| 364 | + expect(response.status).toBe(200) | ||
| 365 | + expect(completePendingAsyncToolCall).toHaveBeenCalledOnce() | ||
| 366 | + expect(completeClaimedAsyncToolCall).toHaveBeenCalledWith( | ||
| 367 | + { | ||
| 368 | + toolCallId: 'tool-call-123', | ||
| 369 | + status: 'failed', | ||
| 370 | + result: { __sealedClientToolCompletionV1: 'sealed-client-result' }, | ||
| 371 | + error: 'Tool failed', | ||
| 372 | + }, | ||
| 373 | + claimOwner | ||
| 374 | + ) | ||
| 375 | + expect(encryptSecret).toHaveBeenCalledWith(expect.stringContaining('"outcomeUnknown":true')) | ||
| 376 | + expect(encryptSecret).toHaveBeenCalledWith(expect.not.stringContaining('discard me')) | ||
| 377 | + expect(publishToolConfirmation).toHaveBeenCalledOnce() | ||
| 378 | + } | ||
| 379 | + ) | ||
| 380 | + | ||
| 381 | + it('does not publish when another terminal transition wins indeterminate claim reconciliation', async () => { | ||
| 382 | + getAsyncToolCall.mockResolvedValue({ | ||
| 383 | + ...existingRow, | ||
| 384 | + toolName: 'browser_snapshot', | ||
| 385 | + status: 'pending', | ||
| 386 | + }) | ||
| 387 | + completePendingAsyncToolCall.mockResolvedValueOnce(null) | ||
| 388 | + completeClaimedAsyncToolCall.mockResolvedValueOnce(null) | ||
| 389 | + | ||
| 390 | + const response = await POST( | ||
| 391 | + createMockPostRequest({ | ||
| 392 | + toolCallId: 'tool-call-123', | ||
| 393 | + status: 'error', | ||
| 394 | + data: { outcomeUnknown: true, doNotRetry: true }, | ||
| 395 | + }) | ||
| 396 | + ) | ||
| 397 | + | ||
| 398 | + expect(response.status).toBe(404) | ||
| 399 | + expect(completeClaimedAsyncToolCall).toHaveBeenCalledWith(expect.any(Object), 'desktop-browser') | ||
| 400 | + expect(publishToolConfirmation).not.toHaveBeenCalled() | ||
| 401 | + }) | ||
| 402 | + | ||
| 403 | + it('returns 500 without publishing when exact claim reconciliation fails', async () => { | ||
| 404 | + getAsyncToolCall.mockResolvedValue({ | ||
| 405 | + ...existingRow, | ||
| 406 | + toolName: 'browser_snapshot', | ||
| 407 | + status: 'pending', | ||
| 408 | + }) | ||
| 409 | + completePendingAsyncToolCall.mockResolvedValueOnce(null) | ||
| 410 | + completeClaimedAsyncToolCall.mockRejectedValueOnce(new Error('database unavailable')) | ||
| 411 | + | ||
| 412 | + const response = await POST( | ||
| 413 | + createMockPostRequest({ | ||
| 414 | + toolCallId: 'tool-call-123', | ||
| 415 | + status: 'error', | ||
| 416 | + data: { outcomeUnknown: true, doNotRetry: true }, | ||
| 417 | + }) | ||
| 418 | + ) | ||
| 419 | + | ||
| 420 | + expect(response.status).toBe(500) | ||
| 421 | + expect(publishToolConfirmation).not.toHaveBeenCalled() | ||
| 422 | + }) | ||
| 423 | + | ||
| 337 | 424 | it.each(['error', 'cancelled'] as const)( | |
| 338 | 425 | 'rejects a pending retired browser tool %s before a native claim', | |
| 339 | 426 | async (status) => { | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -11,12 +11,14 @@ import { | |||
| 11 | 11 | ASYNC_TOOL_STATUS, | |
| 12 | 12 | type AsyncCompletionData, | |
| 13 | 13 | type AsyncConfirmationStatus, | |
| 14 | + DESKTOP_TOOL_CLAIM_OWNER, | ||
| 14 | 15 | isDeliveredAsyncStatus, | |
| 15 | 16 | isTerminalAsyncStatus, | |
| 16 | 17 | isWorkflowToolExecutionClaimable, | |
| 17 | 18 | } from '@/lib/copilot/async-runs/lifecycle' | |
| 18 | 19 | import { | |
| 19 | 20 | completeAsyncToolCall, | |
| 21 | + completeClaimedAsyncToolCall, | ||
| 20 | 22 | completePendingAsyncToolCall, | |
| 21 | 23 | detachAsyncToolCall, | |
| 22 | 24 | getAsyncToolCall, | |
@@ -53,12 +55,16 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' | |||
| 53 | 55 | import { getTrustedWorkflowToolExecution } from '@/lib/workflows/executor/execution-state' | |
| 54 | 56 | ||
| 55 | 57 | const logger = createLogger('CopilotConfirmAPI') | |
| 58 | + const NATIVE_HANDOFF_INTERRUPTED_MESSAGE = | ||
| 59 | + 'The desktop action was interrupted during handoff. Its outcome is unknown; do not retry it automatically.' | ||
| 56 | 60 | ||
| 57 | 61 | type ToolCallStatusUpdateOutcome = 'updated' | 'conflict' | 'failed' | |
| 58 | 62 | ||
| 59 | 63 | interface UpdateToolCallStatusOptions { | |
| 60 | 64 | executionId?: string | |
| 61 | - requirePendingStatus?: boolean | ||
| 65 | + completionGuard?: | ||
| 66 | + | { status: typeof ASYNC_TOOL_STATUS.pending } | ||
| 67 | + | { status: typeof ASYNC_TOOL_STATUS.running; claimedBy: string } | ||
| 62 | 68 | } | |
| 63 | 69 | ||
| 64 | 70 | function getClientToolCompletionMessage(status: AsyncConfirmationStatus): string { | |
@@ -113,9 +119,12 @@ async function updateToolCallStatus( | |||
| 113 | 119 | result: data ?? null, | |
| 114 | 120 | error: status === 'success' ? null : message || status, | |
| 115 | 121 | } | |
| 116 | - const completed = options.requirePendingStatus | ||
| 117 | - ? await completePendingAsyncToolCall(completionInput) | ||
| 118 | - : await completeAsyncToolCall(completionInput) | ||
| 122 | + const completed = | ||
| 123 | + options.completionGuard?.status === ASYNC_TOOL_STATUS.pending | ||
| 124 | + ? await completePendingAsyncToolCall(completionInput) | ||
| 125 | + : options.completionGuard?.status === ASYNC_TOOL_STATUS.running | ||
| 126 | + ? await completeClaimedAsyncToolCall(completionInput, options.completionGuard.claimedBy) | ||
| 127 | + : await completeAsyncToolCall(completionInput) | ||
| 119 | 128 | if (!completed) return 'conflict' | |
| 120 | 129 | publishToolConfirmation({ | |
| 121 | 130 | toolCallId, | |
@@ -283,6 +292,17 @@ export const POST = withRouteHandler((req: NextRequest) => { | |||
| 283 | 292 | (isCurrentBrowserToolName(existing.toolName) || isTerminalToolName(existing.toolName)) && | |
| 284 | 293 | existing.status === ASYNC_TOOL_STATUS.pending && | |
| 285 | 294 | isErrorOrCancelledOutcome | |
| 295 | + const nativeClaimOwner = isCurrentBrowserToolName(existing.toolName) | ||
| 296 | + ? DESKTOP_TOOL_CLAIM_OWNER.browser | ||
| 297 | + : isTerminalToolName(existing.toolName) | ||
| 298 | + ? DESKTOP_TOOL_CLAIM_OWNER.terminal | ||
| 299 | + : undefined | ||
| 300 | + const isIndeterminateNativeExit = | ||
| 301 | + isPreclaimNativeTerminalOutcome && | ||
| 302 | + status === ASYNC_TOOL_CONFIRMATION_STATUS.error && | ||
| 303 | + isPlainRecord(data) && | ||
| 304 | + data.outcomeUnknown === true && | ||
| 305 | + data.doNotRetry === true | ||
| 286 | 306 | const isMutableClientToolCall = isWorkflowTool | |
| 287 | 307 | ? isWorkflowToolExecutionClaimable(existing.status, existing.permissionDecision) | |
| 288 | 308 | : existing.status === ASYNC_TOOL_STATUS.running || isPreclaimNativeTerminalOutcome | |
@@ -377,8 +397,19 @@ export const POST = withRouteHandler((req: NextRequest) => { | |||
| 377 | 397 | toolCallId, | |
| 378 | 398 | runId: existing.runId, | |
| 379 | 399 | userId: authenticatedUserId, | |
| 380 | - ...(message !== undefined ? { message } : {}), | ||
| 381 | - ...(data !== undefined ? { data } : {}), | ||
| 400 | + ...(isIndeterminateNativeExit | ||
| 401 | + ? { | ||
| 402 | + message: NATIVE_HANDOFF_INTERRUPTED_MESSAGE, | ||
| 403 | + data: { | ||
| 404 | + error: NATIVE_HANDOFF_INTERRUPTED_MESSAGE, | ||
| 405 | + outcomeUnknown: true, | ||
| 406 | + doNotRetry: true, | ||
| 407 | + }, | ||
| 408 | + } | ||
| 409 | + : { | ||
| 410 | + ...(message !== undefined ? { message } : {}), | ||
| 411 | + ...(data !== undefined ? { data } : {}), | ||
| 412 | + }), | ||
| 382 | 413 | })), | |
| 383 | 414 | }, | |
| 384 | 415 | } | |
@@ -390,16 +421,34 @@ export const POST = withRouteHandler((req: NextRequest) => { | |||
| 390 | 421 | projected.data, | |
| 391 | 422 | { | |
| 392 | 423 | ...(isWorkflowTool && executionId ? { executionId } : {}), | |
| 393 | - requirePendingStatus: isPreclaimNativeTerminalOutcome, | ||
| 424 | + ...(isPreclaimNativeTerminalOutcome | ||
| 425 | + ? { completionGuard: { status: ASYNC_TOOL_STATUS.pending } as const } | ||
| 426 | + : {}), | ||
| 394 | 427 | } | |
| 395 | 428 | ) | |
| 396 | 429 | ||
| 397 | - if (updateOutcome === 'conflict' && isPreclaimNativeTerminalOutcome) { | ||
| 430 | + const reconciledOutcome = | ||
| 431 | + updateOutcome === 'conflict' && isIndeterminateNativeExit && nativeClaimOwner | ||
| 432 | + ? await updateToolCallStatus( | ||
| 433 | + existing, | ||
| 434 | + ASYNC_TOOL_CONFIRMATION_STATUS.error, | ||
| 435 | + projected.message, | ||
| 436 | + projected.data, | ||
| 437 | + { | ||
| 438 | + completionGuard: { | ||
| 439 | + status: ASYNC_TOOL_STATUS.running, | ||
| 440 | + claimedBy: nativeClaimOwner, | ||
| 441 | + }, | ||
| 442 | + } | ||
| 443 | + ) | ||
| 444 | + : updateOutcome | ||
| 445 | + | ||
| 446 | + if (reconciledOutcome === 'conflict' && isPreclaimNativeTerminalOutcome) { | ||
| 398 | 447 | span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.ToolCallNotFound) | |
| 399 | 448 | return createNotFoundResponse('Pending client tool call not found') | |
| 400 | 449 | } | |
| 401 | 450 | ||
| 402 | - if (updateOutcome !== 'updated') { | ||
| 451 | + if (reconciledOutcome !== 'updated') { | ||
| 403 | 452 | logger.error(`[${tracker.requestId}] Failed to update tool call status`, { | |
| 404 | 453 | userId: authenticatedUserId, | |
| 405 | 454 | toolCallId, | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -4,6 +4,7 @@ import { isRecordLike } from '@sim/utils/object' | |||
| 4 | 4 | import { type NextRequest, NextResponse } from 'next/server' | |
| 5 | 5 | import { authorizeDesktopToolContract } from '@/lib/api/contracts/desktop-tool-authorization' | |
| 6 | 6 | import { parseRequest } from '@/lib/api/server' | |
| 7 | + import { DESKTOP_TOOL_CLAIM_OWNER } from '@/lib/copilot/async-runs/lifecycle' | ||
| 7 | 8 | import { | |
| 8 | 9 | claimPendingAsyncToolCall, | |
| 9 | 10 | getAsyncToolCall, | |
@@ -66,7 +67,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { | |||
| 66 | 67 | } | |
| 67 | 68 | const claimed = await claimPendingAsyncToolCall( | |
| 68 | 69 | toolCall.toolCallId, | |
| 69 | - isBrowserTool ? 'desktop-browser' : 'desktop-terminal' | ||
| 70 | + isBrowserTool ? DESKTOP_TOOL_CLAIM_OWNER.browser : DESKTOP_TOOL_CLAIM_OWNER.terminal | ||
| 70 | 71 | ) | |
| 71 | 72 | if (!claimed) { | |
| 72 | 73 | return createNotFoundResponse('Pending client tool call not found') | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -12,6 +12,11 @@ export const EXECUTABLE_TOOL_PERMISSION_DECISIONS = [ | |||
| 12 | 12 | 'always_allow', | |
| 13 | 13 | ] as const satisfies readonly CopilotToolPermissionDecision[] | |
| 14 | 14 | ||
| 15 | + export const DESKTOP_TOOL_CLAIM_OWNER = { | ||
| 16 | + browser: 'desktop-browser', | ||
| 17 | + terminal: 'desktop-terminal', | ||
| 18 | + } as const | ||
| 19 | + | ||
| 15 | 20 | export type AsyncLifecycleStatus = | |
| 16 | 21 | | typeof ASYNC_TOOL_STATUS.pending | |
| 17 | 22 | | typeof ASYNC_TOOL_STATUS.running | |
| Back | FazBrowse Home | New Git URL |
0 commit comments