fix(gemini): preserve function call pairing for interrupted calls and replay failures

- Synthesize function response parts for interrupted or missing OpenAI Responses tool calls to maintain strict Gemini call-response pairing.
- Preserve function response ordering matching pending call IDs across parallel and partial tool execution turns.
- Degrade gracefully to the original request payload when Antigravity reasoning replay breaks Gemini function call pairing.

Closes: #5682
This commit is contained in:
Luis Pater
2026-09-10 20:01:39 +08:00
parent 9fad505505
commit b8e6ec0ae7
5 changed files with 403 additions and 6 deletions

View File

@@ -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)}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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"

View File

@@ -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)
}
}