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}