fix(codex): hydrate missing response.completed output item IDs

When a `response.completed` payload already includes output entries, fill in only missing/empty item `id`s from the streamed `output_item.done` data while keeping existing IDs untouched.

Closes: #4622
This commit is contained in:
Luis Pater
2026-08-03 06:19:56 +08:00
parent ffdb9c9fbc
commit 134a66738c
2 changed files with 69 additions and 0 deletions

View File

@@ -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")

View File

@@ -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