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
96 changes: 55 additions & 41 deletions src/drivers/antigravity/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { parseAntigravityLine } from "./parser.js";

// Pure so the flag spellings and effort clamping are testable without spawning agy.
export function buildAntigravityArgs(options: StartOptions): string[] {
const args: string[] = ["-p", "--output-format", "stream-json", "--dangerously-skip-permissions"];
const args: string[] = ["--output-format", "stream-json", "--dangerously-skip-permissions"];

if (options.resumeSessionId) {
args.push("--conversation", options.resumeSessionId);
Expand All @@ -27,10 +27,62 @@ export function buildAntigravityArgs(options: StartOptions): string[] {
args.push("--effort", clampedEffort);
}

args.push(options.prompt);
// -p/--print takes the prompt as its value, so a bare -p up front swallows
// the next flag as the prompt ("-p took --output-format as its prompt").
// Attach with `=` so the prompt is unambiguous anywhere on the line, even
// when it starts with a dash.
args.push(`-p=${options.prompt}`);
return args;
}

// Pure so the TAB-separated `agy models` table is testable without the binary.
export function parseAntigravityModelsList(stdout: string): ProviderModels[] {
const lines = stdout.split("\n");
const providerMap = new Map<string, ModelInfo[]>();

for (const rawLine of lines) {
// Strip spinner prefix if present (e.g. ⠋ Fetching available models...)
const clean = rawLine.replace(/^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏\s]+Fetching available models\.\.\./g, "").trim();
if (!clean) continue;
// Banner without spinner prefix is not a model row either.
if (/^fetching available models\.\.\./i.test(clean)) continue;

// `agy models` separates id and display name with a TAB; fall back to
// column spacing for anything else.
const tab = clean.indexOf("\t");
const parts = tab >= 0 ? [clean.slice(0, tab), clean.slice(tab + 1)] : clean.split(/\s{2,}/);
const id = parts[0]?.trim();
const name = parts[1]?.trim() || id;
if (!id || id.startsWith("#") || id.startsWith("Usage")) continue;

let provider = "google";
if (id.startsWith("claude-")) provider = "anthropic";
else if (id.startsWith("gpt-")) provider = "openai";

if (!providerMap.has(provider)) {
providerMap.set(provider, []);
}

providerMap.get(provider)!.push({
id,
name,
provider,
supportsThinking: id.includes("thinking") || id.includes("high") || id.includes("medium"),
});
}

const result: ProviderModels[] = [];
for (const [provider, models] of providerMap.entries()) {
result.push({
provider,
displayName: provider.charAt(0).toUpperCase() + provider.slice(1),
models,
});
}

return result;
}

export class AntigravityDriver extends SessionDriver {
readonly id = "antigravity" as const;

Expand Down Expand Up @@ -132,45 +184,7 @@ export class AntigravityDriver extends SessionDriver {

try {
const stdout = await runCommandWithTimeout(install.path, ["models"], { timeoutMs: 10000 });
const lines = stdout.split("\n");
const providerMap = new Map<string, ModelInfo[]>();

for (const rawLine of lines) {
// Strip spinner prefix if present (e.g. ⠋ Fetching available models...)
const clean = rawLine.replace(/^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏\s]+Fetching available models\.\.\./g, "").trim();
if (!clean) continue;

const parts = clean.split(/\s{2,}/);
const id = parts[0]?.trim();
const name = parts[1]?.trim() || id;
if (!id || id.startsWith("#") || id.startsWith("Usage")) continue;

let provider = "google";
if (id.startsWith("claude-")) provider = "anthropic";
else if (id.startsWith("gpt-")) provider = "openai";

if (!providerMap.has(provider)) {
providerMap.set(provider, []);
}

providerMap.get(provider)!.push({
id,
name,
provider,
supportsThinking: id.includes("thinking") || id.includes("high") || id.includes("medium"),
});
}

const result: ProviderModels[] = [];
for (const [provider, models] of providerMap.entries()) {
result.push({
provider,
displayName: provider.charAt(0).toUpperCase() + provider.slice(1),
models,
});
}

return result;
return parseAntigravityModelsList(stdout);
} catch {
return [];
}
Expand Down
57 changes: 54 additions & 3 deletions tests/antigravity-driver.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest";
import { buildAntigravityArgs, AntigravityDriver } from "../src/drivers/antigravity/driver.js";
import {
buildAntigravityArgs,
parseAntigravityModelsList,
AntigravityDriver,
} from "../src/drivers/antigravity/driver.js";
import { parseAntigravityLine } from "../src/drivers/antigravity/parser.js";
import type { AgentEvent } from "../src/core/events.js";

Expand All @@ -10,14 +14,24 @@ describe("buildAntigravityArgs", () => {
it("builds default headless flags with auto-permissions and prompt", () => {
const args = buildAntigravityArgs(base);
expect(args).toEqual([
"-p",
"--output-format",
"stream-json",
"--dangerously-skip-permissions",
"do something",
"-p=do something",
]);
});

it("attaches the prompt to -p so it cannot swallow the next flag", () => {
const args = buildAntigravityArgs(base);
expect(args).not.toContain("-p");
expect(args[args.length - 1]).toBe("-p=do something");
});

it("keeps a dash-leading prompt attached to -p", () => {
const args = buildAntigravityArgs({ ...base, prompt: "--help is a flag" });
expect(args).toContain("-p=--help is a flag");
});

it("adds --conversation on resume", () => {
const args = buildAntigravityArgs({ ...base, resumeSessionId: "conv-1234" });
expect(args).toContain("--conversation");
Expand Down Expand Up @@ -220,6 +234,43 @@ describe("parseAntigravityLine", () => {
});
});

describe("parseAntigravityModelsList", () => {
// Mirrors real `agy models` output: a banner line plus TAB-separated rows.
const stdout = [
"Fetching available models...",
"gemini-3.8-flash-high\tGemini 3.8 Flash (High)",
"claude-sonnet-4-6\tClaude Sonnet 4.6 (Thinking)",
"gpt-oss-120b-medium\tGPT-OSS 120B (Medium)",
"",
].join("\n");

it("splits TAB-separated id and display name", () => {
const providers = parseAntigravityModelsList(stdout);
const ids = providers.flatMap((p) => p.models.map((m) => m.id));
expect(ids).toEqual(["gemini-3.8-flash-high", "claude-sonnet-4-6", "gpt-oss-120b-medium"]);
const names = providers.flatMap((p) => p.models.map((m) => m.name));
expect(names).toEqual([
"Gemini 3.8 Flash (High)",
"Claude Sonnet 4.6 (Thinking)",
"GPT-OSS 120B (Medium)",
]);
});

it("drops the banner line instead of listing it as a model", () => {
const providers = parseAntigravityModelsList(stdout);
const ids = providers.flatMap((p) => p.models.map((m) => m.id));
expect(ids.every((id) => !/fetching/i.test(id))).toBe(true);
});

it("strips spinner-prefixed banner output", () => {
const providers = parseAntigravityModelsList(
"⠋ Fetching available models...\ngemini-3.8-flash-high\tGemini 3.8 Flash (High)\n",
);
const ids = providers.flatMap((p) => p.models.map((m) => m.id));
expect(ids).toEqual(["gemini-3.8-flash-high"]);
});
});

describe("AntigravityDriver", () => {
it("declares expected capabilities", () => {
const driver = new AntigravityDriver();
Expand Down
Loading