From b72d1b835e1a503a458b1412302cf56c0032945a Mon Sep 17 00:00:00 2001 From: astrada-c <32476296+astrada-c@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:40:22 +0800 Subject: [PATCH] Recover Codex output from streamed message events The ChatGPT Codex OAuth backend can now send assistant text in response.output_item.done while leaving the completed response.output array empty. The Codex provider already receives the streamed items, so this patch reconstructs response.output from those items when the completed payload is empty and adds regression tests for both the fallback path and the existing completed-output path.\n\nTested: go test ./pkg/providers/oauth/... --- pkg/providers/oauth/codex_provider.go | 27 +++++++++ pkg/providers/oauth/codex_provider_test.go | 70 ++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/pkg/providers/oauth/codex_provider.go b/pkg/providers/oauth/codex_provider.go index 0b125997b..693330a81 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 outputItems []responses.ResponseOutputItemUnion for stream.Next() { evt := stream.Current() + if evt.Type == "response.output_item.done" { + outputItems = append(outputItems, evt.Item) + } if evt.Type == "response.completed" || evt.Type == "response.failed" || evt.Type == "response.incomplete" { evtResp := evt.Response if evtResp.ID != "" { @@ -153,9 +157,32 @@ func (p *CodexProvider) Chat( return nil, fmt.Errorf("codex API call: stream ended without completed response") } + resp = hydrateCodexResponseOutput(resp, outputItems, model, resolvedModel, accountID) return orc.ParseResponseFromStruct(resp), nil } +func hydrateCodexResponseOutput( + resp *responses.Response, outputItems []responses.ResponseOutputItemUnion, model, resolvedModel, accountID string, +) *responses.Response { + if resp == nil || len(resp.Output) > 0 || len(outputItems) == 0 { + return resp + } + + resp.Output = outputItems + logger.WarnCF( + "provider.codex", + "Codex completed response had empty output; reconstructed output from streamed output_item.done events", + map[string]any{ + "requested_model": model, + "resolved_model": resolvedModel, + "streamed_output_items": len(outputItems), + "account_id_present": accountID != "", + }, + ) + + return resp +} + func (p *CodexProvider) GetDefaultModel() string { return codexDefaultModel } diff --git a/pkg/providers/oauth/codex_provider_test.go b/pkg/providers/oauth/codex_provider_test.go index aeeb18360..140fd7cc9 100644 --- a/pkg/providers/oauth/codex_provider_test.go +++ b/pkg/providers/oauth/codex_provider_test.go @@ -14,6 +14,17 @@ import ( orc "github.com/sipeed/picoclaw/pkg/providers/openai_responses_common" ) +func mustUnmarshalOutputItem(t *testing.T, raw string) responses.ResponseOutputItemUnion { + t.Helper() + + var item responses.ResponseOutputItemUnion + if err := json.Unmarshal([]byte(raw), &item); err != nil { + t.Fatalf("unmarshal output item: %v", err) + } + + return item +} + func TestBuildCodexParams_BasicMessage(t *testing.T) { messages := []Message{ {Role: "user", Content: "Hello"}, @@ -239,6 +250,65 @@ func TestParseCodexResponse_TextOutput(t *testing.T) { } } +func TestHydrateCodexResponseOutput_UsesStreamItemsWhenCompletedOutputIsEmpty(t *testing.T) { + resp := &responses.Response{} + streamItem := mustUnmarshalOutputItem(t, `{ + "id": "msg_1", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [ + {"type": "output_text", "text": "PONG"} + ] + }`) + + resp = hydrateCodexResponseOutput(resp, []responses.ResponseOutputItemUnion{streamItem}, "gpt-5.4", "gpt-5.4", "acct_123") + result := orc.ParseResponseFromStruct(resp) + + if result.Content != "PONG" { + t.Fatalf("Content = %q, want %q", result.Content, "PONG") + } +} + +func TestHydrateCodexResponseOutput_PreservesExistingCompletedOutput(t *testing.T) { + resp := &responses.Response{} + if err := json.Unmarshal([]byte(`{ + "id": "resp_test", + "object": "response", + "status": "completed", + "output": [ + { + "id": "msg_1", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [ + {"type": "output_text", "text": "from-completed"} + ] + } + ] + }`), resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + streamItem := mustUnmarshalOutputItem(t, `{ + "id": "msg_2", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [ + {"type": "output_text", "text": "from-stream"} + ] + }`) + + resp = hydrateCodexResponseOutput(resp, []responses.ResponseOutputItemUnion{streamItem}, "gpt-5.4", "gpt-5.4", "acct_123") + result := orc.ParseResponseFromStruct(resp) + + if result.Content != "from-completed" { + t.Fatalf("Content = %q, want %q", result.Content, "from-completed") + } +} + func TestParseCodexResponse_FunctionCall(t *testing.T) { respJSON := `{ "id": "resp_test",