Files
CLIProxyAPI/internal/runtime/executor/antigravity_reasoning_replay_test.go
sususu 20784c67ff fix(home): use Home's CAS command instead of EVAL for replay compare-and-swap
Client.KVCompareAndSwap sent Redis EVAL with a Lua script, but the Home RESP
subset does not implement EVAL, so Home replied "ERR unknown command 'eval'".
That broke the Antigravity and Codex reasoning replay caches in Home mode.

Switch the transport to Home's dedicated CAS command:

    CAS <key> <expected-exists 0|1> <expected-value> <new-value> [PX <ttl-ms>]

The semantics match the old script argument for argument, so KVCompareAndSwap's
signature and all its callers are unchanged. Omitting PX when ttl <= 0 mirrors
the script's SET-without-PX branch, which clears the TTL.

Deployments that predate CAS reject the command. Detect that by matching the
unsupported-command error, latch ErrCompareAndSwapUnsupported for the client
lifetime so later calls skip the round trip, and warn exactly once. The latch is
deliberately not carried across NewLifetime, so a Home upgrade takes effect on
the next reconnect rather than requiring a CPA restart.

Also stop replay-state failures from failing the request. A bare replay error has
no HTTP status, so resultErrorFromError does not classify it as request-scoped and
MarkResult marks the credential unavailable for that model, walking every
candidate credential until alias resolution has nothing left and returns 503. A
ledger miss is already a tolerated outcome, so degrade to "no replay this turn"
instead. The pairing failure still returns its 400.

Verified end to end against a real Home over RESP/mTLS on PostgreSQL with
Antigravity OAuth credentials: patched Home recreates the replay row through CAS
with its TTL, while a pre-CAS Home latches once, keeps returning 200 instead of
503, and records no credential error attributable to the replay path.

Refs router-for-me/CLIProxyAPIHome#79
2026-07-29 09:44:05 +08:00

1797 lines
112 KiB
Go

