diff --git a/docs/engineering/mobile_screenshot_paste_2026-09-14.md b/docs/engineering/mobile_screenshot_paste_2026-09-14.md new file mode 100644 index 00000000..51021fb7 --- /dev/null +++ b/docs/engineering/mobile_screenshot_paste_2026-09-14.md @@ -0,0 +1,77 @@ +# Android screenshot paste: 14 September 2026 observations + +- **Kind:** Incident evidence record +- **Lifecycle:** Frozen +- **Authority:** Evidence only; delivery status belongs to the native task +- **Owner:** Michael Witbrock / Codex DGX +- **Evidence as of:** 14 September 2026 +- **Task:** `#V#task_agent_b61e77311c89e38adcf879ff20c995b1` +- **Review trigger:** A changed Android Chrome input implementation or new native evidence + +## Diagnosis + +The supplied original screenshot shows Gboard Clipboard, a screenshot thumbnail, +the main “Talk with Von here…” composer, and Chrome's “Chrome does not support +image pasting here” toast. It is not the person/agent Messages reply composer. +The reported phone is a Pixel 8 Pro, Android 17, Chrome 152; its exact installed +build and feature state were not independently observed. + +The existing main-composer handler already extracts image files from +`clipboardData.items`, falls back to `clipboardData.files`, and cancels default +paste only after it captures an image. Adding another extractor cannot repair +an insertion rejected before a DOM event. + +Native operator evidence on Android 17, Chrome 145.0.7632.218 and Gboard +17.2.2.895242737-preload-arm64-v8a reproduced the refusal with zero image/paste/ +input events in the production textarea and experimental rich control. Captions +and native multiline text paste survived. Native Documents selection reached +the uploader. The rich control's IME `contentMimeTypes` was null. + +Pinned Chromium 152.0.7977.127 source has two relevant conditions: + +1. The Java input connection advertises media MIME types only when + `ANDROID_MEDIA_INSERTION` is enabled: + [ImeAdapterImpl.java](https://github.com/chromium/chromium/blob/671995b43301fa179149ff18b1952efad92abe60/content/public/android/java/src/org/chromium/content/browser/input/ImeAdapterImpl.java#L567). +2. Native MIME discovery returns image types only for a richly editable element: + [ime_adapter_android.cc](https://github.com/chromium/chromium/blob/671995b43301fa179149ff18b1952efad92abe60/content/browser/android/ime_adapter_android.cc#L569). + +The feature is [disabled by default in that source](https://github.com/chromium/chromium/blob/671995b43301fa179149ff18b1952efad92abe60/content/public/common/content_features.cc#L56). +The retained 145 source has the same gate. Von's textarea does not qualify as +richly editable; changing it alone also leaves the feature gate. Null MIME types +and zero DOM events are consistent with the feature being disabled on the +emulator, but its effective field-trial state was not read. Source defaults do +not prove the phone's effective feature state. + +## Explicit clipboard recovery evidence + +A second native operator trial used the exact `initialiseImagePicker` helper +from PR #658 at `c9ef82f098ba552425a39763e726187eecaf5e87`. A trusted native +**Paste image** tap produced Chrome's normal clipboard permission prompt. +Native **Allow** returned a 109-byte PNG, decoded as 24 × 16 pixels, with all +captions preserved. No permission pre-grant, synthetic paste, changed Chrome +flag, backend upload, or model request was used in that trial. Chromium +re-encoded the clipboard PNG; byte identity with the copied PNG is not claimed. + +The helper can therefore bypass the keyboard's unsupported insertion route. +The task reuses that helper in the main composer's More actions menu and its +existing upload callback. PR #658's broader picker layout, cancellation changes +and PWA acceptance remain separate work. + +## Provenance and limits + +Private originals, exported reports and screenshots remain on the native task, +not in Git. Canonical file-copy reads supplied bytes; all five supplied SHA256 +hashes were checked in run `dec7adcb7cd441ce8a36020eeb068285`. + +| Source | File-copy concept | SHA256 | +| --- | --- | --- | +| Original screenshot | `#V#computer_file_copy_f8a50dc5cf4e4a8591c89099cfb73f33` | `6204bdce1672cc7bb313a7588207a712e634fb0ad379e629fcc5c3eab35dbf3f` | +| Native Gboard report | `#V#computer_file_copy_9fe8f1351c4d4919847e3533f8f29d3a` | `a0773217150eba450849fe784942b14a533c5c73372fb2b4617ee916916650b2` | +| Native Clipboard API evidence ZIP | `#V#computer_file_copy_085ec20da79c402da44f42441c120990` | `4aa3bcf2f6d397d75cf4311362f160b5f6ffc9902a0922cf41c136068ee5b197` | + +The native capability trial and local production-composer integration checks +are separate observations. Neither establishes physical Pixel/Chrome 152, +full authenticated Von/PWA operation, public OAuth, production deployment, or +removal of the original Gboard toast. The source conversation had no accessible +locator; the canonical task and supplied files sufficed without inferring its +contents. diff --git a/docs/engineering/otter_archive_and_conversation_images.md b/docs/engineering/otter_archive_and_conversation_images.md index dab79498..4a5ed9a2 100644 --- a/docs/engineering/otter_archive_and_conversation_images.md +++ b/docs/engineering/otter_archive_and_conversation_images.md @@ -38,6 +38,18 @@ text. Consumers reassembling originals must check the complete upstream hash. ## Reusable image handling +In the main conversation composer, open **More actions** (the **+** on a phone), +then choose **Paste image** to read a copied screenshot through the browser's +clipboard permission prompt. **Attach image** opens the native image picker if +clipboard access is unavailable, denied, or contains no image. Both preserve the +caption and use the existing upload/preview path; review the preview before +sending. Ordinary text paste still belongs to the message field. + +Android keyboards can refuse their own image-tile insertion before Von receives +a paste event. The explicit action is a recovery for that browser limitation; +it does not enable Gboard's image tile. See the +[native observations and diagnosis](mobile_screenshot_paste_2026-09-14.md). + The existing drop, paste and picker routes feed one image pipeline. Up to eight still PNG, JPEG or WebP images may accompany a message (8 MiB and 25 million pixels per image). Media is decoded rather than trusted by filename. The composer shows progress, previews, removal and failures; unresolved preparation diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index 6d89b19d..a3aaf7b4 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -14,7 +14,7 @@ import { CONVERSATION_LAYOUT_KEY, CONVERSATION_LAYOUT_KEYS, CONVERSATION_NARROW_ import { createVoiceConversation } from './voiceConversation.js'; import { getClientContext } from './clientContext.js'; import { createDictationController } from './dictation.js'; -import { renderImageAttachments, renderImageComposer, uploadConversationImage, imagesBlocked, imageItems, takeImages, restoreImages, descriptorsForIds } from "./utils/conversationImages.js"; +import { initialiseImagePicker, renderImageAttachments, renderImageComposer, uploadConversationImage, imagesBlocked, imageItems, takeImages, restoreImages, descriptorsForIds } from "./utils/conversationImages.js"; // Chat Tab Module import { annotateTurn, fetchWithTimeout, getJsonDetailed, getUserContext, getWindowSessionId, postJson, WINDOW_SESSION_HEADER } from './apiService.js'; import { @@ -6667,7 +6667,7 @@ function updateExternalConversationUi() { : promptInput.dataset.nativePlaceholder ); } - [resetButton, uploadButton, dictateButton].forEach((button) => { + [resetButton, uploadButton, dictateButton, document.getElementById('attachImageButton'), document.getElementById('pasteImageButton')].forEach((button) => { if (button) { button.disabled = readOnly; } @@ -34020,6 +34020,8 @@ export function initializeChatTab() { }); } + initialiseImagePicker(uploadFilesToVon, renderUploadStatus); + if (uploadFileButton && uploadFileInput) { uploadFileButton.addEventListener('click', () => { try { diff --git a/src/frontend/web/von_interface/static/js/test/conversationImages.test.js b/src/frontend/web/von_interface/static/js/test/conversationImages.test.js index 8ca49596..9827d1e0 100644 --- a/src/frontend/web/von_interface/static/js/test/conversationImages.test.js +++ b/src/frontend/web/von_interface/static/js/test/conversationImages.test.js @@ -24,7 +24,7 @@ test('failed preparation remains visible and prevents text-only submission until expect(panel.querySelector('[role=alert]').textContent).toMatch(/still PNG/); expect(imagesBlocked('failed')).toBe(true); expect(() => takeImages('failed')).toThrow(/preparation failed/); - panel.querySelector('button').click(); + panel.querySelector('button[aria-label="Remove bad.svg"]').click(); expect(imagesBlocked('failed')).toBe(false); }); diff --git a/src/frontend/web/von_interface/static/js/test/imagePicker.test.js b/src/frontend/web/von_interface/static/js/test/imagePicker.test.js new file mode 100644 index 00000000..3413118e --- /dev/null +++ b/src/frontend/web/von_interface/static/js/test/imagePicker.test.js @@ -0,0 +1,37 @@ +/** @jest-environment jsdom */ +import { initialiseImagePicker } from '../utils/conversationImages.js'; +let upload, status; +beforeEach(() => { + document.body.innerHTML = ''; + upload = jest.fn(); status = jest.fn(); + Object.defineProperty(navigator, 'clipboard', {configurable:true, value:undefined}); + initialiseImagePicker(upload, status); +}); +test('picker cancellation preserves draft; selection uses shared upload path', () => { + const input = document.querySelector('input'); + const click = jest.spyOn(input, 'click'); + document.querySelector('#attachImageButton').click(); + expect(click).toHaveBeenCalled(); + input.dispatchEvent(new Event('cancel')); + expect(upload).not.toHaveBeenCalled(); + expect(status).toHaveBeenCalledWith(expect.stringContaining('unchanged')); + const file = new File(['image'], 'photo.png', {type:'image/png'}); + Object.defineProperty(input, 'files', {value:[file]}); + input.dispatchEvent(new Event('change')); + expect(upload).toHaveBeenCalledWith([file]); + expect(input.value).toBe(''); +}); +test('missing clipboard API offers the picker', () => { + document.querySelector('#pasteImageButton').click(); + expect(status).toHaveBeenCalledWith(expect.stringContaining('Use Attach image'), 'error'); +}); +test.each(['denied', 'empty', 'image'])('clipboard %s', async mode => { + Object.defineProperty(navigator, 'clipboard', {value:{read:jest.fn(async () => { + if (mode === 'denied') throw new Error('NotAllowedError'); + return mode === 'empty' ? [] : [{types:['image/png'], getType:async () => new Blob(['image'], {type:'image/png'})}]; + })}}); + document.querySelector('#pasteImageButton').click(); + await new Promise(resolve => setTimeout(resolve, 0)); + if (mode === 'image') expect(upload.mock.calls[0][0][0].type).toBe('image/png'); + else expect(status).toHaveBeenCalledWith(expect.stringContaining('Attach image'), 'error'); +}); diff --git a/src/frontend/web/von_interface/static/js/utils/conversationImages.js b/src/frontend/web/von_interface/static/js/utils/conversationImages.js index e12d242b..ee1c45fb 100644 --- a/src/frontend/web/von_interface/static/js/utils/conversationImages.js +++ b/src/frontend/web/von_interface/static/js/utils/conversationImages.js @@ -142,6 +142,39 @@ async function uploadItem(item, file, sessionId, headers, changed) { changed(); return Boolean(item.descriptor); } +// Both controls use the existing session-bound upload path supplied by chatTab. +export function initialiseImagePicker(upload, status) { + const input = document.getElementById('attachImageInput'); + const button = document.getElementById('attachImageButton'); + const paste = document.getElementById('pasteImageButton'); + if (button && input) { + button.addEventListener('click', () => input.click()); + input.addEventListener('change', () => { + const files = Array.from(input.files || []); + input.value = ''; + if (files.length) void upload(files); + }); + input.addEventListener('cancel', () => status('Image selection cancelled. Your draft is unchanged.')); + } + paste?.addEventListener('click', async () => { + if (!navigator.clipboard?.read) { + status('Clipboard images are unavailable here. Use Attach image, or paste into the message field.', 'error'); + return; + } + try { + const items = await navigator.clipboard.read(); + const files = []; + for (const item of items) { + const type = item.types.find(value => value.startsWith('image/')); + if (type) files.push(new File([await item.getType(type)], `pasted-image-${files.length + 1}.${type.split('/')[1]}`, {type})); + } + if (files.length) await upload(files); + else status('No image found on the clipboard. Use Attach image to choose a photo.', 'error'); + } catch (_) { + status('Clipboard access was unavailable or cancelled. Use Attach image, or paste into the message field.', 'error'); + } + }); +} export function takeImages(sessionId) { if (imagesBlocked(sessionId)) throw new Error('Attachment preparation failed or is still running. Remove or finish the upload before sending.'); const ids = imageItems(sessionId).map(x => x.descriptor.concept_id); diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index dd128a29..1037ba35 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -18566,7 +18566,7 @@ body.settings-body { gap: 8px; margin: 4px 0; padding: 8px 0; border-top: 1px solid #e2e8f0; } - .compact-speech-help { display: block; flex-basis: 100%; font-size: 12px; margin: 0; } + .compact-speech-help, #imageAttachmentHelp { display: block; flex-basis: 100%; font-size: 12px; margin: 0; } .chat-composer-more-actions .button-row > button, .chat-composer-more-actions .button-row > select { min-height: 44px; margin: 0; } .chat-composer-more-actions #dictateButton { width: auto; flex-basis: auto; padding: 0 12px; border-radius: 6px; gap: 6px; } diff --git a/src/frontend/web/von_interface/templates/chat_tab.html b/src/frontend/web/von_interface/templates/chat_tab.html index 55dbc2a7..d52c5ff6 100644 --- a/src/frontend/web/von_interface/templates/chat_tab.html +++ b/src/frontend/web/von_interface/templates/chat_tab.html @@ -256,6 +256,10 @@

