diff --git a/routes/chat_routes.py b/routes/chat_routes.py index d6fea66ff..373ae97ab 100644 --- a/routes/chat_routes.py +++ b/routes/chat_routes.py @@ -3926,6 +3926,19 @@ def setup_chat_routes( if ctx.preprocessed.attachment_meta: yield f"data: {json.dumps({'type': 'attachments', 'data': ctx.preprocessed.attachment_meta})}\n\n" + # Give the page the saved user message's id. Only the reply's id + # was sent back, so a message sent in the open page had none and + # a later regenerate/edit of it could not cut the chat by id. + try: + _last_user_msg = next((m for m in reversed(getattr(sess, "history", None) or []) + if getattr(m, "role", None) == "user"), None) + _user_db_id = ((getattr(_last_user_msg, "metadata", None) or {}).get("_db_id") + if _last_user_msg is not None else None) + if _user_db_id: + yield f"data: {json.dumps({'type': 'user_message_saved', 'id': _user_db_id})}\n\n" + except Exception: + pass + # Announce any docs auto-created during preprocess (e.g. fillable # PDF → editable markdown) so the editor pane switches to them # before the model starts streaming. diff --git a/routes/history/history_routes.py b/routes/history/history_routes.py index a14be5908..617ff7ed7 100644 --- a/routes/history/history_routes.py +++ b/routes/history/history_routes.py @@ -825,6 +825,23 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter: try: body = await request.json() keep_count = body.get("keep_count", 0) + # A message id is exact; keep_count from the client counts only the + # messages loaded in the page. before_msg_id forks before that + # message, through_msg_id includes it. + before_msg_id = str(body.get("before_msg_id") or "").strip() + through_msg_id = str(body.get("through_msg_id") or "").strip() + if before_msg_id or through_msg_id: + db = SessionLocal() + try: + all_db_messages = db.query(DbChatMessage).filter( + DbChatMessage.session_id == session_id + ).order_by(DbChatMessage.timestamp).all() + finally: + db.close() + resolved = _keep_count_before_message(all_db_messages, before_msg_id or through_msg_id) + if resolved is None: + raise HTTPException(404, "Message not found") + keep_count = resolved + (0 if before_msg_id else 1) # Get the source session. keep_count indexes into source.history, # so this must go through get_session — reading the cache directly diff --git a/static/js/chat.js b/static/js/chat.js index da27508ef..ba87066f1 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -336,6 +336,9 @@ import { invalidateSettings } from './appConfig.js'; let _sendInFlight = false; // covers the window from click → streaming start let _displayOverride = null; // Override visible user bubble text (hides injected prompts) let _hideUserBubble = false; // Skip user bubble entirely (e.g. continue after stop) + // The user bubble a regenerate keeps on screen. Its resubmitted message is + // a new DB row, so the saved id is attached here. + let _regenKeptUserBubble = null; let _contextHeaderSeq = 0; let _contextHeaderData = null; let _contextHeaderBound = false; @@ -4995,6 +4998,15 @@ import { invalidateSettings } from './appConfig.js'; refreshChatContextHeader('metrics'); } + } else if (json.type === 'user_message_saved') { + // The user bubble gets its DB id too, so regenerating or + // editing it later cuts the chat by id, not page position. + if (!_isBg && json.id) { + if (_userMsgEl) _userMsgEl.dataset.dbId = json.id; + else if (_regenKeptUserBubble) _regenKeptUserBubble.dataset.dbId = json.id; + _regenKeptUserBubble = null; + } + } else if (json.type === 'message_saved') { // Wire the persisted DB id onto the just-streamed bubble so it // can be edited/deleted immediately, without reloading the chat. @@ -7236,6 +7248,22 @@ import { invalidateSettings } from './appConfig.js'; /** * Edit a user message: show an input, truncate to before it, resubmit the edited text. */ + // Recover attached file IDs from rendered user bubbles, including older + // image previews that were rendered before data-file-id was stamped. + function _userMessageAttachmentIds(msgElement) { + if (!msgElement) return []; + const els = msgElement.querySelectorAll('[data-file-id]'); + const ids = Array.from(els).map(el => el.dataset.fileId).filter(Boolean); + if (!ids.length) { + const images = msgElement.querySelectorAll('.attach-image-preview img, .attach-card img'); + for (const image of images) { + const match = (image.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/); + if (match && match[1] && !ids.includes(match[1])) ids.push(match[1]); + } + } + return ids; + } + export async function editUserMessage(userMsgElement) { const box = document.getElementById('chat-history'); const allMsgs = Array.from(box.querySelectorAll('.msg')); @@ -7283,23 +7311,49 @@ import { invalidateSettings } from './appConfig.js'; e.stopPropagation(); const newText = editor.value.trim(); if (!newText) return; + const editAttachmentIds = _userMessageAttachmentIds(userMsgElement); const sessionId = sessionModule.getCurrentSessionId(); if (!sessionId) return; + // Everything after the original reply to this message would also be + // destroyed. Cancelling leaves the editor open with the draft intact. + const extraCount = allMsgs.length - msgIndex - 2; + const choice = await _confirmTruncateIfDestructive(extraCount, 'edit'); + if (!choice) return; + if (choice === 'fork') { + // Branch just before this user turn and send the edit there; this + // chat keeps the original message and everything after it. + try { + await _forkAndSend(msgIndex, userMsgElement.dataset.dbId || '', newText, editAttachmentIds); + bodyEl.innerHTML = originalHTML; + } catch (err) { + console.error('Fork failed:', err); + if (uiModule) uiModule.showError('Fork failed: ' + err.message); + } + return; + } + const keepCount = msgIndex; + // Cut at the message's database id: msgIndex counts only the messages + // loaded in the page, so it is too small when older history has not + // been scrolled in yet. + const editBeforeId = userMsgElement.dataset.dbId || ''; try { - await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, { + const editTruncate = await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ keep_count: keepCount }) + body: JSON.stringify(editBeforeId ? { before_msg_id: editBeforeId } : { keep_count: keepCount }) }); + if (!editTruncate.ok) throw new Error('Server error ' + editTruncate.status); // Remove DOM elements from msgIndex onward for (let i = allMsgs.length - 1; i >= msgIndex; i--) { allMsgs[i].remove(); } + // The edited user turn still refers to its original attachments. + _pendingRegenAttachments = editAttachmentIds; // Submit the edited text const messageInput = uiModule.el('message'); messageInput.value = newText; @@ -7323,6 +7377,59 @@ import { invalidateSettings } from './appConfig.js'; }); } + // Regenerate, resend and edit permanently delete (server-side truncate) + // everything after the point clicked. Ask first unless nothing comes after + // it (extraCount 0), offering a fork that leaves this chat untouched. + // Resolves to 'proceed', 'fork', or null (cancelled). + const _TRUNCATE_ACTIONS = { + regenerate: { lead: 'Regenerating here', confirmText: 'Delete and regenerate' }, + resend: { lead: 'Resending this message', confirmText: 'Delete and resend' }, + edit: { lead: 'Sending this edit', confirmText: 'Delete and send' }, + }; + async function _confirmTruncateIfDestructive(extraCount, action = 'regenerate') { + if (extraCount <= 0) return 'proceed'; + const { lead, confirmText } = _TRUNCATE_ACTIONS[action] || _TRUNCATE_ACTIONS.regenerate; + const result = await uiModule.styledConfirm( + `${lead} will also permanently delete the ${extraCount} ` + + `message${extraCount === 1 ? '' : 's'} after it. This can't be undone. ` + + `Fork from here keeps this chat as it is and continues in a new one instead.`, + { confirmText, alternateText: 'Fork from here', cancelText: 'Cancel', danger: true } + ); + if (result === 'alternate') return 'fork'; + return result ? 'proceed' : null; + } + + // Copy the first `keepCount` messages of the current chat into a new chat + // and switch to it. Returns the new session id; throws on failure so callers + // that replay an action in the fork can stop before sending anything. + // Fork by message id when the element has one (before it, or through it); + // keepCount is only a fallback, since it counts loaded messages only. + async function _forkSessionAt(keepCount, beforeMsgId = '', throughMsgId = '') { + const sessionId = sessionModule.getCurrentSessionId(); + if (!sessionId) throw new Error('No active chat'); + const res = await fetch(`${API_BASE}/api/session/${sessionId}/fork`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ keep_count: keepCount, + ...(beforeMsgId ? { before_msg_id: beforeMsgId } : throughMsgId ? { through_msg_id: throughMsgId } : {}) }), + }); + if (!res.ok) throw new Error(await res.text()); + const data = await res.json(); + await sessionModule.loadSessions(); + await sessionModule.selectSession(data.id); + if (uiModule) uiModule.showToast(`Forked → ${data.name}`); + return data.id; + } + + // Fork before a user message and send `text` there; this chat is untouched. + // `attachments` (if given) are re-sent only once the fork has succeeded. + async function _forkAndSend(keepCount, beforeMsgId, text, attachments) { + await _forkSessionAt(keepCount, beforeMsgId); + if (attachments !== undefined) _pendingRegenAttachments = attachments; + uiModule.el('message').value = text; + document.querySelector('.send-btn')?.click(); + } + /** * Resend a user message. By default this replaces from the selected user turn * so failed attempts do not remain in model context or SFT traces. Callers can @@ -7344,18 +7451,7 @@ import { invalidateSettings } from './appConfig.js'; let text = (userMsgElement.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim(); text = text.replace(/\s*\[\d+ attachment\(s\)\]$/, ''); - // Collect file_ids attached to this user message so the resend re-carries - // the photos / docs (and the chat handler picks up the user-edited OCR - // text cached server-side under those file ids). - const _attachEls = userMsgElement.querySelectorAll('[data-file-id]'); - let _ids = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean); - if (!_ids.length) { - const _imgs = userMsgElement.querySelectorAll('.attach-image-preview img, .attach-card img'); - for (const _im of _imgs) { - const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/); - if (_m && _m[1] && !_ids.includes(_m[1])) _ids.push(_m[1]); - } - } + const _ids = _userMessageAttachmentIds(userMsgElement); // Rescue: legacy bubbles may have stored the filename as the message // content (artifact of earlier broken resends). Don't re-send that as @@ -7378,6 +7474,18 @@ import { invalidateSettings } from './appConfig.js'; try { if (replaceFromHere) { + // Everything strictly after the immediate AI reply to this user + // message would also be destroyed — that's the part a click on + // Resend / "Regenerate message" doesn't expect to lose. + const extraCount = allMsgs.length - msgIndex - 2; + const choice = await _confirmTruncateIfDestructive(extraCount, 'resend'); + if (!choice) return; + if (choice === 'fork') { + // Branch just before this user turn and send it again there; this + // chat keeps every message. + await _forkAndSend(msgIndex, userMsgElement.dataset.dbId || '', text, _ids); + return; + } // Resend/regenerate trims history to this point before resubmitting so // the replacement request is the only copy the backend sees. const keepCount = msgIndex; @@ -7442,25 +7550,14 @@ import { invalidateSettings } from './appConfig.js'; return; } - // Collect any file_ids attached to the original user message so the - // regenerated send re-uses them. Without this the AI is regenerated on - // text alone — photos (and the user-edited OCR text cached server-side - // under that file_id) would be silently dropped. - const _attachEls = userMsgEl ? userMsgEl.querySelectorAll('[data-file-id]') : []; - let _regenIds = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean); - // Fallback for bubbles rendered before the data-file-id stamp landed: - // sniff the file id straight out of any `.attach-image-preview img` - // src URLs (matches /api/upload/). Otherwise an older bubble would - // regen with zero attachments and the photo would be lost from the - // resulting message even though the file still exists on disk. - if (!_regenIds.length && userMsgEl) { - const _imgs = userMsgEl.querySelectorAll('.attach-image-preview img, .attach-card img'); - for (const _im of _imgs) { - const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/); - if (_m && _m[1] && !_regenIds.includes(_m[1])) _regenIds.push(_m[1]); - } - } - _pendingRegenAttachments = _regenIds; + // Everything strictly after the AI reply being regenerated would also + // be destroyed — that's the part a click on "Regenerate from here" + // doesn't expect to lose. + const extraCount = allMsgs.length - aiIndex - 1; + const choice = await _confirmTruncateIfDestructive(extraCount, 'regenerate'); + if (!choice) return; + + const _regenIds = _userMessageAttachmentIds(userMsgEl); // Rescue: earlier-version regens (before the dataset.raw fix) stored the // photo's filename as the user-message content. On a follow-up regen, @@ -7469,7 +7566,7 @@ import { invalidateSettings } from './appConfig.js'; // an image file". If userText is just a bare image filename and we have // attachments, drop it so the OCR text (or the image bytes for vision // models) is what the model actually sees. - if (userText && _pendingRegenAttachments.length && + if (userText && _regenIds.length && /^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i.test(userText.trim())) { userText = ''; } @@ -7477,7 +7574,7 @@ import { invalidateSettings } from './appConfig.js'; // A photo-only message has empty user text — regen must still proceed, // because the attachments themselves are the message. Bail only if there // is no text AND no attachments to send. - if (!userText && !_pendingRegenAttachments.length) { + if (!userText && !_regenIds.length) { if (uiModule) uiModule.showError('Nothing to regenerate — the user message has no text and no attachments'); return; } @@ -7485,6 +7582,20 @@ import { invalidateSettings } from './appConfig.js'; const sessionId = sessionModule.getCurrentSessionId(); if (!sessionId) return; + if (choice === 'fork') { + // Branch just before the user turn and send it again there, so the new + // chat gets a fresh reply to the same message; this chat keeps every + // message. No variants or hidden bubble: the fork starts without them. + try { + await _forkAndSend(userIndex, (userMsgEl && userMsgEl.dataset.dbId) || '', userText, _regenIds); + } catch (err) { + if (_pendingRegenAttachments === _regenIds) _pendingRegenAttachments = null; + console.error('Fork failed:', err); + if (uiModule) uiModule.showError('Fork failed: ' + err.message); + } + return; + } + // Save current response as a variant const oldRaw = aiMsgElement.dataset.raw || aiMsgElement.querySelector('.body')?.textContent || ''; const oldHtml = aiMsgElement.querySelector('.body')?.innerHTML || ''; @@ -7496,13 +7607,19 @@ import { invalidateSettings } from './appConfig.js'; } const keepCount = userIndex; + // Same as resend: cut at the user message's database id, because + // userIndex counts only the messages loaded in the page. + const regenBeforeId = (userMsgEl && userMsgEl.dataset.dbId) || ''; try { - await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, { + const regenTruncate = await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ keep_count: keepCount }) + body: JSON.stringify(regenBeforeId ? { before_msg_id: regenBeforeId } : { keep_count: keepCount }) }); + // A refused cut (e.g. a stale id) must not stack a second reply. + if (!regenTruncate.ok) throw new Error('Server error ' + regenTruncate.status); + _pendingRegenAttachments = _regenIds; // Keep the original user bubble, but remove every rendered trace after // it, including agent-thread tool history between the user and AI bubble. @@ -7519,12 +7636,14 @@ import { invalidateSettings } from './appConfig.js'; _pendingVariantLabel = 'regen'; _hideUserBubble = true; + _regenKeptUserBubble = userMsgEl; const messageInput = uiModule.el('message'); messageInput.value = userText; const submitBtn = document.querySelector('.send-btn'); if (submitBtn) submitBtn.click(); } catch (err) { + if (_pendingRegenAttachments === _regenIds) _pendingRegenAttachments = null; console.error('Regenerate failed:', err); if (uiModule) uiModule.showError('Regenerate failed: ' + err.message); } @@ -7536,6 +7655,8 @@ import { invalidateSettings } from './appConfig.js'; // File-ids carried over from the original user message during a regen, so // photos / OCR overrides survive into the new send. Consumed once. let _pendingRegenAttachments = null; + // Read-only view for tests. + export function _pendingRegenAttachmentIds() { return _pendingRegenAttachments; } /** * Called after streaming completes to attach variant navigation if this was a regen. @@ -7675,20 +7796,8 @@ import { invalidateSettings } from './appConfig.js'; const sessionId = sessionModule.getCurrentSessionId(); if (!sessionId) return; - const keepCount = aiIndex + 1; - try { - const res = await fetch(`${API_BASE}/api/session/${sessionId}/fork`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ keep_count: keepCount }), - }); - if (!res.ok) throw new Error(await res.text()); - const data = await res.json(); - - await sessionModule.loadSessions(); - await sessionModule.selectSession(data.id); - if (uiModule) uiModule.showToast(`Forked → ${data.name}`); + await _forkSessionAt(aiIndex + 1, '', aiMsgElement.dataset.dbId || ''); } catch (err) { console.error('Fork failed:', err); if (uiModule) uiModule.showError('Fork failed: ' + err.message); diff --git a/tests/test_fork_session_metadata.py b/tests/test_fork_session_metadata.py index 8f36fec18..72ed15757 100644 --- a/tests/test_fork_session_metadata.py +++ b/tests/test_fork_session_metadata.py @@ -90,3 +90,42 @@ def test_fork_does_not_corrupt_source_message_metadata(monkeypatch): # ...and the source session's _db_id values are untouched. assert source.history[0].metadata["_db_id"] == "src-0" assert source.history[1].metadata["_db_id"] == "src-1" + + +def _fork_with(monkeypatch, body, db_ids): + """Fork a 4-message source whose DB rows have the given ids.""" + monkeypatch.setattr(mod, "_verify_session_owner", lambda *a, **k: None) + source = _FakeSession(name="Long chat") + for i, role in enumerate(["user", "assistant", "user", "assistant"]): + source.history.append(ChatMessage(role=role, content=f"m{i}", metadata={"_db_id": db_ids[i]})) + manager = _FakeSessionManager(source) + rows = [SimpleNamespace(id=i) for i in db_ids] + + class _Query: + def filter(self, *a): return self + def order_by(self, *a): return self + def all(self): return rows + + class _DB: + def query(self, *a): return _Query() + def close(self): pass + + monkeypatch.setattr(mod, "SessionLocal", lambda: _DB()) + router = mod.setup_history_routes(manager) + + class _Req: + async def json(self): return body + + asyncio.run(_fork_handler(router)(_Req(), "src-id")) + return [m.content for m in manager.created.history] + + +def test_fork_before_a_message_id_ignores_a_stale_keep_count(monkeypatch): + copied = _fork_with(monkeypatch, {"before_msg_id": "c", "keep_count": 0}, ["a", "b", "c", "d"]) + assert copied == ["m0", "m1"] + + +def test_fork_through_a_message_id_includes_it(monkeypatch): + copied = _fork_with(monkeypatch, {"through_msg_id": "b", "keep_count": 0}, ["a", "b", "c", "d"]) + assert copied == ["m0", "m1"] + diff --git a/tests/test_regenerate_truncate_warning_js.py b/tests/test_regenerate_truncate_warning_js.py new file mode 100644 index 000000000..1df930aaf --- /dev/null +++ b/tests/test_regenerate_truncate_warning_js.py @@ -0,0 +1,541 @@ +"""Executable regression for the "regenerate" data-loss warning. + +`regenerateFrom` (the per-message regenerate footer button), `resendUserMessage` +(footer Resend and the vision editor's "Regenerate message" button) and +`editUserMessage` (sending an edited user message) all permanently delete +every message after the point clicked via POST /api/session/{id}/truncate — +a real, unrecoverable server-side delete. Using any of them on anything but +the very last exchange used to do this with no warning at all; a mis-click +could silently erase an entire conversation. This drives the real chat.js +functions under Node, confirming: + + 1. Regenerating the *last* exchange (nothing after it) never prompts and + truncates directly — the everyday case must not gain friction. + 2. Regenerating anything earlier prompts first, with the correct count of + messages that would be destroyed in the message text. + 3. Cancelling the prompt aborts before any network call — nothing is + deleted. + 4. Confirming the prompt proceeds with the correct `keep_count`. + 5. Choosing "Fork from here" deletes nothing: it forks the chat just + before the user turn and replays the action (same text, or the edited + text) in the new chat instead. + +All three entry points are exercised the same way since they wrap the same +destructive operation. + +Unlike test_pr6020_browser_review_regressions.py's `_chat_smoke_source` +(which appends extra code to a copy of chat.js's own source — fine for +defining an extra exported function, since ES module static imports are +hoisted and evaluated before any top-level code regardless of where in the +file it appears), this harness needs its global stubs (document, fetch, +etc.) in place *before* chat.js's own import tree evaluates, since some of +those transitive dependencies touch browser globals at their own module top +level. So this loads chat.js via a real dynamic `import()` of its actual +file, in a small wrapper script that sets the stubs up first as plain +synchronous statements — dynamic `import()` runs in program order, unlike a +static `import` declaration. +""" +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parents[1] +_JS_DIR = _REPO / "static" / "js" +_HAS_NODE = shutil.which("node") is not None + + +def _versioned_js_uri(entry_js: Path, target_name: str) -> str: + """file:// URI for target_name with the same cache-busting query string + entry_js's own import of it uses. ES modules cache by full URL, so + importing 'ui.js' here while chat.js imports 'ui.js?v=X' yields two module + instances, and patching uiMod here would never reach chat.js.""" + src = entry_js.read_text(encoding="utf-8") + m = re.search(rf"from ['\"]\./{re.escape(target_name)}(\?[^'\"]*)?['\"]", src) + suffix = (m.group(1) or "") if m else "" + return (entry_js.parent / target_name).resolve().as_uri() + suffix + + +def _run_node(source: str) -> dict: + proc = subprocess.run( + ["node", "--input-type=module"], + input=source, + capture_output=True, + text=True, + cwd=_REPO, + timeout=30, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +_HARNESS_PREAMBLE = """ + globalThis.window = globalThis; + Object.defineProperty(globalThis, "navigator", { value: { platform: "" }, writable: true, configurable: true }); + globalThis.addEventListener = () => {}; + globalThis.removeEventListener = () => {}; + globalThis.dispatchEvent = () => {}; + globalThis.requestAnimationFrame = () => 0; + globalThis.cancelAnimationFrame = () => {}; + globalThis.localStorage = { getItem() { return null; }, setItem() {}, removeItem() {} }; + globalThis.location = {}; + globalThis.history = {}; + globalThis.MutationObserver = class { observe() {} }; + globalThis.CustomEvent = class {}; + globalThis.Storage = class {}; + + const fetchCalls = []; + globalThis.fetch = async (url, opts) => { + fetchCalls.push({ url, body: opts && opts.body ? JSON.parse(opts.body) : null }); + const data = url.includes('/fork') ? { id: 'forked-session', name: 'Forked chat' } : {}; + // A test can make the server refuse the cut (e.g. a stale message id). + const ok = !(url.includes('/truncate') && globalThis.__refuseTruncate); + return { ok, status: ok ? 200 : 404, json: async () => data, text: async () => '', headers: { get() { return null; } } }; + }; + + class Element { + constructor() { + this.children = []; + this.classList = { add() {}, remove() {}, toggle() {}, contains: () => false }; + this.style = { setProperty() {} }; + this.dataset = {}; + this._listeners = {}; + this._html = ''; + } + get innerHTML() { return this._html; } + set innerHTML(v) { this._html = v; this.children = []; } + querySelector() { return null; } + querySelectorAll() { return []; } + appendChild(child) { this.children.push(child); return child; } + addEventListener(type, fn) { (this._listeners[type] ||= []).push(fn); } + removeEventListener() {} + focus() {} + // Fire a listener and wait for it — the edit Send handler is async. + fire(type) { + const ev = { stopPropagation() {}, preventDefault() {} }; + return Promise.all((this._listeners[type] || []).map(fn => fn(ev))); + } + } + class HTMLInputElement extends Element { + get value() { return this._value || ''; } + set value(v) { this._value = v; } + } + globalThis.HTMLInputElement = HTMLInputElement; + + // A minimal .msg-list DOM: `msgs` is an ordered array of + // { role: 'user'|'ai' } describing one conversation. Each entry + // becomes a fake message element good enough for regenerateFrom / + // resendUserMessage's own traversal (classList.contains, .dataset, + // querySelector('.body'), querySelectorAll('[data-file-id]')). + function buildMsgEls(msgs) { + return msgs.map((m, i) => { + const body = new Element(); + body.textContent = m === 'user' ? `question ${i}` : `answer ${i}`; + body.innerHTML = body.textContent; + return { + classList: { contains: (c) => c === (m === 'user' ? 'msg-user' : 'msg-ai') }, + dataset: { raw: m === 'user' ? `question ${i}` : '' }, + querySelector: (sel) => sel === '.body' ? body : null, + querySelectorAll: () => [], + nextSibling: null, + remove() {}, + }; + }); + } + + const sendClicks = []; + const messageInputEl = new HTMLInputElement(); + const sendBtnEl = { click: () => sendClicks.push(messageInputEl.value) }; + + globalThis.document = { + body: new Element(), + head: new Element(), + documentElement: new Element(), + getElementById(id) { + if (id === 'message') return messageInputEl; + if (id === 'chat-history') return this._box; + return null; + }, + querySelector(sel) { return sel === '.send-btn' ? sendBtnEl : null; }, + querySelectorAll() { return []; }, + createElement(tag) { return tag === 'input' ? new HTMLInputElement() : new Element(); }, + createTextNode(text) { return { textContent: text }; }, + addEventListener() {}, + removeEventListener() {}, + }; +""" + + +def _scenario_script(func_call_js: str, roles: list, target_index: int, confirm_resolution, then_js: str = "", db_ids: bool = False, refuse_truncate: bool = False, file_ids: dict | None = None) -> str: + # Globals are stubbed above as plain synchronous statements *before* the + # dynamic import()s below run — unlike a static `import` declaration, + # dynamic import() executes in program order, so chat.js's transitive + # dependency tree only evaluates once the stubs are already in place. + script = _HARNESS_PREAMBLE + script += f""" + const chat = await import({json.dumps((_JS_DIR / "chat.js").resolve().as_uri())}); + const {{ default: uiMod }} = await import({json.dumps(_versioned_js_uri(_JS_DIR / "chat.js", "ui.js"))}); + const {{ default: sessionMod }} = await import({json.dumps(_versioned_js_uri(_JS_DIR / "chat.js", "sessions.js"))}); + + let currentSession = 'test-session'; + const selectedSessions = []; + sessionMod.getCurrentSessionId = () => currentSession; + sessionMod.loadSessions = async () => {{}}; + sessionMod.selectSession = async (id) => {{ selectedSessions.push(id); currentSession = id; }}; + + const confirmCalls = []; + uiMod.styledConfirm = async (message, opts) => {{ + confirmCalls.push({{ message, opts }}); + return {json.dumps(confirm_resolution)}; + }}; + uiMod.showError = () => {{}}; + uiMod.showToast = () => {{}}; + + globalThis.__refuseTruncate = {json.dumps(refuse_truncate)}; + const msgs = buildMsgEls({json.dumps(roles)}); + // Persisted messages carry their database id; the page may have loaded + // only the newest of them, so ids (not positions) identify a message. + if ({json.dumps(db_ids)}) msgs.forEach((m, i) => {{ m.dataset.dbId = `db-${{i + 40}}`; }}); + // Attach uploaded files ({{ index: file_id }}) the way rendered bubbles do. + for (const [i, fileId] of Object.entries({json.dumps(file_ids or {})})) {{ + msgs[i].querySelectorAll = (sel) => sel === '[data-file-id]' ? [{{ dataset: {{ fileId }} }}] : []; + }} + // Record the attachments queued for each send as the button is clicked. + const sentAttachments = []; + sendBtnEl.click = () => {{ sendClicks.push(messageInputEl.value); sentAttachments.push(chat._pendingRegenAttachmentIds()); }}; + const box = {{ querySelectorAll: () => msgs, _isBox: true }}; + globalThis.document._box = box; + const target = msgs[{target_index}]; + + await {func_call_js}; + {then_js} + + const forkCall = fetchCalls.find(c => c.url.includes('/fork')); + console.log(JSON.stringify({{ + confirmShown: confirmCalls.length > 0, + confirmMessage: confirmCalls.length ? confirmCalls[0].message : null, + confirmOpts: confirmCalls.length ? confirmCalls[0].opts : null, + truncateCalled: fetchCalls.some(c => c.url.includes('/truncate')), + truncateKeepCount: (fetchCalls.find(c => c.url.includes('/truncate')) || {{}}).body?.keep_count ?? null, + forkCalled: Boolean(forkCall), + forkUrl: forkCall ? forkCall.url : null, + forkKeepCount: forkCall ? forkCall.body.keep_count : null, + forkBody: forkCall ? forkCall.body : null, + truncateBody: (fetchCalls.find(c => c.url.includes('/truncate')) || {{}}).body || null, + selectedSessions, + sendClicks, + sentAttachments, + pendingAttachments: chat._pendingRegenAttachmentIds(), + }})); + process.exit(0); + """ + return script + + +# --------------------------------------------------------------------------- +# regenerateFrom (main chat footer "Regenerate from here" button) +# --------------------------------------------------------------------------- + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_regenerate_from_last_message_skips_prompt_and_truncates(): + roles = ["user", "ai"] # target (index 1) IS the last message + script = _scenario_script("chat.regenerateFrom(target)", roles, 1, confirm_resolution=True) + result = _run_node(script) + assert result["confirmShown"] is False + assert result["truncateCalled"] is True + assert result["truncateKeepCount"] == 0 # keep everything before the user turn (index 0) + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_regenerate_from_earlier_message_prompts_with_correct_count(): + roles = ["user", "ai", "user", "ai", "user", "ai"] # target (index 1) has 4 messages after it + script = _scenario_script("chat.regenerateFrom(target)", roles, 1, confirm_resolution=True) + result = _run_node(script) + assert result["confirmShown"] is True + assert "4 messages" in result["confirmMessage"] + assert result["truncateCalled"] is True + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_regenerate_from_earlier_message_cancel_aborts_without_truncating(): + roles = ["user", "ai", "user", "ai"] + script = _scenario_script("chat.regenerateFrom(target)", roles, 1, confirm_resolution=False) + result = _run_node(script) + assert result["confirmShown"] is True + assert result["truncateCalled"] is False + + +# --------------------------------------------------------------------------- +# resendUserMessage(..., { replaceFromHere: true }) (vision editor "Regenerate message") +# --------------------------------------------------------------------------- + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_resend_replace_from_here_last_pair_skips_prompt(): + roles = ["user", "ai"] # target (index 0) is the user turn of the last pair + script = _scenario_script( + "chat.resendUserMessage(target, { replaceFromHere: true })", roles, 0, confirm_resolution=True, + ) + result = _run_node(script) + assert result["confirmShown"] is False + assert result["truncateCalled"] is True + assert result["truncateKeepCount"] == 0 + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_resend_replace_from_here_earlier_pair_prompts_with_correct_count(): + roles = ["user", "ai", "user", "ai", "user", "ai"] # target (index 0) has 4 messages after its own pair + script = _scenario_script( + "chat.resendUserMessage(target, { replaceFromHere: true })", roles, 0, confirm_resolution=True, + ) + result = _run_node(script) + assert result["confirmShown"] is True + assert "4 messages" in result["confirmMessage"] + assert result["truncateCalled"] is True + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_resend_replace_from_here_cancel_aborts_without_truncating(): + roles = ["user", "ai", "user", "ai"] + script = _scenario_script( + "chat.resendUserMessage(target, { replaceFromHere: true })", roles, 0, confirm_resolution=False, + ) + result = _run_node(script) + assert result["confirmShown"] is True + assert result["truncateCalled"] is False + + +# --------------------------------------------------------------------------- +# resendUserMessage(el) with no options (footer "Resend message") +# --------------------------------------------------------------------------- +# `dev` made plain resend replace-by-default (see +# test_resend_message_nondestructive.py): no options means replaceFromHere, +# so the footer Resend button now truncates everything after the clicked +# turn too, and gets the same warning. Append-only resend must be explicit. + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_resend_default_earlier_pair_prompts_with_correct_count(): + roles = ["user", "ai", "user", "ai", "user", "ai"] + script = _scenario_script("chat.resendUserMessage(target)", roles, 0, confirm_resolution=True) + result = _run_node(script) + assert result["confirmShown"] is True + assert "4 messages" in result["confirmMessage"] + assert result["truncateCalled"] is True + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_resend_append_only_never_truncates_or_prompts(): + roles = ["user", "ai", "user", "ai"] + script = _scenario_script("chat.resendUserMessage(target, { append: true })", roles, 0, confirm_resolution=True) + result = _run_node(script) + assert result["confirmShown"] is False + assert result["truncateCalled"] is False + + +# --------------------------------------------------------------------------- +# editUserMessage (the pencil edit button, then Send) +# --------------------------------------------------------------------------- +# The edit Send handler is wired up inside editUserMessage, so these open the +# editor, type into it, and click the editor's own Send button. + +_EDIT_AND_SEND = """ + const _body = target.querySelector('.body'); + const [_editor, _btnRow] = _body.children; + _editor.value = 'edited question'; + await _btnRow.children[0].fire('click'); +""" + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_edit_last_message_skips_prompt_and_truncates(): + roles = ["user", "ai"] + script = _scenario_script("chat.editUserMessage(target)", roles, 0, True, _EDIT_AND_SEND) + result = _run_node(script) + assert result["confirmShown"] is False + assert result["truncateCalled"] is True + assert result["truncateKeepCount"] == 0 + assert result["sendClicks"] == ["edited question"] + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_edit_earlier_message_prompts_with_correct_count(): + roles = ["user", "ai", "user", "ai", "user", "ai"] # target (index 2) has 2 messages after its own pair + script = _scenario_script("chat.editUserMessage(target)", roles, 2, True, _EDIT_AND_SEND) + result = _run_node(script) + assert result["confirmShown"] is True + assert "2 messages" in result["confirmMessage"] + assert result["confirmOpts"]["confirmText"] == "Delete and send" + assert result["truncateCalled"] is True + assert result["truncateKeepCount"] == 2 + assert result["sendClicks"] == ["edited question"] + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_edit_earlier_message_cancel_aborts_without_truncating_or_sending(): + roles = ["user", "ai", "user", "ai"] + script = _scenario_script("chat.editUserMessage(target)", roles, 0, False, _EDIT_AND_SEND) + result = _run_node(script) + assert result["confirmShown"] is True + assert result["truncateCalled"] is False + assert result["sendClicks"] == [] + + +# --------------------------------------------------------------------------- +# "Fork from here" — the prompt's alternate choice +# --------------------------------------------------------------------------- +# styledConfirm resolves to 'alternate' for its third button. Forking must +# not truncate anything; it forks just before the user turn (so the new chat +# ends where the action starts), switches to the fork, and replays there. + +def _assert_forked_instead(result, keep_count, sent_text): + assert result["confirmShown"] is True + assert result["confirmOpts"]["alternateText"] == "Fork from here" + assert result["truncateCalled"] is False + assert result["forkCalled"] is True + assert "/api/session/test-session/fork" in result["forkUrl"] + assert result["forkKeepCount"] == keep_count + assert result["selectedSessions"] == ["forked-session"] + assert result["sendClicks"] == [sent_text] + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_regenerate_fork_choice_forks_before_user_turn_and_resends(): + roles = ["user", "ai", "user", "ai", "user", "ai"] + script = _scenario_script("chat.regenerateFrom(target)", roles, 3, "alternate") + _assert_forked_instead(_run_node(script), keep_count=2, sent_text="question 2") + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_resend_fork_choice_forks_before_user_turn_and_resends(): + roles = ["user", "ai", "user", "ai", "user", "ai"] + script = _scenario_script("chat.resendUserMessage(target)", roles, 2, "alternate") + _assert_forked_instead(_run_node(script), keep_count=2, sent_text="question 2") + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_edit_fork_choice_forks_before_user_turn_and_sends_edit(): + roles = ["user", "ai", "user", "ai", "user", "ai"] + script = _scenario_script("chat.editUserMessage(target)", roles, 2, "alternate", _EDIT_AND_SEND) + _assert_forked_instead(_run_node(script), keep_count=2, sent_text="edited question") + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_regenerate_fork_choice_keeps_the_user_message_attachments(): + roles = ["user", "ai", "user", "ai", "user", "ai"] + script = _scenario_script("chat.regenerateFrom(target)", roles, 3, "alternate", file_ids={2: "file-abc"}) + result = _run_node(script) + _assert_forked_instead(result, keep_count=2, sent_text="question 2") + assert result["sentAttachments"] == [["file-abc"]] + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_resend_fork_choice_keeps_the_user_message_attachments(): + roles = ["user", "ai", "user", "ai", "user", "ai"] + script = _scenario_script("chat.resendUserMessage(target)", roles, 2, "alternate", file_ids={2: "file-abc"}) + result = _run_node(script) + _assert_forked_instead(result, keep_count=2, sent_text="question 2") + assert result["sentAttachments"] == [["file-abc"]] + + +# --------------------------------------------------------------------------- +# Cutting by message id +# --------------------------------------------------------------------------- +# A long chat loads only its newest messages until the user scrolls up, so a +# position counted in the page is smaller than the real one. Truncating by +# that position deleted real messages above the clicked one. Every action +# sends the clicked message's database id when it has one. + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_regenerate_truncates_before_the_user_message_by_id(): + roles = ["user", "ai", "user", "ai"] + script = _scenario_script("chat.regenerateFrom(target)", roles, 3, True, db_ids=True) + result = _run_node(script) + assert result["truncateBody"] == {"before_msg_id": "db-42"} + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_resend_truncates_before_the_user_message_by_id(): + roles = ["user", "ai", "user", "ai"] + script = _scenario_script("chat.resendUserMessage(target)", roles, 2, True, db_ids=True) + result = _run_node(script) + assert result["truncateBody"] == {"before_msg_id": "db-42"} + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_edit_truncates_before_the_user_message_by_id(): + roles = ["user", "ai", "user", "ai"] + script = _scenario_script("chat.editUserMessage(target)", roles, 2, True, _EDIT_AND_SEND, db_ids=True) + result = _run_node(script) + assert result["truncateBody"] == {"before_msg_id": "db-42"} + assert result["sendClicks"] == ["edited question"] + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_fork_choice_forks_before_the_user_message_by_id(): + roles = ["user", "ai", "user", "ai", "user", "ai"] + script = _scenario_script("chat.regenerateFrom(target)", roles, 3, "alternate", db_ids=True) + result = _run_node(script) + assert result["truncateCalled"] is False + assert result["forkBody"]["before_msg_id"] == "db-42" + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_fork_conversation_forks_through_the_reply_by_id(): + roles = ["user", "ai", "user", "ai"] + script = _scenario_script("chat.forkFrom(target)", roles, 1, True, db_ids=True) + result = _run_node(script) + assert result["forkBody"]["through_msg_id"] == "db-41" + + +# A refused cut (the server can't find the message id, e.g. one a previous +# regenerate already deleted) must stop the action. Sending anyway stacked a +# second reply under the same message. + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_regenerate_stops_when_the_server_refuses_the_cut(): + roles = ["user", "ai"] + script = _scenario_script("chat.regenerateFrom(target)", roles, 1, True, db_ids=True, refuse_truncate=True) + result = _run_node(script) + assert result["truncateCalled"] is True + assert result["sendClicks"] == [] + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_edit_stops_when_the_server_refuses_the_cut(): + roles = ["user", "ai"] + script = _scenario_script("chat.editUserMessage(target)", roles, 0, True, _EDIT_AND_SEND, db_ids=True, refuse_truncate=True) + result = _run_node(script) + assert result["truncateCalled"] is True + assert result["sendClicks"] == [] + + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_edit_fork_choice_keeps_original_attachments(): + roles = ["user", "ai", "user", "ai", "user", "ai"] + script = _scenario_script("chat.editUserMessage(target)", roles, 2, "alternate", _EDIT_AND_SEND, file_ids={2: "file-abc"}) + result = _run_node(script) + _assert_forked_instead(result, keep_count=2, sent_text="edited question") + assert result["sentAttachments"] == [["file-abc"]] + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_edit_destructive_resend_keeps_original_attachments(): + roles = ["user", "ai", "user", "ai"] + script = _scenario_script("chat.editUserMessage(target)", roles, 2, True, _EDIT_AND_SEND, file_ids={2: "file-abc"}) + result = _run_node(script) + assert result["truncateCalled"] is True + assert result["sentAttachments"] == [["file-abc"]] + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +def test_refused_regenerate_does_not_leak_queued_attachments(): + roles = ["user", "ai"] + script = _scenario_script("chat.regenerateFrom(target)", roles, 1, True, db_ids=True, + refuse_truncate=True, file_ids={0: "file-abc"}) + result = _run_node(script) + assert result["truncateCalled"] is True + assert result["sendClicks"] == [] + assert result["pendingAttachments"] is None