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