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 add empty reasoning_content to tool call messages when thinking is enabled", func(t *testing.T) {
351		t.Parallel()
352
353		// When thinking is enabled (reasoning parts exist in history),
354		// tool call messages without their own reasoning must still include
355		// reasoning_content. Providers like Kimi require it.
356		prompt := fantasy.Prompt{
357			{
358				Role: fantasy.MessageRoleUser,
359				Content: []fantasy.MessagePart{
360					fantasy.TextPart{Text: "What is 2+2?"},
361				},
362			},
363			{
364				// First turn has reasoning
365				Role: fantasy.MessageRoleAssistant,
366				Content: []fantasy.MessagePart{
367					fantasy.ReasoningPart{Text: "Simple math."},
368					fantasy.TextPart{Text: "Four"},
369				},
370			},
371			{
372				Role: fantasy.MessageRoleUser,
373				Content: []fantasy.MessagePart{
374					fantasy.TextPart{Text: "Now try a tool call"},
375				},
376			},
377			{
378				// Tool call WITHOUT reasoning on this turn
379				Role: fantasy.MessageRoleAssistant,
380				Content: []fantasy.MessagePart{
381					fantasy.ToolCallPart{
382						ToolCallID: "call_1",
383						ToolName:   "execute",
384						Input:      `{"command":"echo 4"}`,
385					},
386				},
387			},
388		}
389
390		messages, warnings := ToPromptFunc(prompt, "", "")
391
392		require.Empty(t, warnings)
393		require.Len(t, messages, 4)
394
395		// Tool call message must have reasoning_content (empty) since
396		// thinking is enabled in this conversation
397		msg := messages[3].OfAssistant
398		require.NotNil(t, msg)
399		extraFields := msg.ExtraFields()
400		reasoningContent, hasReasoning := extraFields["reasoning_content"]
401		require.True(t, hasReasoning, "reasoning_content must be present on tool call messages when thinking is enabled")
402		require.Equal(t, "", reasoningContent)
403	})
404
405	t.Run("should drop user messages without visible content", func(t *testing.T) {
406		t.Parallel()
407
408		prompt := fantasy.Prompt{
409			{
410				Role: fantasy.MessageRoleUser,
411				Content: []fantasy.MessagePart{
412					fantasy.FilePart{
413						Data:      []byte("not supported"),
414						MediaType: "application/unknown",
415					},
416				},
417			},
418		}
419
420		messages, warnings := ToPromptFunc(prompt, "", "")
421
422		require.Empty(t, messages)
423		require.Len(t, warnings, 2) // unsupported type + empty message
424		require.Contains(t, warnings[1].Message, "dropping empty user message")
425	})
426
427	t.Run("should keep user messages with image content", func(t *testing.T) {
428		t.Parallel()
429
430		prompt := fantasy.Prompt{
431			{
432				Role: fantasy.MessageRoleUser,
433				Content: []fantasy.MessagePart{
434					fantasy.FilePart{
435						Data:      []byte{0x01, 0x02, 0x03},
436						MediaType: "image/png",
437					},
438				},
439			},
440		}
441
442		messages, warnings := ToPromptFunc(prompt, "", "")
443
444		require.Len(t, messages, 1)
445		require.Empty(t, warnings)
446	})
447
448	t.Run("should keep user messages with tool results", func(t *testing.T) {
449		t.Parallel()
450
451		prompt := fantasy.Prompt{
452			{
453				Role: fantasy.MessageRoleTool,
454				Content: []fantasy.MessagePart{
455					fantasy.ToolResultPart{
456						ToolCallID: "call_123",
457						Output:     fantasy.ToolResultOutputContentText{Text: "done"},
458					},
459				},
460			},
461		}
462
463		messages, warnings := ToPromptFunc(prompt, "", "")
464
465		require.Len(t, messages, 1)
466		require.Empty(t, warnings)
467	})
468
469	t.Run("should keep user messages with tool error results", func(t *testing.T) {
470		t.Parallel()
471
472		prompt := fantasy.Prompt{
473			{
474				Role: fantasy.MessageRoleTool,
475				Content: []fantasy.MessagePart{
476					fantasy.ToolResultPart{
477						ToolCallID: "call_456",
478						Output:     fantasy.ToolResultOutputContentError{Error: errors.New("boom")},
479					},
480				},
481			},
482		}
483
484		messages, warnings := ToPromptFunc(prompt, "", "")
485
486		require.Len(t, messages, 1)
487		require.Empty(t, warnings)
488	})
489}