diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index 1f852ad8bcc87..1c655961e764a 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -178,17 +178,29 @@ const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* ( }); describe("Antigravity model catalog", () => { - it("uses legacy session models only when model config is absent", () => { - const fromLegacy = buildAntigravityModelsFromSession({ + it("preserves session models when the model config is partial", () => { + const fromSessionState = buildAntigravityModelsFromSession({ models: sessionSetupResult.models, }); - expect(fromLegacy).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); + expect(fromSessionState).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); expect( buildAntigravityModelsFromSession({ ...sessionSetupResult, configOptions: [{ ...modelConfig, options: [] }], }), - ).toEqual([]); + ).toEqual(fromSessionState); + + const withClaude = buildAntigravityModelsFromSession({ + models: { + ...sessionSetupResult.models, + availableModels: [ + ...sessionSetupResult.models.availableModels, + { modelId: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Thinking)" }, + ], + }, + configOptions: [{ ...modelConfig, options: modelOptions.slice(0, 3) }], + }); + expect(withClaude.some((model) => model.slug === "claude-sonnet-4-6")).toBe(true); }); it("flattens native option groups without combining distinct model IDs", () => { @@ -388,7 +400,7 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { ), ); - it.effect("replaces live model choices and accepts an empty catalog", () => + it.effect("preserves the session model catalog across config-only updates", () => Effect.scoped( Effect.gen(function* () { const harness = yield* makeHarness(); @@ -403,20 +415,21 @@ it.layer(layerTest)("Antigravity provider snapshots", (it) => { options: modelOptions.slice(0, 3), }, ]; - const nextSnapshot = yield* Stream.toPull( - harness.provider.snapshot.streamChanges.pipe( - Stream.filter((snapshot) => snapshot.models.length === 3), - ), - ); + const nextSnapshot = yield* Stream.toPull(harness.provider.snapshot.streamChanges); yield* harness.provider.onConfigOptionsUpdated(configOptions); expect((yield* nextSnapshot)[0]).toMatchObject({ - models: buildAntigravityModelsFromSession({ configOptions }), + models: buildAntigravityModelsFromSession({ + configOptions, + models: sessionSetupResult.models, + }), auth: before.auth, workspaceSnapshots: before.workspaceSnapshots, slashCommands: expectedCommands, }); yield* harness.provider.onConfigOptionsUpdated([]); - expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual([]); + expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual( + buildAntigravityModelsFromSession({ models: sessionSetupResult.models }), + ); }), ), ); diff --git a/apps/server/src/provider/AntigravityProvider.ts b/apps/server/src/provider/AntigravityProvider.ts index b5e9f257d17b3..39468661fd420 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -71,15 +71,18 @@ export function buildAntigravityModelsFromSession( ); const currentValue = config?.type === "select" ? config.currentValue : setup.models?.currentModelId; - const entries = - config?.type === "select" + // Antigravity has exposed account models through both ACP model state and the + // model config option. Keep their union so a narrower/partial config update + // cannot hide Claude or GPT models that the session already advertised. + const entries = [ + ...(config?.type === "select" ? config.options.flatMap((entry) => ("value" in entry ? [entry] : entry.options)) - : config === undefined - ? (setup.models?.availableModels.map((model) => ({ - value: model.modelId, - name: model.name, - })) ?? []) - : []; + : []), + ...(setup.models?.availableModels.map((model) => ({ + value: model.modelId, + name: model.name, + })) ?? []), + ]; 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. @@ -145,6 +148,7 @@ function isMissingInstallation(error: EffectAcpErrors.AcpError | ProviderSetupEr interface AntigravityProviderState { readonly draft: ServerProviderDraft; readonly authRevision: number; + readonly sessionModels: SessionSetupResult["models"] | undefined; } interface AntigravityProviderOptions { @@ -189,6 +193,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func const metadata = yield* SubscriptionRef.make({ draft: initialDraft, authRevision: 0, + sessionModels: undefined, }); // Preserve disk-discovered skills when callbacks replace workspace commands; the registry will not rescan them. const discoveredSkills = new Map(); @@ -328,6 +333,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func const workspace = cwd ? workspaces.find((entry) => entry.cwd === cwd) : undefined; return { authRevision: state.authRevision + 1, + sessionModels: started.sessionSetupResult.models, draft: { ...draft, installed: true, @@ -367,9 +373,15 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func configOptions: ReadonlyArray, ) { observeThinkingLevels(configOptions); - const models = buildAntigravityModelsFromSession({ configOptions }, thinkingLevels); yield* SubscriptionRef.update(metadata, (state) => { if (state.draft.auth.status !== "authenticated") return state; + const models = buildAntigravityModelsFromSession( + { + configOptions, + ...(state.sessionModels === undefined ? {} : { models: state.sessionModels }), + }, + thinkingLevels, + ); return { ...state, draft: { ...state.draft, models } }; }); }); @@ -418,6 +430,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func (state) => ({ authRevision: state.authRevision + 1, + sessionModels: undefined, draft: { ...state.draft, auth: { status: "unauthenticated" }, diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index 74f4daa1323af..6c6fb8ec28a50 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -6,6 +6,7 @@ import { type AntigravitySettings, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -46,13 +47,18 @@ const blockedCredentialKeys = new Set([ "GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_GENAI_USE_VERTEXAI", ]); +const modelCatalogMigrationMarker = ".t3-model-catalog-v1"; function shellQuote(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( - options: { readonly config?: Partial; readonly enabled?: boolean } = {}, + options: { + readonly config?: Partial; + readonly enabled?: boolean; + readonly catalogMigrated?: boolean; + } = {}, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -68,6 +74,10 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( const requestLog = path.join(root, "requests.jsonl"); const directories = yield* resolveAntigravityInstanceDirectories(config.stateDir, instanceId); const profileDirectory = directories.profile; + if (options.catalogMigrated ?? true) { + yield* fs.makeDirectory(profileDirectory, { recursive: true }); + yield* fs.writeFileString(path.join(profileDirectory, modelCatalogMigrationMarker), "1\n"); + } const instancePath = `${path.join(root, "instance-bin")}:${baseEnv.PATH ?? ""}`; const makeExecutable = Effect.fn("AntigravityDriverTest.makeExecutable")(function* ( @@ -109,6 +119,7 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( const controls = { selected: first, failResolution: false, beforeAcquire: Effect.void }; const acquisitions: Array<{ binaryPath: string | undefined; path: string | undefined }> = []; const releases: Array = []; + const releaseObserved = yield* Deferred.make(); const launches: Array<{ command: string; args: ReadonlyArray; @@ -149,7 +160,7 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( yield* Effect.addFinalizer(() => Effect.sync(() => { releases.push(selected.version); - }), + }).pipe(Effect.andThen(Deferred.succeed(releaseObserved, undefined))), ); return selected; }), @@ -231,6 +242,7 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( controls, acquisitions, releases, + releaseObserved, launches, readRequests, assertClosed, @@ -269,6 +281,29 @@ const layerTest = ProviderHostLive.layer.pipe( ); it.layer(layerTest)("AntigravityDriver", (it) => { + it.effect.skipIf(windowsHost)("refreshes a legacy model catalog once on create", () => + Effect.gen(function* () { + const h = yield* makeHarness({ catalogMigrated: false }); + yield* Deferred.await(h.releaseObserved); + + const snapshot = yield* h.instance.snapshot.getSnapshot; + expect(snapshot.status).toBe("ready"); + expect(snapshot.models.map((model) => model.slug)).toEqual([ + "gemini-test-low", + "gemini-test-high", + ]); + expect(h.acquisitions).toEqual([{ binaryPath: "", path: h.instancePath }]); + expect(h.releases).toEqual([h.first.version]); + const requests = yield* h.readRequests; + expect(requests.map((request) => request.method)).toEqual([ + "initialize", + "auth/login", + "session/new", + ]); + yield* h.assertClosed; + }).pipe(Effect.scoped), + ); + it.effect.skipIf(windowsHost)( "refreshes through the selected executable and personal Google ACP", () => diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index f1f39e104b44c..7beabb8eaa3d3 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -14,6 +14,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { HttpClient } from "effect/http"; @@ -68,6 +69,8 @@ import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIden import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts"; const DRIVER = ProviderDriverKind.make("antigravity"); +const MODEL_CATALOG_MIGRATION_MARKER = ".t3-model-catalog-v1"; +const modelCatalogMigrationGate = Semaphore.makeUnsafe(1); const decodeSettings = Schema.decodeSync(AntigravitySettings); const isNodeRuntimeUnavailableError = Schema.is(NodeRuntimeUnavailableError); @@ -502,6 +505,34 @@ export const AntigravityDriver: ProviderDriver< ), ); + // Existing Antigravity accounts can have a provider-status cache created before + // account-scoped Claude/GPT discovery was supported. Refresh that catalog once + // after upgrading, then leave periodic health checks lightweight (the ACP bundle + // is a large PyInstaller executable and must not be spawned every minute). + if (settings.enabled) { + const migrationMarkerPath = path.join(profileDirectory, MODEL_CATALOG_MIGRATION_MARKER); + yield* modelCatalogMigrationGate + .withPermits(1)( + Effect.gen(function* () { + const alreadyMigrated = yield* fileSystem + .exists(migrationMarkerPath) + .pipe(Effect.orElseSucceed(() => false)); + if (alreadyMigrated) return; + + yield* refreshModels(); + yield* fileSystem.writeFileString(migrationMarkerPath, "1\n"); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning( + "Could not complete the one-time Antigravity model catalog refresh.", + { instanceId, cause }, + ), + ), + ), + ) + .pipe(Effect.forkScoped); + } + return { instanceId, driverKind: DRIVER,