From 76b159486c23fbe43d2752b82b1d18e7d688aa0a Mon Sep 17 00:00:00 2001 From: anonb3ll Date: Thu, 27 Aug 2026 21:21:54 -0500 Subject: [PATCH] feat(server): add Antigravity CLI provider protocol spike Establish the wire contract for driving the Antigravity (agy) CLI as a T3 provider: bounded plan-mode launch and resume argument construction, unavailable/unauthenticated/available classification, stream-json event parsing, and terminal-outcome normalization covering cancellation and missing terminal results. Protocol functions only. Nothing spawns a process or registers a provider, so the live provider catalog is unchanged. Verified against agy 1.1.22 by bounded read-only probe: init, step_update, and result envelopes match the fixtures, and --conversation resume continues step_index and reuses cached context. Spike authored by Desktop_Codex; recovered from a disposable checkout, independently re-verified, and committed by Laptop_Cursor. Task #642. --- .../AntigravityCliProtocol.test.ts | 230 ++++++++++++++++ .../antigravity/AntigravityCliProtocol.ts | 260 ++++++++++++++++++ 2 files changed, 490 insertions(+) create mode 100644 apps/server/src/provider/antigravity/AntigravityCliProtocol.test.ts create mode 100644 apps/server/src/provider/antigravity/AntigravityCliProtocol.ts diff --git a/apps/server/src/provider/antigravity/AntigravityCliProtocol.test.ts b/apps/server/src/provider/antigravity/AntigravityCliProtocol.test.ts new file mode 100644 index 000000000000..d8e04c70185a --- /dev/null +++ b/apps/server/src/provider/antigravity/AntigravityCliProtocol.test.ts @@ -0,0 +1,230 @@ +import * as NodeAssert from "node:assert/strict"; + +import { describe, it } from "vite-plus/test"; + +import { + buildAntigravityPrintArgs, + classifyAntigravityAvailability, + normalizeAntigravityCliEvent, + normalizeAntigravityProcessExit, + parseAntigravityStreamLine, +} from "./AntigravityCliProtocol.ts"; + +describe("buildAntigravityPrintArgs", () => { + it("builds a bounded read-only stream-json launch", () => { + NodeAssert.deepStrictEqual( + buildAntigravityPrintArgs({ + prompt: "Inspect the repository without changing files.", + model: "gemini-3.7-flash-low", + effort: "low", + }), + [ + "--print", + "Inspect the repository without changing files.", + "--output-format", + "stream-json", + "--mode", + "plan", + "--sandbox", + "--model", + "gemini-3.7-flash-low", + "--effort", + "low", + ], + ); + }); + + it("resumes an existing conversation without weakening safeguards", () => { + NodeAssert.deepStrictEqual( + buildAntigravityPrintArgs({ + prompt: "Continue the inspection.", + conversationId: "conversation-123", + }), + [ + "--print", + "Continue the inspection.", + "--output-format", + "stream-json", + "--mode", + "plan", + "--sandbox", + "--conversation", + "conversation-123", + ], + ); + }); +}); + +describe("Antigravity stream-json protocol", () => { + it("normalizes session initialization", () => { + const event = parseAntigravityStreamLine( + JSON.stringify({ + event: "init", + conversation_id: "conversation-123", + init: { + model: "gemini-3.7-flash-low", + cwd: "/workspace", + permission_mode: "request-review", + tools: [], + }, + }), + ); + + NodeAssert.deepStrictEqual(normalizeAntigravityCliEvent(event), { + type: "session.started", + conversationId: "conversation-123", + model: "gemini-3.7-flash-low", + cwd: "/workspace", + permissionMode: "request-review", + }); + }); + + it("normalizes assistant text deltas", () => { + const event = parseAntigravityStreamLine( + JSON.stringify({ + event: "step_update", + step_update: { + step_index: 1, + step_type: "agent_response", + state: "ACTIVE", + text_delta: "AGY_OK", + }, + }), + ); + + NodeAssert.deepStrictEqual(normalizeAntigravityCliEvent(event), { + type: "content.delta", + stepIndex: 1, + text: "AGY_OK", + }); + }); + + it("normalizes terminal results", () => { + const event = parseAntigravityStreamLine( + JSON.stringify({ + event: "result", + result: { + conversation_id: "conversation-123", + status: "SUCCESS", + response: "AGY_OK\n", + duration_seconds: 0.806, + num_turns: 1, + usage: { input_tokens: 12, output_tokens: 3 }, + }, + }), + ); + + NodeAssert.deepStrictEqual(normalizeAntigravityCliEvent(event), { + type: "turn.completed", + response: "AGY_OK\n", + durationSeconds: 0.806, + numTurns: 1, + usage: { input_tokens: 12, output_tokens: 3 }, + }); + }); + + it("ignores blank, malformed, and non-assistant step lines", () => { + NodeAssert.equal(parseAntigravityStreamLine(""), null); + NodeAssert.equal(parseAntigravityStreamLine("not-json"), null); + + const event = parseAntigravityStreamLine( + JSON.stringify({ + event: "step_update", + step_update: { + step_index: 0, + step_type: "user_input", + state: "DONE", + }, + }), + ); + NodeAssert.equal(normalizeAntigravityCliEvent(event), null); + }); + + it("maps a failed result to a terminal abort", () => { + const event = parseAntigravityStreamLine( + JSON.stringify({ + event: "result", + result: { + conversation_id: "conversation-123", + status: "FAILED", + response: "Provider failed", + }, + }), + ); + + NodeAssert.deepStrictEqual(normalizeAntigravityCliEvent(event), { + type: "turn.aborted", + status: "FAILED", + response: "Provider failed", + }); + }); +}); + +describe("Antigravity capability probes", () => { + it("distinguishes unavailable, unauthenticated, and available states", () => { + NodeAssert.deepStrictEqual( + classifyAntigravityAvailability({ + version: { exitCode: 127, stdout: "", stderr: "agy: command not found" }, + models: null, + }), + { status: "unavailable", reason: "agy: command not found" }, + ); + + NodeAssert.deepStrictEqual( + classifyAntigravityAvailability({ + version: { exitCode: 0, stdout: "1.1.22\n", stderr: "" }, + models: { exitCode: 1, stdout: "", stderr: "authentication required" }, + }), + { + status: "unauthenticated", + version: "1.1.22", + reason: "authentication required", + }, + ); + + NodeAssert.deepStrictEqual( + classifyAntigravityAvailability({ + version: { exitCode: 0, stdout: "1.1.22\n", stderr: "" }, + models: { exitCode: 0, stdout: "models", stderr: "" }, + }), + { status: "available", version: "1.1.22" }, + ); + }); + + it("maps process cancellation and missing terminal results", () => { + NodeAssert.deepStrictEqual( + normalizeAntigravityProcessExit({ + exitCode: null, + signal: "SIGTERM", + terminalResultSeen: false, + }), + { + type: "turn.aborted", + status: "CANCELLED", + response: "Agy process terminated by SIGTERM", + }, + ); + + NodeAssert.deepStrictEqual( + normalizeAntigravityProcessExit({ + exitCode: 0, + signal: null, + terminalResultSeen: false, + }), + { + type: "turn.aborted", + status: "PROTOCOL_ERROR", + response: "Agy exited without a terminal result event", + }, + ); + + NodeAssert.equal( + normalizeAntigravityProcessExit({ + exitCode: 0, + signal: null, + terminalResultSeen: true, + }), + null, + ); + }); +}); diff --git a/apps/server/src/provider/antigravity/AntigravityCliProtocol.ts b/apps/server/src/provider/antigravity/AntigravityCliProtocol.ts new file mode 100644 index 000000000000..396096d13366 --- /dev/null +++ b/apps/server/src/provider/antigravity/AntigravityCliProtocol.ts @@ -0,0 +1,260 @@ +export type AntigravityEffort = "low" | "medium" | "high"; + +export interface AntigravityPrintOptions { + readonly prompt: string; + readonly conversationId?: string; + readonly model?: string; + readonly effort?: AntigravityEffort; +} + +export interface AntigravityProbeCommandResult { + readonly exitCode: number | null; + readonly stdout: string; + readonly stderr: string; +} + +export interface AntigravityAvailabilityProbe { + readonly version: AntigravityProbeCommandResult; + readonly models: AntigravityProbeCommandResult | null; +} + +export type AntigravityAvailability = + | { readonly status: "unavailable"; readonly reason: string } + | { + readonly status: "unauthenticated"; + readonly version: string; + readonly reason: string; + } + | { readonly status: "available"; readonly version: string }; + +export interface AntigravityProcessExit { + readonly exitCode: number | null; + readonly signal: string | null; + readonly terminalResultSeen: boolean; +} + +interface AntigravityInitEvent { + readonly event: "init"; + readonly conversation_id: string; + readonly init: Readonly>; +} + +interface AntigravityStepUpdateEvent { + readonly event: "step_update"; + readonly step_update: Readonly>; +} + +interface AntigravityResultPayload { + readonly conversation_id: string; + readonly status: string; + readonly response?: string; + readonly duration_seconds?: number; + readonly num_turns?: number; + readonly usage?: Readonly>; +} + +interface AntigravityResultEvent { + readonly event: "result"; + readonly result: AntigravityResultPayload; +} + +export type AntigravityCliEvent = + | AntigravityInitEvent + | AntigravityStepUpdateEvent + | AntigravityResultEvent; + +export type AntigravityProviderSignal = + | { + readonly type: "session.started"; + readonly conversationId: string; + readonly model?: string; + readonly cwd?: string; + readonly permissionMode?: string; + } + | { + readonly type: "content.delta"; + readonly stepIndex: number; + readonly text: string; + } + | { + readonly type: "turn.completed"; + readonly response: string; + readonly durationSeconds?: number; + readonly numTurns?: number; + readonly usage?: Readonly>; + } + | { + readonly type: "turn.aborted"; + readonly status: string; + readonly response?: string; + }; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +export const buildAntigravityPrintArgs = ( + options: AntigravityPrintOptions, +): ReadonlyArray => { + const args = [ + "--print", + options.prompt, + "--output-format", + "stream-json", + "--mode", + "plan", + "--sandbox", + ]; + + if (options.conversationId !== undefined) { + args.push("--conversation", options.conversationId); + } + if (options.model !== undefined) { + args.push("--model", options.model); + } + if (options.effort !== undefined) { + args.push("--effort", options.effort); + } + + return args; +}; + +const commandDiagnostic = (result: AntigravityProbeCommandResult, fallback: string): string => + result.stderr.trim() || result.stdout.trim() || fallback; + +export const classifyAntigravityAvailability = ( + probe: AntigravityAvailabilityProbe, +): AntigravityAvailability => { + if (probe.version.exitCode !== 0) { + return { + status: "unavailable", + reason: commandDiagnostic(probe.version, "agy executable is unavailable"), + }; + } + + const version = probe.version.stdout.trim() || "unknown"; + if (probe.models === null || probe.models.exitCode !== 0) { + return { + status: "unauthenticated", + version, + reason: + probe.models === null + ? "agy models authentication probe was not completed" + : commandDiagnostic(probe.models, "agy model access is unavailable"), + }; + } + + return { status: "available", version }; +}; + +export const parseAntigravityStreamLine = (line: string): AntigravityCliEvent | null => { + if (line.trim().length === 0) return null; + + let value: unknown; + try { + value = JSON.parse(line); + } catch { + return null; + } + if (!isRecord(value) || typeof value.event !== "string") return null; + + if (value.event === "init" && typeof value.conversation_id === "string" && isRecord(value.init)) { + return value as unknown as AntigravityInitEvent; + } + if (value.event === "step_update" && isRecord(value.step_update)) { + return value as unknown as AntigravityStepUpdateEvent; + } + if ( + value.event === "result" && + isRecord(value.result) && + typeof value.result.conversation_id === "string" && + typeof value.result.status === "string" + ) { + return value as unknown as AntigravityResultEvent; + } + + return null; +}; + +export const normalizeAntigravityCliEvent = ( + event: AntigravityCliEvent | null, +): AntigravityProviderSignal | null => { + if (event === null) return null; + + if (event.event === "init") { + const model = event.init.model; + const cwd = event.init.cwd; + const permissionMode = event.init.permission_mode; + return { + type: "session.started", + conversationId: event.conversation_id, + ...(typeof model === "string" ? { model } : {}), + ...(typeof cwd === "string" ? { cwd } : {}), + ...(typeof permissionMode === "string" ? { permissionMode } : {}), + }; + } + + if (event.event === "step_update") { + const stepType = event.step_update.step_type; + const stepIndex = event.step_update.step_index; + const text = event.step_update.text_delta; + if ( + stepType !== "agent_response" || + typeof stepIndex !== "number" || + typeof text !== "string" || + text.length === 0 + ) { + return null; + } + return { type: "content.delta", stepIndex, text }; + } + + if (event.result.status === "SUCCESS") { + return { + type: "turn.completed", + response: event.result.response ?? "", + ...(event.result.duration_seconds !== undefined + ? { durationSeconds: event.result.duration_seconds } + : {}), + ...(event.result.num_turns !== undefined ? { numTurns: event.result.num_turns } : {}), + ...(event.result.usage !== undefined ? { usage: event.result.usage } : {}), + }; + } + + return { + type: "turn.aborted", + status: event.result.status, + ...(event.result.response !== undefined ? { response: event.result.response } : {}), + }; +}; + +export const normalizeAntigravityProcessExit = ( + processExit: AntigravityProcessExit, +): AntigravityProviderSignal | null => { + if (processExit.terminalResultSeen) return null; + + if (processExit.signal !== null) { + return { + type: "turn.aborted", + status: "CANCELLED", + response: `Agy process terminated by ${processExit.signal}`, + }; + } + + if (processExit.exitCode === 0) { + return { + type: "turn.aborted", + status: "PROTOCOL_ERROR", + response: "Agy exited without a terminal result event", + }; + } + + return { + type: "turn.aborted", + status: + processExit.exitCode === null ? "PROTOCOL_ERROR" : `PROCESS_EXIT_${processExit.exitCode}`, + response: + processExit.exitCode === null + ? "Agy process closed without an exit status or terminal result" + : `Agy process exited with code ${processExit.exitCode}`, + }; +};