From 29df9d137d617b092fb64143f59bb67303e34db4 Mon Sep 17 00:00:00 2001 From: witbrock Date: Thu, 17 Sep 2026 17:16:28 -0400 Subject: [PATCH] Prevent duplicate composer submissions during asynchronous sends --- .../web/von_interface/static/js/chatTab.js | 47 ++++++++++++ tests/browser/chatDuplicateSubmission.cjs | 72 ++++++++++++++++++ .../chatAttachmentWorkflowInput.test.js | 76 +++++++++++++++++++ 3 files changed, 195 insertions(+) create mode 100644 tests/browser/chatDuplicateSubmission.cjs diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index ec4e1bfc..2da77749 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -34342,6 +34342,7 @@ export function initializeChatTab() { // Add event listeners const submitComposer = (event) => { + if (isComposerDraftSubmissionInFlight()) return; updateSendButtonForCurrentChatState(); if (!chatSteeringControls?.activate(event)) void handleSendPrompt(); }; @@ -37208,7 +37209,46 @@ function getQueuedChatPromptHeadsBySession() { return heads; } +const composerSubmissionsInFlight = new Set(); + +function isComposerDraftSubmissionInFlight(input = getPromptInputElement()) { + return input && [...composerSubmissionsInFlight].some(claim => ( + claim.sessionId === activeChatSessionId + && claim.generation === chatOrganisationGeneration + && claim.text === input.value + )); +} + async function handleSendPrompt(options = {}) { + const input = getPromptInputElement(); + const direct = input && !options.fromQueue && !options.observeServerDispatch + && typeof options.promptOverride !== 'string' + && !getSelectedChatPromptQueueEntryForSend(); + if (!direct) return performSendPrompt(options); + + // Claim the draft before session creation or dictation can yield. A live + // request alone is insufficient: a repeated activation would queue the + // same draft (including text retained for attachment recovery). + const sessionId = activeChatSessionId; + const generation = chatOrganisationGeneration; + if (isComposerDraftSubmissionInFlight(input)) return; + const claim = { sessionId, generation, text: input.value }; + composerSubmissionsInFlight.add(claim); + const releaseEditedDraft = () => { + if (activeChatSessionId === claim.sessionId && input.value !== claim.text) { + composerSubmissionsInFlight.delete(claim); + } + }; + input.addEventListener('input', releaseEditedDraft); + try { + return await performSendPrompt(options, claim); + } finally { + input.removeEventListener('input', releaseEditedDraft); + composerSubmissionsInFlight.delete(claim); + } +} + +async function performSendPrompt(options = {}, composerClaim = null) { if (!options?.fromQueue && !options?.observeServerDispatch && chatSteeringControls?.isSending()) return; if (pendingChatOrganisationSwitchId !== null) { return; @@ -37357,6 +37397,12 @@ async function handleSendPrompt(options = {}) { return; } + if (composerClaim) { + composerClaim.sessionId = targetSessionId; + composerClaim.text = promptRaw; + composerSubmissionsInFlight.add(composerClaim); + } + if ( !observeServerDispatch && isChatPromptSessionBusy(targetSessionId, { includeQueued: !fromQueue }) @@ -42087,6 +42133,7 @@ export function __testOnly_resetChatRequestState() { chatPromptQueuePollInFlight = false; queuedChatPromptClaimsInFlight.clear(); queuedChatPromptSubmissionsInFlight.clear(); + composerSubmissionsInFlight.clear(); serverDispatchedRetryObserversInFlight.clear(); voicePromptQueueIds.clear(); chatPromptQueueAdmissionServerInstanceId = null; diff --git a/tests/browser/chatDuplicateSubmission.cjs b/tests/browser/chatDuplicateSubmission.cjs new file mode 100644 index 00000000..487e3862 --- /dev/null +++ b/tests/browser/chatDuplicateSubmission.cjs @@ -0,0 +1,72 @@ +// Real chat send and composer modules, isolated HTTP fixtures; no provider calls. +// PLAYWRIGHT_BROWSERS_PATH=/tmp/von-playwright node tests/browser/chatDuplicateSubmission.cjs +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const path = require('node:path'); +const { chromium } = require('@playwright/test'); +const root = path.resolve(__dirname, '../../src/frontend/web/von_interface'); +const output = process.argv[2] || '.run/duplicate-send/browser'; +fs.mkdirSync(output, { recursive: true }); +const markup = fs.readFileSync(path.join(root, 'templates/chat_tab.html'), 'utf8').replace(/{%[\s\S]*?%}/g, '').replace(/{{[\s\S]*?}}/g, ''); +const server = http.createServer((req, res) => { + if (req.url === '/') return res.end(`${markup}`); + const file = path.resolve(root, `.${req.url.split('?')[0]}`); + if (!file.startsWith(`${root}/static/`) || !fs.existsSync(file)) return res.writeHead(404).end(); + res.setHeader('Content-Type', file.endsWith('.css') ? 'text/css' : 'text/javascript'); + res.end(fs.readFileSync(file)); +}); +(async () => { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const browser = await chromium.launch(); + const evidence = []; + try { + for (const width of [1440, 375]) { + const page = await browser.newPage({ viewport: { width, height: 900 } }); + 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 () => { + window.queueBodies = []; window.generateBodies = []; + window.fetch = async (url, options = {}) => { + const body = JSON.parse(options.body && typeof options.body === 'string' ? options.body : '{}'); + let data = {}; + if (url === '/von/api/images/upload') data = { image_attachment: { concept_id: '#V#fixture-image', filename: 'fixture.png' } }; + else if (url === '/von/api/chat_prompt_queue' && options.method === 'POST') { + queueBodies.push(body); + data = { success: true, item: { ...body, queue_id: `queue-${queueBodies.length}`, status: 'queued' } }; + } else if (url === '/von/generate') { + generateBodies.push(body); + return new Promise(resolve => { window.finishGenerate = () => resolve(new Response(JSON.stringify({ response: 'Received.', llm_debug: { model: 'fixture' } }))); }); + } else if (String(url).includes('render_markdown')) data = { html: body.text || '' }; + else if (String(url).includes('history/length')) data = { history_length: 0, authenticated: true }; + else data = { items: [], history: [] }; + return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } }); + }; + window.chat = await import('/static/js/chatTab.js'); + chat.__testOnly_setActiveChatSession('fixture', 'Duplicate submission fixture'); + const { initialiseConversationDraft } = await import('/static/js/components/conversationDraft.js'); + const input = document.querySelector('#promptInput'); + initialiseConversationDraft({ input, sendButton: document.querySelector('#sendButton'), onSubmit: () => { window.pending = chat.sendMessage(); } }); + await chat.__testOnly_uploadFilesToVon([new File(['png'], 'fixture.png', { type: 'image/png' })]); + }); + await page.locator('#promptInput').fill('Describe this image once'); + await page.locator('#sendButton').dblclick(); + await page.waitForFunction(() => generateBodies.length === 1); + if (width > 800) await page.locator('#promptInput').press('Enter'); + else await page.locator('#sendButton').click(); + assert.deepEqual(await page.evaluate(() => [queueBodies.length, generateBodies.length]), [1, 1]); + await page.screenshot({ path: path.join(output, `${width}-single-submission.png`) }); + await page.locator('#promptInput').fill('A deliberate follow-up'); + await page.locator('#sendButton').click(); + await page.waitForFunction(() => queueBodies.length === 2); + assert.equal(await page.evaluate(() => queueBodies[1].prompt_raw), 'A deliberate follow-up'); + await page.evaluate(() => finishGenerate()); + assert.deepEqual(errors, []); + evidence.push({ width, generated: 1, admittedOriginal: 1, admittedFollowUp: 1, errors }); + await page.close(); + } + fs.writeFileSync(path.join(output, 'results.json'), JSON.stringify(evidence, null, 2)); + console.log(JSON.stringify(evidence)); + } 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 4513bf30..91641f93 100644 --- a/tests/frontend/chatAttachmentWorkflowInput.test.js +++ b/tests/frontend/chatAttachmentWorkflowInput.test.js @@ -118,6 +118,82 @@ describe('chat attachment workflow input binding', () => { } ); + test('concurrent sends while a conversation is being prepared admit only one prompt', async () => { + const { __testOnly_setActiveChatSession: select, sendMessage } = require(chatTabModulePath); + select(null); + let finishCreation; + const queueBodies = []; + const generateBodies = []; + global.fetch = jest.fn(async (url, options = {}) => { + if (url === '/von/api/session/create_chat_session') return new Promise(resolve => { finishCreation = resolve; }); + if (url === '/von/api/chat_prompt_queue' && options.method === 'POST') { + const body = JSON.parse(options.body); + queueBodies.push(body); + return { ok: true, json: async () => ({ success: true, item: { ...body, queue_id: `queue-${queueBodies.length}`, status: 'queued' } }) }; + } + if (url === '/von/generate') { + generateBodies.push(JSON.parse(options.body)); + return { ok: true, json: async () => ({ response: '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 () => ({ items: [], history: [] }) }; + }); + document.getElementById('promptInput').value = 'First message'; + const first = sendMessage(); + const repeated = sendMessage(); + for (let i = 0; i < 50 && !finishCreation; i++) await new Promise(resolve => setTimeout(resolve, 5)); + expect(finishCreation).toBeDefined(); + expect(global.fetch.mock.calls.filter(([url]) => url === '/von/api/session/create_chat_session')).toHaveLength(1); + finishCreation({ ok: true, json: async () => ({ success: true, session_id: 'created-session', history: [] }) }); + await Promise.all([first, repeated]); + expect(queueBodies).toHaveLength(1); + expect(generateBodies).toHaveLength(1); + }); + + test.each(['unchanged', 'edited', 'retyped'])('rapid repeat does not enqueue an in-flight attachment draft: %s', async mode => { + const { __testOnly_uploadFilesToVon, sendMessage } = require(chatTabModulePath); + let finish; + const queueBodies = []; + const generateBodies = []; + global.fetch = jest.fn(async (url, options = {}) => { + if (url === '/von/api/images/upload') return { ok: true, json: async () => ({ image_attachment: { concept_id: '#V#repeat-image', filename: 'repeat.png' } }) }; + if (url === '/von/api/chat_prompt_queue' && options.method === 'POST') { + const body = JSON.parse(options.body); + queueBodies.push(body); + return { ok: true, json: async () => ({ success: true, item: { ...body, queue_id: `queue-${queueBodies.length}`, status: 'queued' } }) }; + } + if (url === '/von/generate') { + generateBodies.push(JSON.parse(options.body)); + return new Promise(resolve => { finish = resolve; }); + } + 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 () => ({ items: [] }) }; + }); + await __testOnly_uploadFilesToVon([new File(['png'], 'repeat.png', { type: 'image/png' })]); + const input = document.getElementById('promptInput'); + input.value = 'Describe this image'; + const pending = sendMessage(); + await sendMessage(); + for (let i = 0; i < 50 && !finish; i++) await new Promise(resolve => setTimeout(resolve, 5)); + expect(finish).toBeDefined(); + await sendMessage(); + expect(generateBodies).toHaveLength(1); + expect(queueBodies).toHaveLength(1); + expect(input.value).toBe('Describe this image'); + if (mode !== 'unchanged') { + input.value = 'A different follow-up'; + input.dispatchEvent(new Event('input', { bubbles: true })); + if (mode === 'retyped') input.value = 'Describe this image'; + await sendMessage(); + expect(queueBodies).toHaveLength(2); + expect(queueBodies[1].prompt_raw).toBe(mode === 'retyped' ? 'Describe this image' : 'A different follow-up'); + } + finish({ ok: true, json: async () => ({ response: 'Received.', llm_debug: { model: 'test-model' } }) }); + await pending; + }); + test('successful text-only submission stays cleared after switching away and back', async () => { const { __testOnly_setActiveChatSession: select, sendMessage } = require(chatTabModulePath); global.fetch = jest.fn(async (url, options = {}) => ({ ok: true, json: async () => {