Repository navigation
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/mobile
Steps to reproduce
Preconditions: an existing Antigravity thread on a connected mobile client with a nonempty saved model selection; the provider is enabled and installed; no project override or managed policy restricts it. Case A: the server publishes the post-initialize snapshot status: "warning", auth: { status: "unknown" }, models: [], which a restart produces when no usable matching provider-status cache exists. Case B: a warning or error snapshot with auth: { status: "authenticated" } whose nonempty catalog lacks the saved model. Type a follow-up in the thread: Send stays disabled.
The unchecked startup snapshot is pinned by the production provider test. The mobile composer passes the saved selection and connected server configuration directly to the exported isModelSelectionUnavailable; the send callback does the same for draft.modelSelection ?? thread.modelSelection.
Deterministic reproduction without a Google account or a device: from the root of a checkout of the commit under Version or commit, with its locked dependencies installed, run the script below with sh repro.sh. It uses private runtime directories, writes a temporary mobile test that calls the exported production functions for both failing inputs beside a ready control, and removes the test on exit. Its assertions encode the current defect, so it passes while the defect is present.
Reproduction script: repro.sh
set -eu
tmp=$(mktemp -d)
mkdir -p "$tmp/user" "$tmp/tmp" "$tmp/t3"
export HOME="$tmp/user" TMPDIR="$tmp/tmp" T3CODE_HOME="$tmp/t3"
export VERIFY_OUTPUT="$tmp/result"
export PATH="$PWD/node_modules/.bin:$PATH"
testfile=apps/mobile/src/lib/verify-antigravity-restart.test.ts
trap 'rm -f "$testfile"; rm -rf "$tmp"' EXIT
cat > "$testfile" <<'TS'
import { appendFileSync } from "node:fs";
import { expect, it } from "vite-plus/test";
import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts";
import { isModelSelectionUnavailable, resolveSelectableModelSelection, describeUnavailableModelSelection } from "./modelOptions";
it("compares an unchecked existing selection with a ready catalog", () => {
const selection = { instanceId: ProviderInstanceId.make("google_work"), model: "gemini-3.7-flash-high" };
const base = { instanceId: selection.instanceId, driver: "antigravity", enabled: true, installed: true, status: "warning", auth: { status: "unknown" }, models: [] };
const catalog = [{ slug: selection.model, name: "Gemini 3.7 Flash (High)", capabilities: null, isCustom: false }];
const cases = [
["unchecked", base, true],
["nonready-missing", { ...base, auth: { status: "authenticated" }, models: [{ ...catalog[0], slug: "another-model" }] }, true],
["ready-control", { ...base, status: "ready", auth: { status: "authenticated" }, models: catalog }, false],
] as const;
for (const [name, provider, expected] of cases) {
const config = { providers: [provider] } as unknown as ServerConfig;
expect(resolveSelectableModelSelection(config, selection, null)).toEqual(selection);
const blocked = isModelSelectionUnavailable(config, selection, null);
expect(blocked).toBe(expected);
appendFileSync(process.env.VERIFY_OUTPUT!, JSON.stringify({ name, blocked, ...(blocked ? describeUnavailableModelSelection(config, selection, null) : {}) }) + "\n");
}
});
TS
env -u NODE_ENV vp test run "$testfile"
cat "$VERIFY_OUTPUT"A fuller supporting reproduction, repro-full.sh (run the same way), drives the exported Antigravity provider through the repository's own ACP test harness to produce the real startup snapshot, passes it through production cache hydration and registry merge, executes the exact modelUnavailable, canSend and send-guard expressions extracted from the mobile source, and compares web's getAntigravitySendBlockReason on the same inputs:
Supporting reproduction: repro-full.sh
#!/bin/sh
set -eu
checkout=${1:-$PWD}
cd "$checkout"
checkout=$PWD
fixture=$(mktemp -d "${TMPDIR:-/tmp}/antigravity-mobile-verify.XXXXXX")
mkdir -p "$fixture/user" "$fixture/tmp" "$fixture/t3"
export HOME="$fixture/user" TMPDIR="$fixture/tmp" T3CODE_HOME="$fixture/t3"
export VERIFY_EVIDENCE="$fixture/observations.out"
export PATH="$checkout/node_modules/.bin:$PATH"
server_test="$checkout/apps/server/src/provider/verify-antigravity-mobile-restart.test.ts"
web_test="$checkout/apps/web/src/components/verify-antigravity-mobile-restart.test.ts"
cleanup() { rm -f "$server_test" "$web_test"; rm -rf "$fixture"; }
trap cleanup EXIT INT TERM
# Reuse the repository's provider test harness and its ordinary session fixture.
# The harness supplies an ACP handshake, while exported production functions
# produce snapshots, merge caches, retain selections and decide availability.
awk '/^describe\("Antigravity model catalog"/ { exit } { print }' apps/server/src/provider/AntigravityProvider.test.ts > "$server_test"
cat >> "$server_test" <<'TS'
import { readFileSync, appendFileSync } from "node:fs";
const log = (...values: unknown[]) => appendFileSync(process.env.VERIFY_EVIDENCE!, values.join(" ") + "\n");
import { type ServerConfig as ClientConfig } from "@t3tools/contracts";
import { mergeProviderSnapshot } from "./ProviderRegistry.ts";
import { hydrateCachedProvider } from "./providerStatusCache.ts";
import {
isModelSelectionUnavailable,
resolveSelectableModelSelection,
describeUnavailableModelSelection,
} from "../../../mobile/src/lib/modelOptions.ts";
const selection = { instanceId, model: "gemini-3.7-flash-high" };
const clientConfig = (provider: ServerProvider) => ({ providers: [provider] }) as unknown as ClientConfig;
const unavailable = (provider: ServerProvider) => isModelSelectionUnavailable(clientConfig(provider), selection, null);
it.layer(layerTest)("production Antigravity snapshots into mobile", (it) => {
it.effect("compares unchecked startup, local refresh, session catalog, cache and missing-model inputs", () => Effect.scoped(Effect.gen(function* () {
const harness = yield* makeHarness();
yield* harness.initialize;
const raw = yield* harness.provider.snapshot.getSnapshot;
expect(raw).toMatchObject({ installed: true, status: "warning", auth: { status: "unknown" }, models: [] });
// With no correlated cache, the registry publishes the genuine snapshot unchanged.
const restart = mergeProviderSnapshot(undefined, raw);
expect(resolveSelectableModelSelection(clientConfig(restart), selection, null)).toEqual(selection);
expect(unavailable(restart)).toBe(true);
// Execute the exact connected-state predicate and send-button expression from
// production source; this is source-slice execution, not a mounted native UI.
const ui = readFileSync(new URL("../../../mobile/src/features/threads/ThreadComposer.tsx", import.meta.url), "utf8");
const hook = readFileSync(new URL("../../../mobile/src/state/use-thread-composer-state.ts", import.meta.url), "utf8");
const extract = (source: string, pattern: RegExp) => {
const value = source.match(pattern)?.[1];
if (!value) throw new Error(`Caller changed: ${pattern}`);
return value;
};
const uiUnavailable = extract(ui, /const modelUnavailable =([\s\S]*?);/);
const canSendExpression = extract(ui, /const canSend =([\s\S]*?);/);
const preflight = extract(hook, /(if \(modelUnavailable\) \{\s+alertModelUnavailable\(\);\s+return null;\s+\})/);
expect(ui).toContain('label="Model unavailable. Open model settings."');
expect(ui).toContain("disabled={!canSend}");
const callerResult = (provider: ServerProvider) => {
const props = { connectionState: "connected", canOperateThread: true, serverConfig: clientConfig(provider), selectedThread: { projectId: null } };
const modelUnavailable = new Function("props", "currentModelSelection", "isModelSelectionUnavailable", `return (${uiUnavailable});`)(props, selection, isModelSelectionUnavailable);
const canSend = new Function("props", "hasContent", "contextImports", "composerDraftKey", "isSubmissionLocked", "sendBlockedReason", "modelUnavailable", `return (${canSendExpression});`)(props, true, {}, "draft", false, null, modelUnavailable);
let alert: unknown = null;
const result = new Function("modelUnavailable", "alertModelUnavailable", `${preflight}\nreturn "dispatch";`)(modelUnavailable, () => { alert = describeUnavailableModelSelection(clientConfig(provider), selection, null); });
return { modelUnavailable, canSend, preflight: result, alert };
};
const fail = callerResult(restart);
expect(fail).toEqual({ modelUnavailable: true, canSend: false, preflight: null, alert: { title: "Antigravity model unavailable", detail: "Set up Antigravity on web or desktop, or choose another model." } });
log("RESTART_NO_STATUS_CACHE", JSON.stringify({ status: restart.status, auth: restart.auth.status, models: restart.models.length, ...fail }));
const healthRefreshed = yield* harness.provider.snapshot.refresh;
expect(healthRefreshed).toMatchObject({ status: "warning", auth: { status: "unknown" }, models: [] });
expect(unavailable(healthRefreshed)).toBe(true);
log("LOCAL_HEALTH_REFRESH", JSON.stringify({ blocked: unavailable(healthRefreshed), status: healthRefreshed.status, auth: healthRefreshed.auth.status, models: healthRefreshed.models.length }));
yield* harness.provider.onSessionStarted(started);
const ready = yield* harness.provider.snapshot.getSnapshot;
expect(ready).toMatchObject({ status: "ready", auth: { status: "authenticated" } });
expect(ready.models.some((model) => model.slug === selection.model)).toBe(true);
expect(unavailable(ready)).toBe(false);
expect(callerResult(ready)).toEqual({ modelUnavailable: false, canSend: true, preflight: "dispatch", alert: null });
log("READY_CONTROL_AND_RECOVERY", JSON.stringify(callerResult(ready)));
const cached = hydrateCachedProvider({ cachedProvider: ready, fallbackProvider: restart });
const merged = mergeProviderSnapshot(cached, restart);
expect(merged).toMatchObject({ status: "ready", auth: { status: "authenticated" } });
expect(unavailable(merged)).toBe(false);
log("VALID_STATUS_CACHE_CONTROL", JSON.stringify({ blocked: unavailable(merged), status: merged.status, auth: merged.auth.status, models: merged.models.length }));
const otherModel = ready.models.find((model) => model.slug !== selection.model)!;
for (const status of ["warning", "error"] as const) {
const stale = hydrateCachedProvider({ cachedProvider: { ...ready, status, models: [otherModel] }, fallbackProvider: restart });
const snapshot = stale;
expect(snapshot.status).toBe(status);
expect(unavailable(snapshot)).toBe(true);
log("NONREADY_MISSING_MODEL", JSON.stringify({ status: snapshot.status, auth: snapshot.auth.status, catalog: snapshot.models.map((model) => model.slug), ...callerResult(snapshot) }));
}
})));
});
TS
cat > "$web_test" <<'TS'
import { expect, it } from "vite-plus/test";
import { appendFileSync } from "node:fs";
const log = (...values: unknown[]) => appendFileSync(process.env.VERIFY_EVIDENCE!, values.join(" ") + "\n");
import { type ServerProvider } from "@t3tools/contracts";
import { getAntigravitySendBlockReason } from "./ChatView.logic";
it("compares web allowance for the same installed startup and incomplete catalogs", () => {
const base = { driver: "antigravity", installed: true, status: "warning", auth: { status: "unknown" }, models: [] } as unknown as ServerProvider;
expect(getAntigravitySendBlockReason(base, "gemini-3.7-flash-high")).toBeNull();
log("WEB_UNCHECKED_CONTROL", JSON.stringify({ blockReason: getAntigravitySendBlockReason(base, "gemini-3.7-flash-high") }));
for (const status of ["warning", "error"] as const) {
const provider = { ...base, status, auth: { status: "authenticated" as const }, models: [{ slug: "other-model", name: "Other", capabilities: null, isCustom: false }] };
expect(getAntigravitySendBlockReason(provider, "gemini-3.7-flash-high")).toBeNull();
log("WEB_NONREADY_CONTROL", JSON.stringify({ status, blockReason: getAntigravitySendBlockReason(provider, "gemini-3.7-flash-high") }));
}
});
TS
printf 'REVISION '; git rev-parse HEAD
env -u NODE_ENV vp test run apps/server/src/provider/verify-antigravity-mobile-restart.test.ts apps/web/src/components/verify-antigravity-mobile-restart.test.ts
cat "$VERIFY_EVIDENCE"Expected behavior
An installed Antigravity provider with unchecked Google sign-in should let an existing thread's nonempty saved selection reach session startup, where the saved credentials and requested model are validated. A nonempty but incomplete catalog at a non-ready status should not by itself block retrying the existing selection. A confirmed sign-out, a missing installation, a disabled provider, a project restriction or a managed-policy restriction can still block sending, and a slug absent from a ready, authoritative catalog can still be rejected.
This is what web's production gate does: it allows a nonempty saved model immediately when auth is unknown and rejects a missing model only when the snapshot is ready. Merged PR #9647 (commit d487dfbf46be344e818725be70ee04be2436bfb4) corrected restart continuation on web and documented that an existing thread can continue while sign-in is unchecked. The current Antigravity user guide says a restart keeps Google sign-in and points mobile users to Refresh models in thread settings. The web ready-only missing-model rule dates from the initial Antigravity feature (#9348), which added mobile's unconditional catalog check in the same change; neither PR discussion describes the mobile difference as intentional.
Actual behavior
On a connected mobile client, an existing Antigravity thread with a nonempty saved model cannot send a follow-up when the provider snapshot is installed with unchecked sign-in and an empty catalog (status: "warning", auth: { status: "unknown" }, models: []), or when a non-ready (warning or error) authenticated snapshot has a nonempty catalog that lacks the saved model. The Send button is disabled and a settings button reads "Model unavailable. Open model settings."; if the send callback runs anyway it returns before dispatch with the alert "Antigravity model unavailable". Web allows sending in both cases and lets session startup validate the saved login and model. The unchecked case is the startup snapshot a server publishes when it has no usable provider-status cache, for example after a restart before sign-in has been checked; a valid cached authenticated catalog containing the saved model already avoids it. The block clears once a successful model refresh or session publishes a catalog containing the saved model, so the thread is not permanently stuck.
The exported mobile isModelSelectionUnavailable returns true for both inputs while resolveSelectableModelSelection keeps the same saved selection. Its catalog-membership check requires the saved slug in the catalog unconditionally, with neither web's unknown-auth allowance nor its ready-only missing-model rule.
The resulting UI is source-derived, not observed on a device: the canSend predicate excludes unavailable models, the send button receives disabled={!canSend}, and a separate settings button is labeled "Model unavailable. Open model settings.". If the send callback is invoked, its guard returns null before dispatch and raises "Antigravity model unavailable" with "Set up Antigravity on web or desktop, or choose another model."; no message reaches the provider.
Both scripts passed on the commit under Version or commit from a clean checkout, and git status showed no changes afterward; their full output is under Logs or stack traces. The fuller run shows: the real startup snapshot gives canSend=false and a send guard returning null; another installation-only health refresh leaves it blocked; after a successful session publishes a ready catalog containing the saved slug, the same selection gives canSend=true and the guard reaches dispatch; a valid cached ready catalog survives the registry merge and is not blocked; web returns no block reason for the unchecked and non-ready inputs that mobile blocks. These are exported-function results and executed source slices; React Native was not mounted and no live Google session was used.
Evidence
- Expected source: web
getAntigravitySendBlockReasoninapps/web/src/components/ChatView.logic.tslines 526-553 and its restart-continuation rationale in merged fix(web): resume Antigravity threads without repeated sign-in #9647, plusdocs/user/providers-antigravity.mdlines 144-149. - Failure source: the
repro.shandrepro-full.shoutputs under Logs or stack traces, produced by the exported mobileisModelSelectionUnavailable(apps/mobile/src/lib/modelOptions.tslines 163-190) and its callers inThreadComposer.tsxanduse-thread-composer-state.ts. - Evidence provenance: observed
- Local verification: reproduced
- Reproduction completeness: complete
The disabled Send button and the settings-button text are derived from the production source lines cited above rather than from a rendered device screen.
Related
Related but not duplicates: open #25587 skips one stale thread-model check in the send callback but does not change ThreadComposer.tsx or isModelSelectionUnavailable, so the composer's Send button stays disabled for this input and explicit draft picks stay blocked. Open #28433 improves server catalog retention after partial ACP config updates and can reduce one source of stale catalogs, but does not add mobile's missing allowances. Closed, unmerged #32661 only changed notice presentation. Closed #10110 and merged #10244 fixed cached-account retention across restart, which is why a valid status cache already avoids the unchecked case. #34160, #33032, #29379 and #28870 concern other boundaries: an inactivity watchdog after a prompt is sent, proxy-related installation and sign-in preflight, a session startup hang after authentication, and thinking-signature corruption after a runtime resume.
Restoration check
With the repro.sh inputs, isModelSelectionUnavailable returns false for the unchecked case and the non-ready missing-model case, so the script's true expectations for those two cases fail once fixed; in repro-full.sh, the startup and non-ready inputs give canSend=true and a guard that reaches dispatch. Controls must keep their current results: the ready, authenticated catalog containing the saved slug stays sendable; a slug missing from a ready, authoritative catalog stays blocked; and a confirmed sign-out, missing installation, disabled provider, project restriction or managed-policy restriction still blocks sending.
Impact
Minor bug or occasional failure
Version or commit
T3 Code 0.1.9 at d9805b2, rerun from a clean checkout. An earlier run of repro-full.sh (differing only in a private fixture directory name) produced identical observations on upstream main at 371963c.
Environment
macOS on arm64, Node v26.12.1, repository-locked Vite+ test runner (vp test run) from the repository root; no native app, device, running server or Google account needed. The mobile app release and device OS were not measured because no native app was launched.
Logs or stack traces
Output of repro.sh:
Test Files 1 passed (1)
Tests 1 passed (1)
{"name":"unchecked","blocked":true,"title":"Antigravity model unavailable","detail":"Set up Antigravity on web or desktop, or choose another model."}
{"name":"nonready-missing","blocked":true,"title":"Antigravity model unavailable","detail":"Set up Antigravity on web or desktop, or choose another model."}
{"name":"ready-control","blocked":false}
Observations from repro-full.sh:
Test Files 2 passed (2)
Tests 2 passed (2)
WEB_UNCHECKED_CONTROL {"blockReason":null}
WEB_NONREADY_CONTROL {"status":"warning","blockReason":null}
WEB_NONREADY_CONTROL {"status":"error","blockReason":null}
RESTART_NO_STATUS_CACHE {"status":"warning","auth":"unknown","models":0,"modelUnavailable":true,"canSend":false,"preflight":null,"alert":{"title":"Antigravity model unavailable","detail":"Set up Antigravity on web or desktop, or choose another model."}}
LOCAL_HEALTH_REFRESH {"blocked":true,"status":"warning","auth":"unknown","models":0}
READY_CONTROL_AND_RECOVERY {"modelUnavailable":false,"canSend":true,"preflight":"dispatch","alert":null}
VALID_STATUS_CACHE_CONTROL {"blocked":false,"status":"ready","auth":"authenticated","models":11}
NONREADY_MISSING_MODEL {"status":"warning","auth":"authenticated","catalog":["gemini-3.8-flash-high"],"modelUnavailable":true,"canSend":false,"preflight":null,"alert":{"title":"Antigravity model unavailable","detail":"Set up Antigravity on web or desktop, or choose another model."}}
NONREADY_MISSING_MODEL {"status":"error","auth":"authenticated","catalog":["gemini-3.8-flash-high"],"modelUnavailable":true,"canSend":false,"preflight":null,"alert":{"title":"Antigravity model unavailable","detail":"Set up Antigravity on web or desktop, or choose another model."}}
Screenshots, recordings, or supporting files
No response
Workaround
Refresh models in mobile thread settings (which requests an authenticated catalog refresh), refresh provider models on web or desktop, or start a session from web or desktop; once the catalog containing the saved model arrives the same selection becomes sendable. This path is source-traced and verified through the provider's successful-session callback; a live Google-account refresh was not exercised, and an installation-only health check alone does not populate the catalog.
Activity
juliusmarminge commentedon Nov 12, 2026
I tried to reproduce this on the current build and could not.
On origin/main a909ea4 I added two cases to the existing 'Antigravity selections' describe in apps/mobile/src/lib/modelOptions.test.ts, using its own Antigravity config (enabled, installed). Case 1: status 'warning', auth 'unknown', models [] (the unchecked startup snapshot). Case 2: status 'warning', authenticated, catalog lists only another model. Both assert isModelSelectionUnavailable(config, savedSelection, null) === false, which is what web's getAntigravitySendBlockReason allows.
vp test run src/lib/modelOptions.test.tsin apps/mobile: both new cases fail with 'expected true to be false'; the other 31 pass. That return value drives ThreadComposer's modelUnavailable/canSend and the send guard in use-thread-composer-state, so Send stays disabled. I did not drive this on a device: no VM has Antigravity, so a real Antigravity snapshot can't reach a mobile client here.Closing as not planned. If it still happens for you, reply with the exact steps and the version you are on and it will be looked at again.