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",