diff --git a/src/adapters/google-antigravity-replay.ts b/src/adapters/google-antigravity-replay.ts index 514008a983..178abf3710 100644 --- a/src/adapters/google-antigravity-replay.ts +++ b/src/adapters/google-antigravity-replay.ts @@ -745,9 +745,6 @@ export function applyAntigravityReplay(model: string, sessionId: string, content const now = Date.now(); deleteExpiredReplaySessionsThrottled(now); const entry = replayCache.get(replayKey(model, sessionId)); - if (!entry) { - return contents; - } let touched = false; // Align from the END of the recorded signature list. History may have been truncated @@ -758,6 +755,21 @@ export function applyAntigravityReplay(model: string, sessionId: string, content for (let ci = (contents as { role?: string; parts?: unknown[] }[]).length - 1; ci >= 0; ci--) { const c = (contents as { role?: string; parts?: unknown[] }[])[ci]; if (!c || typeof c !== "object" || c.role !== "model" || !Array.isArray(c.parts)) continue; + let turnHasSignature = false; + let firstMissingPart: Record | undefined; + for (let pi = 0; pi < c.parts.length; pi++) { + const raw = c.parts[pi]; + if (!raw || typeof raw !== "object") continue; + const part = raw as Record; + const fc = part.functionCall as { name?: unknown; args?: unknown } | undefined; + if (!fc) continue; + if (part.thoughtSignature !== undefined || part.thought_signature !== undefined) { + turnHasSignature = true; + } else if (!firstMissingPart) { + firstMissingPart = part; + } + } + if (entry) { for (let pi = c.parts.length - 1; pi >= 0; pi--) { const raw = c.parts[pi]; if (!raw || typeof raw !== "object") continue; @@ -809,15 +821,24 @@ export function applyAntigravityReplay(model: string, sessionId: string, content entry.byCall.delete(matchedKey); entry.byCall.set(matchedKey, { ...call, touchedAtMs: now }); touched = true; + turnHasSignature = true; } } else if (part.thoughtSignature === undefined && part.thought_signature === undefined && call) { part.thoughtSignature = call.signature; touched = true; + turnHasSignature = true; } } + } + // Fallback: Gemini models require thought_signature on the first functionCall part of a turn. + // If neither the wire metadata nor replay cache had a valid signature for this model turn, + // inject the official validator bypass token on the first functionCall part. + if (!turnHasSignature && firstMissingPart) { + firstMissingPart.thoughtSignature = "skip_thought_signature_validator"; + } } - if (touched) { + if (touched && entry) { entry.lastActiveAtMs = now; refreshReplaySessionCandidate(replayKey(model, sessionId), entry); markReplayDirty(); diff --git a/tests/google-antigravity-replay.test.ts b/tests/google-antigravity-replay.test.ts index a21a57d84e..ab892fa5c9 100644 --- a/tests/google-antigravity-replay.test.ts +++ b/tests/google-antigravity-replay.test.ts @@ -78,7 +78,7 @@ describe("antigravity reasoning-replay cache", () => { observeAntigravityReplay(MODEL, SESSION, [fcPart("get_x", {}, "short")]); const contents = [{ role: "model", parts: [{ functionCall: { name: "get_x", args: {} } }] }]; applyAntigravityReplay(MODEL, SESSION, contents); - expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined(); + expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe("skip_thought_signature_validator"); }); test("does not clobber an existing signature on the outgoing part", () => { @@ -153,7 +153,7 @@ describe("antigravity reasoning-replay cache", () => { ], }]; applyAntigravityReplay(MODEL, SESSION, contents); - expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined(); + expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe("skip_thought_signature_validator"); expect((contents[0].parts[1] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined(); }); @@ -165,7 +165,7 @@ describe("antigravity reasoning-replay cache", () => { ]); const contents = [{ role: "model", parts: [{ functionCall: { name: "get_x", args: {} } }] }]; applyAntigravityReplay(MODEL, SESSION, contents); - expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined(); + expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe("skip_thought_signature_validator"); }); test("clear-on-invalid empties the entry", () => { @@ -173,7 +173,7 @@ describe("antigravity reasoning-replay cache", () => { clearAntigravityReplay(MODEL, SESSION); const contents = [{ role: "model", parts: [{ functionCall: { name: "get_x", args: {} } }] }]; applyAntigravityReplay(MODEL, SESSION, contents); - expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined(); + expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe("skip_thought_signature_validator"); }); test("retains EVERY signature across a sequential tool loop (regression)", () => { @@ -278,7 +278,7 @@ describe("antigravity reasoning-replay cache", () => { }]; try { applyAntigravityReplay(MODEL, SESSION, contents); - expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined(); + expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe("skip_thought_signature_validator"); expect(parseCalled).toBe(false); expect(encodeCalledOnPayload).toBe(false); } finally { @@ -313,7 +313,7 @@ describe("antigravity reasoning-replay cache", () => { observeAntigravityReplay(MODEL, SESSION, [fcPart("three", {}, "sig-three-cccccccccc")]); const contents = ["one", "two", "three"].map(name => ({ role: "model", parts: [fcPart(name, {})] })); applyAntigravityReplay(MODEL, SESSION, contents); - expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined(); + expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe("skip_thought_signature_validator"); expect((contents[1].parts[0] as { thoughtSignature?: string }).thoughtSignature).toContain("sig-two"); expect((contents[2].parts[0] as { thoughtSignature?: string }).thoughtSignature).toContain("sig-three"); }); @@ -330,7 +330,7 @@ describe("antigravity reasoning-replay cache", () => { expect(metrics.calls).toBe(2); const contents = ["one", "two", "three"].map(name => ({ role: "model", parts: [fcPart(name, {})] })); applyAntigravityReplay(MODEL, SESSION, contents); - expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined(); + expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe("skip_thought_signature_validator"); }); test("does not cache one oversized signature", () => { @@ -354,7 +354,7 @@ describe("antigravity reasoning-replay cache", () => { now = 1_001 + 60 * 60 * 1000; const expired = [{ role: "model", parts: [fcPart("one", {})] }]; applyAntigravityReplay(MODEL, SESSION, expired); - expect((expired[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined(); + expect((expired[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe("skip_thought_signature_validator"); } finally { Date.now = originalNow; } @@ -382,7 +382,7 @@ describe("antigravity reasoning-replay cache", () => { expect(antigravityReplayRetainedStoreSnapshot().bytes).toBe(before.bytes - released); const old = [{ role: "model", parts: [fcPart("one", {})] }]; applyAntigravityReplay(MODEL, "old", old); - expect((old[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined(); + expect((old[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe("skip_thought_signature_validator"); } finally { Date.now = originalNow; } @@ -705,7 +705,7 @@ describe("durable antigravity replay snapshot", () => { expect(keys).toContain(antigravityReplayKeyForTests(MODEL, SESSION)); // The surviving session still replays. applyAntigravityReplay(MODEL, "-stale", staleContents); - expect((staleContents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined(); + expect((staleContents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe("skip_thought_signature_validator"); const contents = [{ role: "model", parts: [{ functionCall: { name: "get_x", args: { a: 1 } } }] }]; applyAntigravityReplay(MODEL, SESSION, contents); expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe(SIG); @@ -854,11 +854,11 @@ describe("durable antigravity replay snapshot", () => { writeFileSync(snapshotPath(), "{not valid json"); const contents = [{ role: "model", parts: [{ functionCall: { name: "get_x", args: { a: 1 } } }] }]; applyAntigravityReplay(MODEL, SESSION, contents); - expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined(); + expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe("skip_thought_signature_validator"); setAntigravityReplayLimitsForTests(); writeFileSync(snapshotPath(), JSON.stringify({ version: 99, sessions: [] })); applyAntigravityReplay(MODEL, SESSION, contents); - expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined(); + expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe("skip_thought_signature_validator"); }); test("clear-on-invalid removes the session from the next snapshot", async () => { @@ -873,7 +873,7 @@ describe("durable antigravity replay snapshot", () => { writeFileSync(snapshotPath(), "x".repeat(33 * 1024 * 1024)); const contents = [{ role: "model", parts: [{ functionCall: { name: "get_x", args: { a: 1 } } }] }]; applyAntigravityReplay(MODEL, SESSION, contents); - expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined(); + expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe("skip_thought_signature_validator"); }); test("flush waits out a blocked writer and persists mutations that land during it", async () => { @@ -1000,4 +1000,19 @@ describe("durable antigravity replay snapshot", () => { warn.mockRestore(); } }); + test("fallback to skip_thought_signature_validator on the first functionCall when replay cache misses", () => { + const contents = [ + { + role: "model", + parts: [ + { functionCall: { name: "exec", args: { input: "ls" } } }, + { functionCall: { name: "read", args: { path: "/a" } } } + ] + } + ]; + applyAntigravityReplay(MODEL, "-uncached-session", contents); + const parts = (contents[0] as { parts: Record[] }).parts; + expect(parts[0].thoughtSignature).toBe("skip_thought_signature_validator"); + expect(parts[1].thoughtSignature).toBeUndefined(); + }); });