openaicompat_test.go

  1package openaicompat
  2
  3import (
  4	"errors"
  5	"testing"
  6
  7	"charm.land/fantasy"
  8	"github.com/stretchr/testify/require"
  9)
 10
 11func TestToPromptFunc_ReasoningContent(t *testing.T) {
 12	t.Parallel()
 13
 14	t.Run("should add reasoning_content field to assistant messages", func(t *testing.T) {
 15		t.Parallel()
 16
 17		prompt := fantasy.Prompt{
 18			{
 19				Role: fantasy.MessageRoleUser,
 20				Content: []fantasy.MessagePart{
 21					fantasy.TextPart{Text: "What is 2+2?"},
 22				},
 23			},
 24			{
 25				Role: fantasy.MessageRoleAssistant,
 26				Content: []fantasy.MessagePart{
 27					fantasy.ReasoningPart{Text: "Let me think... 2+2 equals 4."},
 28					fantasy.TextPart{Text: "The answer is 4."},
 29				},
 30			},
 31			{
 32				Role: fantasy.MessageRoleUser,
 33				Content: []fantasy.MessagePart{
 34					fantasy.TextPart{Text: "What about 3+3?"},
 35				},
 36			},
 37		}
 38
 39		messages, warnings := ToPromptFunc(prompt, "", "")
 40
 41		require.Empty(t, warnings)
 42		require.Len(t, messages, 3)
 43
 44		// First message (user) - no reasoning
 45		msg1 := messages[0].OfUser
 46		require.NotNil(t, msg1)
 47		require.Equal(t, "What is 2+2?", msg1.Content.OfString.Value)
 48
 49		// Second message (assistant) - with reasoning
 50		msg2 := messages[1].OfAssistant
 51		require.NotNil(t, msg2)
 52		require.Equal(t, "The answer is 4.", msg2.Content.OfString.Value)
 53		// Check reasoning_content in extra fields
 54		extraFields := msg2.ExtraFields()
 55		reasoningContent, hasReasoning := extraFields["reasoning_content"]
 56		require.True(t, hasReasoning)
 57		require.Equal(t, "Let me think... 2+2 equals 4.", reasoningContent)
 58
 59		// Third message (user) - no reasoning
 60		msg3 := messages[2].OfUser
 61		require.NotNil(t, msg3)
 62		require.Equal(t, "What about 3+3?", msg3.Content.OfString.Value)
 63	})
 64
 65	t.Run("should handle assistant messages with only reasoning content", func(t *testing.T) {
 66		t.Parallel()
 67
 68		prompt := fantasy.Prompt{
 69			{
 70				Role: fantasy.MessageRoleUser,
 71				Content: []fantasy.MessagePart{
 72					fantasy.TextPart{Text: "Hello"},
 73				},
 74			},
 75			{
 76				Role: fantasy.MessageRoleAssistant,
 77				Content: []fantasy.MessagePart{
 78					fantasy.ReasoningPart{Text: "Internal reasoning only..."},
 79				},
 80			},
 81		}
 82
 83		messages, warnings := ToPromptFunc(prompt, "", "")
 84
 85		require.Len(t, warnings, 1)
 86		require.Contains(t, warnings[0].Message, "dropping empty assistant message")
 87		require.Len(t, messages, 1) // Only user message, assistant message dropped
 88
 89		// User message - unchanged
 90		msg := messages[0].OfUser
 91		require.NotNil(t, msg)
 92		require.Equal(t, "Hello", msg.Content.OfString.Value)
 93	})
 94
 95	t.Run("should not add reasoning_content to messages without reasoning", func(t *testing.T) {
 96		t.Parallel()
 97
 98		prompt := fantasy.Prompt{
 99			{
100				Role: fantasy.MessageRoleUser,
101				Content: []fantasy.MessagePart{
102					fantasy.TextPart{Text: "Hello"},
103				},
104			},
105			{
106				Role: fantasy.MessageRoleAssistant,
107				Content: []fantasy.MessagePart{
108					fantasy.TextPart{Text: "Hi there!"},
109				},
110			},
111		}
112
113		messages, warnings := ToPromptFunc(prompt, "", "")
114
115		require.Empty(t, warnings)
116		require.Len(t, messages, 2)
117
118		// Assistant message without reasoning
119		msg := messages[1].OfAssistant
120		require.NotNil(t, msg)
121		require.Equal(t, "Hi there!", msg.Content.OfString.Value)
122		extraFields := msg.ExtraFields()
123		_, hasReasoning := extraFields["reasoning_content"]
124		require.False(t, hasReasoning)
125	})
126
127	t.Run("should preserve system and user messages unchanged", func(t *testing.T) {
128		t.Parallel()
129
130		prompt := fantasy.Prompt{
131			{
132				Role: fantasy.MessageRoleSystem,
133				Content: []fantasy.MessagePart{
134					fantasy.TextPart{Text: "You are helpful."},
135				},
136			},
137			{
138				Role: fantasy.MessageRoleUser,
139				Content: []fantasy.MessagePart{
140					fantasy.TextPart{Text: "Hello"},
141				},
142			},
143		}
144
145		messages, warnings := ToPromptFunc(prompt, "", "")
146
147		require.Empty(t, warnings)
148		require.Len(t, messages, 2)
149
150		// System message - unchanged
151		systemMsg := messages[0].OfSystem
152		require.NotNil(t, systemMsg)
153		require.Equal(t, "You are helpful.", systemMsg.Content.OfString.Value)
154
155		// User message - unchanged
156		userMsg := messages[1].OfUser
157		require.NotNil(t, userMsg)
158		require.Equal(t, "Hello", userMsg.Content.OfString.Value)
159	})
160
161	t.Run("should use last assistant TextPart only", func(t *testing.T) {
162		t.Parallel()
163
164		prompt := fantasy.Prompt{
165			{
166				Role: fantasy.MessageRoleUser,
167				Content: []fantasy.MessagePart{
168					fantasy.TextPart{Text: "Hello"},
169				},
170			},
171			{
172				Role: fantasy.MessageRoleAssistant,
173				Content: []fantasy.MessagePart{
174					fantasy.TextPart{Text: "First part. "},
175					fantasy.TextPart{Text: "Second part. "},
176					fantasy.TextPart{Text: "Third part."},
177				},
178			},
179		}
180
181		messages, warnings := ToPromptFunc(prompt, "", "")
182
183		require.Empty(t, warnings)
184		require.Len(t, messages, 2)
185
186		// Assistant message should use only the last TextPart (matching openai behavior)
187		assistantMsg := messages[1].OfAssistant
188		require.NotNil(t, assistantMsg)
189		require.Equal(t, "Third part.", assistantMsg.Content.OfString.Value)
190	})
191
192	t.Run("should include user messages with only unsupported attachments", func(t *testing.T) {
193		t.Parallel()
194
195		prompt := fantasy.Prompt{
196			{
197				Role: fantasy.MessageRoleUser,
198				Content: []fantasy.MessagePart{
199					fantasy.TextPart{Text: "Hello"},
200				},
201			},
202			{
203				Role: fantasy.MessageRoleUser,
204				Content: []fantasy.MessagePart{
205					fantasy.FilePart{
206						MediaType: "application/x-unsupported",
207						Data:      []byte("unsupported data"),
208					},
209				},
210			},
211			{
212				Role: fantasy.MessageRoleUser,
213				Content: []fantasy.MessagePart{
214					fantasy.TextPart{Text: "After unsupported"},
215				},
216			},
217		}
218
219		messages, warnings := ToPromptFunc(prompt, "", "")
220
221		require.Len(t, warnings, 2) // unsupported type + empty message
222		require.Contains(t, warnings[0].Message, "not supported")
223		require.Contains(t, warnings[1].Message, "dropping empty user message")
224		// Should have only 2 messages (empty content message is now dropped)
225		require.Len(t, messages, 2)
226
227		msg1 := messages[0].OfUser
228		require.NotNil(t, msg1)
229		require.Equal(t, "Hello", msg1.Content.OfString.Value)
230
231		msg2 := messages[1].OfUser
232		require.NotNil(t, msg2)
233		require.Equal(t, "After unsupported", msg2.Content.OfString.Value)
234	})
235
236	t.Run("should detect PDF file IDs using strings.HasPrefix", func(t *testing.T) {
237		t.Parallel()
238
239		prompt := fantasy.Prompt{
240			{
241				Role: fantasy.MessageRoleUser,
242				Content: []fantasy.MessagePart{
243					fantasy.TextPart{Text: "Check this PDF"},
244					fantasy.FilePart{
245						MediaType: "application/pdf",
246						Data:      []byte("file-abc123xyz"),
247						Filename:  "test.pdf",
248					},
249				},
250			},
251		}
252
253		messages, warnings := ToPromptFunc(prompt, "", "")
254
255		require.Empty(t, warnings)
256		require.Len(t, messages, 1)
257
258		userMsg := messages[0].OfUser
259		require.NotNil(t, userMsg)
260
261		content := userMsg.Content.OfArrayOfContentParts
262		require.Len(t, content, 2)
263
264		// Second content part should be file with file_id
265		filePart := content[1].OfFile
266		require.NotNil(t, filePart)
267		require.Equal(t, "file-abc123xyz", filePart.File.FileID.Value)
268	})
269}
270
271func TestToPromptFunc_DropsEmptyMessages(t *testing.T) {
272	t.Parallel()
273
274	t.Run("should drop truly empty assistant messages", func(t *testing.T) {
275		t.Parallel()
276
277		prompt := fantasy.Prompt{
278			{
279				Role: fantasy.MessageRoleUser,
280				Content: []fantasy.MessagePart{
281					fantasy.TextPart{Text: "Hello"},
282				},
283			},
284			{
285				Role:    fantasy.MessageRoleAssistant,
286				Content: []fantasy.MessagePart{},
287			},
288		}
289
290		messages, warnings := ToPromptFunc(prompt, "", "")
291
292		require.Len(t, messages, 1, "should only have user message")
293		require.Len(t, warnings, 1)
294		require.Equal(t, fantasy.CallWarningTypeOther, warnings[0].Type)
295		require.Contains(t, warnings[0].Message, "dropping empty assistant message")
296	})
297
298	t.Run("should keep assistant messages with text content", func(t *testing.T) {
299		t.Parallel()
300
301		prompt := fantasy.Prompt{
302			{
303				Role: fantasy.MessageRoleUser,
304				Content: []fantasy.MessagePart{
305					fantasy.TextPart{Text: "Hello"},
306				},
307			},
308			{
309				Role: fantasy.MessageRoleAssistant,
310				Content: []fantasy.MessagePart{
311					fantasy.TextPart{Text: "Hi there!"},
312				},
313			},
314		}
315
316		messages, warnings := ToPromptFunc(prompt, "", "")
317
318		require.Len(t, messages, 2, "should have both user and assistant messages")
319		require.Empty(t, warnings)
320	})
321
322	t.Run("should keep assistant messages with tool calls", func(t *testing.T) {
323		t.Parallel()
324
325		prompt := fantasy.Prompt{
326			{
327				Role: fantasy.MessageRoleUser,
328				Content: []fantasy.MessagePart{
329					fantasy.TextPart{Text: "What's the weather?"},
330				},
331			},
332			{
333				Role: fantasy.MessageRoleAssistant,
334				Content: []fantasy.MessagePart{
335					fantasy.ToolCallPart{
336						ToolCallID: "call_123",
337						ToolName:   "get_weather",
338						Input:      `{"location":"NYC"}`,
339					},
340				},
341			},
342		}
343
344		messages, warnings := ToPromptFunc(prompt, "", "")
345
346		require.Len(t, messages, 2, "should have both user and assistant messages")
347		require.Empty(t, warnings)
348	})
349
350	t.Run("should drop user messages without visible content", func(t *testing.T) {
351		t.Parallel()
352
353		prompt := fantasy.Prompt{
354			{
355				Role: fantasy.MessageRoleUser,
356				Content: []fantasy.MessagePart{
357					fantasy.FilePart{
358						Data:      []byte("not supported"),
359						MediaType: "application/unknown",
360					},
361				},
362			},
363		}
364
365		messages, warnings := ToPromptFunc(prompt, "", "")
366
367		require.Empty(t, messages)
368		require.Len(t, warnings, 2) // unsupported type + empty message
369		require.Contains(t, warnings[1].Message, "dropping empty user message")
370	})
371
372	t.Run("should keep user messages with image content", func(t *testing.T) {
373		t.Parallel()
374
375		prompt := fantasy.Prompt{
376			{
377				Role: fantasy.MessageRoleUser,
378				Content: []fantasy.MessagePart{
379					fantasy.FilePart{
380						Data:      []byte{0x01, 0x02, 0x03},
381						MediaType: "image/png",
382					},
383				},
384			},
385		}
386
387		messages, warnings := ToPromptFunc(prompt, "", "")
388
389		require.Len(t, messages, 1)
390		require.Empty(t, warnings)
391	})
392
393	t.Run("should keep user messages with tool results", func(t *testing.T) {
394		t.Parallel()
395
396		prompt := fantasy.Prompt{
397			{
398				Role: fantasy.MessageRoleTool,
399				Content: []fantasy.MessagePart{
400					fantasy.ToolResultPart{
401						ToolCallID: "call_123",
402						Output:     fantasy.ToolResultOutputContentText{Text: "done"},
403					},
404				},
405			},
406		}
407
408		messages, warnings := ToPromptFunc(prompt, "", "")
409
410		require.Len(t, messages, 1)
411		require.Empty(t, warnings)
412	})
413
414	t.Run("should keep user messages with tool error results", func(t *testing.T) {
415		t.Parallel()
416
417		prompt := fantasy.Prompt{
418			{
419				Role: fantasy.MessageRoleTool,
420				Content: []fantasy.MessagePart{
421					fantasy.ToolResultPart{
422						ToolCallID: "call_456",
423						Output:     fantasy.ToolResultOutputContentError{Error: errors.New("boom")},
424					},
425				},
426			},
427		}
428
429		messages, warnings := ToPromptFunc(prompt, "", "")
430
431		require.Len(t, messages, 1)
432		require.Empty(t, warnings)
433	})
434}