End conversation +

If your keyboard cannot paste a screenshot, use Paste image and allow clipboard access, or choose Attach image. Your caption stays in the draft.

+ + + diff --git a/tests/browser/mobileScreenshotPaste.cjs b/tests/browser/mobileScreenshotPaste.cjs new file mode 100644 index 00000000..a78852f2 --- /dev/null +++ b/tests/browser/mobileScreenshotPaste.cjs @@ -0,0 +1,116 @@ +// Production composer/module fixture, real Chromium clipboard, stubbed HTTP. +// Native Android permission/keyboard evidence is separately retained on the task. +// Run: PLAYWRIGHT_BROWSERS_PATH=... node tests/browser/mobileScreenshotPaste.cjs [evidence-directory] +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const path = require('node:path'); +const { chromium, expect } = require('@playwright/test'); +const root = path.resolve(__dirname, '../../src/frontend/web/von_interface'); +const markup = fs.readFileSync(path.join(root, 'templates/chat_tab.html'), 'utf8') + .replace(/{%[\s\S]*?%}/g, '').replace(/{{[\s\S]*?}}/g, ''); +const png = fs.readFileSync(path.join(__dirname, 'fixtures/attachment.png')); +const evidence = process.argv[2]; +if (evidence) fs.mkdirSync(evidence, {recursive:true}); +let uploads = []; +const server = http.createServer(async (req, res) => { + if (req.url === '/') { + res.setHeader('Content-Type', 'text/html'); + res.end(`${markup}`); + return; + } + if (req.url.startsWith('/static/')) { + const file = path.resolve(root, `.${req.url.split('?')[0]}`); + if (file.startsWith(root + '/static/') && fs.existsSync(file)) { + res.setHeader('Content-Type', file.endsWith('.css') ? 'text/css' : 'text/javascript'); + res.end(fs.readFileSync(file)); return; + } + } + if (req.url === '/fixture.png' || req.url.endsWith('/original')) { + res.setHeader('Content-Type', 'image/png'); res.end(png); return; + } + res.setHeader('Content-Type', 'application/json'); + if (req.url === '/von/api/images/upload') { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const body = Buffer.concat(chunks); + assert(body.includes(Buffer.from('Content-Type: image/png'))); + assert(body.includes(Buffer.from([137, 80, 78, 71]))); + uploads.push({bytes:body.length, windowSession:!!req.headers['x-von-window-session']}); + res.end(JSON.stringify({image_attachment:{concept_id:`#V#fixture-image-${uploads.length}`,filename:'screenshot.png'}})); return; + } + res.end(JSON.stringify({authenticated:true, success:true, sessions:[], messages:[], items:[], history:[], history_length:0, organisations:[]})); +}); +(async () => { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const browser = await chromium.launch({headless:true}); + const results = []; + try { + for (const width of [360, 448, 1440]) { + uploads = []; + const context = await browser.newContext({viewport:{width,height:891},hasTouch:width<800,isMobile:width<800,permissions:['clipboard-read','clipboard-write']}); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.evaluate(async () => { + document.querySelector('#messagesContainer').style.display = 'none'; + window.chat = await import('/static/js/chatTab.js'); + window.chat.initializeChatTab(); + window.chat.__testOnly_setActiveChatSession('clipboard-fixture', 'Clipboard fixture'); + }); + const input = page.locator('#promptInput'); + const caption = 'Describe this screenshot\nKeep both lines'; + await input.fill(caption); + await page.locator('.chat-composer-more-actions>summary').click(); + const paste = page.getByRole('button', {name:'Paste image',exact:true}); + await expect(paste).toBeVisible(); + await page.evaluate(async () => { + const blob = await (await fetch('/fixture.png')).blob(); + await navigator.clipboard.write([new ClipboardItem({'image/png':blob})]); + }); + await paste.click(); + await expect.poll(() => uploads.length).toBe(1); + await expect(page.locator('#conversationImageComposer img')).toBeVisible(); + await expect(input).toHaveValue(caption); + assert(uploads[0].windowSession); + await page.getByRole('button', {name:'Remove pasted-image-1.png',exact:true}).click(); + await expect(page.locator('#conversationImageComposer img')).toHaveCount(0); + // Denied browser clipboard access offers recovery without changing the caption. + await context.clearPermissions(); + const cdp = await context.newCDPSession(page); + await cdp.send('Browser.setPermission', {permission:{name:'clipboard-read'}, setting:'denied', origin:new URL(page.url()).origin}); + if (!await page.locator('.chat-composer-more-actions').evaluate(el => el.open)) { + await page.locator('.chat-composer-more-actions>summary').click(); + } + await paste.click(); + await expect(page.locator('#uploadFileStatus')).toContainText('Use Attach image'); + await expect(input).toHaveValue(caption); + const chooser = page.waitForEvent('filechooser'); + await page.getByRole('button', {name:'Attach image',exact:true}).click(); + await (await chooser).setFiles({name:'picker.png',mimeType:'image/png',buffer:png}); + await expect.poll(() => uploads.length).toBe(2); + await expect(page.locator('#conversationImageComposer img')).toBeVisible(); + await expect(input).toHaveValue(caption); + const overflow = await page.locator('.chat-composer').evaluate(el => el.scrollWidth > el.clientWidth); + assert(!overflow, `composer overflow at ${width}`); + if (evidence) await page.screenshot({path:path.join(evidence, `${width}-recovery.png`)}); + await page.locator('.chat-composer-more-actions>summary').click(); + await page.locator('#conversationImageComposer img').scrollIntoViewIfNeeded(); + if (evidence) await page.screenshot({path:path.join(evidence, `${width}-preview.png`)}); + // Real desktop clipboard insertion verifies that the unchanged textarea + // listener leaves ordinary multiline text to the browser. + await context.grantPermissions(['clipboard-read','clipboard-write']); + await page.evaluate(() => navigator.clipboard.writeText('Plain text\nSecond line')); + await input.fill(''); await input.focus(); await page.keyboard.press('Control+V'); + await expect(input).toHaveValue('Plain text\nSecond line'); + assert.equal(uploads.length, 2); + assert.deepEqual(errors, []); + results.push({width, uploads, overflow, captionPreserved:true, deniedRecovery:true, multilineTextPaste:true, errors}); + await context.close(); + } + const report = {browser:await browser.version(), scope:'Production template and initializeChatTab, real desktop clipboard with test grants/denial, HTTP upload stubs; native Android capability evidence is separate', results}; + if (evidence) fs.writeFileSync(path.join(evidence, 'results.json'), JSON.stringify(report,null,2)); + console.log(JSON.stringify(report)); + } finally { await browser.close(); server.close(); } +})().catch(error => { console.error(error); server.close(); process.exitCode = 1; }); diff --git a/tests/frontend/chatAttachmentWorkflowInput.test.js b/tests/frontend/chatAttachmentWorkflowInput.test.js index d2a2ff33..adb7cbbb 100644 --- a/tests/frontend/chatAttachmentWorkflowInput.test.js +++ b/tests/frontend/chatAttachmentWorkflowInput.test.js @@ -3,6 +3,7 @@ const chatTabModulePath = '../../src/frontend/web/von_interface/static/js/chatTab.js'; jest.mock('../../src/frontend/web/von_interface/static/js/apiService.js', () => ({ + WINDOW_SESSION_HEADER: 'X-Von-Window-Session', annotateTurn: jest.fn(), getUserContext: jest.fn(), getWindowSessionId: jest.fn(() => 'window-attachment-test') @@ -75,6 +76,54 @@ describe('chat attachment workflow input binding', () => { delete global.fetch; }); + test('explicit clipboard image preserves caption and binds to only the next message', async () => { + const { __testOnly_uploadFilesToVon, sendMessage } = require(chatTabModulePath); + const { initialiseImagePicker } = require('../../src/frontend/web/von_interface/static/js/utils/conversationImages.js'); + document.body.insertAdjacentHTML('beforeend', ''); + const bodies = []; + global.fetch = jest.fn(async (url, options = {}) => { + if (url === '/von/api/images/upload') return {ok:true, json:async () => ({image_attachment:{concept_id:'#V#clipboard-image', filename:'pasted-image-1.png'}})}; + if (String(url).startsWith('/von/generate')) { + bodies.push(JSON.parse(options.body)); + return {ok:true, json:async () => ({response:'Image received.', llm_debug:{model:'test-model'}})}; + } + if (String(url).startsWith('/von/history/length')) return {ok:true, json:async () => ({history_length:0, authenticated:true})}; + if (String(url).startsWith('/von/api/render_markdown')) return {ok:true, json:async () => ({html:JSON.parse(options.body).text || ''})}; + return {ok:true, json:async () => ({})}; + }); + const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); + Object.defineProperty(navigator, 'clipboard', {configurable:true, value:{ + read:jest.fn(async () => [{types:['text/plain', 'image/png'], getType:async () => new Blob(['image'], {type:'image/png'})}]) + }}); + try { + let finishUpload; + const uploaded = new Promise(resolve => { finishUpload = resolve; }); + initialiseImagePicker(async files => { + await __testOnly_uploadFilesToVon(files); + finishUpload(); + }, jest.fn()); + const input = document.querySelector('#promptInput'); + input.value = 'Describe this screenshot\nKeep the caption'; + document.querySelector('#pasteImageButton').click(); + await uploaded; + expect(input.value).toBe('Describe this screenshot\nKeep the caption'); + expect(document.querySelector('#conversationImageComposer img')).not.toBeNull(); + const upload = fetch.mock.calls.find(([url]) => url === '/von/api/images/upload'); + expect(upload[1].body.get('file').type).toBe('image/png'); + expect(upload[1].headers['X-Von-Window-Session']).toBe('window-attachment-test'); + await sendMessage(); + expect(bodies[0].image_attachment_ids).toEqual(['#V#clipboard-image']); + expect(bodies[0]).not.toHaveProperty('workflow_inputs'); + input.value = 'Next text-only question'; + await sendMessage(); + expect(bodies[1]).not.toHaveProperty('image_attachment_ids'); + expect(fetch.mock.calls.some(([url]) => url === '/von/api/files/upload')).toBe(false); + } finally { + if (originalClipboard) Object.defineProperty(navigator, 'clipboard', originalClipboard); + else delete navigator.clipboard; + } + }); + test('normal upload binds its trusted file-copy id to the next generate request', async () => { const { __testOnly_uploadFilesToVon,