Skip to content

Commit bbb1608

Browse files
committed
fix(browser): preserve upload dispatch before acknowledgement
1 parent 07b0c8c commit bbb1608

4 files changed

Lines changed: 76 additions & 9 deletions

File tree

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -760,7 +760,7 @@ describe('browser-agent file input handles', () => {
760760
await expect(
761761
setFileInputFiles(contents, handle, ['/staged/a.pdf'], undefined, onDispatched)
762762
).rejects.toThrow('disappeared')
763-
expect(onDispatched).not.toHaveBeenCalled()
763+
expect(onDispatched).toHaveBeenCalledTimes(1)
764764
} finally {
765765
await releaseFileInput(contents, handle)
766766
}
@@ -791,7 +791,7 @@ describe('browser-agent file input handles', () => {
791791
}
792792
})
793793

794-
it('reports dispatch only after acknowledgement and before a pending readback', async () => {
794+
it('reports dispatch before acknowledgement so interruption cannot invite a retry', async () => {
795795
const { contents, frame, behavior, send } = await fileInputFixture()
796796
const handle = await resolveFileInput(contents, frame, 'captureUploadInput(4)')
797797
let acknowledge: () => void = () => {}
@@ -810,7 +810,7 @@ describe('browser-agent file input handles', () => {
810810
await vi.waitFor(() =>
811811
expect(send.mock.calls.some(([method]) => method === 'DOM.setFileInputFiles')).toBe(true)
812812
)
813-
expect(onDispatched).not.toHaveBeenCalled()
813+
expect(onDispatched).toHaveBeenCalledTimes(1)
814814
acknowledge()
815815
await vi.waitFor(() => expect(onDispatched).toHaveBeenCalledTimes(1))
816816
expect(send.mock.calls.some(([method]) => method === 'Runtime.releaseObject')).toBe(false)

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -867,7 +867,10 @@ export async function releaseFileInput(
867867
await releaseRemoteObject(contents, handle.objectId, handle.sessionId)
868868
}
869869

870-
/** Sets files on the captured input in its original CDP session, then reads that exact input. */
870+
/**
871+
* Sets files on the captured input in its original CDP session, then reads that exact input.
872+
* Marks dispatch before awaiting acknowledgement: cancellation cannot retract the command.
873+
*/
871874
export async function setFileInputFiles(
872875
contents: WebContents,
873876
handle: FileInputHandle,
@@ -880,13 +883,14 @@ export async function setFileInputFiles(
880883
if (!input.objectId) throw new Error('Chromium did not retain the upload input node')
881884
try {
882885
signal?.throwIfAborted()
883-
await send(
886+
const assignment = send(
884887
contents,
885888
'DOM.setFileInputFiles',
886889
{ files, objectId: input.objectId },
887890
handle.sessionId
888891
)
889892
onDispatched?.()
893+
await assignment
890894
try {
891895
const { value } = await callFileInput(contents, handle, 'files')
892896
if (!isRecordLike(value) || !Array.isArray(value.files)) {

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

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2524,7 +2524,7 @@ describe('credential protection', () => {
25242524
observation: {
25252525
ok: false,
25262526
doNotRetry: true,
2527-
note: expect.stringContaining('The action already ran'),
2527+
note: expect.stringContaining('The action was dispatched'),
25282528
},
25292529
},
25302530
})
@@ -3892,6 +3892,69 @@ describe('credential protection', () => {
38923892
expect(release).toHaveBeenCalledWith(contents, input)
38933893
})
38943894

3895+
it.each(['cancelled', 'timed out'] as const)(
3896+
'does not retry a dispatched upload when acknowledgement is %s',
3897+
async (stop) => {
3898+
const contents = await openPage()
3899+
const input = { objectId: 'isolated-input', multiple: false }
3900+
vi.spyOn(cdp, 'resolveFileInput').mockResolvedValue(input)
3901+
stageUploadFiles.mockResolvedValue(['/staged/a.pdf'])
3902+
let acknowledge: () => void = () => {}
3903+
const acknowledgement = new Promise<void>((resolve) => {
3904+
acknowledge = resolve
3905+
})
3906+
const send = vi.mocked(contents.debugger.sendCommand)
3907+
send.mockImplementation(async (method, params) => {
3908+
if (method === 'Runtime.callFunctionOn') {
3909+
const mode = (params?.arguments as Array<{ value: string }>)[0].value
3910+
return mode === 'input'
3911+
? { result: { objectId: 'original-input' } }
3912+
: { result: { value: { files: [{ name: 'a.pdf', size: 3 }] } } }
3913+
}
3914+
if (method === 'DOM.setFileInputFiles') await acknowledgement
3915+
return {}
3916+
})
3917+
vi.useFakeTimers()
3918+
try {
3919+
const pending = driver.executeTool(
3920+
'chat-test',
3921+
'browser_upload_file',
3922+
{ elementId: 0, paths: ['files/a.pdf'] },
3923+
'unacknowledged-upload'
3924+
)
3925+
await vi.advanceTimersByTimeAsync(200)
3926+
expect(cdpCalls(contents, 'DOM.setFileInputFiles')).toHaveLength(1)
3927+
expect(cdpCalls(contents, 'Runtime.releaseObject')).toHaveLength(0)
3928+
3929+
if (stop === 'cancelled') driver.cancelTool('chat-test', 'unacknowledged-upload')
3930+
else
3931+
await vi.advanceTimersByTimeAsync(
3932+
driver.browserToolWatchdogMs('browser_upload_file', {})!
3933+
)
3934+
3935+
await expect(pending).resolves.toMatchObject({
3936+
ok: true,
3937+
result: {
3938+
dispatched: true,
3939+
observation: { ok: false, doNotRetry: true },
3940+
},
3941+
})
3942+
await expect(
3943+
driver.executeTool('chat-test', 'browser_list_tabs', {}, 'after-unacknowledged-upload')
3944+
).resolves.toMatchObject({ ok: true })
3945+
} finally {
3946+
acknowledge()
3947+
await vi.advanceTimersByTimeAsync(200)
3948+
vi.useRealTimers()
3949+
}
3950+
expect(cdpCalls(contents, 'DOM.setFileInputFiles')).toHaveLength(1)
3951+
expect(cdpCalls(contents, 'Runtime.releaseObject').map(([, params]) => params)).toEqual([
3952+
{ objectId: 'original-input' },
3953+
{ objectId: 'isolated-input' },
3954+
])
3955+
}
3956+
)
3957+
38953958
it.each(['cancelled', 'timed out'] as const)(
38963959
'retains an acknowledged upload when readback is %s and releases its handle when readback settles',
38973960
async (stop) => {
@@ -3941,7 +4004,7 @@ describe('credential protection', () => {
39414004
observation: {
39424005
ok: false,
39434006
doNotRetry: true,
3944-
note: expect.stringContaining('The action already ran'),
4007+
note: expect.stringContaining('The action was dispatched'),
39454008
},
39464009
},
39474010
})

‎apps/desktop/src/main/browser-agent/post-action-observation.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ const OBSERVABLE_ACTIONS: ReadonlySet<BrowserToolName> = new Set([
1212
'browser_hover',
1313
])
1414

15-
/** Keeps a completed action available when its optional observation fails or is interrupted. */
15+
/** Preserves a dispatched action when its acknowledgement or observation is interrupted. */
1616
export function withFailedPostActionObservation(result: unknown, error: unknown): unknown {
1717
const observation = {
1818
ok: false,
1919
error: getErrorMessage(error),
2020
doNotRetry: true,
21-
note: 'The action already ran. Inspect its result; do not repeat it just because observation failed.',
21+
note: 'The action was dispatched. Inspect its result; do not repeat it just because confirmation failed.',
2222
}
2323
return isRecordLike(result) ? { ...result, observation } : { result, observation }
2424
}

0 commit comments

Comments
 (0)