Skip to content

Commit a5cb883

Browse files
committed
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.
1 parent f49d179 commit a5cb883

2 files changed

Lines changed: 95 additions & 22 deletions

File tree

internal/translator/codex/claude/codex_claude_response.go

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -104,25 +104,22 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
104104
case "response.reasoning_summary_part.done":
105105
params.ThinkingStopPending = true
106106
case "response.content_part.added":
107-
template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`)
108-
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
109-
params.TextBlockOpen = true
110-
111-
output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2)
107+
if rootResult.Get("part.type").String() == "output_text" {
108+
output = append(output, startCodexTextBlock(params)...)
109+
}
112110
case "response.output_text.delta":
113111
params.HasTextDelta = true
112+
output = append(output, finalizeCodexThinkingBlock(params)...)
113+
output = append(output, startCodexTextBlock(params)...)
114114
template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`)
115115
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
116116
template, _ = sjson.SetBytes(template, "delta.text", rootResult.Get("delta").String())
117117

118118
output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2)
119119
case "response.content_part.done":
120-
template = []byte(`{"type":"content_block_stop","index":0}`)
121-
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
122-
params.TextBlockOpen = false
123-
params.BlockIndex++
124-
125-
output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2)
120+
if rootResult.Get("part.type").String() == "output_text" {
121+
output = append(output, stopCodexTextBlock(params)...)
122+
}
126123
case "response.web_search_call.searching", "response.web_search_call.completed", "response.web_search_call.in_progress":
127124
// Wait for populated web_search_call items on output_item.done.
128125
case "response.completed", "response.incomplete":
@@ -145,6 +142,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
145142
switch itemType {
146143
case "function_call":
147144
output = append(output, finalizeCodexThinkingBlock(params)...)
145+
output = append(output, stopCodexTextBlock(params)...)
148146
params.HasToolCall = true
149147
params.HasReceivedArgumentsDelta = false
150148
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
199197
}
200198

201199
output = append(output, finalizeCodexThinkingBlock(params)...)
202-
if !params.TextBlockOpen {
203-
template = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`)
204-
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
205-
params.TextBlockOpen = true
206-
output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2)
207-
}
200+
output = append(output, startCodexTextBlock(params)...)
208201

209202
template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`)
210203
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
211204
template, _ = sjson.SetBytes(template, "delta.text", text)
212205
output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2)
213206

214-
template = []byte(`{"type":"content_block_stop","index":0}`)
215-
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
216-
params.TextBlockOpen = false
217-
params.BlockIndex++
207+
output = append(output, stopCodexTextBlock(params)...)
218208
params.HasTextDelta = true
219-
output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2)
220209
case "function_call":
221210
template = []byte(`{"type":"content_block_stop","index":0}`)
222211
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
@@ -517,6 +506,31 @@ func ClaudeTokenCount(_ context.Context, count int64) []byte {
517506
return translatorcommon.ClaudeInputTokensJSON(count)
518507
}
519508

509+
func startCodexTextBlock(params *ConvertCodexResponseToClaudeParams) []byte {
510+
if params.TextBlockOpen {
511+
return nil
512+
}
513+
514+
template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`)
515+
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
516+
params.TextBlockOpen = true
517+
518+
return translatorcommon.AppendSSEEventBytes(nil, "content_block_start", template, 2)
519+
}
520+
521+
func stopCodexTextBlock(params *ConvertCodexResponseToClaudeParams) []byte {
522+
if !params.TextBlockOpen {
523+
return nil
524+
}
525+
526+
template := []byte(`{"type":"content_block_stop","index":0}`)
527+
template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
528+
params.TextBlockOpen = false
529+
params.BlockIndex++
530+
531+
return translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", template, 2)
532+
}
533+
520534
func startCodexThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte {
521535
if params.ThinkingBlockOpen {
522536
return nil

internal/translator/codex/claude/codex_claude_response_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,65 @@ func TestConvertCodexResponseToClaudeNonStream_ThinkingIncludesSignature(t *test
472472
}
473473
}
474474

475+
func TestConvertCodexResponseToClaude_StreamTextBeforeToolCallsDoesNotEmitGhostStop(t *testing.T) {
476+
ctx := context.Background()
477+
originalRequest := []byte(`{"tools":[{"name":"Read","description":"read"}]}`)
478+
var param any
479+
480+
chunks := [][]byte{
481+
[]byte(`data: {"type":"response.created","response":{"id":"resp_1","model":"grok-composer-2.5-fast"}}`),
482+
[]byte(`data: {"type":"response.output_item.added","item":{"type":"message","status":"in_progress"},"output_index":1}`),
483+
[]byte(`data: {"type":"response.content_part.added","part":{"type":"output_text"},"content_index":0,"output_index":1}`),
484+
[]byte(`data: {"type":"response.output_text.delta","delta":"查看项目的 README 和核心入口,以便准确说明项目用途。\n","output_index":1}`),
485+
[]byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_a","name":"Read","status":"in_progress"},"output_index":2}`),
486+
[]byte(`data: {"type":"response.function_call_arguments.delta","delta":"{\"path\":\"/tmp/README.md\"}","output_index":2}`),
487+
[]byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"path\":\"/tmp/README.md\"}","output_index":2}`),
488+
[]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}`),
489+
[]byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_b","name":"Read","status":"in_progress"},"output_index":3}`),
490+
[]byte(`data: {"type":"response.function_call_arguments.delta","delta":"{\"path\":\"/tmp/main.go\"}","output_index":3}`),
491+
[]byte(`data: {"type":"response.content_part.done","part":{"type":"output_text"},"content_index":0,"output_index":1}`),
492+
[]byte(`data: {"type":"response.output_item.done","item":{"type":"message","status":"completed"},"output_index":1}`),
493+
[]byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"path\":\"/tmp/main.go\"}","output_index":3}`),
494+
[]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}`),
495+
[]byte(`data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}`),
496+
}
497+
498+
var outputs [][]byte
499+
for _, chunk := range chunks {
500+
outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, &param)...)
501+
}
502+
503+
var startIndices []int64
504+
var stopIndices []int64
505+
for _, out := range outputs {
506+
for _, line := range strings.Split(string(out), "\n") {
507+
if !strings.HasPrefix(line, "data: ") {
508+
continue
509+
}
510+
data := gjson.Parse(strings.TrimPrefix(line, "data: "))
511+
switch data.Get("type").String() {
512+
case "content_block_start":
513+
startIndices = append(startIndices, data.Get("index").Int())
514+
case "content_block_stop":
515+
stopIndices = append(stopIndices, data.Get("index").Int())
516+
}
517+
}
518+
}
519+
520+
if len(startIndices) != 3 {
521+
t.Fatalf("expected 3 content_block_start events (text + 2 tools), got %v", startIndices)
522+
}
523+
if len(stopIndices) != 3 {
524+
t.Fatalf("expected 3 content_block_stop events, got %v", stopIndices)
525+
}
526+
if startIndices[0] != 0 || startIndices[1] != 1 || startIndices[2] != 2 {
527+
t.Fatalf("unexpected start indices: %v", startIndices)
528+
}
529+
if stopIndices[0] != 0 || stopIndices[1] != 1 || stopIndices[2] != 2 {
530+
t.Fatalf("unexpected stop indices: %v", stopIndices)
531+
}
532+
}
533+
475534
func TestConvertCodexResponseToClaude_StreamEmptyOutputUsesOutputItemDoneMessageFallback(t *testing.T) {
476535
ctx := context.Background()
477536
originalRequest := []byte(`{"tools":[]}`)

0 commit comments

Comments
 (0)