authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-03 16:35:19-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 16:35:19-07:00
logbaf67b6e3accd48d510fd0d39a4e6b978a1eea80
tree16f54313f76035e495878ff6ca735fc9ad2dd307
parentf25e4428961982e3935300cfd3c78f7d0fac4ee9
signature Signed by PGP key B5690EEEBB952194

fix(antigravity): keep model choices up to date (#9511)


10 files changed, 113 insertions(+), 10 deletions(-)

apps/server/src/provider/Drivers/AntigravityDriver.ts+4
...@@ -233,6 +233,9 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD...@@ -233,6 +233,9 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
233 if (event._tag === "EventStreamBarrier") {233 if (event._tag === "EventStreamBarrier") {
234 return Deferred.succeed(event.acknowledge, undefined).pipe(Effect.asVoid);234 return Deferred.succeed(event.acknowledge, undefined).pipe(Effect.asVoid);
235 }235 }
236 if (event._tag === "ConfigOptionsUpdated") {
237 return provider.onConfigOptionsUpdated(event.configOptions);
238 }
236 return event._tag === "AvailableCommandsUpdated"239 return event._tag === "AvailableCommandsUpdated"
237 ? provider.onAvailableCommands(event.availableCommands)240 ? provider.onAvailableCommands(event.availableCommands)
238 : Effect.void;241 : Effect.void;
...@@ -299,6 +302,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD...@@ -299,6 +302,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
299 withProcess: authFlow.withProcess,302 withProcess: authFlow.withProcess,
300 defaultModel,303 defaultModel,
301 onSessionStarted: provider.onSessionStarted,304 onSessionStarted: provider.onSessionStarted,
305 onConfigOptionsUpdated: provider.onConfigOptionsUpdated,
302 onAvailableCommands: provider.onAvailableCommands,306 onAvailableCommands: provider.onAvailableCommands,
303 onAuthRequired: provider.onAuthRequired,307 onAuthRequired: provider.onAuthRequired,
304 ...(loggers.native ? { nativeEventLogger: loggers.native } : {}),308 ...(loggers.native ? { nativeEventLogger: loggers.native } : {}),
apps/server/src/provider/Layers/AntigravityAdapter.test.ts+8
...@@ -297,6 +297,7 @@ it.layer(layer)("AntigravityAdapter", (it) => {...@@ -297,6 +297,7 @@ it.layer(layer)("AntigravityAdapter", (it) => {
297 );297 );
298 const requestLog = path.join(cwd, "requests.ndjson");298 const requestLog = path.join(cwd, "requests.ndjson");
299 const commands: string[] = [];299 const commands: string[] = [];
300 const modelSelections: string[] = [];
300 const observed: ProviderRuntimeEvent[] = [];301 const observed: ProviderRuntimeEvent[] = [];
301 const completed = yield* Deferred.make<void>();302 const completed = yield* Deferred.make<void>();
302 const adapter = yield* makeAntigravityAdapter(decodeSettings({ enabled: true }), {303 const adapter = yield* makeAntigravityAdapter(decodeSettings({ enabled: true }), {
...@@ -322,6 +323,11 @@ it.layer(layer)("AntigravityAdapter", (it) => {...@@ -322,6 +323,11 @@ it.layer(layer)("AntigravityAdapter", (it) => {
322 Effect.sync(() => {323 Effect.sync(() => {
323 commands.push(...available.map((command) => command.name));324 commands.push(...available.map((command) => command.name));
324 }),325 }),
326 onConfigOptionsUpdated: (configOptions) =>
327 Effect.sync(() => {
328 const model = configOptions.find((option) => option.category === "model");
329 if (model?.type === "select") modelSelections.push(model.currentValue);
330 }),
325 });331 });
326 yield* adapter.streamEvents.pipe(332 yield* adapter.streamEvents.pipe(
327 Stream.runForEach((event) =>333 Stream.runForEach((event) =>
...@@ -350,6 +356,8 @@ it.layer(layer)("AntigravityAdapter", (it) => {...@@ -350,6 +356,8 @@ it.layer(layer)("AntigravityAdapter", (it) => {
350 yield* adapter.sendTurn({ threadId, input: "Reply with one short line." });356 yield* adapter.sendTurn({ threadId, input: "Reply with one short line." });
351 yield* Deferred.await(completed);357 yield* Deferred.await(completed);
352 expect(commands).toEqual(["plan", "logout", "plan", "logout"]);358 expect(commands).toEqual(["plan", "logout", "plan", "logout"]);
359 expect(modelSelections.length).toBeGreaterThan(0);
360 expect(modelSelections.every((model) => model === nativeAlternative)).toBe(true);
353 expect(361 expect(
354 observed362 observed
355 .filter((event) => event.type === "content.delta")363 .filter((event) => event.type === "content.delta")
apps/server/src/provider/Layers/AntigravityAdapter.ts+6
...@@ -133,6 +133,9 @@ export interface AntigravityAdapterOptions {...@@ -133,6 +133,9 @@ export interface AntigravityAdapterOptions {
133 commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>,133 commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>,
134 cwd: string,134 cwd: string,
135 ) => Effect.Effect<void>;135 ) => Effect.Effect<void>;
136 readonly onConfigOptionsUpdated?: (
137 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>,
138 ) => Effect.Effect<void>;
136 readonly onAuthRequired?: Effect.Effect<void>;139 readonly onAuthRequired?: Effect.Effect<void>;
137 /** Model the provider default alias selects, when the account offers it. */140 /** Model the provider default alias selects, when the account offers it. */
138 readonly defaultModel?: Effect.Effect<string | undefined>;141 readonly defaultModel?: Effect.Effect<string | undefined>;
...@@ -503,6 +506,9 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi...@@ -503,6 +506,9 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi
503 case "AvailableCommandsUpdated":506 case "AvailableCommandsUpdated":
504 yield* options.onAvailableCommands?.(event.availableCommands, context.cwd) ?? Effect.void;507 yield* options.onAvailableCommands?.(event.availableCommands, context.cwd) ?? Effect.void;
505 return;508 return;
509 case "ConfigOptionsUpdated":
510 yield* options.onConfigOptionsUpdated?.(event.configOptions) ?? Effect.void;
511 return;
506 case "ConnectionTerminated":512 case "ConnectionTerminated":
507 context.stopped = true;513 context.stopped = true;
508 context.disconnected = true;514 context.disconnected = true;
apps/server/src/provider/Layers/AntigravityProvider.test.ts+35-2
...@@ -175,7 +175,6 @@ describe("Antigravity model catalog", () => {...@@ -175,7 +175,6 @@ describe("Antigravity model catalog", () => {
175175
176 it("uses legacy session models only when model config is absent", () => {176 it("uses legacy session models only when model config is absent", () => {
177 const fromLegacy = buildAntigravityModelsFromSession({177 const fromLegacy = buildAntigravityModelsFromSession({
178 sessionId: "legacy-session",
179 models: sessionSetupResult.models,178 models: sessionSetupResult.models,
180 });179 });
181 expect(fromLegacy).toEqual(buildAntigravityModelsFromSession(sessionSetupResult));180 expect(fromLegacy).toEqual(buildAntigravityModelsFromSession(sessionSetupResult));
...@@ -189,7 +188,6 @@ describe("Antigravity model catalog", () => {...@@ -189,7 +188,6 @@ describe("Antigravity model catalog", () => {
189188
190 it("flattens native option groups without combining distinct model IDs", () => {189 it("flattens native option groups without combining distinct model IDs", () => {
191 const models = buildAntigravityModelsFromSession({190 const models = buildAntigravityModelsFromSession({
192 sessionId: "grouped-session",
193 configOptions: [191 configOptions: [
194 {192 {
195 ...modelConfig,193 ...modelConfig,
...@@ -356,7 +354,9 @@ it.layer(testLayer)("Antigravity provider snapshots", (it) => {...@@ -356,7 +354,9 @@ it.layer(testLayer)("Antigravity provider snapshots", (it) => {
356 supportsTextGeneration: false,354 supportsTextGeneration: false,
357 });355 });
358 yield* harness.provider.onAvailableCommands(commands, "/workspace");356 yield* harness.provider.onAvailableCommands(commands, "/workspace");
357 yield* harness.provider.onConfigOptionsUpdated([modelConfig]);
359 expect((yield* harness.provider.snapshotForCwd("/workspace")).slashCommands).toEqual([]);358 expect((yield* harness.provider.snapshotForCwd("/workspace")).slashCommands).toEqual([]);
359 expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual([]);
360 }360 }
361 const refreshed = yield* harness.provider.snapshot.refresh;361 const refreshed = yield* harness.provider.snapshot.refresh;
362 expect(refreshed.auth.status).toBe("unauthenticated");362 expect(refreshed.auth.status).toBe("unauthenticated");
...@@ -365,6 +365,39 @@ it.layer(testLayer)("Antigravity provider snapshots", (it) => {...@@ -365,6 +365,39 @@ it.layer(testLayer)("Antigravity provider snapshots", (it) => {
365 ),365 ),
366 );366 );
367367
368 it.effect("replaces live model choices and accepts an empty catalog", () =>
369 Effect.scoped(
370 Effect.gen(function* () {
371 const harness = yield* makeHarness();
372 yield* harness.initialize;
373 yield* harness.provider.onSessionStarted(started, "/workspace");
374 yield* harness.provider.onAvailableCommands(commands, "/workspace");
375 const before = yield* harness.provider.snapshot.getSnapshot;
376 const configOptions = [
377 {
378 ...modelConfig,
379 currentValue: "gemini-3.8-flash-high",
380 options: modelOptions.slice(0, 3),
381 },
382 ];
383 const nextSnapshot = yield* Stream.toPull(
384 harness.provider.snapshot.streamChanges.pipe(
385 Stream.filter((snapshot) => snapshot.models.length === 3),
386 ),
387 );
388 yield* harness.provider.onConfigOptionsUpdated(configOptions);
389 expect((yield* nextSnapshot)[0]).toMatchObject({
390 models: buildAntigravityModelsFromSession({ configOptions }),
391 auth: before.auth,
392 workspaceSnapshots: before.workspaceSnapshots,
393 slashCommands: commands,
394 });
395 yield* harness.provider.onConfigOptionsUpdated([]);
396 expect((yield* harness.provider.snapshot.getSnapshot).models).toEqual([]);
397 }),
398 ),
399 );
400
368 it.effect("replaces one account's catalog instead of combining accounts", () =>401 it.effect("replaces one account's catalog instead of combining accounts", () =>
369 Effect.scoped(402 Effect.scoped(
370 Effect.gen(function* () {403 Effect.gen(function* () {
apps/server/src/provider/Layers/AntigravityProvider.ts+15-1
...@@ -37,7 +37,10 @@ const SIGN_IN_MESSAGE = "Sign in with Google to use Antigravity.";...@@ -37,7 +37,10 @@ const SIGN_IN_MESSAGE = "Sign in with Google to use Antigravity.";
37const AUTH_UNCHECKED_MESSAGE =37const AUTH_UNCHECKED_MESSAGE =
38 "Antigravity is installed. Google account access is not checked yet.";38 "Antigravity is installed. Google account access is not checked yet.";
3939
40type SessionSetupResult = AcpSessionRuntimeStartResult["sessionSetupResult"];40type SessionSetupResult = Pick<
41 AcpSessionRuntimeStartResult["sessionSetupResult"],
42 "configOptions" | "models"
43>;
4144
42/** Keep the native model IDs, including model-specific thinking levels. */45/** Keep the native model IDs, including model-specific thinking levels. */
43export function buildAntigravityModelsFromSession(46export function buildAntigravityModelsFromSession(
...@@ -299,6 +302,16 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func...@@ -299,6 +302,16 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func
299 });302 });
300 });303 });
301304
305 const onConfigOptionsUpdated = Effect.fn("AntigravityProvider.onConfigOptionsUpdated")(function* (
306 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>,
307 ) {
308 const models = buildAntigravityModelsFromSession({ configOptions });
309 yield* SubscriptionRef.update(metadata, (state) => {
310 if (state.draft.auth.status !== "authenticated") return state;
311 return { ...state, draft: { ...state.draft, models } };
312 });
313 });
314
302 const onAvailableCommands = Effect.fn("AntigravityProvider.onAvailableCommands")(function* (315 const onAvailableCommands = Effect.fn("AntigravityProvider.onAvailableCommands")(function* (
303 commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>,316 commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>,
304 cwd?: string,317 cwd?: string,
...@@ -373,6 +386,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func...@@ -373,6 +386,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func
373 return {386 return {
374 snapshot: { ...managed, getSnapshot },387 snapshot: { ...managed, getSnapshot },
375 onSessionStarted,388 onSessionStarted,
389 onConfigOptionsUpdated,
376 onAvailableCommands,390 onAvailableCommands,
377 onSignedOut: clearAccountMetadata(),391 onSignedOut: clearAccountMetadata(),
378 onAuthRequired: clearAccountMetadata(),392 onAuthRequired: clearAccountMetadata(),
apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts+18
...@@ -69,11 +69,15 @@ describe("AcpSessionRuntime", () => {...@@ -69,11 +69,15 @@ describe("AcpSessionRuntime", () => {
69 expect(events.map((event) => event._tag)).toEqual([69 expect(events.map((event) => event._tag)).toEqual([
70 "AvailableCommandsUpdated",70 "AvailableCommandsUpdated",
71 "ModeChanged",71 "ModeChanged",
72 "ConfigOptionsUpdated",
72 ]);73 ]);
73 expect(events[0]).toMatchObject({74 expect(events[0]).toMatchObject({
74 availableCommands: [{ name: "plan", description: "Native command" }],75 availableCommands: [{ name: "plan", description: "Native command" }],
75 });76 });
76 expect(yield* runtime.getModeState).toMatchObject({ currentModeId: "code" });77 expect(yield* runtime.getModeState).toMatchObject({ currentModeId: "code" });
78 expect(events[2]).toMatchObject({
79 configOptions: yield* runtime.getConfigOptions,
80 });
77 expect(81 expect(
78 (yield* runtime.getConfigOptions).find((option) => option.category === "model"),82 (yield* runtime.getConfigOptions).find((option) => option.category === "model"),
79 ).toMatchObject({ currentValue: "gpt-5.4" });83 ).toMatchObject({ currentValue: "gpt-5.4" });
...@@ -81,6 +85,20 @@ describe("AcpSessionRuntime", () => {...@@ -81,6 +85,20 @@ describe("AcpSessionRuntime", () => {
81 );85 );
82 }86 }
8387
88 it.effect("publishes model changes returned by a config request and live notifications", () =>
89 Effect.gen(function* () {
90 const runtime = yield* AcpSessionRuntime.make(mockRuntimeOptions);
91 yield* runtime.start();
92 const updates = yield* Stream.toPull(
93 runtime.getEvents().pipe(Stream.filter((event) => event._tag === "ConfigOptionsUpdated")),
94 );
95 const selected = yield* runtime.setConfigOption("model", "composer-2");
96 expect((yield* updates)[0]?.configOptions).toEqual(selected.configOptions);
97 yield* runtime.request("_test/startup-metadata", {});
98 expect((yield* updates)[0]?.configOptions).toEqual(yield* runtime.getConfigOptions);
99 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
100 );
101
84 it.effect("awaits native resume instead of using the load replay idle fallback", () =>102 it.effect("awaits native resume instead of using the load replay idle fallback", () =>
85 Effect.gen(function* () {103 Effect.gen(function* () {
86 const resumeStarted = yield* Deferred.make<void>();104 const resumeStarted = yield* Deferred.make<void>();
apps/server/src/provider/acp/AcpRuntimeModel.ts+13
...@@ -90,6 +90,11 @@ export type AcpParsedSessionEvent =...@@ -90,6 +90,11 @@ export type AcpParsedSessionEvent =
90 readonly availableCommands: ReadonlyArray<EffectAcpSchema.AvailableCommand>;90 readonly availableCommands: ReadonlyArray<EffectAcpSchema.AvailableCommand>;
91 readonly rawPayload: unknown;91 readonly rawPayload: unknown;
92 }92 }
93 | {
94 readonly _tag: "ConfigOptionsUpdated";
95 readonly configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>;
96 readonly rawPayload: unknown;
97 }
93 | {98 | {
94 readonly _tag: "AssistantItemStarted";99 readonly _tag: "AssistantItemStarted";
95 readonly itemId: string;100 readonly itemId: string;
...@@ -788,6 +793,14 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat...@@ -788,6 +793,14 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat
788 let modeId: string | undefined;793 let modeId: string | undefined;
789794
790 switch (upd.sessionUpdate) {795 switch (upd.sessionUpdate) {
796 case "config_option_update": {
797 events.push({
798 _tag: "ConfigOptionsUpdated",
799 configOptions: upd.configOptions,
800 rawPayload: params,
801 });
802 break;
803 }
791 case "available_commands_update": {804 case "available_commands_update": {
792 events.push({805 events.push({
793 _tag: "AvailableCommandsUpdated",806 _tag: "AvailableCommandsUpdated",
apps/server/src/provider/acp/AcpSessionRuntime.ts+11-7
...@@ -615,13 +615,17 @@ export const make = (...@@ -615,13 +615,17 @@ export const make = (
615 });615 });
616 });616 });
617617
618 const updateConfigOptions = (618 const updateConfigOptions = Effect.fn("AcpSessionRuntime.updateConfigOptions")(function* (
619 response:619 response: EffectAcpSchema.SetSessionConfigOptionResponse,
620 | EffectAcpSchema.SetSessionConfigOptionResponse620 ) {
621 | EffectAcpSchema.LoadSessionResponse621 const configOptions = sessionConfigOptionsFromSetup(response);
622 | EffectAcpSchema.NewSessionResponse622 yield* Ref.set(configOptionsRef, configOptions);
623 | EffectAcpSchema.ResumeSessionResponse,623 yield* Queue.offer(eventQueue, {
624 ): Effect.Effect<void> => Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(response));624 _tag: "ConfigOptionsUpdated",
625 configOptions,
626 rawPayload: response,
627 });
628 });
625629
626 const updateCurrentModeId = (modeId: string): Effect.Effect<void> =>630 const updateCurrentModeId = (modeId: string): Effect.Effect<void> =>
627 Ref.update(modeStateRef, (current) =>631 Ref.update(modeStateRef, (current) =>
docs/internals/providers.md+2
...@@ -168,6 +168,8 @@ Account access starts unknown and becomes authenticated after successful session...@@ -168,6 +168,8 @@ Account access starts unknown and becomes authenticated after successful session
168including an explicit model refresh.168including an explicit model refresh.
169The [provider snapshot][antigravity-provider] takes models and commands from setup and native169The [provider snapshot][antigravity-provider] takes models and commands from setup and native
170updates. It preserves returned Gemini model IDs, labels, order, and thinking-level choices.170updates. It preserves returned Gemini model IDs, labels, order, and thinking-level choices.
171ACP `config_option_update` notifications and `session/set_config_option` responses replace
172the instance's model catalog. Child session notifications do not change the root catalog.
171The registry treats a successful empty catalog as authoritative and clears cached metadata173The registry treats a successful empty catalog as authoritative and clears cached metadata
172after sign-out. It must not retain a previous account's models. Cached models do not prove174after sign-out. It must not retain a previous account's models. Cached models do not prove
173current access. The auth response does not supply an email, plan tier, or reliable quota.175current access. The auth response does not supply an email, plan tier, or reliable quota.
docs/user/providers-antigravity.md+1
...@@ -107,6 +107,7 @@ installations. Clear **Binary path** to use managed installation controls....@@ -107,6 +107,7 @@ installations. Clear **Binary path** to use managed installation controls.
107The current official ACP exposes Gemini models only. T3 Code uses the model IDs and names107The current official ACP exposes Gemini models only. T3 Code uses the model IDs and names
108returned for your account, including any model choices with different thinking levels. Models108returned for your account, including any model choices with different thinking levels. Models
109available in other Antigravity apps might not be available through this agent.109available in other Antigravity apps might not be available through this agent.
110The model picker updates when a running session reports new model choices.
110111
111New threads use Gemini 3.8 Flash (High) when your account offers it. Older Gemini generations112New threads use Gemini 3.8 Flash (High) when your account offers it. Older Gemini generations
112stay available under **Legacy models** in the picker.113stay available under **Legacy models** in the picker.