fix(agent): replay attachments in multi-turn history

Agent mode does not persist `rendered_content` for user messages, so
when the next turn's history was rebuilt from DB, attachments uploaded
in prior turns disappeared — the model only saw the raw query plus the
prior assistant reply, breaking follow-up questions that referenced
the file (e.g. "what is in there?").

Reconstruct the attachment prompt from the stored `Attachments` column
when `RenderedContent` is empty, mirroring how image captions are
already replayed. KnowledgeQA turns (which do persist
`RenderedContent`) are unaffected and won't get attachments injected
twice.

Refs #1237
This commit is contained in:
wizardchen
2026-05-12 14:46:57 +08:00
committed by lyingbug
parent d2fb51b809
commit 9ad8e7ca78
2 changed files with 53 additions and 2 deletions
+11 -2
View File
@@ -127,8 +127,17 @@ func buildUserHistoryMessage(m *types.Message) chat.Message {
if content == "" {
content = m.Content
}
if captions := extractImageCaptionsFromMessage(m.Images); captions != "" && m.RenderedContent == "" {
content += "\n\n[用户上传图片内容]\n" + captions
// Only append fallbacks when RenderedContent is absent — when present, it
// already carries the augmented version persisted by the original turn.
// Agent-mode turns currently do not persist RenderedContent, so attachments
// and image captions would otherwise be invisible to subsequent rounds.
if m.RenderedContent == "" {
if captions := extractImageCaptionsFromMessage(m.Images); captions != "" {
content += "\n\n[用户上传图片内容]\n" + captions
}
if len(m.Attachments) > 0 {
content += m.Attachments.BuildPrompt()
}
}
return chat.Message{Role: "user", Content: content}
}
@@ -42,6 +42,48 @@ func TestBuildUserHistoryMessage_FallsBackToContentWithCaptions(t *testing.T) {
assert.Equal(t, "look at this\n\n[用户上传图片内容]\na bar chart\na pie chart", got.Content)
}
// TestBuildUserHistoryMessage_AppendsAttachmentsWhenNoRenderedContent covers
// the Agent-mode multi-turn path: AgentQA does not persist RenderedContent, so
// the next turn's history must reconstruct the original attachment prompt from
// the stored Attachments column. Otherwise, follow-up questions like "what is
// in there?" lose all reference to the uploaded file.
func TestBuildUserHistoryMessage_AppendsAttachmentsWhenNoRenderedContent(t *testing.T) {
msg := &types.Message{
Role: "user",
Content: "summarize this",
Attachments: types.MessageAttachments{
{
FileName: "report.pdf",
FileType: ".pdf",
FileSize: 2048,
Content: "hello world",
},
},
}
got := buildUserHistoryMessage(msg)
assert.Equal(t, "user", got.Role)
assert.Contains(t, got.Content, "summarize this")
assert.Contains(t, got.Content, `<attachment index="1" name="report.pdf">`)
assert.Contains(t, got.Content, "hello world")
}
// TestBuildUserHistoryMessage_RenderedContentSkipsAttachmentReplay ensures the
// KnowledgeQA path (where RenderedContent already includes the attachment
// prompt persisted by the pipeline) does not double-inject attachments.
func TestBuildUserHistoryMessage_RenderedContentSkipsAttachmentReplay(t *testing.T) {
msg := &types.Message{
Role: "user",
Content: "summarize this",
RenderedContent: "summarize this [with retrieval context already included]",
Attachments: types.MessageAttachments{
{FileName: "report.pdf", FileType: ".pdf", Content: "hello"},
},
}
got := buildUserHistoryMessage(msg)
assert.Equal(t, "summarize this [with retrieval context already included]", got.Content)
assert.NotContains(t, got.Content, "<attachment")
}
// TestBuildAssistantHistoryMessages_NaturalFinishEmitsSingleAnswer covers the
// most common path: a turn with no tool calls (model answered directly). The
// result must be a single assistant message holding the canonical answer —