From a06e21b439990b8d50cf1a514354143692e734aa Mon Sep 17 00:00:00 2001 From: sususu Date: Tue, 28 Jul 2026 11:52:03 +0800 Subject: [PATCH] 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) } } -- 2.51.2