diff --git a/internal/runtime/executor/antigravity_reasoning_replay.go b/internal/runtime/executor/antigravity_reasoning_replay.go index 248ca7321..2c71803da 100644 --- a/internal/runtime/executor/antigravity_reasoning_replay.go +++ b/internal/runtime/executor/antigravity_reasoning_replay.go @@ -268,6 +268,8 @@ func prepareAntigravityGeminiReasoningReplayPayload(ctx context.Context, modelNa // the pairing diagnosis below with an untyped error. logAntigravityReasoningReplayDegraded(scope, "invalidate", errDelete) } + log.Warnf("antigravity executor: reasoning replay broke Gemini function call pairing (%v); degrading to original payload", errPairing) + return payload, scope, nil } return payload, scope, statusErr{code: http.StatusBadRequest, msg: fmt.Sprintf("antigravity executor: invalid Gemini function call history: %v", errPairing)} } diff --git a/internal/runtime/executor/antigravity_reasoning_replay_test.go b/internal/runtime/executor/antigravity_reasoning_replay_test.go index a0defb71a..cd8481f08 100644 --- a/internal/runtime/executor/antigravity_reasoning_replay_test.go +++ b/internal/runtime/executor/antigravity_reasoning_replay_test.go @@ -1943,3 +1943,31 @@ func TestPrepareAntigravityGeminiReasoningReplayStillRejectsBrokenPairing(t *tes t.Fatalf("error = %v, want structural pairing rejection", errPrepare) } } + +func TestPrepareAntigravityGeminiReasoningReplayDegradesWhenReplayBreaksPairing(t *testing.T) { + internalcache.ClearAntigravityReasoningReplayCache() + t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) + + const model = "gemini-3.6-flash-high" + const args = `{"file_path":"/tmp/a"}` + payload := []byte(`{"sessionId":"sess-pairing-break","request":{"contents":[{"role":"model","parts":[{"thoughtSignature":"skip_thought_signature_validator","functionCall":{"name":"Read","args":` + args + `}}]},{"role":"user","parts":[{"functionResponse":{"name":"Read","response":{"result":"ok"}}}]}]}}`) + payload = normalizeAntigravityGeminiFunctionResponseRoles(payload) + if err := internalsignature.ValidateGeminiFunctionCallPairing(payload); err != nil { + t.Fatalf("original payload pairing invalid: %v", err) + } + + item := []byte(`{"type":"function_call_part","contentIndex":0,"partIndex":0,"targetOccurrence":0,"name":"Write","args":` + args + `,"thoughtSignature":"EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg"}`) + sessionKey := antigravityReasoningReplayScopeFromPayload(model, payload).sessionKey + if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, [][]byte{item}) { + t.Fatal("failed to cache replay item") + } + opts := cliproxyexecutor.Options{} + + out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{Model: model, Payload: payload}, opts, payload) + if errPrepare != nil { + t.Fatalf("prepareAntigravityGeminiReasoningReplayPayload error: %v, want graceful degradation to original payload", errPrepare) + } + if !bytes.Equal(out, payload) { + t.Fatalf("out = %s, want original payload %s", out, payload) + } +} diff --git a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go index eabef8d4f..10f36f37b 100644 --- a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go +++ b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go @@ -596,3 +596,149 @@ func TestConvertOpenAIResponsesRequestToAntigravity_FunctionCallOutputAlternateI t.Fatalf("functionResponse.id = %q, want call_bash_1", responseID) } } + +func TestConvertOpenAIResponsesRequestToAntigravity_InterruptedFunctionCallPreservesPairing(t *testing.T) { + inputJSON := `{ + "model": "gemini-3.8-flash-high", + "input": [ + {"type":"message","role":"user","content":[{"type":"input_text","text":"List the files."}]}, + {"type":"function_call","call_id":"c1","name":"shell","arguments":"{\"command\":[\"ls\"]}"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"Stop, do something else instead."}]}, + {"type":"function_call","call_id":"c2","name":"shell","arguments":"{\"command\":[\"pwd\"]}"}, + {"type":"function_call_output","call_id":"c2","output":"/tmp\n"} + ], + "tools": [{"type":"function","name":"shell","description":"Runs a shell command.","strict":false, + "parameters":{"type":"object","properties":{"command":{"type":"array","items":{"type":"string"}}}, + "required":["command"],"additionalProperties":false}}], + "tool_choice": "auto", + "parallel_tool_calls": false, + "store": false, + "stream": false + }` + + out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3.8-flash-high", []byte(inputJSON), false) + rawRequest := gjson.GetBytes(out, "request").Raw + if errPair := sigcompat.ValidateGeminiFunctionCallPairing([]byte(rawRequest)); errPair != nil { + t.Fatalf("ValidateGeminiFunctionCallPairing failed on Antigravity request: %v; output=%s", errPair, out) + } + + c1Resp := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse") + if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" { + t.Fatalf("unexpected synthesized response for c1: %s", c1Resp.Raw) + } + c2Resp := gjson.GetBytes(out, "request.contents.5.parts.0.functionResponse") + if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" { + t.Fatalf("unexpected real response for c2: %s", c2Resp.Raw) + } +} + +func TestConvertOpenAIResponsesRequestToAntigravity_ParallelInterruptedFunctionCallPreservesPairingAndOrder(t *testing.T) { + inputJSON := `{ + "model": "gemini-3.8-flash-high", + "input": [ + {"type":"message","role":"user","content":[{"type":"input_text","text":"List and print."}]}, + {"type":"function_call","call_id":"c1","name":"shell","arguments":"{\"command\":[\"ls\"]}"}, + {"type":"function_call","call_id":"c2","name":"shell","arguments":"{\"command\":[\"pwd\"]}"}, + {"type":"function_call_output","call_id":"c2","output":"/tmp\n"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"Stop, do something else instead."}]}, + {"type":"function_call","call_id":"c3","name":"shell","arguments":"{\"command\":[\"whoami\"]}"}, + {"type":"function_call_output","call_id":"c3","output":"root\n"} + ], + "tools": [{"type":"function","name":"shell","description":"Runs a shell command.","strict":false, + "parameters":{"type":"object","properties":{"command":{"type":"array","items":{"type":"string"}}}, + "required":["command"],"additionalProperties":false}}], + "tool_choice": "auto", + "parallel_tool_calls": false, + "store": false, + "stream": false + }` + + out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3.8-flash-high", []byte(inputJSON), false) + rawRequest := gjson.GetBytes(out, "request").Raw + if errPair := sigcompat.ValidateGeminiFunctionCallPairing([]byte(rawRequest)); errPair != nil { + t.Fatalf("ValidateGeminiFunctionCallPairing failed on Antigravity request: %v; output=%s", errPair, out) + } + + c1Resp := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse") + if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" { + t.Fatalf("unexpected synthesized response for c1: %s", c1Resp.Raw) + } + c2Resp := gjson.GetBytes(out, "request.contents.2.parts.1.functionResponse") + if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" { + t.Fatalf("unexpected real response for c2: %s", c2Resp.Raw) + } +} + +func TestConvertOpenAIResponsesRequestToAntigravity_TrailingPartialParallelCallsPreservesPairingAndOrder(t *testing.T) { + inputJSON := `{ + "model": "gemini-3.8-flash-high", + "input": [ + {"type":"message","role":"user","content":[{"type":"input_text","text":"List and print."}]}, + {"type":"function_call","call_id":"c1","name":"shell","arguments":"{\"command\":[\"ls\"]}"}, + {"type":"function_call","call_id":"c2","name":"shell","arguments":"{\"command\":[\"pwd\"]}"}, + {"type":"function_call_output","call_id":"c2","output":"/tmp\n"} + ], + "tools": [{"type":"function","name":"shell","description":"Runs a shell command.","strict":false, + "parameters":{"type":"object","properties":{"command":{"type":"array","items":{"type":"string"}}}, + "required":["command"],"additionalProperties":false}}], + "tool_choice": "auto", + "parallel_tool_calls": false, + "store": false, + "stream": false + }` + + out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3.8-flash-high", []byte(inputJSON), false) + rawRequest := gjson.GetBytes(out, "request").Raw + if errPair := sigcompat.ValidateGeminiFunctionCallPairing([]byte(rawRequest)); errPair != nil { + t.Fatalf("ValidateGeminiFunctionCallPairing failed on Antigravity trailing partial parallel request: %v; output=%s", errPair, out) + } + + c1Resp := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse") + if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" { + t.Fatalf("unexpected synthesized response for c1: %s", c1Resp.Raw) + } + c2Resp := gjson.GetBytes(out, "request.contents.2.parts.1.functionResponse") + if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" { + t.Fatalf("unexpected real response for c2: %s", c2Resp.Raw) + } +} + +func TestConvertOpenAIResponsesRequestToAntigravity_InterruptedMessageBeforeRealOutputPreservesPairingAndOrder(t *testing.T) { + inputJSON := `{ + "model": "gemini-3.8-flash-high", + "input": [ + {"type":"message","role":"user","content":[{"type":"input_text","text":"List and print."}]}, + {"type":"function_call","call_id":"c1","name":"shell","arguments":"{\"command\":[\"ls\"]}"}, + {"type":"function_call","call_id":"c2","name":"shell","arguments":"{\"command\":[\"pwd\"]}"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"Stop, do something else instead."}]}, + {"type":"function_call_output","call_id":"c2","output":"/tmp\n"} + ], + "tools": [{"type":"function","name":"shell","description":"Runs a shell command.","strict":false, + "parameters":{"type":"object","properties":{"command":{"type":"array","items":{"type":"string"}}}, + "required":["command"],"additionalProperties":false}}], + "tool_choice": "auto", + "parallel_tool_calls": false, + "store": false, + "stream": false + }` + + out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3.8-flash-high", []byte(inputJSON), false) + rawRequest := gjson.GetBytes(out, "request").Raw + if errPair := sigcompat.ValidateGeminiFunctionCallPairing([]byte(rawRequest)); errPair != nil { + t.Fatalf("ValidateGeminiFunctionCallPairing failed on Antigravity interrupted message before real output request: %v; output=%s", errPair, out) + } + + // The user message precedes the completed tool response turn + stopText := gjson.GetBytes(out, "request.contents.2.parts.0.text").String() + if stopText != "Stop, do something else instead." { + t.Fatalf("unexpected text in request.contents[2]: %q", stopText) + } + c1Resp := gjson.GetBytes(out, "request.contents.3.parts.0.functionResponse") + if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" { + t.Fatalf("unexpected synthesized response for c1: %s", c1Resp.Raw) + } + c2Resp := gjson.GetBytes(out, "request.contents.3.parts.1.functionResponse") + if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" { + t.Fatalf("unexpected real response for c2: %s", c2Resp.Raw) + } +} diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go index bd06728c5..60f225a05 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go @@ -143,11 +143,29 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte hasEncounteredConversation = true if _, isAssistantOutput := openAIResponsesAssistantVisibleText(item); !isAssistantOutput { + if len(pendingFunctionCallIDs) > 0 { + anyHasFutureOutput := false + for _, callID := range pendingFunctionCallIDs { + if responsesHasMatchingOutput(normalized[i:], callID) { + anyHasFutureOutput = true + break + } + } + if !anyHasFutureOutput { + var synthesizedParts [][]byte + for _, callID := range pendingFunctionCallIDs { + synthesizedParts = append(synthesizedParts, buildOpenAIResponsesSynthesizedFunctionResponsePart(callID, functionNamesByCallID)) + } + if len(synthesizedParts) > 0 { + contentItems = append(contentItems, geminiContent("user", synthesizedParts)) + } + pendingFunctionCallIDs = nil + } + } if len(pendingDeveloperParts) > 0 { contentItems = append(contentItems, geminiContent("user", pendingDeveloperParts)) pendingDeveloperParts = nil } - pendingFunctionCallIDs = nil } // Handle regular messages @@ -286,15 +304,57 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte case "function_call_output", "custom_tool_call_output": hasEncounteredConversation = true - orderedOutputs, consumedIndexes, remainingPending := collectOpenAIResponsesFunctionCallOutputs(normalized, i, pendingFunctionCallIDs) - pendingFunctionCallIDs = remainingPending + orderedOutputs, consumedIndexes, _ := collectOpenAIResponsesFunctionCallOutputs(normalized, i, pendingFunctionCallIDs) for consumedIndex := range consumedIndexes { consumedFunctionOutputIndexes[consumedIndex] = true } - responseParts := make([][]byte, 0, len(orderedOutputs)) - for _, output := range orderedOutputs { - responseParts = append(responseParts, buildOpenAIResponsesFunctionResponseParts(output, functionNamesByCallID)...) + end := i + len(consumedIndexes) + hasSubsequent := responsesHasSubsequentTurn(normalized[end:]) + + outputByCallID := make(map[string]gjson.Result) + var extraOutputs []gjson.Result + anyMatched := false + for _, out := range orderedOutputs { + id := extractOpenAIResponsesCallID(out) + if id != "" { + outputByCallID[id] = out + } else { + extraOutputs = append(extraOutputs, out) + } } + for _, pendingID := range pendingFunctionCallIDs { + if _, ok := outputByCallID[pendingID]; ok { + anyMatched = true + break + } + } + + responseParts := make([][]byte, 0, len(pendingFunctionCallIDs)+len(extraOutputs)) + stillPending := make([]string, 0, len(pendingFunctionCallIDs)) + + for _, pendingID := range pendingFunctionCallIDs { + if out, ok := outputByCallID[pendingID]; ok { + responseParts = append(responseParts, buildOpenAIResponsesFunctionResponseParts(out, functionNamesByCallID)...) + delete(outputByCallID, pendingID) + } else if (hasSubsequent || anyMatched) && !responsesHasMatchingOutput(normalized[end:], pendingID) { + responseParts = append(responseParts, buildOpenAIResponsesSynthesizedFunctionResponsePart(pendingID, functionNamesByCallID)) + } else { + stillPending = append(stillPending, pendingID) + } + } + + for _, out := range orderedOutputs { + id := extractOpenAIResponsesCallID(out) + if _, remaining := outputByCallID[id]; remaining { + responseParts = append(responseParts, buildOpenAIResponsesFunctionResponseParts(out, functionNamesByCallID)...) + delete(outputByCallID, id) + } + } + for _, out := range extraOutputs { + responseParts = append(responseParts, buildOpenAIResponsesFunctionResponseParts(out, functionNamesByCallID)...) + } + + pendingFunctionCallIDs = stillPending if len(responseParts) > 0 { contentItems = append(contentItems, geminiContent("user", responseParts)) } @@ -908,6 +968,48 @@ func extractOpenAIResponsesCallID(node gjson.Result) string { return translatorcommon.ExtractResponsesCallID(node) } +func buildOpenAIResponsesSynthesizedFunctionResponsePart(callID string, functionNamesByCallID map[string]string) []byte { + functionName := "unknown" + if matchedName, ok := functionNamesByCallID[callID]; ok && matchedName != "" { + functionName = matchedName + } + functionResponse := []byte(`{"functionResponse":{"name":"","response":{"result":"call interrupted, no output"}}}`) + functionResponse, _ = sjson.SetBytes(functionResponse, "functionResponse.name", util.SanitizeFunctionName(functionName)) + if callID != "" { + functionResponse, _ = sjson.SetBytes(functionResponse, "functionResponse.id", callID) + } + return functionResponse +} + +func responsesHasMatchingOutput(items []gjson.Result, callID string) bool { + if callID == "" { + return false + } + for _, item := range items { + typ := item.Get("type").String() + if typ == "function_call_output" || typ == "custom_tool_call_output" { + if extractOpenAIResponsesCallID(item) == callID { + return true + } + } + } + return false +} + +func responsesHasSubsequentTurn(items []gjson.Result) bool { + for _, item := range items { + typ := item.Get("type").String() + role := item.Get("role").String() + if typ == "message" || (typ == "" && role != "") { + return true + } + if typ == "function_call" || typ == "custom_tool_call" { + return true + } + } + return false +} + func buildOpenAIResponsesFunctionResponseParts(item gjson.Result, functionNamesByCallID map[string]string) [][]byte { callID := extractOpenAIResponsesCallID(item) functionName := "unknown" diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go index ecb27f3f2..4c7716267 100644 --- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go +++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go @@ -2283,3 +2283,122 @@ func TestConvertOpenAIResponsesRequestToGemini_AllPendingCallsReservedByFutureEx } } } + +func TestConvertOpenAIResponsesRequestToGemini_InterruptedFunctionCallPreservesPairing(t *testing.T) { + inputJSON := `{ + "model": "gemini-3.8-flash-high", + "input": [ + {"type":"message","role":"user","content":[{"type":"input_text","text":"List the files."}]}, + {"type":"function_call","call_id":"c1","name":"shell","arguments":"{\"command\":[\"ls\"]}"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"Stop, do something else instead."}]}, + {"type":"function_call","call_id":"c2","name":"shell","arguments":"{\"command\":[\"pwd\"]}"}, + {"type":"function_call_output","call_id":"c2","output":"/tmp\n"} + ] + }` + + result := ConvertOpenAIResponsesRequestToGemini("gemini-3.8-flash-high", []byte(inputJSON), false) + if err := internalsignature.ValidateGeminiFunctionCallPairing(result); err != nil { + t.Fatalf("ValidateGeminiFunctionCallPairing failed on Gemini request: %v; output=%s", err, result) + } + + // Verify synthesized response for c1 + c1Resp := gjson.GetBytes(result, "contents.2.parts.0.functionResponse") + if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" { + t.Fatalf("unexpected synthesized response for c1: %s", c1Resp.Raw) + } + // Verify real response for c2 + c2Resp := gjson.GetBytes(result, "contents.5.parts.0.functionResponse") + if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" { + t.Fatalf("unexpected real response for c2: %s", c2Resp.Raw) + } +} + +func TestConvertOpenAIResponsesRequestToGemini_ParallelInterruptedFunctionCallPreservesPairingAndOrder(t *testing.T) { + inputJSON := `{ + "model": "gemini-3.8-flash-high", + "input": [ + {"type":"message","role":"user","content":[{"type":"input_text","text":"List and print."}]}, + {"type":"function_call","call_id":"c1","name":"shell","arguments":"{\"command\":[\"ls\"]}"}, + {"type":"function_call","call_id":"c2","name":"shell","arguments":"{\"command\":[\"pwd\"]}"}, + {"type":"function_call_output","call_id":"c2","output":"/tmp\n"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"Stop, do something else instead."}]}, + {"type":"function_call","call_id":"c3","name":"shell","arguments":"{\"command\":[\"whoami\"]}"}, + {"type":"function_call_output","call_id":"c3","output":"root\n"} + ] + }` + + result := ConvertOpenAIResponsesRequestToGemini("gemini-3.8-flash-high", []byte(inputJSON), false) + if err := internalsignature.ValidateGeminiFunctionCallPairing(result); err != nil { + t.Fatalf("ValidateGeminiFunctionCallPairing failed on parallel interrupted request: %v; output=%s", err, result) + } + + // In the response turn for [c1, c2], c1 must be first (synthesized) and c2 must be second (real) + c1Resp := gjson.GetBytes(result, "contents.2.parts.0.functionResponse") + if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" { + t.Fatalf("unexpected response part 0 for c1: %s", c1Resp.Raw) + } + c2Resp := gjson.GetBytes(result, "contents.2.parts.1.functionResponse") + if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" { + t.Fatalf("unexpected response part 1 for c2: %s", c2Resp.Raw) + } +} + +func TestConvertOpenAIResponsesRequestToGemini_TrailingPartialParallelCallsPreservesPairingAndOrder(t *testing.T) { + inputJSON := `{ + "model": "gemini-3.8-flash-high", + "input": [ + {"type":"message","role":"user","content":[{"type":"input_text","text":"List and print."}]}, + {"type":"function_call","call_id":"c1","name":"shell","arguments":"{\"command\":[\"ls\"]}"}, + {"type":"function_call","call_id":"c2","name":"shell","arguments":"{\"command\":[\"pwd\"]}"}, + {"type":"function_call_output","call_id":"c2","output":"/tmp\n"} + ] + }` + + result := ConvertOpenAIResponsesRequestToGemini("gemini-3.8-flash-high", []byte(inputJSON), false) + if err := internalsignature.ValidateGeminiFunctionCallPairing(result); err != nil { + t.Fatalf("ValidateGeminiFunctionCallPairing failed on trailing partial parallel request: %v; output=%s", err, result) + } + + // In the response turn for [c1, c2], c1 must be first (synthesized) and c2 must be second (real) + c1Resp := gjson.GetBytes(result, "contents.2.parts.0.functionResponse") + if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" { + t.Fatalf("unexpected response part 0 for c1: %s", c1Resp.Raw) + } + c2Resp := gjson.GetBytes(result, "contents.2.parts.1.functionResponse") + if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" { + t.Fatalf("unexpected response part 1 for c2: %s", c2Resp.Raw) + } +} + +func TestConvertOpenAIResponsesRequestToGemini_InterruptedMessageBeforeRealOutputPreservesPairingAndOrder(t *testing.T) { + inputJSON := `{ + "model": "gemini-3.8-flash-high", + "input": [ + {"type":"message","role":"user","content":[{"type":"input_text","text":"List and print."}]}, + {"type":"function_call","call_id":"c1","name":"shell","arguments":"{\"command\":[\"ls\"]}"}, + {"type":"function_call","call_id":"c2","name":"shell","arguments":"{\"command\":[\"pwd\"]}"}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"Stop, do something else instead."}]}, + {"type":"function_call_output","call_id":"c2","output":"/tmp\n"} + ] + }` + + result := ConvertOpenAIResponsesRequestToGemini("gemini-3.8-flash-high", []byte(inputJSON), false) + if err := internalsignature.ValidateGeminiFunctionCallPairing(result); err != nil { + t.Fatalf("ValidateGeminiFunctionCallPairing failed on interrupted message before real output request: %v; output=%s", err, result) + } + + // The user message precedes the completed tool response turn + stopText := gjson.GetBytes(result, "contents.2.parts.0.text").String() + if stopText != "Stop, do something else instead." { + t.Fatalf("unexpected text in contents[2]: %q", stopText) + } + // In the response turn for [c1, c2], c1 must be first (synthesized) and c2 must be second (real) + c1Resp := gjson.GetBytes(result, "contents.3.parts.0.functionResponse") + if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" { + t.Fatalf("unexpected response part 0 for c1: %s", c1Resp.Raw) + } + c2Resp := gjson.GetBytes(result, "contents.3.parts.1.functionResponse") + if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" { + t.Fatalf("unexpected response part 1 for c2: %s", c2Resp.Raw) + } +}