diff --git a/static/js/chat.js b/static/js/chat.js index adb68c9c5..4279df570 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -3876,9 +3876,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer } /** - * Resend a user message — truncates history to that point and resubmits. + * Resend a user message. Normal resend appends a fresh copy at the end of + * the current thread; regenerate flows can opt into replacing from here. */ - export async function resendUserMessage(userMsgElement) { + export async function resendUserMessage(userMsgElement, opts = {}) { + const replaceFromHere = Boolean(opts && opts.replaceFromHere); const box = document.getElementById('chat-history'); const allMsgs = Array.from(box.querySelectorAll('.msg')); const msgIndex = allMsgs.indexOf(userMsgElement); @@ -3924,25 +3926,28 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer const sessionId = sessionModule.getCurrentSessionId(); if (!sessionId) return; - // Truncate backend to keep everything before this user message - const keepCount = msgIndex; try { - await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ keep_count: keepCount }) - }); + if (replaceFromHere) { + // Regenerate flows intentionally trim history to this point before + // resubmitting. The plain "Resend message" action must not do this. + const keepCount = msgIndex; + await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ keep_count: keepCount }) + }); - // Drop the AI replies after the user message but KEEP the user bubble - // itself (so its photo stays visible). Then suppress the new user - // bubble that send would otherwise add — same pattern as regenerate. - let sibling = userMsgElement.nextSibling; - while (sibling) { - const next = sibling.nextSibling; - sibling.remove(); - sibling = next; + // Drop the AI replies after the user message but KEEP the user bubble + // itself (so its photo stays visible). Then suppress the new user + // bubble that send would otherwise add — same pattern as regenerate. + let sibling = userMsgElement.nextSibling; + while (sibling) { + const next = sibling.nextSibling; + sibling.remove(); + sibling = next; + } + _hideUserBubble = true; } - _hideUserBubble = true; _pendingRegenAttachments = _ids; // Resubmit diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js index 7c6ecd096..ce98be4b9 100644 --- a/static/js/chatRenderer.js +++ b/static/js/chatRenderer.js @@ -362,7 +362,7 @@ function _openVisionEditor(att, userMsgEl) { await _saveVisionText(); _closeVisionEditor(); if (userMsgEl && window.chatModule?.resendUserMessage) { - window.chatModule.resendUserMessage(userMsgEl); + window.chatModule.resendUserMessage(userMsgEl, { replaceFromHere: true }); } else if (uiModule?.showToast) { uiModule.showToast('Saved'); } diff --git a/tests/test_resend_message_nondestructive.py b/tests/test_resend_message_nondestructive.py new file mode 100644 index 000000000..c107e84fc --- /dev/null +++ b/tests/test_resend_message_nondestructive.py @@ -0,0 +1,43 @@ +"""Regression guard for #4149: normal Resend must not delete chat history. + +chat.js is browser-heavy, so this pins the source-level contract: the footer's +plain "Resend message" path appends a fresh send, while regenerate-only paths +must opt into truncating/replacing from the selected message. +""" + +from pathlib import Path + + +_REPO = Path(__file__).resolve().parent.parent +_CHAT_JS = _REPO / "static" / "js" / "chat.js" +_CHAT_RENDERER_JS = _REPO / "static" / "js" / "chatRenderer.js" + + +def _resend_body() -> str: + src = _CHAT_JS.read_text(encoding="utf-8") + start = src.index("export async function resendUserMessage(") + end = src.index("export async function regenerateFrom(", start) + return src[start:end] + + +def test_resend_message_does_not_truncate_by_default(): + body = _resend_body() + + assert "opts = {}" in body + assert "const replaceFromHere = Boolean(opts && opts.replaceFromHere);" in body + + guard_idx = body.index("if (replaceFromHere)") + truncate_idx = body.index("/api/session/${sessionId}/truncate") + hide_idx = body.index("_hideUserBubble = true;") + + assert guard_idx < truncate_idx + assert guard_idx < hide_idx + assert "/truncate" not in body[:guard_idx] + assert "_hideUserBubble = true;" not in body[:guard_idx] + + +def test_only_regenerate_callers_opt_into_replace_from_here(): + renderer = _CHAT_RENDERER_JS.read_text(encoding="utf-8") + + assert "window.chatModule.resendUserMessage(msgElement);" in renderer + assert "window.chatModule.resendUserMessage(userMsgEl, { replaceFromHere: true });" in renderer