diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index 5cf359451ef29..90226cc68bd2a 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 fbdd5748adcfc..4d06da900f4a8 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 35f279f29ddcc..1f852ad8bcc87 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 988661a7ce614..db7d22a45e0cd 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 0c7937a28ee45..909de58cb7557 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 1eeb0cdac31dd..a91d45164caaf 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;