fix: refresh conversation revision before post-run actions

This commit is contained in:
snowspeeder committed 2026-10-03 12:10:14 -04:00
1 parent 0c57217ad9
commit 6bb2f53dce
2 files changed
+27 -4

No files matched your search

+24 -1
View File
@@ -36,6 +36,8 @@ def fixture(page, replay=False):
streams.append(r.request.url)
message = 'id: 1\nevent: message\ndata: {"message":{"id":"a1","role":"assistant","content":"## Answer\\n| Name | Value |\\n| --- | --- |\\n| <img src=x> | **safe** |\\n**Safe** and `clear`.\\n<script>window.injected=true</script>\\n[bad](javascript:alert(1))","status":"completed"}}\n\n'
done = 'id: 2\nevent: done\ndata: {"run":{"id":"r1","status":"completed"}}\n\n'
if not replay or len(streams) > 1:
for c in conversations.values(): c['revision'] += 1
r.fulfill(body=message if replay and len(streams) == 1 else message + done, content_type='text/event-stream'); return
elif path.endswith('/messages'):
cid = path.split('/')[3]; message = dict(id='u2', role='user', content=body['content'], status='completed'); messages[cid].append(message)
@@ -46,7 +48,10 @@ def fixture(page, replay=False):
elif path.startswith('/api/conversations/'):
cid = path.split('/')[3]
if method == 'DELETE': del conversations[cid]; data = {'ok':True}
elif method == 'PATCH': conversations[cid].update(body); conversations[cid]['revision'] += 1; data = {'conversation':conversations[cid]}
elif method == 'PATCH':
if body['revision'] != conversations[cid]['revision']:
r.fulfill(status=409,json={'error':'Stale revision'}); return
conversations[cid].update(body); conversations[cid]['revision'] += 1; data = {'conversation':conversations[cid]}
else: data = {'conversation':conversations[cid],'messages':messages[cid],'runs':[],'next_cursor':None}
elif path == '/api/auth/logout': data = {'ok':True}
else:
@@ -203,3 +208,21 @@ def test_qualified_research_and_private_export():
assert info.value.suggested_filename.endswith('.json')
assert all('/api/conversations/' in path for method,path,body in calls if path.endswith('/export'))
browser.close()
def test_immediate_pin_after_response_uses_final_server_revision():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(); calls, streams, conversations, messages = fixture(page)
page.goto('https://hope.test/'); page.wait_for_load_state('networkidle')
page.get_by_role('button',name='A saved thought',exact=True).click()
expect(page.locator('#chatTitle')).to_have_text('A saved thought')
page.locator('#primaryInput').fill('Finish and immediately pin'); page.locator('#primarySend').click()
expect(page.locator('#chatTimeline')).to_contain_text('Answer')
page.locator('#pinConversation').click()
expect(page.locator('#pinConversation')).to_have_text('Unpin')
assert conversations['c1']['pinned'] is True
assert 'Conversation refreshed' not in page.locator('#chatNotice').inner_text()
patch = next(body for method,path,body in calls if method == 'PATCH')
assert patch['revision'] == 2
browser.close()
+3 -3
View File
@@ -8,11 +8,11 @@
const notice = text => { $('chatNotice').textContent = text; };
const button = (text, fn, cls = 'btn btn-ghost') => { const b = document.createElement('button'); b.type = 'button'; b.className = cls; b.textContent = text; b.addEventListener('click', () => Promise.resolve().then(fn).catch(error => notice(error.message))); return b; };
function controls() {
const ready = HopeAuth.user?.status === 'ready', running = !terminal(state.run) || state.voiceActive;
const ready = HopeAuth.user?.status === 'ready', running = !terminal(state.run) || !!state.stream || state.voiceActive;
$('primaryInput').disabled = !ready || state.busy || running; $('chatTimeline').setAttribute('aria-busy', String(!terminal(state.run))); $('primaryComposer').setAttribute('aria-busy', String(state.busy));
$('primarySend').disabled = !ready || state.busy || running || window.HopeFeatures?.hasPendingUploads() || !$('primaryInput').value.trim();
$('cancelRun').hidden = terminal(state.run); $('researchMode').disabled = !ready || running || state.busy;
for (const id of ['pinConversation', 'renameConversation', 'moveConversation', 'exportMarkdown', 'exportJSON', 'archiveConversation', 'deleteConversation']) $(id).disabled = !state.conversation || state.busy;
for (const id of ['pinConversation', 'renameConversation', 'moveConversation', 'exportMarkdown', 'exportJSON', 'archiveConversation', 'deleteConversation']) $(id).disabled = !state.conversation || state.busy || !!state.stream || !terminal(state.run);
$('conversationActions').hidden = !state.conversation; $('conversationMenu').hidden = !state.conversation;
$('archiveConversation').textContent = state.conversation?.archived ? 'Unarchive' : 'Archive';
$('pinConversation').textContent = state.conversation?.pinned ? 'Unpin' : 'Pin';
@@ -163,7 +163,7 @@
} catch (error) {if (controller.signal.aborted) return; if (++retries >= 3) {notice('Connection interrupted. Reopen this conversation to resume the response.'); break;}}
await new Promise(resolve => {const timer = setTimeout(resolve, 1000); controller.signal.addEventListener('abort', () => {clearTimeout(timer); resolve();}, {once: true});});
}
} finally {if (state.stream === controller) {state.stream = null; $('runProgress').replaceChildren(); if (state.run?.error) notice(state.run.error); controls(); render();}}
} finally {if (state.stream === controller) {if (terminal(state.run) && epoch === state.epoch && !controller.signal.aborted) {try {await refreshSelected(false); await list();} catch (error) {if (epoch === state.epoch) notice('Response saved, but conversation details could not refresh. Reopen the conversation before changing it.');}} if (state.stream !== controller || epoch !== state.epoch) return; state.stream = null; $('runProgress').replaceChildren(); if (state.run?.error) notice(state.run.error); controls(); render();}}
}
function approval(data, runId) {const area = $('runProgress'); area.replaceChildren(); const text = document.createElement('p'); text.textContent = data.prompt || 'Hope needs your permission to continue.'; area.append(text); for (const decision of ['approve', 'deny']) area.append(button(decision === 'approve' ? 'Approve' : 'Deny', async () => {const result = await api('/api/runs/' + enc(runId) + '/approval', 'POST', {approval_id: data.approval_id, decision}); state.run = result.run; area.replaceChildren(); controls();}));}
$('cancelRun').onclick = async () => {try {const data = await api('/api/runs/' + enc(state.run.id) + '/cancel', 'POST'); state.run = data.run; controls();} catch (e) {notice(e.message);} };