| author | |
| committer | |
| log | c8872fd22aadb8d155f1bedc10576d8314f93e56 |
| tree | 38be5358a6fe866b9e2ed8298d4f023219c70e83 |
| parent | add8c3a55ac8a7d520dc2ea11a8fa05bc3cee361 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>5 files changed, 190 insertions(+), 23 deletions(-)
apps/server/src/provider/Layers/AntigravityProvider.test.ts+17| ... | @@ -264,6 +264,23 @@ it.layer(testLayer)("Antigravity provider snapshots", (it) => { | ... | @@ -264,6 +264,23 @@ it.layer(testLayer)("Antigravity provider snapshots", (it) => { |
| 264 | ), | 264 | ), |
| 265 | ); | 265 | ); |
| 266 | 266 | ||
| 267 | it.effect("publishes the configured sign-in method before any account is checked", () => | ||
| 268 | Effect.scoped( | ||
| 269 | Effect.gen(function* () { | ||
| 270 | const provider = yield* makeAntigravityProvider(decodeSettings({ enabled: true }), { | ||
| 271 | stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }), | ||
| 272 | probe: Effect.succeed(initializeResult), | ||
| 273 | supportsTextGeneration: Effect.succeed(true), | ||
| 274 | auth: { type: "gemini-api-key", label: "Gemini API key" }, | ||
| 275 | }); | ||
| 276 | expect((yield* provider.snapshot.getSnapshot).auth).toEqual({ | ||
| 277 | status: "unknown", | ||
| 278 | type: "gemini-api-key", | ||
| 279 | }); | ||
| 280 | }), | ||
| 281 | ), | ||
| 282 | ); | ||
| 283 | |||
| 267 | it.effect("treats initialize as installation proof, not account or model discovery", () => | 284 | it.effect("treats initialize as installation proof, not account or model discovery", () => |
| 268 | Effect.scoped( | 285 | Effect.scoped( |
| 269 | Effect.gen(function* () { | 286 | Effect.gen(function* () { |
apps/server/src/provider/Layers/AntigravityProvider.ts+3-1| ... | @@ -144,7 +144,9 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func | ... | @@ -144,7 +144,9 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func |
| 144 | installed: false, | 144 | installed: false, |
| 145 | version: null, | 145 | version: null, |
| 146 | status: "warning", | 146 | status: "warning", |
| 147 | auth: { status: "unknown" }, | 147 | // The configured method rides along so the registry can tell a saved |
| 148 | // account for this method from one left by a previous configuration. | ||
| 149 | auth: { status: "unknown", ...(options.auth ? { type: options.auth.type } : {}) }, | ||
| 148 | message: settings.enabled | 150 | message: settings.enabled |
| 149 | ? "Checking Antigravity availability." | 151 | ? "Checking Antigravity availability." |
| 150 | : "Antigravity is disabled in T3 Code settings.", | 152 | : "Antigravity is disabled in T3 Code settings.", |
apps/server/src/provider/Layers/ProviderRegistry.test.ts+107| ... | @@ -1179,6 +1179,113 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te | ... | @@ -1179,6 +1179,113 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te |
| 1179 | }); | 1179 | }); |
| 1180 | }); | 1180 | }); |
| 1181 | 1181 | ||
| 1182 | describe("Antigravity saved account", () => { | ||
| 1183 | const signedIn = { | ||
| 1184 | instanceId: ProviderInstanceId.make("antigravity-personal"), | ||
| 1185 | driver: ProviderDriverKind.make("antigravity"), | ||
| 1186 | status: "ready", | ||
| 1187 | enabled: true, | ||
| 1188 | installed: true, | ||
| 1189 | auth: { status: "authenticated", type: "oauth-personal", label: "Google account" }, | ||
| 1190 | checkedAt: "2026-09-05T00:00:00.000Z", | ||
| 1191 | version: "agy_acp_server_1.1.1", | ||
| 1192 | models: [ | ||
| 1193 | { | ||
| 1194 | slug: "gemini-3.7-flash-high", | ||
| 1195 | name: "Gemini 3.7 Flash", | ||
| 1196 | isCustom: false, | ||
| 1197 | capabilities: null, | ||
| 1198 | }, | ||
| 1199 | ], | ||
| 1200 | slashCommands: [{ name: "plan" }], | ||
| 1201 | skills: [], | ||
| 1202 | } as const satisfies ServerProvider; | ||
| 1203 | const uncheckedMessage = | ||
| 1204 | "Antigravity is installed. Google account access is not checked yet."; | ||
| 1205 | const restartProbe = { | ||
| 1206 | ...signedIn, | ||
| 1207 | status: "warning", | ||
| 1208 | auth: { status: "unknown" }, | ||
| 1209 | checkedAt: "2026-09-05T00:01:00.000Z", | ||
| 1210 | message: uncheckedMessage, | ||
| 1211 | models: [], | ||
| 1212 | } as const satisfies ServerProvider; | ||
| 1213 | |||
| 1214 | it("keeps the saved Google account through restart health checks", () => { | ||
| 1215 | const merged = mergeProviderSnapshot(signedIn, restartProbe); | ||
| 1216 | const { message: _uncheckedMessage, ...probeWithoutMessage } = restartProbe; | ||
| 1217 | assert.deepStrictEqual(merged, { | ||
| 1218 | ...probeWithoutMessage, | ||
| 1219 | status: "ready", | ||
| 1220 | auth: signedIn.auth, | ||
| 1221 | models: signedIn.models, | ||
| 1222 | }); | ||
| 1223 | assert.equal("message" in merged, false); | ||
| 1224 | // The next periodic probe reads the merged snapshot as its previous state. | ||
| 1225 | assert.deepStrictEqual(mergeProviderSnapshot(merged, restartProbe), merged); | ||
| 1226 | }); | ||
| 1227 | |||
| 1228 | it("carries the account through the boot probe and a failed probe without hiding them", () => { | ||
| 1229 | const booting = { | ||
| 1230 | ...restartProbe, | ||
| 1231 | installed: false, | ||
| 1232 | version: null, | ||
| 1233 | message: "Checking Antigravity availability.", | ||
| 1234 | } satisfies ServerProvider; | ||
| 1235 | assert.deepStrictEqual(mergeProviderSnapshot(signedIn, booting), { | ||
| 1236 | ...booting, | ||
| 1237 | auth: signedIn.auth, | ||
| 1238 | models: signedIn.models, | ||
| 1239 | }); | ||
| 1240 | |||
| 1241 | const failed = { | ||
| 1242 | ...restartProbe, | ||
| 1243 | status: "error", | ||
| 1244 | message: "Antigravity did not respond to its local health check within 90 seconds.", | ||
| 1245 | } satisfies ServerProvider; | ||
| 1246 | assert.deepStrictEqual(mergeProviderSnapshot(signedIn, failed), { | ||
| 1247 | ...failed, | ||
| 1248 | auth: signedIn.auth, | ||
| 1249 | models: signedIn.models, | ||
| 1250 | }); | ||
| 1251 | }); | ||
| 1252 | |||
| 1253 | it("does not invent an account after sign-out, disable, uninstall, or for other providers", () => { | ||
| 1254 | const untouched = [ | ||
| 1255 | { ...restartProbe, auth: { status: "unauthenticated" } }, | ||
| 1256 | { ...restartProbe, status: "disabled", enabled: false }, | ||
| 1257 | { ...restartProbe, status: "error", installed: false }, | ||
| 1258 | { ...restartProbe, driver: ProviderDriverKind.make("codex") }, | ||
| 1259 | // The instance was rebuilt with another sign-in method. | ||
| 1260 | { ...restartProbe, auth: { status: "unknown", type: "gemini-api-key" } }, | ||
| 1261 | ] satisfies ReadonlyArray<ServerProvider>; | ||
| 1262 | for (const next of untouched) { | ||
| 1263 | const merged = mergeProviderSnapshot(signedIn, next); | ||
| 1264 | assert.deepStrictEqual(merged.auth, next.auth); | ||
| 1265 | assert.equal(merged.status, next.status); | ||
| 1266 | assert.equal(merged.message, next.message); | ||
| 1267 | } | ||
| 1268 | assert.deepStrictEqual( | ||
| 1269 | mergeProviderSnapshot({ ...signedIn, auth: { status: "unknown" } }, restartProbe).auth, | ||
| 1270 | { status: "unknown" }, | ||
| 1271 | ); | ||
| 1272 | assert.equal( | ||
| 1273 | mergeProviderSnapshot( | ||
| 1274 | { ...signedIn, driver: ProviderDriverKind.make("codex") }, | ||
| 1275 | restartProbe, | ||
| 1276 | ).auth.status, | ||
| 1277 | "unknown", | ||
| 1278 | ); | ||
| 1279 | assert.deepStrictEqual( | ||
| 1280 | mergeProviderSnapshot(signedIn, { | ||
| 1281 | ...restartProbe, | ||
| 1282 | auth: { status: "unknown", type: "oauth-personal" }, | ||
| 1283 | }).auth, | ||
| 1284 | signedIn.auth, | ||
| 1285 | ); | ||
| 1286 | }); | ||
| 1287 | }); | ||
| 1288 | |||
| 1182 | it("fills missing capabilities from the previous provider snapshot", () => { | 1289 | it("fills missing capabilities from the previous provider snapshot", () => { |
| 1183 | const previousProvider = { | 1290 | const previousProvider = { |
| 1184 | instanceId: ProviderInstanceId.make("cursor"), | 1291 | instanceId: ProviderInstanceId.make("cursor"), |
apps/server/src/provider/Layers/ProviderRegistry.ts+60-22| ... | @@ -162,31 +162,69 @@ const mergeProviderModels = ( | ... | @@ -162,31 +162,69 @@ const mergeProviderModels = ( |
| 162 | : mergedModels; | 162 | : mergedModels; |
| 163 | }; | 163 | }; |
| 164 | 164 | ||
| 165 | /** | ||
| 166 | * Antigravity's health check only initializes the agent, so after a server | ||
| 167 | * restart it reports the account as unchecked. The saved Google login still | ||
| 168 | * works, and the previous snapshot proves it. Carry that account state until | ||
| 169 | * a session, refresh, or sign-out reports something new. A confirmed missing | ||
| 170 | * installation, sign-out, disabled instance, or a changed sign-in method is | ||
| 171 | * never overridden. | ||
| 172 | */ | ||
| 173 | const carrySavedAntigravityAccount = ( | ||
| 174 | previousProvider: ServerProvider, | ||
| 175 | nextProvider: ServerProvider, | ||
| 176 | ): Pick<ServerProvider, "auth" | "status"> | undefined => { | ||
| 177 | const antigravity = ProviderDriverKind.make("antigravity"); | ||
| 178 | if ( | ||
| 179 | nextProvider.driver !== antigravity || | ||
| 180 | previousProvider.driver !== antigravity || | ||
| 181 | !nextProvider.enabled || | ||
| 182 | nextProvider.auth.status !== "unknown" || | ||
| 183 | previousProvider.auth.status !== "authenticated" || | ||
| 184 | (nextProvider.auth.type !== undefined && | ||
| 185 | nextProvider.auth.type !== previousProvider.auth.type) || | ||
| 186 | (!nextProvider.installed && nextProvider.status !== "warning") | ||
| 187 | ) { | ||
| 188 | return undefined; | ||
| 189 | } | ||
| 190 | // The pending boot probe (`installed: false`, warning) and a failed probe | ||
| 191 | // keep their own status; only a passed health check reads as ready. | ||
| 192 | const status = | ||
| 193 | nextProvider.installed && nextProvider.status === "warning" ? "ready" : nextProvider.status; | ||
| 194 | return { auth: previousProvider.auth, status }; | ||
| 195 | }; | ||
| 196 | |||
| 165 | export const mergeProviderSnapshot = ( | 197 | export const mergeProviderSnapshot = ( |
| 166 | previousProvider: ServerProvider | undefined, | 198 | previousProvider: ServerProvider | undefined, |
| 167 | nextProvider: ServerProvider, | 199 | nextProvider: ServerProvider, |
| 168 | ): ServerProvider => | 200 | ): ServerProvider => { |
| 169 | !previousProvider | 201 | if (!previousProvider) { |
| 170 | ? nextProvider | 202 | return nextProvider; |
| 171 | : { | 203 | } |
| 172 | ...nextProvider, | 204 | const savedAccount = carrySavedAntigravityAccount(previousProvider, nextProvider); |
| 173 | models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), | 205 | // "Google account access is not checked yet" describes the probe, not the |
| 174 | ...(nextProvider.workspaceSnapshots !== undefined | 206 | // account; it must not outlive the state it explained. |
| 175 | ? { workspaceSnapshots: nextProvider.workspaceSnapshots } | 207 | const { message: _uncheckedMessage, ...nextWithoutMessage } = nextProvider; |
| 176 | : previousProvider.workspaceSnapshots !== undefined | 208 | return { |
| 177 | ? { workspaceSnapshots: previousProvider.workspaceSnapshots } | 209 | ...(savedAccount?.status === "ready" ? nextWithoutMessage : nextProvider), |
| 178 | : {}), | 210 | ...savedAccount, |
| 179 | ...(shouldRetainMissingOpenCodeMetadata(nextProvider) | 211 | models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), |
| 180 | ? { | 212 | ...(nextProvider.workspaceSnapshots !== undefined |
| 181 | slashCommands: | 213 | ? { workspaceSnapshots: nextProvider.workspaceSnapshots } |
| 182 | nextProvider.slashCommands.length === 0 | 214 | : previousProvider.workspaceSnapshots !== undefined |
| 183 | ? previousProvider.slashCommands | 215 | ? { workspaceSnapshots: previousProvider.workspaceSnapshots } |
| 184 | : nextProvider.slashCommands, | 216 | : {}), |
| 185 | skills: | 217 | ...(shouldRetainMissingOpenCodeMetadata(nextProvider) |
| 186 | nextProvider.skills.length === 0 ? previousProvider.skills : nextProvider.skills, | 218 | ? { |
| 187 | } | 219 | slashCommands: |
| 188 | : {}), | 220 | nextProvider.slashCommands.length === 0 |
| 189 | }; | 221 | ? previousProvider.slashCommands |
| 222 | : nextProvider.slashCommands, | ||
| 223 | skills: nextProvider.skills.length === 0 ? previousProvider.skills : nextProvider.skills, | ||
| 224 | } | ||
| 225 | : {}), | ||
| 226 | }; | ||
| 227 | }; | ||
| 190 | 228 | ||
| 191 | const haveProvidersChanged = ( | 229 | const haveProvidersChanged = ( |
| 192 | previousProviders: ReadonlyArray<ServerProvider>, | 230 | previousProviders: ReadonlyArray<ServerProvider>, |
docs/user/providers-antigravity.md+3| ... | @@ -123,6 +123,9 @@ is refused while the runtime is in use. | ... | @@ -123,6 +123,9 @@ is refused while the runtime is in use. |
| 123 | 123 | ||
| 124 | ## Check access and troubleshoot | 124 | ## Check access and troubleshoot |
| 125 | 125 | ||
| 126 | A server restart keeps your Google sign-in. The provider shows the saved account | ||
| 127 | until a session, a refresh, or a sign-out reports something new. | ||
| 128 | |||
| 126 | To check access and reload models, use **Refresh provider status** in web or desktop | 129 | To check access and reload models, use **Refresh provider status** in web or desktop |
| 127 | provider settings, or **Refresh models** in mobile thread settings. If asked to | 130 | provider settings, or **Refresh models** in mobile thread settings. If asked to |
| 128 | sign in again, use setup on web or desktop. | 131 | sign in again, use setup on web or desktop. |