From 5a011a9ec2b90f9b3a3f9fe325aef6d14e7e42dc Mon Sep 17 00:00:00 2001 From: witbrock Date: Wed, 16 Sep 2026 15:46:12 -0400 Subject: [PATCH] Reconcile conversation queue after foreground turn completion --- .../web/von_interface/static/js/chatTab.js | 16 ++- tests/browser/conversationQueueCompletion.cjs | 115 ++++++++++++++++++ tests/frontend/chatTaskQueue.test.js | 79 +++++++++++- 3 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 tests/browser/conversationQueueCompletion.cjs diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index e4eeca86..c87feca6 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -24450,6 +24450,7 @@ let queuedChatPromptCounter = 0; let chatPromptQueuePollTimerId = null; let chatPromptQueuePollInFlight = false; let chatPromptQueuePollAbortController = null; +let chatPromptQueueRefreshSequence = 0; const queuedChatPromptClaimsInFlight = new Set(); const queuedChatPromptSubmissionsInFlight = new Map(); const queuedChatPromptSyncTimers = new Map(); @@ -35817,12 +35818,14 @@ async function refreshChatPromptQueueFromServer(options = {}) { return false; } const organisationGenerationAtStart = chatOrganisationGeneration; + const refreshSequence = ++chatPromptQueueRefreshSequence; const previousEntries = [...queuedChatPrompts]; try { const data = await fetchChatPromptQueueJson('', { signal: options.signal }); - if (!isChatOrganisationRequestCurrent(organisationGenerationAtStart)) { + if (!isChatOrganisationRequestCurrent(organisationGenerationAtStart) + || refreshSequence !== chatPromptQueueRefreshSequence) { return false; } chatPromptQueueAdmissionServerInstanceId = ( @@ -35882,7 +35885,9 @@ async function refreshChatPromptQueueFromServer(options = {}) { renderChatTaskQueuePanel(); refreshChatSessionTabActivityIndicators(); updateSendButtonForCurrentChatState(); - syncServerDispatchedRetryObserver(); + if (options.observeRetries !== false) { + syncServerDispatchedRetryObserver(); + } syncChatPromptQueuePolling(); if (shouldRefreshActiveHistory) { refreshActiveConversationHistoryAfterQueueChange(); @@ -38162,6 +38167,12 @@ async function handleSendPrompt(options = {}) { scheduleChatSessionTabsRefresh(true); if (request.serverQueueTerminalObserved) { publishChatPromptQueueChange('turn_terminal'); + // Storage events notify other tabs only. Reconcile this tab too, + // using canonical state rather than inferring completion for any + // other queued prompt. Starting this read also retires older reads. + // A task result can arrive before its queue projection catches up; + // do not immediately attach another observer to that same attempt. + void refreshChatPromptQueueFromServer({ silent: true, observeRetries: false }); } syncChatPromptQueuePolling(); } @@ -42023,6 +42034,7 @@ export function __testOnly_handleChatPromptQueueStorageEvent(event) { handleChatPromptQueueStorageEvent(event); } export function __testOnly_resetChatRequestState() { + chatPromptQueueRefreshSequence += 1; chatSteeringControls?.dispose(); chatSteeringControls = null; chatSteeringSendButton = null; diff --git a/tests/browser/conversationQueueCompletion.cjs b/tests/browser/conversationQueueCompletion.cjs new file mode 100644 index 00000000..39eaad86 --- /dev/null +++ b/tests/browser/conversationQueueCompletion.cjs @@ -0,0 +1,115 @@ +// Queue completion acceptance using production templates and queue/send/switch code. +// No authentication, live messages, database writes, or model calls are exercised. +// PLAYWRIGHT_BROWSERS_PATH=/tmp/von-playwright node tests/browser/conversationQueueCompletion.cjs DIR +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 evidence = process.argv[2] || '.run/queue-evidence/browser'; +fs.mkdirSync(evidence, { recursive: true }); +function template(name) { + return fs.readFileSync(path.join(root, 'templates', name), 'utf8') + .replace(/{% include '([^']+)' %}/g, (_, child) => template(child)) + .replace(/]*>[\s\S]*?<\/script>/g, '') + .replace(/{{ url_for\('static', filename='([^']+)'\) }}/g, '/static/$1') + .replace(/{%[\s\S]*?%}|{{[\s\S]*?}}/g, ''); +} +const server = http.createServer((req, res) => { + const pathname = new URL(req.url, 'http://localhost').pathname; + if (pathname === '/') { res.setHeader('Content-Type', 'text/html'); return res.end(template('von_interface.html')); } + const file = path.resolve(root, `.${pathname}`); + if (!file.startsWith(`${root}/static/`) || !fs.existsSync(file)) return res.writeHead(404).end(); + res.setHeader('Content-Type', file.endsWith('.css') ? 'text/css' : file.endsWith('.png') ? 'image/png' : '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({ headless: true }); + const results = []; + try { + for (const width of [1440, 390]) { + const page = await browser.newPage({ viewport: { width, height: 900 } }); + const finished = { queue_id: 'finished', session_id: 'first', session_name: 'Find Email from Yitan', prompt_raw: 'Find Email from Yitan', status: 'queued' }; + const pending = { queue_id: 'pending', session_id: 'first', prompt_raw: 'A distinct unsent request', status: 'queued' }; + let terminal = false; + let finishGenerate; + let releaseOld; + let holdRead = false; + const calls = []; + await page.route('**/*', async route => { + const url = new URL(route.request().url()); + if (!url.pathname.includes('/api/') && !url.pathname.startsWith('/von/')) return route.continue(); + calls.push({ path: url.pathname, method: route.request().method() }); + let data = {}; + if (url.pathname === '/von/api/chat_prompt_queue') { + if (route.request().method() === 'POST') data = { item: finished }; + else { + data = { items: terminal ? [pending] : [finished, pending] }; + if (holdRead) { + holdRead = false; + await new Promise(resolve => { releaseOld = resolve; }); + } + } + } else if (url.pathname === '/von/generate') { + await new Promise(resolve => { finishGenerate = resolve; }); + terminal = true; + data = { response: 'Completed fixture response.' }; + } else if (url.pathname === '/von/api/session/set_chat_session') data = { session_id: route.request().postDataJSON().session_id }; + else if (url.pathname === '/von/history') data = { history: [], session_id: url.searchParams.get('session_id'), authenticated: true }; + else if (url.pathname === '/von/history/length') data = { history_length: 0, authenticated: true }; + else if (url.pathname === '/von/api/render_markdown') data = { html: route.request().postDataJSON().text || '' }; + await route.fulfill({ json: data }); + }); + const mount = () => page.evaluate(async () => { + document.body.classList.remove('von-auth-pending'); + document.querySelector('#vonAuthenticationGate').hidden = true; + document.querySelector('#vonAuthenticatedApp').hidden = false; + localStorage.setItem('von_current_user', JSON.stringify({ concept_id: '#V#fixture_user' })); + window.chat = await import('/static/js/chatTab.js'); + chat.__testOnly_setActiveChatSession('first', 'Find Email from Yitan'); + }); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await mount(); + await page.locator('#promptInput').fill(finished.prompt_raw); + await page.evaluate(() => { window.sending = chat.sendMessage(); }); + await expect.poll(() => Boolean(finishGenerate)).toBe(true); + await page.evaluate(() => chat.switchToChatSession('second')); + holdRead = true; + await page.evaluate(() => { window.oldRead = chat.__testOnly_refreshChatPromptQueueFromServer(); }); + await expect.poll(() => Boolean(releaseOld)).toBe(true); + finishGenerate(); + await page.evaluate(() => sending); + await expect.poll(() => calls.filter(c => c.path === '/von/api/chat_prompt_queue' && c.method === 'GET').length).toBe(2); + // Wait for the completion read to apply before releasing the stale response. + await page.evaluate(() => chat.__testOnly_setActiveChatSession('first', 'Find Email from Yitan')); + await expect(page.locator('.chat-task-queue-edit')).toHaveValue(pending.prompt_raw); + const historyReads = () => calls.filter(c => c.path === '/von/history').length; + const countBeforeOld = historyReads(); + releaseOld(); + assert.equal(await page.evaluate(() => oldRead), false); + assert.equal(historyReads(), countBeforeOld); + await page.evaluate(() => chat.switchToChatSession('first')); + await expect(page.locator('.chat-task-queue-edit')).toHaveCount(1); + await expect(page.locator('.chat-task-queue-edit')).toHaveValue(pending.prompt_raw); + await expect(page.locator('#chatTaskQueueCount')).toContainText('1'); + await page.screenshot({ path: path.join(evidence, `${width}-pending-only.png`) }); + await page.reload(); + await mount(); + await page.evaluate(() => chat.__testOnly_refreshChatPromptQueueFromServer()); + await expect(page.locator('.chat-task-queue-edit')).toHaveCount(1); + await expect(page.locator('.chat-task-queue-edit')).toHaveValue(pending.prompt_raw); + assert.equal(calls.filter(c => c.path === '/von/generate').length, 1); + assert.equal(calls.some(c => /\/(finish|claim)$/.test(c.path) || c.method === 'DELETE'), false); + results.push({ width, stale_read_ignored: true, switched_and_remounted: true, unrelated_queue_preserved: true, executions: 1 }); + await page.close(); + } + fs.writeFileSync(path.join(evidence, 'results.json'), JSON.stringify({ mode: 'production modules with local HTTP fixtures; no live authentication or model calls', results }, null, 2)); + console.log(JSON.stringify(results)); + } finally { + await browser.close(); + await new Promise(resolve => server.close(resolve)); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/frontend/chatTaskQueue.test.js b/tests/frontend/chatTaskQueue.test.js index 8cfd0f69..a67c3f28 100644 --- a/tests/frontend/chatTaskQueue.test.js +++ b/tests/frontend/chatTaskQueue.test.js @@ -255,6 +255,14 @@ describe('chat task queue', () => { }); } + if (url === '/von/api/chat_prompt_queue' && options.method === 'GET') { + return Promise.resolve({ ok: true, json: async () => ({ + items: queueBodies.slice(1).map((body, index) => ({ + ...body, queue_id: `queue-${index + 2}`, status: 'queued' + })) + }) }); + } + if (typeof url === 'string' && url.startsWith('/von/generate')) { const parsed = JSON.parse(options.body || '{}'); generateBodies.push(parsed); @@ -1365,6 +1373,73 @@ describe('chat task queue', () => { expect(document.getElementById('sendButton').textContent).toBe('Send Prompt'); }, 15000); + test.each([false, true])('reconciles a foreground completion in its own tab (switch away: %s)', async (switchAway) => { + const chat = require(chatTabModulePath); + const { getUserContext } = require('../../src/frontend/web/von_interface/static/js/apiService.js'); + getUserContext.mockReturnValue({ user_id: 'user', org_id: 'org', language: 'en-NZ' }); + const completed = { queue_id: 'finished', session_id: 'session-1', + prompt_raw: 'Find Email from Yitan', status: 'queued' }; + const pending = { queue_id: 'pending', session_id: 'session-1', + prompt_raw: 'A distinct unsent request', status: 'queued' }; + let finish; + let terminal = false; + const calls = []; + global.fetch = jest.fn((url, options = {}) => { + calls.push({ url, method: options.method || 'GET' }); + const reply = data => Promise.resolve({ ok: true, json: async () => data }); + if (url === '/von/api/chat_prompt_queue') { + if (options.method === 'POST') return reply({ item: completed }); + return reply({ items: terminal ? [pending] : [completed, pending] }); + } + if (url === '/von/generate') return new Promise(resolve => { + finish = () => { terminal = true; resolve({ ok: true, json: async () => ({ response: 'Done' }) }); }; + }); + if (String(url).startsWith('/von/history/length')) return reply({ history_length: 0 }); + return reply({}); + }); + document.getElementById('promptInput').value = completed.prompt_raw; + const sending = chat.sendMessage(); + await flushMicrotasks(); + expect(finish).toBeDefined(); + if (switchAway) chat.__testOnly_setActiveChatSession('session-2', 'Other'); + await chat.__testOnly_refreshChatPromptQueueFromServer(); + finish(); + await sending; + await flushMicrotasks(); + chat.__testOnly_setActiveChatSession('session-1', 'Current'); + expect(Array.from(document.querySelectorAll('.chat-task-queue-edit'), el => el.value)).not.toContain(completed.prompt_raw); + expect(Array.from(document.querySelectorAll('.chat-task-queue-edit'), el => el.value)).toContain(pending.prompt_raw); + expect(calls.filter(call => call.url === '/von/api/chat_prompt_queue' && call.method === 'GET')).toHaveLength(2); + expect(calls.filter(call => call.url === '/von/generate')).toHaveLength(1); + expect(calls.some(call => /\/(finish|claim)$/.test(call.url) || call.method === 'DELETE')).toBe(false); + expect(calls.some(call => call.url.startsWith('/von/history?'))).toBe(false); + // A fresh mount obtains the same canonical queue without replaying work. + chat.__testOnly_resetChatRequestState(); + chat.__testOnly_setActiveChatSession('session-1', 'Current'); + await chat.__testOnly_refreshChatPromptQueueFromServer(); + expect(Array.from(document.querySelectorAll('.chat-task-queue-edit'), el => el.value)).not.toContain(completed.prompt_raw); + expect(Array.from(document.querySelectorAll('.chat-task-queue-edit'), el => el.value)).toContain(pending.prompt_raw); + expect(calls.filter(call => call.url === '/von/generate')).toHaveLength(1); + }); + + test('an older queue read cannot resurrect a completed row or refetch history', async () => { + const chat = require(chatTabModulePath); + let resolveOld; + let count = 0; + global.fetch = jest.fn(() => { + count += 1; + if (count === 1) return new Promise(resolve => { resolveOld = resolve; }); + return Promise.resolve({ ok: true, json: async () => ({ items: [] }) }); + }); + const oldRead = chat.__testOnly_refreshChatPromptQueueFromServer(); + await chat.__testOnly_refreshChatPromptQueueFromServer(); + resolveOld({ ok: true, json: async () => ({ items: [{ queue_id: 'finished', + session_id: 'session-1', prompt_raw: 'Find Email from Yitan', status: 'queued', dispatch_mode: 'server' }] }) }); + expect(await oldRead).toBe(false); + expect(document.querySelector('.chat-task-queue-item')).toBeNull(); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + test('polls server-owned queue work and refreshes active history after its row disappears', async () => { const { __testOnly_refreshChatPromptQueueFromServer, @@ -2310,6 +2385,7 @@ describe('chat task queue', () => { }); let resolveGenerate = null; + let terminal = false; const generateBodies = []; const queued = { queue_id: 'queue-manual-legacy', @@ -2323,7 +2399,7 @@ describe('chat task queue', () => { return Promise.resolve({ ok: true, status: 200, - json: async () => ({ success: true, items: [queued] }) + json: async () => ({ success: true, items: terminal ? [] : [queued] }) }); } if (url === '/von/api/chat_prompt_queue/queue-manual-legacy' && options.method === 'PATCH') { @@ -2376,6 +2452,7 @@ describe('chat task queue', () => { expect(generateBodies[0].prompt_queue_id).toBe('queue-manual-legacy'); expect(document.querySelector('[data-queue-id="queue-manual-legacy"]')).toBeTruthy(); + terminal = true; resolveGenerate(); await flushMicrotasks(); await flushMicrotasks();