Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions src/adapters/google-antigravity-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,8 @@ export function antigravityUsesReplayCache(model: string): boolean {
* Observe a parsed CCA chunk's `candidates[0].content.parts` and record thought signatures keyed by
* the functionCall identity (name + args). Accumulates across the whole session so a sequential
* multi-step tool loop keeps EVERY prior call's signature, not just the latest part-index slot.
* A signature on a standalone thought part is paired with the next functionCall in the same
* array (#897); a call's own signature takes precedence and an unpaired one is dropped.
* `parts` is the already-unwrapped `response.candidates[0].content.parts`.
*/
export function observeAntigravityReplay(model: string, sessionId: string, parts: unknown[]): void {
Expand All @@ -380,19 +382,29 @@ export function observeAntigravityReplay(model: string, sessionId: string, parts
oldestAtMs: null,
};
let inserted = false;
let pendingThoughtSig: string | undefined;
for (const raw of parts) {
if (!raw || typeof raw !== "object") continue;
const part = raw as Record<string, unknown>;
const sig = extractSignature(part);
if (!sig) continue;
const fc = part.functionCall as { name?: unknown; args?: unknown } | undefined;
const ck = fc ? functionCallKey(fc.name, fc.args) : undefined;
if (!fc) {
// A signature on a standalone thought part pairs with the NEXT functionCall in this
// array: thought parts are stripped from replayed history, so the call part is the only
// carrier that survives (#897). Non-thought parts keep their own signature in history.
if (sig && part.thought === true) pendingThoughtSig = sig;
continue;
}
const callSig = sig ?? pendingThoughtSig; // a signature on the call part itself wins
pendingThoughtSig = undefined;
if (!callSig) continue;
const ck = functionCallKey(fc.name, fc.args);
if (!ck) continue; // only function-call signatures are replayable by identity
const signatureBytes = utf8.encode(sig).byteLength;
const signatureBytes = utf8.encode(callSig).byteLength;
const sizeBytes = utf8.encode(ck).byteLength + signatureBytes;
if (signatureBytes > replayLimits.maxSignatureBytes || sizeBytes > replayLimits.maxBytesPerSession) continue;
deleteReplayCall(entry, ck);
entry.byCall.set(ck, { signature: sig, sizeBytes, touchedAtMs: now });
entry.byCall.set(ck, { signature: callSig, sizeBytes, touchedAtMs: now });
entry.bytes += sizeBytes;
replayBytes += sizeBytes;
inserted = true;
Expand Down
46 changes: 46 additions & 0 deletions tests/google-antigravity-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,52 @@ describe("antigravity reasoning-replay cache", () => {
expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe(SIG);
});

test("a signature on a standalone thought part replays onto the next functionCall (#897)", () => {
observeAntigravityReplay(MODEL, SESSION, [
{ thought: true, thoughtSignature: SIG },
fcPart("get_x", { a: 1 }),
]);
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);
});

test("a call's own signature wins over a preceding standalone thought signature", () => {
observeAntigravityReplay(MODEL, SESSION, [
{ thought: true, thoughtSignature: "sig-standalone-aaaa" },
fcPart("get_x", {}, SIG),
]);
const contents = [{ role: "model", parts: [{ functionCall: { name: "get_x", args: {} } }] }];
applyAntigravityReplay(MODEL, SESSION, contents);
expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe(SIG);
});

test("a standalone thought signature does not pair backwards or leak into a later observe", () => {
observeAntigravityReplay(MODEL, SESSION, [fcPart("get_x", {}), { thought: true, thoughtSignature: SIG }]);
observeAntigravityReplay(MODEL, SESSION, [fcPart("get_y", {})]);
const contents = [{
role: "model",
parts: [
{ functionCall: { name: "get_x", args: {} } },
{ functionCall: { name: "get_y", args: {} } },
],
}];
applyAntigravityReplay(MODEL, SESSION, contents);
expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined();
expect((contents[0].parts[1] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined();
});

test("a signature on a non-thought part does not attach to the next call", () => {
// Text parts survive replayed history with their own signature; only thought parts are stripped.
observeAntigravityReplay(MODEL, SESSION, [
{ text: "answer", thoughtSignature: SIG },
fcPart("get_x", {}),
]);
const contents = [{ role: "model", parts: [{ functionCall: { name: "get_x", args: {} } }] }];
applyAntigravityReplay(MODEL, SESSION, contents);
expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined();
});

test("clear-on-invalid empties the entry", () => {
observeAntigravityReplay(MODEL, SESSION, [fcPart("get_x", {}, SIG)]);
clearAntigravityReplay(MODEL, SESSION);
Expand Down
Loading