diff --git a/internal/runtime/executor/codex_executor_stream_output_test.go b/internal/runtime/executor/codex_executor_stream_output_test.go index 477a9800a..46a43de39 100644 --- a/internal/runtime/executor/codex_executor_stream_output_test.go +++ b/internal/runtime/executor/codex_executor_stream_output_test.go @@ -18,6 +18,43 @@ import ( "github.com/tidwall/gjson" ) +func TestCodexExecutorExecute_NonEmptyCompletionOutputHydratesMissingItemID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte(`data: {"type":"response.output_item.done","item":{"id":"fc_123","type":"function_call","call_id":"call_123","name":"weather","arguments":"{}"},"output_index":0}` + "\n\n")) + _, _ = w.Write([]byte(`data: {"type":"response.output_item.done","item":{"id":"fc_done_existing","type":"function_call","call_id":"call_existing","name":"other","arguments":"{}"},"output_index":1}` + "\n\n")) + _, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed","output":[{"id":null,"type":"function_call","call_id":"call_123","name":"weather-terminal","arguments":"{}"},{"id":"fc_existing","type":"function_call","call_id":"call_existing","name":"preserved","arguments":"{}"}]}}` + "\n\n")) + })) + defer server.Close() + + executor := NewCodexExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{Attributes: map[string]string{ + "base_url": server.URL, + "api_key": "test", + }} + + resp, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "gpt-5.4", + Payload: []byte(`{"model":"gpt-5.4","input":"What is the weather?"}`), + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai-response"), + Stream: false, + }) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + + if got := gjson.GetBytes(resp.Payload, "output.0.id").String(); got != "fc_123" { + t.Fatalf("output[0].id = %q, want %q; payload=%s", got, "fc_123", resp.Payload) + } + if got := gjson.GetBytes(resp.Payload, "output.0.name").String(); got != "weather-terminal" { + t.Fatalf("output[0].name = %q, want terminal value; payload=%s", got, resp.Payload) + } + if got := gjson.GetBytes(resp.Payload, "output.1.id").String(); got != "fc_existing" { + t.Fatalf("output[1].id = %q, want existing value; payload=%s", got, resp.Payload) + } +} + func TestCodexExecutorExecute_EmptyStreamCompletionOutputUsesOutputItemDone(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") diff --git a/internal/runtime/executor/codex_executor_terminal.go b/internal/runtime/executor/codex_executor_terminal.go index 3ebc3d4ec..b2845f928 100644 --- a/internal/runtime/executor/codex_executor_terminal.go +++ b/internal/runtime/executor/codex_executor_terminal.go @@ -4,6 +4,7 @@ import ( "bytes" "net/http" "sort" + "strconv" "strings" "time" @@ -44,8 +45,39 @@ func collectCodexOutputItemDone(eventData []byte, outputItemsByIndex map[int64][ *outputItemsFallback = append(*outputItemsFallback, []byte(itemResult.Raw)) } +func hydrateCodexCompletedOutputItemIDs(eventData []byte, outputItems []gjson.Result, outputItemsByIndex map[int64][]byte) []byte { + patchedData := eventData + for outputIndex, outputItem := range outputItems { + itemData := []byte(outputItem.Raw) + itemID := gjson.GetBytes(itemData, "id") + if itemID.Exists() && itemID.Type != gjson.Null && (itemID.Type != gjson.String || strings.TrimSpace(itemID.String()) != "") { + continue + } + + completedItem, ok := outputItemsByIndex[int64(outputIndex)] + if !ok { + continue + } + completedID := gjson.GetBytes(completedItem, "id") + if completedID.Type != gjson.String || strings.TrimSpace(completedID.String()) == "" { + continue + } + + updatedData, errSet := sjson.SetRawBytes(patchedData, "response.output."+strconv.Itoa(outputIndex)+".id", []byte(completedID.Raw)) + if errSet != nil { + continue + } + patchedData = updatedData + } + return patchedData +} + func patchCodexCompletedOutput(eventData []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback [][]byte) []byte { outputResult := gjson.GetBytes(eventData, "response.output") + if outputResult.Exists() && outputResult.IsArray() && len(outputResult.Array()) > 0 { + return hydrateCodexCompletedOutputItemIDs(eventData, outputResult.Array(), outputItemsByIndex) + } + shouldPatchOutput := (!outputResult.Exists() || !outputResult.IsArray() || len(outputResult.Array()) == 0) && (len(outputItemsByIndex) > 0 || len(outputItemsFallback) > 0) if !shouldPatchOutput { return eventData