From 2ff8b01cc66e8f1c5b6a02770b6f79fd22802d75 Mon Sep 17 00:00:00 2001 From: miruchigawa <99255321+miruchigawa@users.noreply.github.com> Date: Sat, 30 May 2026 10:12:29 +0700 Subject: [PATCH 1/2] fix(codex): preserve streamed output text deltas OpenAI/Codex OAuth streams can return text through response.output_text.delta while the final response.completed payload has response.output set to null. That made PicoClaw report an empty model response even though the backend returned valid content. Accumulate streamed output_text delta events during the Codex response stream and use them as a fallback when the parsed final response has no content. Add a regression test covering the null final output case from issue #2953. --- pkg/providers/oauth/codex_provider.go | 10 +++- pkg/providers/oauth/codex_provider_test.go | 60 ++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/pkg/providers/oauth/codex_provider.go b/pkg/providers/oauth/codex_provider.go index 0b125997b..b0d7bd758 100644 --- a/pkg/providers/oauth/codex_provider.go +++ b/pkg/providers/oauth/codex_provider.go @@ -104,8 +104,12 @@ func (p *CodexProvider) Chat( defer stream.Close() var resp *responses.Response + var streamedText strings.Builder for stream.Next() { evt := stream.Current() + if evt.Type == "response.output_text.delta" { + streamedText.WriteString(evt.Delta) + } if evt.Type == "response.completed" || evt.Type == "response.failed" || evt.Type == "response.incomplete" { evtResp := evt.Response if evtResp.ID != "" { @@ -153,7 +157,11 @@ func (p *CodexProvider) Chat( return nil, fmt.Errorf("codex API call: stream ended without completed response") } - return orc.ParseResponseFromStruct(resp), nil + parsed := orc.ParseResponseFromStruct(resp) + if parsed.Content == "" && streamedText.Len() > 0 { + parsed.Content = streamedText.String() + } + return parsed, nil } func (p *CodexProvider) GetDefaultModel() string { diff --git a/pkg/providers/oauth/codex_provider_test.go b/pkg/providers/oauth/codex_provider_test.go index aeeb18360..7ff2999ce 100644 --- a/pkg/providers/oauth/codex_provider_test.go +++ b/pkg/providers/oauth/codex_provider_test.go @@ -374,6 +374,45 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) { } } +func TestCodexProvider_ChatRoundTrip_OutputTextDeltaFallback(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/responses" { + http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound) + return + } + + var reqBody map[string]any + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if reqBody["stream"] != true { + http.Error(w, "stream must be true", http.StatusBadRequest) + return + } + + resp := map[string]any{ + "id": "resp_test", + "object": "response", + "status": "completed", + "output": nil, + } + writeOutputTextDeltaSSE(w, "OK", resp) + })) + defer server.Close() + + provider := NewCodexProvider("test-token", "acc-123") + provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123") + + resp, err := provider.Chat(t.Context(), []Message{{Role: "user", Content: "Hello"}}, nil, "gpt-4o", map[string]any{}) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if resp.Content != "OK" { + t.Errorf("Content = %q, want %q", resp.Content, "OK") + } +} + func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/responses" { @@ -647,3 +686,24 @@ func writeCompletedSSE(w http.ResponseWriter, response map[string]any) { fmt.Fprintf(w, "data: %s\n\n", string(b)) fmt.Fprintf(w, "data: [DONE]\n\n") } + +func writeOutputTextDeltaSSE(w http.ResponseWriter, delta string, response map[string]any) { + deltaEvent := map[string]any{ + "type": "response.output_text.delta", + "sequence_number": 1, + "delta": delta, + } + completedEvent := map[string]any{ + "type": "response.completed", + "sequence_number": 2, + "response": response, + } + deltaBytes, _ := json.Marshal(deltaEvent) + completedBytes, _ := json.Marshal(completedEvent) + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "event: response.output_text.delta\n") + fmt.Fprintf(w, "data: %s\n\n", string(deltaBytes)) + fmt.Fprintf(w, "event: response.completed\n") + fmt.Fprintf(w, "data: %s\n\n", string(completedBytes)) + fmt.Fprintf(w, "data: [DONE]\n\n") +} From 93391223eaf84c982791684b4eb774edc2eba86c Mon Sep 17 00:00:00 2001 From: miruchigawa <99255321+miruchigawa@users.noreply.github.com> Date: Sun, 31 May 2026 05:00:22 +0700 Subject: [PATCH 2/2] fix: format long line in codex_provider_test.go to satisfy golines --- pkg/providers/oauth/codex_provider_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pkg/providers/oauth/codex_provider_test.go b/pkg/providers/oauth/codex_provider_test.go index 7ff2999ce..8deeb8d2a 100644 --- a/pkg/providers/oauth/codex_provider_test.go +++ b/pkg/providers/oauth/codex_provider_test.go @@ -404,7 +404,13 @@ func TestCodexProvider_ChatRoundTrip_OutputTextDeltaFallback(t *testing.T) { provider := NewCodexProvider("test-token", "acc-123") provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123") - resp, err := provider.Chat(t.Context(), []Message{{Role: "user", Content: "Hello"}}, nil, "gpt-4o", map[string]any{}) + resp, err := provider.Chat( + t.Context(), + []Message{{Role: "user", Content: "Hello"}}, + nil, + "gpt-4o", + map[string]any{}, + ) if err != nil { t.Fatalf("Chat() error: %v", err) }