diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index b6a8a2fb..3a8dab5e 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -104,25 +104,22 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa case "response.reasoning_summary_part.done": params.ThinkingStopPending = true case "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) + if rootResult.Get("part.type").String() == "output_text" { + output = append(output, startCodexTextBlock(params)...) + } case "response.output_text.delta": params.HasTextDelta = true + output = append(output, finalizeCodexThinkingBlock(params)...) + output = append(output, startCodexTextBlock(params)...) 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()) output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2) case "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) + if rootResult.Get("part.type").String() == "output_text" { + output = append(output, stopCodexTextBlock(params)...) + } case "response.web_search_call.searching", "response.web_search_call.completed", "response.web_search_call.in_progress": // Wait for populated web_search_call items on output_item.done. case "response.completed", "response.incomplete": @@ -145,6 +142,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa switch itemType { case "function_call": output = append(output, finalizeCodexThinkingBlock(params)...) + output = append(output, stopCodexTextBlock(params)...) params.HasToolCall = true params.HasReceivedArgumentsDelta = false template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`) @@ -199,24 +197,15 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa } 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) - } + output = append(output, startCodexTextBlock(params)...) 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++ + output = append(output, stopCodexTextBlock(params)...) params.HasTextDelta = true - output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2) case "function_call": template = []byte(`{"type":"content_block_stop","index":0}`) template, _ = sjson.SetBytes(template, "index", params.BlockIndex) @@ -517,6 +506,31 @@ func ClaudeTokenCount(_ context.Context, count int64) []byte { return translatorcommon.ClaudeInputTokensJSON(count) } +func startCodexTextBlock(params *ConvertCodexResponseToClaudeParams) []byte { + if params.TextBlockOpen { + return nil + } + + template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`) + template, _ = sjson.SetBytes(template, "index", params.BlockIndex) + params.TextBlockOpen = true + + return translatorcommon.AppendSSEEventBytes(nil, "content_block_start", template, 2) +} + +func stopCodexTextBlock(params *ConvertCodexResponseToClaudeParams) []byte { + if !params.TextBlockOpen { + return nil + } + + template := []byte(`{"type":"content_block_stop","index":0}`) + template, _ = sjson.SetBytes(template, "index", params.BlockIndex) + params.TextBlockOpen = false + params.BlockIndex++ + + return translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", template, 2) +} + func startCodexThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte { if params.ThinkingBlockOpen { return nil diff --git a/internal/translator/codex/claude/codex_claude_response_test.go b/internal/translator/codex/claude/codex_claude_response_test.go index 78e6a4d8..e707fa6f 100644 --- a/internal/translator/codex/claude/codex_claude_response_test.go +++ b/internal/translator/codex/claude/codex_claude_response_test.go @@ -472,6 +472,65 @@ func TestConvertCodexResponseToClaudeNonStream_ThinkingIncludesSignature(t *test } } +func TestConvertCodexResponseToClaude_StreamTextBeforeToolCallsDoesNotEmitGhostStop(t *testing.T) { + ctx := context.Background() + originalRequest := []byte(`{"tools":[{"name":"Read","description":"read"}]}`) + var param any + + chunks := [][]byte{ + []byte(`data: {"type":"response.created","response":{"id":"resp_1","model":"grok-composer-2.5-fast"}}`), + []byte(`data: {"type":"response.output_item.added","item":{"type":"message","status":"in_progress"},"output_index":1}`), + []byte(`data: {"type":"response.content_part.added","part":{"type":"output_text"},"content_index":0,"output_index":1}`), + []byte(`data: {"type":"response.output_text.delta","delta":"查看项目的 README 和核心入口,以便准确说明项目用途。\n","output_index":1}`), + []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_a","name":"Read","status":"in_progress"},"output_index":2}`), + []byte(`data: {"type":"response.function_call_arguments.delta","delta":"{\"path\":\"/tmp/README.md\"}","output_index":2}`), + []byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"path\":\"/tmp/README.md\"}","output_index":2}`), + []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_a","name":"Read","arguments":"{\"path\":\"/tmp/README.md\"}"},"output_index":2}`), + []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_b","name":"Read","status":"in_progress"},"output_index":3}`), + []byte(`data: {"type":"response.function_call_arguments.delta","delta":"{\"path\":\"/tmp/main.go\"}","output_index":3}`), + []byte(`data: {"type":"response.content_part.done","part":{"type":"output_text"},"content_index":0,"output_index":1}`), + []byte(`data: {"type":"response.output_item.done","item":{"type":"message","status":"completed"},"output_index":1}`), + []byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"path\":\"/tmp/main.go\"}","output_index":3}`), + []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_b","name":"Read","arguments":"{\"path\":\"/tmp/main.go\"}"},"output_index":3}`), + []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)...) + } + + var startIndices []int64 + var stopIndices []int64 + 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: ")) + switch data.Get("type").String() { + case "content_block_start": + startIndices = append(startIndices, data.Get("index").Int()) + case "content_block_stop": + stopIndices = append(stopIndices, data.Get("index").Int()) + } + } + } + + if len(startIndices) != 3 { + t.Fatalf("expected 3 content_block_start events (text + 2 tools), got %v", startIndices) + } + if len(stopIndices) != 3 { + t.Fatalf("expected 3 content_block_stop events, got %v", stopIndices) + } + if startIndices[0] != 0 || startIndices[1] != 1 || startIndices[2] != 2 { + t.Fatalf("unexpected start indices: %v", startIndices) + } + if stopIndices[0] != 0 || stopIndices[1] != 1 || stopIndices[2] != 2 { + t.Fatalf("unexpected stop indices: %v", stopIndices) + } +} + func TestConvertCodexResponseToClaude_StreamEmptyOutputUsesOutputItemDoneMessageFallback(t *testing.T) { ctx := context.Background() originalRequest := []byte(`{"tools":[]}`)