v0.38.0 58133ea

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close