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 Unverified
B5690EEEBB952194The 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. | |||
| 256 | 256 | ||
| 257 | 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 | 263 | ## Local llama.cpp router | |
| 260 | 264 | ||
| 261 | 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. | |
| @@ -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 router | 263 | ## 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) { | |||
| 348 | 348 | body.Input = append(body.Input, codexInputMessage{Role: role, Content: content}) | |
| 349 | 349 | case RoleAssistant: | |
| 350 | 350 | // 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 | 352 | // 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. | |
| 356 | 358 | for _, c := range msg.Content { | |
| 357 | 359 | switch v := c.(type) { | |
| 358 | 360 | 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 | |
| 367 | 364 | } | |
| 368 | 365 | body.Input = append(body.Input, item) | |
| 369 | 366 | case TextBlock: | |
| @@ -439,6 +436,28 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||
| 439 | 436 | 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 | 461 | func splitCallID(id string) (string, string) { | |
| 443 | 462 | if i := strings.Index(id, "|"); i >= 0 { | |
| 444 | 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 | 533 | resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle) | |
| 515 | 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 | 541 | if resp.StatusCode != http.StatusOK { | |
| 519 | 542 | b, _ := io.ReadAll(resp.Body) | |
| 520 | 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 | 551 | out := make(chan Event, 16) | |
| @@ -772,20 +799,24 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques | |||
| 772 | 799 | case "function_call": | |
| 773 | 800 | out <- EventToolEnd{ID: it.callID} | |
| 774 | 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 | 805 | it.encrypted = p.Item.EncryptedContent | |
| 777 | 806 | } | |
| 778 | 807 | if it.rawID == "" && p.Item.ID != "" { | |
| 779 | 808 | it.rawID = p.Item.ID | |
| 780 | 809 | } | |
| 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) | |
| 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 | 834 | } `json:"usage"` | |
| 804 | 835 | Status string `json:"status"` | |
| 805 | 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 | 846 | } `json:"response"` | |
| 807 | 847 | } | |
| 808 | 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 | 897 | usage = normalizeOpenAIUsage(p.Response.Usage.InputTokens, p.Response.Usage.OutputTokens, p.Response.Usage.InputTokensDetails) | |
| 810 | 898 | if details := p.Response.Usage.OutputTokensDetails; details != nil { | |
| 811 | 899 | 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 per | 350 | // 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 interleaving | 352 | // preserving the order so the model sees the same interleaving | ||
| 353 | - | // we captured. The reasoning replay is what keeps OpenAI | 353 | + | // we captured. The reasoning replay is what keeps Responses |
| 354 | - | // Codex from rejecting follow-up tool calls with | 354 | + | // 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, nil | 436 | 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.EncryptedContent | 805 | 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.ID | 808 | 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 | - | continue | 812 | + | 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.ReasoningTokens | 899 | usage.ReasoningTokens = details.ReasoningTokens | ||
M packages/provider/openai_responses_continuation_websocket_test.go +87 -0
| @@ -133,3 +133,90 @@ func TestResponsesContinuationWebSocket(t *testing.T) { | |||
| 133 | 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 | + | } | |
| @@ -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 | + | } | |||