fix: handle expired chat file attachments in replay and UI (#24518)

Closes CODAGT-216

## Problem

`dbpurge` deletes `chat_files` rows after the deployment's configured
retention window, but `chat_messages.content` can still contain
`file_id` references to those files. On replay, that left the Anthropic
provider with an empty file payload and a `400 image cannot be empty`
error. In the UI, the same missing file showed up as a broken image.

## Fix

- Backend: when replay hits a `file_id` whose bytes are gone, replace it
with a short text placeholder instead of emitting an empty file part. We
could also drop the missing attachment entirely, but that would silently
remove context from the replay and make the conversation harder for the
model to interpret. The placeholder keeps the request valid while still
telling the model that a file used to be there and is no longer
available.
- Frontend: classify chat image failures instead of treating every
broken image the same.
- `404` file fetches render `Image expired`, with a tooltip explaining
that chat attachments are deleted after the retention window set for the
deployment.
- Other remote failures render `Image failed to load`, with a tooltip
that surfaces server/network detail when available.
- Invalid inline image data still renders `Image failed to load` without
a probe.
This commit is contained in:
Ethan
2026-04-22 14:10:51 +10:00
committed by GitHub
parent f77827e84a
commit 353e522614
13 changed files with 1115 additions and 264 deletions
+75 -19
View File
@@ -66,8 +66,9 @@ func ExtractFileID(raw json.RawMessage) (uuid.UUID, error) {
// ConvertMessagesWithFiles converts persisted chat messages into LLM
// prompt messages, resolving user file references via the provided
// resolver. Persisted file references without bytes are omitted from
// the prompt instead of being replayed back to the model.
// resolver. Missing-data placeholders are emitted only for replayed
// user uploads; assistant-side and tool-side file metadata without
// bytes is dropped from later model turns.
func ConvertMessagesWithFiles(
ctx context.Context,
messages []database.ChatMessage,
@@ -76,8 +77,8 @@ func ConvertMessagesWithFiles(
) ([]fantasy.Message, error) {
// Phase 1: Parse all messages via ParseContent (→ SDK parts)
// and collect file_id references from user messages for batch
// resolution. Assistant-side file attachments remain persisted chat
// metadata and are intentionally not replayed to the model.
// resolution. Assistant-side file attachments remain persisted
// chat metadata and are intentionally not replayed to the model.
type parsedMessage struct {
role codersdk.ChatMessageRole
parts []codersdk.ChatMessagePart
@@ -124,6 +125,10 @@ func ConvertMessagesWithFiles(
return nil, xerrors.Errorf("resolve chat files: %w", err)
}
}
userMissingFilePolicy := dropMissingFiles
if resolver != nil {
userMissingFilePolicy = placeholderMissingFiles
}
// Phase 3: Build fantasy messages from SDK parts via
// partsToMessageParts. Track tool names for injection.
@@ -144,7 +149,13 @@ func ConvertMessagesWithFiles(
},
})
case codersdk.ChatMessageRoleUser:
userParts := partsToMessageParts(logger, pm.parts, resolved)
userParts := partsToMessageParts(
ctx,
logger,
pm.parts,
resolved,
userMissingFilePolicy,
)
if len(userParts) == 0 {
continue
}
@@ -154,7 +165,7 @@ func ConvertMessagesWithFiles(
})
case codersdk.ChatMessageRoleAssistant:
fantasyParts := normalizeAssistantToolCallInputs(
partsToMessageParts(logger, pm.parts, nil),
partsToMessageParts(ctx, logger, pm.parts, nil, dropMissingFiles),
)
for _, toolCall := range ExtractToolCalls(fantasyParts) {
if toolCall.ToolCallID == "" || strings.TrimSpace(toolCall.ToolName) == "" {
@@ -178,7 +189,7 @@ func ConvertMessagesWithFiles(
}
}
}
toolParts := partsToMessageParts(logger, pm.parts, nil)
toolParts := partsToMessageParts(ctx, logger, pm.parts, nil, dropMissingFiles)
if len(toolParts) == 0 {
continue
}
@@ -1191,6 +1202,25 @@ func formatSyntheticPasteText(name string, body []byte) string {
return sb.String()
}
func formatMissingAttachmentText(mediaType string) string {
const missingAttachmentBody = "[missing-attachment] The user attached a file here, but the content has expired and is no longer available."
const missingAttachmentAction = " If you need to inspect it, ask the user to re-upload."
if parsedMediaType, _, err := mime.ParseMediaType(mediaType); err == nil {
mediaType = parsedMediaType
}
mediaType = strings.TrimSpace(mediaType)
if mediaType == "" || mediaType == "application/octet-stream" {
return missingAttachmentBody + missingAttachmentAction
}
return fmt.Sprintf(
"%s Reported MIME type: %s.%s",
missingAttachmentBody,
mediaType,
missingAttachmentAction,
)
}
// fileReferencePartToText formats a file-reference SDK part as
// plain text for LLM consumption. LLMs don't understand
// file-reference natively, so we convert to a readable text
@@ -1297,14 +1327,23 @@ type persistedMediaResult struct {
Text string `json:"text"`
}
type missingFilePolicy uint8
const (
dropMissingFiles missingFilePolicy = iota
placeholderMissingFiles
)
// partsToMessageParts converts SDK chat message parts into fantasy
// message parts for LLM dispatch. It handles file data injection
// from resolved files, file-reference to text conversion, and
// source part skipping.
// message parts for LLM dispatch. resolved is a lookup map for file
// bytes, and policy controls whether missing file-backed parts are
// dropped or replaced with text placeholders.
func partsToMessageParts(
ctx context.Context,
logger slog.Logger,
parts []codersdk.ChatMessagePart,
resolved map[uuid.UUID]FileData,
policy missingFilePolicy,
) []fantasy.MessagePart {
result := make([]fantasy.MessagePart, 0, len(parts))
for _, part := range parts {
@@ -1345,8 +1384,10 @@ func partsToMessageParts(
data := part.Data
mediaType := part.MediaType
var name string
resolvedFile := false
if part.FileID.Valid {
if fd, ok := resolved[part.FileID.UUID]; ok {
resolvedFile = true
data = fd.Data
name = fd.Name
if mediaType == "" {
@@ -1354,13 +1395,7 @@ func partsToMessageParts(
}
}
}
if len(data) == 0 {
// File parts without bytes are persistence metadata, not
// prompt content. User uploads should have been resolved
// above; assistant tool attachments intentionally are not
// replayed into later model turns.
continue
}
opts := providerMetadataToOptions(logger, part.ProviderMetadata)
// Providers only accept a small set of MIME types in file
// content blocks, typically images and PDFs. A synthetic
// paste sent as a text/plain FilePart is dropped or rejected,
@@ -1369,14 +1404,35 @@ func partsToMessageParts(
if isSyntheticPaste(name, mediaType) {
result = append(result, fantasy.TextPart{
Text: formatSyntheticPasteText(name, data),
ProviderOptions: providerMetadataToOptions(logger, part.ProviderMetadata),
ProviderOptions: opts,
})
continue
}
if part.FileID.Valid && !resolvedFile {
if policy == placeholderMissingFiles {
logger.Info(ctx,
"chat file unavailable, replacing file part with text placeholder",
slog.F("file_id", part.FileID.UUID),
slog.F("media_type", mediaType),
)
result = append(result, fantasy.TextPart{
Text: formatMissingAttachmentText(mediaType),
ProviderOptions: opts,
})
}
continue
}
if len(data) == 0 {
// File parts without bytes are persistence metadata, empty
// uploads, or provider-invalid prompt content. Unresolved
// file-backed parts are handled above so empty uploads do
// not look expired.
continue
}
result = append(result, fantasy.FilePart{
Data: data,
MediaType: mediaType,
ProviderOptions: providerMetadataToOptions(logger, part.ProviderMetadata),
ProviderOptions: opts,
})
case codersdk.ChatMessagePartTypeFileReference:
// LLMs don't understand file-reference natively.
@@ -191,6 +191,171 @@ func TestConvertMessagesWithFiles_ResolvesFileData(t *testing.T) {
require.Equal(t, "image/png", filePart.MediaType)
}
func TestConvertMessagesWithFiles_MissingFileBackedAttachmentBecomesTextPart(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mediaType string
expectedText string
}{
{
name: "missing image file",
mediaType: "image/png",
expectedText: "[missing-attachment] The user attached a file here, but the content has expired and is no longer available. " +
"Reported MIME type: image/png. If you need to inspect it, ask the user to re-upload.",
},
{
name: "generic mime omits mime sentence",
mediaType: "application/octet-stream",
expectedText: "[missing-attachment] The user attached a file here, but the content has expired and is no longer available. If you need to inspect it, ask the user to re-upload.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fileID := uuid.New()
rawContent := mustJSON(t, []json.RawMessage{
mustJSON(t, map[string]any{
"type": "file",
"data": map[string]any{
"media_type": tt.mediaType,
"file_id": fileID.String(),
},
}),
})
resolver := func(_ context.Context, _ []uuid.UUID) (map[uuid.UUID]chatprompt.FileData, error) {
return map[uuid.UUID]chatprompt.FileData{}, nil
}
prompt, err := chatprompt.ConvertMessagesWithFiles(
context.Background(),
[]database.ChatMessage{{
Role: database.ChatMessageRoleUser,
Visibility: database.ChatMessageVisibilityBoth,
Content: pqtype.NullRawMessage{RawMessage: rawContent, Valid: true},
}},
resolver,
slogtest.Make(t, nil),
)
require.NoError(t, err)
require.Len(t, prompt, 1)
require.Len(t, prompt[0].Content, 1)
textPart, ok := fantasy.AsMessagePart[fantasy.TextPart](prompt[0].Content[0])
require.True(t, ok, "expected TextPart")
require.Equal(t, tt.expectedText, textPart.Text)
})
}
}
func TestConvertMessagesWithFiles_ResolvedZeroByteFileIsDropped(t *testing.T) {
t.Parallel()
fileID := uuid.New()
rawContent := mustJSON(t, []json.RawMessage{
mustJSON(t, map[string]any{
"type": "file",
"data": map[string]any{
"file_id": fileID.String(),
},
}),
})
resolver := func(_ context.Context, ids []uuid.UUID) (map[uuid.UUID]chatprompt.FileData, error) {
result := make(map[uuid.UUID]chatprompt.FileData)
for _, id := range ids {
if id == fileID {
result[id] = chatprompt.FileData{
Data: []byte{},
MediaType: "text/plain",
Name: "empty.txt",
}
}
}
return result, nil
}
prompt, err := chatprompt.ConvertMessagesWithFiles(
context.Background(),
[]database.ChatMessage{{
Role: database.ChatMessageRoleUser,
Visibility: database.ChatMessageVisibilityBoth,
Content: pqtype.NullRawMessage{RawMessage: rawContent, Valid: true},
}},
resolver,
slogtest.Make(t, nil),
)
require.NoError(t, err)
require.Empty(t, prompt)
}
func TestConvertMessagesWithFiles_MixedResolvedAndMissingFilePartsInSingleMessage(t *testing.T) {
t.Parallel()
resolvedFileID := uuid.New()
missingFileID := uuid.New()
resolvedData := []byte("resolved-image-data")
rawContent := mustJSON(t, []json.RawMessage{
mustJSON(t, map[string]any{
"type": "file",
"data": map[string]any{
"media_type": "image/png",
"file_id": resolvedFileID.String(),
},
}),
mustJSON(t, map[string]any{
"type": "file",
"data": map[string]any{
"media_type": "application/pdf",
"file_id": missingFileID.String(),
},
}),
})
resolver := func(_ context.Context, ids []uuid.UUID) (map[uuid.UUID]chatprompt.FileData, error) {
result := make(map[uuid.UUID]chatprompt.FileData)
for _, id := range ids {
if id == resolvedFileID {
result[id] = chatprompt.FileData{
Data: resolvedData,
MediaType: "image/png",
Name: "resolved.png",
}
}
}
return result, nil
}
prompt, err := chatprompt.ConvertMessagesWithFiles(
context.Background(),
[]database.ChatMessage{{
Role: database.ChatMessageRoleUser,
Visibility: database.ChatMessageVisibilityBoth,
Content: pqtype.NullRawMessage{RawMessage: rawContent, Valid: true},
}},
resolver,
slogtest.Make(t, nil),
)
require.NoError(t, err)
require.Len(t, prompt, 1)
require.Equal(t, fantasy.MessageRoleUser, prompt[0].Role)
require.Len(t, prompt[0].Content, 2)
filePart, ok := fantasy.AsMessagePart[fantasy.FilePart](prompt[0].Content[0])
require.True(t, ok, "expected first part to stay a FilePart")
require.Equal(t, resolvedData, filePart.Data)
require.Equal(t, "image/png", filePart.MediaType)
textPart, ok := fantasy.AsMessagePart[fantasy.TextPart](prompt[0].Content[1])
require.True(t, ok, "expected missing second part to become a TextPart")
require.Equal(t,
"[missing-attachment] The user attached a file here, but the content has expired and is no longer available. "+
"Reported MIME type: application/pdf. If you need to inspect it, ask the user to re-upload.",
textPart.Text,
)
}
func TestConvertMessagesWithFiles_BackwardCompat(t *testing.T) {
t.Parallel()