openai_responses_reasoning_test.go RawHistoryFolder
1package provider
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "strings"
9 "testing"
10)
11
12func terminalPayload(event, id, usage, output string) string {
13 response := `"id":"` + id + `"`
14 if usage != "" {
15 response += `,` + usage
16 }
17 if output != "" {
18 response += `,"output":` + output
19 }
20 return `{"type":"` + event + `","response":{` + response + `}}`
21}
22
23func reasoningTerminalItem(id, encrypted, summary string) string {
24 var sb strings.Builder
25 sb.WriteString(`{"type":"reasoning"`)
26 if id != "" {
27 enc, _ := json.Marshal(id)
28 sb.WriteString(`,"id":` + string(enc))
29 }
30 if encrypted != "" {
31 enc, _ := json.Marshal(encrypted)
32 sb.WriteString(`,"encrypted_content":` + string(enc))
33 }
34 sb.WriteString(`,"summary":`)
35 if summary == "" {
36 sb.WriteString(`[]`)
37 } else {
38 text, _ := json.Marshal(summary)
39 sb.WriteString(`[{"type":"summary_text","text":` + string(text) + `}]`)
40 }
41 sb.WriteString(`}`)
42 return sb.String()
43}
44
45func findReasoningBlocks(msg Message) []ReasoningBlock {
46 var out []ReasoningBlock
47 for _, c := range msg.Content {
48 if rb, ok := c.(ReasoningBlock); ok {
49 out = append(out, rb)
50 }
51 }
52 return out
53}
54
55func TestResponsesReasoningTerminalBackfillCompleted(t *testing.T) {
56 done, _ := runResponsesCompletionFixture(t,
57 `{"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_test_a"}}`,
58 terminalPayload("response.completed", "resp-1", `"usage":{"input_tokens":1,"output_tokens":1}`, `[`+reasoningTerminalItem("rs_test_a", "blob-terminal", "")+`]`),
59 )
60 blocks := findReasoningBlocks(done.Message)
61 if len(blocks) != 1 {
62 t.Fatalf("reasoning blocks = %#v, want 1", done.Message.Content)
63 }
64 if blocks[0].ID != "rs_test_a" || blocks[0].Encrypted != "blob-terminal" {
65 t.Fatalf("reasoning = %+v, want id rs_test_a with terminal payload", blocks[0])
66 }
67}
68
69func TestResponsesReasoningTerminalBackfillDone(t *testing.T) {
70 done, _ := runResponsesCompletionFixture(t,
71 `{"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_test_a"}}`,
72 terminalPayload("response.done", "resp-1", `"usage":{"input_tokens":1,"output_tokens":1}`, `[`+reasoningTerminalItem("rs_test_a", "blob-terminal", "")+`]`),
73 )
74 blocks := findReasoningBlocks(done.Message)
75 if len(blocks) != 1 {
76 t.Fatalf("reasoning blocks = %#v, want 1", done.Message.Content)
77 }
78 if blocks[0].ID != "rs_test_a" || blocks[0].Encrypted != "blob-terminal" {
79 t.Fatalf("reasoning = %+v, want id rs_test_a with terminal payload", blocks[0])
80 }
81}
82
83func TestResponsesReasoningEarlierPayloadWins(t *testing.T) {
84 done, _ := runResponsesCompletionFixture(t,
85 `{"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_test_a","encrypted_content":"early"}}`,
86 `{"type":"response.output_item.done","output_index":0,"item":{"type":"reasoning","id":"rs_test_a","encrypted_content":"done-late","summary":[{"type":"summary_text","text":"done summary"}]}}`,
87 terminalPayload("response.completed", "resp-1", `"usage":{"input_tokens":1,"output_tokens":1}`, `[`+reasoningTerminalItem("rs_test_a", "late", "terminal summary")+`]`),
88 )
89 blocks := findReasoningBlocks(done.Message)
90 if len(blocks) != 1 || blocks[0].Encrypted != "early" {
91 t.Fatalf("reasoning = %+v, want early payload to win", done.Message.Content)
92 }
93 if blocks[0].Summary != "done summary" {
94 t.Fatalf("summary = %q, want done summary to fill missing added summary", blocks[0].Summary)
95 }
96}
97
98func TestResponsesReasoningDoneDoesNotDuplicateStreamedSummary(t *testing.T) {
99 done, _ := runResponsesCompletionFixture(t,
100 `{"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_test_a"}}`,
101 `{"type":"response.reasoning_summary_text.delta","output_index":0,"delta":"hello"}`,
102 `{"type":"response.output_item.done","output_index":0,"item":{"type":"reasoning","id":"rs_test_a","encrypted_content":"blob-done","summary":[{"type":"summary_text","text":"hello"}]}}`,
103 terminalPayload("response.completed", "resp-1", `"usage":{"input_tokens":1,"output_tokens":1}`, `[`+reasoningTerminalItem("rs_test_a", "blob-terminal", "hello")+`]`),
104 )
105 blocks := findReasoningBlocks(done.Message)
106 if len(blocks) != 1 {
107 t.Fatalf("reasoning blocks = %#v, want 1", done.Message.Content)
108 }
109 if blocks[0].Summary != "hello" {
110 t.Fatalf("summary = %q, want single hello without duplication", blocks[0].Summary)
111 }
112 if blocks[0].Encrypted != "blob-done" {
113 t.Fatalf("encrypted = %q, want done payload to fill missing added payload", blocks[0].Encrypted)
114 }
115}
116
117func TestResponsesReasoningTerminalIDMismatch(t *testing.T) {
118 done, _ := runResponsesCompletionFixture(t,
119 `{"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_test_a"}}`,
120 terminalPayload("response.completed", "resp-1", `"usage":{"input_tokens":1,"output_tokens":1}`, `[`+reasoningTerminalItem("rs_other", "blob-other", "")+`]`),
121 )
122 blocks := findReasoningBlocks(done.Message)
123 if len(blocks) != 1 {
124 t.Fatalf("reasoning blocks = %#v, want 1", done.Message.Content)
125 }
126 if blocks[0].Encrypted != "" {
127 t.Fatalf("mismatched terminal id transferred payload: %+v", blocks[0])
128 }
129}
130
131func TestResponsesReasoningMultipleItemsPreserveIdentity(t *testing.T) {
132 done, _ := runResponsesCompletionFixture(t,
133 `{"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_a"}}`,
134 `{"type":"response.output_item.added","output_index":1,"item":{"type":"reasoning","id":"rs_b"}}`,
135 terminalPayload("response.completed", "resp-1", `"usage":{"input_tokens":1,"output_tokens":1}`,
136 `[`+reasoningTerminalItem("rs_b", "blob-b", "")+`,`+reasoningTerminalItem("rs_a", "blob-a", "")+`]`),
137 )
138 blocks := findReasoningBlocks(done.Message)
139 if len(blocks) != 2 {
140 t.Fatalf("reasoning blocks = %#v, want 2", done.Message.Content)
141 }
142 if blocks[0].ID != "rs_a" || blocks[0].Encrypted != "blob-a" {
143 t.Fatalf("first reasoning = %+v, want rs_a/blob-a", blocks[0])
144 }
145 if blocks[1].ID != "rs_b" || blocks[1].Encrypted != "blob-b" {
146 t.Fatalf("second reasoning = %+v, want rs_b/blob-b", blocks[1])
147 }
148}
149
150func TestResponsesReasoningMissingOutputKeepsIDOnly(t *testing.T) {
151 done, _ := runResponsesCompletionFixture(t,
152 `{"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_test_a"}}`,
153 terminalPayload("response.completed", "resp-1", `"usage":{"input_tokens":1,"output_tokens":1}`, ``),
154 )
155 blocks := findReasoningBlocks(done.Message)
156 if len(blocks) != 1 || blocks[0].ID != "rs_test_a" {
157 t.Fatalf("reasoning blocks = %#v, want ID-only block", done.Message.Content)
158 }
159}
160
161func TestResponsesReasoningNoDuplicateSummary(t *testing.T) {
162 done, _ := runResponsesCompletionFixture(t,
163 `{"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_test_a"}}`,
164 `{"type":"response.reasoning_summary_text.delta","output_index":0,"delta":"hello"}`,
165 terminalPayload("response.completed", "resp-1", `"usage":{"input_tokens":1,"output_tokens":1}`, `[`+reasoningTerminalItem("rs_test_a", "blob-terminal", "hello")+`]`),
166 )
167 blocks := findReasoningBlocks(done.Message)
168 if len(blocks) != 1 {
169 t.Fatalf("reasoning blocks = %#v, want 1", done.Message.Content)
170 }
171 if blocks[0].Summary != "hello" {
172 t.Fatalf("summary = %q, want single hello without duplication", blocks[0].Summary)
173 }
174 if blocks[0].Encrypted != "blob-terminal" {
175 t.Fatalf("encrypted = %q, want terminal payload", blocks[0].Encrypted)
176 }
177}
178
179func TestResponsesReasoningCompletionCallbackSeesBackfill(t *testing.T) {
180 c := newOpenAICodexClient("token", "acct", "")
181 raw := make(chan sseEvent, 4)
182 raw <- sseEvent{Data: `{"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_test_a"}}`}
183 raw <- sseEvent{Data: terminalPayload("response.completed", "resp-9", `"usage":{"input_tokens":1,"output_tokens":1}`, `[`+reasoningTerminalItem("rs_test_a", "blob-terminal", "")+`]`)}
184 close(raw)
185 out := make(chan Event, 16)
186 var callbackMsg Message
187 var callbackID string
188 go c.runResponseEventsWithFirst(context.Background(), Request{Model: "gpt-5.2"}, out, raw, nil, func(id string, msg Message) {
189 callbackID = id
190 callbackMsg = msg
191 })
192 for ev := range out {
193 if _, ok := ev.(EventDone); ok {
194 break
195 }
196 }
197 if callbackID != "resp-9" {
198 t.Fatalf("callback id = %q, want resp-9", callbackID)
199 }
200 blocks := findReasoningBlocks(callbackMsg)
201 if len(blocks) != 1 || blocks[0].Encrypted != "blob-terminal" {
202 t.Fatalf("callback reasoning = %#v, want backfilled payload", callbackMsg.Content)
203 }
204}
205
206func TestResponsesReasoningBackfillThenReplay(t *testing.T) {
207 done, _ := runResponsesCompletionFixture(t,
208 `{"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_test_a"}}`,
209 `{"type":"response.output_item.added","output_index":1,"item":{"type":"message"}}`,
210 `{"type":"response.output_text.delta","output_index":1,"delta":"hi"}`,
211 terminalPayload("response.completed", "resp-1", `"usage":{"input_tokens":1,"output_tokens":1}`, `[`+reasoningTerminalItem("rs_test_a", "blob-terminal", "")+`]`),
212 )
213 c := newOpenAICodexClient("token", "acct", "")
214 wire, err := c.buildRequest(Request{Model: "gpt-5.2", Messages: []Message{done.Message}})
215 if err != nil {
216 t.Fatal(err)
217 }
218 var sawReasoning bool
219 for _, item := range wire.Input {
220 if reasoning, ok := item.(codexReasoningItem); ok {
221 sawReasoning = true
222 if reasoning.EncryptedContent != "blob-terminal" || reasoning.ID != "rs_test_a" {
223 t.Fatalf("replayed reasoning = %+v", reasoning)
224 }
225 }
226 }
227 if !sawReasoning {
228 t.Fatalf("terminal payload did not reach next request: %#v", wire.Input)
229 }
230}
231
232func TestResponsesReasoningRequestBuilderOmitsNonReplayable(t *testing.T) {
233 c := newOpenAICodexClient("token", "acct", "")
234 original := []Message{
235 {Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}},
236 {Role: RoleAssistant, Content: []Content{
237 ReasoningBlock{ID: "rs_id_only"},
238 ReasoningBlock{Summary: "summary only"},
239 ReasoningBlock{},
240 ReasoningBlock{ID: "rs_full", Summary: "s", Encrypted: "blob-full"},
241 ReasoningBlock{Encrypted: "blob-no-id"},
242 TextBlock{Text: "hello"},
243 ToolCallBlock{ID: "call-1", Name: "lookup", Arguments: json.RawMessage(`{"q":"x"}`)},
244 }},
245 {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "call-1", Content: []Content{TextBlock{Text: "ok"}}}}},
246 }
247 before, _ := json.Marshal(original)
248 wire, err := c.buildRequest(Request{Model: "gpt-5.2", Messages: original})
249 if err != nil {
250 t.Fatal(err)
251 }
252 after, _ := json.Marshal(original)
253 if string(before) != string(after) {
254 t.Fatalf("buildRequest mutated input messages")
255 }
256 var reasoningItems []codexReasoningItem
257 var sawText, sawCall, sawResult bool
258 for _, item := range wire.Input {
259 switch v := item.(type) {
260 case codexReasoningItem:
261 reasoningItems = append(reasoningItems, v)
262 case codexOutputMessage:
263 for _, part := range v.Content {
264 if part.Text == "hello" {
265 sawText = true
266 }
267 }
268 case codexFunctionCall:
269 if v.Name == "lookup" {
270 sawCall = true
271 }
272 case codexFunctionCallOutput:
273 sawResult = true
274 }
275 }
276 if len(reasoningItems) != 2 {
277 t.Fatalf("reasoning items = %#v, want only 2 encrypted blocks", reasoningItems)
278 }
279 if reasoningItems[0].ID != "rs_full" || reasoningItems[0].EncryptedContent != "blob-full" {
280 t.Fatalf("first reasoning = %+v", reasoningItems[0])
281 }
282 if len(reasoningItems[0].Summary) != 1 || reasoningItems[0].Summary[0].Text != "s" {
283 t.Fatalf("first summary = %+v", reasoningItems[0].Summary)
284 }
285 if reasoningItems[1].EncryptedContent != "blob-no-id" {
286 t.Fatalf("second reasoning = %+v", reasoningItems[1])
287 }
288 if reasoningItems[1].Summary == nil || len(reasoningItems[1].Summary) != 0 {
289 t.Fatalf("empty summary must encode as empty array, got %+v", reasoningItems[1].Summary)
290 }
291 if !sawText || !sawCall || !sawResult {
292 t.Fatalf("text/call/result missing: text=%v call=%v result=%v input=%#v", sawText, sawCall, sawResult, wire.Input)
293 }
294 encoded, _ := json.Marshal(wire.Input)
295 if strings.Contains(string(encoded), "rs_id_only") || strings.Contains(string(encoded), "summary only") {
296 t.Fatalf("non-replayable reasoning leaked into input: %s", encoded)
297 }
298}
299
300func TestNamedResponsesErrorIdentity(t *testing.T) {
301 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
302 w.WriteHeader(http.StatusBadRequest)
303 _, _ = w.Write([]byte(`{"error":{"message":"nope"}}`))
304 }))
305 defer server.Close()
306
307 client := NewOpenAIResponsesNamed("synthetic-key", server.URL, "custom-responses")
308 _, err := client.Stream(context.Background(), Request{
309 Model: "gpt-5",
310 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
311 })
312 if err == nil {
313 t.Fatal("want HTTP 400 error, got nil")
314 }
315 if !strings.Contains(err.Error(), "custom-responses") {
316 t.Fatalf("error = %q, want provider identity custom-responses", err.Error())
317 }
318 if !strings.Contains(err.Error(), "400") {
319 t.Fatalf("error = %q, want retained HTTP status", err.Error())
320 }
321 if strings.Contains(err.Error(), "openai-codex") {
322 t.Fatalf("error = %q, must not identify as openai-codex", err.Error())
323 }
324}