detached 362913c

fix(responses): recover reasoning replay without touching session history (#190)

* fix(responses): recover reasoning replay without touching session history

Backfill terminal-only encrypted reasoning by item ID, omit non-replayable reasoning blocks from Responses input, and report named-provider error identity.

* fix(responses): keep earliest reasoning payload through output_item.done
commit
362913c92036eb88c503adf9d782322eb1e636fa
author
bnema · · 2026-09-08 03:46 UTC
committer
GitHub · · 2026-09-08 03:46 UTC
parent
44bd3a1
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.32.0
5 files · +757 -22
M docs/providers.md +4 -0
@@ -256,6 +256,10 @@ the next continuation.
256256
257257 When a Responses completion carries an explicit `end_turn:false`, zut treats the finished inference as a continuation (`turn_end` with `stop:"continue"`) and issues another model call in the same turn, reusing its request context, guards, usage accounting, and queued-input boundary. Tool calls still take precedence over the signal, and errors or cancellation never become continuations. A missing, null, or true `end_turn` keeps the existing behavior and ends the turn.
258258
259+### Responses reasoning replay
260+
261+Responses requests run with `store:false`, so follow-up requests replay reasoning explicitly. Only reasoning blocks that carry a replayable encrypted payload are sent; reasoning metadata without that payload is omitted from outgoing requests without editing session history. Opaque reasoning payloads are not guaranteed portable across providers or models.
262+
259263 ## Local llama.cpp router
260264
261265 The `llama.cpp` provider connects to a multi-model router and is separate from the `ollama` provider. Ollama normally uses `http://localhost:11434`; entering that URL for llama.cpp management produces a 404 because Ollama does not implement the router endpoints.
@@ -256,6 +256,10 @@ the next continuation.
256 256
257 When a Responses completion carries an explicit `end_turn:false`, zut treats the finished inference as a continuation (`turn_end` with `stop:"continue"`) and issues another model call in the same turn, reusing its request context, guards, usage accounting, and queued-input boundary. Tool calls still take precedence over the signal, and errors or cancellation never become continuations. A missing, null, or true `end_turn` keeps the existing behavior and ends the turn.257 When a Responses completion carries an explicit `end_turn:false`, zut treats the finished inference as a continuation (`turn_end` with `stop:"continue"`) and issues another model call in the same turn, reusing its request context, guards, usage accounting, and queued-input boundary. Tool calls still take precedence over the signal, and errors or cancellation never become continuations. A missing, null, or true `end_turn` keeps the existing behavior and ends the turn.
258 258
259+### Responses reasoning replay
260+
261+Responses requests run with `store:false`, so follow-up requests replay reasoning explicitly. Only reasoning blocks that carry a replayable encrypted payload are sent; reasoning metadata without that payload is omitted from outgoing requests without editing session history. Opaque reasoning payloads are not guaranteed portable across providers or models.
262+
259 ## Local llama.cpp router263 ## Local llama.cpp router
260 264
261 The `llama.cpp` provider connects to a multi-model router and is separate from the `ollama` provider. Ollama normally uses `http://localhost:11434`; entering that URL for llama.cpp management produces a 404 because Ollama does not implement the router endpoints.265 The `llama.cpp` provider connects to a multi-model router and is separate from the `ollama` provider. Ollama normally uses `http://localhost:11434`; entering that URL for llama.cpp management produces a 404 because Ollama does not implement the router endpoints.
A packages/core/session_responses_reasoning_test.go +232 -0
@@ -0,0 +1,232 @@
1+package core
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "net/http"
8+ "net/http/httptest"
9+ "os"
10+ "strings"
11+ "sync"
12+ "testing"
13+ "time"
14+
15+ "github.com/bnema/zut/packages/provider"
16+)
17+
18+// responsesReasoningRecorder serves synthetic Responses SSE while recording
19+// raw request bodies for stateless-replay assertions.
20+type responsesReasoningRecorder struct {
21+ mu sync.Mutex
22+ bodies []string
23+}
24+
25+func (r *responsesReasoningRecorder) handler(summary string) http.HandlerFunc {
26+ return func(w http.ResponseWriter, req *http.Request) {
27+ body, _ := io.ReadAll(req.Body)
28+ r.mu.Lock()
29+ r.bodies = append(r.bodies, string(body))
30+ r.mu.Unlock()
31+ w.Header().Set("content-type", "text/event-stream")
32+ _, _ = io.WriteString(w, "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"message\"}}\n\n")
33+ encoded, _ := json.Marshal(summary)
34+ _, _ = io.WriteString(w, "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"delta\":"+string(encoded)+"}\n\n")
35+ _, _ = io.WriteString(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-test\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
36+ }
37+}
38+
39+func (r *responsesReasoningRecorder) requestBodies() []string {
40+ r.mu.Lock()
41+ defer r.mu.Unlock()
42+ return append([]string(nil), r.bodies...)
43+}
44+
45+func consumeResponsesStream(t *testing.T, client provider.Client, req provider.Request) {
46+ t.Helper()
47+ stream, err := client.Stream(context.Background(), req)
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ for ev := range stream {
52+ if done, ok := ev.(provider.EventDone); ok && done.Err != nil {
53+ t.Fatalf("stream error: %v", done.Err)
54+ }
55+ }
56+}
57+
58+func findStoredReasoning(msgs []provider.Message, id string) *provider.ReasoningBlock {
59+ for _, msg := range msgs {
60+ for _, c := range msg.Content {
61+ if rb, ok := c.(provider.ReasoningBlock); ok && rb.ID == id {
62+ clone := rb
63+ return &clone
64+ }
65+ }
66+ }
67+ return nil
68+}
69+
70+func TestSessionResponsesReasoningReplay(t *testing.T) {
71+ root := t.TempDir()
72+ cwd := t.TempDir()
73+ session, err := NewSession(root, cwd, "openai-responses", "gpt-5", "test")
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+ if err := session.AppendMessage(provider.Message{
78+ Role: provider.RoleUser,
79+ Content: []provider.Content{provider.TextBlock{Text: "hello"}},
80+ Time: time.Now(),
81+ }); err != nil {
82+ t.Fatal(err)
83+ }
84+ if err := session.AppendMessage(provider.Message{
85+ Role: provider.RoleAssistant,
86+ Content: []provider.Content{
87+ provider.ReasoningBlock{ID: "rs_stale"},
88+ provider.TextBlock{Text: "visible answer"},
89+ provider.ToolCallBlock{ID: "call-1", Name: "lookup", Arguments: json.RawMessage(`{"q":"x"}`)},
90+ },
91+ Time: time.Now(),
92+ }); err != nil {
93+ t.Fatal(err)
94+ }
95+ if err := session.AppendMessage(provider.Message{
96+ Role: provider.RoleTool,
97+ Content: []provider.Content{provider.ToolResultBlock{
98+ CallID: "call-1",
99+ Content: []provider.Content{provider.TextBlock{Text: "result"}},
100+ }},
101+ Time: time.Now(),
102+ }); err != nil {
103+ t.Fatal(err)
104+ }
105+ path := session.Path
106+ if err := session.Close(); err != nil {
107+ t.Fatal(err)
108+ }
109+ before, err := os.ReadFile(path)
110+ if err != nil {
111+ t.Fatal(err)
112+ }
113+
114+ snapshot, err := ReadSessionSnapshot(path)
115+ if err != nil {
116+ t.Fatal(err)
117+ }
118+ if stored := findStoredReasoning(snapshot.Messages, "rs_stale"); stored == nil {
119+ t.Fatalf("snapshot lost ID-only reasoning: %#v", snapshot.Messages)
120+ }
121+
122+ forkedPath, err := BranchSession(path, root, cwd, "test", len(snapshot.Messages))
123+ if err != nil {
124+ t.Fatal(err)
125+ }
126+ forked, err := ReadSessionSnapshot(forkedPath)
127+ if err != nil {
128+ t.Fatal(err)
129+ }
130+ if stored := findStoredReasoning(forked.Messages, "rs_stale"); stored == nil {
131+ t.Fatalf("forked transcript lost ID-only reasoning: %#v", forked.Messages)
132+ }
133+
134+ for _, msgs := range [][]provider.Message{snapshot.Messages, forked.Messages} {
135+ recorder := &responsesReasoningRecorder{}
136+ server := httptest.NewServer(recorder.handler("ok"))
137+ client := provider.NewOpenAIResponsesNamed("synthetic-key", server.URL, "openai-responses")
138+ consumeResponsesStream(t, client, provider.Request{
139+ Model: "gpt-5",
140+ Messages: msgs,
141+ })
142+ server.Close()
143+
144+ bodies := recorder.requestBodies()
145+ if len(bodies) != 1 {
146+ t.Fatalf("requests = %d, want 1", len(bodies))
147+ }
148+ body := bodies[0]
149+ if strings.Contains(body, `"type":"reasoning"`) || strings.Contains(body, "rs_stale") {
150+ t.Fatalf("ID-only reasoning leaked into Responses request: %s", body)
151+ }
152+ for _, want := range []string{"visible answer", "lookup", "result"} {
153+ if !strings.Contains(body, want) {
154+ t.Fatalf("request body missing %q: %s", want, body)
155+ }
156+ }
157+ }
158+
159+ after, err := os.ReadFile(path)
160+ if err != nil {
161+ t.Fatal(err)
162+ }
163+ if string(before) != string(after) {
164+ t.Fatal("source session bytes changed during replay")
165+ }
166+ reread, err := ReadSessionSnapshot(path)
167+ if err != nil {
168+ t.Fatal(err)
169+ }
170+ if stored := findStoredReasoning(reread.Messages, "rs_stale"); stored == nil {
171+ t.Fatal("stored reasoning was modified by replay")
172+ }
173+}
174+
175+func TestCompactResponsesReasoningReplay(t *testing.T) {
176+ recorder := &responsesReasoningRecorder{}
177+ server := httptest.NewServer(recorder.handler("synthetic summary"))
178+ defer server.Close()
179+ client := provider.NewOpenAIResponsesNamed("synthetic-key", server.URL, "openai-responses")
180+
181+ agent := NewAgent(client, "gpt-5", "system", Registry{})
182+ agent.SetMessages([]provider.Message{
183+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "start"}}},
184+ {Role: provider.RoleAssistant, Content: []provider.Content{
185+ provider.ToolCallBlock{ID: "call-1", Name: "lookup", Arguments: json.RawMessage(`{"q":"x"}`)},
186+ }},
187+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "continue"}}},
188+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
189+ CallID: "call-1",
190+ Content: []provider.Content{provider.TextBlock{Text: "orphaned result"}},
191+ }}},
192+ {Role: provider.RoleAssistant, Content: []provider.Content{
193+ provider.ReasoningBlock{ID: "rs_stale"},
194+ provider.TextBlock{Text: "tail answer"},
195+ }},
196+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "next question"}}},
197+ })
198+
199+ if _, err := agent.Compact(context.Background(), 4, nil); err != nil {
200+ t.Fatal(err)
201+ }
202+ bodies := recorder.requestBodies()
203+ if len(bodies) < 1 {
204+ t.Fatal("summary request was not recorded")
205+ }
206+ if strings.Contains(bodies[0], `"type":"reasoning"`) || strings.Contains(bodies[0], "rs_stale") {
207+ t.Fatalf("summary request contains reasoning item: %s", bodies[0])
208+ }
209+
210+ compacted := agent.Messages()
211+ if stored := findStoredReasoning(compacted, "rs_stale"); stored == nil {
212+ t.Fatalf("compacted tail lost retained reasoning block: %#v", compacted)
213+ }
214+
215+ consumeResponsesStream(t, client, provider.Request{Model: "gpt-5", Messages: compacted})
216+ bodies = recorder.requestBodies()
217+ if len(bodies) != 2 {
218+ t.Fatalf("requests = %d, want summary plus replay", len(bodies))
219+ }
220+ replay := bodies[1]
221+ if strings.Contains(replay, "rs_stale") || strings.Contains(replay, `"type":"reasoning"`) {
222+ t.Fatalf("retained invalid reference reached Responses request: %s", replay)
223+ }
224+ if strings.Contains(replay, "orphaned result") || strings.Contains(replay, "call-1") {
225+ t.Fatalf("orphaned tool result reached Responses request: %s", replay)
226+ }
227+ for _, want := range []string{"tail answer", "next question"} {
228+ if !strings.Contains(replay, want) {
229+ t.Fatalf("replay request missing %q: %s", want, replay)
230+ }
231+ }
232+}
@@ -0,0 +1,232 @@
1+package core
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "net/http"
8+ "net/http/httptest"
9+ "os"
10+ "strings"
11+ "sync"
12+ "testing"
13+ "time"
14+
15+ "github.com/bnema/zut/packages/provider"
16+)
17+
18+// responsesReasoningRecorder serves synthetic Responses SSE while recording
19+// raw request bodies for stateless-replay assertions.
20+type responsesReasoningRecorder struct {
21+ mu sync.Mutex
22+ bodies []string
23+}
24+
25+func (r *responsesReasoningRecorder) handler(summary string) http.HandlerFunc {
26+ return func(w http.ResponseWriter, req *http.Request) {
27+ body, _ := io.ReadAll(req.Body)
28+ r.mu.Lock()
29+ r.bodies = append(r.bodies, string(body))
30+ r.mu.Unlock()
31+ w.Header().Set("content-type", "text/event-stream")
32+ _, _ = io.WriteString(w, "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"message\"}}\n\n")
33+ encoded, _ := json.Marshal(summary)
34+ _, _ = io.WriteString(w, "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"delta\":"+string(encoded)+"}\n\n")
35+ _, _ = io.WriteString(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-test\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n")
36+ }
37+}
38+
39+func (r *responsesReasoningRecorder) requestBodies() []string {
40+ r.mu.Lock()
41+ defer r.mu.Unlock()
42+ return append([]string(nil), r.bodies...)
43+}
44+
45+func consumeResponsesStream(t *testing.T, client provider.Client, req provider.Request) {
46+ t.Helper()
47+ stream, err := client.Stream(context.Background(), req)
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ for ev := range stream {
52+ if done, ok := ev.(provider.EventDone); ok && done.Err != nil {
53+ t.Fatalf("stream error: %v", done.Err)
54+ }
55+ }
56+}
57+
58+func findStoredReasoning(msgs []provider.Message, id string) *provider.ReasoningBlock {
59+ for _, msg := range msgs {
60+ for _, c := range msg.Content {
61+ if rb, ok := c.(provider.ReasoningBlock); ok && rb.ID == id {
62+ clone := rb
63+ return &clone
64+ }
65+ }
66+ }
67+ return nil
68+}
69+
70+func TestSessionResponsesReasoningReplay(t *testing.T) {
71+ root := t.TempDir()
72+ cwd := t.TempDir()
73+ session, err := NewSession(root, cwd, "openai-responses", "gpt-5", "test")
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+ if err := session.AppendMessage(provider.Message{
78+ Role: provider.RoleUser,
79+ Content: []provider.Content{provider.TextBlock{Text: "hello"}},
80+ Time: time.Now(),
81+ }); err != nil {
82+ t.Fatal(err)
83+ }
84+ if err := session.AppendMessage(provider.Message{
85+ Role: provider.RoleAssistant,
86+ Content: []provider.Content{
87+ provider.ReasoningBlock{ID: "rs_stale"},
88+ provider.TextBlock{Text: "visible answer"},
89+ provider.ToolCallBlock{ID: "call-1", Name: "lookup", Arguments: json.RawMessage(`{"q":"x"}`)},
90+ },
91+ Time: time.Now(),
92+ }); err != nil {
93+ t.Fatal(err)
94+ }
95+ if err := session.AppendMessage(provider.Message{
96+ Role: provider.RoleTool,
97+ Content: []provider.Content{provider.ToolResultBlock{
98+ CallID: "call-1",
99+ Content: []provider.Content{provider.TextBlock{Text: "result"}},
100+ }},
101+ Time: time.Now(),
102+ }); err != nil {
103+ t.Fatal(err)
104+ }
105+ path := session.Path
106+ if err := session.Close(); err != nil {
107+ t.Fatal(err)
108+ }
109+ before, err := os.ReadFile(path)
110+ if err != nil {
111+ t.Fatal(err)
112+ }
113+
114+ snapshot, err := ReadSessionSnapshot(path)
115+ if err != nil {
116+ t.Fatal(err)
117+ }
118+ if stored := findStoredReasoning(snapshot.Messages, "rs_stale"); stored == nil {
119+ t.Fatalf("snapshot lost ID-only reasoning: %#v", snapshot.Messages)
120+ }
121+
122+ forkedPath, err := BranchSession(path, root, cwd, "test", len(snapshot.Messages))
123+ if err != nil {
124+ t.Fatal(err)
125+ }
126+ forked, err := ReadSessionSnapshot(forkedPath)
127+ if err != nil {
128+ t.Fatal(err)
129+ }
130+ if stored := findStoredReasoning(forked.Messages, "rs_stale"); stored == nil {
131+ t.Fatalf("forked transcript lost ID-only reasoning: %#v", forked.Messages)
132+ }
133+
134+ for _, msgs := range [][]provider.Message{snapshot.Messages, forked.Messages} {
135+ recorder := &responsesReasoningRecorder{}
136+ server := httptest.NewServer(recorder.handler("ok"))
137+ client := provider.NewOpenAIResponsesNamed("synthetic-key", server.URL, "openai-responses")
138+ consumeResponsesStream(t, client, provider.Request{
139+ Model: "gpt-5",
140+ Messages: msgs,
141+ })
142+ server.Close()
143+
144+ bodies := recorder.requestBodies()
145+ if len(bodies) != 1 {
146+ t.Fatalf("requests = %d, want 1", len(bodies))
147+ }
148+ body := bodies[0]
149+ if strings.Contains(body, `"type":"reasoning"`) || strings.Contains(body, "rs_stale") {
150+ t.Fatalf("ID-only reasoning leaked into Responses request: %s", body)
151+ }
152+ for _, want := range []string{"visible answer", "lookup", "result"} {
153+ if !strings.Contains(body, want) {
154+ t.Fatalf("request body missing %q: %s", want, body)
155+ }
156+ }
157+ }
158+
159+ after, err := os.ReadFile(path)
160+ if err != nil {
161+ t.Fatal(err)
162+ }
163+ if string(before) != string(after) {
164+ t.Fatal("source session bytes changed during replay")
165+ }
166+ reread, err := ReadSessionSnapshot(path)
167+ if err != nil {
168+ t.Fatal(err)
169+ }
170+ if stored := findStoredReasoning(reread.Messages, "rs_stale"); stored == nil {
171+ t.Fatal("stored reasoning was modified by replay")
172+ }
173+}
174+
175+func TestCompactResponsesReasoningReplay(t *testing.T) {
176+ recorder := &responsesReasoningRecorder{}
177+ server := httptest.NewServer(recorder.handler("synthetic summary"))
178+ defer server.Close()
179+ client := provider.NewOpenAIResponsesNamed("synthetic-key", server.URL, "openai-responses")
180+
181+ agent := NewAgent(client, "gpt-5", "system", Registry{})
182+ agent.SetMessages([]provider.Message{
183+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "start"}}},
184+ {Role: provider.RoleAssistant, Content: []provider.Content{
185+ provider.ToolCallBlock{ID: "call-1", Name: "lookup", Arguments: json.RawMessage(`{"q":"x"}`)},
186+ }},
187+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "continue"}}},
188+ {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
189+ CallID: "call-1",
190+ Content: []provider.Content{provider.TextBlock{Text: "orphaned result"}},
191+ }}},
192+ {Role: provider.RoleAssistant, Content: []provider.Content{
193+ provider.ReasoningBlock{ID: "rs_stale"},
194+ provider.TextBlock{Text: "tail answer"},
195+ }},
196+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "next question"}}},
197+ })
198+
199+ if _, err := agent.Compact(context.Background(), 4, nil); err != nil {
200+ t.Fatal(err)
201+ }
202+ bodies := recorder.requestBodies()
203+ if len(bodies) < 1 {
204+ t.Fatal("summary request was not recorded")
205+ }
206+ if strings.Contains(bodies[0], `"type":"reasoning"`) || strings.Contains(bodies[0], "rs_stale") {
207+ t.Fatalf("summary request contains reasoning item: %s", bodies[0])
208+ }
209+
210+ compacted := agent.Messages()
211+ if stored := findStoredReasoning(compacted, "rs_stale"); stored == nil {
212+ t.Fatalf("compacted tail lost retained reasoning block: %#v", compacted)
213+ }
214+
215+ consumeResponsesStream(t, client, provider.Request{Model: "gpt-5", Messages: compacted})
216+ bodies = recorder.requestBodies()
217+ if len(bodies) != 2 {
218+ t.Fatalf("requests = %d, want summary plus replay", len(bodies))
219+ }
220+ replay := bodies[1]
221+ if strings.Contains(replay, "rs_stale") || strings.Contains(replay, `"type":"reasoning"`) {
222+ t.Fatalf("retained invalid reference reached Responses request: %s", replay)
223+ }
224+ if strings.Contains(replay, "orphaned result") || strings.Contains(replay, "call-1") {
225+ t.Fatalf("orphaned tool result reached Responses request: %s", replay)
226+ }
227+ for _, want := range []string{"tail answer", "next question"} {
228+ if !strings.Contains(replay, want) {
229+ t.Fatalf("replay request missing %q: %s", want, replay)
230+ }
231+ }
232+}
M packages/provider/openai_codex.go +110 -22
@@ -348,22 +348,19 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
348348 body.Input = append(body.Input, codexInputMessage{Role: role, Content: content})
349349 case RoleAssistant:
350350 // Emit one output_message per text block, one function_call per
351- // tool call, and one reasoning item per ReasoningBlock,
351+ // tool call, and one reasoning item per replayable ReasoningBlock,
352352 // preserving the order so the model sees the same interleaving
353- // we captured. The reasoning replay is what keeps OpenAI
354- // Codex from rejecting follow-up tool calls with
355- // "thinking is enabled but reasoning_content is missing".
353+ // we captured. The reasoning replay is what keeps Responses
354+ // providers from rejecting follow-up tool calls when thinking
355+ // is enabled. Blocks without encrypted content are omitted:
356+ // an ID or summary alone is not a self-contained reasoning
357+ // item while store is false. See reasoningForReplay.
356358 for _, c := range msg.Content {
357359 switch v := c.(type) {
358360 case ReasoningBlock:
359- item := codexReasoningItem{
360- Type: "reasoning",
361- ID: v.ID,
362- EncryptedContent: v.Encrypted,
363- Summary: []codexReasoningSummary{},
364- }
365- if v.Summary != "" {
366- item.Summary = []codexReasoningSummary{{Type: "summary_text", Text: v.Summary}}
361+ item, ok := reasoningForReplay(v)
362+ if !ok {
363+ continue
367364 }
368365 body.Input = append(body.Input, item)
369366 case TextBlock:
@@ -439,6 +436,28 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
439436 return body, nil
440437 }
441438
439+// reasoningForReplay decides whether a persisted ReasoningBlock carries a
440+// self-contained Responses payload. Only blocks with encrypted content are
441+// replayed; an ID or a readable summary alone cannot stand in for the
442+// provider-issued payload when store is false. The boolean is false when
443+// the block must be omitted from outgoing Responses input. Stored messages
444+// are never mutated by this decision.
445+func reasoningForReplay(block ReasoningBlock) (codexReasoningItem, bool) {
446+ if block.Encrypted == "" {
447+ return codexReasoningItem{}, false
448+ }
449+ item := codexReasoningItem{
450+ Type: "reasoning",
451+ ID: block.ID,
452+ EncryptedContent: block.Encrypted,
453+ Summary: []codexReasoningSummary{},
454+ }
455+ if block.Summary != "" {
456+ item.Summary = []codexReasoningSummary{{Type: "summary_text", Text: block.Summary}}
457+ }
458+ return item, true
459+}
460+
442461 func splitCallID(id string) (string, string) {
443462 if i := strings.Index(id, "|"); i >= 0 {
444463 return id[:i], id[i+1:]
@@ -513,12 +532,20 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
513532
514533 resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle)
515534 if err != nil {
516- return nil, fmt.Errorf("openai-codex: %w", err)
535+ label := c.providerName
536+ if label == "" {
537+ label = ProviderOpenAICodex
538+ }
539+ return nil, fmt.Errorf("%s: %w", label, err)
517540 }
518541 if resp.StatusCode != http.StatusOK {
519542 b, _ := io.ReadAll(resp.Body)
520543 resp.Body.Close()
521- return nil, fmt.Errorf("openai-codex: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
544+ label := c.providerName
545+ if label == "" {
546+ label = ProviderOpenAICodex
547+ }
548+ return nil, fmt.Errorf("%s: http %d: %s", label, resp.StatusCode, strings.TrimSpace(string(b)))
522549 }
523550
524551 out := make(chan Event, 16)
@@ -772,20 +799,24 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques
772799 case "function_call":
773800 out <- EventToolEnd{ID: it.callID}
774801 case "reasoning":
775- if p.Item.EncryptedContent != "" {
802+ // Fill-only: earlier streamed payloads and summaries win
803+ // over later duplicates from output_item.done.
804+ if it.encrypted == "" && p.Item.EncryptedContent != "" {
776805 it.encrypted = p.Item.EncryptedContent
777806 }
778807 if it.rawID == "" && p.Item.ID != "" {
779808 it.rawID = p.Item.ID
780809 }
781- for _, s := range p.Item.Summary {
782- if s.Text == "" {
783- continue
784- }
785- if it.summary.Len() > 0 {
786- it.summary.WriteString("\n")
810+ if it.summary.Len() == 0 {
811+ for _, s := range p.Item.Summary {
812+ if s.Text == "" {
813+ continue
814+ }
815+ if it.summary.Len() > 0 {
816+ it.summary.WriteString("\n")
817+ }
818+ it.summary.WriteString(s.Text)
787819 }
788- it.summary.WriteString(s.Text)
789820 }
790821 }
791822 }
@@ -803,9 +834,66 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques
803834 } `json:"usage"`
804835 Status string `json:"status"`
805836 EndTurn *bool `json:"end_turn"`
837+ Output []struct {
838+ Type string `json:"type"`
839+ ID string `json:"id"`
840+ EncryptedContent string `json:"encrypted_content"`
841+ Summary []struct {
842+ Type string `json:"type"`
843+ Text string `json:"text"`
844+ } `json:"summary"`
845+ } `json:"output"`
806846 } `json:"response"`
807847 }
808848 _ = json.Unmarshal([]byte(ev.Data), &p)
849+ // Backfill encrypted reasoning supplied only in the terminal
850+ // response output. Match by nonempty provider-issued item ID,
851+ // never by output-array position. Fill a missing payload from
852+ // a matching terminal item without overwriting an earlier
853+ // payload or duplicating streamed summaries.
854+ if len(p.Response.Output) > 0 {
855+ terminal := make(map[string]struct {
856+ encrypted string
857+ summary string
858+ }, len(p.Response.Output))
859+ for _, out := range p.Response.Output {
860+ if out.Type != "reasoning" || out.ID == "" {
861+ continue
862+ }
863+ if _, seen := terminal[out.ID]; seen {
864+ continue
865+ }
866+ var summary strings.Builder
867+ for _, s := range out.Summary {
868+ if s.Text == "" {
869+ continue
870+ }
871+ if summary.Len() > 0 {
872+ summary.WriteString("\n")
873+ }
874+ summary.WriteString(s.Text)
875+ }
876+ terminal[out.ID] = struct {
877+ encrypted string
878+ summary string
879+ }{encrypted: out.EncryptedContent, summary: summary.String()}
880+ }
881+ for _, it := range items {
882+ if it == nil || it.kind != "reasoning" || it.rawID == "" {
883+ continue
884+ }
885+ match, ok := terminal[it.rawID]
886+ if !ok {
887+ continue
888+ }
889+ if it.encrypted == "" && match.encrypted != "" {
890+ it.encrypted = match.encrypted
891+ }
892+ if it.summary.Len() == 0 && match.summary != "" {
893+ it.summary.WriteString(match.summary)
894+ }
895+ }
896+ }
809897 usage = normalizeOpenAIUsage(p.Response.Usage.InputTokens, p.Response.Usage.OutputTokens, p.Response.Usage.InputTokensDetails)
810898 if details := p.Response.Usage.OutputTokensDetails; details != nil {
811899 usage.ReasoningTokens = details.ReasoningTokens
@@ -348,22 +348,19 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
348 body.Input = append(body.Input, codexInputMessage{Role: role, Content: content})348 body.Input = append(body.Input, codexInputMessage{Role: role, Content: content})
349 case RoleAssistant:349 case RoleAssistant:
350 // Emit one output_message per text block, one function_call per350 // Emit one output_message per text block, one function_call per
351- // tool call, and one reasoning item per ReasoningBlock,351+ // tool call, and one reasoning item per replayable ReasoningBlock,
352 // preserving the order so the model sees the same interleaving352 // preserving the order so the model sees the same interleaving
353- // we captured. The reasoning replay is what keeps OpenAI353+ // we captured. The reasoning replay is what keeps Responses
354- // Codex from rejecting follow-up tool calls with354+ // providers from rejecting follow-up tool calls when thinking
355- // "thinking is enabled but reasoning_content is missing".355+ // is enabled. Blocks without encrypted content are omitted:
356+ // an ID or summary alone is not a self-contained reasoning
357+ // item while store is false. See reasoningForReplay.
356 for _, c := range msg.Content {358 for _, c := range msg.Content {
357 switch v := c.(type) {359 switch v := c.(type) {
358 case ReasoningBlock:360 case ReasoningBlock:
359- item := codexReasoningItem{361+ item, ok := reasoningForReplay(v)
360- Type: "reasoning",362+ if !ok {
361- ID: v.ID,363+ continue
362- EncryptedContent: v.Encrypted,
363- Summary: []codexReasoningSummary{},
364- }
365- if v.Summary != "" {
366- item.Summary = []codexReasoningSummary{{Type: "summary_text", Text: v.Summary}}
367 }364 }
368 body.Input = append(body.Input, item)365 body.Input = append(body.Input, item)
369 case TextBlock:366 case TextBlock:
@@ -439,6 +436,28 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
439 return body, nil436 return body, nil
440 }437 }
441 438
439+// reasoningForReplay decides whether a persisted ReasoningBlock carries a
440+// self-contained Responses payload. Only blocks with encrypted content are
441+// replayed; an ID or a readable summary alone cannot stand in for the
442+// provider-issued payload when store is false. The boolean is false when
443+// the block must be omitted from outgoing Responses input. Stored messages
444+// are never mutated by this decision.
445+func reasoningForReplay(block ReasoningBlock) (codexReasoningItem, bool) {
446+ if block.Encrypted == "" {
447+ return codexReasoningItem{}, false
448+ }
449+ item := codexReasoningItem{
450+ Type: "reasoning",
451+ ID: block.ID,
452+ EncryptedContent: block.Encrypted,
453+ Summary: []codexReasoningSummary{},
454+ }
455+ if block.Summary != "" {
456+ item.Summary = []codexReasoningSummary{{Type: "summary_text", Text: block.Summary}}
457+ }
458+ return item, true
459+}
460+
442 func splitCallID(id string) (string, string) {461 func splitCallID(id string) (string, string) {
443 if i := strings.Index(id, "|"); i >= 0 {462 if i := strings.Index(id, "|"); i >= 0 {
444 return id[:i], id[i+1:]463 return id[:i], id[i+1:]
@@ -513,12 +532,20 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
513 532
514 resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle)533 resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle)
515 if err != nil {534 if err != nil {
516- return nil, fmt.Errorf("openai-codex: %w", err)535+ label := c.providerName
536+ if label == "" {
537+ label = ProviderOpenAICodex
538+ }
539+ return nil, fmt.Errorf("%s: %w", label, err)
517 }540 }
518 if resp.StatusCode != http.StatusOK {541 if resp.StatusCode != http.StatusOK {
519 b, _ := io.ReadAll(resp.Body)542 b, _ := io.ReadAll(resp.Body)
520 resp.Body.Close()543 resp.Body.Close()
521- return nil, fmt.Errorf("openai-codex: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))544+ label := c.providerName
545+ if label == "" {
546+ label = ProviderOpenAICodex
547+ }
548+ return nil, fmt.Errorf("%s: http %d: %s", label, resp.StatusCode, strings.TrimSpace(string(b)))
522 }549 }
523 550
524 out := make(chan Event, 16)551 out := make(chan Event, 16)
@@ -772,20 +799,24 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques
772 case "function_call":799 case "function_call":
773 out <- EventToolEnd{ID: it.callID}800 out <- EventToolEnd{ID: it.callID}
774 case "reasoning":801 case "reasoning":
775- if p.Item.EncryptedContent != "" {802+ // Fill-only: earlier streamed payloads and summaries win
803+ // over later duplicates from output_item.done.
804+ if it.encrypted == "" && p.Item.EncryptedContent != "" {
776 it.encrypted = p.Item.EncryptedContent805 it.encrypted = p.Item.EncryptedContent
777 }806 }
778 if it.rawID == "" && p.Item.ID != "" {807 if it.rawID == "" && p.Item.ID != "" {
779 it.rawID = p.Item.ID808 it.rawID = p.Item.ID
780 }809 }
781- for _, s := range p.Item.Summary {810+ if it.summary.Len() == 0 {
782- if s.Text == "" {811+ for _, s := range p.Item.Summary {
783- continue812+ if s.Text == "" {
784- }813+ continue
785- if it.summary.Len() > 0 {814+ }
786- it.summary.WriteString("\n")815+ if it.summary.Len() > 0 {
816+ it.summary.WriteString("\n")
817+ }
818+ it.summary.WriteString(s.Text)
787 }819 }
788- it.summary.WriteString(s.Text)
789 }820 }
790 }821 }
791 }822 }
@@ -803,9 +834,66 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques
803 } `json:"usage"`834 } `json:"usage"`
804 Status string `json:"status"`835 Status string `json:"status"`
805 EndTurn *bool `json:"end_turn"`836 EndTurn *bool `json:"end_turn"`
837+ Output []struct {
838+ Type string `json:"type"`
839+ ID string `json:"id"`
840+ EncryptedContent string `json:"encrypted_content"`
841+ Summary []struct {
842+ Type string `json:"type"`
843+ Text string `json:"text"`
844+ } `json:"summary"`
845+ } `json:"output"`
806 } `json:"response"`846 } `json:"response"`
807 }847 }
808 _ = json.Unmarshal([]byte(ev.Data), &p)848 _ = json.Unmarshal([]byte(ev.Data), &p)
849+ // Backfill encrypted reasoning supplied only in the terminal
850+ // response output. Match by nonempty provider-issued item ID,
851+ // never by output-array position. Fill a missing payload from
852+ // a matching terminal item without overwriting an earlier
853+ // payload or duplicating streamed summaries.
854+ if len(p.Response.Output) > 0 {
855+ terminal := make(map[string]struct {
856+ encrypted string
857+ summary string
858+ }, len(p.Response.Output))
859+ for _, out := range p.Response.Output {
860+ if out.Type != "reasoning" || out.ID == "" {
861+ continue
862+ }
863+ if _, seen := terminal[out.ID]; seen {
864+ continue
865+ }
866+ var summary strings.Builder
867+ for _, s := range out.Summary {
868+ if s.Text == "" {
869+ continue
870+ }
871+ if summary.Len() > 0 {
872+ summary.WriteString("\n")
873+ }
874+ summary.WriteString(s.Text)
875+ }
876+ terminal[out.ID] = struct {
877+ encrypted string
878+ summary string
879+ }{encrypted: out.EncryptedContent, summary: summary.String()}
880+ }
881+ for _, it := range items {
882+ if it == nil || it.kind != "reasoning" || it.rawID == "" {
883+ continue
884+ }
885+ match, ok := terminal[it.rawID]
886+ if !ok {
887+ continue
888+ }
889+ if it.encrypted == "" && match.encrypted != "" {
890+ it.encrypted = match.encrypted
891+ }
892+ if it.summary.Len() == 0 && match.summary != "" {
893+ it.summary.WriteString(match.summary)
894+ }
895+ }
896+ }
809 usage = normalizeOpenAIUsage(p.Response.Usage.InputTokens, p.Response.Usage.OutputTokens, p.Response.Usage.InputTokensDetails)897 usage = normalizeOpenAIUsage(p.Response.Usage.InputTokens, p.Response.Usage.OutputTokens, p.Response.Usage.InputTokensDetails)
810 if details := p.Response.Usage.OutputTokensDetails; details != nil {898 if details := p.Response.Usage.OutputTokensDetails; details != nil {
811 usage.ReasoningTokens = details.ReasoningTokens899 usage.ReasoningTokens = details.ReasoningTokens
M packages/provider/openai_responses_continuation_websocket_test.go +87 -0
@@ -133,3 +133,90 @@ func TestResponsesContinuationWebSocket(t *testing.T) {
133133 t.Fatalf("incremental input = %s", encoded)
134134 }
135135 }
136+
137+// TestResponsesReasoningWebSocketTerminalBackfill pins terminal-only
138+// encrypted reasoning over the shared WebSocket path: the streamed item
139+// carries only an ID, the terminal response output supplies the payload,
140+// and the final assistant message keeps it for full-history replay.
141+func TestResponsesReasoningWebSocketTerminalBackfill(t *testing.T) {
142+ server := httptest.NewServer(websocket.Handler(func(conn *websocket.Conn) {
143+ defer conn.Close()
144+ var payload map[string]any
145+ if err := websocket.JSON.Receive(conn, &payload); err != nil {
146+ return
147+ }
148+ response := map[string]any{
149+ "id": "resp-ws-1",
150+ "usage": map[string]any{"input_tokens": 1, "output_tokens": 1},
151+ "output": []any{
152+ map[string]any{"type": "reasoning", "id": "rs-ws-a", "encrypted_content": "blob-ws", "summary": []any{}},
153+ map[string]any{"type": "message", "id": "msg-ws-1", "status": "completed", "role": "assistant", "content": []any{map[string]any{"type": "output_text", "text": "hello", "annotations": []any{}}}},
154+ },
155+ }
156+ for _, event := range []map[string]any{
157+ {"type": "response.created", "response": map[string]any{"id": "resp-ws-1"}},
158+ {"type": "response.output_item.added", "output_index": 0, "item": map[string]any{"type": "reasoning", "id": "rs-ws-a"}},
159+ {"type": "response.output_item.added", "output_index": 1, "item": map[string]any{"type": "message"}},
160+ {"type": "response.output_text.delta", "output_index": 1, "delta": "hello"},
161+ {"type": "response.completed", "response": response},
162+ } {
163+ _ = websocket.JSON.Send(conn, event)
164+ }
165+ }))
166+ defer server.Close()
167+
168+ inner := &codexClient{
169+ token: "test-token",
170+ baseURL: server.URL + "/v1/responses",
171+ providerName: "openai",
172+ capabilities: responsesCapabilities{StablePromptCacheKey: true},
173+ }
174+ client := newResponsesWebSocketClient(inner)
175+
176+ stream, err := client.Stream(context.Background(), Request{
177+ Model: "gpt-5.6-sol",
178+ Context: RequestContext{CacheSessionID: "cache-ws", ThreadID: "thread-ws-reasoning", TurnID: "turn-1"},
179+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
180+ })
181+ if err != nil {
182+ t.Fatal(err)
183+ }
184+ var done EventDone
185+ for event := range stream {
186+ if e, ok := event.(EventDone); ok {
187+ done = e
188+ }
189+ }
190+ if done.Err != nil {
191+ t.Fatalf("stream error: %v", done.Err)
192+ }
193+ var sawReasoning bool
194+ for _, c := range done.Message.Content {
195+ if rb, ok := c.(ReasoningBlock); ok && rb.ID == "rs-ws-a" && rb.Encrypted == "blob-ws" {
196+ sawReasoning = true
197+ }
198+ }
199+ if !sawReasoning {
200+ t.Fatalf("websocket reasoning not backfilled: %#v", done.Message.Content)
201+ }
202+
203+ wire, err := inner.buildRequest(Request{
204+ Model: "gpt-5.6-sol",
205+ Messages: []Message{
206+ {Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}},
207+ done.Message,
208+ },
209+ })
210+ if err != nil {
211+ t.Fatal(err)
212+ }
213+ var replayed bool
214+ for _, item := range wire.Input {
215+ if reasoning, ok := item.(codexReasoningItem); ok && reasoning.ID == "rs-ws-a" && reasoning.EncryptedContent == "blob-ws" {
216+ replayed = true
217+ }
218+ }
219+ if !replayed {
220+ t.Fatalf("terminal websocket payload did not reach full-history request: %#v", wire.Input)
221+ }
222+}
@@ -133,3 +133,90 @@ func TestResponsesContinuationWebSocket(t *testing.T) {
133 t.Fatalf("incremental input = %s", encoded)133 t.Fatalf("incremental input = %s", encoded)
134 }134 }
135 }135 }
136+
137+// TestResponsesReasoningWebSocketTerminalBackfill pins terminal-only
138+// encrypted reasoning over the shared WebSocket path: the streamed item
139+// carries only an ID, the terminal response output supplies the payload,
140+// and the final assistant message keeps it for full-history replay.
141+func TestResponsesReasoningWebSocketTerminalBackfill(t *testing.T) {
142+ server := httptest.NewServer(websocket.Handler(func(conn *websocket.Conn) {
143+ defer conn.Close()
144+ var payload map[string]any
145+ if err := websocket.JSON.Receive(conn, &payload); err != nil {
146+ return
147+ }
148+ response := map[string]any{
149+ "id": "resp-ws-1",
150+ "usage": map[string]any{"input_tokens": 1, "output_tokens": 1},
151+ "output": []any{
152+ map[string]any{"type": "reasoning", "id": "rs-ws-a", "encrypted_content": "blob-ws", "summary": []any{}},
153+ map[string]any{"type": "message", "id": "msg-ws-1", "status": "completed", "role": "assistant", "content": []any{map[string]any{"type": "output_text", "text": "hello", "annotations": []any{}}}},
154+ },
155+ }
156+ for _, event := range []map[string]any{
157+ {"type": "response.created", "response": map[string]any{"id": "resp-ws-1"}},
158+ {"type": "response.output_item.added", "output_index": 0, "item": map[string]any{"type": "reasoning", "id": "rs-ws-a"}},
159+ {"type": "response.output_item.added", "output_index": 1, "item": map[string]any{"type": "message"}},
160+ {"type": "response.output_text.delta", "output_index": 1, "delta": "hello"},
161+ {"type": "response.completed", "response": response},
162+ } {
163+ _ = websocket.JSON.Send(conn, event)
164+ }
165+ }))
166+ defer server.Close()
167+
168+ inner := &codexClient{
169+ token: "test-token",
170+ baseURL: server.URL + "/v1/responses",
171+ providerName: "openai",
172+ capabilities: responsesCapabilities{StablePromptCacheKey: true},
173+ }
174+ client := newResponsesWebSocketClient(inner)
175+
176+ stream, err := client.Stream(context.Background(), Request{
177+ Model: "gpt-5.6-sol",
178+ Context: RequestContext{CacheSessionID: "cache-ws", ThreadID: "thread-ws-reasoning", TurnID: "turn-1"},
179+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
180+ })
181+ if err != nil {
182+ t.Fatal(err)
183+ }
184+ var done EventDone
185+ for event := range stream {
186+ if e, ok := event.(EventDone); ok {
187+ done = e
188+ }
189+ }
190+ if done.Err != nil {
191+ t.Fatalf("stream error: %v", done.Err)
192+ }
193+ var sawReasoning bool
194+ for _, c := range done.Message.Content {
195+ if rb, ok := c.(ReasoningBlock); ok && rb.ID == "rs-ws-a" && rb.Encrypted == "blob-ws" {
196+ sawReasoning = true
197+ }
198+ }
199+ if !sawReasoning {
200+ t.Fatalf("websocket reasoning not backfilled: %#v", done.Message.Content)
201+ }
202+
203+ wire, err := inner.buildRequest(Request{
204+ Model: "gpt-5.6-sol",
205+ Messages: []Message{
206+ {Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}},
207+ done.Message,
208+ },
209+ })
210+ if err != nil {
211+ t.Fatal(err)
212+ }
213+ var replayed bool
214+ for _, item := range wire.Input {
215+ if reasoning, ok := item.(codexReasoningItem); ok && reasoning.ID == "rs-ws-a" && reasoning.EncryptedContent == "blob-ws" {
216+ replayed = true
217+ }
218+ }
219+ if !replayed {
220+ t.Fatalf("terminal websocket payload did not reach full-history request: %#v", wire.Input)
221+ }
222+}
A packages/provider/openai_responses_reasoning_test.go +324 -0
@@ -0,0 +1,324 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+)
11+
12+func 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+
23+func 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+
45+func 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+
55+func 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+
69+func 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+
83+func 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+
98+func 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+
117+func 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+
131+func 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+
150+func 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+
161+func 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+
179+func 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+
206+func 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+
232+func 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+
300+func 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+}
@@ -0,0 +1,324 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+)
11+
12+func 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+
23+func 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+
45+func 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+
55+func 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+
69+func 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+
83+func 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+
98+func 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+
117+func 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+
131+func 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+
150+func 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+
161+func 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+
179+func 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+
206+func 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+
232+func 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+
300+func 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 477

↑↓ moveenter openesc close