diff --git a/internal/runtime/executor/antigravity_reasoning_replay.go b/internal/runtime/executor/antigravity_reasoning_replay.go index 9619a239..9f395a1a 100644 --- a/internal/runtime/executor/antigravity_reasoning_replay.go +++ b/internal/runtime/executor/antigravity_reasoning_replay.go @@ -15,10 +15,42 @@ import ( 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" + log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) +// antigravityReplayLogKey returns a short, non-reversible tag for a replay +// identifier. Session keys and tool call IDs are never logged verbatim. +func antigravityReplayLogKey(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + sum := sha256.Sum256([]byte(value)) + return fmt.Sprintf("%x", sum[:8]) +} + +// antigravityCountClaudeToolProvenanceIDs reports how many reserved +// Claude-facing provenance IDs are still present in a Gemini-shaped payload. +func antigravityCountClaudeToolProvenanceIDs(payload []byte) int { + count := 0 + contents := gjson.GetBytes(payload, "request.contents") + if !contents.IsArray() { + return 0 + } + for _, content := range contents.Array() { + for _, part := range content.Get("parts").Array() { + for _, path := range []string{"functionCall.id", "functionResponse.id"} { + if util.IsGeminiClaudeToolUseID(part.Get(path).String()) { + count++ + } + } + } + } + return count +} + type antigravityReasoningReplayScope struct { modelName string sessionKey string @@ -208,8 +240,17 @@ func prepareAntigravityGeminiReasoningReplayPayload(ctx context.Context, modelNa } updated = normalizeAntigravityGeminiFunctionResponseRoles(updated) if antigravityPayloadHasClaudeToolProvenanceID(updated) { - return payload, scope, statusErr{code: http.StatusBadRequest, msg: "antigravity executor: missing Claude tool provenance; start a new session or restore replay state"} - } + // The replay ledger could not resolve every tool ID — the session lane + // changed, the entry expired, the process restarted, or a turn never + // committed. Degrade those calls instead of killing the conversation. + degradedPayload, degradedCount := degradeAntigravityClaudeToolProvenanceIDs(updated) + log.Warnf("antigravity executor: replay state missing for %d tool ID(s); rewriting them to synthetic IDs and continuing without reasoning replay for those calls", degradedCount) + updated = degradedPayload + } + // An identity-only restore drops the cached signature, which can leave a model + // turn's first function call unsigned. Gemini rejects that, so re-assert the + // invariant the pre-replay sanitizer established. + updated = antigravityRepairUnsignedFirstFunctionCalls(updated) if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(updated); errPairing != nil { originalPairingValid := internalsignature.ValidateGeminiFunctionCallPairing(payload) == nil if replayApplied && originalPairingValid && scope.valid() { @@ -244,7 +285,15 @@ func applyAntigravityReasoningReplayCache(ctx context.Context, modelName string, } items, snapshot, ok, err := internalcache.GetAntigravityReasoningReplayItemsWithSnapshotRequired(ctx, scope.modelName, scope.sessionKey) scope.cacheSnapshot = snapshot + reservedBefore := antigravityCountClaudeToolProvenanceIDs(payload) if err != nil || !ok || len(items) == 0 { + // A ledger miss on a payload that still carries reserved provenance IDs is + // the signature of a session/lane switch, cache expiry, or a turn that never + // committed. Log it so the two failure families stay distinguishable. + if reservedBefore > 0 { + log.Debugf("antigravity replay: ledger miss with %d reserved tool provenance ID(s) present (session=%s found=%t)", + reservedBefore, antigravityReplayLogKey(scope.sessionKey), ok) + } return payload, scope, false, err } updated := payload @@ -265,6 +314,11 @@ func applyAntigravityReasoningReplayCache(ctx context.Context, modelName string, updated = next changed = true } + if reservedBefore > 0 { + log.Debugf("antigravity replay: ledger items=%d reserved before=%d after=%d applied=%t (session=%s)", + len(items), reservedBefore, antigravityCountClaudeToolProvenanceIDs(updated), changed, + antigravityReplayLogKey(scope.sessionKey)) + } if !changed { return payload, scope, false, nil } @@ -292,6 +346,11 @@ func filterAntigravityReasoningReplayItemsForRequestWithSchemas(payload []byte, } break } + // Even without a context match, an exact opaque ID match can still + // restore the native call identity. + if _, _, foundProvenance := antigravityFunctionCallProvenanceLocation(payload, itemResult, toolSchemas); foundProvenance { + break + } callID := strings.TrimSpace(itemResult.Get("call_id").String()) if callID == "" { continue @@ -530,7 +589,15 @@ func antigravityFunctionCallPartLocationForReplayWithSchemas(payload []byte, ite if antigravityFunctionCallMatchesReplayItem(fc, itemResult, toolSchemas) { return ci, pi, true } + log.Debugf("antigravity replay: located call %q at contents[%d].parts[%d] but name/args did not match ledger item (opaque_id=%t)", + name, ci, pi, util.IsGeminiClaudeToolUseID(candidateID)) + return -1, -1, false } + // The candidate ID matched exactly, so callID+name+args are already proven + // identical. Only the surrounding context drifted, which invalidates the + // cached signature but not the tool identity. + log.Debugf("antigravity replay: exact tool ID match for %q at contents[%d].parts[%d] rejected by context hash (opaque_id=%t)", + name, ci, pi, util.IsGeminiClaudeToolUseID(candidateID)) return -1, -1, false } contents := gjson.GetBytes(payload, "request.contents") @@ -579,6 +646,37 @@ func antigravityFunctionCallPartLocationForReplayWithSchemas(payload []byte, ite return -1, -1, false } +// antigravityFunctionCallProvenanceLocation locates the function call whose +// Claude-facing opaque ID was derived from this exact ledger item. +// +// The opaque ID is sha256(call_id, name, args), so an exact match already proves +// that the call ID, tool name and arguments are identical to the provider-native +// call. The surrounding context hash adds nothing to that proof; it only decides +// whether the cached thoughtSignature is still valid. Callers therefore use this +// to recover tool identity after the context has drifted, without replaying any +// signature. +func antigravityFunctionCallProvenanceLocation(payload []byte, itemResult gjson.Result, toolSchemas map[string]any) (contentIndex int, partIndex int, ok bool) { + name := strings.TrimSpace(itemResult.Get("name").String()) + args := itemResult.Get("args") + callID := strings.TrimSpace(itemResult.Get("call_id").String()) + if name == "" || !args.Exists() || callID == "" { + return -1, -1, false + } + stableID := util.GeminiClaudeToolUseID(callID, name, args.Raw) + if stableID == "" || stableID == callID { + return -1, -1, false + } + ci, pi, found := antigravityFunctionCallPartLocation(payload, stableID) + if !found { + return -1, -1, false + } + fc := gjson.GetBytes(payload, fmt.Sprintf("request.contents.%d.parts.%d.functionCall", ci, pi)) + if !antigravityFunctionCallMatchesReplayItem(fc, itemResult, toolSchemas) { + return -1, -1, false + } + return ci, pi, true +} + func insertAntigravityModelFunctionCallBeforeContent(payload []byte, beforeIndex int, name, callID, thoughtSig string, args gjson.Result) ([]byte, bool) { contents := gjson.GetBytes(payload, "request.contents") if !contents.IsArray() { @@ -887,6 +985,103 @@ func antigravityPayloadHasClaudeToolProvenanceID(payload []byte) bool { return false } +// antigravitySyntheticToolCallID derives a deterministic neutral call ID for a +// reserved Claude-facing provenance ID that could not be resolved back to its +// provider-native call. It is stable across turns and never lands in the reserved +// namespace, so call/response pairs stay consistent without impersonating a +// provider-issued ID. +func antigravitySyntheticToolCallID(reservedID string) string { + sum := sha256.Sum256([]byte("antigravity-degraded-tool-call\x00" + reservedID)) + return fmt.Sprintf("call_%x", sum[:6]) +} + +// degradeAntigravityClaudeToolProvenanceIDs rewrites unresolved reserved tool +// provenance IDs to neutral synthetic IDs so a conversation survives a replay +// ledger miss instead of failing closed forever. +// +// The same reserved ID always maps to the same synthetic ID, so functionCall and +// functionResponse stay paired. Signatures on a rewritten call are dropped because +// they can no longer correspond to it; callers restore the leading call's bypass +// sentinel via antigravityRepairUnsignedFirstFunctionCalls. Every other part is +// left alone, preserving the native "1 signed + N unsigned" parallel-call shape. +func degradeAntigravityClaudeToolProvenanceIDs(payload []byte) ([]byte, int) { + contents := gjson.GetBytes(payload, "request.contents") + if !contents.IsArray() { + return payload, 0 + } + out := payload + degraded := 0 + for ci, content := range contents.Array() { + parts := content.Get("parts") + if !parts.IsArray() { + continue + } + for pi, part := range parts.Array() { + partPath := fmt.Sprintf("request.contents.%d.parts.%d", ci, pi) + if fc := part.Get("functionCall"); fc.Exists() { + id := strings.TrimSpace(fc.Get("id").String()) + if !util.IsGeminiClaudeToolUseID(id) { + continue + } + out, _ = sjson.SetBytes(out, partPath+".functionCall.id", antigravitySyntheticToolCallID(id)) + for _, field := range []string{"thoughtSignature", "thought_signature", "extra_content.google.thought_signature"} { + out, _ = sjson.DeleteBytes(out, partPath+"."+field) + } + degraded++ + continue + } + if fr := part.Get("functionResponse"); fr.Exists() { + id := strings.TrimSpace(fr.Get("id").String()) + if !util.IsGeminiClaudeToolUseID(id) { + continue + } + out, _ = sjson.SetBytes(out, partPath+".functionResponse.id", antigravitySyntheticToolCallID(id)) + degraded++ + } + } + } + return out, degraded +} + +// antigravityRepairUnsignedFirstFunctionCalls restores Gemini's bypass sentinel on +// the first function call of any model turn that replay left completely unsigned. +// +// Gemini rejects a model turn whose leading functionCall carries no +// thoughtSignature. The request-level sanitizer enforces that invariant, but it +// runs before reasoning replay, and replay can legitimately drop a signature +// afterwards: a degraded call loses one, and an identity-only restore on drifted +// context deliberately declines to replay one. Only a missing signature is filled +// in here, so native signatures are never touched. +func antigravityRepairUnsignedFirstFunctionCalls(payload []byte) []byte { + contents := gjson.GetBytes(payload, "request.contents") + if !contents.IsArray() { + return payload + } + out := payload + for ci, content := range contents.Array() { + if !strings.EqualFold(strings.TrimSpace(content.Get("role").String()), "model") { + continue + } + parts := content.Get("parts") + if !parts.IsArray() { + continue + } + for pi, part := range parts.Array() { + if !part.Get("functionCall").Exists() { + continue + } + if antigravityNativePartThoughtSignature(part) == "" { + out, _ = sjson.SetBytes(out, fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", ci, pi), + internalsignature.GeminiSkipThoughtSignatureValidator) + } + // Only the first function call of a turn needs a signature; siblings stay + // unsigned to preserve the native parallel-call shape. + break + } + } + return out +} + func antigravityCanonicalReplayJSON(raw []byte) []byte { var value any if json.Unmarshal(raw, &value) != nil { @@ -1100,7 +1295,12 @@ func antigravityFunctionResponsesCanRestoreID(payload []byte, currentID, nativeN return valid } -func restoreAntigravityNativeFunctionCallReplay(payload []byte, contentIndex, partIndex int, itemResult gjson.Result, allowLegacyIDRestore bool) ([]byte, bool) { +// restoreAntigravityNativeFunctionCallReplay rewrites one function call part back +// to its provider-native identity. allowSignature reports whether the cached +// thoughtSignature may be replayed as well; identity-only restores pass false +// because the surrounding context no longer matches the one the signature was +// issued for. +func restoreAntigravityNativeFunctionCallReplay(payload []byte, contentIndex, partIndex int, itemResult gjson.Result, allowLegacyIDRestore, allowSignature bool) ([]byte, bool) { partPath := fmt.Sprintf("request.contents.%d.parts.%d", contentIndex, partIndex) currentCall := gjson.GetBytes(payload, partPath+".functionCall") if !currentCall.Exists() { @@ -1112,7 +1312,7 @@ func restoreAntigravityNativeFunctionCallReplay(payload []byte, contentIndex, pa restoreIdentity := currentID == nativeID || util.IsGeminiClaudeToolUseID(currentID) || allowLegacyIDRestore if !restoreIdentity { signature := strings.TrimSpace(itemResult.Get("thoughtSignature").String()) - if signature == "" || antigravityHasNativeThoughtSignature(gjson.GetBytes(payload, partPath+".thoughtSignature").String()) { + if !allowSignature || signature == "" || antigravityHasNativeThoughtSignature(gjson.GetBytes(payload, partPath+".thoughtSignature").String()) { return payload, false } payload = antigravityRemoveThoughtSignatureFromOtherParts(payload, contentIndex, signature, partPath) @@ -1133,7 +1333,7 @@ func restoreAntigravityNativeFunctionCallReplay(payload []byte, contentIndex, pa for _, field := range []string{"thoughtSignature", "thought_signature", "extra_content.google.thought_signature"} { out, _ = sjson.DeleteBytes(out, partPath+"."+field) } - if signature := strings.TrimSpace(itemResult.Get("thoughtSignature").String()); signature != "" { + if signature := strings.TrimSpace(itemResult.Get("thoughtSignature").String()); allowSignature && signature != "" { out = antigravityRemoveThoughtSignatureFromOtherParts(out, contentIndex, signature, partPath) out, _ = sjson.SetBytes(out, partPath+".thoughtSignature", signature) } @@ -1170,12 +1370,21 @@ func mergeAntigravityFunctionCallPartReplayWithSchemas(payload []byte, itemResul } if ci, pi, exists := antigravityFunctionCallPartLocationForReplayWithSchemas(payload, itemResult, toolSchemas); exists { _, allowLegacyIDRestore := toolSchemas[name] - return restoreAntigravityNativeFunctionCallReplay(payload, ci, pi, itemResult, allowLegacyIDRestore) + return restoreAntigravityNativeFunctionCallReplay(payload, ci, pi, itemResult, allowLegacyIDRestore, true) + } + // The context drifted, but an exact opaque ID match still proves this call's + // identity. Restore the native call so the request stays replayable, and leave + // it unsigned rather than replaying a signature issued for a different history. + if ci, pi, exists := antigravityFunctionCallProvenanceLocation(payload, itemResult, toolSchemas); exists { + return restoreAntigravityNativeFunctionCallReplay(payload, ci, pi, itemResult, false, false) } if callID != "" { - if antigravityPayloadHasFunctionCallID(payload, callID) { - // The ID is present but its semantic payload did not match above. Never - // replay or reinsert an opaque signature onto that changed call. + stableID := util.GeminiClaudeToolUseID(callID, name, args.Raw) + if antigravityPayloadHasFunctionCallID(payload, callID) || (stableID != "" && antigravityPayloadHasFunctionCallID(payload, stableID)) { + // The call is already in the history under its native or Claude-facing + // ID, and neither lookup above accepted it, so the client changed it. + // Never replay an opaque signature onto that changed call, and never + // insert a second copy of it further down. return payload, false } if frIndex, currentResponseID, ok := antigravityFunctionResponseContentIndexForReplay(payload, itemResult); ok { @@ -1700,7 +1909,14 @@ func (a *antigravityReasoningReplayAccumulator) appendPendingThoughtSignatures() } func (a *antigravityReasoningReplayAccumulator) Commit(ctx context.Context) { - if a == nil || !a.scope.valid() || !a.terminal { + if a == nil || !a.scope.valid() { + return + } + log.Debugf("antigravity replay: accumulator commit terminal=%t overflow=%t items=%d (session=%s)", + a.terminal, a.overflow, len(a.items), antigravityReplayLogKey(a.scope.sessionKey)) + if !a.terminal { + // No terminal finishReason means the stream never completed, so this turn + // contributes nothing to the ledger and its tool IDs become unresolvable. return } if a.overflow { diff --git a/internal/runtime/executor/antigravity_reasoning_replay_test.go b/internal/runtime/executor/antigravity_reasoning_replay_test.go index 10e40fb8..65bb93a3 100644 --- a/internal/runtime/executor/antigravity_reasoning_replay_test.go +++ b/internal/runtime/executor/antigravity_reasoning_replay_test.go @@ -759,8 +759,10 @@ func TestAntigravityReasoningReplayAccumulatorCountsExistingFunctionOccurrenceTh 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 || parts[0].Get("thoughtSignature").String() != "" || parts[1].Get("thoughtSignature").String() != signature { + 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) } } @@ -843,7 +845,7 @@ func TestPrepareAntigravityGeminiReasoningReplayRejectsReusedIDWithChangedCall(t if errPrepare != nil { t.Fatal(errPrepare) } - if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "" { + 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 != "" { @@ -875,7 +877,7 @@ func TestPrepareAntigravityGeminiReasoningReplayRejectsChangedIDLessCallAtSamePo if errPrepare != nil { t.Fatal(errPrepare) } - if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "" { + 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) } } @@ -1469,7 +1471,7 @@ func TestPrepareAntigravityGeminiReasoningReplayRestoresLegacyClaudeToolIDWithSc } } -func TestPrepareAntigravityGeminiReasoningReplayFailsClosedWithoutClaudeToolProvenance(t *testing.T) { +func TestPrepareAntigravityGeminiReasoningReplayDegradesWithoutClaudeToolProvenance(t *testing.T) { internalcache.ClearAntigravityReasoningReplayCache() t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) @@ -1478,9 +1480,26 @@ func TestPrepareAntigravityGeminiReasoningReplayFailsClosedWithoutClaudeToolProv 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")} - _, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{Model: model, Payload: payload}, opts, payload) - if errPrepare == nil || !strings.Contains(errPrepare.Error(), "missing Claude tool provenance") { - t.Fatalf("error = %v, want fail-closed missing provenance", errPrepare) + // 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) } } @@ -1539,9 +1558,28 @@ func TestPrepareAntigravityGeminiReasoningReplayRejectsChangedClaudeToolArgument 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")} - _, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{Model: model, Payload: payload}, opts, payload) - if errPrepare == nil || !strings.Contains(errPrepare.Error(), "missing Claude tool provenance") { - t.Fatalf("error = %v, want fail-closed changed arguments", errPrepare) + // 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) } } @@ -1599,3 +1637,116 @@ func TestPrepareAntigravityGeminiReasoningReplayRejectsUnmatchedNonPlaceholderRe 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 no native signature replayed on drifted context", 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 && signature != internalsignature.GeminiSkipThoughtSignatureValidator { + t.Fatalf("first call thoughtSignature = %q, want bypass sentinel", 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 strings.Contains(string(out), "EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg") { + t.Fatalf("stale native signature leaked after degradation: %s", out) + } + 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) + } +} -- 2.51.2 From d2c0c58b75161f46139462fc74f1d95c26c099ad Mon Sep 17 00:00:00 2001 From: sususu Date: Tue, 28 Jul 2026 11:31:46 +0800 Subject: [PATCH 2/3] fix(antigravity): keep thought signatures when replay context drifts Reasoning replay treated a changed history as proof that a cached thought signature had become invalid, so a drifted context or a ledger miss dropped the signature and left the call on the bypass sentinel. Once that happened the damage cascaded: every later ledger item verifies its contextHash against the restored bytes of all preceding contents, so one broken link cost the whole tail of the conversation its reasoning. Testing the assumption directly against daily-cloudcode-pa.googleapis.com shows it does not hold. Gemini validates a thought signature's own integrity and nothing else -- corrupting one byte returns "Corrupted thought signature", while changing the system instruction, adding a tool, rewriting an earlier call's args, rewriting a tool result, or swapping two turns' signatures are all accepted. Only the newest functionCall group has to carry a signature at all, and the bypass sentinel satisfies that. Keeping the signature is worth doing rather than merely harmless: on an otherwise identical request, replacing every signature with the sentinel raises thoughtsTokenCount from 11-15 to 41-53, so a broken chain makes the model re-reason from scratch. Restore the signature on the identity-only path, and stop deleting the client's in-band signature while degrading unresolved provenance IDs. Argument integrity is unaffected: that is the opaque digest ID's job, not the signature's. --- .../executor/antigravity_reasoning_replay.go | 20 +++++++++---------- .../antigravity_reasoning_replay_test.go | 11 ++++------ 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/internal/runtime/executor/antigravity_reasoning_replay.go b/internal/runtime/executor/antigravity_reasoning_replay.go index 9f395a1a..fbe0fc28 100644 --- a/internal/runtime/executor/antigravity_reasoning_replay.go +++ b/internal/runtime/executor/antigravity_reasoning_replay.go @@ -1000,10 +1000,12 @@ func antigravitySyntheticToolCallID(reservedID string) string { // ledger miss instead of failing closed forever. // // The same reserved ID always maps to the same synthetic ID, so functionCall and -// functionResponse stay paired. Signatures on a rewritten call are dropped because -// they can no longer correspond to it; callers restore the leading call's bypass -// sentinel via antigravityRepairUnsignedFirstFunctionCalls. Every other part is -// left alone, preserving the native "1 signed + N unsigned" parallel-call shape. +// functionResponse stay paired. Whatever signature the client carried in-band is +// kept: Gemini validates a thought signature's own integrity, not its binding to +// the call ID or the surrounding history, so rewriting the ID does not invalidate +// it. Calls left with no signature at all get the leading bypass sentinel from +// antigravityRepairUnsignedFirstFunctionCalls. Every other part is left alone, +// preserving the native "1 signed + N unsigned" parallel-call shape. func degradeAntigravityClaudeToolProvenanceIDs(payload []byte) ([]byte, int) { contents := gjson.GetBytes(payload, "request.contents") if !contents.IsArray() { @@ -1024,9 +1026,6 @@ func degradeAntigravityClaudeToolProvenanceIDs(payload []byte) ([]byte, int) { continue } out, _ = sjson.SetBytes(out, partPath+".functionCall.id", antigravitySyntheticToolCallID(id)) - for _, field := range []string{"thoughtSignature", "thought_signature", "extra_content.google.thought_signature"} { - out, _ = sjson.DeleteBytes(out, partPath+"."+field) - } degraded++ continue } @@ -1373,10 +1372,11 @@ func mergeAntigravityFunctionCallPartReplayWithSchemas(payload []byte, itemResul return restoreAntigravityNativeFunctionCallReplay(payload, ci, pi, itemResult, allowLegacyIDRestore, true) } // The context drifted, but an exact opaque ID match still proves this call's - // identity. Restore the native call so the request stays replayable, and leave - // it unsigned rather than replaying a signature issued for a different history. + // identity. Gemini validates a thought signature's own integrity and nothing + // about the history around it, so the drift costs the signature nothing: restore + // the native call and its signature rather than making the model re-reason. if ci, pi, exists := antigravityFunctionCallProvenanceLocation(payload, itemResult, toolSchemas); exists { - return restoreAntigravityNativeFunctionCallReplay(payload, ci, pi, itemResult, false, false) + return restoreAntigravityNativeFunctionCallReplay(payload, ci, pi, itemResult, false, true) } if callID != "" { stableID := util.GeminiClaudeToolUseID(callID, name, args.Raw) diff --git a/internal/runtime/executor/antigravity_reasoning_replay_test.go b/internal/runtime/executor/antigravity_reasoning_replay_test.go index 65bb93a3..1b05d8be 100644 --- a/internal/runtime/executor/antigravity_reasoning_replay_test.go +++ b/internal/runtime/executor/antigravity_reasoning_replay_test.go @@ -1670,8 +1670,8 @@ func TestPrepareAntigravityGeminiReasoningReplayRestoresIdentityOnContextDrift(t 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 no native signature replayed on drifted context", 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) @@ -1713,8 +1713,8 @@ func TestDegradeAntigravityClaudeToolProvenanceIDsKeepsParallelShape(t *testing. if signature != "" { signed++ } - if i == 0 && signature != internalsignature.GeminiSkipThoughtSignatureValidator { - t.Fatalf("first call thoughtSignature = %q, want bypass sentinel", signature) + 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) @@ -1726,9 +1726,6 @@ func TestDegradeAntigravityClaudeToolProvenanceIDsKeepsParallelShape(t *testing. if signed != 1 { t.Fatalf("signed calls = %d, want exactly 1 signed + 2 unsigned native parallel shape", signed) } - if strings.Contains(string(out), "EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg") { - t.Fatalf("stale native signature leaked after degradation: %s", out) - } if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(out); errPairing != nil { t.Fatalf("degraded history is invalid: %v", errPairing) } -- 2.51.2 From a06e21b439990b8d50cf1a514354143692e734aa Mon Sep 17 00:00:00 2001 From: sususu Date: Tue, 28 Jul 2026 11:52:03 +0800 Subject: [PATCH 3/3] fix(antigravity): replay text thought signatures across context drift A cached text signature is pinned to its part by that part's own content fingerprint. Gemini validates a signature's own integrity and never its binding to the surrounding history, so drift elsewhere in the conversation cannot invalidate it. Gating the fingerprinted lookup on the context hash only discarded reasoning the model then had to redo. The legacy positional fallback has no such proof and stays gated. --- .../executor/antigravity_reasoning_replay.go | 14 +++++++--- .../antigravity_reasoning_replay_test.go | 27 +++++++++++++++++-- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/internal/runtime/executor/antigravity_reasoning_replay.go b/internal/runtime/executor/antigravity_reasoning_replay.go index fbe0fc28..7c8e93a0 100644 --- a/internal/runtime/executor/antigravity_reasoning_replay.go +++ b/internal/runtime/executor/antigravity_reasoning_replay.go @@ -1115,9 +1115,6 @@ func antigravityThoughtSignatureReplayPartPath(payload []byte, itemResult gjson. if ci < 0 || ci >= len(contentArr) || !strings.EqualFold(strings.TrimSpace(contentArr[ci].Get("role").String()), "model") { return "", false } - if !antigravityReplayItemContextMatches(payload, itemResult, ci) { - return "", false - } parts := contentArr[ci].Get("parts") if !parts.IsArray() { return "", false @@ -1125,6 +1122,12 @@ func antigravityThoughtSignatureReplayPartPath(payload []byte, itemResult gjson. partArr := parts.Array() targetKind := strings.TrimSpace(itemResult.Get("targetKind").String()) targetHash := strings.TrimSpace(itemResult.Get("targetHash").String()) + // A target hash pins the signature to a part whose own bytes are unchanged, + // which is all Gemini validates: the signature's own integrity, never its + // binding to the surrounding history. Drift elsewhere in the conversation + // therefore costs this signature nothing, so it is deliberately not gated on + // the context fingerprint. The fallback below has no such proof and stays + // gated. if targetHash != "" { if targetOccurrence := itemResult.Get("targetOccurrence"); targetOccurrence.Exists() { wanted := int(targetOccurrence.Int()) @@ -1157,6 +1160,11 @@ func antigravityThoughtSignatureReplayPartPath(payload []byte, itemResult gjson. return "", false } + // No target hash: nothing proves which part this signature belongs to, so + // only a matching context fingerprint makes the positional guess safe. + if !antigravityReplayItemContextMatches(payload, itemResult, ci) { + return "", false + } pi := int(itemResult.Get("partIndex").Int()) if pi >= 0 && pi < len(partArr) && partArr[pi].Type != gjson.Null { if kind, _ := antigravityReplayPartFingerprint(partArr[pi]); kind != "" { diff --git a/internal/runtime/executor/antigravity_reasoning_replay_test.go b/internal/runtime/executor/antigravity_reasoning_replay_test.go index 1b05d8be..bfa508eb 100644 --- a/internal/runtime/executor/antigravity_reasoning_replay_test.go +++ b/internal/runtime/executor/antigravity_reasoning_replay_test.go @@ -1016,7 +1016,7 @@ func TestAntigravityReasoningReplayDoesNotCommitPartialResponse(t *testing.T) { } } -func TestPrepareAntigravityGeminiReasoningReplayRejectsFingerprintMismatch(t *testing.T) { +func TestPrepareAntigravityGeminiReasoningReplayKeepsTextSignatureOnContextDrift(t *testing.T) { internalcache.ClearAntigravityReasoningReplayCache() t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) @@ -1031,8 +1031,31 @@ func TestPrepareAntigravityGeminiReasoningReplayRejectsFingerprintMismatch(t *te 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("mismatched rebuilt context received stale signature %q; body=%s", got, out) + t.Fatalf("edited part received stale signature %q; body=%s", got, out) } }