From 675b19b1b1403ecc1c0aa770c358e4f4ff359526 Mon Sep 17 00:00:00 2001 From: carterwsmith Date: Sat, 22 Aug 2026 23:27:46 -0700 Subject: [PATCH 1/6] feat: add Antigravity provider --- README.md | 6 +- apps/mobile/src/components/ProviderIcon.tsx | 11 + apps/server/src/provider/AgyModelSelection.ts | 11 + apps/server/src/provider/Drivers/AgyDriver.ts | 148 +++ .../src/provider/Layers/AgyAdapter.test.ts | 335 ++++++ apps/server/src/provider/Layers/AgyAdapter.ts | 1046 +++++++++++++++++ .../src/provider/Layers/AgyProvider.test.ts | 187 +++ .../server/src/provider/Layers/AgyProvider.ts | 510 ++++++++ .../provider/Layers/ProviderRegistry.test.ts | 5 +- .../src/provider/Services/AgyAdapter.ts | 16 + apps/server/src/provider/builtInDrivers.ts | 3 + .../textGeneration/AgyTextGeneration.test.ts | 119 ++ .../src/textGeneration/AgyTextGeneration.ts | 326 +++++ .../src/textGeneration/TextGeneration.ts | 8 +- apps/web/src/components/Icons.tsx | 10 +- .../src/components/chat/providerIconUtils.ts | 11 +- .../settings/ProviderInstanceCard.test.ts | 41 +- .../settings/ProviderInstanceCard.tsx | 35 +- .../components/settings/providerDriverMeta.ts | 17 +- apps/web/src/modelSelection.test.ts | 63 + apps/web/src/modelSelection.ts | 22 +- apps/web/src/providerInstances.test.ts | 19 + apps/web/src/providerInstances.ts | 14 +- apps/web/src/providerModels.ts | 51 + apps/web/src/session-logic.ts | 6 + docs/user/install.md | 20 +- docs/user/providers-antigravity.md | 50 + packages/contracts/src/model.ts | 15 + packages/contracts/src/settings.test.ts | 2 + packages/contracts/src/settings.ts | 36 + 30 files changed, 3096 insertions(+), 47 deletions(-) create mode 100644 apps/server/src/provider/AgyModelSelection.ts create mode 100644 apps/server/src/provider/Drivers/AgyDriver.ts create mode 100644 apps/server/src/provider/Layers/AgyAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/AgyAdapter.ts create mode 100644 apps/server/src/provider/Layers/AgyProvider.test.ts create mode 100644 apps/server/src/provider/Layers/AgyProvider.ts create mode 100644 apps/server/src/provider/Services/AgyAdapter.ts create mode 100644 apps/server/src/textGeneration/AgyTextGeneration.test.ts create mode 100644 apps/server/src/textGeneration/AgyTextGeneration.ts create mode 100644 docs/user/providers-antigravity.md diff --git a/README.md b/README.md index 8ec101387f67..b62e4ffb7eba 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). -Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them. +Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, and Google Antigravity. If they're set up on your computer, T3 Code can control them. ## "Wait, what are you selling me?" @@ -13,13 +13,14 @@ We wanted something performant, remote-ready, and truly open. If we ever go the ## Installation > [!WARNING] -> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use: +> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode, and Google Antigravity. Install and authenticate at least one provider before use: > > - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` > - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` > - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login` > - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login` > - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` +> - Antigravity: install [Antigravity CLI](https://antigravity.google/docs/cli/install/) and run `agy` to sign in ### Try it out (install-free) @@ -83,6 +84,7 @@ Full docs live in [docs/](./docs). There's no docs site yet. - [Keeping app and server in sync](./docs/user/updating.md) - [Source control integrations](./docs/user/source-control.md) - Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md) +- Provider setup: [Antigravity](./docs/user/providers-antigravity.md) - Linux: [run T3 Code as a background service](./docs/user/background-service.md) Building from source? Start at [docs/internals/overview.md](./docs/internals/overview.md). diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 5eb69627f58d..ddf2c9500265 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -12,6 +12,17 @@ export function ProviderIcon(props: ProviderIconProps) { const size = props.size ?? 16; const mono = isDarkMode ? "#e5e5e5" : "#171717"; + if (props.provider === "agy") { + return ( + + + + ); + } + if (props.provider === "claudeAgent") { return ( diff --git a/apps/server/src/provider/AgyModelSelection.ts b/apps/server/src/provider/AgyModelSelection.ts new file mode 100644 index 000000000000..02a89411c34e --- /dev/null +++ b/apps/server/src/provider/AgyModelSelection.ts @@ -0,0 +1,11 @@ +const AGY_REASONING_EFFORT_SUFFIX = /^(.*)-(low|medium|high)$/; + +export function resolveAgyModelForEffort( + model: string | undefined, + effort: string | undefined, +): string | undefined { + if (!model || !effort) return model; + + const match = AGY_REASONING_EFFORT_SUFFIX.exec(model); + return match?.[1] ? `${match[1]}-${effort}` : model; +} diff --git a/apps/server/src/provider/Drivers/AgyDriver.ts b/apps/server/src/provider/Drivers/AgyDriver.ts new file mode 100644 index 000000000000..16f33d41e3e2 --- /dev/null +++ b/apps/server/src/provider/Drivers/AgyDriver.ts @@ -0,0 +1,148 @@ +import { AgySettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeAgyTextGeneration } from "../../textGeneration/AgyTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeAgyAdapter } from "../Layers/AgyAdapter.ts"; +import { buildInitialAgyProviderSnapshot, checkAgyProviderStatus } from "../Layers/AgyProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeAgySettings = Schema.decodeSync(AgySettings); + +const DRIVER_KIND = ProviderDriverKind.make("agy"); + +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), +); + +export type AgyDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const AgyDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Antigravity", + supportsMultipleInstances: true, + }, + configSchema: AgySettings, + defaultConfig: (): AgySettings => decodeAgySettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies AgySettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeAgyAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeAgyTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkAgyProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialAgyProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Antigravity snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/AgyAdapter.test.ts b/apps/server/src/provider/Layers/AgyAdapter.test.ts new file mode 100644 index 000000000000..7184b05e4bf6 --- /dev/null +++ b/apps/server/src/provider/Layers/AgyAdapter.test.ts @@ -0,0 +1,335 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; +import * as NodeOS from "node:os"; +import * as NodeFSP from "node:fs/promises"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { + AgySettings, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import { makeAgyAdapter } from "./AgyAdapter.ts"; + +const decodeAgySettings = Schema.decodeSync(AgySettings); + +async function makeMockAgyWrapper() { + const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "agy-mock-")); + const wrapperPath = NodePath.join(dir, "fake-agy.mjs"); + const script = ` +import readline from "node:readline"; + +const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false }); + +console.log("malformed native event"); +console.log(JSON.stringify({ + event: "init", + conversation_id: "conv-12345", + init: { cwd: process.cwd(), tools: ["run_command", "write_to_file"] } +})); + +rl.on("line", (line) => { + if (!line.trim()) return; + const msg = JSON.parse(line); + if (msg.event === "user") { + if (msg.message.content === "hang") return; + if (msg.message.content === "crash") { + process.exit(7); + return; + } + // Emit step updates for user input, assistant response, and tool call + console.log(JSON.stringify({ + event: "step_update", + step_update: { step_index: 0, state: "DONE", step_type: "user_input" } + })); + console.log(JSON.stringify({ + event: "step_update", + step_update: { + step_index: 1, + state: "ACTIVE", + step_type: "agent_response", + text_delta: "Hello from " + } + })); + console.log(JSON.stringify({ + event: "step_update", + step_update: { + step_index: 2, + state: "ACTIVE", + step_type: "tool", + tool_name: "run_command", + tool_info: { name: "run_command", parameters: { command: "pwd" } } + } + })); + console.log(JSON.stringify({ + event: "step_update", + step_update: { + step_index: 2, + state: "DONE", + step_type: "tool", + tool_name: "run_command", + tool_info: { name: "run_command" }, + output: "/tmp/project" + } + })); + console.log(JSON.stringify({ + event: "step_update", + step_update: { + step_index: 1, + state: "DONE", + step_type: "agent_response", + text_delta: "mock agy!" + } + })); + console.log(JSON.stringify({ + event: "result", + result: { + conversation_id: "conv-12345", + status: "SUCCESS", + response: "Hello from mock agy!", + num_turns: 1, + usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 } + } + })); + } +}); +`; + await NodeFSP.writeFile(wrapperPath, script, "utf8"); + + const shPath = NodePath.join(dir, "fake-agy.sh"); + const argsPath = NodePath.join(dir, "args.log"); + await NodeFSP.writeFile( + shPath, + [ + "#!/bin/sh", + `printf '%s\\n' "$*" >> ${JSON.stringify(argsPath)}`, + `exec ${JSON.stringify(process.execPath)} ${JSON.stringify(wrapperPath)} "$@"`, + "", + ].join("\n"), + "utf8", + ); + await NodeFSP.chmod(shPath, 0o755); + return { binaryPath: shPath, argsPath, dir }; +} + +const agyAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-agy-adapter-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +describe("AgyAdapter", () => { + it.layer(agyAdapterTestLayer)( + "starts a session, sends a turn, and receives streamed events", + (it) => { + it.effect("completes a full turn cycle", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => makeMockAgyWrapper()); + const adapter = yield* makeAgyAdapter( + decodeAgySettings({ binaryPath: mock.binaryPath, launchArgs: "--agent reviewer" }), + ); + + const threadId = ThreadId.make("thread-1"); + + const receivedEvents: Array = []; + const turnCompletions = + yield* Queue.unbounded>(); + + const eventFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => { + receivedEvents.push(event); + }).pipe( + Effect.andThen( + event.type === "turn.completed" ? Queue.offer(turnCompletions, event) : Effect.void, + ), + ), + ).pipe(Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + instanceId: ProviderInstanceId.make("agy"), + model: "gemini-3.7-flash-high", + options: [{ id: "reasoningEffort", value: "low" }], + }, + }); + + expect(session.threadId).toBe(threadId); + expect(session.provider).toBe(ProviderDriverKind.make("agy")); + expect(yield* adapter.hasSession(threadId)).toBe(true); + + const result = yield* adapter.sendTurn({ + threadId, + input: "Say hello", + }); + + expect(result.threadId).toBe(threadId); + expect(result.turnId).toBeTruthy(); + expect(result.resumeCursor).toEqual({ + schemaVersion: 1, + conversationId: "conv-12345", + }); + + yield* Queue.take(turnCompletions); + + const turnCompletedEvent = receivedEvents.find((e) => e.type === "turn.completed"); + expect(turnCompletedEvent).toBeDefined(); + if (turnCompletedEvent && turnCompletedEvent.type === "turn.completed") { + expect(turnCompletedEvent.payload.state).toBe("completed"); + expect(turnCompletedEvent.payload.usage).toEqual({ + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + }); + } + + const deltaEvents = receivedEvents.filter((e) => e.type === "content.delta"); + expect(deltaEvents.length).toBeGreaterThan(0); + expect( + receivedEvents.some( + (event) => + event.type === "item.completed" && + event.payload.itemType === "command_execution" && + event.payload.status === "completed", + ), + ).toBe(true); + + const secondResult = yield* adapter.sendTurn({ + threadId, + input: "Say hello again", + interactionMode: "plan", + }); + yield* Queue.take(turnCompletions); + expect(secondResult.turnId).not.toBe(result.turnId); + + const switchedResult = yield* adapter.sendTurn({ + threadId, + input: "Use the medium model", + interactionMode: "plan", + modelSelection: { + instanceId: ProviderInstanceId.make("agy"), + model: "gemini-3.7-flash-high", + options: [{ id: "reasoningEffort", value: "medium" }], + }, + }); + yield* Queue.take(turnCompletions); + expect(switchedResult.turnId).not.toBe(secondResult.turnId); + + const snapshot = yield* adapter.readThread(threadId); + expect(snapshot.threadId).toBe(threadId); + expect(snapshot.turns).toHaveLength(3); + expect(snapshot.turns.every((turn) => turn.items.length > 0)).toBe(true); + const rollbackError = yield* adapter.rollbackThread(threadId, 1).pipe(Effect.flip); + expect(rollbackError.message).toContain("do not support provider-side rollback"); + + const invocations = yield* Effect.promise(() => NodeFSP.readFile(mock.argsPath, "utf8")); + expect(invocations).toContain("--dangerously-skip-permissions"); + expect(invocations).toContain("--mode accept-edits"); + expect(invocations).toContain("--mode plan"); + expect(invocations).toContain("--agent reviewer"); + expect(invocations).toContain("--conversation conv-12345"); + expect(invocations).toContain("--model gemini-3.7-flash-low --effort low"); + expect(invocations).toContain("--model gemini-3.7-flash-medium --effort medium"); + expect(adapter.capabilities.sessionModelSwitch).toBe("in-session"); + + yield* adapter.stopSession(threadId); + expect(yield* adapter.hasSession(threadId)).toBe(false); + + yield* Fiber.interrupt(eventFiber); + yield* Effect.promise(() => NodeFSP.rm(mock.dir, { recursive: true, force: true })); + }), + ); + + it.effect("uses the sandbox for non-full-access sessions", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => makeMockAgyWrapper()); + const adapter = yield* makeAgyAdapter(decodeAgySettings({ binaryPath: mock.binaryPath })); + const threadId = ThreadId.make("thread-sandbox"); + + yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "approval-required", + }); + yield* adapter.sendTurn({ threadId, input: "Inspect safely" }); + + const invocation = yield* Effect.promise(() => NodeFSP.readFile(mock.argsPath, "utf8")); + expect(invocation).toContain("--sandbox"); + expect(invocation).not.toContain("--dangerously-skip-permissions"); + + yield* adapter.stopSession(threadId); + yield* Effect.promise(() => NodeFSP.rm(mock.dir, { recursive: true, force: true })); + }), + ); + + it.effect("interrupts an active turn and keeps the session ready", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => makeMockAgyWrapper()); + const adapter = yield* makeAgyAdapter(decodeAgySettings({ binaryPath: mock.binaryPath })); + const threadId = ThreadId.make("thread-interrupt"); + const completions = + yield* Queue.unbounded>(); + const eventFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "turn.completed" ? Queue.offer(completions, event) : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ threadId, input: "hang" }); + yield* adapter.interruptTurn(threadId, turn.turnId); + + expect((yield* Queue.take(completions)).payload.state).toBe("interrupted"); + expect((yield* adapter.listSessions())[0]?.status).toBe("ready"); + + yield* adapter.stopSession(threadId); + yield* Fiber.interrupt(eventFiber); + yield* Effect.promise(() => NodeFSP.rm(mock.dir, { recursive: true, force: true })); + }), + ); + + it.effect("fails the active turn when the CLI exits unexpectedly", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => makeMockAgyWrapper()); + const adapter = yield* makeAgyAdapter(decodeAgySettings({ binaryPath: mock.binaryPath })); + const threadId = ThreadId.make("thread-crash"); + const completions = + yield* Queue.unbounded>(); + const eventFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + event.type === "turn.completed" ? Queue.offer(completions, event) : Effect.void, + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId, input: "crash" }); + + const completed = yield* Queue.take(completions); + expect(completed.payload.state).toBe("failed"); + expect(completed.payload.errorMessage).toContain("exited before the turn completed"); + expect((yield* adapter.listSessions())[0]?.status).toBe("error"); + + yield* adapter.stopSession(threadId); + yield* Fiber.interrupt(eventFiber); + yield* Effect.promise(() => NodeFSP.rm(mock.dir, { recursive: true, force: true })); + }), + ); + }, + ); +}); diff --git a/apps/server/src/provider/Layers/AgyAdapter.ts b/apps/server/src/provider/Layers/AgyAdapter.ts new file mode 100644 index 000000000000..e35065451364 --- /dev/null +++ b/apps/server/src/provider/Layers/AgyAdapter.ts @@ -0,0 +1,1046 @@ +/** + * AgyAdapter — provider adapter for the Google Antigravity CLI (agy). + * + * Drives the `agy` CLI in headless streaming JSON mode (`--input-format stream-json --output-format stream-json`) + * and translates output events into T3 Code provider runtime events. + * + * @module AgyAdapter + */ +import { + type AgySettings, + type ModelSelection, + EventId, + type ProviderInteractionMode, + ProviderInstanceId, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderSessionStartInput, + type ProviderSendTurnInput, + type ProviderTurnStartResult, + type ThreadId, + TurnId, + ProviderDriverKind, + RuntimeItemId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import type * as ChildProcessSpawnerTypes from "effect/unstable/process/ChildProcessSpawner"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { resolveAgyModelForEffort } from "../AgyModelSelection.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { type AgyAdapterShape } from "../Services/AgyAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = ProviderDriverKind.make("agy"); +const AGY_RESUME_VERSION = 1 as const; +const AGY_INIT_TIMEOUT_MS = 10_000; + +const AgyToolInfo = Schema.Struct({ + name: Schema.optional(Schema.String), + parameters: Schema.optional(Schema.Unknown), + output: Schema.optional(Schema.Unknown), + error: Schema.optional(Schema.Unknown), +}); + +const AgyNativeEvent = Schema.Union([ + Schema.Struct({ + event: Schema.Literal("init"), + conversation_id: Schema.optional(Schema.String), + init: Schema.optional(Schema.Struct({ conversation_id: Schema.optional(Schema.String) })), + }), + Schema.Struct({ + event: Schema.Literal("step_update"), + step_update: Schema.Struct({ + step_index: Schema.optional(Schema.Number), + state: Schema.optional(Schema.String), + step_type: Schema.optional(Schema.String), + text_delta: Schema.optional(Schema.String), + tool_name: Schema.optional(Schema.String), + tool_info: Schema.optional(AgyToolInfo), + output: Schema.optional(Schema.Unknown), + error: Schema.optional(Schema.Unknown), + }), + }), + Schema.Struct({ + event: Schema.Literal("result"), + result: Schema.optional( + Schema.Struct({ + status: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), + usage: Schema.optional( + Schema.Struct({ + input_tokens: Schema.optional(Schema.Number), + output_tokens: Schema.optional(Schema.Number), + total_tokens: Schema.optional(Schema.Number), + }), + ), + }), + ), + }), +]); + +const AgyUserMessage = Schema.Struct({ + event: Schema.Literal("user"), + message: Schema.Struct({ content: Schema.String }), +}); + +const decodeAgyNativeEventExit = Schema.decodeUnknownExit(Schema.fromJsonString(AgyNativeEvent)); +const encodeAgyUserMessage = Schema.encodeEffect(Schema.fromJsonString(AgyUserMessage)); +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +function encodeJsonForDisplay(value: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(value); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface AgyAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + readonly instanceId?: ProviderInstanceId; +} + +interface EventBaseInput { + readonly threadId: ThreadId; + readonly turnId?: TurnId | undefined; + readonly itemId?: RuntimeItemId | undefined; + readonly createdAt?: string | undefined; +} + +interface AgyTurnState { + readonly turnId: TurnId; + readonly items: Array; + assistantItemId: RuntimeItemId | undefined; + toolItemIds: Map; +} + +interface AgySessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + cwd: string; + conversationId: string | undefined; + readonly conversationReady: Deferred.Deferred; + activeTurnId: TurnId | undefined; + currentTurnState: AgyTurnState | undefined; + childProcess: ChildProcessSpawnerTypes.ChildProcessHandle | undefined; + stdinQueue: Queue.Queue | undefined; + stdinFiber: Fiber.Fiber | undefined; + readFiber: Fiber.Fiber | undefined; + readonly scope: Scope.Closeable; + readonly turns: Array<{ id: TurnId; items: Array }>; + threadStarted: boolean; + stopped: boolean; + modelSelection: ModelSelection | undefined; + interactionMode: ProviderInteractionMode; +} + +function parseAgyResume(raw: unknown): { conversationId: string } | undefined { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined; + const record = raw as Record; + if (record.schemaVersion !== AGY_RESUME_VERSION) return undefined; + if (typeof record.conversationId !== "string" || !record.conversationId.trim()) return undefined; + return { conversationId: record.conversationId.trim() }; +} + +export const makeAgyAdapter = Effect.fn("makeAgyAdapter")(function* ( + agySettings: AgySettings, + options?: AgyAdapterLiveOptions, +) { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("agy"); + const events = yield* PubSub.unbounded(); + const sessions = new Map(); + const environment = options?.environment ?? process.env; + const nativeLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" }) + : undefined); + const managedNativeLogger = options?.nativeEventLogger === undefined ? nativeLogger : undefined; + + const randomUUIDv4 = crypto.randomUUIDv4.pipe(Effect.orDie); + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + + const emit = (event: ProviderRuntimeEvent) => PubSub.publish(events, event).pipe(Effect.asVoid); + + const buildEventBase = (input: EventBaseInput) => + Effect.gen(function* () { + const eventId = EventId.make(yield* randomUUIDv4); + const createdAt = input.createdAt ?? (yield* nowIso); + return { + eventId, + provider: PROVIDER, + providerInstanceId: boundInstanceId, + createdAt, + threadId: input.threadId, + ...(input.turnId ? { turnId: input.turnId } : {}), + ...(input.itemId ? { itemId: input.itemId } : {}), + }; + }); + + const ensureSessionContext = (threadId: ThreadId) => + Effect.gen(function* () { + const context = sessions.get(threadId); + if (!context || context.stopped) { + return yield* new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + return context; + }); + + const stopChildProcess = (context: AgySessionContext) => + Effect.gen(function* () { + if (context.stdinQueue) { + yield* Queue.shutdown(context.stdinQueue); + context.stdinQueue = undefined; + } + if (context.stdinFiber) { + yield* Fiber.interrupt(context.stdinFiber); + context.stdinFiber = undefined; + } + if (context.readFiber) { + yield* Fiber.interrupt(context.readFiber); + context.readFiber = undefined; + } + if (context.childProcess) { + const childProcess = context.childProcess; + context.childProcess = undefined; + yield* childProcess.kill().pipe(Effect.ignore); + } + }); + + const handleNdjsonLine = (context: AgySessionContext, line: string) => + Effect.gen(function* () { + const trimmed = line.trim(); + if (!trimmed) return; + + const decoded = decodeAgyNativeEventExit(trimmed); + if (!Exit.isSuccess(decoded)) { + yield* Effect.logWarning("Ignoring malformed Antigravity event.", { + threadId: context.threadId, + }); + return; + } + const parsed = decoded.value; + if (nativeLogger) { + yield* nativeLogger.write(parsed, context.threadId); + } + const eventType = parsed.event; + + if (eventType === "init") { + const conversationId = parsed.conversation_id ?? parsed.init?.conversation_id; + if (typeof conversationId === "string" && conversationId.trim()) { + context.conversationId = conversationId.trim(); + context.session = { + ...context.session, + resumeCursor: { + schemaVersion: AGY_RESUME_VERSION, + conversationId: context.conversationId, + }, + updatedAt: yield* nowIso, + }; + yield* Deferred.succeed(context.conversationReady, context.conversationId); + if (!context.threadStarted) { + context.threadStarted = true; + yield* emit({ + ...(yield* buildEventBase({ threadId: context.threadId })), + type: "thread.started", + payload: { providerThreadId: context.conversationId }, + }); + } + } + return; + } + + if (eventType === "step_update") { + const stepUpdate = parsed.step_update; + if (!stepUpdate) return; + + const turnId = context.activeTurnId; + if (!turnId) return; + context.currentTurnState?.items.push(parsed); + + const stepType = stepUpdate.step_type; + const stepState = stepUpdate.state; // "ACTIVE" | "DONE" + const stepIndex = typeof stepUpdate.step_index === "number" ? stepUpdate.step_index : 0; + + if (stepType === "agent_response") { + const textDelta = stepUpdate.text_delta; + if (typeof textDelta === "string" && textDelta.length > 0) { + if (!context.currentTurnState?.assistantItemId) { + const assistantItemId = RuntimeItemId.make(yield* randomUUIDv4); + if (context.currentTurnState) { + context.currentTurnState.assistantItemId = assistantItemId; + } + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.threadId, + turnId, + itemId: assistantItemId, + })), + type: "item.started", + payload: { + itemType: "assistant_message", + status: "inProgress", + }, + }); + } + + const itemId = context.currentTurnState?.assistantItemId; + if (itemId) { + yield* emit({ + ...(yield* buildEventBase({ threadId: context.threadId, turnId, itemId })), + type: "content.delta", + payload: { + streamKind: "assistant_text", + delta: textDelta, + }, + }); + } + } + + if (stepState === "DONE" && context.currentTurnState?.assistantItemId) { + const itemId = context.currentTurnState.assistantItemId; + yield* emit({ + ...(yield* buildEventBase({ threadId: context.threadId, turnId, itemId })), + type: "item.completed", + payload: { + itemType: "assistant_message", + status: "completed", + }, + }); + context.currentTurnState.assistantItemId = undefined; + } + return; + } + + if (stepType === "tool") { + const toolName = stepUpdate.tool_name ?? stepUpdate.tool_info?.name ?? "tool"; + const canonicalItemType = + toolName === "run_command" + ? "command_execution" + : toolName === "write_to_file" || + toolName === "replace_file_content" || + toolName === "multi_replace_file_content" + ? "file_change" + : "dynamic_tool_call"; + let toolItemId = context.currentTurnState?.toolItemIds.get(stepIndex); + + if (stepState === "ACTIVE" && !toolItemId) { + toolItemId = RuntimeItemId.make(yield* randomUUIDv4); + context.currentTurnState?.toolItemIds.set(stepIndex, toolItemId); + + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.threadId, + turnId, + itemId: toolItemId, + })), + type: "item.started", + payload: { + itemType: canonicalItemType, + status: "inProgress", + title: toolName, + data: stepUpdate.tool_info?.parameters, + }, + }); + } + + if (stepState === "DONE") { + if (!toolItemId) { + toolItemId = RuntimeItemId.make(yield* randomUUIDv4); + context.currentTurnState?.toolItemIds.set(stepIndex, toolItemId); + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.threadId, + turnId, + itemId: toolItemId, + })), + type: "item.started", + payload: { + itemType: canonicalItemType, + status: "inProgress", + title: toolName, + }, + }); + } + + const output = stepUpdate.output ?? stepUpdate.tool_info?.output; + const error = stepUpdate.error ?? stepUpdate.tool_info?.error; + + if (output !== undefined) { + const delta = typeof output === "string" ? output : encodeJsonForDisplay(output); + if (delta !== undefined) { + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.threadId, + turnId, + itemId: toolItemId, + })), + type: "content.delta", + payload: { + streamKind: + canonicalItemType === "command_execution" + ? "command_output" + : canonicalItemType === "file_change" + ? "file_change_output" + : "unknown", + delta, + }, + }); + } + } + + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.threadId, + turnId, + itemId: toolItemId, + })), + type: "item.completed", + payload: { + itemType: canonicalItemType, + status: error ? "failed" : "completed", + ...(error + ? { + detail: + typeof error === "string" + ? error + : (encodeJsonForDisplay(error) ?? "Antigravity tool failed."), + } + : {}), + }, + }); + context.currentTurnState?.toolItemIds.delete(stepIndex); + } + return; + } + + return; + } + + if (eventType === "result") { + const result = parsed.result; + const turnId = context.activeTurnId; + if (!turnId) return; + context.currentTurnState?.items.push(parsed); + + const status = result?.status; + const state = + status === "SUCCESS" + ? "completed" + : status === "CANCELED" + ? "cancelled" + : status === "INTERRUPTED" + ? "interrupted" + : "failed"; + + yield* emit({ + ...(yield* buildEventBase({ threadId: context.threadId, turnId })), + type: "turn.completed", + payload: { + state, + ...(state === "failed" + ? { + errorMessage: + result?.error ?? `Antigravity turn ended with status ${status ?? "UNKNOWN"}.`, + } + : {}), + usage: { + inputTokens: result?.usage?.input_tokens ?? 0, + outputTokens: result?.usage?.output_tokens ?? 0, + totalTokens: result?.usage?.total_tokens ?? 0, + }, + }, + }); + + context.activeTurnId = undefined; + context.currentTurnState = undefined; + context.session = { + ...context.session, + status: state === "failed" ? "error" : "ready", + activeTurnId: undefined, + updatedAt: yield* nowIso, + ...(state === "failed" + ? { + lastError: + result?.error ?? `Antigravity turn ended with status ${status ?? "UNKNOWN"}.`, + } + : {}), + }; + + yield* emit({ + ...(yield* buildEventBase({ threadId: context.threadId })), + type: "session.state.changed", + payload: { + state: state === "failed" ? "error" : "ready", + ...(state === "failed" + ? { + reason: + result?.error ?? `Antigravity turn ended with status ${status ?? "UNKNOWN"}.`, + } + : {}), + }, + }); + } + }); + + const spawnAgyProcess = ( + context: AgySessionContext, + modelSelection: ModelSelection | undefined, + interactionMode: ProviderInteractionMode | undefined, + ) => + Effect.gen(function* () { + yield* stopChildProcess(context); + + const commandName = agySettings.binaryPath || "agy"; + const effort = modelSelection + ? getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") + : undefined; + const model = resolveAgyModelForEffort(modelSelection?.model, effort); + const mode = interactionMode === "plan" ? "plan" : "accept-edits"; + + const args = [ + "--input-format", + "stream-json", + "--output-format", + "stream-json", + ...(context.session.runtimeMode === "full-access" + ? ["--dangerously-skip-permissions"] + : ["--sandbox"]), + ...(context.conversationId ? ["--conversation", context.conversationId] : []), + ...(model ? ["--model", model] : []), + ...(effort ? ["--effort", effort] : []), + "--mode", + mode, + ...tokenizeCliArgs(agySettings.launchArgs), + ]; + + const spawnCommand = yield* resolveSpawnCommand(commandName, args, { + env: environment, + }); + + const childCommand = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + cwd: context.cwd, + shell: spawnCommand.shell, + }); + + const spawned = yield* childProcessSpawner.spawn(childCommand).pipe( + Effect.provideService(Scope.Scope, context.scope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: context.threadId, + detail: `Failed to spawn Antigravity CLI (${commandName})`, + cause, + }), + ), + ); + + context.childProcess = spawned; + const stdinQueue = yield* Queue.unbounded(); + const stdinFiber = yield* Stream.fromQueue(stdinQueue).pipe( + Stream.run(spawned.stdin), + Effect.catchCause((cause) => + Effect.logWarning("Failed to write to Antigravity stdin stream.", { + threadId: context.threadId, + cause, + }), + ), + Effect.forkIn(context.scope), + ); + context.stdinQueue = stdinQueue; + context.stdinFiber = stdinFiber; + + const readFiber = yield* spawned.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.runForEach((line) => handleNdjsonLine(context, line)), + Effect.catchCause((cause) => + Effect.logWarning("Error in Antigravity stdout stream", { + threadId: context.threadId, + cause, + }), + ), + Effect.forkIn(context.scope), + ); + + context.readFiber = readFiber; + + yield* spawned.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((message) => + message.trim().length > 0 + ? Effect.logWarning("Antigravity CLI stderr", { + threadId: context.threadId, + message: message.trim(), + }) + : Effect.void, + ), + Effect.catchCause((cause) => + Effect.logWarning("Failed to read Antigravity stderr stream.", { + threadId: context.threadId, + cause, + }), + ), + Effect.forkIn(context.scope), + ); + + yield* spawned.exitCode.pipe( + Effect.andThen(Fiber.join(readFiber)), + Effect.flatMap(() => + Effect.gen(function* () { + if (context.childProcess !== spawned || context.stopped) { + return; + } + + context.childProcess = undefined; + context.readFiber = undefined; + const activeTurnId = context.activeTurnId; + if (!activeTurnId) { + return; + } + context.activeTurnId = undefined; + context.currentTurnState = undefined; + context.session = { + ...context.session, + status: "error", + activeTurnId: undefined, + updatedAt: yield* nowIso, + lastError: "Antigravity CLI exited before the turn completed.", + }; + + yield* emit({ + ...(yield* buildEventBase({ + threadId: context.threadId, + turnId: activeTurnId, + })), + type: "turn.completed", + payload: { + state: "failed", + errorMessage: "Antigravity CLI exited before the turn completed.", + }, + }); + yield* emit({ + ...(yield* buildEventBase({ threadId: context.threadId })), + type: "session.state.changed", + payload: { + state: "error", + reason: "Antigravity CLI exited before the turn completed.", + }, + }); + }), + ), + Effect.catchCause((cause) => + Effect.logWarning("Failed to monitor Antigravity process exit.", { + threadId: context.threadId, + cause, + }), + ), + Effect.forkIn(context.scope), + ); + }); + + const startSession: AgyAdapterShape["startSession"] = Effect.fn("startSession")(function* ( + input: ProviderSessionStartInput, + ) { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + + const existing = sessions.get(input.threadId); + if (existing) { + existing.stopped = true; + yield* stopChildProcess(existing); + yield* Scope.close(existing.scope, Exit.void); + sessions.delete(input.threadId); + } + + const startedAt = yield* nowIso; + const scope = yield* Scope.make(); + const conversationReady = yield* Deferred.make(); + const resumeInfo = parseAgyResume(input.resumeCursor); + const conversationId = resumeInfo?.conversationId; + const modelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + if (conversationId !== undefined) { + yield* Deferred.succeed(conversationReady, conversationId); + } + + const session: ProviderSession = { + threadId: input.threadId, + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + createdAt: startedAt, + updatedAt: startedAt, + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(modelSelection?.model ? { model: modelSelection.model } : {}), + ...(conversationId + ? { resumeCursor: { schemaVersion: AGY_RESUME_VERSION, conversationId } } + : {}), + }; + + const context: AgySessionContext = { + threadId: input.threadId, + session, + cwd: input.cwd ?? process.cwd(), + conversationId, + conversationReady, + activeTurnId: undefined, + currentTurnState: undefined, + childProcess: undefined, + stdinQueue: undefined, + stdinFiber: undefined, + readFiber: undefined, + scope, + turns: [], + threadStarted: conversationId !== undefined, + stopped: false, + modelSelection, + interactionMode: "default", + }; + + sessions.set(input.threadId, context); + + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId })), + type: "session.started", + payload: session.resumeCursor !== undefined ? { resume: session.resumeCursor } : {}, + }); + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId })), + type: "session.state.changed", + payload: { + state: "ready", + }, + }); + if (conversationId !== undefined) { + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId })), + type: "thread.started", + payload: { providerThreadId: conversationId }, + }); + } + + return session; + }); + + const sendTurn: AgyAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* ( + input: ProviderSendTurnInput, + ) { + const context = yield* ensureSessionContext(input.threadId); + if (context.session.status === "error") { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sendTurn", + detail: "Antigravity session is in an error state and must be restarted.", + }); + } + if (context.activeTurnId !== undefined) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sendTurn", + detail: "Antigravity does not accept a new prompt while a turn is running.", + }); + } + const turnId = TurnId.make(yield* randomUUIDv4); + const interactionMode = input.interactionMode ?? context.interactionMode; + const modelSelection = + input.modelSelection?.instanceId === boundInstanceId + ? input.modelSelection + : context.modelSelection; + + const currentEffort = context.modelSelection + ? getModelSelectionStringOptionValue(context.modelSelection, "reasoningEffort") + : undefined; + const nextEffort = modelSelection + ? getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") + : undefined; + const modelSelectionChanged = + resolveAgyModelForEffort(context.modelSelection?.model, currentEffort) !== + resolveAgyModelForEffort(modelSelection?.model, nextEffort) || currentEffort !== nextEffort; + + if ( + context.childProcess && + (interactionMode !== context.interactionMode || modelSelectionChanged) + ) { + yield* stopChildProcess(context); + } + + if (!context.childProcess) { + yield* spawnAgyProcess(context, modelSelection, interactionMode); + } + context.interactionMode = interactionMode; + context.modelSelection = modelSelection; + + context.activeTurnId = turnId; + const turnItems: Array = []; + context.currentTurnState = { + turnId, + items: turnItems, + assistantItemId: undefined, + toolItemIds: new Map(), + }; + context.turns.push({ id: turnId, items: turnItems }); + context.session = { + ...context.session, + status: "running", + activeTurnId: turnId, + updatedAt: yield* nowIso, + ...(modelSelection?.model ? { model: modelSelection.model } : {}), + }; + + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "turn.started", + payload: modelSelection?.model ? { model: modelSelection.model } : {}, + }); + + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId })), + type: "session.state.changed", + payload: { + state: "running", + }, + }); + + const userMessagePayload = yield* encodeAgyUserMessage({ + event: "user", + message: { + content: input.input ?? "", + }, + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sendTurn", + detail: "Failed to encode Antigravity prompt.", + cause, + }), + ), + ); + + if (context.stdinQueue) { + const bytes = new TextEncoder().encode(`${userMessagePayload}\n`); + yield* Queue.offer(context.stdinQueue, bytes).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "Failed to write prompt to Antigravity CLI stdin", + cause, + }), + ), + ); + } + + const initialized = yield* Deferred.await(context.conversationReady).pipe( + Effect.timeoutOption(AGY_INIT_TIMEOUT_MS), + ); + if (Option.isNone(initialized)) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sendTurn", + detail: "Antigravity CLI did not initialize a conversation.", + }); + } + + return { + threadId: input.threadId, + turnId, + ...(context.session.resumeCursor !== undefined + ? { resumeCursor: context.session.resumeCursor } + : {}), + } satisfies ProviderTurnStartResult; + }); + + const interruptTurn: AgyAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(function* ( + threadId: ThreadId, + turnId?: TurnId, + ) { + const context = yield* ensureSessionContext(threadId); + const activeTurnId = turnId ?? context.activeTurnId; + + yield* stopChildProcess(context); + + if (activeTurnId) { + yield* emit({ + ...(yield* buildEventBase({ threadId, turnId: activeTurnId })), + type: "turn.completed", + payload: { + state: "interrupted", + }, + }); + } + + context.activeTurnId = undefined; + context.currentTurnState = undefined; + context.session = { + ...context.session, + status: "ready", + activeTurnId: undefined, + updatedAt: yield* nowIso, + }; + + yield* emit({ + ...(yield* buildEventBase({ threadId })), + type: "session.state.changed", + payload: { + state: "ready", + }, + }); + }); + + const stopSession: AgyAdapterShape["stopSession"] = Effect.fn("stopSession")(function* ( + threadId: ThreadId, + ) { + const context = sessions.get(threadId); + if (!context) { + return yield* new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + + context.stopped = true; + yield* stopChildProcess(context); + yield* Scope.close(context.scope, Exit.void); + sessions.delete(threadId); + + yield* emit({ + ...(yield* buildEventBase({ threadId })), + type: "session.exited", + payload: { + reason: "Session stopped.", + recoverable: false, + exitKind: "graceful", + }, + }); + }); + + const respondToRequest: AgyAdapterShape["respondToRequest"] = Effect.fn("respondToRequest")( + function* (threadId) { + yield* ensureSessionContext(threadId); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "respondToRequest", + detail: "Antigravity streaming sessions do not expose interactive approval requests.", + }); + }, + ); + + const respondToUserInput: AgyAdapterShape["respondToUserInput"] = Effect.fn("respondToUserInput")( + function* (threadId) { + yield* ensureSessionContext(threadId); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "respondToUserInput", + detail: "Antigravity streaming sessions do not expose structured user-input requests.", + }); + }, + ); + + const listSessions: AgyAdapterShape["listSessions"] = () => + Effect.sync(() => [...sessions.values()].map((c) => c.session)); + + const hasSession: AgyAdapterShape["hasSession"] = (threadId: ThreadId) => + Effect.sync(() => sessions.has(threadId)); + + const readThread: AgyAdapterShape["readThread"] = Effect.fn("readThread")(function* ( + threadId: ThreadId, + ) { + const context = yield* ensureSessionContext(threadId); + return { + threadId, + turns: context.turns, + }; + }); + + const rollbackThread: AgyAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")(function* ( + threadId: ThreadId, + numTurns: number, + ) { + yield* ensureSessionContext(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: "Antigravity streaming sessions do not support provider-side rollback.", + }); + }); + + const stopAll: AgyAdapterShape["stopAll"] = Effect.fn("stopAll")(function* () { + for (const context of sessions.values()) { + context.stopped = true; + yield* stopChildProcess(context); + yield* Scope.close(context.scope, Exit.void); + } + sessions.clear(); + }); + + const streamEvents: AgyAdapterShape["streamEvents"] = Stream.fromPubSub(events); + + yield* Effect.addFinalizer(() => + Effect.ignore(stopAll()).pipe( + Effect.tap(() => PubSub.shutdown(events)), + Effect.tap(() => managedNativeLogger?.close() ?? Effect.void), + ), + ); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + streamEvents, + } satisfies AgyAdapterShape; +}); diff --git a/apps/server/src/provider/Layers/AgyProvider.test.ts b/apps/server/src/provider/Layers/AgyProvider.test.ts new file mode 100644 index 000000000000..5e2bd1edad53 --- /dev/null +++ b/apps/server/src/provider/Layers/AgyProvider.test.ts @@ -0,0 +1,187 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { AgySettings } from "@t3tools/contracts"; + +import { + agyModelsFromSettings, + buildInitialAgyProviderSnapshot, + checkAgyProviderStatus, + parseAgyModelsOutput, +} from "./AgyProvider.ts"; + +const decodeAgySettings = Schema.decodeSync(AgySettings); + +describe("buildInitialAgyProviderSnapshot", () => { + it.effect("returns a disabled snapshot when settings.enabled is false", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialAgyProviderSnapshot( + decodeAgySettings({ enabled: false }), + ); + expect(snapshot.enabled).toBe(false); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.installed).toBe(false); + expect(snapshot.message).toContain("disabled"); + }), + ); + + it.effect("returns a pending snapshot by default — Antigravity is enabled by default", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialAgyProviderSnapshot(decodeAgySettings({})); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("warning"); + expect(snapshot.version).toBeNull(); + expect(snapshot.message).toContain("Checking Antigravity"); + expect(snapshot.requiresNewThreadForModelChange).toBeUndefined(); + }), + ); +}); + +describe("parseAgyModelsOutput", () => { + it("parses the tab-separated model catalog", () => { + const models = parseAgyModelsOutput( + [ + "Fetching available models...", + "gemini-3.7-flash-high\tGemini 3.7 Flash (High)", + "gemini-3.7-flash-medium\tGemini 3.7 Flash (Medium)", + "gemini-3.7-flash-low\tGemini 3.7 Flash (Low)", + "claude-sonnet-4-6\tClaude Sonnet 4.6 (Thinking)", + ].join("\n"), + ); + + expect(models.map(({ slug, name }) => ({ slug, name }))).toEqual([ + { slug: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash" }, + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Thinking)" }, + ]); + expect(models.find((model) => model.slug === "gemini-3.7-flash-high")?.isLegacy).toBeFalsy(); + const claude = models.find((model) => model.slug === "claude-sonnet-4-6"); + expect(claude?.isLegacy).toBe(true); + expect(claude?.capabilities).toBeNull(); + const capabilities = models[0]?.capabilities; + expect(capabilities).not.toBeNull(); + expect(capabilities?.optionDescriptors?.[0]).toMatchObject({ + id: "reasoningEffort", + currentValue: "high", + options: [{ id: "low" }, { id: "medium" }, { id: "high", isDefault: true }], + }); + }); + + it("parses the escaped tab delimiter emitted by agy 1.1.x", () => { + const models = parseAgyModelsOutput("gemini-3.7-flash-high\\tGemini 3.7 Flash (High)\n"); + + expect(models.map(({ slug, name }) => ({ slug, name }))).toEqual([ + { slug: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash" }, + ]); + }); +}); + +describe("agyModelsFromSettings", () => { + it("does not append reasoning variants as custom models", () => { + const models = agyModelsFromSettings([ + "gemini-3.7-flash-low", + "gemini-3.7-flash-medium", + "my-custom-model", + ]); + + expect(models.map((model) => model.slug)).not.toContain("gemini-3.7-flash-low"); + expect(models.map((model) => model.slug)).not.toContain("gemini-3.7-flash-medium"); + expect(models.map((model) => model.slug)).toContain("my-custom-model"); + }); +}); + +it.layer(NodeServices.layer)("checkAgyProviderStatus", (it) => { + it.effect("reports the binary as missing when the binary path does not resolve", () => + Effect.gen(function* () { + const snapshot = yield* checkAgyProviderStatus( + decodeAgySettings({ + enabled: true, + binaryPath: "/definitely/not/installed/agy-binary", + }), + ); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toMatch(/not found on PATH|Failed to execute/); + }), + ); + + it.effect("reports an installed CLI as ready when --version succeeds", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-agy-version-" }); + const agyPath = path.join(dir, "agy"); + yield* fs.writeFileString( + agyPath, + [ + "#!/bin/sh", + 'if [ "$1" = "models" ]; then', + ' printf "gemini-test\\tGemini Test\\n"', + "else", + ' echo "agy version 1.1.19"', + "fi", + "exit 0", + "", + ].join("\n"), + ); + yield* fs.chmod(agyPath, 0o755); + + return yield* checkAgyProviderStatus( + decodeAgySettings({ enabled: true, binaryPath: agyPath }), + ); + }), + ); + + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("ready"); + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.version).toBe("1.1.19"); + expect(snapshot.models.length).toBeGreaterThan(0); + expect(snapshot.models[0]?.slug).toBe("gemini-test"); + }), + ); + + it.effect("reports a signed-out CLI as unauthenticated", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-agy-auth-" }); + const agyPath = path.join(dir, "agy"); + yield* fs.writeFileString( + agyPath, + [ + "#!/bin/sh", + 'if [ "$1" = "models" ]; then', + ' echo "Error: Please sign in to view available models. Launch the CLI without arguments to sign in." >&2', + " exit 1", + "fi", + 'echo "agy version 1.1.19"', + "", + ].join("\n"), + ); + yield* fs.chmod(agyPath, 0o755); + + return yield* checkAgyProviderStatus( + decodeAgySettings({ enabled: true, binaryPath: agyPath }), + ); + }), + ); + + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.message).toBe( + "Antigravity CLI is not authenticated. Launch `agy` to sign in.", + ); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/AgyProvider.ts b/apps/server/src/provider/Layers/AgyProvider.ts new file mode 100644 index 000000000000..1e66d4ae61b7 --- /dev/null +++ b/apps/server/src/provider/Layers/AgyProvider.ts @@ -0,0 +1,510 @@ +import { + type AgySettings, + type ModelCapabilities, + type ServerProviderModel, +} from "@t3tools/contracts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + buildSelectOptionDescriptor, + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, +} from "../providerSnapshot.ts"; + +export const AGY_PRESENTATION = { + displayName: "Antigravity", + showInteractionModeToggle: true, +} as const; + +const AGY_REASONING_EFFORTS = ["low", "medium", "high"] as const; +type AgyReasoningEffort = (typeof AGY_REASONING_EFFORTS)[number]; + +const agyModelCapabilities = ( + supportedEfforts: ReadonlyArray, +): ModelCapabilities => { + const defaultEffort = supportedEfforts.includes("high") + ? "high" + : supportedEfforts.includes("medium") + ? "medium" + : supportedEfforts[0]; + return createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "reasoningEffort", + label: "Reasoning", + options: AGY_REASONING_EFFORTS.filter((effort) => supportedEfforts.includes(effort)).map( + (effort) => ({ + value: effort, + label: `${effort.slice(0, 1).toUpperCase()}${effort.slice(1)}`, + ...(effort === defaultEffort ? { isDefault: true } : {}), + }), + ), + }), + ], + }); +}; + +export const DEFAULT_AGY_MODEL_CAPABILITIES: ModelCapabilities = + agyModelCapabilities(AGY_REASONING_EFFORTS); + +const AGY_BUILT_IN_MODEL_VARIANTS: ReadonlyArray = [ + { + slug: "gemini-3.7-flash-high", + name: "Gemini 3.7 Flash (High)", + isCustom: false, + capabilities: DEFAULT_AGY_MODEL_CAPABILITIES, + }, + { + slug: "gemini-3.7-flash-medium", + name: "Gemini 3.7 Flash (Medium)", + isCustom: false, + capabilities: DEFAULT_AGY_MODEL_CAPABILITIES, + }, + { + slug: "gemini-3.7-flash-low", + name: "Gemini 3.7 Flash (Low)", + isCustom: false, + capabilities: DEFAULT_AGY_MODEL_CAPABILITIES, + }, + { + slug: "gemini-3.6-flash-high", + name: "Gemini 3.6 Flash (High)", + isCustom: false, + capabilities: DEFAULT_AGY_MODEL_CAPABILITIES, + }, + { + slug: "gemini-3.6-flash-medium", + name: "Gemini 3.6 Flash (Medium)", + isCustom: false, + capabilities: DEFAULT_AGY_MODEL_CAPABILITIES, + }, + { + slug: "gemini-3.6-flash-low", + name: "Gemini 3.6 Flash (Low)", + isCustom: false, + capabilities: DEFAULT_AGY_MODEL_CAPABILITIES, + }, + { + slug: "gemini-3.5-flash-high", + name: "Gemini 3.5 Flash (High)", + isCustom: false, + capabilities: DEFAULT_AGY_MODEL_CAPABILITIES, + }, + { + slug: "gemini-3.5-flash-medium", + name: "Gemini 3.5 Flash (Medium)", + isCustom: false, + capabilities: DEFAULT_AGY_MODEL_CAPABILITIES, + }, + { + slug: "gemini-3.5-flash-low", + name: "Gemini 3.5 Flash (Low)", + isCustom: false, + capabilities: DEFAULT_AGY_MODEL_CAPABILITIES, + }, + { + slug: "gemini-3.1-pro-high", + name: "Gemini 3.1 Pro (High)", + isCustom: false, + capabilities: DEFAULT_AGY_MODEL_CAPABILITIES, + }, + { + slug: "gemini-3.1-pro-low", + name: "Gemini 3.1 Pro (Low)", + isCustom: false, + capabilities: DEFAULT_AGY_MODEL_CAPABILITIES, + }, + { + slug: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6 (Thinking)", + isCustom: false, + capabilities: DEFAULT_AGY_MODEL_CAPABILITIES, + }, + { + slug: "claude-opus-4-6-thinking", + name: "Claude Opus 4.6 (Thinking)", + isCustom: false, + capabilities: DEFAULT_AGY_MODEL_CAPABILITIES, + }, + { + slug: "gpt-oss-120b-medium", + name: "GPT-OSS 120B (Medium)", + isCustom: false, + capabilities: DEFAULT_AGY_MODEL_CAPABILITIES, + }, +]; + +export const AGY_BUILT_IN_MODELS: ReadonlyArray = parseAgyModelsOutput( + AGY_BUILT_IN_MODEL_VARIANTS.map(({ slug, name }) => `${slug}\t${name}`).join("\n"), +); + +const DEFAULT_TIMEOUT_MS = 10_000; +const AGY_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; +const AGY_UNAUTHENTICATED_MESSAGE = + "Antigravity CLI is not authenticated. Launch `agy` to sign in."; + +function isAgyAuthenticationError(stdout: string, stderr: string): boolean { + const output = `${stdout}\n${stderr}`.toLowerCase(); + return ( + output.includes("please sign in") || + output.includes("authentication required") || + output.includes("not authenticated") || + output.includes("login required") + ); +} + +export function agyModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = AGY_BUILT_IN_MODELS, +): ReadonlyArray { + const groupedVariantSlugs = new Set(); + for (const model of builtInModels) { + const match = /^(.*)-(low|medium|high)$/.exec(model.slug); + const reasoning = model.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "reasoningEffort" && descriptor.type === "select", + ); + if (!match?.[1] || !reasoning || reasoning.type !== "select") continue; + for (const option of reasoning.options) { + groupedVariantSlugs.add(`${match[1]}-${option.id}`); + } + } + return providerModelsFromSettings( + builtInModels, + (customModels ?? []).filter((slug) => !groupedVariantSlugs.has(slug.trim())), + DEFAULT_AGY_MODEL_CAPABILITIES, + ); +} + +export function parseAgyModelsOutput(output: string): ReadonlyArray { + const parsedModels = output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && line !== "Fetching available models...") + .flatMap((line) => { + // agy 1.1.x prints the delimiter as the two literal characters `\\t`. + // Accept a real tab as well for compatibility with scripts and future CLI versions. + const [slug, ...nameParts] = line.split(/\\t|\t/); + const normalizedSlug = slug?.trim(); + const name = nameParts.join("\t").trim(); + return normalizedSlug && name + ? [ + { + slug: normalizedSlug, + name, + isCustom: false, + ...(!normalizedSlug.startsWith("gemini-") ? { isLegacy: true } : {}), + capabilities: null, + } satisfies ServerProviderModel, + ] + : []; + }); + + const grouped = new Map< + string, + { + readonly name: string; + readonly variants: Map; + } + >(); + const orderedModels: Array< + | { readonly type: "group"; readonly slug: string } + | { readonly type: "standalone"; readonly model: ServerProviderModel } + > = []; + + for (const model of parsedModels) { + const slugMatch = /^(.*)-(low|medium|high)$/.exec(model.slug); + const nameMatch = /^(.*) \((Low|Medium|High)\)$/.exec(model.name); + if (!slugMatch || !nameMatch) { + orderedModels.push({ type: "standalone", model }); + continue; + } + + const baseSlug = slugMatch[1]; + const effort = slugMatch[2] as AgyReasoningEffort; + const baseName = nameMatch[1]; + if (!baseSlug || !baseName || nameMatch[2]?.toLowerCase() !== effort) { + orderedModels.push({ type: "standalone", model }); + continue; + } + + const existing = grouped.get(baseSlug); + const entry = existing ?? { name: baseName, variants: new Map() }; + if (!existing) { + orderedModels.push({ type: "group", slug: baseSlug }); + } + entry.variants.set(effort, model); + grouped.set(baseSlug, entry); + } + + return orderedModels.map((orderedModel) => { + if (orderedModel.type === "standalone") return orderedModel.model; + + const group = grouped.get(orderedModel.slug); + if (!group) { + throw new Error(`Missing grouped Antigravity model: ${orderedModel.slug}`); + } + const { name, variants } = group; + const efforts = AGY_REASONING_EFFORTS.filter((effort) => variants.has(effort)); + const defaultEffort = efforts.includes("high") + ? "high" + : efforts.includes("medium") + ? "medium" + : efforts[0]; + const defaultVariant = defaultEffort ? variants.get(defaultEffort) : undefined; + return { + slug: defaultVariant?.slug ?? [...variants.values()][0]!.slug, + name, + isCustom: false, + ...(defaultVariant?.isLegacy ? { isLegacy: true } : {}), + capabilities: agyModelCapabilities(efforts), + } satisfies ServerProviderModel; + }); +} + +export function buildInitialAgyProviderSnapshot( + agySettings: AgySettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = agyModelsFromSettings(agySettings.customModels); + + if (!agySettings.enabled) { + return buildServerProvider({ + presentation: AGY_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Antigravity is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: AGY_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Antigravity CLI availability...", + }, + }); + }); +} + +const runAgyCommand = ( + agySettings: AgySettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const command = agySettings.binaryPath || "agy"; + const spawnCommand = yield* resolveSpawnCommand(command, args, { + env: environment, + }); + const childCommand = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + stdin: "ignore", + }); + return yield* spawnAndCollect(agySettings.binaryPath, childCommand); + }); + +export const checkAgyProviderStatus = Effect.fn("checkAgyProviderStatus")(function* ( + agySettings: AgySettings, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return { + const resolvedEnvironment = environment ?? process.env; + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const allModels = agyModelsFromSettings(agySettings.customModels); + + if (!agySettings.enabled) { + return buildServerProvider({ + presentation: AGY_PRESENTATION, + enabled: false, + checkedAt, + models: allModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Antigravity is disabled in T3 Code settings.", + }, + }); + } + + const versionProbe = yield* runAgyCommand(agySettings, ["--version"], resolvedEnvironment).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + yield* Effect.logWarning("Antigravity CLI health check failed.", { + errorTag: error._tag, + }); + return buildServerProvider({ + presentation: AGY_PRESENTATION, + enabled: agySettings.enabled, + checkedAt, + models: allModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Antigravity CLI (`agy`) was not found on PATH." + : "Failed to execute Antigravity CLI health check.", + }, + }); + } + + if (Option.isNone(versionProbe.success)) { + return buildServerProvider({ + presentation: AGY_PRESENTATION, + enabled: agySettings.enabled, + checkedAt, + models: allModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Antigravity CLI is installed but failed to run. Timed out while running command.", + }, + }); + } + + const version = versionProbe.success.value; + const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); + if (version.code !== 0) { + yield* Effect.logWarning("Antigravity CLI version probe exited with non-zero status.", { + exitCode: version.code, + stdoutLength: version.stdout.length, + stderrLength: version.stderr.length, + }); + return buildServerProvider({ + presentation: AGY_PRESENTATION, + enabled: agySettings.enabled, + checkedAt, + models: allModels, + probe: { + installed: true, + version: parsedVersion, + status: "error", + auth: { status: "unknown" }, + message: "Antigravity CLI is installed but failed to run.", + }, + }); + } + + const modelsProbe = yield* runAgyCommand(agySettings, ["models"], resolvedEnvironment).pipe( + Effect.timeoutOption(AGY_MODEL_DISCOVERY_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(modelsProbe)) { + yield* Effect.logWarning("Antigravity model discovery failed", { + errorTag: modelsProbe.failure._tag, + }); + return buildServerProvider({ + presentation: AGY_PRESENTATION, + enabled: agySettings.enabled, + checkedAt, + models: allModels, + probe: { + installed: true, + version: parsedVersion, + status: "error", + auth: { status: "unknown" }, + message: + "Antigravity CLI is installed but model discovery failed. Check server logs for details.", + }, + }); + } + + if (Option.isNone(modelsProbe.success)) { + yield* Effect.logWarning( + `Antigravity model discovery timed out after ${AGY_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + ); + return buildServerProvider({ + presentation: AGY_PRESENTATION, + enabled: agySettings.enabled, + checkedAt, + models: allModels, + probe: { + installed: true, + version: parsedVersion, + status: "error", + auth: { status: "unknown" }, + message: `Antigravity CLI is installed but model discovery timed out after ${AGY_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + }, + }); + } + + if (modelsProbe.success.value.code !== 0) { + const authenticationRequired = isAgyAuthenticationError( + modelsProbe.success.value.stdout, + modelsProbe.success.value.stderr, + ); + yield* Effect.logWarning("Antigravity model discovery exited with non-zero status", { + exitCode: modelsProbe.success.value.code, + stdoutLength: modelsProbe.success.value.stdout.length, + stderrLength: modelsProbe.success.value.stderr.length, + authenticationRequired, + }); + return buildServerProvider({ + presentation: AGY_PRESENTATION, + enabled: agySettings.enabled, + checkedAt, + models: allModels, + probe: { + installed: true, + version: parsedVersion, + status: "error", + auth: { status: authenticationRequired ? "unauthenticated" : "unknown" }, + message: authenticationRequired + ? AGY_UNAUTHENTICATED_MESSAGE + : "Antigravity CLI is installed but model discovery failed. Check server logs for details.", + }, + }); + } + + const discoveredModels = parseAgyModelsOutput(modelsProbe.success.value.stdout); + const models = + discoveredModels.length > 0 + ? agyModelsFromSettings(agySettings.customModels, discoveredModels) + : allModels; + + return buildServerProvider({ + presentation: AGY_PRESENTATION, + enabled: agySettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "ready", + auth: { status: "authenticated" }, + }, + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index cc994f8e5fc2..74ac091e2ee7 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1545,7 +1545,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.strictEqual(initialCodex?.status, "error"); assert.strictEqual(initialCodex?.installed, false); - assert.deepStrictEqual(spawnedCommands, [firstMissing]); + assert.deepStrictEqual(spawnedCommands, [firstMissing, "agy"]); // Drive a settings change. The Hydration layer's // `SettingsWatcherLive` consumes this via `streamChanges`, @@ -1582,7 +1582,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }); const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex"); - assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]); + assert.deepStrictEqual(spawnedCommands, [firstMissing, "agy", secondMissing]); assert.strictEqual(reprobedCodex?.status, "error"); assert.strictEqual(reprobedCodex?.installed, false); }).pipe(Effect.provide(runtimeServices)); @@ -1734,6 +1734,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [ + "agy", "claudeAgent", "codex", "cursor", diff --git a/apps/server/src/provider/Services/AgyAdapter.ts b/apps/server/src/provider/Services/AgyAdapter.ts new file mode 100644 index 000000000000..4a17daa937f2 --- /dev/null +++ b/apps/server/src/provider/Services/AgyAdapter.ts @@ -0,0 +1,16 @@ +/** + * AgyAdapter — shape type for the Antigravity (agy) provider adapter. + * + * The driver model ({@link ../Drivers/AgyDriver}) bundles one adapter per + * instance as a captured closure, so this module only retains the shape + * interface as a naming anchor for the driver bundle. + * + * @module AgyAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * AgyAdapterShape — per-instance Antigravity adapter contract. + */ +export interface AgyAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3c..85228bea55da 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -20,6 +20,7 @@ * * @module provider/builtInDrivers */ +import { AgyDriver, type AgyDriverEnv } from "./Drivers/AgyDriver.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; @@ -33,6 +34,7 @@ import type { AnyProviderDriver } from "./ProviderDriver.ts"; * layer must provide every service in this union. */ export type BuiltInDriversEnv = + | AgyDriverEnv | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv @@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray { + it.effect("generates structured output with the selected model and effort", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const fake = yield* makeFakeAgy(); + const textGeneration = yield* makeAgyTextGeneration( + decodeAgySettings({ binaryPath: fake.binaryPath }), + { + ...process.env, + T3_FAKE_AGY_ARGS_PATH: fake.argsPath, + T3_FAKE_AGY_OUTPUT: encodeJsonString({ + status: "SUCCESS", + structured_output: { + subject: "Add Antigravity provider", + body: "Wire the agy harness into T3 Code.", + }, + }), + }, + ); + + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feat/antigravity-provider", + stagedSummary: "Add the agy provider", + stagedPatch: "diff --git a/agy b/agy", + modelSelection: createModelSelection( + ProviderInstanceId.make("agy"), + "gemini-3.7-flash-high", + [{ id: "reasoningEffort", value: "medium" }], + ), + }); + + expect(generated).toEqual({ + subject: "Add Antigravity provider", + body: "Wire the agy harness into T3 Code.", + }); + const args = yield* fileSystem.readFileString(fake.argsPath); + expect(args).toContain("--output-format json"); + expect(args).toContain("--json-schema"); + expect(args).toContain("--model gemini-3.7-flash-medium"); + expect(args).toContain("--effort medium"); + expect(args).toContain("--dangerously-skip-permissions"); + }), + ), + ); + + it.effect("reports non-zero CLI exits as text-generation errors", () => + Effect.scoped( + Effect.gen(function* () { + const fake = yield* makeFakeAgy(); + const textGeneration = yield* makeAgyTextGeneration( + decodeAgySettings({ binaryPath: fake.binaryPath }), + { + ...process.env, + T3_FAKE_AGY_ARGS_PATH: fake.argsPath, + T3_FAKE_AGY_STDERR: "authentication required", + T3_FAKE_AGY_EXIT_CODE: "1", + }, + ); + + const error = yield* textGeneration + .generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread", + modelSelection: { + instanceId: ProviderInstanceId.make("agy"), + model: "gemini-3.7-flash-high", + }, + }) + .pipe(Effect.flip); + + expect(error.detail).toContain("authentication required"); + }), + ), + ); +}); diff --git a/apps/server/src/textGeneration/AgyTextGeneration.ts b/apps/server/src/textGeneration/AgyTextGeneration.ts new file mode 100644 index 000000000000..970dc08eebfa --- /dev/null +++ b/apps/server/src/textGeneration/AgyTextGeneration.ts @@ -0,0 +1,326 @@ +/** + * AgyTextGeneration – Text generation layer using the Antigravity CLI (agy). + * + * Implements the TextGeneration service contract by delegating to + * `agy -p --output-format json --json-schema ...`. + * + * @module AgyTextGeneration + */ +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { type AgySettings, type ModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import { TextGenerationError } from "@t3tools/contracts"; +import type { ProviderInstance } from "../provider/ProviderDriver.ts"; +import { resolveAgyModelForEffort } from "../provider/AgyModelSelection.ts"; +import * as TextGeneration from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + normalizeCliError, + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, + toJsonSchemaObject, +} from "./TextGenerationUtils.ts"; + +const AGY_TIMEOUT_MS = 180_000; + +const AgyOutputEnvelope = Schema.Struct({ + status: Schema.optional(Schema.String), + structured_output: Schema.optional(Schema.Unknown), + response: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), +}); + +const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeAgyOutputEnvelope = Schema.decodeEffect(Schema.fromJsonString(AgyOutputEnvelope)); + +export const makeAgyTextGeneration = Effect.fn("makeAgyTextGeneration")(function* ( + agySettings: AgySettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const readStreamAsString = ( + operation: string, + stream: Stream.Stream, + ): Effect.Effect => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + Effect.mapError((cause) => + normalizeCliError("agy", operation, cause, "Failed to collect process output"), + ), + ); + + const encodeJsonForOperation = ( + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle", + value: unknown, + detail: string, + ): Effect.Effect => + encodeJsonString(value).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail, + cause, + }), + ), + ); + + const runAgyJson = Effect.fn("runAgyJson")(function* ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.fn.Return { + const jsonSchemaStr = yield* encodeJsonForOperation( + operation, + toJsonSchemaObject(outputSchemaJson), + "Failed to encode structured output schema.", + ); + const effort = getModelSelectionStringOptionValue(modelSelection, "reasoningEffort"); + const model = resolveAgyModelForEffort(modelSelection.model, effort); + + const runAgyCommand = Effect.fn("runAgyJson.runAgyCommand")(function* () { + const spawnCommand = yield* resolveSpawnCommand( + agySettings.binaryPath || "agy", + [ + "-p", + prompt, + "--output-format", + "json", + "--json-schema", + jsonSchemaStr, + ...(model ? ["--model", model] : []), + ...(effort ? ["--effort", effort] : []), + "--dangerously-skip-permissions", + ], + { env: environment }, + ); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + cwd, + shell: spawnCommand.shell, + }); + + const spawned = yield* commandSpawner + .spawn(command) + .pipe( + Effect.mapError((cause) => + normalizeCliError("agy", operation, cause, "Failed to spawn Antigravity CLI"), + ), + ); + + const outputFiber = yield* Effect.all( + [ + readStreamAsString(operation, spawned.stdout), + readStreamAsString(operation, spawned.stderr), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + const exitCode = yield* spawned.exitCode.pipe( + Effect.mapError((cause) => + normalizeCliError("agy", operation, cause, "Failed to wait for process exit"), + ), + ); + const [stdout, stderr] = yield* Fiber.join(outputFiber); + + return { + exitCode, + stdout, + stderr, + }; + }); + + const result = yield* runAgyCommand().pipe( + Effect.timeoutOption(AGY_TIMEOUT_MS), + Effect.flatMap((timedOut) => + Option.match(timedOut, { + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Timed out waiting for Antigravity CLI to complete.", + }), + ), + onSome: Effect.succeed, + }), + ), + Effect.scoped, + ); + + if (result.exitCode !== 0) { + return yield* new TextGenerationError({ + operation, + detail: + result.stderr.trim() || + result.stdout.trim() || + `Antigravity CLI process exited with code ${result.exitCode}`, + }); + } + + const envelope = yield* decodeAgyOutputEnvelope(result.stdout).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: `Failed to parse Antigravity output envelope: ${result.stdout}`, + cause, + }), + ), + ); + + const payload = envelope.structured_output ?? extractJsonObject(envelope.response ?? ""); + if (!payload) { + return yield* new TextGenerationError({ + operation, + detail: envelope.error || "Antigravity CLI returned empty structured output.", + }); + } + + return yield* Schema.decodeUnknownEffect(outputSchemaJson)(payload).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: "Antigravity CLI response did not match expected schema.", + cause, + }), + ), + ); + }); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("AgyTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + const generated = yield* runAgyJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + const branch = + "branch" in generated && typeof generated.branch === "string" + ? sanitizeBranchFragment(generated.branch) + : undefined; + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body?.trim() ?? "", + ...(branch ? { branch: sanitizeFeatureBranchName(branch) } : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("AgyTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + const generated = yield* runAgyJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("AgyTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runAgyJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("AgyTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + const generated = yield* runAgyJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies ProviderInstance["textGeneration"]; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 66b7ccd465f1..202e260a1946 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "claudeAgent" + | "cursor" + | "grok" + | "opencode" + | "agy"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index cd0854e176b7..e23658718f5e 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -641,12 +641,12 @@ export const Gemini: Icon = (props) => ( ); -const ANTIGRAVITY_ICON_DATA_URL = - "data:image/png;base64,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"; - export const AntigravityIcon: Icon = (props) => ( - - + + ); diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 842c616fe1fe..91975610e973 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,13 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + AntigravityIcon, + ClaudeAI, + CursorIcon, + GrokIcon, + Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -8,6 +16,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("agy")]: AntigravityIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/ProviderInstanceCard.test.ts b/apps/web/src/components/settings/ProviderInstanceCard.test.ts index 051045b030c3..e3ec63dc2f88 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.test.ts +++ b/apps/web/src/components/settings/ProviderInstanceCard.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import type { ServerProviderModel } from "@t3tools/contracts"; +import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts"; import { deriveProviderModelsForDisplay } from "./ProviderInstanceCard"; @@ -33,4 +33,43 @@ describe("deriveProviderModelsForDisplay", () => { }).map((model) => model.slug), ).toEqual(["server-model", "kept-custom"]); }); + + it("shows one grouped Antigravity row and suppresses its raw effort variants", () => { + const grouped: ServerProviderModel = { + slug: "gemini-3.7-flash-high", + name: "Gemini 3.7 Flash", + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + ], + currentValue: "high", + }, + ], + }, + }; + + expect( + deriveProviderModelsForDisplay({ + driverKind: ProviderDriverKind.make("agy"), + liveModels: [ + grouped, + { + slug: "gemini-3.7-flash-medium", + name: "Gemini 3.7 Flash (Medium)", + isCustom: false, + capabilities: null, + }, + ], + customModels: ["gemini-3.7-flash-low", "my-custom-model"], + }).map((model) => model.slug), + ).toEqual(["gemini-3.7-flash-high", "my-custom-model"]); + }); }); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index a663aa90990d..3db64bddcbde 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -15,11 +15,11 @@ import * as Result from "effect/Result"; import { useState, type ReactNode } from "react"; import { isProviderDriverKind, + type ProviderDriverKind, resolveProviderInstanceEnabled, type ProviderInstanceConfig, type ProviderInstanceEnvironmentVariable, type ProviderInstanceId, - type ProviderDriverKind, type ServerProvider, type ServerProviderModel, } from "@t3tools/contracts"; @@ -27,6 +27,10 @@ import { import { cn } from "../../lib/utils"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { normalizeProviderAccentColor } from "../../providerInstances"; +import { + getGroupedProviderModelVariantSlugs, + getVisibleProviderModels, +} from "../../providerModels"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Checkbox } from "../ui/checkbox"; @@ -113,22 +117,32 @@ function nextConfigBlobWithValue( export function deriveProviderModelsForDisplay(input: { readonly liveModels: ReadonlyArray | undefined; readonly customModels: ReadonlyArray; + readonly driverKind?: ProviderDriverKind | null; }): ReadonlyArray { const liveCustomModelsBySlug = new Map( Arr.filterMap(input.liveModels ?? [], (model) => model.isCustom ? Result.succeed([model.slug, model] as const) : Result.failVoid, ), ); - const serverModels = input.liveModels?.filter((model) => !model.isCustom) ?? []; - const customModels = input.customModels.map( - (slug) => - liveCustomModelsBySlug.get(slug) ?? { - slug, - name: slug, - isCustom: true, - capabilities: null, - }, + const rawServerModels = input.liveModels?.filter((model) => !model.isCustom) ?? []; + const groupedVariantSlugs = getGroupedProviderModelVariantSlugs( + rawServerModels, + input.driverKind, ); + const serverModels = input.driverKind + ? getVisibleProviderModels(rawServerModels, input.driverKind) + : rawServerModels; + const customModels = input.customModels + .filter((slug) => !groupedVariantSlugs.has(slug)) + .map( + (slug) => + liveCustomModelsBySlug.get(slug) ?? { + slug, + name: slug, + isCustom: true, + capabilities: null, + }, + ); return [...serverModels, ...customModels]; } @@ -449,6 +463,7 @@ export function ProviderInstanceCard({ const modelsForDisplay = deriveProviderModelsForDisplay({ liveModels: liveProvider?.models, customModels, + driverKind, }); const updateDisplayName = (value: string) => { diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d6807..eee04be8d40a 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -1,4 +1,5 @@ import { + AgySettings, ClaudeSettings, CodexSettings, CursorSettings, @@ -7,7 +8,15 @@ import { ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + AntigravityIcon, + ClaudeAI, + CursorIcon, + GrokIcon, + type Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -67,6 +76,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("agy"), + label: "Antigravity", + icon: AntigravityIcon, + settingsSchema: AgySettings, + }, ]; export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 405366d9fcbe..49e1adca04ec 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -176,6 +176,69 @@ describe("instance-scoped model selection", () => { ); }); + it("does not append grouped Antigravity reasoning variants as custom models", () => { + const base = provider({ + provider: ProviderDriverKind.make("agy"), + instanceId: "agy", + models: ["gemini-3.7-flash-high"], + }); + const providers: ServerProvider[] = [ + { + ...base, + models: [ + { + ...base.models[0]!, + name: "Gemini 3.7 Flash", + capabilities: { + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + ], + currentValue: "high", + }, + ], + }, + }, + { + slug: "gemini-3.7-flash-medium", + name: "Gemini 3.7 Flash (Medium)", + isCustom: true, + capabilities: {}, + }, + { + slug: "gemini-3.7-flash-low", + name: "Gemini 3.7 Flash (Low)", + isCustom: false, + capabilities: {}, + }, + ], + }, + ]; + const settings: UnifiedSettings = { + ...settingsWithProviderInstances(), + providerInstances: { + [ProviderInstanceId.make("agy")]: { + driver: ProviderDriverKind.make("agy"), + config: { + customModels: ["gemini-3.7-flash-low", "gemini-3.7-flash-medium", "my-custom-model"], + }, + }, + }, + }; + const agy = deriveProviderInstanceEntries(providers)[0]!; + + expect(getAppModelOptionsForInstance(settings, agy).map((option) => option.slug)).toEqual([ + "gemini-3.7-flash-high", + "my-custom-model", + ]); + }); + it("does not inject an unknown selected slug into the stock instance list", () => { const providers = [ provider({ diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index ccdffdda1004..1724013fd296 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -15,11 +15,11 @@ import { } from "@t3tools/shared/model"; import { getComposerProviderState } from "./components/chat/composerProviderState"; import { UnifiedSettings } from "@t3tools/contracts/settings"; -import * as Arr from "effect/Array"; -import * as Result from "effect/Result"; import { + getBuiltInProviderModelSlugs, getDefaultServerModel, getProviderModels, + getVisibleProviderModels, resolveSelectableProvider, } from "./providerModels"; import { ModelEsque } from "./components/chat/providerIconUtils"; @@ -153,13 +153,10 @@ export function getAppModelOptions( provider: ProviderDriverKind, _selectedModel?: string | null, ): AppModelOption[] { - const options: AppModelOption[] = getProviderModels(providers, provider).map(toAppModelOption); + const providerModels = getVisibleProviderModels(getProviderModels(providers, provider), provider); + const options: AppModelOption[] = providerModels.map(toAppModelOption); const seen = new Set(options.map((option) => option.slug)); - const builtInModelSlugs = new Set( - Arr.filterMap(getProviderModels(providers, provider), (model) => - model.isCustom ? Result.failVoid : Result.succeed(model.slug), - ), - ); + const builtInModelSlugs = getBuiltInProviderModelSlugs(providerModels, provider); // Read from the default instance's config first (that's where edits // now land), falling back to the legacy per-kind bucket so unmigrated @@ -201,13 +198,10 @@ export function getAppModelOptionsForInstance( settings: UnifiedSettings, entry: ProviderInstanceEntry, ): AppModelOption[] { - const options: AppModelOption[] = entry.models.map(toAppModelOption); + const providerModels = getVisibleProviderModels(entry.models, entry.driverKind); + const options: AppModelOption[] = providerModels.map(toAppModelOption); const seen = new Set(options.map((option) => option.slug)); - const builtInModelSlugs = new Set( - Arr.filterMap(entry.models, (model) => - model.isCustom ? Result.failVoid : Result.succeed(model.slug), - ), - ); + const builtInModelSlugs = getBuiltInProviderModelSlugs(providerModels, entry.driverKind); const customModels = readInstanceCustomModels(settings, entry.instanceId, entry.driverKind); for (const slug of normalizeCustomModelSlugs(customModels, builtInModelSlugs)) { diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index b64a5e25d508..d47c4311a4ea 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -10,6 +10,7 @@ import { resolveDefaultProviderModelSelection, resolveSelectableProviderInstance, resolveProviderDriverKindForInstanceSelection, + sortProviderInstanceEntries, } from "./providerInstances"; function provider(input: { @@ -135,6 +136,24 @@ describe("deriveProviderInstanceEntries", () => { }); }); +describe("sortProviderInstanceEntries", () => { + it("uses canonical provider order even when Antigravity is emitted first", () => { + const entries = deriveProviderInstanceEntries([ + provider({ provider: ProviderDriverKind.make("agy"), instanceId: "agy" }), + provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), + provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent" }), + provider({ provider: ProviderDriverKind.make("opencode"), instanceId: "opencode" }), + ]); + + expect(sortProviderInstanceEntries(entries).map((entry) => entry.driverKind)).toEqual([ + "codex", + "claudeAgent", + "opencode", + "agy", + ]); + }); +}); + describe("deriveProviderEntriesByEnvironment", () => { it("keeps same-id default instances distinct per environment", () => { const byEnvironment = deriveProviderEntriesByEnvironment([ diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index ef60d554dd78..91d4d7cf9d12 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -279,8 +279,20 @@ export function sortProviderInstanceEntries( byKind.set(entry.driverKind, [entry]); } } + const driverOrder = new Map( + Object.keys(PROVIDER_DISPLAY_NAMES).map((driverKind, index) => [driverKind, index] as const), + ); + const firstSeenOrder = new Map( + [...byKind.keys()].map((driverKind, index) => [driverKind, index] as const), + ); + const orderedKinds = [...byKind.keys()].toSorted((left, right) => { + const leftRank = driverOrder.get(left) ?? Number.POSITIVE_INFINITY; + const rightRank = driverOrder.get(right) ?? Number.POSITIVE_INFINITY; + return leftRank - rightRank || firstSeenOrder.get(left)! - firstSeenOrder.get(right)!; + }); const sorted: ProviderInstanceEntry[] = []; - for (const bucket of byKind.values()) { + for (const driverKind of orderedKinds) { + const bucket = byKind.get(driverKind)!; const defaults = bucket.filter((entry) => entry.isDefault); const customs = bucket.filter((entry) => !entry.isDefault); sorted.push(...defaults, ...customs); diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index 6fc8b5e122a9..e2fd440588ff 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -14,6 +14,57 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], }); const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); +const AGY_DRIVER_KIND = ProviderDriverKind.make("agy"); + +function getGroupedAgyModelVariants(models: ReadonlyArray): { + readonly canonicalSlugs: ReadonlySet; + readonly variantSlugs: ReadonlySet; +} { + const canonicalSlugs = new Set(); + const variantSlugs = new Set(); + for (const model of models) { + const match = /^(.*)-(low|medium|high)$/.exec(model.slug); + const isIndividualVariant = / \((Low|Medium|High)\)$/.test(model.name); + const reasoning = model.capabilities?.optionDescriptors?.find( + (descriptor) => descriptor.id === "reasoningEffort" && descriptor.type === "select", + ); + if (isIndividualVariant || !match?.[1] || !reasoning || reasoning.type !== "select") continue; + + canonicalSlugs.add(model.slug); + for (const option of reasoning.options) { + variantSlugs.add(`${match[1]}-${option.id}`); + } + } + return { canonicalSlugs, variantSlugs }; +} + +export function getGroupedProviderModelVariantSlugs( + models: ReadonlyArray, + provider: ProviderDriverKind | null | undefined, +): ReadonlySet { + return provider === AGY_DRIVER_KIND ? getGroupedAgyModelVariants(models).variantSlugs : new Set(); +} + +export function getVisibleProviderModels( + models: ReadonlyArray, + provider: ProviderDriverKind, +): ReadonlyArray { + if (provider !== AGY_DRIVER_KIND) return models; + + const { canonicalSlugs, variantSlugs } = getGroupedAgyModelVariants(models); + return models.filter((model) => !variantSlugs.has(model.slug) || canonicalSlugs.has(model.slug)); +} + +export function getBuiltInProviderModelSlugs( + models: ReadonlyArray, + provider: ProviderDriverKind, +): ReadonlySet { + const slugs = new Set(models.filter((model) => !model.isCustom).map((model) => model.slug)); + for (const slug of getGroupedProviderModelVariantSlugs(models, provider)) { + slugs.add(slug); + } + return slugs; +} export function formatProviderDriverKindLabel(provider: ProviderDriverKind): string { return provider diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4824258422fb..b3ae7953af57 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -52,6 +52,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("agy"), + label: "Antigravity", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = diff --git a/docs/user/install.md b/docs/user/install.md index 15f96e00d4f3..5e4cd65e7701 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -54,15 +54,16 @@ yay -S t3code-nightly-bin T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want to use, then authenticate it. -| Provider | CLI | Default binary | Log in with | -| ---------- | ----------------------------------------------------- | -------------- | --------------------- | -| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | -| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | -| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | -| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | -| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | - -Codex and Claude are on by default. Cursor, Grok Build, and OpenCode are off by default; turn +| Provider | CLI | Default binary | Log in with | +| ----------- | --------------------------------------------------------------- | -------------- | --------------------- | +| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | +| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | +| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | +| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | +| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | +| Antigravity | [Antigravity CLI](https://antigravity.google/docs/cli/install/) | `agy` | `agy` | + +Codex, Claude, Cursor, and Antigravity are on by default. Grok Build and OpenCode are off by default; turn them on in **Settings** → the provider's card when you want to use them. Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that @@ -86,6 +87,7 @@ authenticated shows its status in **Settings** and fails at session start with t to run. For multi-account setups, see [Codex](./providers-codex.md) and [Claude](./providers-claude.md). +For Antigravity setup and behavior, see [Antigravity](./providers-antigravity.md). ## Next Steps diff --git a/docs/user/providers-antigravity.md b/docs/user/providers-antigravity.md new file mode 100644 index 000000000000..82f5af4feda7 --- /dev/null +++ b/docs/user/providers-antigravity.md @@ -0,0 +1,50 @@ +# Antigravity + +T3 Code can run Google Antigravity CLI sessions through the `agy` harness. + +## Install And Sign In + +Install Antigravity CLI using the +[official installation guide](https://antigravity.google/docs/cli/install/), then launch it once on +the machine running the T3 Code server: + +```bash +agy +``` + +Complete the sign-in flow, then confirm the CLI can load its model catalog: + +```bash +agy models +``` + +T3 Code uses that command to check provider readiness and populate the model picker. + +## Configure T3 Code + +Open **Settings**, select **Antigravity**, and refresh the provider status. T3 Code finds `agy` on +the server's `PATH` by default. Set **Binary path** when the CLI is installed somewhere the server +cannot discover. + +Use **Launch arguments** only for additional Antigravity CLI flags that should apply to every turn. +T3 Code supplies the streaming format, model, reasoning effort, execution mode, continuation, and +permission flags itself. + +## Sessions And Models + +Antigravity sessions preserve the CLI conversation ID, so later turns and restored T3 Code threads +continue the same Antigravity conversation. Changing models between turns restarts the CLI process +with the same conversation ID and the newly selected model. + +The standard T3 Code Plan toggle maps to Antigravity's plan mode. Other turns use accept-edits mode. +Full-access sessions auto-approve Antigravity tool permissions; other T3 Code permission modes run +the CLI sandboxed. + +## Troubleshooting + +If Antigravity is unavailable in the model picker: + +1. Run `agy --version` on the T3 Code server. +2. Run `agy models` and complete sign-in if requested. +3. Check **Settings** → **Antigravity** → **Binary path**. +4. Refresh the provider status. diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd6..ce36e40955aa 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -132,6 +132,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const AGY_DRIVER_KIND = ProviderDriverKind.make("agy"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -153,6 +154,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", + [AGY_DRIVER_KIND]: "Antigravity", }; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 55023bcc48e7..2dd3ee8845cf 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -203,12 +203,14 @@ describe("provider enabled defaults", () => { expect(decoded.providers.cursor.enabled).toBe(true); expect(decoded.providers.grok.enabled).toBe(false); expect(decoded.providers.opencode.enabled).toBe(false); + expect(decoded.providers.agy.enabled).toBe(true); }); it("derives per-driver defaults from the settings schemas", () => { expect(defaultEnabledForDriver(ProviderDriverKind.make("codex"))).toBe(true); expect(defaultEnabledForDriver(ProviderDriverKind.make("cursor"))).toBe(true); expect(defaultEnabledForDriver(ProviderDriverKind.make("grok"))).toBe(false); + expect(defaultEnabledForDriver(ProviderDriverKind.make("agy"))).toBe(true); // Unknown fork drivers stay enabled; their own build decides otherwise. expect(defaultEnabledForDriver(ProviderDriverKind.make("ollama"))).toBe(true); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index ba4facaf53ce..70deb1154c7c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -530,6 +530,41 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +export const AgySettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("agy").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Antigravity (agy) binary used by this instance.", + providerSettingsForm: { placeholder: "agy", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + launchArgs: Schema.String.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Launch arguments", + description: "Additional CLI arguments passed on session start.", + providerSettingsForm: { + placeholder: "e.g. --agent reviewer", + clearWhenEmpty: "omit", + }, + }), + ), + }, + { + order: ["binaryPath", "launchArgs"], + }, +); +export type AgySettings = typeof AgySettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -672,6 +707,7 @@ export const ServerSettings = Schema.Struct({ cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + agy: AgySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values // are `ProviderInstanceConfig` envelopes. The driver-specific config blob From 666ca70211042e1ebf85485fa4cf682cd83905f8 Mon Sep 17 00:00:00 2001 From: carterwsmith Date: Sun, 23 Aug 2026 19:33:22 -0700 Subject: [PATCH 2/6] fix(web): address automated Macroscope review --- apps/mobile/src/components/ProviderIcon.tsx | 4 ++-- apps/server/src/provider/Drivers/AgyDriver.ts | 2 +- apps/server/src/provider/Layers/AgyAdapter.ts | 9 ++++++++ .../src/textGeneration/AgyTextGeneration.ts | 2 +- apps/web/src/components/Icons.tsx | 14 +++++++----- packages/contracts/src/settings.test.ts | 22 +++++++++++++++++++ packages/contracts/src/settings.ts | 8 +++++++ 7 files changed, 51 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index ddf2c9500265..a4f90531180b 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -14,9 +14,9 @@ export function ProviderIcon(props: ProviderIconProps) { if (props.provider === "agy") { return ( - + diff --git a/apps/server/src/provider/Drivers/AgyDriver.ts b/apps/server/src/provider/Drivers/AgyDriver.ts index 16f33d41e3e2..b60536bbe4ad 100644 --- a/apps/server/src/provider/Drivers/AgyDriver.ts +++ b/apps/server/src/provider/Drivers/AgyDriver.ts @@ -127,7 +127,7 @@ export const AgyDriver: ProviderDriver = { new ProviderDriverError({ driver: DRIVER_KIND, instanceId, - detail: `Failed to build Antigravity snapshot: ${cause.message ?? String(cause)}`, + detail: "Failed to build the Antigravity provider snapshot.", cause, }), ), diff --git a/apps/server/src/provider/Layers/AgyAdapter.ts b/apps/server/src/provider/Layers/AgyAdapter.ts index e35065451364..91b0bd54fd30 100644 --- a/apps/server/src/provider/Layers/AgyAdapter.ts +++ b/apps/server/src/provider/Layers/AgyAdapter.ts @@ -870,6 +870,15 @@ export const makeAgyAdapter = Effect.fn("makeAgyAdapter")(function* ( Effect.timeoutOption(AGY_INIT_TIMEOUT_MS), ); if (Option.isNone(initialized)) { + yield* stopChildProcess(context); + context.activeTurnId = undefined; + context.currentTurnState = undefined; + context.session = { + ...context.session, + status: "ready", + activeTurnId: undefined, + updatedAt: yield* nowIso, + }; return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "sendTurn", diff --git a/apps/server/src/textGeneration/AgyTextGeneration.ts b/apps/server/src/textGeneration/AgyTextGeneration.ts index 970dc08eebfa..12836d521549 100644 --- a/apps/server/src/textGeneration/AgyTextGeneration.ts +++ b/apps/server/src/textGeneration/AgyTextGeneration.ts @@ -198,7 +198,7 @@ export const makeAgyTextGeneration = Effect.fn("makeAgyTextGeneration")(function (cause) => new TextGenerationError({ operation, - detail: `Failed to parse Antigravity output envelope: ${result.stdout}`, + detail: "Antigravity CLI returned unexpected output format.", cause, }), ), diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index e23658718f5e..a3ff3b48f6ab 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -641,12 +641,14 @@ export const Gemini: Icon = (props) => ( ); -export const AntigravityIcon: Icon = (props) => ( - - +export const AntigravityIcon: Icon = ({ className, ...props }) => ( + + ); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 2dd3ee8845cf..35dd484951aa 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -307,6 +307,28 @@ describe("ServerSettingsPatch.providerInstances", () => { }); }); +describe("ServerSettingsPatch.providers", () => { + it("preserves Antigravity provider updates", () => { + const patch = decodeServerSettingsPatch({ + providers: { + agy: { + enabled: false, + binaryPath: " /usr/local/bin/agy ", + customModels: ["gemini-custom"], + launchArgs: " --agent reviewer ", + }, + }, + }); + + expect(patch.providers?.agy).toEqual({ + enabled: false, + binaryPath: "/usr/local/bin/agy", + customModels: ["gemini-custom"], + launchArgs: "--agent reviewer", + }); + }); +}); + describe("ServerSettingsPatch string normalization", () => { it("trims string settings while decoding patches", () => { const patch = decodeServerSettingsPatch({ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 70deb1154c7c..57f5ebfb1376 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -855,6 +855,13 @@ const OpenCodeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const AgySettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), + launchArgs: Schema.optionalKey(TrimmedString), +}); + export const ServerSettingsPatch = Schema.Struct({ // Server settings enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), @@ -896,6 +903,7 @@ export const ServerSettingsPatch = Schema.Struct({ cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), + agy: Schema.optionalKey(AgySettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual From 7140ac6401936d943eac48077d4ad97b3fec49ec Mon Sep 17 00:00:00 2001 From: carterwsmith Date: Sun, 23 Aug 2026 19:38:01 -0700 Subject: [PATCH 3/6] fix(web): address automated Bugbot review --- .../src/provider/Layers/AgyAdapter.test.ts | 55 +++++++++++++++++-- apps/server/src/provider/Layers/AgyAdapter.ts | 13 +++++ 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Layers/AgyAdapter.test.ts b/apps/server/src/provider/Layers/AgyAdapter.test.ts index 7184b05e4bf6..403f910582b6 100644 --- a/apps/server/src/provider/Layers/AgyAdapter.test.ts +++ b/apps/server/src/provider/Layers/AgyAdapter.test.ts @@ -11,6 +11,7 @@ import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/TestClock"; import { AgySettings, @@ -25,7 +26,7 @@ import { makeAgyAdapter } from "./AgyAdapter.ts"; const decodeAgySettings = Schema.decodeSync(AgySettings); -async function makeMockAgyWrapper() { +async function makeMockAgyWrapper(options?: { readonly emitInit?: boolean }) { const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "agy-mock-")); const wrapperPath = NodePath.join(dir, "fake-agy.mjs"); const script = ` @@ -34,11 +35,13 @@ import readline from "node:readline"; const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false }); console.log("malformed native event"); -console.log(JSON.stringify({ - event: "init", - conversation_id: "conv-12345", - init: { cwd: process.cwd(), tools: ["run_command", "write_to_file"] } -})); +if (${JSON.stringify(options?.emitInit !== false)}) { + console.log(JSON.stringify({ + event: "init", + conversation_id: "conv-12345", + init: { cwd: process.cwd(), tools: ["run_command", "write_to_file"] } + })); +} rl.on("line", (line) => { if (!line.trim()) return; @@ -274,6 +277,46 @@ describe("AgyAdapter", () => { }), ); + it.effect("closes the turn and restores the session when initialization times out", () => + Effect.gen(function* () { + const mock = yield* Effect.promise(() => makeMockAgyWrapper({ emitInit: false })); + const adapter = yield* makeAgyAdapter(decodeAgySettings({ binaryPath: mock.binaryPath })); + const threadId = ThreadId.make("thread-init-timeout"); + const receivedEvents: Array = []; + const eventFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.sync(() => receivedEvents.push(event)), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "full-access", + }); + const sendFiber = yield* adapter + .sendTurn({ threadId, input: "Wait for initialization" }) + .pipe(Effect.flip, Effect.forkChild); + yield* TestClock.adjust("10 seconds"); + const error = yield* Fiber.join(sendFiber); + + expect(error.message).toContain("did not initialize"); + expect((yield* adapter.listSessions())[0]?.status).toBe("ready"); + expect( + receivedEvents.some( + (event) => event.type === "turn.completed" && event.payload.state === "failed", + ), + ).toBe(true); + expect( + receivedEvents.some( + (event) => event.type === "session.state.changed" && event.payload.state === "ready", + ), + ).toBe(true); + + yield* adapter.stopSession(threadId); + yield* Fiber.interrupt(eventFiber); + yield* Effect.promise(() => NodeFSP.rm(mock.dir, { recursive: true, force: true })); + }), + ); + it.effect("interrupts an active turn and keeps the session ready", () => Effect.gen(function* () { const mock = yield* Effect.promise(() => makeMockAgyWrapper()); diff --git a/apps/server/src/provider/Layers/AgyAdapter.ts b/apps/server/src/provider/Layers/AgyAdapter.ts index 91b0bd54fd30..38ffabe7d08c 100644 --- a/apps/server/src/provider/Layers/AgyAdapter.ts +++ b/apps/server/src/provider/Layers/AgyAdapter.ts @@ -879,6 +879,19 @@ export const makeAgyAdapter = Effect.fn("makeAgyAdapter")(function* ( activeTurnId: undefined, updatedAt: yield* nowIso, }; + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId, turnId })), + type: "turn.completed", + payload: { + state: "failed", + errorMessage: "Antigravity CLI did not initialize a conversation.", + }, + }); + yield* emit({ + ...(yield* buildEventBase({ threadId: input.threadId })), + type: "session.state.changed", + payload: { state: "ready" }, + }); return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "sendTurn", From 9263e9d7fe53d2bc17484850ca8fbbad94f5d629 Mon Sep 17 00:00:00 2001 From: carterwsmith Date: Sun, 23 Aug 2026 19:42:15 -0700 Subject: [PATCH 4/6] fix(web): Macroscope turn --- .../src/provider/Layers/AgyAdapter.test.ts | 22 +++++++++++++++++-- apps/server/src/provider/Layers/AgyAdapter.ts | 3 ++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/AgyAdapter.test.ts b/apps/server/src/provider/Layers/AgyAdapter.test.ts index 403f910582b6..d1d937b6f5c4 100644 --- a/apps/server/src/provider/Layers/AgyAdapter.test.ts +++ b/apps/server/src/provider/Layers/AgyAdapter.test.ts @@ -11,7 +11,7 @@ import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/TestClock"; +import * as TestClock from "effect/testing/TestClock"; import { AgySettings, @@ -230,9 +230,18 @@ describe("AgyAdapter", () => { yield* Queue.take(turnCompletions); expect(switchedResult.turnId).not.toBe(secondResult.turnId); + yield* adapter.sendTurn({ + threadId, + input: "Use the default model", + interactionMode: "plan", + modelSelection: { instanceId: ProviderInstanceId.make("agy"), model: "" }, + }); + yield* Queue.take(turnCompletions); + expect((yield* adapter.listSessions())[0]?.model).toBeUndefined(); + const snapshot = yield* adapter.readThread(threadId); expect(snapshot.threadId).toBe(threadId); - expect(snapshot.turns).toHaveLength(3); + expect(snapshot.turns).toHaveLength(4); expect(snapshot.turns.every((turn) => turn.items.length > 0)).toBe(true); const rollbackError = yield* adapter.rollbackThread(threadId, 1).pipe(Effect.flip); expect(rollbackError.message).toContain("do not support provider-side rollback"); @@ -339,6 +348,15 @@ describe("AgyAdapter", () => { expect((yield* Queue.take(completions)).payload.state).toBe("interrupted"); expect((yield* adapter.listSessions())[0]?.status).toBe("ready"); + const activeTurn = yield* adapter.sendTurn({ threadId, input: "hang" }); + yield* adapter.interruptTurn(threadId, turn.turnId); + expect((yield* adapter.listSessions())[0]?.status).toBe("running"); + expect((yield* adapter.listSessions())[0]?.activeTurnId).toBe(activeTurn.turnId); + + yield* adapter.interruptTurn(threadId, activeTurn.turnId); + expect((yield* Queue.take(completions)).payload.state).toBe("interrupted"); + expect((yield* adapter.listSessions())[0]?.status).toBe("ready"); + yield* adapter.stopSession(threadId); yield* Fiber.interrupt(eventFiber); yield* Effect.promise(() => NodeFSP.rm(mock.dir, { recursive: true, force: true })); diff --git a/apps/server/src/provider/Layers/AgyAdapter.ts b/apps/server/src/provider/Layers/AgyAdapter.ts index 38ffabe7d08c..f544f32bd841 100644 --- a/apps/server/src/provider/Layers/AgyAdapter.ts +++ b/apps/server/src/provider/Layers/AgyAdapter.ts @@ -817,7 +817,7 @@ export const makeAgyAdapter = Effect.fn("makeAgyAdapter")(function* ( status: "running", activeTurnId: turnId, updatedAt: yield* nowIso, - ...(modelSelection?.model ? { model: modelSelection.model } : {}), + ...(modelSelection?.model ? { model: modelSelection.model } : { model: undefined }), }; yield* emit({ @@ -914,6 +914,7 @@ export const makeAgyAdapter = Effect.fn("makeAgyAdapter")(function* ( ) { const context = yield* ensureSessionContext(threadId); const activeTurnId = turnId ?? context.activeTurnId; + if (turnId !== undefined && turnId !== context.activeTurnId) return; yield* stopChildProcess(context); From 06c01f06a32121f6df2aeddc7462355c18ded3dd Mon Sep 17 00:00:00 2001 From: carterwsmith Date: Sun, 23 Aug 2026 19:46:41 -0700 Subject: [PATCH 5/6] fix(web): Bugbot turn --- .../src/provider/Layers/AgyAdapter.test.ts | 21 ++++--------------- apps/server/src/provider/Layers/AgyAdapter.ts | 13 ------------ 2 files changed, 4 insertions(+), 30 deletions(-) diff --git a/apps/server/src/provider/Layers/AgyAdapter.test.ts b/apps/server/src/provider/Layers/AgyAdapter.test.ts index d1d937b6f5c4..88c1c60ebf34 100644 --- a/apps/server/src/provider/Layers/AgyAdapter.test.ts +++ b/apps/server/src/provider/Layers/AgyAdapter.test.ts @@ -286,15 +286,11 @@ describe("AgyAdapter", () => { }), ); - it.effect("closes the turn and restores the session when initialization times out", () => + it.effect("cleans local session state when initialization times out", () => Effect.gen(function* () { const mock = yield* Effect.promise(() => makeMockAgyWrapper({ emitInit: false })); const adapter = yield* makeAgyAdapter(decodeAgySettings({ binaryPath: mock.binaryPath })); const threadId = ThreadId.make("thread-init-timeout"); - const receivedEvents: Array = []; - const eventFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.sync(() => receivedEvents.push(event)), - ).pipe(Effect.forkChild); yield* adapter.startSession({ threadId, @@ -308,20 +304,11 @@ describe("AgyAdapter", () => { const error = yield* Fiber.join(sendFiber); expect(error.message).toContain("did not initialize"); - expect((yield* adapter.listSessions())[0]?.status).toBe("ready"); - expect( - receivedEvents.some( - (event) => event.type === "turn.completed" && event.payload.state === "failed", - ), - ).toBe(true); - expect( - receivedEvents.some( - (event) => event.type === "session.state.changed" && event.payload.state === "ready", - ), - ).toBe(true); + const session = (yield* adapter.listSessions())[0]; + expect(session?.status).toBe("ready"); + expect(session?.activeTurnId).toBeUndefined(); yield* adapter.stopSession(threadId); - yield* Fiber.interrupt(eventFiber); yield* Effect.promise(() => NodeFSP.rm(mock.dir, { recursive: true, force: true })); }), ); diff --git a/apps/server/src/provider/Layers/AgyAdapter.ts b/apps/server/src/provider/Layers/AgyAdapter.ts index f544f32bd841..09e59f8684fb 100644 --- a/apps/server/src/provider/Layers/AgyAdapter.ts +++ b/apps/server/src/provider/Layers/AgyAdapter.ts @@ -879,19 +879,6 @@ export const makeAgyAdapter = Effect.fn("makeAgyAdapter")(function* ( activeTurnId: undefined, updatedAt: yield* nowIso, }; - yield* emit({ - ...(yield* buildEventBase({ threadId: input.threadId, turnId })), - type: "turn.completed", - payload: { - state: "failed", - errorMessage: "Antigravity CLI did not initialize a conversation.", - }, - }); - yield* emit({ - ...(yield* buildEventBase({ threadId: input.threadId })), - type: "session.state.changed", - payload: { state: "ready" }, - }); return yield* new ProviderAdapterRequestError({ provider: PROVIDER, method: "sendTurn", From bb31d62690015bab61a8bd166f44ca026a95b5ee Mon Sep 17 00:00:00 2001 From: carterwsmith Date: Sun, 23 Aug 2026 19:55:15 -0700 Subject: [PATCH 6/6] fix(web): Macroscope turn --- .../src/provider/Layers/AgyAdapter.test.ts | 1 + apps/server/src/provider/Layers/AgyAdapter.ts | 7 ++----- .../settings/ProviderInstanceCard.tsx | 5 +++++ .../settings/ProviderModelsSection.tsx | 8 +++++++- apps/web/src/providerInstances.test.ts | 4 ++++ apps/web/src/providerInstances.ts | 17 +++++++++++++---- 6 files changed, 32 insertions(+), 10 deletions(-) diff --git a/apps/server/src/provider/Layers/AgyAdapter.test.ts b/apps/server/src/provider/Layers/AgyAdapter.test.ts index 88c1c60ebf34..e9b51f59e21b 100644 --- a/apps/server/src/provider/Layers/AgyAdapter.test.ts +++ b/apps/server/src/provider/Layers/AgyAdapter.test.ts @@ -296,6 +296,7 @@ describe("AgyAdapter", () => { threadId, cwd: process.cwd(), runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, conversationId: "conv-resumed" }, }); const sendFiber = yield* adapter .sendTurn({ threadId, input: "Wait for initialization" }) diff --git a/apps/server/src/provider/Layers/AgyAdapter.ts b/apps/server/src/provider/Layers/AgyAdapter.ts index 09e59f8684fb..50df9dfa429c 100644 --- a/apps/server/src/provider/Layers/AgyAdapter.ts +++ b/apps/server/src/provider/Layers/AgyAdapter.ts @@ -138,7 +138,7 @@ interface AgySessionContext { session: ProviderSession; cwd: string; conversationId: string | undefined; - readonly conversationReady: Deferred.Deferred; + conversationReady: Deferred.Deferred; activeTurnId: TurnId | undefined; currentTurnState: AgyTurnState | undefined; childProcess: ChildProcessSpawnerTypes.ChildProcessHandle | undefined; @@ -514,6 +514,7 @@ export const makeAgyAdapter = Effect.fn("makeAgyAdapter")(function* ( ) => Effect.gen(function* () { yield* stopChildProcess(context); + context.conversationReady = yield* Deferred.make(); const commandName = agySettings.binaryPath || "agy"; const effort = modelSelection @@ -691,10 +692,6 @@ export const makeAgyAdapter = Effect.fn("makeAgyAdapter")(function* ( const conversationId = resumeInfo?.conversationId; const modelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; - if (conversationId !== undefined) { - yield* Deferred.succeed(conversationReady, conversationId); - } - const session: ProviderSession = { threadId: input.threadId, provider: PROVIDER, diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 3db64bddcbde..16a41ba534a6 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -465,6 +465,10 @@ export function ProviderInstanceCard({ customModels, driverKind, }); + const groupedVariantSlugs = getGroupedProviderModelVariantSlugs( + liveProvider?.models ?? [], + driverKind, + ); const updateDisplayName = (value: string) => { const trimmed = value.trim(); @@ -792,6 +796,7 @@ export function ProviderInstanceCard({ instanceId={instanceId} driverKind={driverKind} models={modelsForDisplay} + groupedVariantSlugs={groupedVariantSlugs} customModels={customModels} hiddenModels={hiddenModels} favoriteModels={favoriteModels} diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 9a42961d13ee..8d6b52edf3b5 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -50,6 +50,8 @@ interface ProviderModelsSectionProps { * and custom entries, distinguished by `isCustom`. */ readonly models: ReadonlyArray; + /** Built-in effort variants represented by a grouped model row. */ + readonly groupedVariantSlugs: ReadonlySet; /** * The persisted custom-model slug list for this instance. Drives dedup, * and is the array we hand back verbatim (with the new slug appended / @@ -88,6 +90,7 @@ export function ProviderModelsSection({ instanceId, driverKind, models, + groupedVariantSlugs, customModels, hiddenModels, favoriteModels, @@ -116,7 +119,10 @@ export function ProviderModelsSection({ setError("Enter a model slug."); return; } - if (models.some((model) => !model.isCustom && model.slug === normalized)) { + if ( + groupedVariantSlugs.has(normalized) || + models.some((model) => !model.isCustom && model.slug === normalized) + ) { setError("That model is already built in."); return; } diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index d47c4311a4ea..3bf630e439ee 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -143,11 +143,15 @@ describe("sortProviderInstanceEntries", () => { provider({ provider: ProviderDriverKind.make("codex"), instanceId: "codex" }), provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent" }), provider({ provider: ProviderDriverKind.make("opencode"), instanceId: "opencode" }), + provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok" }), + provider({ provider: ProviderDriverKind.make("cursor"), instanceId: "cursor" }), ]); expect(sortProviderInstanceEntries(entries).map((entry) => entry.driverKind)).toEqual([ "codex", "claudeAgent", + "cursor", + "grok", "opencode", "agy", ]); diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 91d4d7cf9d12..6cbb6ad56257 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -18,7 +18,7 @@ import { PROVIDER_DISPLAY_NAMES, resolveProviderInstanceEnabled, type ModelSelection, - type ProviderDriverKind, + ProviderDriverKind, ProviderInstanceId, type ServerProvider, type ServerProviderModel, @@ -28,6 +28,15 @@ import { import { formatProviderDriverKindLabel } from "./providerModels"; +const BUILT_IN_PROVIDER_ORDER: ReadonlyArray = [ + "codex", + "claudeAgent", + "cursor", + "grok", + "opencode", + "agy", +].map((driverKind) => ProviderDriverKind.make(driverKind)); + /** * Local-only placeholder used while a draft has no provider it can safely * target. It must never be persisted or dispatched; the composer disables @@ -260,8 +269,8 @@ export function applyProviderInstanceSettings( * Sort instance entries so the default instance of each driver kind appears * before any custom instances of the same kind. Within a kind, custom * instances keep their settings-author order (which is how the server - * emits them). Stable across kinds: entries retain the server's - * cross-driver ordering. + * emits them). Built-in kinds follow the explicit provider display order; + * unknown fork kinds retain their first-seen server order afterward. */ export function sortProviderInstanceEntries( entries: ReadonlyArray, @@ -280,7 +289,7 @@ export function sortProviderInstanceEntries( } } const driverOrder = new Map( - Object.keys(PROVIDER_DISPLAY_NAMES).map((driverKind, index) => [driverKind, index] as const), + BUILT_IN_PROVIDER_ORDER.map((driverKind, index) => [driverKind, index] as const), ); const firstSeenOrder = new Map( [...byKind.keys()].map((driverKind, index) => [driverKind, index] as const),