feat(translator): enhance content block handling and add stream-specific test

- Refactored content block start/stop logic into `startCodexTextBlock` and `stopCodexTextBlock` for better readability and reusability.
- Updated logic to ensure proper handling of "output_text" block events to avoid ghost stop emissions.
- Added `TestConvertCodexResponseToClaude_StreamTextBeforeToolCallsDoesNotEmitGhostStop` to validate content block start/stop behavior in streamed responses.
This commit is contained in:
Luis Pater
2026-06-17 00:41:08 +08:00
parent f49d1798d1
commit a5cb88323d
2 changed files with 95 additions and 22 deletions
@@ -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
@@ -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, &param)...)
}
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":[]}`)