package executor
import (
"context"
"fmt"
"net/http"
"strings"
"testing"
internalcache "github.com/router-for-me/CLIProxyAPI/v7/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
homekv "github.com/router-for-me/CLIProxyAPI/v7/internal/home"
internalsignature "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
"github.com/tidwall/gjson"
)
func TestAntigravityReasoningReplayAccumulatorMultiToolSSEChunks(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
requestPayload := []byte(`{"sessionId":"sess-1","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`)
scope := antigravityReasoningReplayScope{modelName: "gemini-3-flash-agent", sessionKey: "session:sess-1"}
acc := newAntigravityReasoningReplayAccumulator(scope, requestPayload)
if acc == nil {
t.Fatal("accumulator is nil")
}
if acc.contentIndex != 1 || acc.nextPartIndex != 0 {
t.Fatalf("pending model slot = %d/%d, want 1/0", acc.contentIndex, acc.nextPartIndex)
}
line1 := []byte(`data: {"response":{"candidates":[{"content":{"parts":[{"thoughtSignature":"sig-first","functionCall":{"name":"Read","args":{"file_path":"/a"},"id":"id1"}}]}}]}}`)
line2 := []byte(`data: {"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"Read","args":{"file_path":"/b"},"id":"id2"}}]},"finishReason":"STOP"}]}}`)
acc.ObserveSSELine(line1)
acc.ObserveSSELine(line2)
acc.Commit(context.Background())
items, ok := internalcache.GetAntigravityReasoningReplayItems("gemini-3-flash-agent", "session:sess-1")
if !ok || len(items) != 2 {
t.Fatalf("cached items = %v ok=%v, want 2 items", len(items), ok)
}
pi0 := int(gjson.GetBytes(items[0], "partIndex").Int())
pi1 := int(gjson.GetBytes(items[1], "partIndex").Int())
if pi0 != 0 || pi1 != 1 {
t.Fatalf("partIndex = %d,%d, want 0,1", pi0, pi1)
}
if got := gjson.GetBytes(items[0], "thoughtSignature").String(); got != "sig-first" {
t.Fatalf("first sig = %q", got)
}
}
func TestPrepareAntigravityGeminiReasoningReplayPayloadToleratesHomeKVFailure(t *testing.T) {
// An enabled Home client with no heartbeat makes CurrentKVClient report home
// mode with an error, which is how every Home-side KV failure reaches the
// replay cache — including the "unknown command 'cas'" case from an older
// Home. The request must proceed without replay rather than fail, because a
// bare executor error would make MarkResult mark the credential unavailable.
homekv.SetCurrent(homekv.New(config.HomeConfig{Enabled: true}))
t.Cleanup(func() { homekv.SetCurrent(nil) })
payload := []byte(`{"sessionId":"kv-failure","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3-flash-agent", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload)
if errPrepare != nil {
t.Fatalf("prepare error = %v, want nil so the request proceeds without replay", errPrepare)
}
if len(out) == 0 {
t.Fatal("prepare returned an empty payload")
}
if got := gjson.GetBytes(out, "sessionId").String(); got != "kv-failure" {
t.Fatalf("payload sessionId = %q, want kv-failure", got)
}
}
func TestPrepareAntigravityGeminiReasoningReplayPayloadRejectsToolOutputsAcrossUserBoundary(t *testing.T) {
payload := []byte(`{"sessionId":"tool-output-boundary","request":{"contents":[{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"run","args":{}}},{"functionCall":{"id":"call-2","name":"run","args":{}}}]},{"role":"model","parts":[{"functionResponse":{"id":"call-1","name":"run","response":{"result":"one"}}}]},{"role":"user","parts":[{"text":"boundary"}]},{"role":"model","parts":[{"functionResponse":{"id":"call-2","name":"run","response":{"result":"two"}}}]}]}}`)
_, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload)
if errPrepare == nil {
t.Fatal("invalid tool output history was not rejected")
}
status, ok := errPrepare.(statusErr)
if !ok || status.code != http.StatusBadRequest {
t.Fatalf("prepare error = %#v, want local 400", errPrepare)
}
}
func TestPrepareAntigravityGeminiReasoningReplayPayloadKeepsCacheForAlreadyInvalidToolHistory(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const model, sessionKey = "gemini-3.6-flash-high", "session:invalid-injected-tool-history"
item := []byte(`{"type":"function_call_part","contentIndex":0,"partIndex":0,"call_id":"call-2","name":"run","args":{},"thoughtSignature":"injected-tool-signature-123456789"}`)
if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, [][]byte{item}) {
t.Fatal("cache write failed")
}
payload := []byte(`{"sessionId":"invalid-injected-tool-history","request":{"contents":[{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"run","args":{}}}]},{"role":"model","parts":[{"functionResponse":{"id":"call-2","name":"run","response":{"result":"two"}}}]}]}}`)
_, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload)
if errPrepare == nil {
t.Fatal("invalid replay-injected history was not rejected")
}
if _, found := internalcache.GetAntigravityReasoningReplayItems(model, sessionKey); !found {
t.Fatal("already-invalid client history cleared replay state")
}
}
func TestPrepareAntigravityGeminiReasoningReplayPayloadKeepsCacheForClientMalformedHistory(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const model, sessionKey = "gemini-3.6-flash-high", "session:client-malformed-history"
payload := []byte(`{"sessionId":"client-malformed-history","request":{"contents":[{"role":"model","parts":[{"text":"answer"}]},{"role":"model","parts":[{"functionResponse":{"id":"orphan","name":"run","response":{"result":"bad"}}}]}]}}`)
kind, fingerprint := antigravityReplayPartFingerprint(gjson.Parse(`{"text":"answer"}`))
item := buildAntigravityThoughtSignatureItem(0, 0, "valid-cache-signature-123456789", kind, fingerprint)
item = antigravitySetReplayItemContextHash(item, payload, 0)
if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, [][]byte{item}) {
t.Fatal("cache write failed")
}
_, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload)
if errPrepare == nil {
t.Fatal("client-malformed history was not rejected")
}
if _, found := internalcache.GetAntigravityReasoningReplayItems(model, sessionKey); !found {
t.Fatal("client-malformed history cleared unrelated valid replay state")
}
}
func TestPrepareAntigravityGeminiReasoningReplayPayloadInjectsCachedToolPart(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
item := []byte(`{"type":"function_call_part","contentIndex":1,"partIndex":0,"name":"Read","call_id":"id1","args":{"file_path":"/a"},"thoughtSignature":"sig-first"}`)
if !internalcache.CacheAntigravityReasoningReplayItems("gemini-3-flash-agent", "session:sess-2", [][]byte{item}) {
t.Fatal("cache write failed")
}
req := cliproxyexecutor.Request{}
opts := cliproxyexecutor.Options{}
payload := []byte(`{"sessionId":"sess-2","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]},{"role":"user","parts":[{"functionResponse":{"id":"id1","name":"Read","response":{"result":"ok"}}}]}]}}`)
out, scope, err := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3-flash-agent", req, opts, payload)
if err != nil {
t.Fatalf("prepare error: %v", err)
}
if !scope.valid() {
t.Fatal("scope invalid")
}
if gjson.GetBytes(out, "request.contents.1.role").String() != "model" {
t.Fatalf("functionCall replay must be model role at [1], got %s", string(out))
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "sig-first" {
t.Fatalf("thoughtSignature = %q, want sig-first", got)
}
if !gjson.GetBytes(out, "request.contents.1.parts.0.functionCall").Exists() {
t.Fatalf("functionCall not injected: %s", string(out))
}
if !gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse").Exists() {
t.Fatalf("functionResponse should follow model functionCall at [2]: %s", string(out))
}
}
func TestPrepareAntigravityGeminiReasoningReplayPayloadSanitizesInsertedUnsignedToolPart(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
item := []byte(`{"type":"function_call_part","contentIndex":1,"partIndex":0,"name":"Read","call_id":"id1","args":{"file_path":"/a"}}`)
if !internalcache.CacheAntigravityReasoningReplayItems("gemini-3-flash-agent", "session:sess-unsigned-replay", [][]byte{item}) {
t.Fatal("cache write failed")
}
payload := []byte(`{"sessionId":"sess-unsigned-replay","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]},{"role":"user","parts":[{"functionResponse":{"id":"id1","name":"Read","response":{"result":"ok"}}}]}]}}`)
payload = sanitizeAntigravityGeminiRequestSignatures("gemini-3-flash-agent", payload)
out, _, err := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3-flash-agent", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload)
if err != nil {
t.Fatalf("prepare error: %v", err)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "skip_thought_signature_validator" {
t.Fatalf("inserted first synthetic functionCall signature = %q, want bypass sentinel; output=%s", got, out)
}
if got := gjson.GetBytes(out, "request.contents.2.role").String(); got != "model" {
t.Fatalf("replayed functionResponse role = %q, want native model role; output=%s", got, out)
}
}
func TestPrepareAntigravityGeminiReasoningReplayInsertsBeforeModelFunctionResponse(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
item := []byte(`{"type":"function_call_part","contentIndex":1,"partIndex":0,"name":"Read","call_id":"id1","args":{"file_path":"/a"},"thoughtSignature":"sig-first"}`)
internalcache.CacheAntigravityReasoningReplayItems("gemini-3-flash-agent", "session:sess-3", [][]byte{item})
payload := []byte(`{"sessionId":"sess-3","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]},{"role":"model","parts":[{"functionResponse":{"id":"id1","name":"Read","response":{"result":"ok"}}}]}]}}`)
out, _, err := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3-flash-agent", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload)
if err != nil {
t.Fatal(err)
}
if !gjson.GetBytes(out, "request.contents.1.parts.0.functionCall").Exists() || gjson.GetBytes(out, "request.contents.1.role").String() != "model" {
t.Fatalf("want model functionCall at [1]: %s", string(out))
}
if !gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse").Exists() {
t.Fatalf("functionResponse should be at [2]: %s", string(out))
}
}
func TestMergeAntigravityFunctionCallPartReplayMergesSignatureIntoExistingFunctionCall(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
item := []byte(`{"type":"function_call_part","contentIndex":1,"partIndex":0,"name":"Read","call_id":"id1","args":{"file_path":"/a"},"thoughtSignature":"sig-first"}`)
internalcache.CacheAntigravityReasoningReplayItems("gemini-3-flash-agent", "session:sess-merge", [][]byte{item})
payload := []byte(`{"sessionId":"sess-merge","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]},{"role":"model","parts":[{"functionCall":{"id":"id1","name":"Read","args":{"file_path":"/a"}}}]},{"role":"user","parts":[{"functionResponse":{"id":"id1","name":"Read","response":{"result":"ok"}}}]}]}}`)
out, _, err := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3-flash-agent", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload)
if err != nil {
t.Fatal(err)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "sig-first" {
t.Fatalf("thoughtSignature = %q, want sig-first; body=%s", got, out)
}
}
func TestPrepareAntigravityGeminiReasoningReplayPayloadDropsStaleThoughtSignature(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
item := []byte(`{"type":"thought_signature","contentIndex":8,"partIndex":3,"thoughtSignature":"stale-thought-sig-ok12"}`)
internalcache.CacheAntigravityReasoningReplayItems("gemini-3-flash-agent", "session:sess-stale-text", [][]byte{item})
payload := []byte(`{"sessionId":"sess-stale-text","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]},{"role":"model","parts":[{"text":"visible answer"}]},{"role":"user","parts":[{"text":"next"}]}]}}`)
out, _, err := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3-flash-agent", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload)
if err != nil {
t.Fatal(err)
}
parts := gjson.GetBytes(out, "request.contents.1.parts").Array()
if len(parts) != 1 {
t.Fatalf("parts length = %d, want unchanged single text part; body=%s", len(parts), out)
}
if got := parts[0].Get("text").String(); got != "visible answer" {
t.Fatalf("text part = %q, want visible answer; body=%s", got, out)
}
if got := parts[0].Get("thoughtSignature").String(); got != "" {
t.Fatalf("stale thoughtSignature must not move to another turn, got %q; body=%s", got, out)
}
}
func TestAntigravityReasoningReplayAccumulatesCompleteTextSignatureChain(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const (
model = "gemini-3.6-flash-high"
sessionKey = "session:chain-session"
sig1 = "native-signature-turn-one-123456"
sig2 = "native-signature-turn-two-123456"
)
scope := antigravityReasoningReplayScope{modelName: model, sessionKey: sessionKey}
request1 := []byte(`{"sessionId":"chain-session","request":{"contents":[{"role":"user","parts":[{"text":"turn one"}]}]}}`)
acc1 := newAntigravityReasoningReplayAccumulator(scope, request1)
acc1.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"answer-"}]}}]}}`))
acc1.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"one"}]}}]}}`))
acc1.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"` + sig1 + `"}]},"finishReason":"STOP"}]}}`))
acc1.Commit(context.Background())
request2 := []byte(`{"sessionId":"chain-session","request":{"contents":[{"role":"user","parts":[{"text":"turn one"}]},{"role":"model","parts":[{"text":"answer-one"}]},{"role":"user","parts":[{"text":"turn two"}]}]}}`)
prepared2, _, errPrepare2 := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2)
if errPrepare2 != nil {
t.Fatal(errPrepare2)
}
if got := gjson.GetBytes(prepared2, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("turn 2 signature = %q, want %q; body=%s", got, sig1, prepared2)
}
if got := gjson.GetBytes(prepared2, "request.contents.1.parts.#").Int(); got != 1 {
t.Fatalf("turn 2 must attach signature in place, parts=%d; body=%s", got, prepared2)
}
acc2 := newAntigravityReasoningReplayAccumulator(scope, prepared2)
acc2.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"answer-two"}]}}]}}`))
acc2.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"` + sig2 + `"}]},"finishReason":"STOP"}]}}`))
acc2.Commit(context.Background())
items, ok := internalcache.GetAntigravityReasoningReplayItems(model, sessionKey)
if !ok || len(items) != 2 {
t.Fatalf("cached chain length = %d ok=%v, want 2", len(items), ok)
}
request3 := []byte(`{"sessionId":"chain-session","request":{"contents":[{"role":"user","parts":[{"text":"turn one"}]},{"role":"model","parts":[{"text":"answer-one"}]},{"role":"user","parts":[{"text":"turn two"}]},{"role":"model","parts":[{"text":"answer-two"}]},{"role":"user","parts":[{"text":"turn three"}]}]}}`)
prepared3, _, errPrepare3 := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request3)
if errPrepare3 != nil {
t.Fatal(errPrepare3)
}
if got := gjson.GetBytes(prepared3, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("turn 3 first signature = %q, want %q; body=%s", got, sig1, prepared3)
}
if got := gjson.GetBytes(prepared3, "request.contents.3.parts.0.thoughtSignature").String(); got != sig2 {
t.Fatalf("turn 3 second signature = %q, want %q; body=%s", got, sig2, prepared3)
}
if got := len(gjson.GetBytes(prepared3, "request.contents.1.parts").Array()) + len(gjson.GetBytes(prepared3, "request.contents.3.parts").Array()); got != 2 {
t.Fatalf("signatures must remain attached to native text parts, total parts=%d; body=%s", got, prepared3)
}
}
func TestAntigravityReasoningReplaySplitsConsecutiveSignedTextSegments(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const (
sig1 = "consecutive-text-signature-one-123456"
sig2 = "consecutive-text-signature-two-123456"
)
scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:consecutive-signed-text"}
request1 := []byte(`{"sessionId":"consecutive-signed-text","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]}]}}`)
acc := newAntigravityReasoningReplayAccumulator(scope, request1)
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"a"}]}}]}}`))
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"b","thoughtSignature":"` + sig1 + `"}]}}]}}`))
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"c","thoughtSignature":"` + sig2 + `"}]},"finishReason":"STOP"}]}}`))
acc.Commit(context.Background())
request2 := []byte(`{"sessionId":"consecutive-signed-text","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]},{"role":"model","parts":[{"text":"ab"},{"text":"c"}]},{"role":"user","parts":[{"text":"next"}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2)
if errPrepare != nil {
t.Fatal(errPrepare)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("first consecutive text signature = %q, want %q; body=%s", got, sig1, out)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig2 {
t.Fatalf("second consecutive text signature = %q, want %q; body=%s", got, sig2, out)
}
}
func TestAntigravityReasoningReplaySignatureOnlyCarrierEndsTextSegment(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const (
sig1 = "trailing-text-signature-one-123456"
sig2 = "trailing-text-signature-two-123456"
)
scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:trailing-signed-text"}
request1 := []byte(`{"sessionId":"trailing-signed-text","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]}]}}`)
acc := newAntigravityReasoningReplayAccumulator(scope, request1)
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"a"}]}}]}}`))
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"` + sig1 + `"}]}}]}}`))
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"b"}]}}]}}`))
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"c","thoughtSignature":"` + sig2 + `"}]},"finishReason":"STOP"}]}}`))
acc.Commit(context.Background())
request2 := []byte(`{"sessionId":"trailing-signed-text","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]},{"role":"model","parts":[{"text":"a"},{"text":"bc"}]},{"role":"user","parts":[{"text":"next"}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2)
if errPrepare != nil {
t.Fatal(errPrepare)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("first trailing text signature = %q, want %q; body=%s", got, sig1, out)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig2 {
t.Fatalf("second trailing text signature = %q, want %q; body=%s", got, sig2, out)
}
}
func TestAntigravityReasoningReplayDropsUnmatchedConsecutiveCarrier(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const (
sig1 = "matched-detached-signature-one-123456"
sig2 = "unmatched-detached-signature-two-123456"
sig3 = "matched-text-signature-three-123456"
)
scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:unmatched-detached"}
request1 := []byte(`{"sessionId":"unmatched-detached","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]}]}}`)
acc := newAntigravityReasoningReplayAccumulator(scope, request1)
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"a"},{"text":"","thoughtSignature":"` + sig1 + `"},{"text":"","thoughtSignature":"` + sig2 + `"},{"text":"b","thoughtSignature":"` + sig3 + `"}]},"finishReason":"STOP"}]}}`))
acc.Commit(context.Background())
request2 := []byte(`{"sessionId":"unmatched-detached","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]},{"role":"model","parts":[{"text":"a"},{"text":"b"}]},{"role":"user","parts":[{"text":"next"}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2)
if errPrepare != nil {
t.Fatal(errPrepare)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("first signature = %q, want %q; body=%s", got, sig1, out)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig3 {
t.Fatalf("second signature = %q, want %q; body=%s", got, sig3, out)
}
if strings.Contains(string(out), sig2) {
t.Fatalf("unmatched carrier must not replace a semantic signature; body=%s", out)
}
}
func TestAntigravityReasoningReplayDuplicateCarrierDoesNotSplitSegment(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const (
sig1 = "duplicate-thought-signature-one-123456"
sig2 = "following-thought-signature-two-123456"
)
scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:duplicate-carrier"}
request1 := []byte(`{"sessionId":"duplicate-carrier","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]}]}}`)
acc := newAntigravityReasoningReplayAccumulator(scope, request1)
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"a","thought":true},{"text":"","thought":true,"thoughtSignature":"` + sig1 + `"},{"text":"b","thought":true},{"text":"","thought":true,"thoughtSignature":"` + sig1 + `"},{"text":"c","thought":true,"thoughtSignature":"` + sig2 + `"}]},"finishReason":"STOP"}]}}`))
acc.Commit(context.Background())
request2 := []byte(`{"sessionId":"duplicate-carrier","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]},{"role":"model","parts":[{"text":"a","thought":true},{"text":"bc","thought":true}]},{"role":"user","parts":[{"text":"next"}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2)
if errPrepare != nil {
t.Fatal(errPrepare)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("first thought signature = %q, want %q; body=%s", got, sig1, out)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig2 {
t.Fatalf("second thought signature = %q, want %q; body=%s", got, sig2, out)
}
}
func TestAntigravityReasoningReplayDirectTextSignatureWinsOverUnboundPrefix(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const (
prefixSig = "unbound-prefix-signature-123456"
directSig = "direct-thought-signature-123456"
)
scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:direct-over-prefix"}
request1 := []byte(`{"sessionId":"direct-over-prefix","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]}]}}`)
acc := newAntigravityReasoningReplayAccumulator(scope, request1)
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thought":true,"thoughtSignature":"` + prefixSig + `"},{"text":"hidden","thought":true,"thoughtSignature":"` + directSig + `"}]},"finishReason":"STOP"}]}}`))
acc.Commit(context.Background())
request2 := []byte(`{"sessionId":"direct-over-prefix","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]},{"role":"model","parts":[{"text":"hidden","thought":true}]},{"role":"user","parts":[{"text":"next"}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2)
if errPrepare != nil {
t.Fatal(errPrepare)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != directSig {
t.Fatalf("thought signature = %q, want direct signature %q; body=%s", got, directSig, out)
}
if strings.Contains(string(out), prefixSig) {
t.Fatalf("unbound prefix must not replace a direct semantic signature; body=%s", out)
}
}
func TestAntigravityReasoningReplaySameDirectSignatureReplacesPrefix(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const signature = "same-prefix-and-direct-signature-123456"
scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:same-direct-prefix"}
request1 := []byte(`{"sessionId":"same-direct-prefix","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]}]}}`)
acc := newAntigravityReasoningReplayAccumulator(scope, request1)
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thought":true,"thoughtSignature":"` + signature + `"},{"text":"hidden","thought":true,"thoughtSignature":"` + signature + `"}]},"finishReason":"STOP"}]}}`))
acc.Commit(context.Background())
request2 := []byte(`{"sessionId":"same-direct-prefix","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]},{"role":"model","parts":[{"text":"hidden","thought":true}]},{"role":"user","parts":[{"text":"next"}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2)
if errPrepare != nil {
t.Fatal(errPrepare)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != signature {
t.Fatalf("thought signature = %q, want %q; body=%s", got, signature, out)
}
}
func TestAntigravityReasoningReplayDirectToolSignatureWinsOverPrefix(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const (
prefixSig = "unbound-tool-prefix-signature-123456"
directSig = "direct-tool-signature-123456"
)
scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:direct-tool-over-prefix"}
request1 := []byte(`{"sessionId":"direct-tool-over-prefix","request":{"contents":[{"role":"user","parts":[{"text":"run"}]}]}}`)
acc := newAntigravityReasoningReplayAccumulator(scope, request1)
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"` + prefixSig + `"},{"functionCall":{"id":"call-1","name":"run","args":{}},"thoughtSignature":"` + directSig + `"},{"text":"after"}]},"finishReason":"STOP"}]}}`))
acc.Commit(context.Background())
request2 := []byte(`{"sessionId":"direct-tool-over-prefix","request":{"contents":[{"role":"user","parts":[{"text":"run"}]},{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"run","args":{}}},{"text":"after"}]},{"role":"user","parts":[{"functionResponse":{"id":"call-1","name":"run","response":{"result":"ok"}}}]},{"role":"user","parts":[{"text":"next"}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2)
if errPrepare != nil {
t.Fatal(errPrepare)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != directSig {
t.Fatalf("tool signature = %q, want %q; body=%s", got, directSig, out)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != "" {
t.Fatalf("prefix signature retargeted to later text: %q; body=%s", got, out)
}
}
func TestAntigravityReasoningReplayAttachesDetachedSignatureToFunctionCall(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const signature = "detached-function-signature-123456789"
scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:detached-function"}
request1 := []byte(`{"sessionId":"detached-function","request":{"contents":[{"role":"user","parts":[{"text":"run"}]}]}}`)
acc := newAntigravityReasoningReplayAccumulator(scope, request1)
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"functionCall":{"id":"call-1","name":"run","args":{"n":1}}},{"text":"","thoughtSignature":"` + signature + `"}]},"finishReason":"STOP"}]}}`))
acc.Commit(context.Background())
request2 := []byte(`{"sessionId":"detached-function","request":{"contents":[{"role":"user","parts":[{"text":"run"}]},{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"run","args":{"n":1}}}]},{"role":"function","parts":[{"functionResponse":{"id":"call-1","name":"run","response":{"result":"ok"}}}]},{"role":"user","parts":[{"text":"next"}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2)
if errPrepare != nil {
t.Fatal(errPrepare)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != signature {
t.Fatalf("function signature = %q, want %q; body=%s", got, signature, out)
}
}
func TestPrepareAntigravityGeminiReasoningReplayRestoresParallelOmittedCalls(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const (
model = "gemini-3.6-flash-high"
sessionKey = "session:parallel-omitted-calls"
)
full := []byte(`{"sessionId":"parallel-omitted-calls","request":{"contents":[{"role":"user","parts":[{"text":"run both"}]},{"role":"model","parts":[{"functionCall":{"id":"id1","name":"run","args":{"n":1}},"thoughtSignature":"parallel-call-signature-one-123456"},{"functionCall":{"id":"id2","name":"run","args":{"n":2}},"thoughtSignature":"parallel-call-signature-two-123456"}]},{"role":"user","parts":[{"functionResponse":{"id":"id1","name":"run","response":{"result":"one"}}},{"functionResponse":{"id":"id2","name":"run","response":{"result":"two"}}}]},{"role":"user","parts":[{"text":"finish"}]}]}}`)
items := antigravityReasoningReplayItemsFromRequest(full)
if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, items) {
t.Fatal("cache write failed")
}
rebuilt := []byte(`{"sessionId":"parallel-omitted-calls","request":{"contents":[{"role":"user","parts":[{"text":"run both"}]},{"role":"user","parts":[{"functionResponse":{"id":"id1","name":"run","response":{"result":"one"}}},{"functionResponse":{"id":"id2","name":"run","response":{"result":"two"}}}]},{"role":"user","parts":[{"text":"finish"}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, rebuilt)
if errPrepare != nil {
t.Fatal(errPrepare)
}
if errValidate := internalsignature.ValidateGeminiFunctionCallPairing(out); errValidate != nil {
t.Fatalf("parallel replay is invalid: %v; body=%s", errValidate, out)
}
calls := gjson.GetBytes(out, "request.contents.1.parts").Array()
if len(calls) != 2 || calls[0].Get("functionCall.id").String() != "id1" || calls[1].Get("functionCall.id").String() != "id2" {
t.Fatalf("parallel calls were not restored together: %s", out)
}
}
func TestPrepareAntigravityGeminiReasoningReplayReordersResponsesAfterRestoringParallelCalls(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const (
model = "gemini-3.6-flash-high"
sessionKey = "session:parallel-reversed-responses"
)
full := []byte(`{"sessionId":"parallel-reversed-responses","request":{"contents":[{"role":"user","parts":[{"text":"run both"}]},{"role":"model","parts":[{"functionCall":{"id":"id1","name":"run","args":{"n":1}},"thoughtSignature":"parallel-call-signature-one-123456"},{"functionCall":{"id":"id2","name":"run","args":{"n":2}}}]},{"role":"model","parts":[{"functionResponse":{"id":"id1","name":"run","response":{"result":"one"}}},{"functionResponse":{"id":"id2","name":"run","response":{"result":"two"}}}]}]}}`)
if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, antigravityReasoningReplayItemsFromRequest(full)) {
t.Fatal("cache write failed")
}
rebuilt := []byte(`{"sessionId":"parallel-reversed-responses","request":{"contents":[{"role":"user","parts":[{"text":"run both"}]},{"role":"user","parts":[{"functionResponse":{"id":"id2","name":"run","response":{"result":"two"}}},{"functionResponse":{"id":"id1","name":"run","response":{"result":"one"}}}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, rebuilt)
if errPrepare != nil {
t.Fatal(errPrepare)
}
if errValidate := internalsignature.ValidateGeminiFunctionCallPairing(out); errValidate != nil {
t.Fatalf("restored reverse responses are invalid: %v; body=%s", errValidate, out)
}
responses := gjson.GetBytes(out, "request.contents.2.parts").Array()
if len(responses) != 2 || responses[0].Get("functionResponse.id").String() != "id1" || responses[1].Get("functionResponse.id").String() != "id2" {
t.Fatalf("restored responses were not reordered: %s", out)
}
if got := gjson.GetBytes(out, "request.contents.2.role").String(); got != "model" {
t.Fatalf("restored response role = %q, want model; body=%s", got, out)
}
}
func TestPrepareAntigravityGeminiReasoningReplayRestoresSyntheticParallelCallsWithFirstBypassOnly(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const (
model = "gemini-3.6-flash-high"
sessionKey = "session:parallel-omitted-synthetic-calls"
)
full := []byte(`{"sessionId":"parallel-omitted-synthetic-calls","request":{"contents":[{"role":"user","parts":[{"text":"run both"}]},{"role":"model","parts":[{"functionCall":{"id":"id1","name":"run","args":{"n":1}},"thoughtSignature":"skip_thought_signature_validator"},{"functionCall":{"id":"id2","name":"run","args":{"n":2}}}]},{"role":"model","parts":[{"functionResponse":{"id":"id1","name":"run","response":{"result":"one"}}},{"functionResponse":{"id":"id2","name":"run","response":{"result":"two"}}}]}]}}`)
items := antigravityReasoningReplayItemsFromRequest(full)
if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, items) {
t.Fatal("cache write failed")
}
rebuilt := []byte(`{"sessionId":"parallel-omitted-synthetic-calls","request":{"contents":[{"role":"user","parts":[{"text":"run both"}]},{"role":"user","parts":[{"functionResponse":{"id":"id1","name":"run","response":{"result":"one"}}},{"functionResponse":{"id":"id2","name":"run","response":{"result":"two"}}}]}]}}`)
rebuilt = sanitizeAntigravityGeminiRequestSignatures(model, rebuilt)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, rebuilt)
if errPrepare != nil {
t.Fatal(errPrepare)
}
calls := gjson.GetBytes(out, "request.contents.1.parts").Array()
if len(calls) != 2 {
t.Fatalf("parallel synthetic calls = %d, want 2; body=%s", len(calls), out)
}
if got := calls[0].Get("thoughtSignature").String(); got != "skip_thought_signature_validator" {
t.Fatalf("first synthetic call signature = %q, want bypass; body=%s", got, out)
}
if signature := calls[1].Get("thoughtSignature"); signature.Exists() {
t.Fatalf("second synthetic parallel call must remain unsigned; body=%s", out)
}
}
func TestPrepareAntigravityGeminiReasoningReplayRestoresSequentialOmittedCalls(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const (
model = "gemini-3.6-flash-high"
sessionKey = "session:sequential-omitted-calls"
)
full := []byte(`{"sessionId":"sequential-omitted-calls","request":{"contents":[{"role":"user","parts":[{"text":"run1"}]},{"role":"model","parts":[{"functionCall":{"id":"id1","name":"run","args":{"n":1}},"thoughtSignature":"omitted-call-signature-one-123456"}]},{"role":"function","parts":[{"functionResponse":{"id":"id1","name":"run","response":{"result":"one"}}}]},{"role":"user","parts":[{"text":"run2"}]},{"role":"model","parts":[{"functionCall":{"id":"id2","name":"run","args":{"n":2}},"thoughtSignature":"omitted-call-signature-two-123456"}]},{"role":"function","parts":[{"functionResponse":{"id":"id2","name":"run","response":{"result":"two"}}}]}]}}`)
items := antigravityReasoningReplayItemsFromRequest(full)
if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, items) {
t.Fatal("cache write failed")
}
rebuilt := []byte(`{"sessionId":"sequential-omitted-calls","request":{"contents":[{"role":"user","parts":[{"text":"run1"}]},{"role":"function","parts":[{"functionResponse":{"id":"id1","name":"run","response":{"result":"one"}}}]},{"role":"user","parts":[{"text":"run2"}]},{"role":"function","parts":[{"functionResponse":{"id":"id2","name":"run","response":{"result":"two"}}}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, rebuilt)
if errPrepare != nil {
t.Fatal(errPrepare)
}
var calls []string
gjson.GetBytes(out, "request.contents").ForEach(func(_, content gjson.Result) bool {
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
if callID := part.Get("functionCall.id").String(); callID != "" {
calls = append(calls, callID)
}
return true
})
return true
})
if got := strings.Join(calls, ","); got != "id1,id2" {
t.Fatalf("restored calls = %q, want id1,id2; body=%s", got, out)
}
}
func TestAntigravityReasoningReplayAccumulatesCompleteToolSignatureChain(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const (
model = "gemini-3.6-flash-high"
sessionKey = "session:tool-chain"
sig1 = "native-tool-signature-one-123456"
sig2 = "native-tool-signature-two-123456"
)
scope := antigravityReasoningReplayScope{modelName: model, sessionKey: sessionKey}
request1 := []byte(`{"sessionId":"tool-chain","request":{"contents":[{"role":"user","parts":[{"text":"run first"}]}]}}`)
acc1 := newAntigravityReasoningReplayAccumulator(scope, request1)
acc1.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"thoughtSignature":"` + sig1 + `","functionCall":{"id":"call-1","name":"run_command","args":{"command":"one"}}}]},"finishReason":"STOP"}]}}`))
acc1.Commit(context.Background())
request2 := []byte(`{"sessionId":"tool-chain","request":{"contents":[{"role":"user","parts":[{"text":"run first"}]},{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"run_command","args":{"command":"one"}}}]},{"role":"function","parts":[{"functionResponse":{"id":"call-1","name":"run_command","response":{"result":"ok"}}}]},{"role":"user","parts":[{"text":"run second"}]}]}}`)
request2 = normalizeAntigravityGeminiFunctionResponseRoles(request2)
prepared2, _, errPrepare2 := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2)
if errPrepare2 != nil {
t.Fatal(errPrepare2)
}
if got := gjson.GetBytes(prepared2, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("turn 2 tool signature = %q, want %q; body=%s", got, sig1, prepared2)
}
acc2 := newAntigravityReasoningReplayAccumulator(scope, prepared2)
acc2.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"thoughtSignature":"` + sig2 + `","functionCall":{"id":"call-2","name":"view_file","args":{"path":"two"}}}]},"finishReason":"STOP"}]}}`))
acc2.Commit(context.Background())
request3 := []byte(`{"sessionId":"tool-chain","request":{"contents":[{"role":"user","parts":[{"text":"run first"}]},{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"run_command","args":{"command":"one"}}}]},{"role":"function","parts":[{"functionResponse":{"id":"call-1","name":"run_command","response":{"result":"ok"}}}]},{"role":"user","parts":[{"text":"run second"}]},{"role":"model","parts":[{"functionCall":{"id":"call-2","name":"view_file","args":{"path":"two"}}}]},{"role":"function","parts":[{"functionResponse":{"id":"call-2","name":"view_file","response":{"result":"ok"}}}]},{"role":"user","parts":[{"text":"finish"}]}]}}`)
request3 = normalizeAntigravityGeminiFunctionResponseRoles(request3)
prepared3, _, errPrepare3 := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request3)
if errPrepare3 != nil {
t.Fatal(errPrepare3)
}
if got := gjson.GetBytes(prepared3, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("turn 3 first tool signature = %q, want %q; body=%s", got, sig1, prepared3)
}
if got := gjson.GetBytes(prepared3, "request.contents.4.parts.0.thoughtSignature").String(); got != sig2 {
t.Fatalf("turn 3 second tool signature = %q, want %q; body=%s", got, sig2, prepared3)
}
}
func TestAntigravityReasoningReplayDirectSignatureClosesSegmentBeforeUnsignedText(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const signature = "direct-text-signature-closes-segment-123456"
scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:direct-signed-unsigned"}
request1 := []byte(`{"sessionId":"direct-signed-unsigned","request":{"contents":[{"role":"user","parts":[{"text":"start"}]}]}}`)
acc := newAntigravityReasoningReplayAccumulator(scope, request1)
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"signed","thoughtSignature":"` + signature + `"}]}}]}}`))
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"unsigned"}]},"finishReason":"STOP"}]}}`))
acc.Commit(context.Background())
request2 := []byte(`{"sessionId":"direct-signed-unsigned","request":{"contents":[{"role":"user","parts":[{"text":"start"}]},{"role":"model","parts":[{"text":"signed"},{"text":"unsigned"}]},{"role":"user","parts":[{"text":"next"}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2)
if errPrepare != nil {
t.Fatal(errPrepare)
}
parts := gjson.GetBytes(out, "request.contents.1.parts").Array()
if len(parts) != 2 || parts[0].Get("thoughtSignature").String() != signature || parts[1].Get("thoughtSignature").String() != "" {
t.Fatalf("direct signature crossed into unsigned text: %s", out)
}
}
func TestAntigravityReasoningReplayKeepsMixedTextToolTextFingerprintsSeparate(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const (
textSig1 = "mixed-text-signature-one-123456"
toolSig = "mixed-tool-signature-123456789"
textSig2 = "mixed-text-signature-two-123456"
)
scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:mixed-segments"}
request1 := []byte(`{"sessionId":"mixed-segments","request":{"contents":[{"role":"user","parts":[{"text":"mixed"}]}]}}`)
acc := newAntigravityReasoningReplayAccumulator(scope, request1)
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"sa"}]}}]}}`))
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"me","thoughtSignature":"` + textSig1 + `"}]}}]}}`))
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"functionCall":{"id":"call-1","name":"run_command","args":{"command":"true"}},"thoughtSignature":"` + toolSig + `"}]}}]}}`))
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"sa"}]}}]}}`))
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"me","thoughtSignature":"` + textSig2 + `"}]},"finishReason":"STOP"}]}}`))
acc.Commit(context.Background())
items, ok := internalcache.GetAntigravityReasoningReplayItems(scope.modelName, scope.sessionKey)
if !ok || len(items) != 3 {
t.Fatalf("cached mixed items = %d ok=%v, want 3", len(items), ok)
}
if occurrence := gjson.GetBytes(items[0], "targetOccurrence"); !occurrence.Exists() || occurrence.Int() != 0 {
t.Fatalf("first text targetOccurrence = %s, want 0; item=%s", occurrence.Raw, items[0])
}
if got := gjson.GetBytes(items[2], "targetOccurrence").Int(); got != 1 {
t.Fatalf("second text targetOccurrence = %d, want 1; item=%s", got, items[2])
}
request2 := []byte(`{"sessionId":"mixed-segments","request":{"contents":[{"role":"user","parts":[{"text":"mixed"}]},{"role":"model","parts":[{"text":"same"},{"functionCall":{"id":"call-1","name":"run_command","args":{"command":"true"}}},{"text":"same"}]},{"role":"function","parts":[{"functionResponse":{"id":"call-1","name":"run_command","response":{"result":"ok"}}}]},{"role":"user","parts":[{"text":"next"}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2)
if errPrepare != nil {
t.Fatal(errPrepare)
}
for partIndex, want := range []string{textSig1, toolSig, textSig2} {
if got := gjson.GetBytes(out, fmt.Sprintf("request.contents.1.parts.%d.thoughtSignature", partIndex)).String(); got != want {
t.Fatalf("part %d signature = %q, want %q; body=%s", partIndex, got, want, out)
}
}
}
func TestAntigravityReasoningReplayAccumulatorCountsExistingSegmentOccurrences(t *testing.T) {
request := []byte(`{"request":{"contents":[{"role":"model","parts":[{"text":"same"},{"text":"same","thought":true}]}]}}`)
acc := newAntigravityReasoningReplayAccumulator(
antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:existing-segments"},
request,
)
acc.observeResponsePayload([]byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"same","thoughtSignature":"text-signature-123456789"},{"text":"same","thought":true,"thoughtSignature":"thought-signature-123456789"}]},"finishReason":"STOP"}]}}`))
acc.appendPendingThoughtSignatures()
if len(acc.items) != 2 {
t.Fatalf("captured items = %d, want 2: %q", len(acc.items), acc.items)
}
for itemIndex, wantKind := range []string{"text", "thought"} {
item := gjson.ParseBytes(acc.items[itemIndex])
if item.Get("targetKind").String() != wantKind || item.Get("targetOccurrence").Int() != 1 {
t.Fatalf("item %d kind/occurrence = %q/%d, want %q/1: %s", itemIndex, item.Get("targetKind").String(), item.Get("targetOccurrence").Int(), wantKind, item.Raw)
}
}
}
func TestAntigravityReasoningReplayAccumulatorCountsExistingFunctionOccurrenceThroughReplay(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const signature = "second-function-signature-123456789"
scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:existing-function"}
request := []byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"name":"run","args":{"value":"same"}}}]}]}}`)
acc := newAntigravityReasoningReplayAccumulator(scope, request)
acc.observeResponsePayload([]byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"run","args":{"value":"same"}},"thoughtSignature":"` + signature + `"}]},"finishReason":"STOP"}]}}`))
acc.Commit(context.Background())
items, ok := internalcache.GetAntigravityReasoningReplayItems(scope.modelName, scope.sessionKey)
if !ok || len(items) != 2 || gjson.GetBytes(items[1], "targetOccurrence").Int() != 1 {
t.Fatalf("function occurrences were not committed: ok=%v items=%q", ok, items)
}
replayPayload := []byte(`{"sessionId":"existing-function","request":{"contents":[{"role":"model","parts":[{"functionCall":{"name":"run","args":{"value":"same"}}},{"functionCall":{"name":"run","args":{"value":"same"}}}]}]}}`)
prepared, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, replayPayload)
if errPrepare != nil {
t.Fatal(errPrepare)
}
// The leading call gets Gemini's bypass sentinel (it carries no native
// signature); only the second occurrence may receive the replayed one.
parts := gjson.GetBytes(prepared, "request.contents.0.parts").Array()
if len(parts) != 2 || antigravityHasNativeThoughtSignature(parts[0].Get("thoughtSignature").String()) || parts[1].Get("thoughtSignature").String() != signature {
t.Fatalf("function occurrence replay targeted the wrong call: %s", prepared)
}
}
func TestAntigravityReasoningReplayCapturesSignatureBeforeThoughtText(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const signature = "thought-first-signature-123456789"
scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:thought-first"}
request1 := []byte(`{"sessionId":"thought-first","request":{"contents":[{"role":"user","parts":[{"text":"think"}]}]}}`)
acc := newAntigravityReasoningReplayAccumulator(scope, request1)
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thought":true,"thoughtSignature":"` + signature + `"}]}}]}}`))
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"hidden thought","thought":true}]},"finishReason":"STOP"}]}}`))
acc.Commit(context.Background())
request2 := []byte(`{"sessionId":"thought-first","request":{"contents":[{"role":"user","parts":[{"text":"think"}]},{"role":"model","parts":[{"text":"hidden thought","thought":true}]},{"role":"user","parts":[{"text":"next"}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2)
if errPrepare != nil {
t.Fatal(errPrepare)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != signature {
t.Fatalf("thought-first signature = %q, want %q; body=%s", got, signature, out)
}
}
func TestPrepareAntigravityGeminiReasoningReplayReplacesIDLessFunctionCallBypass(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
item := []byte(`{"type":"function_call_part","contentIndex":1,"partIndex":0,"name":"run_command","args":{"command":"same"},"thoughtSignature":"idless-native-signature-123456"}`)
internalcache.CacheAntigravityReasoningReplayItems("gemini-3.6-flash-high", "session:idless", [][]byte{item})
payload := []byte(`{"sessionId":"idless","request":{"contents":[{"role":"user","parts":[{"text":"run"}]},{"role":"model","parts":[{"functionCall":{"name":"run_command","args":{"command":"same"}},"thoughtSignature":"skip_thought_signature_validator"}]},{"role":"function","parts":[{"functionResponse":{"name":"run_command","response":{"result":"ok"}}}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload)
if errPrepare != nil {
t.Fatal(errPrepare)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "idless-native-signature-123456" {
t.Fatalf("id-less function signature = %q, want native replay; body=%s", got, out)
}
}
func TestAntigravityReasoningReplayContextFingerprintCanonicalizesJSON(t *testing.T) {
payload1 := []byte(`{"request":{"tools":[{"functionDeclarations":[{"name":"run","parameters":{"type":"object","properties":{"a":{"type":"string"},"b":{"type":"number"}}}}]}],"contents":[{"role":"user","parts":[{"text":"turn"}]},{"role":"model","parts":[{"functionCall":{"name":"run","args":{"a":"x","b":2}}}]}]}}`)
payload2 := []byte(`{"request":{"tools":[{"functionDeclarations":[{"parameters":{"properties":{"b":{"type":"number"},"a":{"type":"string"}},"type":"object"},"name":"run"}]}],"contents":[{"parts":[{"text":"turn"}],"role":"user"},{"parts":[{"functionCall":{"args":{"b":2,"a":"x"},"name":"run"}}],"role":"model"}]}}`)
if got1, got2 := antigravityReplayContextFingerprint(payload1, 2), antigravityReplayContextFingerprint(payload2, 2); got1 == "" || got1 != got2 {
t.Fatalf("canonical context hashes differ: %q vs %q", got1, got2)
}
key1 := antigravityFunctionCallKey("run", `{"a":"x","b":2}`, "")
key2 := antigravityFunctionCallKey("run", `{"b":2,"a":"x"}`, "")
if key1 == "" || key1 != key2 {
t.Fatalf("canonical function keys differ: %q vs %q", key1, key2)
}
}
func TestPrepareAntigravityGeminiReasoningReplayMatchesRewrittenToolCallID(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
item := []byte(`{"type":"function_call_part","contentIndex":1,"partIndex":0,"call_id":"native-call-id","name":"run_command","args":{"command":"same"},"thoughtSignature":"rewritten-id-signature-123456"}`)
internalcache.CacheAntigravityReasoningReplayItems("gemini-3.6-flash-high", "session:rewritten-id", [][]byte{item})
payload := []byte(`{"sessionId":"rewritten-id","request":{"contents":[{"role":"user","parts":[{"text":"run"}]},{"role":"model","parts":[{"functionCall":{"id":"claude-generated-id","name":"run_command","args":{"command":"same"}}}]},{"role":"function","parts":[{"functionResponse":{"id":"claude-generated-id","name":"run_command","response":{"result":"ok"}}}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload)
if errPrepare != nil {
t.Fatal(errPrepare)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "rewritten-id-signature-123456" {
t.Fatalf("rewritten-ID function signature = %q, want native replay; body=%s", got, out)
}
}
func TestPrepareAntigravityGeminiReasoningReplayRejectsReusedIDWithChangedCall(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
item := []byte(`{"type":"function_call_part","contentIndex":1,"partIndex":0,"call_id":"reused-id","name":"run_command","args":{"command":"old"},"thoughtSignature":"reused-id-stale-signature-123456"}`)
internalcache.CacheAntigravityReasoningReplayItems("gemini-3.6-flash-high", "session:reused-id", [][]byte{item})
payload := []byte(`{"sessionId":"reused-id","request":{"contents":[{"role":"user","parts":[{"text":"run"}]},{"role":"model","parts":[{"functionCall":{"id":"reused-id","name":"run_command","args":{"command":"new"}}},{"functionCall":{"id":"other-id","name":"run_command","args":{"command":"old"}}}]},{"role":"function","parts":[{"functionResponse":{"id":"reused-id","name":"run_command","response":{"result":"ok"}}},{"functionResponse":{"id":"other-id","name":"run_command","response":{"result":"ok"}}}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload)
if errPrepare != nil {
t.Fatal(errPrepare)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); antigravityHasNativeThoughtSignature(got) {
t.Fatalf("changed call with reused ID received stale signature %q; body=%s", got, out)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != "" {
t.Fatalf("reused-ID signature fell through to another semantic match %q; body=%s", got, out)
}
callCount := 0
gjson.GetBytes(out, "request.contents").ForEach(func(_, content gjson.Result) bool {
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
if part.Get("functionCall.id").String() == "reused-id" {
callCount++
}
return true
})
return true
})
if callCount != 1 {
t.Fatalf("changed call with reused ID was duplicated: count=%d body=%s", callCount, out)
}
}
func TestPrepareAntigravityGeminiReasoningReplayRejectsChangedIDLessCallAtSamePosition(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
item := []byte(`{"type":"function_call_part","contentIndex":1,"partIndex":0,"name":"run_command","args":{"command":"old"},"thoughtSignature":"idless-stale-signature-123456"}`)
internalcache.CacheAntigravityReasoningReplayItems("gemini-3.6-flash-high", "session:idless-changed", [][]byte{item})
payload := []byte(`{"sessionId":"idless-changed","request":{"contents":[{"role":"user","parts":[{"text":"run"}]},{"role":"model","parts":[{"functionCall":{"name":"run_command","args":{"command":"new"}}}]},{"role":"function","parts":[{"functionResponse":{"name":"run_command","response":{"result":"ok"}}}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload)
if errPrepare != nil {
t.Fatal(errPrepare)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); antigravityHasNativeThoughtSignature(got) {
t.Fatalf("changed ID-less call received stale signature %q; body=%s", got, out)
}
}
func TestAntigravityReasoningReplayPreservesRepeatedIDLessCalls(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const (
sig1 = "repeated-idless-signature-one-123456"
sig2 = "repeated-idless-signature-two-123456"
)
scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:repeated-idless"}
request1 := []byte(`{"sessionId":"repeated-idless","request":{"contents":[{"role":"user","parts":[{"text":"run twice"}]}]}}`)
acc := newAntigravityReasoningReplayAccumulator(scope, request1)
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"thoughtSignature":"` + sig1 + `","functionCall":{"name":"run_command","args":{"command":"same"}}},{"thoughtSignature":"` + sig2 + `","functionCall":{"name":"run_command","args":{"command":"same"}}}]},"finishReason":"STOP"}]}}`))
acc.Commit(context.Background())
request2 := []byte(`{"sessionId":"repeated-idless","request":{"contents":[{"role":"user","parts":[{"text":"run twice"}]},{"role":"model","parts":[{"functionCall":{"name":"run_command","args":{"command":"same"}}},{"functionCall":{"name":"run_command","args":{"command":"same"}}}]},{"role":"model","parts":[{"functionResponse":{"name":"run_command","response":{"result":"one"}}},{"functionResponse":{"name":"run_command","response":{"result":"two"}}}]},{"role":"user","parts":[{"text":"continue"}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2)
if errPrepare != nil {
t.Fatal(errPrepare)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("first repeated signature = %q, want %q; body=%s", got, sig1, out)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig2 {
t.Fatalf("second repeated signature = %q, want %q; body=%s", got, sig2, out)
}
}
func TestAntigravityReasoningReplayPreservesRepeatedIDLessCallsAcrossSplitSSEPartDrift(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const (
sig1 = "split-idless-signature-one-123456"
sig2 = "split-idless-signature-two-123456"
)
scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:split-repeated-idless"}
request1 := []byte(`{"sessionId":"split-repeated-idless","request":{"contents":[{"role":"user","parts":[{"text":"run twice"}]}]}}`)
acc := newAntigravityReasoningReplayAccumulator(scope, request1)
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"hidden","thought":true}]}}]}}`))
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"thoughtSignature":"` + sig1 + `","functionCall":{"name":"run_command","args":{"command":"same"}}}]}}]}}`))
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"thoughtSignature":"` + sig2 + `","functionCall":{"name":"run_command","args":{"command":"same"}}}]},"finishReason":"STOP"}]}}`))
acc.Commit(context.Background())
items, ok := internalcache.GetAntigravityReasoningReplayItems(scope.modelName, scope.sessionKey)
if !ok || len(items) != 2 {
t.Fatalf("cached items = %d ok=%v, want 2", len(items), ok)
}
for index, item := range items {
if occurrence := gjson.GetBytes(item, "targetOccurrence"); !occurrence.Exists() || occurrence.Int() != int64(index) {
t.Fatalf("item %d occurrence = %s, want %d; item=%s", index, occurrence.Raw, index, item)
}
}
request2 := []byte(`{"sessionId":"split-repeated-idless","request":{"contents":[{"role":"user","parts":[{"text":"run twice"}]},{"role":"model","parts":[{"functionCall":{"name":"run_command","args":{"command":"same"}}},{"functionCall":{"name":"run_command","args":{"command":"same"}}}]},{"role":"model","parts":[{"functionResponse":{"name":"run_command","response":{"result":"one"}}},{"functionResponse":{"name":"run_command","response":{"result":"two"}}}]},{"role":"user","parts":[{"text":"continue"}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2)
if errPrepare != nil {
t.Fatal(errPrepare)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("first split repeated signature = %q, want %q; body=%s", got, sig1, out)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig2 {
t.Fatalf("second split repeated signature = %q, want %q; body=%s", got, sig2, out)
}
rebuiltItems := antigravityReasoningReplayItemsFromRequest(out)
if len(rebuiltItems) != 2 || gjson.GetBytes(rebuiltItems[0], "targetOccurrence").Int() != 0 || gjson.GetBytes(rebuiltItems[1], "targetOccurrence").Int() != 1 {
t.Fatalf("rebuilt occurrences were not preserved: %q", rebuiltItems)
}
}
func TestAntigravityReasoningReplayLegacyAmbiguousIDLessCallFailsClosed(t *testing.T) {
item := []byte(`{"type":"function_call_part","contentIndex":1,"partIndex":1,"name":"run_command","args":{"command":"same"},"thoughtSignature":"legacy-ambiguous-signature-123456"}`)
payload := []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"run"}]},{"role":"model","parts":[{"functionCall":{"name":"run_command","args":{"command":"same"}}},{"functionCall":{"name":"run_command","args":{"command":"same"}}}]}]}}`)
out, changed := insertAntigravityReasoningReplayItems(payload, [][]byte{item})
if changed || strings.Contains(string(out), "legacy-ambiguous-signature") {
t.Fatalf("legacy ambiguous ID-less replay must fail closed: changed=%v body=%s", changed, out)
}
}
func TestAntigravityReasoningReplayAssociatesSignatureBeforeFunctionCall(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const signature = "signature-before-function-call-123456"
scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:signature-first-tool"}
request1 := []byte(`{"sessionId":"signature-first-tool","request":{"contents":[{"role":"user","parts":[{"text":"run"}]}]}}`)
acc := newAntigravityReasoningReplayAccumulator(scope, request1)
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"` + signature + `"}]}}]}}`))
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"functionCall":{"id":"call-1","name":"run_command","args":{"command":"one"}}}]},"finishReason":"STOP"}]}}`))
acc.Commit(context.Background())
request2 := []byte(`{"sessionId":"signature-first-tool","request":{"contents":[{"role":"user","parts":[{"text":"run"}]},{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"run_command","args":{"command":"one"}}}]},{"role":"function","parts":[{"functionResponse":{"id":"call-1","name":"run_command","response":{"result":"ok"}}}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2)
if errPrepare != nil {
t.Fatal(errPrepare)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != signature {
t.Fatalf("signature-first tool signature = %q, want %q; body=%s", got, signature, out)
}
}
func TestAntigravityReasoningReplayTerminalEmptyChainClearsCache(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:empty-reset"}
old := []byte(`{"type":"thought_signature","contentIndex":1,"partIndex":0,"thoughtSignature":"old-signature-123456789"}`)
internalcache.CacheAntigravityReasoningReplayItems(scope.modelName, scope.sessionKey, [][]byte{old})
request := []byte(`{"sessionId":"empty-reset","request":{"contents":[{"role":"user","parts":[{"text":"new conversation"}]}]}}`)
acc := newAntigravityReasoningReplayAccumulator(scope, request)
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"answer without signature"}]},"finishReason":"STOP"}]}}`))
acc.Commit(context.Background())
if items, ok := internalcache.GetAntigravityReasoningReplayItems(scope.modelName, scope.sessionKey); ok || len(items) != 0 {
t.Fatalf("empty terminal chain did not clear old cache: %d ok=%v", len(items), ok)
}
}
func TestAntigravityReasoningReplayDoesNotCommitPartialResponse(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:partial"}
request := []byte(`{"sessionId":"partial","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]}]}}`)
acc := newAntigravityReasoningReplayAccumulator(scope, request)
acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"partial"},{"text":"","thoughtSignature":"partial-signature-123456789"}]}}]}}`))
acc.Commit(context.Background())
if items, ok := internalcache.GetAntigravityReasoningReplayItems(scope.modelName, scope.sessionKey); ok || len(items) != 0 {
t.Fatalf("partial response published replay items: %d ok=%v", len(items), ok)
}
}
func TestPrepareAntigravityGeminiReasoningReplayKeepsTextSignatureOnContextDrift(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
kind, fingerprint := antigravityReplayPartFingerprint(gjson.Parse(`{"text":"same answer"}`))
item := buildAntigravityThoughtSignatureItem(1, 0, "fingerprinted-signature-123456", kind, fingerprint)
originalPayload := []byte(`{"sessionId":"rebuilt","request":{"contents":[{"role":"user","parts":[{"text":"old context"}]},{"role":"model","parts":[{"text":"same answer"}]},{"role":"user","parts":[{"text":"old next"}]}]}}`)
item = antigravitySetReplayItemContextHash(item, originalPayload, 1)
internalcache.CacheAntigravityReasoningReplayItems("gemini-3.6-flash-high", "session:rebuilt", [][]byte{item})
payload := []byte(`{"sessionId":"rebuilt","request":{"contents":[{"role":"user","parts":[{"text":"new context"}]},{"role":"model","parts":[{"text":"same answer"}]},{"role":"user","parts":[{"text":"next"}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload)
if errPrepare != nil {
t.Fatal(errPrepare)
}
// The signed part itself is byte-identical, so the signature still describes
// it exactly. Only the surrounding turns drifted, which Gemini does not bind
// signatures to, so dropping it here would only force needless re-reasoning.
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "fingerprinted-signature-123456" {
t.Fatalf("signature = %q, want the signature replayed even though the surrounding context drifted; body=%s", got, out)
}
}
func TestPrepareAntigravityGeminiReasoningReplayRejectsFingerprintMismatch(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
kind, fingerprint := antigravityReplayPartFingerprint(gjson.Parse(`{"text":"original answer"}`))
item := buildAntigravityThoughtSignatureItem(1, 0, "fingerprinted-signature-123456", kind, fingerprint)
internalcache.CacheAntigravityReasoningReplayItems("gemini-3.6-flash-high", "session:edited", [][]byte{item})
// The client rewrote the signed part, so the cached signature describes text
// that is no longer in the request and must not be attached to the new text.
payload := []byte(`{"sessionId":"edited","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]},{"role":"model","parts":[{"text":"edited answer"}]},{"role":"user","parts":[{"text":"next"}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload)
if errPrepare != nil {
t.Fatal(errPrepare)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "" {
t.Fatalf("edited part received stale signature %q; body=%s", got, out)
}
}
func TestPrepareAntigravityGeminiReasoningReplayMovesClientSignatureToNativePart(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
kind, fingerprint := antigravityReplayPartFingerprint(gjson.Parse(`{"text":"visible answer"}`))
item := buildAntigravityThoughtSignatureItem(1, 1, "client-carried-signature-123456", kind, fingerprint)
internalcache.CacheAntigravityReasoningReplayItems("gemini-3.6-flash-high", "session:client-carried", [][]byte{item})
payload := []byte(`{"sessionId":"client-carried","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]},{"role":"model","parts":[{"text":"hidden","thought":true,"thoughtSignature":"client-carried-signature-123456"},{"text":"visible answer"}]},{"role":"user","parts":[{"text":"next"}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload)
if errPrepare != nil {
t.Fatal(errPrepare)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "" {
t.Fatalf("client-carried signature remained on non-native thought part: %q; body=%s", got, out)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != "client-carried-signature-123456" {
t.Fatalf("client-carried signature = %q on visible part, want native placement; body=%s", got, out)
}
}
func TestPrepareAntigravityGeminiReasoningReplayReplacesBypassSignature(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
item := []byte(`{"type":"thought_signature","contentIndex":1,"partIndex":0,"thoughtSignature":"native-real-signature-123456"}`)
internalcache.CacheAntigravityReasoningReplayItems("gemini-3.6-flash-high", "session:bypass", [][]byte{item})
payload := []byte(`{"sessionId":"bypass","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]},{"role":"model","parts":[{"text":"answer","thoughtSignature":"skip_thought_signature_validator"}]},{"role":"user","parts":[{"text":"next"}]}]}}`)
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload)
if errPrepare != nil {
t.Fatal(errPrepare)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "native-real-signature-123456" {
t.Fatalf("signature = %q, want native replay; body=%s", got, out)
}
}
func TestAntigravityReasoningReplayScopePrefersExecutionSession(t *testing.T) {
req := cliproxyexecutor.Request{Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "client-session"}}
payload := []byte(`{"sessionId":"provider-session","request":{"contents":[{"role":"user","parts":[{"text":"same prompt"}]}]}}`)
scope := antigravityReasoningReplayScopeFromRequest(context.Background(), "gemini-3.6-flash-high", req, cliproxyexecutor.Options{}, payload)
if got := scope.sessionKey; got != "execution:client-session" {
t.Fatalf("session key = %q, want downstream execution session", got)
}
}
func TestAntigravityReasoningReplayScopePrefersStableSessionOverExecutionUUID(t *testing.T) {
opts := cliproxyexecutor.Options{
Headers: http.Header{"Session-Id": []string{"stable-session"}},
Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "socket-uuid"},
}
scope := antigravityReasoningReplayScopeFromRequest(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, opts, nil)
if got := scope.sessionKey; got != "responses:stable-session" {
t.Fatalf("session key = %q, want stable Responses session", got)
}
}
func TestAntigravityReasoningReplayScopeKeepsExecutionAheadOfPromptCacheKey(t *testing.T) {
opts := cliproxyexecutor.Options{
OriginalRequest: []byte(`{"prompt_cache_key":"shared-cache-bucket"}`),
Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "socket-session"},
}
scope := antigravityReasoningReplayScopeFromRequest(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, opts, nil)
if got := scope.sessionKey; got != "execution:socket-session" {
t.Fatalf("session key = %q, want execution scope ahead of prompt cache key", got)
}
}
func TestAntigravityReasoningReplayScopeSeparatesPromptCacheAndExplicitSessionNamespaces(t *testing.T) {
promptScope := antigravityReasoningReplayScopeFromRequest(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, cliproxyexecutor.Options{OriginalRequest: []byte(`{"prompt_cache_key":"same-value"}`)}, nil)
sessionScope := antigravityReasoningReplayScopeFromRequest(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, cliproxyexecutor.Options{OriginalRequest: []byte(`{"session_id":"same-value"}`)}, nil)
if promptScope.sessionKey != "prompt-cache:same-value" || sessionScope.sessionKey != "responses:same-value" || promptScope.sessionKey == sessionScope.sessionKey {
t.Fatalf("prompt/session namespaces collided: %q vs %q", promptScope.sessionKey, sessionScope.sessionKey)
}
}
func TestAntigravityReasoningReplayScopeUsesClaudeMetadataSession(t *testing.T) {
opts := cliproxyexecutor.Options{OriginalRequest: []byte(`{"metadata":{"user_id":"{\"session_id\":\"claude-session\",\"device_id\":\"device\"}"}}`)}
payload := []byte(`{"sessionId":"generated-from-prompt","request":{"contents":[{"role":"user","parts":[{"text":"same prompt"}]}]}}`)
scope := antigravityReasoningReplayScopeFromRequest(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, opts, payload)
if got := scope.sessionKey; got != "claude:claude-session:agent:main" {
t.Fatalf("session key = %q, want Claude root-agent session", got)
}
}
func TestAntigravityReasoningReplaySeparatesClaudeSessionTitleFromResumedTranscript(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const (
model = "gemini-3.6-flash-high"
sig1 = "claude-resume-signature-one-123456"
sig2 = "claude-resume-signature-two-123456"
)
headers := http.Header{"X-Claude-Code-Session-Id": []string{"claude-resume-session"}}
mainOriginal1 := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"root prompt"}]}],"system":[{"type":"text","text":"You are Claude Code."}],"thinking":{"type":"enabled"},"tools":[{"name":"Read"}]}`)
mainRequest1 := []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"root prompt"}]}]}}`)
mainScope := antigravityReasoningReplayScopeFromRequest(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{Headers: headers, OriginalRequest: mainOriginal1}, mainRequest1)
mainAccumulator1 := newAntigravityReasoningReplayAccumulator(mainScope, mainRequest1)
mainAccumulator1.observeResponsePayload([]byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"answer one"},{"text":"","thoughtSignature":"` + sig1 + `"}]},"finishReason":"STOP"}]}}`))
mainAccumulator1.Commit(context.Background())
// Prepare the next main turn before the auxiliary request commits. This
// reproduces Claude Code's concurrent title/main request ordering.
mainOriginal2 := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"root prompt"}]},{"role":"assistant","content":[{"type":"text","text":"answer one"}]},{"role":"user","content":[{"type":"text","text":"next prompt"}]}],"system":[{"type":"text","text":"You are Claude Code."}],"thinking":{"type":"enabled"},"tools":[{"name":"Read"}]}`)
mainRequest2 := []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"root prompt"}]},{"role":"model","parts":[{"text":"answer one"}]},{"role":"user","parts":[{"text":"next prompt"}]}]}}`)
prepared2, scope2, errPrepare2 := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{Headers: headers, OriginalRequest: mainOriginal2}, mainRequest2)
if errPrepare2 != nil {
t.Fatal(errPrepare2)
}
if scope2.sessionKey != mainScope.sessionKey {
t.Fatalf("main replay scope changed: first=%q second=%q", mainScope.sessionKey, scope2.sessionKey)
}
titleOriginal := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"root prompt"}]}],"system":[{"type":"text","text":"Generate a concise title that summarizes this session."}],"tools":[]}`)
titleRequest := []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"root prompt"}]}]}}`)
preparedTitle, titleScope, errPrepareTitle := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{Headers: headers, OriginalRequest: titleOriginal}, titleRequest)
if errPrepareTitle != nil {
t.Fatal(errPrepareTitle)
}
if titleScope.sessionKey == mainScope.sessionKey || !strings.Contains(mainScope.sessionKey, ":context:") || !strings.Contains(titleScope.sessionKey, ":context:") {
t.Fatalf("Claude title/main replay scopes collided: main=%q title=%q", mainScope.sessionKey, titleScope.sessionKey)
}
titleAccumulator := newAntigravityReasoningReplayAccumulator(titleScope, preparedTitle)
titleAccumulator.observeResponsePayload([]byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"session title"},{"text":"","thoughtSignature":"claude-title-signature-123456"}]},"finishReason":"STOP"}]}}`))
titleAccumulator.Commit(context.Background())
if got := gjson.GetBytes(prepared2, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("first main signature after title request = %q, want %q; body=%s", got, sig1, prepared2)
}
mainAccumulator2 := newAntigravityReasoningReplayAccumulator(scope2, prepared2)
mainAccumulator2.observeResponsePayload([]byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"answer two"},{"text":"","thoughtSignature":"` + sig2 + `"}]},"finishReason":"STOP"}]}}`))
mainAccumulator2.Commit(context.Background())
resumeOriginal := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"root prompt"}]},{"role":"assistant","content":[{"type":"text","text":"answer one"}]},{"role":"user","content":[{"type":"text","text":"next prompt"}]},{"role":"assistant","content":[{"type":"text","text":"answer two"}]},{"role":"user","content":[{"type":"text","text":"resumed prompt"}]}],"system":[{"type":"text","text":"You are Claude Code.","cache_control":{"type":"ephemeral"}}],"thinking":{"type":"enabled"},"tools":[{"name":"Read"}]}`)
resumeRequest := []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"root prompt"}]},{"role":"model","parts":[{"text":"answer one"}]},{"role":"user","parts":[{"text":"next prompt"}]},{"role":"model","parts":[{"text":"answer two"}]},{"role":"user","parts":[{"text":"resumed prompt"}]}]}}`)
resumed, resumeScope, errResume := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{Headers: headers, OriginalRequest: resumeOriginal}, resumeRequest)
if errResume != nil {
t.Fatal(errResume)
}
if resumeScope.sessionKey != mainScope.sessionKey {
t.Fatalf("resumed replay scope = %q, want %q", resumeScope.sessionKey, mainScope.sessionKey)
}
if got := gjson.GetBytes(resumed, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("resumed first signature = %q, want %q; body=%s", got, sig1, resumed)
}
if got := gjson.GetBytes(resumed, "request.contents.3.parts.0.thoughtSignature").String(); got != sig2 {
t.Fatalf("resumed second signature = %q, want %q; body=%s", got, sig2, resumed)
}
}
func TestAntigravityReasoningReplayScopeSeparatesClaudeAgents(t *testing.T) {
opts := cliproxyexecutor.Options{Headers: http.Header{
"X-Claude-Code-Session-Id": []string{"claude-session"},
"X-Claude-Code-Agent-Id": []string{"subagent-1"},
}}
scope := antigravityReasoningReplayScopeFromRequest(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, opts, nil)
if got := scope.sessionKey; got != "claude:claude-session:agent:subagent-1" {
t.Fatalf("session key = %q, want agent-scoped Claude session", got)
}
}
func TestAntigravityReasoningReplayScopeUsesStableSessionWithoutSessionId(t *testing.T) {
payload := []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"stable-user-text"}]}]}}`)
scope := antigravityReasoningReplayScopeFromPayload("gemini-3-flash-agent", payload)
if !scope.valid() {
t.Fatal("scope should be valid from stable session hash")
}
if !strings.HasPrefix(scope.sessionKey, "session:") {
t.Fatalf("sessionKey = %q", scope.sessionKey)
}
}
func TestAntigravityReplayToolCallKeysUsesNativeFunctionCallID(t *testing.T) {
fc := gjson.Parse(`{"name":"Read","args":{"file_path":"/a"},"id":"id-native"}`)
keys := antigravityReplayToolCallKeysFromPart(fc)
if len(keys) != 1 {
t.Fatalf("keys = %v", keys)
}
fc2 := gjson.Parse(`{"name":"Read","args":{"file_path":"/a"},"id":"id-native-2"}`)
keys2 := antigravityReplayToolCallKeysFromPart(fc2)
if keys[0] == keys2[0] {
t.Fatalf("parallel tool calls should not share replay key: %v vs %v", keys, keys2)
}
}
func TestAntigravityRequestHasMatchingFunctionResponseWhitespaceCallID(t *testing.T) {
item := gjson.Parse(`{"call_id":" "}`)
if !antigravityRequestHasMatchingFunctionResponse(nil, item) {
t.Fatal("whitespace-only call_id should be treated as empty => true")
}
}
func TestPrepareAntigravityGeminiReasoningReplayRepairsSequentialCompactedUnknownResponseName(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
model := "gemini-3.6-flash-high"
sessionKey := antigravityReasoningReplayScopeFromPayload(model, []byte(`{"sessionId":"sess-seq-compact"}`)).sessionKey
item := []byte(`{
"type": "function_call_part",
"contentIndex": 1,
"partIndex": 0,
"targetOccurrence": 0,
"name": "Read",
"call_id": "call_seq_1",
"args": {"path": "/tmp/a"},
"thoughtSignature": "EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg"
}`)
if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, [][]byte{item}) {
t.Fatal("failed to cache replay item")
}
// Payload simulates Responses compaction where assistant function_call was dropped,
// and translator generated functionResponse with placeholder name "unknown".
compactedPayload := []byte(`{
"sessionId": "sess-seq-compact",
"request": {
"contents": [
{
"role": "user",
"parts": [{"text": "Read file /tmp/a"}]
},
{
"role": "user",
"parts": [
{
"functionResponse": {
"id": "call_seq_1",
"name": "unknown",
"response": {"output": "hello world"}
}
}
]
}
]
}
}`)
req := cliproxyexecutor.Request{Model: model, Payload: compactedPayload}
opts := cliproxyexecutor.Options{}
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, req, opts, compactedPayload)
if errPrepare != nil {
t.Fatalf("prepare failed unexpectedly: %v", errPrepare)
}
// Verify restored model functionCall has name "Read" and thoughtSignature
fcName := gjson.GetBytes(out, "request.contents.1.parts.0.functionCall.name").String()
fcID := gjson.GetBytes(out, "request.contents.1.parts.0.functionCall.id").String()
fcSig := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String()
if fcName != "Read" || fcID != "call_seq_1" || fcSig != "EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg" {
t.Fatalf("restored functionCall = name:%q id:%q sig:%q, want Read/call_seq_1/signature", fcName, fcID, fcSig)
}
// Verify user functionResponse.name was repaired from "unknown" to "Read"
frName := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.name").String()
frID := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.id").String()
if frName != "Read" || frID != "call_seq_1" {
t.Fatalf("repaired functionResponse = name:%q id:%q, want Read/call_seq_1", frName, frID)
}
// Verify ValidateGeminiFunctionCallPairing passes cleanly
if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(out); errPairing != nil {
t.Fatalf("ValidateGeminiFunctionCallPairing failed: %v", errPairing)
}
}
func TestPrepareAntigravityGeminiReasoningReplayRepairsParallelCompactedUnknownResponseName(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
model := "gemini-3.6-flash-high"
sessionKey := antigravityReasoningReplayScopeFromPayload(model, []byte(`{"sessionId":"sess-par-compact"}`)).sessionKey
item1 := []byte(`{
"type": "function_call_part",
"contentIndex": 1,
"partIndex": 0,
"targetOccurrence": 0,
"name": "Read",
"call_id": "call_par_1",
"args": {"path": "/tmp/a"},
"thoughtSignature": "EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg"
}`)
item2 := []byte(`{
"type": "function_call_part",
"contentIndex": 1,
"partIndex": 1,
"targetOccurrence": 0,
"name": "Grep",
"call_id": "call_par_2",
"args": {"query": "foo"},
"thoughtSignature": "EvQCCvECARFNMg/sZy4s+7HU2/PDOR12"
}`)
if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, [][]byte{item1, item2}) {
t.Fatal("failed to cache parallel replay items")
}
compactedPayload := []byte(`{
"sessionId": "sess-par-compact",
"request": {
"contents": [
{
"role": "user",
"parts": [{"text": "Read /tmp/a and Grep foo"}]
},
{
"role": "user",
"parts": [
{
"functionResponse": {
"id": "call_par_1",
"name": "unknown",
"response": {"output": "content a"}
}
},
{
"functionResponse": {
"id": "call_par_2",
"name": "unknown",
"response": {"output": "matches b"}
}
}
]
}
]
}
}`)
req := cliproxyexecutor.Request{Model: model, Payload: compactedPayload}
opts := cliproxyexecutor.Options{}
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, req, opts, compactedPayload)
if errPrepare != nil {
t.Fatalf("prepare parallel failed: %v", errPrepare)
}
// Verify parallel functionCall restoration
fc1Name := gjson.GetBytes(out, "request.contents.1.parts.0.functionCall.name").String()
fc2Name := gjson.GetBytes(out, "request.contents.1.parts.1.functionCall.name").String()
if fc1Name != "Read" || fc2Name != "Grep" {
t.Fatalf("restored parallel functionCalls = %q, %q, want Read, Grep", fc1Name, fc2Name)
}
// Verify parallel functionResponse.name repair
fr1Name := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.name").String()
fr2Name := gjson.GetBytes(out, "request.contents.2.parts.1.functionResponse.name").String()
if fr1Name != "Read" || fr2Name != "Grep" {
t.Fatalf("repaired parallel functionResponses = %q, %q, want Read, Grep", fr1Name, fr2Name)
}
// Verify strict pairing validation succeeds
if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(out); errPairing != nil {
t.Fatalf("ValidateGeminiFunctionCallPairing failed: %v", errPairing)
}
}
func TestAntigravityFunctionCallMatchesReplayItemOnlyIgnoresDeclaredDefaults(t *testing.T) {
item := gjson.Parse(`{"name":"Edit","args":{"path":"/tmp/a"}}`)
schemas := map[string]any{"Edit": map[string]any{"type": "object", "properties": map[string]any{"replace_all": map[string]any{"type": "boolean", "default": false}}}}
declaredDefault := gjson.Parse(`{"name":"Edit","args":{"path":"/tmp/a","replace_all":false}}`)
if !antigravityFunctionCallMatchesReplayItem(declaredDefault, item, schemas) {
t.Fatal("declared schema default should be semantically equivalent")
}
changedDefault := gjson.Parse(`{"name":"Edit","args":{"path":"/tmp/a","replace_all":true}}`)
if antigravityFunctionCallMatchesReplayItem(changedDefault, item, schemas) {
t.Fatal("non-default value must not match native args")
}
undeclaredExtra := gjson.Parse(`{"name":"Edit","args":{"path":"/tmp/a","force":false}}`)
if antigravityFunctionCallMatchesReplayItem(undeclaredExtra, item, schemas) {
t.Fatal("undeclared client arg must not match native args")
}
}
func TestPrepareAntigravityGeminiReasoningReplayRestoresClaudeToolProvenanceWithSchemaDefault(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const model = "gemini-3.6-flash-high"
const nativeID = "native-edit-1"
const signature = "EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg"
const nativeArgs = `{"file_path":"/tmp/a","old_string":"x","new_string":"y"}`
clientID := util.GeminiClaudeToolUseID(nativeID, "Edit", nativeArgs)
payload := []byte(`{"sessionId":"sess-claude-default","request":{"contents":[{"role":"user","parts":[{"text":"edit"}]},{"role":"model","parts":[{"thoughtSignature":"skip_thought_signature_validator","functionCall":{"id":"` + clientID + `","name":"Edit","args":{"file_path":"/tmp/a","old_string":"x","new_string":"y","replace_all":false}}}]},{"role":"user","parts":[{"functionResponse":{"id":"` + clientID + `","name":"Edit","response":{"result":"ok"}}}]}]}}`)
item := []byte(`{"type":"function_call_part","contentIndex":1,"partIndex":0,"targetOccurrence":0,"name":"Edit","call_id":"` + nativeID + `","args":` + nativeArgs + `,"thoughtSignature":"` + signature + `"}`)
sessionKey := antigravityReasoningReplayScopeFromPayload(model, payload).sessionKey
if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, [][]byte{item}) {
t.Fatal("failed to cache native Edit provenance")
}
original := []byte(`{"model":"` + model + `","tools":[{"name":"Edit","input_schema":{"type":"object","properties":{"file_path":{"type":"string"},"old_string":{"type":"string"},"new_string":{"type":"string"},"replace_all":{"type":"boolean","default":false}}}}]}`)
opts := cliproxyexecutor.Options{OriginalRequest: original, SourceFormat: sdktranslator.FromString("claude")}
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{Model: model, Payload: payload}, opts, payload)
if errPrepare != nil {
t.Fatalf("prepare failed: %v", errPrepare)
}
call := gjson.GetBytes(out, "request.contents.1.parts.0")
if call.Get("functionCall.id").String() != nativeID || call.Get("functionCall.name").String() != "Edit" || call.Get("thoughtSignature").String() != signature {
t.Fatalf("native call provenance was not restored: %s", call.Raw)
}
if call.Get("functionCall.args.replace_all").Exists() {
t.Fatalf("client-inserted schema default leaked into restored native call: %s", call.Raw)
}
response := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse")
if response.Get("id").String() != nativeID || response.Get("name").String() != "Edit" {
t.Fatalf("function response provenance was not restored: %s", response.Raw)
}
if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(out); errPairing != nil {
t.Fatalf("restored history is invalid: %v", errPairing)
}
}
func TestPrepareAntigravityGeminiReasoningReplayRestoresLegacyClaudeToolIDWithSchemaDefault(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const model = "gemini-3.6-flash-high"
payload := []byte(`{"sessionId":"sess-claude-legacy-default","request":{"contents":[{"role":"user","parts":[{"text":"edit"}]},{"role":"model","parts":[{"thoughtSignature":"skip_thought_signature_validator","functionCall":{"id":"Edit-legacy-client-id","name":"Edit","args":{"file_path":"/tmp/a","old_string":"x","new_string":"y","replace_all":false}}}]},{"role":"user","parts":[{"functionResponse":{"id":"Edit-legacy-client-id","name":"Edit","response":{"result":"ok"}}}]}]}}`)
item := []byte(`{"type":"function_call_part","contentIndex":1,"partIndex":0,"targetOccurrence":0,"name":"Edit","call_id":"native-edit-legacy","args":{"file_path":"/tmp/a","old_string":"x","new_string":"y"},"thoughtSignature":"EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg"}`)
sessionKey := antigravityReasoningReplayScopeFromPayload(model, payload).sessionKey
internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, [][]byte{item})
original := []byte(`{"tools":[{"name":"Edit","input_schema":{"type":"object","properties":{"replace_all":{"type":"boolean","default":false}}}}]}`)
opts := cliproxyexecutor.Options{OriginalRequest: original, SourceFormat: sdktranslator.FromString("claude")}
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{Model: model, Payload: payload}, opts, payload)
if errPrepare != nil {
t.Fatalf("prepare failed: %v", errPrepare)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.functionCall.id").String(); got != "native-edit-legacy" {
t.Fatalf("legacy client ID was not restored: %q", got)
}
if got := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.id").String(); got != "native-edit-legacy" {
t.Fatalf("legacy functionResponse ID was not restored: %q", got)
}
}
func TestPrepareAntigravityGeminiReasoningReplayDegradesWithoutClaudeToolProvenance(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const model = "gemini-3.6-flash-high"
clientID := util.GeminiClaudeToolUseID("native-missing", "Read", `{"file_path":"/tmp/a"}`)
payload := []byte(`{"sessionId":"sess-missing-provenance","request":{"contents":[{"role":"model","parts":[{"thoughtSignature":"skip_thought_signature_validator","functionCall":{"id":"` + clientID + `","name":"Read","args":{"file_path":"/tmp/a"}}}]},{"role":"user","parts":[{"functionResponse":{"id":"` + clientID + `","name":"Read","response":{"result":"ok"}}}]}]}}`)
opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")}
// An empty ledger must not kill the conversation: the reserved IDs are
// rewritten to neutral synthetic IDs and the request stays valid.
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{Model: model, Payload: payload}, opts, payload)
if errPrepare != nil {
t.Fatalf("prepare failed: %v", errPrepare)
}
if antigravityPayloadHasClaudeToolProvenanceID(out) {
t.Fatalf("reserved provenance IDs leaked upstream: %s", out)
}
call := gjson.GetBytes(out, "request.contents.0.parts.0")
response := gjson.GetBytes(out, "request.contents.1.parts.0.functionResponse")
callID := call.Get("functionCall.id").String()
if callID == "" || callID != response.Get("id").String() {
t.Fatalf("degraded call/response pairing broken: call=%q response=%q", callID, response.Get("id").String())
}
if got := call.Get("thoughtSignature").String(); got != internalsignature.GeminiSkipThoughtSignatureValidator {
t.Fatalf("first degraded call thoughtSignature = %q, want bypass sentinel", got)
}
if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(out); errPairing != nil {
t.Fatalf("degraded history is invalid: %v", errPairing)
}
}
func TestPrepareAntigravityGeminiReasoningReplayRestoresParallelClaudeToolProvenance(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const model = "gemini-3.6-flash-high"
const args1 = `{"file_path":"/tmp/a"}`
const args2 = `{"file_path":"/tmp/b"}`
clientID1 := util.GeminiClaudeToolUseID("native-read-1", "Read", args1)
clientID2 := util.GeminiClaudeToolUseID("native-read-2", "Read", args2)
payload := []byte(`{"sessionId":"sess-parallel-provenance","request":{"contents":[{"role":"model","parts":[{"thoughtSignature":"skip_thought_signature_validator","functionCall":{"id":"` + clientID1 + `","name":"Read","args":{"file_path":"/tmp/a","offset":0}}},{"functionCall":{"id":"` + clientID2 + `","name":"Read","args":{"file_path":"/tmp/b","offset":0}}}]},{"role":"user","parts":[{"functionResponse":{"id":"` + clientID2 + `","name":"Read","response":{"result":"b"}}},{"functionResponse":{"id":"` + clientID1 + `","name":"Read","response":{"result":"a"}}}]}]}}`)
items := [][]byte{
[]byte(`{"type":"function_call_part","contentIndex":0,"partIndex":0,"targetOccurrence":0,"name":"Read","call_id":"native-read-1","args":` + args1 + `,"thoughtSignature":"EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg"}`),
[]byte(`{"type":"function_call_part","contentIndex":0,"partIndex":1,"targetOccurrence":0,"name":"Read","call_id":"native-read-2","args":` + args2 + `}`),
}
sessionKey := antigravityReasoningReplayScopeFromPayload(model, payload).sessionKey
if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, items) {
t.Fatal("failed to cache parallel provenance")
}
original := []byte(`{"tools":[{"name":"Read","input_schema":{"type":"object","properties":{"file_path":{"type":"string"},"offset":{"type":"integer","default":0}}}}]}`)
opts := cliproxyexecutor.Options{OriginalRequest: original, SourceFormat: sdktranslator.FromString("claude")}
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{Model: model, Payload: payload}, opts, payload)
if errPrepare != nil {
t.Fatalf("prepare failed: %v", errPrepare)
}
calls := gjson.GetBytes(out, "request.contents.0.parts").Array()
if len(calls) != 2 || calls[0].Get("functionCall.id").String() != "native-read-1" || calls[1].Get("functionCall.id").String() != "native-read-2" {
t.Fatalf("parallel calls were not restored in native order: %s", out)
}
if calls[0].Get("thoughtSignature").String() == "" || calls[1].Get("thoughtSignature").Exists() {
t.Fatalf("signed/unsigned parallel provenance changed: %s", gjson.GetBytes(out, "request.contents.0").Raw)
}
responses := gjson.GetBytes(out, "request.contents.1.parts").Array()
if len(responses) != 2 || responses[0].Get("functionResponse.id").String() != "native-read-1" || responses[1].Get("functionResponse.id").String() != "native-read-2" {
t.Fatalf("parallel responses were not normalized to native order: %s", gjson.GetBytes(out, "request.contents.1").Raw)
}
if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(out); errPairing != nil {
t.Fatalf("parallel restored history is invalid: %v", errPairing)
}
}
func TestPrepareAntigravityGeminiReasoningReplayRejectsChangedClaudeToolArguments(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const model = "gemini-3.6-flash-high"
const nativeArgs = `{"file_path":"/tmp/a","old_string":"x","new_string":"y"}`
clientID := util.GeminiClaudeToolUseID("native-edit-changed", "Edit", nativeArgs)
payload := []byte(`{"sessionId":"sess-changed-args","request":{"contents":[{"role":"model","parts":[{"thoughtSignature":"skip_thought_signature_validator","functionCall":{"id":"` + clientID + `","name":"Edit","args":{"file_path":"/tmp/a","old_string":"x","new_string":"y","replace_all":true}}}]},{"role":"user","parts":[{"functionResponse":{"id":"` + clientID + `","name":"Edit","response":{"result":"ok"}}}]}]}}`)
item := []byte(`{"type":"function_call_part","contentIndex":0,"partIndex":0,"targetOccurrence":0,"name":"Edit","call_id":"native-edit-changed","args":` + nativeArgs + `,"thoughtSignature":"EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg"}`)
sessionKey := antigravityReasoningReplayScopeFromPayload(model, payload).sessionKey
internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, [][]byte{item})
original := []byte(`{"tools":[{"name":"Edit","input_schema":{"type":"object","properties":{"replace_all":{"type":"boolean","default":false}}}}]}`)
opts := cliproxyexecutor.Options{OriginalRequest: original, SourceFormat: sdktranslator.FromString("claude")}
// The client changed the arguments, so the native call must NOT be restored.
// The request still goes through, but only with a neutral synthetic ID and
// without the native identity or the cached signature.
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{Model: model, Payload: payload}, opts, payload)
if errPrepare != nil {
t.Fatalf("prepare failed: %v", errPrepare)
}
if antigravityPayloadHasClaudeToolProvenanceID(out) {
t.Fatalf("reserved provenance IDs leaked upstream: %s", out)
}
call := gjson.GetBytes(out, "request.contents.0.parts.0")
if got := call.Get("functionCall.id").String(); got == "native-edit-changed" {
t.Fatalf("native call ID was restored onto changed arguments: %s", out)
}
if got := call.Get("thoughtSignature").String(); got != internalsignature.GeminiSkipThoughtSignatureValidator {
t.Fatalf("changed call thoughtSignature = %q, want bypass sentinel and no native signature", got)
}
if !call.Get("functionCall.args.replace_all").Bool() {
t.Fatalf("client arguments were rewritten by replay: %s", call.Raw)
}
if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(out); errPairing != nil {
t.Fatalf("degraded history is invalid: %v", errPairing)
}
}
func TestPrepareAntigravityGeminiReasoningReplayRejectsUnmatchedNonPlaceholderResponseName(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
model := "gemini-3.6-flash-high"
sessionKey := antigravityReasoningReplayScopeFromPayload(model, []byte(`{"sessionId":"sess-mismatch"}`)).sessionKey
item := []byte(`{
"type": "function_call_part",
"contentIndex": 1,
"partIndex": 0,
"targetOccurrence": 0,
"name": "Read",
"call_id": "call_mismatch_1",
"args": {"path": "/tmp/a"},
"thoughtSignature": "EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg"
}`)
internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, [][]byte{item})
// Payload has a non-placeholder name mismatch ("Write" != "Read")
mismatchedPayload := []byte(`{
"sessionId": "sess-mismatch",
"request": {
"contents": [
{
"role": "user",
"parts": [{"text": "Do task"}]
},
{
"role": "user",
"parts": [
{
"functionResponse": {
"id": "call_mismatch_1",
"name": "Write",
"response": {"output": "ok"}
}
}
]
}
]
}
}`)
req := cliproxyexecutor.Request{Model: model, Payload: mismatchedPayload}
opts := cliproxyexecutor.Options{}
_, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, req, opts, mismatchedPayload)
if errPrepare == nil {
t.Fatal("expected 400 error for non-placeholder name mismatch, got nil")
}
if !strings.Contains(errPrepare.Error(), "invalid Gemini function call history") {
t.Fatalf("error = %v, want invalid Gemini function call history", errPrepare)
}
}
func TestPrepareAntigravityGeminiReasoningReplayRestoresIdentityOnContextDrift(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const model = "gemini-3.6-flash-high"
const args = `{"file_path":"/tmp/a"}`
clientID := util.GeminiClaudeToolUseID("native-drift", "Read", args)
payload := []byte(`{"sessionId":"sess-context-drift","request":{"contents":[{"role":"model","parts":[{"thoughtSignature":"skip_thought_signature_validator","functionCall":{"id":"` + clientID + `","name":"Read","args":` + args + `}}]},{"role":"user","parts":[{"functionResponse":{"id":"` + clientID + `","name":"Read","response":{"result":"ok"}}}]}]}}`)
// A stale contextHash stands in for compacted or rewritten history: the tool
// identity is still provable from the opaque ID, but the cached signature is
// no longer valid for this conversation.
item := []byte(`{"type":"function_call_part","contentIndex":0,"partIndex":0,"targetOccurrence":0,"name":"Read","call_id":"native-drift","args":` + args + `,"thoughtSignature":"EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg","contextHash":"0000000000000000000000000000000000000000000000000000000000000000"}`)
sessionKey := antigravityReasoningReplayScopeFromPayload(model, payload).sessionKey
if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, [][]byte{item}) {
t.Fatal("failed to cache drifted provenance")
}
opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")}
out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{Model: model, Payload: payload}, opts, payload)
if errPrepare != nil {
t.Fatalf("prepare failed: %v", errPrepare)
}
if antigravityPayloadHasClaudeToolProvenanceID(out) {
t.Fatalf("reserved provenance IDs leaked upstream: %s", out)
}
call := gjson.GetBytes(out, "request.contents.0.parts.0")
if got := call.Get("functionCall.id").String(); got != "native-drift" {
t.Fatalf("functionCall.id = %q, want native identity restored despite context drift", got)
}
if got := gjson.GetBytes(out, "request.contents.1.parts.0.functionResponse.id").String(); got != "native-drift" {
t.Fatalf("functionResponse.id = %q, want native identity restored", got)
}
if got := call.Get("thoughtSignature").String(); !antigravityHasNativeThoughtSignature(got) {
t.Fatalf("thoughtSignature = %q, want the native signature replayed even though the context drifted", got)
}
if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(out); errPairing != nil {
t.Fatalf("restored history is invalid: %v", errPairing)
}
}
func TestDegradeAntigravityClaudeToolProvenanceIDsKeepsParallelShape(t *testing.T) {
ids := make([]string, 3)
for i := range ids {
ids[i] = util.GeminiClaudeToolUseID(fmt.Sprintf("native-%d", i), "Read", `{"file_path":"/tmp/a"}`)
}
payload := []byte(`{"request":{"contents":[{"role":"model","parts":[` +
`{"thoughtSignature":"EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg","functionCall":{"id":"` + ids[0] + `","name":"Read","args":{"file_path":"/tmp/a"}}},` +
`{"functionCall":{"id":"` + ids[1] + `","name":"Read","args":{"file_path":"/tmp/b"}}},` +
`{"functionCall":{"id":"` + ids[2] + `","name":"Read","args":{"file_path":"/tmp/c"}}}` +
`]},{"role":"user","parts":[` +
`{"functionResponse":{"id":"` + ids[0] + `","name":"Read","response":{"result":"a"}}},` +
`{"functionResponse":{"id":"` + ids[1] + `","name":"Read","response":{"result":"b"}}},` +
`{"functionResponse":{"id":"` + ids[2] + `","name":"Read","response":{"result":"c"}}}` +
`]}]}}`)
out, degraded := degradeAntigravityClaudeToolProvenanceIDs(payload)
out = antigravityRepairUnsignedFirstFunctionCalls(out)
if degraded != 6 {
t.Fatalf("degraded = %d, want 6 (3 calls + 3 responses)", degraded)
}
if antigravityPayloadHasClaudeToolProvenanceID(out) {
t.Fatalf("reserved provenance IDs leaked upstream: %s", out)
}
calls := gjson.GetBytes(out, "request.contents.0.parts").Array()
responses := gjson.GetBytes(out, "request.contents.1.parts").Array()
if len(calls) != 3 || len(responses) != 3 {
t.Fatalf("part counts changed: %d calls, %d responses", len(calls), len(responses))
}
signed := 0
for i, call := range calls {
signature := call.Get("thoughtSignature").String()
if signature != "" {
signed++
}
if i == 0 && !antigravityHasNativeThoughtSignature(signature) {
t.Fatalf("first call thoughtSignature = %q, want the in-band signature kept through degradation", signature)
}
if i > 0 && signature != "" {
t.Fatalf("sibling call %d gained a signature %q, want unsigned", i, signature)
}
if got := call.Get("functionCall.id").String(); got != responses[i].Get("functionResponse.id").String() {
t.Fatalf("call/response pairing broken at %d: %q vs %q", i, got, responses[i].Get("functionResponse.id").String())
}
}
if signed != 1 {
t.Fatalf("signed calls = %d, want exactly 1 signed + 2 unsigned native parallel shape", signed)
}
if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(out); errPairing != nil {
t.Fatalf("degraded history is invalid: %v", errPairing)
}
}
func TestPrepareAntigravityGeminiReasoningReplayStillRejectsBrokenPairing(t *testing.T) {
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const model = "gemini-3.6-flash-high"
clientID := util.GeminiClaudeToolUseID("native-orphan", "Read", `{"file_path":"/tmp/a"}`)
// A functionResponse with no preceding functionCall is structurally invalid and
// must keep failing even though provenance degradation is now in play.
payload := []byte(`{"sessionId":"sess-orphan","request":{"contents":[{"role":"user","parts":[{"functionResponse":{"id":"` + clientID + `","name":"Read","response":{"result":"ok"}}}]}]}}`)
opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")}
_, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{Model: model, Payload: payload}, opts, payload)
if errPrepare == nil || !strings.Contains(errPrepare.Error(), "invalid Gemini function call history") {
t.Fatalf("error = %v, want structural pairing rejection", errPrepare)
}
}