diff --git a/src/drivers/antigravity/driver.ts b/src/drivers/antigravity/driver.ts index 5febaa7..e33694f 100644 --- a/src/drivers/antigravity/driver.ts +++ b/src/drivers/antigravity/driver.ts @@ -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); @@ -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(); + + 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; @@ -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(); - - 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 []; } diff --git a/tests/antigravity-driver.test.ts b/tests/antigravity-driver.test.ts index a6c37ff..4642afc 100644 --- a/tests/antigravity-driver.test.ts +++ b/tests/antigravity-driver.test.ts @@ -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"; @@ -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"); @@ -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();