From fcc59d606d903cf1d1ec86aa9dc2c455f6d8087f Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 8 Apr 2026 03:54:15 +0800 Subject: [PATCH] fix(translator): add unit tests to validate output_item.done fallback logic for Gemini and Claude --- .../codex/claude/codex_claude_response.go | 49 +++++++++++- .../claude/codex_claude_response_test.go | 37 +++++++++ .../codex/gemini/codex_gemini_response.go | 75 +++++++++++++------ .../gemini/codex_gemini_response_test.go | 35 +++++++++ 4 files changed, 173 insertions(+), 23 deletions(-) create mode 100644 internal/translator/codex/gemini/codex_gemini_response_test.go diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index 708194e6..388b907a 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -26,6 +26,8 @@ type ConvertCodexResponseToClaudeParams struct { HasToolCall bool BlockIndex int HasReceivedArgumentsDelta bool + HasTextDelta bool + TextBlockOpen bool ThinkingBlockOpen bool ThinkingStopPending bool ThinkingSignature string @@ -104,9 +106,11 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa } else if typeStr == "response.content_part.added" { template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`) template, _ = sjson.SetBytes(template, "index", params.BlockIndex) + params.TextBlockOpen = true output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2) } else if typeStr == "response.output_text.delta" { + params.HasTextDelta = true template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`) template, _ = sjson.SetBytes(template, "index", params.BlockIndex) template, _ = sjson.SetBytes(template, "delta.text", rootResult.Get("delta").String()) @@ -115,6 +119,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa } else if typeStr == "response.content_part.done" { template = []byte(`{"type":"content_block_stop","index":0}`) template, _ = sjson.SetBytes(template, "index", params.BlockIndex) + params.TextBlockOpen = false params.BlockIndex++ output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2) @@ -172,7 +177,49 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa } else if typeStr == "response.output_item.done" { itemResult := rootResult.Get("item") itemType := itemResult.Get("type").String() - if itemType == "function_call" { + if itemType == "message" { + if params.HasTextDelta { + return [][]byte{output} + } + contentResult := itemResult.Get("content") + if !contentResult.Exists() || !contentResult.IsArray() { + return [][]byte{output} + } + var textBuilder strings.Builder + contentResult.ForEach(func(_, part gjson.Result) bool { + if part.Get("type").String() != "output_text" { + return true + } + if txt := part.Get("text").String(); txt != "" { + textBuilder.WriteString(txt) + } + return true + }) + text := textBuilder.String() + if text == "" { + return [][]byte{output} + } + + output = append(output, finalizeCodexThinkingBlock(params)...) + if !params.TextBlockOpen { + template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`) + template, _ = sjson.SetBytes(template, "index", params.BlockIndex) + params.TextBlockOpen = true + output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2) + } + + template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`) + template, _ = sjson.SetBytes(template, "index", params.BlockIndex) + template, _ = sjson.SetBytes(template, "delta.text", text) + output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2) + + template = []byte(`{"type":"content_block_stop","index":0}`) + template, _ = sjson.SetBytes(template, "index", params.BlockIndex) + params.TextBlockOpen = false + params.BlockIndex++ + params.HasTextDelta = true + output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2) + } else if itemType == "function_call" { template = []byte(`{"type":"content_block_stop","index":0}`) template, _ = sjson.SetBytes(template, "index", params.BlockIndex) params.BlockIndex++ diff --git a/internal/translator/codex/claude/codex_claude_response_test.go b/internal/translator/codex/claude/codex_claude_response_test.go index a8d4d189..c36c9edb 100644 --- a/internal/translator/codex/claude/codex_claude_response_test.go +++ b/internal/translator/codex/claude/codex_claude_response_test.go @@ -280,3 +280,40 @@ func TestConvertCodexResponseToClaudeNonStream_ThinkingIncludesSignature(t *test t.Fatalf("unexpected thinking text: %q", got) } } + +func TestConvertCodexResponseToClaude_StreamEmptyOutputUsesOutputItemDoneMessageFallback(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"tools":[]}`) + var param any + + chunks := [][]byte{ + []byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-5\"}}"), + []byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]},\"output_index\":0}"), + []byte("data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"), + } + + var outputs [][]byte + for _, chunk := range chunks { + outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, ¶m)...) + } + + foundText := false + for _, out := range outputs { + for _, line := range strings.Split(string(out), "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + data := gjson.Parse(strings.TrimPrefix(line, "data: ")) + if data.Get("type").String() == "content_block_delta" && data.Get("delta.type").String() == "text_delta" && data.Get("delta.text").String() == "ok" { + foundText = true + break + } + } + if foundText { + break + } + } + if !foundText { + t.Fatalf("expected fallback content from response.output_item.done message; outputs=%q", outputs) + } +} diff --git a/internal/translator/codex/gemini/codex_gemini_response.go b/internal/translator/codex/gemini/codex_gemini_response.go index 4bd76791..f6ef8771 100644 --- a/internal/translator/codex/gemini/codex_gemini_response.go +++ b/internal/translator/codex/gemini/codex_gemini_response.go @@ -20,10 +20,11 @@ var ( // ConvertCodexResponseToGeminiParams holds parameters for response conversion. type ConvertCodexResponseToGeminiParams struct { - Model string - CreatedAt int64 - ResponseID string - LastStorageOutput []byte + Model string + CreatedAt int64 + ResponseID string + LastStorageOutput []byte + HasOutputTextDelta bool } // ConvertCodexResponseToGemini converts Codex streaming response format to Gemini format. @@ -42,10 +43,11 @@ type ConvertCodexResponseToGeminiParams struct { func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { if *param == nil { *param = &ConvertCodexResponseToGeminiParams{ - Model: modelName, - CreatedAt: 0, - ResponseID: "", - LastStorageOutput: nil, + Model: modelName, + CreatedAt: 0, + ResponseID: "", + LastStorageOutput: nil, + HasOutputTextDelta: false, } } @@ -58,18 +60,18 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR typeResult := rootResult.Get("type") typeStr := typeResult.String() + params := (*param).(*ConvertCodexResponseToGeminiParams) + // Base Gemini response template template := []byte(`{"candidates":[{"content":{"role":"model","parts":[]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-2.5-pro","createTime":"2025-08-15T02:52:03.884209Z","responseId":"06CeaPH7NaCU48APvNXDyA4"}`) - if len((*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput) > 0 && typeStr == "response.output_item.done" { - template = append([]byte(nil), (*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput...) - } else { - template, _ = sjson.SetBytes(template, "modelVersion", (*param).(*ConvertCodexResponseToGeminiParams).Model) + { + template, _ = sjson.SetBytes(template, "modelVersion", params.Model) createdAtResult := rootResult.Get("response.created_at") if createdAtResult.Exists() { - (*param).(*ConvertCodexResponseToGeminiParams).CreatedAt = createdAtResult.Int() - template, _ = sjson.SetBytes(template, "createTime", time.Unix((*param).(*ConvertCodexResponseToGeminiParams).CreatedAt, 0).Format(time.RFC3339Nano)) + params.CreatedAt = createdAtResult.Int() + template, _ = sjson.SetBytes(template, "createTime", time.Unix(params.CreatedAt, 0).Format(time.RFC3339Nano)) } - template, _ = sjson.SetBytes(template, "responseId", (*param).(*ConvertCodexResponseToGeminiParams).ResponseID) + template, _ = sjson.SetBytes(template, "responseId", params.ResponseID) } // Handle function call completion @@ -101,7 +103,7 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", functionCall) template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP") - (*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput = append([]byte(nil), template...) + params.LastStorageOutput = append([]byte(nil), template...) // Use this return to storage message return [][]byte{} @@ -111,15 +113,45 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR if typeStr == "response.created" { // Handle response creation - set model and response ID template, _ = sjson.SetBytes(template, "modelVersion", rootResult.Get("response.model").String()) template, _ = sjson.SetBytes(template, "responseId", rootResult.Get("response.id").String()) - (*param).(*ConvertCodexResponseToGeminiParams).ResponseID = rootResult.Get("response.id").String() + params.ResponseID = rootResult.Get("response.id").String() } else if typeStr == "response.reasoning_summary_text.delta" { // Handle reasoning/thinking content delta part := []byte(`{"thought":true,"text":""}`) part, _ = sjson.SetBytes(part, "text", rootResult.Get("delta").String()) template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part) } else if typeStr == "response.output_text.delta" { // Handle regular text content delta + params.HasOutputTextDelta = true part := []byte(`{"text":""}`) part, _ = sjson.SetBytes(part, "text", rootResult.Get("delta").String()) template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part) + } else if typeStr == "response.output_item.done" { // Fallback: emit final message text when no delta chunks were received + itemResult := rootResult.Get("item") + if itemResult.Get("type").String() != "message" || params.HasOutputTextDelta { + return [][]byte{} + } + contentResult := itemResult.Get("content") + if !contentResult.Exists() || !contentResult.IsArray() { + return [][]byte{} + } + wroteText := false + contentResult.ForEach(func(_, partResult gjson.Result) bool { + if partResult.Get("type").String() != "output_text" { + return true + } + text := partResult.Get("text").String() + if text == "" { + return true + } + part := []byte(`{"text":""}`) + part, _ = sjson.SetBytes(part, "text", text) + template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part) + wroteText = true + return true + }) + if wroteText { + params.HasOutputTextDelta = true + return [][]byte{template} + } + return [][]byte{} } else if typeStr == "response.completed" { // Handle response completion with usage metadata template, _ = sjson.SetBytes(template, "usageMetadata.promptTokenCount", rootResult.Get("response.usage.input_tokens").Int()) template, _ = sjson.SetBytes(template, "usageMetadata.candidatesTokenCount", rootResult.Get("response.usage.output_tokens").Int()) @@ -129,11 +161,10 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR return [][]byte{} } - if len((*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput) > 0 { - return [][]byte{ - append([]byte(nil), (*param).(*ConvertCodexResponseToGeminiParams).LastStorageOutput...), - template, - } + if len(params.LastStorageOutput) > 0 { + stored := append([]byte(nil), params.LastStorageOutput...) + params.LastStorageOutput = nil + return [][]byte{stored, template} } return [][]byte{template} } diff --git a/internal/translator/codex/gemini/codex_gemini_response_test.go b/internal/translator/codex/gemini/codex_gemini_response_test.go new file mode 100644 index 00000000..b8f227be --- /dev/null +++ b/internal/translator/codex/gemini/codex_gemini_response_test.go @@ -0,0 +1,35 @@ +package gemini + +import ( + "context" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertCodexResponseToGemini_StreamEmptyOutputUsesOutputItemDoneMessageFallback(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"tools":[]}`) + var param any + + chunks := [][]byte{ + []byte("data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]},\"output_index\":0}"), + []byte("data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}"), + } + + var outputs [][]byte + for _, chunk := range chunks { + outputs = append(outputs, ConvertCodexResponseToGemini(ctx, "gemini-2.5-pro", originalRequest, nil, chunk, ¶m)...) + } + + found := false + for _, out := range outputs { + if gjson.GetBytes(out, "candidates.0.content.parts.0.text").String() == "ok" { + found = true + break + } + } + if !found { + t.Fatalf("expected fallback content from response.output_item.done message; outputs=%q", outputs) + } +}