diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 9388916b49609bfbf873be46c266a830d12b6c40..80d8586a91b7af34cefe46addf961ac593c64cc5 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -233,6 +233,9 @@ export const AntigravityDriver: ProviderDriver { ); const requestLog = path.join(cwd, "requests.ndjson"); const commands: string[] = []; + const modelSelections: string[] = []; const observed: ProviderRuntimeEvent[] = []; const completed = yield* Deferred.make(); const adapter = yield* makeAntigravityAdapter(decodeSettings({ enabled: true }), { @@ -322,6 +323,11 @@ it.layer(layer)("AntigravityAdapter", (it) => { Effect.sync(() => { commands.push(...available.map((command) => command.name)); }), + onConfigOptionsUpdated: (configOptions) => + Effect.sync(() => { + const model = configOptions.find((option) => option.category === "model"); + if (model?.type === "select") modelSelections.push(model.currentValue); + }), }); yield* adapter.streamEvents.pipe( Stream.runForEach((event) => @@ -350,6 +356,8 @@ it.layer(layer)("AntigravityAdapter", (it) => { yield* adapter.sendTurn({ threadId, input: "Reply with one short line." }); yield* Deferred.await(completed); expect(commands).toEqual(["plan", "logout", "plan", "logout"]); + expect(modelSelections.length).toBeGreaterThan(0); + expect(modelSelections.every((model) => model === nativeAlternative)).toBe(true); expect( observed .filter((event) => event.type === "content.delta") diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index e91c04e59025d73382926eea2b3e0028e27afec7..ee78aa2cc3daefdf0a3ef73a787ced3db2154150 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -133,6 +133,9 @@ export interface AntigravityAdapterOptions { commands: ReadonlyArray, cwd: string, ) => Effect.Effect; + readonly onConfigOptionsUpdated?: ( + configOptions: ReadonlyArray, + ) => Effect.Effect; readonly onAuthRequired?: Effect.Effect; /** Model the provider default alias selects, when the account offers it. */ readonly defaultModel?: Effect.Effect; @@ -503,6 +506,9 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi case "AvailableCommandsUpdated": yield* options.onAvailableCommands?.(event.availableCommands, context.cwd) ?? Effect.void; return; + case "ConfigOptionsUpdated": + yield* options.onConfigOptionsUpdated?.(event.configOptions) ?? Effect.void; + return; case "ConnectionTerminated": context.stopped = true; context.disconnected = true; diff --git a/apps/server/src/provider/Layers/AntigravityProvider.test.ts b/apps/server/src/provider/Layers/AntigravityProvider.test.ts index 4794a8ff189e91c8700aeff7d22c9c9a7d4c6baf..363afbee11068af840290d0f8f63bb830695930e 100644 --- a/apps/server/src/provider/Layers/AntigravityProvider.test.ts +++ b/apps/server/src/provider/Layers/AntigravityProvider.test.ts @@ -175,7 +175,6 @@ describe("Antigravity model catalog", () => { it("uses legacy session models only when model config is absent", () => { const fromLegacy = buildAntigravityModelsFromSession({ - sessionId: "legacy-session", models: sessionSetupResult.models, }); expect(fromLegacy).toEqual(buildAntigravityModelsFromSession(sessionSetupResult)); @@ -189,7 +188,6 @@ describe("Antigravity model catalog", () => { it("flattens native option groups without combining distinct model IDs", () => { const models = buildAntigravityModelsFromSession({ - sessionId: "grouped-session", configOptions: [ { ...modelConfig, @@ -356,7 +354,9 @@ it.layer(testLayer)("Antigravity provider snapshots", (it) => { supportsTextGeneration: false, }); yield* harness.provider.onAvailableCommands(commands, "/workspace"); + yield* harness.provider.onConfigOptionsUpdated([modelConfig]); expect((yield* harness.provider.snapshotForCwd("/workspace")).slashCommands).toEqual([]); + expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual([]); } const refreshed = yield* harness.provider.snapshot.refresh; expect(refreshed.auth.status).toBe("unauthenticated"); @@ -365,6 +365,39 @@ it.layer(testLayer)("Antigravity provider snapshots", (it) => { ), ); + it.effect("replaces live model choices and accepts an empty catalog", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.initialize; + yield* harness.provider.onSessionStarted(started, "/workspace"); + yield* harness.provider.onAvailableCommands(commands, "/workspace"); + const before = yield* harness.provider.snapshot.getSnapshot; + const configOptions = [ + { + ...modelConfig, + currentValue: "gemini-3.8-flash-high", + options: modelOptions.slice(0, 3), + }, + ]; + const nextSnapshot = yield* Stream.toPull( + harness.provider.snapshot.streamChanges.pipe( + Stream.filter((snapshot) => snapshot.models.length === 3), + ), + ); + yield* harness.provider.onConfigOptionsUpdated(configOptions); + expect((yield* nextSnapshot)[0]).toMatchObject({ + models: buildAntigravityModelsFromSession({ configOptions }), + auth: before.auth, + workspaceSnapshots: before.workspaceSnapshots, + slashCommands: commands, + }); + yield* harness.provider.onConfigOptionsUpdated([]); + expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual([]); + }), + ), + ); + it.effect("replaces one account's catalog instead of combining accounts", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/AntigravityProvider.ts b/apps/server/src/provider/Layers/AntigravityProvider.ts index 9c91c24d799d252759e24097fbb7707c30e36d65..62bd224cd4da0e4a6549d8febfbda461b31dc10f 100644 --- a/apps/server/src/provider/Layers/AntigravityProvider.ts +++ b/apps/server/src/provider/Layers/AntigravityProvider.ts @@ -37,7 +37,10 @@ const SIGN_IN_MESSAGE = "Sign in with Google to use Antigravity."; const AUTH_UNCHECKED_MESSAGE = "Antigravity is installed. Google account access is not checked yet."; -type SessionSetupResult = AcpSessionRuntimeStartResult["sessionSetupResult"]; +type SessionSetupResult = Pick< + AcpSessionRuntimeStartResult["sessionSetupResult"], + "configOptions" | "models" +>; /** Keep the native model IDs, including model-specific thinking levels. */ export function buildAntigravityModelsFromSession( @@ -299,6 +302,16 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func }); }); + const onConfigOptionsUpdated = Effect.fn("AntigravityProvider.onConfigOptionsUpdated")(function* ( + configOptions: ReadonlyArray, + ) { + const models = buildAntigravityModelsFromSession({ configOptions }); + yield* SubscriptionRef.update(metadata, (state) => { + if (state.draft.auth.status !== "authenticated") return state; + return { ...state, draft: { ...state.draft, models } }; + }); + }); + const onAvailableCommands = Effect.fn("AntigravityProvider.onAvailableCommands")(function* ( commands: ReadonlyArray, cwd?: string, @@ -373,6 +386,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func return { snapshot: { ...managed, getSnapshot }, onSessionStarted, + onConfigOptionsUpdated, onAvailableCommands, onSignedOut: clearAccountMetadata(), onAuthRequired: clearAccountMetadata(), diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 02693ff1ebc5be376e1c256baaec4b7fc2e2e753..cb8355881eb3609970a435891fb76f42da51ae83 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -69,11 +69,15 @@ describe("AcpSessionRuntime", () => { expect(events.map((event) => event._tag)).toEqual([ "AvailableCommandsUpdated", "ModeChanged", + "ConfigOptionsUpdated", ]); expect(events[0]).toMatchObject({ availableCommands: [{ name: "plan", description: "Native command" }], }); expect(yield* runtime.getModeState).toMatchObject({ currentModeId: "code" }); + expect(events[2]).toMatchObject({ + configOptions: yield* runtime.getConfigOptions, + }); expect( (yield* runtime.getConfigOptions).find((option) => option.category === "model"), ).toMatchObject({ currentValue: "gpt-5.4" }); @@ -81,6 +85,20 @@ describe("AcpSessionRuntime", () => { ); } + it.effect("publishes model changes returned by a config request and live notifications", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.make(mockRuntimeOptions); + yield* runtime.start(); + const updates = yield* Stream.toPull( + runtime.getEvents().pipe(Stream.filter((event) => event._tag === "ConfigOptionsUpdated")), + ); + const selected = yield* runtime.setConfigOption("model", "composer-2"); + expect((yield* updates)[0]?.configOptions).toEqual(selected.configOptions); + yield* runtime.request("_test/startup-metadata", {}); + expect((yield* updates)[0]?.configOptions).toEqual(yield* runtime.getConfigOptions); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("awaits native resume instead of using the load replay idle fallback", () => Effect.gen(function* () { const resumeStarted = yield* Deferred.make(); diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index b293e0d199a6a663fd637c852f8d2c113c6d4a10..89c02dc7f949ec0a6330ae5ab66d9d2a6fee7262 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -90,6 +90,11 @@ export type AcpParsedSessionEvent = readonly availableCommands: ReadonlyArray; readonly rawPayload: unknown; } + | { + readonly _tag: "ConfigOptionsUpdated"; + readonly configOptions: ReadonlyArray; + readonly rawPayload: unknown; + } | { readonly _tag: "AssistantItemStarted"; readonly itemId: string; @@ -788,6 +793,14 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat let modeId: string | undefined; switch (upd.sessionUpdate) { + case "config_option_update": { + events.push({ + _tag: "ConfigOptionsUpdated", + configOptions: upd.configOptions, + rawPayload: params, + }); + break; + } case "available_commands_update": { events.push({ _tag: "AvailableCommandsUpdated", diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 1d894d18d8ccf911edac1ec6fa82070b32e434b7..10fa76aa74d7da2888c367d6066b0e2082b262ba 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -615,13 +615,17 @@ export const make = ( }); }); - const updateConfigOptions = ( - response: - | EffectAcpSchema.SetSessionConfigOptionResponse - | EffectAcpSchema.LoadSessionResponse - | EffectAcpSchema.NewSessionResponse - | EffectAcpSchema.ResumeSessionResponse, - ): Effect.Effect => Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(response)); + const updateConfigOptions = Effect.fn("AcpSessionRuntime.updateConfigOptions")(function* ( + response: EffectAcpSchema.SetSessionConfigOptionResponse, + ) { + const configOptions = sessionConfigOptionsFromSetup(response); + yield* Ref.set(configOptionsRef, configOptions); + yield* Queue.offer(eventQueue, { + _tag: "ConfigOptionsUpdated", + configOptions, + rawPayload: response, + }); + }); const updateCurrentModeId = (modeId: string): Effect.Effect => Ref.update(modeStateRef, (current) => diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 4b22674630760fb81a1ba7f262e31a891aa39e19..d29dd10792077134f198f19086162a4822b919db 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -168,6 +168,8 @@ Account access starts unknown and becomes authenticated after successful session including an explicit model refresh. The [provider snapshot][antigravity-provider] takes models and commands from setup and native updates. It preserves returned Gemini model IDs, labels, order, and thinking-level choices. +ACP `config_option_update` notifications and `session/set_config_option` responses replace +the instance's model catalog. Child session notifications do not change the root catalog. The registry treats a successful empty catalog as authoritative and clears cached metadata after sign-out. It must not retain a previous account's models. Cached models do not prove current access. The auth response does not supply an email, plan tier, or reliable quota. diff --git a/docs/user/providers-antigravity.md b/docs/user/providers-antigravity.md index 23ebd6f644cf9e93a3691f63af6fe3d4b03403bb..e7854672e747f46f6b5d8298454d5855cec29750 100644 --- a/docs/user/providers-antigravity.md +++ b/docs/user/providers-antigravity.md @@ -107,6 +107,7 @@ installations. Clear **Binary path** to use managed installation controls. The current official ACP exposes Gemini models only. T3 Code uses the model IDs and names returned for your account, including any model choices with different thinking levels. Models available in other Antigravity apps might not be available through this agent. +The model picker updates when a running session reports new model choices. New threads use Gemini 3.8 Flash (High) when your account offers it. Older Gemini generations stay available under **Legacy models** in the picker.