Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions apps/server/src/provider/Layers/AntigravityAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,62 @@ GPT-OSS 120B (Medium)
});
});

it("parses Antigravity 1.1.5 machine model slugs and preserves them for selection", () => {
expect(
parseAntigravityModelLines(`
gemini-3.5-flash-medium
gemini-3.5-flash-high
gemini-3.5-flash-low
gemini-3.1-pro-low
gemini-3.1-pro-high
claude-sonnet-4-6
claude-opus-4-6-thinking
gpt-oss-120b-medium
`),
).toEqual([
{
slug: "gemini-3.5-flash",
name: "Gemini 3.5 Flash",
supportedReasoningEfforts: [
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
],
defaultReasoningEffort: "medium",
},
{
slug: "gemini-3.1-pro",
name: "Gemini 3.1 Pro",
supportedReasoningEfforts: [
{ value: "low", label: "Low" },
{ value: "high", label: "High" },
],
defaultReasoningEffort: "low",
},
{ slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{
slug: "claude-opus-4-6",
name: "Claude Opus 4.6",
supportedReasoningEfforts: [{ value: "thinking", label: "Thinking" }],
defaultReasoningEffort: "thinking",
},
{
slug: "gpt-oss-120b",
name: "GPT OSS 120B",
supportedReasoningEfforts: [{ value: "medium", label: "Medium" }],
defaultReasoningEffort: "medium",
},
]);

expect(resolveAntigravityCliModelLabel("gemini-3.5-flash", undefined, "medium")).toBe(
"gemini-3.5-flash-medium",
);
expect(resolveAntigravityCliModelLabel("gemini-3.5-flash", { reasoningEffort: "high" })).toBe(
"gemini-3.5-flash-high",
);
expect(resolveAntigravityCliModelLabel("claude-sonnet-4-6")).toBe("claude-sonnet-4-6");
});

it("discovers future CLI models without requiring a static catalog update", () => {
expect(
parseAntigravityModelLines(`
Expand Down
60 changes: 49 additions & 11 deletions apps/server/src/provider/Layers/AntigravityAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,9 +297,14 @@ const DEFAULT_EFFORT_BY_MODEL: Readonly<Record<string, string>> = {
"Claude Sonnet 4.6": "thinking",
"Claude Opus 4.6": "thinking",
"GPT-OSS 120B": "medium",
"gemini-3.5-flash": "medium",
"gemini-3.1-pro": "low",
"claude-opus-4-6": "thinking",
"gpt-oss-120b": "medium",
};

const EFFORT_ORDER = ["low", "medium", "high", "thinking"] as const;
const EFFORT_ORDER = ["low", "medium", "high", "thinking", "ultra"] as const;
const MACHINE_MODEL_EFFORTS = new Set(EFFORT_ORDER);

function effortLabel(value: string): string {
return value
Expand All @@ -309,6 +314,23 @@ function effortLabel(value: string): string {
.join(" ");
}

function isAntigravityMachineModelSlug(value: string): boolean {
return /^(?:claude|deepseek|gemini|gpt|grok|llama|mistral|qwen)(?:-[a-z0-9.]+)+$/u.test(value);
}

function antigravityModelDisplayName(model: string): string {
if (!isAntigravityMachineModelSlug(model)) return model;
return model
.split("-")
.map((part) => {
if (part === "gpt" || part === "oss") return part.toUpperCase();
if (/^\d+b$/u.test(part)) return part.toUpperCase();
return part.charAt(0).toUpperCase() + part.slice(1);
})
.join(" ")
.replace(/\b(\d+) (\d+)\b/gu, "$1.$2");
}

export function parseAntigravityCliModelLabel(
value: string,
): { model: string; effort?: string } | null {
Expand All @@ -318,11 +340,23 @@ export function parseAntigravityCliModelLabel(
.replace(/^(?:[*•-]\s+)+/u, "");
if (!trimmed) return null;
const match = trimmed.match(/^(.*?)\s+\(([^()]+)\)$/u);
if (!match?.[1] || !match[2]) return { model: trimmed };
return {
model: match[1].trim(),
effort: match[2].trim().toLowerCase(),
};
if (match?.[1] && match[2]) {
return {
model: match[1].trim(),
effort: match[2].trim().toLowerCase(),
};
}
if (isAntigravityMachineModelSlug(trimmed)) {
const separatorIndex = trimmed.lastIndexOf("-");
const possibleEffort = trimmed.slice(separatorIndex + 1);
if (MACHINE_MODEL_EFFORTS.has(possibleEffort as (typeof EFFORT_ORDER)[number])) {
return {
model: trimmed.slice(0, separatorIndex),
effort: possibleEffort,
};
}
}
return { model: trimmed };
}

export function antigravityPromptCommandLineIssue(
Expand All @@ -340,13 +374,14 @@ export function parseAntigravityModelLines(output: string): ProviderListModelsRe
for (const line of output.split(/\r?\n/g)) {
const parsed = parseAntigravityCliModelLabel(line);
if (
!parsed?.effort ||
!/^(?:claude|deepseek|gemini|gpt|grok|llama|mistral|qwen)\b/iu.test(parsed.model)
!parsed ||
!/^(?:claude|deepseek|gemini|gpt|grok|llama|mistral|qwen)\b/iu.test(parsed.model) ||
(!parsed.effort && !isAntigravityMachineModelSlug(parsed.model))
) {
continue;
}
const efforts = groups.get(parsed.model) ?? [];
if (!efforts.includes(parsed.effort)) efforts.push(parsed.effort);
if (parsed.effort && !efforts.includes(parsed.effort)) efforts.push(parsed.effort);
groups.set(parsed.model, efforts);
}
return [...groups.entries()].map(([model, discoveredEfforts]) => {
Expand All @@ -361,7 +396,7 @@ export function parseAntigravityModelLines(output: string): ProviderListModelsRe
const defaultEffort = DEFAULT_EFFORT_BY_MODEL[model] ?? efforts[0];
return {
slug: model,
name: model,
name: antigravityModelDisplayName(model),
...(efforts.length > 0
? {
supportedReasoningEfforts: efforts.map((effort) => ({
Expand All @@ -387,7 +422,10 @@ export function resolveAntigravityCliModelLabel(
options?.reasoningEffort?.trim().toLowerCase() ??
discoveredDefaultEffort?.trim().toLowerCase() ??
DEFAULT_EFFORT_BY_MODEL[parsed.model];
return effort ? `${parsed.model} (${effortLabel(effort)})` : parsed.model;
if (!effort) return parsed.model;
return isAntigravityMachineModelSlug(parsed.model)
? `${parsed.model}-${effort}`
: `${parsed.model} (${effortLabel(effort)})`;
}

function parseModelLines(output: string): ProviderListModelsResult["models"] {
Expand Down
50 changes: 49 additions & 1 deletion apps/server/src/provider/Layers/ProviderConnection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ function makeConnectionTestLayer(input?: {
readonly antigravityCodeWindowCloseSignal?: Effect.Effect<void>;
readonly antigravityTimeout?: Duration.Duration;
readonly antigravityAuthenticationProbeInterval?: Duration.Duration;
readonly antigravityAuthenticationSettleTimeout?: Duration.Duration;
readonly beforeAntigravityOutputPublication?: Effect.Effect<void>;
readonly afterAntigravityCodeWindowInputClosed?: Effect.Effect<void>;
readonly onSpawn?: (command: CapturedCommand) => void;
Expand Down Expand Up @@ -371,6 +372,11 @@ function makeConnectionTestLayer(input?: {
antigravityAuthenticationProbeInterval: input.antigravityAuthenticationProbeInterval,
}
: {}),
...(input?.antigravityAuthenticationSettleTimeout
? {
antigravityAuthenticationSettleTimeout: input.antigravityAuthenticationSettleTimeout,
}
: {}),
...(input?.beforeAntigravityOutputPublication
? { beforeAntigravityOutputPublication: input.beforeAntigravityOutputPublication }
: {}),
Expand Down Expand Up @@ -525,7 +531,7 @@ describe("Antigravity OAuth authorization URL parsing", () => {
"--model",
"__scient_auth_only_operation-1",
"--print-timeout",
"60s",
"600s",
"--print",
"Authenticate this Antigravity CLI only. Do not inspect or modify files and do not perform a task.",
]);
Expand Down Expand Up @@ -893,6 +899,48 @@ describe("ProviderConnectionLive", () => {
);
});

it("accepts authentication that settles just after Antigravity's browser process exits", async () => {
const authorizationUrl =
"https://accounts.google.com/o/oauth2/auth?response_type=code&redirect_uri=https%3A%2F%2Fantigravity.google%2Foauth-callback&client_id=test-client&state=test-state&code_challenge=test-challenge&code_challenge_method=S256";
let authenticationVisible = false;
const fixture = makeConnectionTestLayer({
provider: "antigravity",
antigravityTimeout: Duration.millis(250),
antigravityAuthenticationProbeInterval: Duration.millis(5),
antigravityAuthenticationSettleTimeout: Duration.millis(150),
processForCommand: ({ args }) =>
args.includes("--print")
? {
code: 1,
delayMs: 20,
stdout: `Authentication required:\n${authorizationUrl}\n`,
}
: authenticationVisible
? { code: 0, stdout: "Gemini 3.5 Flash (High)\n" }
: {
code: 1,
stdout: "Please sign in to view available models.\n",
},
});

await Effect.runPromise(
Effect.gen(function* () {
const connection = yield* ProviderConnection;
const connectedPublished = fixture.waitForConnectionState(
(state) => state?.status === "connected",
);
yield* connection.start({
provider: "antigravity",
method: "antigravity_browser",
});
yield* Effect.sleep(Duration.millis(45));
authenticationVisible = true;
yield* Effect.promise(() => connectedPublished);
expect(fixture.getConnectionState()?.status).toBe("connected");
}).pipe(Effect.provide(fixture.layer)),
);
});

it("delivers one transient authorization code only to the active Antigravity PTY", async () => {
const authorizationUrl =
"https://accounts.google.com/o/oauth2/auth?response_type=code&redirect_uri=https%3A%2F%2Fantigravity.google%2Foauth-callback&client_id=test-client&state=test-state&code_challenge=test-challenge&code_challenge_method=S256";
Expand Down
34 changes: 27 additions & 7 deletions apps/server/src/provider/Layers/ProviderConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,16 @@ import { ProviderRuntimeManager } from "../Services/ProviderRuntimeManager";
import { parseAntigravityModelsAuthStatus } from "./ProviderHealth";

const CONNECTION_TIMEOUT = Duration.minutes(10);
const ANTIGRAVITY_CODE_WINDOW_TIMEOUT = Duration.seconds(60);
const ANTIGRAVITY_AUTHORIZATION_WINDOW_SECONDS = 10 * 60;
const ANTIGRAVITY_CODE_WINDOW_TIMEOUT = Duration.seconds(ANTIGRAVITY_AUTHORIZATION_WINDOW_SECONDS);
const ANTIGRAVITY_AUTHENTICATION_PROBE_INTERVAL = Duration.millis(500);
// Antigravity keeps the provider-owned code window at 60 seconds. The local
// supervisor gets a small hidden grace period so a CLI exit at that boundary
// can still complete one bounded authentication probe.
const ANTIGRAVITY_CONNECTION_TIMEOUT = Duration.seconds(65);
const ANTIGRAVITY_AUTHENTICATION_SETTLE_TIMEOUT = Duration.seconds(30);
// Keep the provider-owned browser/code window aligned with the CLI print
// timeout, then allow a short hidden grace period for credentials written by
// the browser callback to become visible to `agy models`.
const ANTIGRAVITY_CONNECTION_TIMEOUT = Duration.seconds(
ANTIGRAVITY_AUTHORIZATION_WINDOW_SECONDS + 30,
);
const CONNECTION_OUTPUT_MAX_BYTES = 64 * 1024;

interface ActiveConnection {
Expand Down Expand Up @@ -180,7 +184,7 @@ export function parseAntigravityOAuthAuthorizationUrl(output: string): string |
}

/**
* Antigravity 1.1.4 has no login subcommand, and its hidden bare TUI does not
* Antigravity 1.1.4 and newer have no login subcommand, and the hidden bare TUI does not
* advance to authentication. Print mode reaches provider-owned OAuth before
* model selection. A per-operation impossible model plus sandboxed plan mode
* prevents a real turn; the models health probe stops the process after auth.
Expand All @@ -193,7 +197,7 @@ export function antigravityAuthenticationCommandArgs(operationId: string): Reado
"--model",
`__scient_auth_only_${operationId}`,
"--print-timeout",
"60s",
`${ANTIGRAVITY_AUTHORIZATION_WINDOW_SECONDS}s`,
"--print",
ANTIGRAVITY_AUTH_PROMPT,
];
Expand Down Expand Up @@ -257,6 +261,7 @@ export function makeProviderConnectionLive(options?: {
readonly antigravityCodeWindowCloseSignal?: Effect.Effect<void>;
readonly antigravityTimeout?: Duration.Duration;
readonly antigravityAuthenticationProbeInterval?: Duration.Duration;
readonly antigravityAuthenticationSettleTimeout?: Duration.Duration;
readonly beforeAntigravityOutputPublication?: Effect.Effect<void>;
readonly afterAntigravityCodeWindowInputClosed?: Effect.Effect<void>;
readonly droidAuthenticationProbe?: typeof probeDroidAcpAuthentication;
Expand All @@ -267,6 +272,8 @@ export function makeProviderConnectionLive(options?: {
const antigravityTimeout = options?.antigravityTimeout ?? ANTIGRAVITY_CONNECTION_TIMEOUT;
const antigravityAuthenticationProbeInterval =
options?.antigravityAuthenticationProbeInterval ?? ANTIGRAVITY_AUTHENTICATION_PROBE_INTERVAL;
const antigravityAuthenticationSettleTimeout =
options?.antigravityAuthenticationSettleTimeout ?? ANTIGRAVITY_AUTHENTICATION_SETTLE_TIMEOUT;

return Layer.effect(
ProviderConnection,
Expand Down Expand Up @@ -671,6 +678,19 @@ export function makeProviderConnectionLive(options?: {
yield* Deferred.succeed(authorizationCodeAccepted, undefined);
return 0;
}
// A validated OAuth URL means the provider-owned browser flow
// may still be committing credentials when the bootstrap PTY
// exits. Keep probing briefly instead of publishing a false
// failure while that callback finishes.
const authorizationStarted = yield* Effect.sync(() => outputPublicationClaimed);
if (authorizationStarted) {
const settledAuthentication = yield* waitForAuthentication.pipe(
Effect.timeoutOption(antigravityAuthenticationSettleTimeout),
);
if (Option.isSome(settledAuthentication)) {
return settledAuthentication.value;
}
}
return code || 1;
}),
),
Expand Down
33 changes: 31 additions & 2 deletions apps/server/src/provider/Layers/ProviderHealth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,8 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => {
});

assert.deepStrictEqual(capabilities.update, {
command: "agy update",
executable: "agy",
command: "/Users/test/.local/bin/agy update",
executable: "/Users/test/.local/bin/agy",
args: ["update"],
lockKey: "antigravity-native",
pathPrepend: "/Users/test/.local/bin",
Expand Down Expand Up @@ -2100,6 +2100,35 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => {
),
);

it.effect("returns ready for Antigravity 1.1.5 machine model output", () =>
Effect.gen(function* () {
const status = yield* checkAntigravityProviderStatus();
assert.strictEqual(status.provider, "antigravity");
assert.strictEqual(status.status, "ready");
assert.strictEqual(status.available, true);
assert.strictEqual(status.authStatus, "authenticated");
assert.strictEqual(status.version, "1.1.5");
}).pipe(
Effect.provide(
mockSpawnerLayer((args, command) => {
assert.strictEqual(command, "agy");
const joined = args.join(" ");
if (joined === "--version") {
return { stdout: "1.1.5\n", stderr: "", code: 0 };
}
if (joined === "models") {
return {
stdout: "gemini-3.5-flash-medium\ngemini-3.5-flash-high\nclaude-sonnet-4-6\n",
stderr: "",
code: 0,
};
}
throw new Error(`Unexpected args: ${joined}`);
}),
),
),
);

it.effect("maps Antigravity's clean-profile response to unauthenticated", () =>
Effect.gen(function* () {
const status = yield* checkAntigravityProviderStatus();
Expand Down
Loading
Loading