From 16bf6fed390864ad81c036e7f5e26587b37ee630 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 29 Oct 2026 04:57:18 -0700 Subject: [PATCH] fix(antigravity): served reads complete right away and duplicate model names are labeled The agent never completes a subagent's client_view_file call, so its row kept spinning until the turn ended. T3 reports the call completed when it serves the file. The agent can also list two model IDs under one name; later ones now show their ID. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AntigravityAdapterV2.test.ts | 10 ++++++ .../Adapters/AntigravityAdapterV2.ts | 31 ++++++++++++++++--- .../src/provider/AntigravityProvider.test.ts | 19 ++++++++++++ .../src/provider/AntigravityProvider.ts | 7 ++++- .../provider/acp/AntigravityProtocol.test.ts | 3 +- .../src/provider/acp/AntigravityProtocol.ts | 28 +++++++++++++---- 6 files changed, 85 insertions(+), 13 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index 5cf359451ef2..90226cc68bd2 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -896,6 +896,16 @@ describe("AntigravityAdapterV2 subagent reads", () => { : undefined; }; + const firstEvent = (nativeId: string, status: string) => + events.findIndex( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === nativeId && + event.turnItem.status === status, + ); + // T3 completes a served read itself instead of leaving it running until the flush. + assert.isAbove(firstEvent("call_served", "completed"), -1); + assert.isBelow(firstEvent("call_served", "completed"), firstEvent("call_native", "failed")); const failed = { status: "failed" as const, output: stepError }; assert.deepEqual(read("call_served"), { status: "completed", output: undefined }); assert.deepEqual(read("call_retried"), { status: "completed", output: undefined }); diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index fbdd5748adcf..4d06da900f4a 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -11,6 +11,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Exit from "effect/Exit"; +import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import * as EffectAcpErrors from "effect-acp/errors"; @@ -131,20 +132,40 @@ export function makeAntigravityAcpAdapterFlavor( .pipe(Effect.provideService(Scope.Scope, scope)); // Repair keeps state across updates, so each runtime must have one session-update handler. const reads = makeAntigravityReadRepair(); + // Served reads are reported from the read handler, so they take turns with agent updates. + const updates = yield* Semaphore.make(1); + let projectUpdate: Parameters[0] | undefined; return { ...runtime, handleReadTextFile: (handler) => runtime.handleReadTextFile((request, context) => handler(request, context).pipe( - Effect.onExit((exit) => - Effect.sync(() => reads.attempted(request.path, Exit.isSuccess(exit))), - ), + Effect.onExit((exit) => { + const served = reads.attempted(request.path, Exit.isSuccess(exit)); + if (served === undefined || projectUpdate === undefined) return Effect.void; + return updates + .withPermit( + projectUpdate({ + sessionId: served.sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: served.toolCallId, + status: "completed", + }, + }), + ) + .pipe(Effect.ignore); + }), ), ), prompt: (payload, promptOptions) => Effect.sync(reads.reset).pipe(Effect.andThen(runtime.prompt(payload, promptOptions))), - handleSessionUpdate: (handler) => - runtime.handleSessionUpdate((notification) => handler(reads.repair(notification))), + handleSessionUpdate: (handler) => { + projectUpdate = handler; + return runtime.handleSessionUpdate((notification) => + updates.withPermit(handler(reads.repair(notification))), + ); + }, start: () => runtime .start() diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index 35f279f29ddc..1f852ad8bcc8 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -215,6 +215,25 @@ describe("Antigravity model catalog", () => { ); }); + it("labels models that share a display name with their IDs", () => { + const models = buildAntigravityModelsFromSession({ + configOptions: [ + { + ...modelConfig, + currentValue: "gemini-3.8-flash", + options: [ + { value: "gemini-3.8-flash", name: "Gemini 3.8 Flash" }, + { value: "gemini-3.8-flash-preview", name: "Gemini 3.8 Flash" }, + ], + }, + ], + }); + expect(models.map((model) => model.name)).toEqual([ + "Gemini 3.8 Flash", + "Gemini 3.8 Flash (gemini-3.8-flash-preview)", + ]); + }); + it("offers each model the thinking levels it reported when selected", () => { const thinking = (currentValue: string) => ({ diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index 988661a7ce61..db7d22a45e0c 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -82,15 +82,20 @@ export function buildAntigravityModelsFromSession( : []; const currentThinking = antigravityThinkingConfig(setup.configOptions); const seen = new Set(); + // The agent can list two IDs under one name; label the later ones so the picker tells them apart. + const names = new Set(); return entries.flatMap((entry): ServerProviderModel[] => { if (!entry.value.trim() || seen.has(entry.value)) return []; seen.add(entry.value); + const baseName = entry.name.trim() ? entry.name : entry.value; + const name = names.has(baseName) ? `${baseName} (${entry.value})` : baseName; + names.add(baseName); const observed = entry.value === currentValue ? (currentThinking ?? null) : thinkingLevels.get(entry.value); return [ { slug: entry.value, - name: entry.name.trim() ? entry.name : entry.value, + name, isCustom: false, ...(entry.value === currentValue ? { isDefault: true, aliases: [ANTIGRAVITY_DEFAULT_MODEL] } diff --git a/apps/server/src/provider/acp/AntigravityProtocol.test.ts b/apps/server/src/provider/acp/AntigravityProtocol.test.ts index 0c7937a28ee4..909de58cb755 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.test.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.test.ts @@ -430,7 +430,7 @@ describe("Antigravity served reads", () => { it("credits a read T3 served before the agent announced it", () => { const reads = makeAntigravityReadRepair(); reads.attempted("/repo/a.ts", true); - reads.repair({ + const announced = reads.repair({ sessionId: "session-1", update: { sessionUpdate: "tool_call", @@ -441,6 +441,7 @@ describe("Antigravity served reads", () => { locations: [{ path: "/repo/a.ts" }], }, }); + expect(announced.update).toMatchObject({ status: "completed" }); expect(flush(reads, "call_late")).toEqual({ sessionUpdate: "tool_call_update", toolCallId: "call_late", diff --git a/apps/server/src/provider/acp/AntigravityProtocol.ts b/apps/server/src/provider/acp/AntigravityProtocol.ts index 1eeb0cdac31d..a91d45164caa 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.ts @@ -239,7 +239,10 @@ function antigravityClientReadPath(update: { */ export function makeAntigravityReadRepair() { type Outcome = "served" | "refused"; - const openReads = new Map(); + const openReads = new Map< + string, + { readonly path: string; readonly sessionId: string; outcome?: Outcome } + >(); // The agent runs a client tool in a background task, so its request can precede the announcement. const earlyOutcomes: Array<{ readonly path: string; readonly outcome: Outcome }> = []; return { @@ -248,15 +251,19 @@ export function makeAntigravityReadRepair() { openReads.clear(); earlyOutcomes.length = 0; }, - /** Each `fs/read_text_file` request answers the oldest open read of its path, in order. */ + /** + * Each `fs/read_text_file` request answers the oldest open read of its path, in order. + * Returns the call a served read completes, so T3 can report it without waiting. + */ attempted: (path: string, served: boolean) => { const outcome = served ? "served" : "refused"; - for (const read of openReads.values()) { + for (const [toolCallId, read] of openReads) { if (read.path !== path || read.outcome !== undefined) continue; read.outcome = outcome; - return; + return served ? { sessionId: read.sessionId, toolCallId } : undefined; } earlyOutcomes.push({ path, outcome }); + return undefined; }, repair: ( notification: EffectAcpSchema.SessionNotification, @@ -269,10 +276,19 @@ export function makeAntigravityReadRepair() { if (path !== undefined && !openReads.has(update.toolCallId)) { const early = earlyOutcomes.findIndex((entry) => entry.path === path); const outcome = early === -1 ? undefined : earlyOutcomes.splice(early, 1)[0]?.outcome; - openReads.set(update.toolCallId, outcome === undefined ? { path } : { path, outcome }); + const sessionId = notification.sessionId; + openReads.set( + update.toolCallId, + outcome === undefined ? { path, sessionId } : { path, sessionId, outcome }, + ); } - if (update.status !== "completed" && update.status !== "failed") return notification; const read = openReads.get(update.toolCallId); + if (update.status === "pending" || update.status === "in_progress") { + return read?.outcome === "served" + ? { ...notification, update: { ...update, status: "completed" } } + : notification; + } + if (update.status !== "completed" && update.status !== "failed") return notification; openReads.delete(update.toolCallId); if (update.status === "completed" || read?.outcome !== "served") return notification; const { rawOutput: _stepError, ...rest } = update;