diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go index ace43013..b60e7c3f 100644 --- a/internal/translator/codex/claude/codex_claude_response.go +++ b/internal/translator/codex/claude/codex_claude_response.go @@ -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 { diff --git a/internal/translator/codex/claude/codex_claude_response_test.go b/internal/translator/codex/claude/codex_claude_response_test.go index c4c82862..adae5148 100644 --- a/internal/translator/codex/claude/codex_claude_response_test.go +++ b/internal/translator/codex/claude/codex_claude_response_test.go @@ -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, ¶m)...) + } + + 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, ¶m)...) + } + + 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, ¶m)...) + } + + 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, ¶m)...) + } + 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, ¶m)...) + } + 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":[]}`)