1789 lines
112 KiB
Go
1789 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 = antigravityReplayItemContextHashForTest(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 := newAntigravityReplayRequestIndex(payload1).contextFingerprint(2), newAntigravityReplayRequestIndex(payload2).contextFingerprint(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 := insertAntigravityReasoningReplayItemsWithSchemas(newAntigravityReplayRequestIndex(payload), payload, [][]byte{item}, nil)
|
|
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 = antigravityReplayItemContextHashForTest(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 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)
|
|
}
|
|
}
|