Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/frontend/web/von_interface/static/js/chatTab.js
Original file line number Diff line number Diff line change
Expand Up @@ -34342,6 +34342,7 @@ export function initializeChatTab() {

// Add event listeners
const submitComposer = (event) => {
if (isComposerDraftSubmissionInFlight()) return;
updateSendButtonForCurrentChatState();
if (!chatSteeringControls?.activate(event)) void handleSendPrompt();
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -42087,6 +42133,7 @@ export function __testOnly_resetChatRequestState() {
chatPromptQueuePollInFlight = false;
queuedChatPromptClaimsInFlight.clear();
queuedChatPromptSubmissionsInFlight.clear();
composerSubmissionsInFlight.clear();
serverDispatchedRetryObserversInFlight.clear();
voicePromptQueueIds.clear();
chatPromptQueueAdmissionServerInstanceId = null;
Expand Down
72 changes: 72 additions & 0 deletions tests/browser/chatDuplicateSubmission.cjs
Original file line number Diff line number Diff line change
@@ -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(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="/static/styles.css">${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; });
76 changes: 76 additions & 0 deletions tests/frontend/chatAttachmentWorkflowInput.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading