@@ -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":
@@ -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)
+ })
+}