fix(translator): resolve pending codex tool calls on terminal response

This commit is contained in:
Luis Pater
2026-07-08 05:25:34 +08:00
parent 505c59d8c9
commit cdccc72dfe
2 changed files with 473 additions and 42 deletions
@@ -23,9 +23,12 @@ var (
// ConvertCodexResponseToClaudeParams holds parameters for response conversion.
type ConvertCodexResponseToClaudeParams struct {
HasToolCall bool
HasEmittedToolUse bool
BlockIndex int
HasReceivedArgumentsDelta bool
FunctionCallBlockOpen bool
FunctionCallBlockCallID string
FunctionCallBlockIndex int
HasTextDelta bool
TextBlockOpen bool
ThinkingBlockOpen bool
@@ -42,7 +45,6 @@ type ConvertCodexResponseToClaudeParams struct {
type pendingCodexFunctionCall struct {
CallID string
Arguments string
BlockIndex int
HasReceivedArgumentsDelta bool
StartEmitted bool
}
@@ -66,8 +68,7 @@ type pendingCodexFunctionCall struct {
func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRawJSON, _ []byte, rawJSON []byte, param *any) [][]byte {
if *param == nil {
*param = &ConvertCodexResponseToClaudeParams{
HasToolCall: false,
BlockIndex: 0,
BlockIndex: 0,
}
}
@@ -135,7 +136,10 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
case "response.completed", "response.incomplete":
template = []byte(`{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
responseData := rootResult.Get("response")
template, _ = sjson.SetBytes(template, "delta.stop_reason", mapCodexStopReasonToClaude(codexStopReason(responseData), params.HasToolCall))
output = hydrateOpenCodexFunctionCallFromTerminal(output, params, responseData)
output = append(output, finalizeCodexOpenContentBlocks(params)...)
output = appendPendingCodexFunctionCallsFromTerminal(output, params, originalRequestRawJSON, responseData)
template, _ = sjson.SetBytes(template, "delta.stop_reason", mapCodexStopReasonToClaude(codexStopReason(responseData), params.HasEmittedToolUse))
template = setClaudeStopSequence(template, "delta.stop_sequence", responseData)
inputTokens, outputTokens, cachedTokens := extractResponsesUsage(responseData.Get("usage"))
template, _ = sjson.SetBytes(template, "usage.input_tokens", inputTokens)
@@ -153,28 +157,25 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
case "function_call":
output = append(output, finalizeCodexThinkingBlock(params)...)
output = append(output, stopCodexTextBlock(params)...)
params.HasToolCall = true
params.HasReceivedArgumentsDelta = false
callID := itemResult.Get("call_id").String()
callID := codexFunctionCallID(itemResult)
name := itemResult.Get("name").String()
key := codexFunctionCallKey(rootResult, itemResult)
if name == "" {
if params.PendingFunctionCalls == nil {
params.PendingFunctionCalls = map[string]*pendingCodexFunctionCall{}
}
params.PendingFunctionCalls[key] = &pendingCodexFunctionCall{
CallID: callID,
BlockIndex: params.BlockIndex,
}
params.LastPendingFunctionCallKey = key
params.BlockIndex++
recordPendingCodexFunctionCall(params, rootResult, itemResult)
break
}
delete(params.PendingFunctionCalls, key)
output = appendCodexFunctionCallStart(output, originalRequestRawJSON, callID, name, params.BlockIndex)
output = appendCodexFunctionCallArgumentDelta(output, "", params.BlockIndex)
if pending, pendingKeys := pendingCodexFunctionCallForDone(params, rootResult, itemResult); pending != nil {
deletePendingCodexFunctionCallAliases(params, pendingKeys)
}
blockIndex := params.BlockIndex
output = appendCodexFunctionCallStart(output, originalRequestRawJSON, callID, name, blockIndex)
params.HasEmittedToolUse = true
output = appendCodexFunctionCallArgumentDelta(output, "", blockIndex)
params.FunctionCallBlockOpen = true
params.FunctionCallBlockCallID = callID
params.FunctionCallBlockIndex = blockIndex
case "reasoning":
params.ThinkingSummarySeen = false
params.ThinkingSignature = itemResult.Get("encrypted_content").String()
@@ -219,17 +220,18 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
output = append(output, stopCodexTextBlock(params)...)
params.HasTextDelta = true
case "function_call":
key := codexFunctionCallKey(rootResult, itemResult)
if pending, pendingKey := pendingCodexFunctionCallForKey(params, key); pending != nil && !pending.StartEmitted {
if pending, pendingKeys := pendingCodexFunctionCallForDone(params, rootResult, itemResult); pending != nil && !pending.StartEmitted {
name := itemResult.Get("name").String()
if name == "" {
return [][]byte{output}
}
callID := pending.CallID
if callID == "" {
callID = itemResult.Get("call_id").String()
callID = codexFunctionCallID(itemResult)
}
output = appendCodexFunctionCallStart(output, originalRequestRawJSON, callID, name, pending.BlockIndex)
blockIndex := params.BlockIndex
output = appendCodexFunctionCallStart(output, originalRequestRawJSON, callID, name, blockIndex)
params.HasEmittedToolUse = true
pending.StartEmitted = true
args := pending.Arguments
@@ -237,17 +239,20 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
args = itemResult.Get("arguments").String()
}
if args != "" {
output = appendCodexFunctionCallArgumentDelta(output, args, pending.BlockIndex)
output = appendCodexFunctionCallArgumentDelta(output, args, blockIndex)
}
output = appendCodexFunctionCallStop(output, pending.BlockIndex)
delete(params.PendingFunctionCalls, pendingKey)
if params.LastPendingFunctionCallKey == pendingKey {
params.LastPendingFunctionCallKey = ""
}
} else {
output = appendCodexFunctionCallStop(output, params.BlockIndex)
output = appendCodexFunctionCallStop(output, blockIndex)
params.BlockIndex++
deletePendingCodexFunctionCallAliases(params, pendingKeys)
} else if params.FunctionCallBlockOpen {
if !params.HasReceivedArgumentsDelta {
if args := itemResult.Get("arguments").String(); args != "" {
output = appendCodexFunctionCallArgumentDelta(output, args, params.FunctionCallBlockIndex)
params.HasReceivedArgumentsDelta = true
}
}
output = appendCodexOpenFunctionCallStop(output, params)
}
case "reasoning":
if signature := itemResult.Get("encrypted_content").String(); signature != "" {
@@ -286,6 +291,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
if !params.HasReceivedArgumentsDelta {
if args := rootResult.Get("arguments").String(); args != "" {
output = appendCodexFunctionCallArgumentDelta(output, args, params.BlockIndex)
params.HasReceivedArgumentsDelta = true
}
}
}
@@ -482,7 +488,7 @@ func mapCodexStopReasonToClaude(stopReason string, hasToolCall bool) string {
case "max_tokens", "max_output_tokens":
return "max_tokens"
case "tool_use", "tool_calls", "function_call":
return "tool_use"
return "end_turn"
case "end_turn", "stop_sequence", "pause_turn", "refusal", "model_context_window_exceeded":
return stopReason
case "content_filter":
@@ -507,12 +513,23 @@ func codexFunctionCallKey(rootResult, itemResult gjson.Result) string {
if outputIndex := rootResult.Get("output_index"); outputIndex.Exists() {
return "output:" + outputIndex.Raw
}
if callID := itemResult.Get("call_id").String(); callID != "" {
if callID := codexFunctionCallID(itemResult); callID != "" {
return "call:" + callID
}
return "last"
}
func codexFunctionCallID(itemResult gjson.Result) string {
return itemResult.Get("call_id").String()
}
func codexFunctionCallIDKey(callID string) string {
if callID == "" {
return ""
}
return "call:" + callID
}
func codexArgumentsFunctionCallKey(params *ConvertCodexResponseToClaudeParams, rootResult gjson.Result) string {
if outputIndex := rootResult.Get("output_index"); outputIndex.Exists() {
return "output:" + outputIndex.Raw
@@ -520,21 +537,88 @@ func codexArgumentsFunctionCallKey(params *ConvertCodexResponseToClaudeParams, r
return params.LastPendingFunctionCallKey
}
func recordPendingCodexFunctionCall(params *ConvertCodexResponseToClaudeParams, rootResult, itemResult gjson.Result) {
if params.PendingFunctionCalls == nil {
params.PendingFunctionCalls = map[string]*pendingCodexFunctionCall{}
}
pending := &pendingCodexFunctionCall{CallID: codexFunctionCallID(itemResult)}
key := codexFunctionCallKey(rootResult, itemResult)
params.PendingFunctionCalls[key] = pending
if callIDKey := codexFunctionCallIDKey(pending.CallID); callIDKey != "" {
params.PendingFunctionCalls[callIDKey] = pending
}
params.LastPendingFunctionCallKey = key
}
func pendingCodexFunctionCallForKey(params *ConvertCodexResponseToClaudeParams, key string) (*pendingCodexFunctionCall, string) {
if params == nil || params.PendingFunctionCalls == nil {
if params == nil || params.PendingFunctionCalls == nil || key == "" {
return nil, ""
}
if key != "" {
pending, ok := params.PendingFunctionCalls[key]
if !ok {
return nil, ""
}
return pending, key
}
func pendingCodexFunctionCallForDone(params *ConvertCodexResponseToClaudeParams, rootResult, itemResult gjson.Result) (*pendingCodexFunctionCall, []string) {
if params == nil || params.PendingFunctionCalls == nil {
return nil, nil
}
keys := []string{codexFunctionCallKey(rootResult, itemResult)}
callID := codexFunctionCallID(itemResult)
if callID != "" {
keys = appendUniqueCodexFunctionCallKey(keys, codexFunctionCallIDKey(callID))
} else if !rootResult.Get("output_index").Exists() && params.LastPendingFunctionCallKey != "" {
keys = appendUniqueCodexFunctionCallKey(keys, params.LastPendingFunctionCallKey)
}
for _, key := range keys {
if pending, ok := params.PendingFunctionCalls[key]; ok {
return pending, key
return pending, keysForPendingCodexFunctionCall(params, pending)
}
}
if params.LastPendingFunctionCallKey != "" {
if pending, ok := params.PendingFunctionCalls[params.LastPendingFunctionCallKey]; ok {
return pending, params.LastPendingFunctionCallKey
return nil, nil
}
func appendUniqueCodexFunctionCallKey(keys []string, key string) []string {
if key == "" {
return keys
}
for _, existing := range keys {
if existing == key {
return keys
}
}
return append(keys, key)
}
func keysForPendingCodexFunctionCall(params *ConvertCodexResponseToClaudeParams, pending *pendingCodexFunctionCall) []string {
if params == nil || pending == nil || params.PendingFunctionCalls == nil {
return nil
}
keys := make([]string, 0, 2)
for key, candidate := range params.PendingFunctionCalls {
if candidate == pending {
keys = append(keys, key)
}
}
return keys
}
func deletePendingCodexFunctionCallAliases(params *ConvertCodexResponseToClaudeParams, keys []string) {
if params == nil || params.PendingFunctionCalls == nil {
return
}
for _, key := range keys {
delete(params.PendingFunctionCalls, key)
if params.LastPendingFunctionCallKey == key {
params.LastPendingFunctionCallKey = ""
}
}
return nil, ""
}
func appendCodexFunctionCallStart(output []byte, originalRequestRawJSON []byte, callID, name string, blockIndex int) []byte {
@@ -558,6 +642,134 @@ func appendCodexFunctionCallStop(output []byte, blockIndex int) []byte {
return translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2)
}
func appendCodexOpenFunctionCallStop(output []byte, params *ConvertCodexResponseToClaudeParams) []byte {
if params == nil || !params.FunctionCallBlockOpen {
return output
}
blockIndex := params.FunctionCallBlockIndex
output = appendCodexFunctionCallStop(output, blockIndex)
if params.BlockIndex <= blockIndex {
params.BlockIndex = blockIndex + 1
}
params.FunctionCallBlockOpen = false
params.FunctionCallBlockCallID = ""
params.FunctionCallBlockIndex = 0
return output
}
func hydrateOpenCodexFunctionCallFromTerminal(output []byte, params *ConvertCodexResponseToClaudeParams, responseData gjson.Result) []byte {
if params == nil || !params.FunctionCallBlockOpen || params.HasReceivedArgumentsDelta {
return output
}
responseData.Get("output").ForEach(func(_, item gjson.Result) bool {
if item.Get("type").String() != "function_call" || codexFunctionCallID(item) != params.FunctionCallBlockCallID {
return true
}
if args := item.Get("arguments").String(); args != "" {
output = appendCodexFunctionCallArgumentDelta(output, args, params.FunctionCallBlockIndex)
params.HasReceivedArgumentsDelta = true
}
return false
})
return output
}
func appendPendingCodexFunctionCallsFromTerminal(output []byte, params *ConvertCodexResponseToClaudeParams, originalRequestRawJSON []byte, responseData gjson.Result) []byte {
if params == nil || len(params.PendingFunctionCalls) == 0 {
return output
}
responseData.Get("output").ForEach(func(index, item gjson.Result) bool {
if item.Get("type").String() != "function_call" {
return true
}
pending, pendingKeys := pendingCodexFunctionCallForTerminalItem(params, index, item)
if pending == nil {
return true
}
if pending.StartEmitted {
deletePendingCodexFunctionCallAliases(params, pendingKeys)
return true
}
name := item.Get("name").String()
if name == "" {
deletePendingCodexFunctionCallAliases(params, pendingKeys)
return true
}
callID := pending.CallID
if callID == "" {
callID = codexFunctionCallID(item)
}
blockIndex := params.BlockIndex
output = appendCodexFunctionCallStart(output, originalRequestRawJSON, callID, name, blockIndex)
params.HasEmittedToolUse = true
pending.StartEmitted = true
args := item.Get("arguments").String()
if args == "" {
args = pending.Arguments
}
if args != "" {
output = appendCodexFunctionCallArgumentDelta(output, args, blockIndex)
}
output = appendCodexFunctionCallStop(output, blockIndex)
params.BlockIndex++
deletePendingCodexFunctionCallAliases(params, pendingKeys)
return true
})
clearPendingCodexFunctionCalls(params)
return output
}
func pendingCodexFunctionCallForTerminalItem(params *ConvertCodexResponseToClaudeParams, outputIndex, item gjson.Result) (*pendingCodexFunctionCall, []string) {
if params == nil || params.PendingFunctionCalls == nil {
return nil, nil
}
keys := make([]string, 0, 3)
if callID := codexFunctionCallID(item); callID != "" {
keys = appendUniqueCodexFunctionCallKey(keys, codexFunctionCallIDKey(callID))
}
if itemOutputIndex := item.Get("output_index"); itemOutputIndex.Exists() {
keys = appendUniqueCodexFunctionCallKey(keys, "output:"+itemOutputIndex.Raw)
}
if outputIndex.Exists() {
keys = appendUniqueCodexFunctionCallKey(keys, "output:"+outputIndex.Raw)
}
for _, key := range keys {
if pending, ok := params.PendingFunctionCalls[key]; ok {
return pending, keysForPendingCodexFunctionCall(params, pending)
}
}
return nil, nil
}
func clearPendingCodexFunctionCalls(params *ConvertCodexResponseToClaudeParams) {
if params == nil || params.PendingFunctionCalls == nil {
return
}
for key := range params.PendingFunctionCalls {
delete(params.PendingFunctionCalls, key)
}
params.LastPendingFunctionCallKey = ""
}
func finalizeCodexOpenContentBlocks(params *ConvertCodexResponseToClaudeParams) []byte {
output := make([]byte, 0, 256)
output = append(output, finalizeCodexThinkingBlock(params)...)
output = append(output, stopCodexTextBlock(params)...)
output = appendCodexOpenFunctionCallStop(output, params)
return output
}
func resolveCodexClaudeToolUseName(originalRequestRawJSON []byte, name string) string {
rev := buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON)
if orig, ok := rev[name]; ok {
@@ -587,6 +587,225 @@ func TestConvertCodexResponseToClaude_StreamFunctionCallDefersStartUntilDoneName
}
}
func TestConvertCodexResponseToClaude_StreamUnnamedFunctionCallDoneByCallIDKeepsPendingSlots(t *testing.T) {
ctx := context.Background()
originalRequest := []byte(`{"tools":[{"name":"lookup","description":"lookup"}]}`)
var param any
chunks := [][]byte{
[]byte(`data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5"}}`),
[]byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_first"},"output_index":1}`),
[]byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_second"},"output_index":2}`),
[]byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_first","name":"lookup","arguments":"{\"id\":1}"}}`),
[]byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_second","name":"lookup","arguments":"{\"id\":2}"}}`),
}
var outputs [][]byte
for _, chunk := range chunks {
outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, &param)...)
}
var toolIDs []string
var startIndices []int64
var stopIndices []int64
var argumentDeltas []string
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":
if data.Get("content_block.type").String() == "tool_use" {
toolIDs = append(toolIDs, data.Get("content_block.id").String())
startIndices = append(startIndices, data.Get("index").Int())
}
case "content_block_delta":
if data.Get("delta.type").String() == "input_json_delta" {
argumentDeltas = append(argumentDeltas, data.Get("delta.partial_json").String())
}
case "content_block_stop":
stopIndices = append(stopIndices, data.Get("index").Int())
}
}
}
if len(toolIDs) != 2 || toolIDs[0] != "call_first" || toolIDs[1] != "call_second" {
t.Fatalf("unexpected tool IDs: %v; outputs=%q", toolIDs, outputs)
}
if len(startIndices) != 2 || startIndices[0] != 0 || startIndices[1] != 1 {
t.Fatalf("unexpected start indices: %v; outputs=%q", startIndices, outputs)
}
if len(stopIndices) != 2 || stopIndices[0] != 0 || stopIndices[1] != 1 {
t.Fatalf("unexpected stop indices: %v; outputs=%q", stopIndices, outputs)
}
if len(argumentDeltas) != 2 || argumentDeltas[0] != `{"id":1}` || argumentDeltas[1] != `{"id":2}` {
t.Fatalf("unexpected argument deltas: %v; outputs=%q", argumentDeltas, outputs)
}
}
func TestConvertCodexResponseToClaude_StreamDeferredUnnamedFunctionCallDoesNotReserveBlockIndex(t *testing.T) {
ctx := context.Background()
originalRequest := []byte(`{"tools":[{"name":"lookup","description":"lookup"}]}`)
var param any
chunks := [][]byte{
[]byte(`data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5"}}`),
[]byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_hidden"},"output_index":1}`),
[]byte(`data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]},"output_index":2}`),
}
var outputs [][]byte
for _, chunk := range chunks {
outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, &param)...)
}
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_start" && data.Get("content_block.type").String() == "text" {
if got := data.Get("index").Int(); got != 0 {
t.Fatalf("text block index = %d, want 0; outputs=%q", got, outputs)
}
return
}
}
}
t.Fatalf("missing text content_block_start; outputs=%q", outputs)
}
func TestConvertCodexResponseToClaude_StreamTerminalOutputHydratesOpenFunctionCallArguments(t *testing.T) {
ctx := context.Background()
originalRequest := []byte(`{"tools":[{"name":"lookup","description":"lookup"}]}`)
var param any
chunks := [][]byte{
[]byte(`data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5"}}`),
[]byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_1","name":"lookup"},"output_index":1}`),
[]byte(`data: {"type":"response.completed","response":{"stop_reason":"stop","usage":{"input_tokens":1,"output_tokens":1},"output":[{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"query\":\"example\"}"}]}}`),
}
var outputs [][]byte
for _, chunk := range chunks {
outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, &param)...)
}
var finalArgumentPosition = -1
var stopPosition = -1
var messageDeltaPosition = -1
position := 0
for _, out := range outputs {
for _, line := range strings.Split(string(out), "\n") {
if !strings.HasPrefix(line, "data: ") {
continue
}
position++
data := gjson.Parse(strings.TrimPrefix(line, "data: "))
switch data.Get("type").String() {
case "content_block_delta":
if data.Get("delta.type").String() == "input_json_delta" && data.Get("delta.partial_json").String() == `{"query":"example"}` {
finalArgumentPosition = position
}
case "content_block_stop":
if data.Get("index").Int() == 0 {
stopPosition = position
}
case "message_delta":
messageDeltaPosition = position
}
}
}
if finalArgumentPosition == -1 {
t.Fatalf("missing terminal argument delta; outputs=%q", outputs)
}
if stopPosition == -1 {
t.Fatalf("missing content_block_stop for open function call; outputs=%q", outputs)
}
if messageDeltaPosition == -1 {
t.Fatalf("missing message_delta; outputs=%q", outputs)
}
if !(finalArgumentPosition < stopPosition && stopPosition < messageDeltaPosition) {
t.Fatalf("unexpected event order: args=%d stop=%d message_delta=%d; outputs=%q", finalArgumentPosition, stopPosition, messageDeltaPosition, outputs)
}
}
func TestConvertCodexResponseToClaude_StreamTerminalOutputEmitsPendingUnnamedFunctionCall(t *testing.T) {
ctx := context.Background()
originalRequest := []byte(`{"tools":[{"name":"lookup","description":"lookup"}]}`)
var param any
chunks := [][]byte{
[]byte(`data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5"}}`),
[]byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_1"},"output_index":1}`),
[]byte(`data: {"type":"response.function_call_arguments.done","arguments":"{\"query\":\"example\"}","output_index":1}`),
[]byte(`data: {"type":"response.completed","response":{"stop_reason":"stop","usage":{"input_tokens":1,"output_tokens":1},"output":[{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"query\":\"example\"}"}]}}`),
}
var outputs [][]byte
for _, chunk := range chunks {
outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, &param)...)
}
outputText := string(bytes.Join(outputs, nil))
if strings.Count(outputText, `"type":"tool_use"`) != 1 {
t.Fatalf("expected one terminal tool_use block, got output:\n%s", outputText)
}
if !strings.Contains(outputText, `"name":"lookup"`) || !strings.Contains(outputText, `"partial_json":"{\"query\":\"example\"}"`) {
t.Fatalf("expected terminal tool name and arguments, got output:\n%s", outputText)
}
gotReason, ok := findClaudeStreamStopReason(outputs)
if !ok {
t.Fatalf("missing message_delta; outputs=%q", outputs)
}
if gotReason != "tool_use" {
t.Fatalf("stop_reason = %q, want tool_use. Outputs=%q", gotReason, outputs)
}
toolUsePosition := strings.Index(outputText, `"type":"tool_use"`)
messageDeltaPosition := strings.Index(outputText, `"type":"message_delta"`)
if toolUsePosition < 0 || messageDeltaPosition < 0 || toolUsePosition > messageDeltaPosition {
t.Fatalf("terminal tool_use must be emitted before message_delta:\n%s", outputText)
}
}
func TestConvertCodexResponseToClaude_StreamUnresolvedPendingFunctionCallDoesNotForceToolUseStopReason(t *testing.T) {
ctx := context.Background()
originalRequest := []byte(`{"tools":[{"name":"lookup","description":"lookup"}]}`)
var param any
chunks := [][]byte{
[]byte(`data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5"}}`),
[]byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_hidden"},"output_index":1}`),
[]byte(`data: {"type":"response.completed","response":{"stop_reason":"stop","usage":{"input_tokens":1,"output_tokens":1},"output":[]}}`),
}
var outputs [][]byte
for _, chunk := range chunks {
outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, &param)...)
}
outputText := string(bytes.Join(outputs, nil))
if strings.Contains(outputText, `"type":"tool_use"`) {
t.Fatalf("unresolved pending function_call must not emit tool_use:\n%s", outputText)
}
gotReason, ok := findClaudeStreamStopReason(outputs)
if !ok {
t.Fatalf("missing message_delta; outputs=%q", outputs)
}
if gotReason != "end_turn" {
t.Fatalf("stop_reason = %q, want end_turn. Outputs=%q", gotReason, outputs)
}
params, ok := param.(*ConvertCodexResponseToClaudeParams)
if !ok || len(params.PendingFunctionCalls) != 0 || params.LastPendingFunctionCallKey != "" {
t.Fatalf("pending function calls were not cleared: %#v", param)
}
}
func TestConvertCodexResponseToClaude_StreamEmptyOutputUsesOutputItemDoneMessageFallback(t *testing.T) {
ctx := context.Background()
originalRequest := []byte(`{"tools":[]}`)