fix: preserve uploaded file content when regenerating a non-last answer (#10819)

handleRegenerate rebuilt the outbound message from the display-only
message.files metadata ({name, type: 'file'|'image'|..., content}),
which doesn't carry the base64/textContent payload sendMessage's
file-building loop expects. As a result, regenerating any answer whose
own question had an attachment silently dropped that attachment from
the resent message. This wasn't fork-specific, but forking a chat and
then regenerating an earlier (now non-last) answer is the natural way
to hit it.

Fix by reusing the original message's already-assembled `content`
verbatim (it already has the file text / image_url / audio_url /
video_url parts embedded from the first send) instead of trying to
reconstruct it from lossy display metadata.

Fixes #10806

Assisted-by: Claude:claude-sonnet-5

Signed-off-by: ajuijas <189517297+ajuijas@users.noreply.github.com>
Co-authored-by: ajuijas <189517297+ajuijas@users.noreply.github.com>
This commit is contained in:
Ijas
2026-07-14 14:54:42 +05:30
committed by GitHub
parent 05b8d8aafe
commit a5aa56db81
3 changed files with 62 additions and 6 deletions

View File

@@ -98,6 +98,53 @@ test('retry regenerates the first answer and drops the later turn', async ({ pag
expect(contents.join('\n')).not.toContain('first answer')
})
const FILE_TURNS = [
{
role: 'user',
content: [
{ type: 'text', text: 'what does the file say' },
{ type: 'text', text: '\n\n--- File: notes.txt ---\nthe secret is 42\n--- End of notes.txt ---' },
],
files: [{ name: 'notes.txt', type: 'file', content: 'the secret is 42' }],
},
{ role: 'assistant', content: 'the file says the secret is 42' },
{ role: 'user', content: 'anything else' },
{ role: 'assistant', content: 'nope, that is it' },
]
test('regenerating a non-last answer in a fork still sends the uploaded file content', async ({ page }) => {
await mockModels(page)
let sentMessages = null
await page.route('**/v1/chat/completions', (route) => {
sentMessages = route.request().postDataJSON()?.messages || []
const sse =
`data: ${JSON.stringify({ choices: [{ delta: { content: 'REGENERATED file answer' } }] })}\n\n` +
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } })}\n\n` +
`data: [DONE]\n\n`
route.fulfill({ status: 200, contentType: 'text/event-stream', body: sse })
})
await seedChat(page, FILE_TURNS)
await page.goto('/app/chat')
// Fork after the second turn, then regenerate the FIRST (now non-last) answer.
const secondAssistant = page.locator('.chat-message-assistant').nth(1)
await secondAssistant.hover()
await secondAssistant.getByTitle('Branch from here').click()
await expect(page.locator('.chat-header-title')).toHaveText('Seeded Chat (fork)')
const firstAssistant = page.locator('.chat-message-assistant').first()
await firstAssistant.hover()
await firstAssistant.getByTitle('Regenerate').click()
await expect(page.locator('.chat-message-assistant')).toContainText(['REGENERATED file answer'])
// The outbound payload for the regenerated turn must still carry the file text.
const contents = (sentMessages || []).map(m =>
typeof m.content === 'string' ? m.content : JSON.stringify(m.content)
)
expect(contents.join('\n')).toContain('the secret is 42')
})
test('copy chat puts the whole conversation on the clipboard', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await mockModels(page)

View File

@@ -218,8 +218,15 @@ export function useChat(initialModel = '') {
// Build user message content
let messageContent
const userFiles = []
if (files.length > 0) {
let userFiles = []
if (options.prebuiltContent) {
// Caller (e.g. regenerate) already has the fully-assembled content from
// the original send — reuse it verbatim instead of rebuilding from
// display-only file metadata, which doesn't carry the base64/text
// payload needed to re-embed attachments.
messageContent = content
userFiles = files
} else if (files.length > 0) {
messageContent = [{ type: 'text', text: content }]
for (const file of files) {
if (file.type?.startsWith('image/')) {

View File

@@ -809,9 +809,11 @@ export default function Chat() {
if (history[i].role === 'user') { userIdx = i; break }
}
if (userIdx === -1) return
const userMsg = typeof history[userIdx].content === 'string'
? history[userIdx].content
: history[userIdx].content?.[0]?.text || ''
// Reuse the original message's content verbatim (not re-extracted text):
// it already has any file text / image_url / audio_url / video_url parts
// embedded from when it was first sent, which display-only file metadata
// can't reconstruct.
const userContent = history[userIdx].content
const userFiles = history[userIdx].files || []
// Drop the user turn and everything after it; sendMessage re-appends it.
// Thread the truncated history through explicitly: updateChatSettings only
@@ -819,7 +821,7 @@ export default function Chat() {
// the stale pre-truncation history for the outbound API payload.
const baseHistory = history.slice(0, userIdx)
updateChatSettings(activeChat.id, { history: baseHistory })
await sendMessage(userMsg, userFiles, { baseHistory })
await sendMessage(userContent, userFiles, { baseHistory, prebuiltContent: true })
}, [activeChat, isStreaming, sendMessage, updateChatSettings])
const handleKeyDown = (e) => {