diff --git a/pkg/providers/oauth/codex_provider.go b/pkg/providers/oauth/codex_provider.go index b0d7bd758..f8a1b172e 100644 --- a/pkg/providers/oauth/codex_provider.go +++ b/pkg/providers/oauth/codex_provider.go @@ -105,11 +105,18 @@ func (p *CodexProvider) Chat( var resp *responses.Response var streamedText strings.Builder + streamedOutputItems := make([]responses.ResponseOutputItemUnion, 0) for stream.Next() { evt := stream.Current() if evt.Type == "response.output_text.delta" { streamedText.WriteString(evt.Delta) } + if evt.Type == "response.output_item.done" { + itemEvt := evt.AsResponseOutputItemDone() + if itemEvt.Item.Type != "" { + streamedOutputItems = append(streamedOutputItems, itemEvt.Item) + } + } if evt.Type == "response.completed" || evt.Type == "response.failed" || evt.Type == "response.incomplete" { evtResp := evt.Response if evtResp.ID != "" { @@ -156,6 +163,9 @@ func (p *CodexProvider) Chat( logger.ErrorCF("provider.codex", "Codex stream ended without completed response event", fields) return nil, fmt.Errorf("codex API call: stream ended without completed response") } + if len(resp.Output) == 0 && len(streamedOutputItems) > 0 { + resp.Output = streamedOutputItems + } parsed := orc.ParseResponseFromStruct(resp) if parsed.Content == "" && streamedText.Len() > 0 { diff --git a/pkg/providers/oauth/codex_provider_test.go b/pkg/providers/oauth/codex_provider_test.go index 8deeb8d2a..92d43bf16 100644 --- a/pkg/providers/oauth/codex_provider_test.go +++ b/pkg/providers/oauth/codex_provider_test.go @@ -419,6 +419,84 @@ func TestCodexProvider_ChatRoundTrip_OutputTextDeltaFallback(t *testing.T) { } } +func TestCodexProvider_ChatRoundTrip_OutputItemDoneFallback(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 + } + + item := map[string]any{ + "id": "fc_1", + "type": "function_call", + "call_id": "call_abc", + "name": "write_file", + "arguments": `{"path":"x.txt","content":"ok"}`, + "status": "completed", + } + resp := map[string]any{ + "id": "resp_test", + "object": "response", + "status": "completed", + "output": []map[string]any{}, + } + writeOutputItemDoneSSE(w, item, 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: "Create x.txt"}}, + []ToolDefinition{ + { + Type: "function", + Function: ToolFunctionDefinition{ + Name: "write_file", + Description: "write file", + Parameters: map[string]any{"type": "object"}, + }, + }, + }, + "gpt-5.5", + map[string]any{}, + ) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if len(resp.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(resp.ToolCalls)) + } + tc := resp.ToolCalls[0] + if tc.ID != "call_abc" { + t.Errorf("ToolCall.ID = %q, want %q", tc.ID, "call_abc") + } + if tc.Name != "write_file" { + t.Errorf("ToolCall.Name = %q, want %q", tc.Name, "write_file") + } + if tc.Arguments["path"] != "x.txt" { + t.Errorf("ToolCall.Arguments[path] = %v, want x.txt", tc.Arguments["path"]) + } + if tc.Arguments["content"] != "ok" { + t.Errorf("ToolCall.Arguments[content] = %v, want ok", tc.Arguments["content"]) + } + if resp.FinishReason != "tool_calls" { + t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls") + } +} + func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/responses" { @@ -713,3 +791,25 @@ func writeOutputTextDeltaSSE(w http.ResponseWriter, delta string, response map[s fmt.Fprintf(w, "data: %s\n\n", string(completedBytes)) fmt.Fprintf(w, "data: [DONE]\n\n") } + +func writeOutputItemDoneSSE(w http.ResponseWriter, item map[string]any, response map[string]any) { + itemEvent := map[string]any{ + "type": "response.output_item.done", + "sequence_number": 1, + "output_index": 0, + "item": item, + } + completedEvent := map[string]any{ + "type": "response.completed", + "sequence_number": 2, + "response": response, + } + itemBytes, _ := json.Marshal(itemEvent) + completedBytes, _ := json.Marshal(completedEvent) + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "event: response.output_item.done\n") + fmt.Fprintf(w, "data: %s\n\n", string(itemBytes)) + fmt.Fprintf(w, "event: response.completed\n") + fmt.Fprintf(w, "data: %s\n\n", string(completedBytes)) + fmt.Fprintf(w, "data: [DONE]\n\n") +}