@@ -1,6 +1,7 @@
1package agent
1package agent
@@ -10,9 +11,11 @@ import (
14+ "charm.land/catwalk/pkg/catwalk"
13 "charm.land/fantasy"
15 "charm.land/fantasy"
14 "charm.land/x/vcr"
16 "charm.land/x/vcr"
15 "github.com/charmbracelet/crush/internal/agent/tools"
17 "github.com/charmbracelet/crush/internal/agent/tools"
18+ "github.com/charmbracelet/crush/internal/config"
16 "github.com/charmbracelet/crush/internal/message"
19 "github.com/charmbracelet/crush/internal/message"
17 "github.com/charmbracelet/crush/internal/session"
20 "github.com/charmbracelet/crush/internal/session"
18 "github.com/stretchr/testify/assert"
21 "github.com/stretchr/testify/assert"
@@ -840,6 +843,146 @@ func TestPreparePrompt_OrphanedToolUseMixed(t *testing.T) {
840 require.Equal(t, 1, syntheticCount, "expected exactly one synthetic result for the orphaned call")
843 require.Equal(t, 1, syntheticCount, "expected exactly one synthetic result for the orphaned call")
846+func TestWorkaroundProviderMediaLimitations_TextOnlyModel(t *testing.T) {
848+ sa := testSessionAgent(env, nil, nil, "test prompt")
849+ agent := sa.(*sessionAgent)
851+ pngBase64 := base64.StdEncoding.EncodeToString([]byte("fake-png-data"))
853+ messages := []fantasy.Message{
855+ Role: fantasy.MessageRoleTool,
856+ Content: []fantasy.MessagePart{
857+ fantasy.ToolResultPart{
858+ ToolCallID: "call_1",
859+ Output: fantasy.ToolResultOutputContentMedia{
861+ MediaType: "image/png",
868+ // Non-Anthropic provider, no image support — should replace media with
869+ // a text placeholder and not create a synthetic user message.
870+ largeModel := Model{
871+ ModelCfg: config.SelectedModel{Provider: "openai"},
872+ CatwalkCfg: catwalk.Model{
873+ SupportsImages: false,
877+ result := agent.workaroundProviderMediaLimitations(messages, largeModel)
879+ // Should produce exactly one message: the tool message with a text
880+ // placeholder. No synthetic user message with FilePart.
881+ require.Len(t, result, 1)
882+ require.Equal(t, fantasy.MessageRoleTool, result[0].Role)
884+ tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](result[0].Content[0])
886+ _, ok = fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](tr.Output)
890+func TestWorkaroundProviderMediaLimitations_VisionModel(t *testing.T) {
892+ sa := testSessionAgent(env, nil, nil, "test prompt")
893+ agent := sa.(*sessionAgent)
895+ pngBase64 := base64.StdEncoding.EncodeToString([]byte("fake-png-data"))
897+ messages := []fantasy.Message{
899+ Role: fantasy.MessageRoleTool,
900+ Content: []fantasy.MessagePart{
901+ fantasy.ToolResultPart{
902+ ToolCallID: "call_1",
903+ Output: fantasy.ToolResultOutputContentMedia{
905+ MediaType: "image/png",
912+ // Non-Anthropic provider, image support — should create a synthetic
913+ // user message with FilePart.
914+ largeModel := Model{
915+ ModelCfg: config.SelectedModel{Provider: "openai"},
916+ CatwalkCfg: catwalk.Model{
917+ SupportsImages: true,
921+ result := agent.workaroundProviderMediaLimitations(messages, largeModel)
923+ // Should produce two messages: tool message with placeholder text,
924+ // and synthetic user message with FilePart.
925+ require.Len(t, result, 2)
926+ require.Equal(t, fantasy.MessageRoleTool, result[0].Role)
927+ require.Equal(t, fantasy.MessageRoleUser, result[1].Role)
929+ // The tool message should have text placeholder.
930+ tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](result[0].Content[0])
932+ textOutput, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](tr.Output)
934+ require.Contains(t, textOutput.Text, "see attached file")
936+ // The synthetic user message should contain a TextPart and a FilePart.
937+ require.Len(t, result[1].Content, 2)
938+ file, ok := fantasy.AsMessagePart[fantasy.FilePart](result[1].Content[1])
940+ require.Equal(t, "image/png", file.MediaType)
943+func TestWorkaroundProviderMediaLimitations_AnthropicProvider(t *testing.T) {
945+ sa := testSessionAgent(env, nil, nil, "test prompt")
946+ agent := sa.(*sessionAgent)
948+ pngBase64 := base64.StdEncoding.EncodeToString([]byte("fake-png-data"))
950+ messages := []fantasy.Message{
952+ Role: fantasy.MessageRoleTool,
953+ Content: []fantasy.MessagePart{
954+ fantasy.ToolResultPart{
955+ ToolCallID: "call_1",
956+ Output: fantasy.ToolResultOutputContentMedia{
958+ MediaType: "image/png",
965+ // Anthropic provider — should return messages unchanged regardless of
966+ // SupportsImages, since Anthropic handles media in tool results natively.
967+ largeModel := Model{
968+ ModelCfg: config.SelectedModel{Provider: string(catwalk.InferenceProviderAnthropic)},
969+ CatwalkCfg: catwalk.Model{
970+ SupportsImages: true,
974+ result := agent.workaroundProviderMediaLimitations(messages, largeModel)
975+ require.Len(t, result, 1)
976+ require.Equal(t, fantasy.MessageRoleTool, result[0].Role)
978+ // The media should still be in the tool result, untouched.
979+ tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](result[0].Content[0])
981+ media, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](tr.Output)
983+ require.Equal(t, "image/png", media.MediaType)
843func TestProviderRetryLogFields(t *testing.T) {
986func TestProviderRetryLogFields(t *testing.T) {
844 t.Run("nil provider error", func(t *testing.T) {
987 t.Run("nil provider error", func(t *testing.T) {
845 fields := providerRetryLogFields(nil, 2*time.Second)
988 fields := providerRetryLogFields(nil, 2*time.Second)