Skip to content

Commit 60436b0

Browse files
committed
fix(browser): distinguish unconfirmed upload outcomes
1 parent bbb1608 commit 60436b0

7 files changed

Lines changed: 282 additions & 41 deletions

File tree

‎apps/desktop/e2e/browser-tools.spec.ts‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,102 @@ test.describe('browser tools', () => {
437437
})
438438
})
439439

440+
test('reports an unconfirmed upload when cancelled before Chromium acknowledgement arrives', async () => {
441+
const { elementId, evaluate } = await openUploadTarget('root')
442+
await app.evaluate(({ webContents }, site) => {
443+
const contents = webContents
444+
.getAllWebContents()
445+
.find((contents) => contents.getURL() === `${site}/upload-target?shadow=0&steal=0`)
446+
if (!contents) throw new Error('Missing upload acknowledgement fixture')
447+
const original = contents.debugger.sendCommand
448+
let release = () => {}
449+
const acknowledgement = new Promise<void>((resolve) => {
450+
release = resolve
451+
})
452+
const state = {
453+
count: 0,
454+
applied: false,
455+
release,
456+
restore: () => {
457+
contents.debugger.sendCommand = original
458+
},
459+
}
460+
const globals = globalThis as typeof globalThis & { heldUploadAcknowledgement?: typeof state }
461+
globals.heldUploadAcknowledgement = state
462+
contents.debugger.sendCommand = async (method, params, sessionId) => {
463+
if (method !== 'DOM.setFileInputFiles') {
464+
return original.call(contents.debugger, method, params, sessionId)
465+
}
466+
state.count++
467+
const result = await original.call(contents.debugger, method, params, sessionId)
468+
state.applied = true
469+
await acknowledgement
470+
return result
471+
}
472+
}, site)
473+
const pending = execute('browser_upload_file', { elementId, paths: ['files/receipt.txt'] })
474+
const toolCallId = `browser-fixture-${callCount}`
475+
try {
476+
await expect
477+
.poll(() => evaluate('window.uploadTestState()'))
478+
.toEqual({
479+
original: [{ name: 'receipt.txt', text: 'receipt-bytes' }],
480+
decoy: [],
481+
currentCount: 1,
482+
})
483+
await expect
484+
.poll(() =>
485+
app.evaluate(
486+
() =>
487+
(
488+
globalThis as typeof globalThis & {
489+
heldUploadAcknowledgement?: { applied: boolean }
490+
}
491+
).heldUploadAcknowledgement?.applied
492+
)
493+
)
494+
.toBe(true)
495+
await window.evaluate(
496+
async ({ toolCallId, scope }) => {
497+
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
498+
if (!api.browserAgent.cancelTool) throw new Error('Browser cancellation is unavailable')
499+
await api.browserAgent.cancelTool(toolCallId, scope)
500+
},
501+
{ toolCallId, scope: SCOPE }
502+
)
503+
504+
const upload = await pending
505+
expect(upload.ok, JSON.stringify(upload)).toBe(true)
506+
expect(upload.result).toMatchObject({
507+
outcomeUnknown: true,
508+
doNotRetry: true,
509+
note: expect.stringContaining('Inspect the page'),
510+
})
511+
expect(upload.result).not.toHaveProperty('dispatched', true)
512+
expect((await execute('browser_list_tabs', {})).ok).toBe(true)
513+
expect(
514+
await app.evaluate(
515+
() =>
516+
(
517+
globalThis as typeof globalThis & {
518+
heldUploadAcknowledgement?: { count: number }
519+
}
520+
).heldUploadAcknowledgement?.count
521+
)
522+
).toBe(1)
523+
} finally {
524+
await app.evaluate(() => {
525+
const globals = globalThis as typeof globalThis & {
526+
heldUploadAcknowledgement?: { release: () => void; restore: () => void }
527+
}
528+
globals.heldUploadAcknowledgement?.release()
529+
globals.heldUploadAcknowledgement?.restore()
530+
globals.heldUploadAcknowledgement = undefined
531+
})
532+
await pending
533+
}
534+
})
535+
440536
for (const mutation of ['replace', 'disable', 'navigate-frame'] as const) {
441537
test(`refuses uploads when the target changes during staging: ${mutation}`, async () => {
442538
const { elementId, evaluate } = await openUploadTarget(

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

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -692,10 +692,18 @@ describe('browser-agent file input handles', () => {
692692
if (change === 'document-closed') document.defaultView = null
693693
if (change === 'type') input.type = 'text'
694694
if (change === 'multiple') input.multiple = false
695+
const onDispatch = vi.fn()
695696
try {
696697
await expect(
697-
setFileInputFiles(contents, handle, ['/staged/a.pdf', '/staged/b.pdf'])
698+
setFileInputFiles(
699+
contents,
700+
handle,
701+
['/staged/a.pdf', '/staged/b.pdf'],
702+
undefined,
703+
onDispatch
704+
)
698705
).rejects.toThrow(/upload input|upload target/)
706+
expect(onDispatch).not.toHaveBeenCalled()
699707
expect(send.mock.calls.some(([method]) => method === 'DOM.setFileInputFiles')).toBe(false)
700708
} finally {
701709
await releaseFileInput(contents, handle)
@@ -737,14 +745,14 @@ describe('browser-agent file input handles', () => {
737745
const { contents, frame, behavior, send } = await fileInputFixture()
738746
const handle = await resolveFileInput(contents, frame, 'captureUploadInput(4)')
739747
const controller = new AbortController()
740-
const onDispatched = vi.fn()
748+
const onDispatch = vi.fn()
741749
behavior.afterInputValidation = () => controller.abort()
742750
try {
743751
await expect(
744-
setFileInputFiles(contents, handle, ['/staged/a.pdf'], controller.signal, onDispatched)
752+
setFileInputFiles(contents, handle, ['/staged/a.pdf'], controller.signal, onDispatch)
745753
).rejects.toThrow()
746754
expect(send.mock.calls.some(([method]) => method === 'DOM.setFileInputFiles')).toBe(false)
747-
expect(onDispatched).not.toHaveBeenCalled()
755+
expect(onDispatch).not.toHaveBeenCalled()
748756
} finally {
749757
await releaseFileInput(contents, handle)
750758
}
@@ -755,12 +763,12 @@ describe('browser-agent file input handles', () => {
755763
const { contents, frame, behavior, send } = await fileInputFixture(true)
756764
const handle = await resolveFileInput(contents, frame, 'captureUploadInput(4)')
757765
behavior.rejectSet = true
758-
const onDispatched = vi.fn()
766+
const onDispatch = vi.fn()
759767
try {
760768
await expect(
761-
setFileInputFiles(contents, handle, ['/staged/a.pdf'], undefined, onDispatched)
769+
setFileInputFiles(contents, handle, ['/staged/a.pdf'], undefined, onDispatch)
762770
).rejects.toThrow('disappeared')
763-
expect(onDispatched).toHaveBeenCalledTimes(1)
771+
expect(onDispatch.mock.calls).toEqual([['pending']])
764772
} finally {
765773
await releaseFileInput(contents, handle)
766774
}
@@ -791,7 +799,7 @@ describe('browser-agent file input handles', () => {
791799
}
792800
})
793801

794-
it('reports dispatch before acknowledgement so interruption cannot invite a retry', async () => {
802+
it('reports pending dispatch while acknowledgement is held, then acknowledges before readback', async () => {
795803
const { contents, frame, behavior, send } = await fileInputFixture()
796804
const handle = await resolveFileInput(contents, frame, 'captureUploadInput(4)')
797805
let acknowledge: () => void = () => {}
@@ -804,19 +812,19 @@ describe('browser-agent file input handles', () => {
804812
})
805813
behavior.beforeSet = () => acknowledgement
806814
behavior.beforeReadback = () => readback
807-
const onDispatched = vi.fn()
808-
const pending = setFileInputFiles(contents, handle, ['/staged/a.pdf'], undefined, onDispatched)
815+
const onDispatch = vi.fn()
816+
const pending = setFileInputFiles(contents, handle, ['/staged/a.pdf'], undefined, onDispatch)
809817
try {
810818
await vi.waitFor(() =>
811819
expect(send.mock.calls.some(([method]) => method === 'DOM.setFileInputFiles')).toBe(true)
812820
)
813-
expect(onDispatched).toHaveBeenCalledTimes(1)
821+
expect(onDispatch.mock.calls).toEqual([['pending']])
814822
acknowledge()
815-
await vi.waitFor(() => expect(onDispatched).toHaveBeenCalledTimes(1))
823+
await vi.waitFor(() => expect(onDispatch.mock.calls).toEqual([['pending'], ['acknowledged']]))
816824
expect(send.mock.calls.some(([method]) => method === 'Runtime.releaseObject')).toBe(false)
817825
releaseReadback()
818826
await expect(pending).resolves.toEqual({ files: [{ name: 'a.pdf', size: 12 }] })
819-
expect(onDispatched).toHaveBeenCalledTimes(1)
827+
expect(onDispatch.mock.calls).toEqual([['pending'], ['acknowledged']])
820828
} finally {
821829
acknowledge()
822830
releaseReadback()

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -868,29 +868,29 @@ export async function releaseFileInput(
868868
}
869869

870870
/**
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.
871+
* Reports assignment dispatch and acknowledgement separately before reading the captured input.
872+
* Stopping a wait cannot revoke a CDP command, so its outcome becomes uncertain before the send.
873873
*/
874874
export async function setFileInputFiles(
875875
contents: WebContents,
876876
handle: FileInputHandle,
877877
files: readonly string[],
878878
signal?: AbortSignal,
879-
onDispatched?: () => void
879+
onDispatch?: (status: 'pending' | 'acknowledged') => void
880880
): Promise<{ files: Array<{ name: string; size: number }> } | { readbackError: string }> {
881881
signal?.throwIfAborted()
882882
const input = await callFileInput(contents, handle, 'input', files.length)
883883
if (!input.objectId) throw new Error('Chromium did not retain the upload input node')
884884
try {
885885
signal?.throwIfAborted()
886-
const assignment = send(
886+
onDispatch?.('pending')
887+
await send(
887888
contents,
888889
'DOM.setFileInputFiles',
889890
{ files, objectId: input.objectId },
890891
handle.sessionId
891892
)
892-
onDispatched?.()
893-
await assignment
893+
onDispatch?.('acknowledged')
894894
try {
895895
const { value } = await callFileInput(contents, handle, 'files')
896896
if (!isRecordLike(value) || !Array.isArray(value.files)) {

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

Lines changed: 99 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3835,7 +3835,7 @@ describe('credential protection', () => {
38353835
})
38363836

38373837
it.each(['staging', 'attachment'])(
3838-
'releases the pinned input after %s fails',
3838+
'releases the pinned input after %s fails before dispatch',
38393839
async (failure) => {
38403840
const contents = await openPage()
38413841
const input = { objectId: 'isolated-input', multiple: false }
@@ -3855,7 +3855,7 @@ describe('credential protection', () => {
38553855
'call-failed'
38563856
)
38573857

3858-
expect(result).toMatchObject({
3858+
expect(result).toEqual({
38593859
ok: false,
38603860
error: expect.stringContaining(`${failure} failed`),
38613861
})
@@ -3935,8 +3935,8 @@ describe('credential protection', () => {
39353935
await expect(pending).resolves.toMatchObject({
39363936
ok: true,
39373937
result: {
3938-
dispatched: true,
3939-
observation: { ok: false, doNotRetry: true },
3938+
outcomeUnknown: true,
3939+
doNotRetry: true,
39403940
},
39413941
})
39423942
await expect(
@@ -3971,8 +3971,9 @@ describe('credential protection', () => {
39713971
)
39723972
const setFiles = vi
39733973
.spyOn(cdp, 'setFileInputFiles')
3974-
.mockImplementation(async (_contents, _handle, _files, _signal, onDispatched) => {
3975-
onDispatched?.()
3974+
.mockImplementation(async (_contents, _handle, _files, _signal, onDispatch) => {
3975+
onDispatch?.('pending')
3976+
onDispatch?.('acknowledged')
39763977
return readback
39773978
})
39783979
const release = vi.spyOn(cdp, 'releaseFileInput').mockResolvedValue()
@@ -4021,6 +4022,98 @@ describe('credential protection', () => {
40214022
expect(setFiles).toHaveBeenCalledTimes(1)
40224023
}
40234024
)
4025+
4026+
it.each(['cancelled', 'timed out'] as const)(
4027+
'reports an unconfirmed upload when its acknowledgment is %s without replaying it or affecting queued work',
4028+
async (stop) => {
4029+
const contents = await openPage()
4030+
const input = { objectId: 'isolated-input', multiple: false }
4031+
vi.spyOn(cdp, 'resolveFileInput').mockResolvedValue(input)
4032+
let acknowledgeUpload: () => void = () => {}
4033+
const acknowledgment = new Promise<void>((resolve) => {
4034+
acknowledgeUpload = resolve
4035+
})
4036+
let appliedUploads = 0
4037+
const setFiles = vi
4038+
.spyOn(cdp, 'setFileInputFiles')
4039+
.mockImplementation(async (_contents, _handle, _files, _signal, onDispatch) => {
4040+
onDispatch?.('pending')
4041+
appliedUploads++
4042+
await acknowledgment
4043+
onDispatch?.('acknowledged')
4044+
return { files: [{ name: 'a.pdf', size: 3 }] }
4045+
})
4046+
const release = vi.spyOn(cdp, 'releaseFileInput').mockResolvedValue()
4047+
stageUploadFiles.mockResolvedValue(['/staged/a.pdf'])
4048+
let releaseSnapshot: (value: unknown) => void = () => {}
4049+
const snapshot = new Promise<unknown>((resolve) => {
4050+
releaseSnapshot = resolve
4051+
})
4052+
let snapshotStarted = false
4053+
vi.mocked(contents.executeJavaScript).mockImplementation((expression) => {
4054+
if (isPageCall(expression, 'collectSnapshot')) {
4055+
snapshotStarted = true
4056+
return snapshot
4057+
}
4058+
return Promise.resolve({})
4059+
})
4060+
4061+
vi.useFakeTimers()
4062+
try {
4063+
const timersBefore = vi.getTimerCount()
4064+
const pending = driver.executeTool(
4065+
'chat-test',
4066+
'browser_upload_file',
4067+
{ elementId: 0, paths: ['files/a.pdf'] },
4068+
'unconfirmed-upload'
4069+
)
4070+
await vi.advanceTimersByTimeAsync(200)
4071+
expect(appliedUploads).toBe(1)
4072+
const queued = driver.executeTool('chat-test', 'browser_snapshot', {}, 'next-snapshot')
4073+
4074+
if (stop === 'cancelled') driver.cancelTool('chat-test', 'unconfirmed-upload')
4075+
else
4076+
await vi.advanceTimersByTimeAsync(
4077+
driver.browserToolWatchdogMs('browser_upload_file', {})!
4078+
)
4079+
4080+
const result = await pending
4081+
expect(result).toMatchObject({
4082+
ok: true,
4083+
result: {
4084+
outcomeUnknown: true,
4085+
doNotRetry: true,
4086+
error: expect.any(String),
4087+
note: expect.stringContaining('The action may already have run'),
4088+
},
4089+
})
4090+
expect(result.result).not.toHaveProperty('dispatched')
4091+
await vi.advanceTimersByTimeAsync(0)
4092+
expect(snapshotStarted).toBe(true)
4093+
expect(release).not.toHaveBeenCalled()
4094+
4095+
acknowledgeUpload()
4096+
await vi.advanceTimersByTimeAsync(200)
4097+
expect(release).toHaveBeenCalledExactlyOnceWith(contents, input)
4098+
expect(appliedUploads).toBe(1)
4099+
expect(setFiles).toHaveBeenCalledTimes(1)
4100+
expect(result.result).toMatchObject({ outcomeUnknown: true, doNotRetry: true })
4101+
expect(result.result).not.toHaveProperty('dispatched')
4102+
4103+
driver.cancelTool('chat-test', 'next-snapshot')
4104+
await expect(queued).resolves.toEqual({
4105+
ok: false,
4106+
error: expect.stringContaining('cancelled'),
4107+
})
4108+
expect(vi.getTimerCount()).toBe(timersBefore)
4109+
} finally {
4110+
acknowledgeUpload()
4111+
releaseSnapshot({ outline: 'Late snapshot', refIds: [], nextElementId: 1 })
4112+
await vi.advanceTimersByTimeAsync(200)
4113+
vi.useRealTimers()
4114+
}
4115+
}
4116+
)
40244117
})
40254118

40264119
it('validates upload paths before touching the page', async () => {

0 commit comments

Comments
 (0)