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
16 changes: 14 additions & 2 deletions src/frontend/web/von_interface/static/js/chatTab.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -35882,7 +35885,9 @@ async function refreshChatPromptQueueFromServer(options = {}) {
renderChatTaskQueuePanel();
refreshChatSessionTabActivityIndicators();
updateSendButtonForCurrentChatState();
syncServerDispatchedRetryObserver();
if (options.observeRetries !== false) {
syncServerDispatchedRetryObserver();
}
syncChatPromptQueuePolling();
if (shouldRefreshActiveHistory) {
refreshActiveConversationHistoryAfterQueueChange();
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -42023,6 +42034,7 @@ export function __testOnly_handleChatPromptQueueStorageEvent(event) {
handleChatPromptQueueStorageEvent(event);
}
export function __testOnly_resetChatRequestState() {
chatPromptQueueRefreshSequence += 1;
chatSteeringControls?.dispose();
chatSteeringControls = null;
chatSteeringSendButton = null;
Expand Down
115 changes: 115 additions & 0 deletions tests/browser/conversationQueueCompletion.cjs
Original file line number Diff line number Diff line change
@@ -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(/<script\b[^>]*>[\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; });
79 changes: 78 additions & 1 deletion tests/frontend/chatTaskQueue.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2310,6 +2385,7 @@ describe('chat task queue', () => {
});

let resolveGenerate = null;
let terminal = false;
const generateBodies = [];
const queued = {
queue_id: 'queue-manual-legacy',
Expand All @@ -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') {
Expand Down Expand Up @@ -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();
Expand Down
Loading