From 5353241743d7bb410c1e5a41912b781f1e2b9c63 Mon Sep 17 00:00:00 2001 From: John W <93501228+JGW3@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:47:32 -0500 Subject: [PATCH] fix(chat): warn before regenerate, resend or edit deletes later messages; offer fork (#6225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(chat): warn before "regenerate" permanently deletes later messages Both "regenerate" entry points — the per-message footer's ↻ "Regenerate from here" button, and the vision editor's "Regenerate message" button — call POST /api/session/{id}/truncate, which does a real, permanent SQL delete of every message after the point clicked, then generates a new reply. Clicking either on anything but the very last exchange did this with zero warning: the button's own label doesn't say it also erases the rest of the conversation, and neither button visually signals it's destructive differently from any other message-footer action. Caught live: a mis-click destroyed a real conversation with no way to recover it (no backup covered the affected window). This adds a confirmation prompt — via the existing uiModule.styledConfirm — whenever the action would delete more than just the message(s) being regenerated, stating the exact count of messages that would be lost. Regenerating the most recent exchange (the everyday case, nothing exists after it) is unaffected and still proceeds with zero friction. New test: tests/test_regenerate_truncate_warning_js.py — drives the real regenerateFrom/resendUserMessage functions under Node with a fake message list DOM, confirming: the last-exchange case never prompts and truncates directly; an earlier case prompts with the correct count; cancelling aborts before any network call; confirming proceeds with the correct keep_count. Also fixed a pre-existing, unrelated gap this surfaced while verifying the full suite: test_pr6020_browser_review_regressions.py's Node harness never stubbed `navigator` at all (sessions.js reads navigator.platform at module load) — a gap on origin/dev itself, not caused by this branch's diff, using the same Object.defineProperty fix already applied on #6019/#6148 for the sibling issue there (Node 21+'s own getter-only navigator global, not relevant to this specific failure but the same defensive pattern). Verified: full pytest suite in-container, 5798 passed / 11 pre-existing unrelated failures (README/docs/agent-loop baseline) / 0 new. Co-Authored-By: Claude Sonnet 5 * test(chat): cover plain resend after dev's replace-by-default change dev made plain resend (no options) replace from the clicked turn by default, with append-only resend opt-in via { append: true } (see test_resend_message_nondestructive.py). The footer "Resend message" button calls resendUserMessage(el) with no options, so it now truncates everything after the clicked turn and goes through this PR's warning. Replace the old "plain resend never prompts" test, which assumed the previous append-by-default behavior, with one asserting that plain resend on an earlier pair prompts with the right count, and one asserting that explicit append-only resend never truncates or prompts. Also resolve ui.js/sessions.js through the same cache-busting query string chat.js imports them with. ES modules cache by full URL, so the harness's unversioned import of ui.js was a second module instance and its uiMod.styledConfirm stub never reached the instance chat.js calls. Co-Authored-By: Claude Opus 5.5 * fix(chat): warn before an edit deletes later messages; offer to fork instead Sending an edited user message truncates the chat at that message, the same unrecoverable server-side delete as regenerate and resend, but did it with no prompt. It now goes through the same confirmation, worded for the action ("Sending this edit will also permanently delete the N messages after it"). Cancelling leaves the editor open with the draft. The confirmation for all three actions (regenerate, resend, edit) also offers "Fork from here": it forks the chat just before the user turn, switches to the new chat, and replays the action there (the same message, or the edited text), leaving the original chat untouched. forkFrom now shares the same fork helper. Tests drive the real editUserMessage Send handler under Node and cover the edit prompt (count, cancel, last-message no-prompt) and the fork choice for each action (fork keep_count, no truncate, replayed text). Co-Authored-By: Claude Opus 5.5 * fix(chat): cut and fork a chat by message id, not loaded-page position Regenerate and edit sent keep_count, the clicked message's position among the messages loaded in the page. A long chat loads only its newest messages until the user scrolls up, so that position is smaller than the real one and the truncate deleted real messages above the clicked one (regenerating the latest reply in a ~50-message chat removed about a dozen earlier messages). Resend already sent before_msg_id; regenerate and edit now do the same. The fork options this PR adds had the same flaw. Fork now accepts before_msg_id (fork before that message) and through_msg_id (include it), resolved against the stored messages; keep_count stays as the fallback for a message that has no id yet. Co-Authored-By: Claude Opus 5.5 * fix(chat): keep a regenerated turn's message id current; stop on a refused cut Regenerate keeps the original user bubble on screen and resubmits its text without drawing a new bubble. The resubmitted message is a new DB row, but the bubble kept the deleted row's id, so the next regenerate's cut was refused (404) and the page sent the turn anyway, stacking a second reply. The stream now reports the saved user message's id (user_message_saved), which the page attaches to the user bubble, or to the bubble a regenerate kept. Regenerate and edit stop with an error when the server refuses the cut instead of sending. Co-Authored-By: Claude Opus 5.5 * test(chat): name the buttons in words instead of symbols CONTRIBUTING asks for no Unicode emoji in UI or code; the test comments used the regenerate and pencil glyphs to name buttons. Co-Authored-By: Claude Opus 5.5 * refactor(chat): share the fork-and-send step across edit, resend and regenerate Co-Authored-By: Claude Opus 5.5 * fix(chat): pass the regenerate fork its attachment ids explicitly Resend already hands its collected file ids to _forkAndSend; regenerate relied on the earlier _pendingRegenAttachments assignment. Pass _regenIds too so both fork paths read the same, and check the hand-off in the regenerate tests. Co-Authored-By: Claude Opus 5.5 * test(chat): assert fork choices keep the user message's attachments Run regenerate and resend's "Fork from here" against a user message with an uploaded file and check that the send fired in the fork carries its file id, replacing the source-text check. chat.js exposes a read-only _pendingRegenAttachmentIds() for this, like cookbook-hwfit.js's test hooks. Co-Authored-By: Claude Opus 5.5 * fix(chat): preserve attachments across edit and regeneration --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Alexandre Teixeira --- routes/chat_routes.py | 13 + routes/history/history_routes.py | 17 + static/js/chat.js | 209 +++++-- tests/test_fork_session_metadata.py | 39 ++ tests/test_regenerate_truncate_warning_js.py | 541 +++++++++++++++++++ 5 files changed, 769 insertions(+), 50 deletions(-) create mode 100644 tests/test_regenerate_truncate_warning_js.py 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