From e031e04b63da453ed6812fdf971ccab7f45e88dd Mon Sep 17 00:00:00 2001 From: Amolith Date: Tue, 2 Dec 2025 01:37:56 -0700 Subject: [PATCH] fix(anthropic): drop empty messages with warning (#79) --- providers/anthropic/anthropic.go | 39 ++- providers/anthropic/anthropic_test.go | 343 ++++++++++++++++++++++++++ 2 files changed, 379 insertions(+), 3 deletions(-) create mode 100644 providers/anthropic/anthropic_test.go diff --git a/providers/anthropic/anthropic.go b/providers/anthropic/anthropic.go index eb1500060a9e53db4e8a41b7a21493a1ddacb07e..afc8dcc5dd2c92596eadf730105b298c48a1e325 100644 --- a/providers/anthropic/anthropic.go +++ b/providers/anthropic/anthropic.go @@ -619,6 +619,13 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl } } } + if !hasVisibleUserContent(anthropicContent) { + warnings = append(warnings, fantasy.CallWarning{ + Type: fantasy.CallWarningTypeOther, + Message: "dropping empty user message (contains neither user-facing content nor tool results)", + }) + continue + } messages = append(messages, anthropic.NewUserMessage(anthropicContent...)) case fantasy.MessageRoleAssistant: var anthropicContent []anthropic.ContentBlockParamUnion @@ -651,7 +658,7 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl } if !sendReasoningData { warnings = append(warnings, fantasy.CallWarning{ - Type: "other", + Type: fantasy.CallWarningTypeOther, Message: "sending reasoning content is disabled for this model", }) continue @@ -659,7 +666,7 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl reasoningMetadata := GetReasoningMetadata(part.Options()) if reasoningMetadata == nil { warnings = append(warnings, fantasy.CallWarning{ - Type: "other", + Type: fantasy.CallWarningTypeOther, Message: "unsupported reasoning metadata", }) continue @@ -671,7 +678,7 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl anthropicContent = append(anthropicContent, anthropic.NewRedactedThinkingBlock(reasoningMetadata.RedactedData)) } else { warnings = append(warnings, fantasy.CallWarning{ - Type: "other", + Type: fantasy.CallWarningTypeOther, Message: "unsupported reasoning metadata", }) continue @@ -701,12 +708,38 @@ func toPrompt(prompt fantasy.Prompt, sendReasoningData bool) ([]anthropic.TextBl } } } + + if !hasVisibleAssistantContent(anthropicContent) { + warnings = append(warnings, fantasy.CallWarning{ + Type: fantasy.CallWarningTypeOther, + Message: "dropping empty assistant message (contains neither user-facing content nor tool calls)", + }) + continue + } messages = append(messages, anthropic.NewAssistantMessage(anthropicContent...)) } } return systemBlocks, messages, warnings } +func hasVisibleUserContent(content []anthropic.ContentBlockParamUnion) bool { + for _, block := range content { + if block.OfText != nil || block.OfImage != nil || block.OfToolResult != nil { + return true + } + } + return false +} + +func hasVisibleAssistantContent(content []anthropic.ContentBlockParamUnion) bool { + for _, block := range content { + if block.OfText != nil || block.OfToolUse != nil { + return true + } + } + return false +} + func mapFinishReason(finishReason string) fantasy.FinishReason { switch finishReason { case "end_turn", "pause_turn", "stop_sequence": diff --git a/providers/anthropic/anthropic_test.go b/providers/anthropic/anthropic_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b826882c59b5ce1a2002dce6e0aca8982498f62b --- /dev/null +++ b/providers/anthropic/anthropic_test.go @@ -0,0 +1,343 @@ +package anthropic + +import ( + "errors" + "testing" + + "charm.land/fantasy" + "github.com/stretchr/testify/require" +) + +func TestToPrompt_DropsEmptyMessages(t *testing.T) { + t.Parallel() + + t.Run("should drop assistant messages with only reasoning content", func(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "Hello"}, + }, + }, + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + fantasy.ReasoningPart{ + Text: "Let me think about this...", + ProviderOptions: fantasy.ProviderOptions{ + Name: &ReasoningOptionMetadata{ + Signature: "abc123", + }, + }, + }, + }, + }, + } + + systemBlocks, messages, warnings := toPrompt(prompt, true) + + require.Empty(t, systemBlocks) + require.Len(t, messages, 1, "should only have user message, assistant message should be dropped") + require.Len(t, warnings, 1) + require.Equal(t, fantasy.CallWarningTypeOther, warnings[0].Type) + require.Contains(t, warnings[0].Message, "dropping empty assistant message") + require.Contains(t, warnings[0].Message, "neither user-facing content nor tool calls") + }) + + t.Run("should drop assistant reasoning when sendReasoning disabled", func(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "Hello"}, + }, + }, + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + fantasy.ReasoningPart{ + Text: "Let me think about this...", + ProviderOptions: fantasy.ProviderOptions{ + Name: &ReasoningOptionMetadata{ + Signature: "def456", + }, + }, + }, + }, + }, + } + + systemBlocks, messages, warnings := toPrompt(prompt, false) + + require.Empty(t, systemBlocks) + require.Len(t, messages, 1, "should only have user message, assistant message should be dropped") + require.Len(t, warnings, 2) + require.Equal(t, fantasy.CallWarningTypeOther, warnings[0].Type) + require.Contains(t, warnings[0].Message, "sending reasoning content is disabled") + require.Equal(t, fantasy.CallWarningTypeOther, warnings[1].Type) + require.Contains(t, warnings[1].Message, "dropping empty assistant message") + }) + + t.Run("should drop truly empty assistant messages", func(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "Hello"}, + }, + }, + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{}, + }, + } + + systemBlocks, messages, warnings := toPrompt(prompt, true) + + require.Empty(t, systemBlocks) + require.Len(t, messages, 1, "should only have user message") + require.Len(t, warnings, 1) + require.Equal(t, fantasy.CallWarningTypeOther, warnings[0].Type) + require.Contains(t, warnings[0].Message, "dropping empty assistant message") + }) + + t.Run("should keep assistant messages with text content", func(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "Hello"}, + }, + }, + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "Hi there!"}, + }, + }, + } + + systemBlocks, messages, warnings := toPrompt(prompt, true) + + require.Empty(t, systemBlocks) + require.Len(t, messages, 2, "should have both user and assistant messages") + require.Empty(t, warnings) + }) + + t.Run("should keep assistant messages with tool calls", func(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "What's the weather?"}, + }, + }, + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + fantasy.ToolCallPart{ + ToolCallID: "call_123", + ToolName: "get_weather", + Input: `{"location":"NYC"}`, + }, + }, + }, + } + + systemBlocks, messages, warnings := toPrompt(prompt, true) + + require.Empty(t, systemBlocks) + require.Len(t, messages, 2, "should have both user and assistant messages") + require.Empty(t, warnings) + }) + + t.Run("should drop assistant messages with invalid tool input", func(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "Hi"}, + }, + }, + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + fantasy.ToolCallPart{ + ToolCallID: "call_123", + ToolName: "get_weather", + Input: "{not-json", + }, + }, + }, + } + + systemBlocks, messages, warnings := toPrompt(prompt, true) + + require.Empty(t, systemBlocks) + require.Len(t, messages, 1, "should only have user message") + require.Len(t, warnings, 1) + require.Equal(t, fantasy.CallWarningTypeOther, warnings[0].Type) + require.Contains(t, warnings[0].Message, "dropping empty assistant message") + }) + + t.Run("should keep assistant messages with reasoning and text", func(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.TextPart{Text: "Hello"}, + }, + }, + { + Role: fantasy.MessageRoleAssistant, + Content: []fantasy.MessagePart{ + fantasy.ReasoningPart{ + Text: "Let me think...", + ProviderOptions: fantasy.ProviderOptions{ + Name: &ReasoningOptionMetadata{ + Signature: "abc123", + }, + }, + }, + fantasy.TextPart{Text: "Hi there!"}, + }, + }, + } + + systemBlocks, messages, warnings := toPrompt(prompt, true) + + require.Empty(t, systemBlocks) + require.Len(t, messages, 2, "should have both user and assistant messages") + require.Empty(t, warnings) + }) + + t.Run("should keep user messages with image content", func(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.FilePart{ + Data: []byte{0x01, 0x02, 0x03}, + MediaType: "image/png", + }, + }, + }, + } + + systemBlocks, messages, warnings := toPrompt(prompt, true) + + require.Empty(t, systemBlocks) + require.Len(t, messages, 1) + require.Empty(t, warnings) + }) + + t.Run("should drop user messages without visible content", func(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleUser, + Content: []fantasy.MessagePart{ + fantasy.FilePart{ + Data: []byte("not supported"), + MediaType: "application/pdf", + }, + }, + }, + } + + systemBlocks, messages, warnings := toPrompt(prompt, true) + + require.Empty(t, systemBlocks) + require.Empty(t, messages) + require.Len(t, warnings, 1) + require.Equal(t, fantasy.CallWarningTypeOther, warnings[0].Type) + require.Contains(t, warnings[0].Message, "dropping empty user message") + require.Contains(t, warnings[0].Message, "neither user-facing content nor tool results") + }) + + t.Run("should keep user messages with tool results", func(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleTool, + Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ + ToolCallID: "call_123", + Output: fantasy.ToolResultOutputContentText{Text: "done"}, + }, + }, + }, + } + + systemBlocks, messages, warnings := toPrompt(prompt, true) + + require.Empty(t, systemBlocks) + require.Len(t, messages, 1) + require.Empty(t, warnings) + }) + + t.Run("should keep user messages with tool error results", func(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleTool, + Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ + ToolCallID: "call_456", + Output: fantasy.ToolResultOutputContentError{Error: errors.New("boom")}, + }, + }, + }, + } + + systemBlocks, messages, warnings := toPrompt(prompt, true) + + require.Empty(t, systemBlocks) + require.Len(t, messages, 1) + require.Empty(t, warnings) + }) + + t.Run("should keep user messages with tool media results", func(t *testing.T) { + t.Parallel() + + prompt := fantasy.Prompt{ + { + Role: fantasy.MessageRoleTool, + Content: []fantasy.MessagePart{ + fantasy.ToolResultPart{ + ToolCallID: "call_789", + Output: fantasy.ToolResultOutputContentMedia{ + Data: "AQID", + MediaType: "image/png", + }, + }, + }, + }, + } + + systemBlocks, messages, warnings := toPrompt(prompt, true) + + require.Empty(t, systemBlocks) + require.Len(t, messages, 1) + require.Empty(t, warnings) + }) +}