From df9a3f1ca83b87f24a7f6e23f2db6655d3a32698 Mon Sep 17 00:00:00 2001 From: Agent <191493048+agentool@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:46:26 -0400 Subject: [PATCH 1/9] Add Antigravity (agy) provider via an ACP compatibility bridge Antigravity ships no native agent protocol: `agy agent stdio` is not a thing, and the bundled `language_server` speaks Codeium gRPC rather than ACP. So this adds a bridge that presents the ACP surface `AcpSessionRuntime` needs and runs each turn through the documented non-interactive `agy --print`. The bridge ships as hidden subcommands of the server binary (`t3 agy-acp`, `t3 agy-hook`) so it always versions with the server and needs no separate artifact. A live event stream is reconstructed from two documented Antigravity sources that correlate on step index (a hook's `stepIdx` is the transcript record's `step_index`): - Hooks (PreToolUse/PostToolUse/Stop) supply tool name, arguments and completion status. They are registered through a throwaway `--add-dir` workspace, so nothing is written into the user's repository. Both the session cwd and that hook workspace are passed, because `--add-dir` replaces the workspace rather than extending cwd -- passing only the hook dir hides the project from the agent. - The trajectory transcript supplies assistant text and real tool output as the turn runs. Its format is undocumented, so every parse path degrades to emitting nothing rather than failing a turn. File contents for an edit diff are captured inside the hook process. The bridge cannot read them when it drains hook output: both hooks for a fast edit land within a single poll interval, by which point "before" would read back as "after". Any `agy` subcommand spawned for a probe sets `stdin: "ignore"`. `agy` starts a language server and emits nothing while stdin stays open, so the default "pipe" hangs it and model discovery returns empty. Known constraints, all inherent to print mode and documented in AGENTS.md: tools auto-approve (`--dangerously-skip-permissions`) so no approval flow is possible; no image attachments; no session modes; the model binds at spawn, so `sessionModelSwitch` is "unsupported". The three ProviderRegistry fixtures assert an exact provider list or that only the subject provider probes, so they gain the new driver. --- AGENTS.md | 25 + apps/server/src/bin.ts | 3 + apps/server/src/cli/agy.ts | 31 + .../src/provider/Drivers/AntigravityDriver.ts | 177 +++++ .../src/provider/Layers/AntigravityAdapter.ts | 690 ++++++++++++++++++ .../provider/Layers/AntigravityProvider.ts | 328 +++++++++ .../provider/Layers/ProviderRegistry.test.ts | 4 + .../provider/Services/AntigravityAdapter.ts | 16 + .../acp/AntigravityAcpSupport.test.ts | 102 +++ .../src/provider/acp/AntigravityAcpSupport.ts | 143 ++++ .../src/provider/acp/antigravity/agyBridge.ts | 646 ++++++++++++++++ .../acp/antigravity/agyEvents.test.ts | 217 ++++++ .../src/provider/acp/antigravity/agyEvents.ts | 359 +++++++++ .../acp/antigravity/agyTranscript.test.ts | 154 ++++ .../provider/acp/antigravity/agyTranscript.ts | 187 +++++ apps/server/src/provider/builtInDrivers.ts | 5 +- .../AntigravityTextGeneration.ts | 254 +++++++ .../src/components/chat/providerIconUtils.ts | 11 +- .../components/settings/providerDriverMeta.ts | 18 +- apps/web/src/session-logic.ts | 6 + packages/contracts/src/model.ts | 14 + packages/contracts/src/settings.ts | 55 ++ 22 files changed, 3442 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/cli/agy.ts create mode 100644 apps/server/src/provider/Drivers/AntigravityDriver.ts create mode 100644 apps/server/src/provider/Layers/AntigravityAdapter.ts create mode 100644 apps/server/src/provider/Layers/AntigravityProvider.ts create mode 100644 apps/server/src/provider/Services/AntigravityAdapter.ts create mode 100644 apps/server/src/provider/acp/AntigravityAcpSupport.test.ts create mode 100644 apps/server/src/provider/acp/AntigravityAcpSupport.ts create mode 100644 apps/server/src/provider/acp/antigravity/agyBridge.ts create mode 100644 apps/server/src/provider/acp/antigravity/agyEvents.test.ts create mode 100644 apps/server/src/provider/acp/antigravity/agyEvents.ts create mode 100644 apps/server/src/provider/acp/antigravity/agyTranscript.test.ts create mode 100644 apps/server/src/provider/acp/antigravity/agyTranscript.ts create mode 100644 apps/server/src/textGeneration/AntigravityTextGeneration.ts diff --git a/AGENTS.md b/AGENTS.md index ef69591a340..6a40825f5d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,31 @@ - `packages/shared`: Shared runtime utilities consumed by both server and client applications. Uses explicit subpath exports (e.g. `@t3tools/shared/git`) — no barrel index. - `packages/client-runtime`: Shared runtime package for sharing client code across web and mobile. +## Antigravity provider + +The `antigravity` driver wraps the Antigravity CLI (`agy`), which has **no native agent +protocol**. `apps/server/src/provider/acp/antigravity/agyBridge.ts` presents the ACP surface +`AcpSessionRuntime` needs and runs each turn through `agy --print`, shipping as hidden +subcommands of the server binary (`t3 agy-acp`, `t3 agy-hook`). + +A live event stream is reconstructed from two documented Antigravity sources that correlate on +step index (`stepIdx` in a hook == `step_index` in the transcript): + +- **Hooks** (`PreToolUse`/`PostToolUse`/`Stop`) give tool name, arguments, and status. The bridge + registers them via a throwaway `--add-dir` workspace so nothing is written into the user's repo. + Both the session cwd and that hook workspace must be passed — `--add-dir` replaces the workspace + rather than adding to cwd, so passing only the hook dir hides the project from the agent. +- **The trajectory transcript** (`transcript.jsonl`) gives assistant text and real tool output as + the turn runs. Its record format is undocumented, so parsing degrades to emitting nothing. + +Known constraints, all inherent to print mode: + +- Tools are auto-approved (`--dangerously-skip-permissions`); no approval flow is possible. +- No image attachments, and no session modes. +- The model binds when the bridge spawns `agy`, so `sessionModelSwitch` is `"unsupported"`. +- Any `agy` subcommand spawned for a probe must set `stdin: "ignore"`. `agy` starts a language + server and will not emit output while stdin stays open, so the default `"pipe"` hangs it. + ## Reference Repos - Open-source Codex repo: https://github.com/openai/codex diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index a7767b50a15..3d08d53c963 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -7,6 +7,7 @@ import * as CliError from "effect/unstable/cli/CliError"; import * as NetService from "@t3tools/shared/Net"; import packageJson from "../package.json" with { type: "json" }; +import { agyAcpCommand, agyHookCommand } from "./cli/agy.ts"; import { authCommand } from "./cli/auth.ts"; import { connectCommand } from "./cli/connect.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; @@ -49,6 +50,8 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => authCommand, projectCommand, serviceCommand, + agyAcpCommand, + agyHookCommand, cloudEnabled ? connectCommand : connectUnavailableCommand, ]), ); diff --git a/apps/server/src/cli/agy.ts b/apps/server/src/cli/agy.ts new file mode 100644 index 00000000000..78f10196eba --- /dev/null +++ b/apps/server/src/cli/agy.ts @@ -0,0 +1,31 @@ +/** + * Hidden CLI entry points for the Antigravity ACP bridge. + * + * Both are internal plumbing rather than user-facing commands: the provider + * spawns `t3 agy-acp` as an ACP agent, and that bridge registers `t3 agy-hook + * ` as Antigravity's tool-lifecycle hook. Shipping them as subcommands + * of the server binary keeps the bridge inside the same bundle. + * + * @module cli/agy + */ +import * as Effect from "effect/Effect"; +import { Command, Flag } from "effect/unstable/cli"; + +import { runAgyBridge, runAgyHook } from "../provider/acp/antigravity/agyBridge.ts"; + +export const agyAcpCommand = Command.make("agy-acp").pipe( + Command.withDescription("Run the Antigravity ACP compatibility bridge over stdio."), + Command.withHidden, + Command.withHandler(() => Effect.promise(() => runAgyBridge())), +); + +export const agyHookCommand = Command.make("agy-hook", { + event: Flag.string("event").pipe( + Flag.withDescription("Antigravity hook event name."), + Flag.withDefault("unknown"), + ), +}).pipe( + Command.withDescription("Handle one Antigravity tool-lifecycle hook event."), + Command.withHidden, + Command.withHandler(({ event }) => Effect.promise(() => runAgyHook(event))), +); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts new file mode 100644 index 00000000000..ddc8a7c8c90 --- /dev/null +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -0,0 +1,177 @@ +/** + * AntigravityDriver — built-in driver for the Antigravity CLI (`agy`). + * + * Sessions run through T3 Code's bundled ACP bridge rather than a native agent + * protocol; see `provider/acp/antigravity/agyBridge.ts`. + * + * @module Drivers/AntigravityDriver + */ +import { AntigravitySettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +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 { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeAntigravityTextGeneration } from "../../textGeneration/AntigravityTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeAntigravityAdapter } from "../Layers/AntigravityAdapter.ts"; +import { + buildInitialAntigravityProviderSnapshot, + checkAntigravityProviderStatus, + enrichAntigravitySnapshot, +} from "../Layers/AntigravityProvider.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 decodeAntigravitySettings = Schema.decodeSync(AntigravitySettings); + +const DRIVER_KIND = ProviderDriverKind.make("antigravity"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +// Antigravity ships its own installer and updater (`agy update`), so T3 Code +// never manages the binary itself. +const UPDATE = makeStaticProviderMaintenanceResolver( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), +); + +export type AntigravityDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | 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 AntigravityDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Antigravity", + supportsMultipleInstances: true, + }, + configSchema: AntigravitySettings, + defaultConfig: (): AntigravitySettings => decodeAntigravitySettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + 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 AntigravitySettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeAntigravityAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeAntigravityTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkAntigravityProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider< + ProviderSnapshotSettings + >({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialAntigravityProviderSnapshot(settings.provider).pipe( + Effect.map(stampIdentity), + ), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichAntigravitySnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).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/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts new file mode 100644 index 00000000000..7ea4237c647 --- /dev/null +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -0,0 +1,690 @@ +/** + * AntigravityAdapterLive — Antigravity CLI (`agy`) via the bundled ACP bridge. + * + * Antigravity has no native agent protocol, so the ACP peer this adapter talks + * to is T3 Code's own bridge (`t3 agy-acp`). That shapes three deliberate + * differences from the CLI-native ACP adapters: + * + * - **No permission requests.** The bridge drives `agy` with + * `--dangerously-skip-permissions` because print mode cannot prompt, so + * tools are auto-approved and no approval flow can be surfaced. + * - **No session modes.** Print mode has no plan/ask distinction to switch. + * - **Model is fixed per session.** The bridge passes `--model` when it + * spawns `agy`, so changing models requires a new session + * (`sessionModelSwitch: "unsupported"`). + * + * @module AntigravityAdapterLive + */ + +import { + type AntigravitySettings, + EventId, + type ProviderRuntimeEvent, + type ProviderSession, + ProviderDriverKind, + ProviderInstanceId, + type ThreadId, + TurnId, +} 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 Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { + makeAntigravityAcpRuntime, + resolveAntigravityBaseModelId, +} from "../acp/AntigravityAcpSupport.ts"; +import { type AntigravityAdapterShape } from "../Services/AntigravityAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = ProviderDriverKind.make("antigravity"); +const ANTIGRAVITY_RESUME_VERSION = 1 as const; + +export interface AntigravityAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + /** + * Selections are honored when `modelSelection.instanceId` matches this value. + * Defaults to the legacy built-in instance id (`antigravity`). + */ + readonly instanceId?: ProviderInstanceId; + /** + * Optional per-session settings resolver, used by tests that mutate + * `ServerSettingsService` mid-flight. Production leaves this undefined and + * relies on the hydration layer rebuilding the adapter on config change. + */ + readonly resolveSettings?: Effect.Effect; +} + +interface AntigravitySessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + notificationFiber: Fiber.Fiber | undefined; + readonly turns: Array<{ id: TurnId; items: Array }>; + activeTurnId: TurnId | undefined; + /** + * Number of prompts in flight. >0 means a turn is running, so a new + * sendTurn steers the existing turn rather than opening a new one. + */ + promptsInFlight: number; + stopped: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseAntigravityResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== ANTIGRAVITY_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +/** + * Reasoning effort for `agy --effort`, read from the model option selections. + * Antigravity accepts only low/medium/high. + */ +function resolveEffortSelection( + options: + | ReadonlyArray<{ readonly id: string; readonly value: string | boolean }> + | null + | undefined, +): string | undefined { + // Option values are a string/boolean union across providers; only a string + // effort is meaningful here. + const raw = options?.find((option) => option.id === "effort")?.value; + const effort = typeof raw === "string" ? raw.trim().toLowerCase() : undefined; + return effort === "low" || effort === "medium" || effort === "high" ? effort : undefined; +} + +export function makeAntigravityAdapter( + antigravitySettings: AntigravitySettings, + options?: AntigravityAdapterLiveOptions, +) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("antigravity"); + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate Antigravity runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const logNative = (threadId: ThreadId, method: string, payload: unknown) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* randomUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: AntigravitySessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: AntigravityAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const modelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + let ctx!: AntigravitySessionContext; + + const resumeSessionId = parseAntigravityResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + const effectiveSettings = options?.resolveSettings + ? yield* options.resolveSettings + : antigravitySettings; + + // The model and effort are bound when the bridge spawns `agy`, so + // they must be resolved before the runtime is constructed rather + // than applied afterwards via session config. + const boundModel = resolveAntigravityBaseModelId(modelSelection?.model); + const boundEffort = resolveEffortSelection(modelSelection?.options); + + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const acp = yield* makeAntigravityAcpRuntime({ + antigravitySettings: effectiveSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + model: boundModel, + ...(boundEffort ? { effort: boundEffort } : {}), + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [{ name: "Authorization", value: mcpSession.authorizationHeader }], + }, + ], + } + : {}), + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + + const started = yield* acp + .start() + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model: boundModel, + threadId: input.threadId, + resumeCursor: { + schemaVersion: ANTIGRAVITY_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + ctx = { + threadId: input.threadId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + turns: [], + activeTurnId: undefined, + promptsInFlight: 0, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + switch (event._tag) { + case "EventStreamBarrier": + yield* Deferred.succeed(event.acknowledge, undefined); + return; + // The bridge never changes modes; print mode has none. + case "ModeChanged": + return; + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload: event.payload, + source: "acp.jsonrpc", + method: "session/update", + rawPayload: event.rawPayload, + }), + ); + return; + case "ToolCallUpdated": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process Antigravity runtime notification.", { cause }), + ), + Effect.forkChild, + ); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Antigravity ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: AntigravityAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + // A sendTurn while a prompt is in flight is a steer: the new prompt + // folds into the ongoing work, so the active turn id is reused. + const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + ctx.promptsInFlight += 1; + + return yield* Effect.gen(function* () { + ctx.activeTurnId = turnId; + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model: ctx.session.model }, + }); + } + + // The bridge declares no image capability: `agy --print` takes text + // only, so attachments cannot be forwarded. + const promptParts: Array = []; + if (input.input?.trim()) { + promptParts.push({ type: "text", text: input.input.trim() }); + } + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: + "Turn requires non-empty text. Antigravity print mode does not accept attachments.", + }); + } + + const result = yield* ctx.acp + .prompt({ prompt: promptParts }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + const turnRecord = ctx.turns.find((turn) => turn.id === turnId); + if (turnRecord) { + turnRecord.items.push({ prompt: promptParts, result }); + } else { + ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + // Only the last remaining prompt settles the turn, so a steer- + // superseded prompt resolving does not end the merged turn. + if (ctx.promptsInFlight === 1) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason ?? null, + }, + }); + } + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }).pipe( + Effect.ensuring( + Effect.sync(() => { + ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); + }), + ), + ); + }); + + const interruptTurn: AntigravityAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + }); + + // Antigravity runs every turn with `--dangerously-skip-permissions`, so + // the bridge never opens an approval or question request. Reaching either + // responder means the caller is tracking a request this provider cannot + // have produced. + const respondToRequest: AntigravityAdapterShape["respondToRequest"] = (threadId, requestId) => + Effect.gen(function* () { + yield* requireSession(threadId); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Antigravity auto-approves tools and never opens approvals; unknown request: ${requestId}`, + }); + }); + + const respondToUserInput: AntigravityAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + ) => + Effect.gen(function* () { + yield* requireSession(threadId); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/user_input", + detail: `Antigravity print mode cannot ask questions; unknown request: ${requestId}`, + }); + }); + + const readThread: AntigravityAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: AntigravityAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + const nextLength = Math.max(0, ctx.turns.length - numTurns); + ctx.turns.splice(nextLength); + return { threadId, turns: ctx.turns }; + }); + + const stopSession: AntigravityAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: AntigravityAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: AntigravityAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: AntigravityAdapterShape["stopAll"] = () => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( + Effect.catch((cause) => + Effect.logError("Failed to emit Antigravity session shutdown event.", { cause }), + ), + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + // `agy --model` is applied when the bridge spawns the CLI, so switching + // models mid-session is not possible. + capabilities: { sessionModelSwitch: "unsupported" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies AntigravityAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/AntigravityProvider.ts b/apps/server/src/provider/Layers/AntigravityProvider.ts new file mode 100644 index 00000000000..50783e975d4 --- /dev/null +++ b/apps/server/src/provider/Layers/AntigravityProvider.ts @@ -0,0 +1,328 @@ +/** + * AntigravityProvider — health probe and model discovery for the Antigravity + * CLI (`agy`). + * + * Model discovery calls `agy models` directly rather than starting an ACP + * session as the Grok provider does: Antigravity has no agent protocol of its + * own, so spinning up the bridge just to enumerate models would spawn a + * print-mode process for no reason. + * + * @module AntigravityProvider + */ +import { + type AntigravitySettings, + type ModelCapabilities, + type ServerProvider, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { causeErrorTag } from "@t3tools/shared/observability"; +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 { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { parseAntigravityModelList } from "../acp/AntigravityAcpSupport.ts"; + +const ANTIGRAVITY_PRESENTATION = { + displayName: "Antigravity", + badgeLabel: "Experimental", + showInteractionModeToggle: false, + // The bridge passes `--model` when it spawns `agy`, so a model change only + // takes effect on a new session. + requiresNewThreadForModelChange: true, +} as const; + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [] }); + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const MODEL_DISCOVERY_TIMEOUT_MS = 15_000; + +/** + * Fallback list used before discovery completes or when `agy models` fails. + * Discovery replaces this with whatever the installed CLI actually offers. + */ +const ANTIGRAVITY_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "gemini-3.1-pro-high", + name: "Gemini 3.1 Pro (High)", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, +]; + +/** + * Turn an Antigravity model slug into a display name. + * + * Slugs encode the reasoning tier as a trailing `-high` / `-medium` / `-low`, + * which reads better as a parenthetical suffix. + */ +export function formatAntigravityModelName(slug: string): string { + const tierMatch = /^(.*)-(high|medium|low)$/.exec(slug); + const base = tierMatch?.[1] ?? slug; + const tier = tierMatch?.[2]; + // Vendor initialisms read wrong under plain title-casing. + const initialisms: Record = { gpt: "GPT", oss: "OSS", ai: "AI" }; + const pretty = base + .split("-") + .map((word) => { + const lower = word.toLowerCase(); + if (initialisms[lower]) { + return initialisms[lower]; + } + // Leave version and size fragments (3.1, 120b) as written. + return /^\d/.test(word) ? word : `${word.charAt(0).toUpperCase()}${word.slice(1)}`; + }) + .join(" "); + return tier ? `${pretty} (${tier.charAt(0).toUpperCase()}${tier.slice(1)})` : pretty; +} + +function antigravityModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = ANTIGRAVITY_BUILT_IN_MODELS, +): ReadonlyArray { + return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); +} + +export function buildInitialAntigravityProviderSnapshot( + settings: AntigravitySettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = antigravityModelsFromSettings(settings.customModels); + + if (!settings.enabled) { + return buildServerProvider({ + presentation: ANTIGRAVITY_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: ANTIGRAVITY_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Antigravity CLI availability...", + }, + }); + }); +} + +const runAgyCommand = ( + settings: AntigravitySettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const command = settings.binaryPath || "agy"; + const spawnCommand = yield* resolveSpawnCommand(command, [...args], { env: environment }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + // `agy` starts a language server and will not emit `models` output + // until stdin closes. The default "pipe" leaves it open forever, so + // the probe would hang and time out with an empty list. + stdin: "ignore", + }), + ); + }); + +const discoverAntigravityModels = ( + settings: AntigravitySettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.map(runAgyCommand(settings, ["models"], environment), (output) => + output.code === 0 + ? parseAntigravityModelList(output.stdout).map( + (slug): ServerProviderModel => ({ + slug, + name: formatAntigravityModelName(slug), + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }), + ) + : [], + ); + +export const checkAntigravityProviderStatus = Effect.fn("checkAntigravityProviderStatus")( + function* ( + settings: AntigravitySettings, + environment: NodeJS.ProcessEnv = process.env, + ): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = antigravityModelsFromSettings(settings.customModels); + + if (!settings.enabled) { + return buildServerProvider({ + presentation: ANTIGRAVITY_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Antigravity is disabled in T3 Code settings.", + }, + }); + } + + const versionResult = yield* runAgyCommand(settings, ["--version"], environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + yield* Effect.logWarning("Antigravity CLI health check failed.", { errorTag: error._tag }); + return buildServerProvider({ + presentation: ANTIGRAVITY_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Antigravity CLI (`agy`) is not installed or not on PATH." + : "Failed to execute Antigravity CLI health check.", + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: ANTIGRAVITY_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Antigravity CLI is installed but timed out while running `agy --version`.", + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + yield* Effect.logWarning("Antigravity CLI version probe exited with a non-zero status.", { + exitCode: versionOutput.code, + }); + return buildServerProvider({ + presentation: ANTIGRAVITY_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Antigravity CLI is installed but failed to run.", + }, + }); + } + + const discoveryResult = yield* discoverAntigravityModels(settings, environment).pipe( + Effect.timeoutOption(MODEL_DISCOVERY_TIMEOUT_MS), + Effect.result, + ); + const discoveredModels = + Result.isSuccess(discoveryResult) && Option.isSome(discoveryResult.success) + ? discoveryResult.success.value + : []; + if (discoveredModels.length === 0) { + // A CLI that runs but lists no models is almost always an unfinished + // Google sign-in, which `agy models` reports by printing nothing. + yield* Effect.logWarning("Antigravity model discovery returned no models."); + return buildServerProvider({ + presentation: ANTIGRAVITY_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "warning", + auth: { status: "unknown" }, + message: "Antigravity CLI is installed but listed no models. Run `agy` to sign in.", + }, + }); + } + + return buildServerProvider({ + presentation: ANTIGRAVITY_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: antigravityModelsFromSettings(settings.customModels, discoveredModels), + probe: { + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + }, + }); + }, +); + +export const enrichAntigravitySnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { snapshot, publishSnapshot } = input; + + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("Antigravity version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); +}; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 3f00d3cc662..8428357600d 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1402,6 +1402,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te claudeAgent: { enabled: false }, cursor: { enabled: false }, grok: { enabled: false }, + antigravity: { enabled: false }, opencode: { enabled: false }, }, // `providerInstances` keys are branded `ProviderInstanceId`; @@ -1513,6 +1514,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te claudeAgent: { enabled: false }, cursor: { enabled: false }, grok: { enabled: false }, + antigravity: { enabled: false }, opencode: { enabled: false }, }, }), @@ -1626,6 +1628,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te claudeAgent: { enabled: false }, cursor: { enabled: false }, grok: { enabled: false }, + antigravity: { enabled: false }, opencode: { enabled: false }, }, providerInstances: { @@ -1759,6 +1762,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [ + "antigravity", "claudeAgent", "codex", "cursor", diff --git a/apps/server/src/provider/Services/AntigravityAdapter.ts b/apps/server/src/provider/Services/AntigravityAdapter.ts new file mode 100644 index 00000000000..10171fd2ff5 --- /dev/null +++ b/apps/server/src/provider/Services/AntigravityAdapter.ts @@ -0,0 +1,16 @@ +/** + * AntigravityAdapter — shape type for the Antigravity provider adapter. + * + * The driver model ({@link ../Drivers/AntigravityDriver}) 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 AntigravityAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * AntigravityAdapterShape — per-instance Antigravity adapter contract. + */ +export interface AntigravityAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts new file mode 100644 index 00000000000..9db6fbdaedc --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildAntigravityAcpSpawnInput, + parseAntigravityModelList, + resolveAntigravityBaseModelId, +} from "./AntigravityAcpSupport.ts"; +import { formatAntigravityModelName } from "../Layers/AntigravityProvider.ts"; + +describe("parseAntigravityModelList", () => { + it("parses the one-slug-per-line output of `agy models`", () => { + const models = parseAntigravityModelList( + "gemini-3.6-flash-high\ngemini-3.1-pro-high\nclaude-sonnet-4-6\n", + ); + + expect(models).toEqual(["gemini-3.6-flash-high", "gemini-3.1-pro-high", "claude-sonnet-4-6"]); + }); + + it("drops blank lines, duplicates, and prose", () => { + const models = parseAntigravityModelList( + "\ngemini-3.1-pro-high\n\ngemini-3.1-pro-high\nAvailable agents:\n gpt-oss-120b-medium \n", + ); + + expect(models).toEqual(["gemini-3.1-pro-high", "gpt-oss-120b-medium"]); + }); +}); + +describe("resolveAntigravityBaseModelId", () => { + it("resolves a bare family name to a concrete reasoning tier", () => { + expect(resolveAntigravityBaseModelId("gemini-3.1-pro")).toBe("gemini-3.1-pro-high"); + expect(resolveAntigravityBaseModelId("flash")).toBe("gemini-3.6-flash-medium"); + }); + + it("passes through a slug that already names a tier", () => { + expect(resolveAntigravityBaseModelId("gemini-3.6-flash-low")).toBe("gemini-3.6-flash-low"); + }); + + it("falls back to the default when nothing is selected", () => { + expect(resolveAntigravityBaseModelId(undefined)).toBe("gemini-3.1-pro-high"); + expect(resolveAntigravityBaseModelId(" ")).toBe("gemini-3.1-pro-high"); + }); +}); + +describe("buildAntigravityAcpSpawnInput", () => { + it("spawns this server binary's bridge subcommand rather than agy directly", () => { + const spawn = buildAntigravityAcpSpawnInput({ + antigravitySettings: null, + cwd: "/repo", + }); + + expect(spawn.command).toBe(process.execPath); + expect(spawn.args.at(-1)).toBe("agy-acp"); + expect(spawn.cwd).toBe("/repo"); + }); + + it("passes per-turn configuration to the bridge through the environment", () => { + const spawn = buildAntigravityAcpSpawnInput({ + antigravitySettings: { + binaryPath: "/opt/agy", + printTimeout: "30m", + appDataDir: "/data/agy", + } as never, + cwd: "/repo", + model: "gemini-3.1-pro-high", + effort: "high", + }); + + expect(spawn.env).toMatchObject({ + T3_AGY_COMMAND: "/opt/agy", + T3_AGY_PRINT_TIMEOUT: "30m", + T3_AGY_APP_DATA_DIR: "/data/agy", + T3_AGY_MODEL: "gemini-3.1-pro-high", + T3_AGY_EFFORT: "high", + }); + }); + + it("omits unset settings so the bridge keeps its own defaults", () => { + const spawn = buildAntigravityAcpSpawnInput({ + antigravitySettings: { binaryPath: "", printTimeout: "", appDataDir: "" } as never, + cwd: "/repo", + }); + + expect(spawn.env).not.toHaveProperty("T3_AGY_COMMAND"); + expect(spawn.env).not.toHaveProperty("T3_AGY_PRINT_TIMEOUT"); + expect(spawn.env).not.toHaveProperty("T3_AGY_MODEL"); + }); +}); + +describe("formatAntigravityModelName", () => { + it("renders the trailing reasoning tier as a suffix", () => { + expect(formatAntigravityModelName("gemini-3.1-pro-high")).toBe("Gemini 3.1 Pro (High)"); + expect(formatAntigravityModelName("gemini-3.6-flash-low")).toBe("Gemini 3.6 Flash (Low)"); + }); + + it("leaves a slug with no tier suffix alone", () => { + expect(formatAntigravityModelName("claude-sonnet-4-6")).toBe("Claude Sonnet 4 6"); + }); + + it("keeps vendor initialisms uppercase", () => { + expect(formatAntigravityModelName("gpt-oss-120b-medium")).toBe("GPT OSS 120b (Medium)"); + }); +}); diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts new file mode 100644 index 00000000000..c3ded2e3584 --- /dev/null +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -0,0 +1,143 @@ +/** + * Spawn wiring for the Antigravity provider. + * + * Unlike Cursor and Grok — whose CLIs speak ACP natively — Antigravity has no + * agent protocol, so the "ACP agent" spawned here is T3 Code's own bridge + * (`t3 agy-acp`, see `antigravity/agyBridge.ts`). Running it as a subcommand of + * this same binary means the bridge always ships and versions with the server. + * + * @module provider/acp/AntigravityAcpSupport + */ +import { type AntigravitySettings, ProviderDriverKind } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; + +import { normalizeModelSlug } from "@t3tools/shared/model"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +const ANTIGRAVITY_DRIVER_KIND = ProviderDriverKind.make("antigravity"); + +/** Antigravity manages its own Google sign-in, so there is nothing to select. */ +const ANTIGRAVITY_AUTH_METHOD = "none"; + +export const DEFAULT_ANTIGRAVITY_MODEL = "gemini-3.1-pro-high"; + +type AntigravityAcpRuntimeSettings = Pick< + AntigravitySettings, + "binaryPath" | "printTimeout" | "appDataDir" +>; + +interface AntigravityAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly antigravitySettings: AntigravityAcpRuntimeSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; + /** Model the bridge should pass to `agy --model` for this session. */ + readonly model?: string | undefined; + /** Reasoning effort for `agy --effort`. */ + readonly effort?: string | undefined; +} + +/** + * Resolve how to re-invoke this server binary. + * + * Under `node src/bin.ts` the entry script must be passed explicitly; a packed + * single-file build is itself the executable. + */ +export function resolveBridgeCommand(): { command: string; args: ReadonlyArray } { + const entry = process.argv[1]; + return entry + ? { command: process.execPath, args: [entry, "agy-acp"] } + : { command: process.execPath, args: ["agy-acp"] }; +} + +export function buildAntigravityAcpSpawnInput(input: { + readonly antigravitySettings: AntigravityAcpRuntimeSettings | null | undefined; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv; + readonly model?: string | undefined; + readonly effort?: string | undefined; +}): AcpSessionRuntime.AcpSpawnInput { + const { command, args } = resolveBridgeCommand(); + const settings = input.antigravitySettings; + // The bridge reads its per-turn configuration from the environment so that + // `agy` invocation details stay entirely inside the bridge process. + const env: NodeJS.ProcessEnv = { ...input.environment }; + if (settings?.binaryPath?.trim()) { + env["T3_AGY_COMMAND"] = settings.binaryPath.trim(); + } + if (settings?.printTimeout?.trim()) { + env["T3_AGY_PRINT_TIMEOUT"] = settings.printTimeout.trim(); + } + if (settings?.appDataDir?.trim()) { + env["T3_AGY_APP_DATA_DIR"] = settings.appDataDir.trim(); + } + if (input.model?.trim()) { + env["T3_AGY_MODEL"] = input.model.trim(); + } + if (input.effort?.trim()) { + env["T3_AGY_EFFORT"] = input.effort.trim(); + } + + return { command, args: [...args], cwd: input.cwd, env }; +} + +export const makeAntigravityAcpRuntime = ( + input: AntigravityAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildAntigravityAcpSpawnInput({ + antigravitySettings: input.antigravitySettings, + cwd: input.cwd, + ...(input.environment ? { environment: input.environment } : {}), + ...(input.model ? { model: input.model } : {}), + ...(input.effort ? { effort: input.effort } : {}), + }), + authMethodId: ANTIGRAVITY_AUTH_METHOD, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +export function resolveAntigravityBaseModelId(model: string | null | undefined): string { + const trimmed = model?.trim(); + const base = trimmed && trimmed.length > 0 ? trimmed : DEFAULT_ANTIGRAVITY_MODEL; + return normalizeModelSlug(base, ANTIGRAVITY_DRIVER_KIND) ?? DEFAULT_ANTIGRAVITY_MODEL; +} + +/** + * Parse `agy models` output. + * + * The command prints one slug per line with no header or decoration. + */ +export function parseAntigravityModelList(output: string): ReadonlyArray { + const seen = new Set(); + for (const line of output.split("\n")) { + const slug = line.trim(); + if (slug.length === 0 || slug.includes(" ")) { + continue; + } + seen.add(slug); + } + return [...seen]; +} diff --git a/apps/server/src/provider/acp/antigravity/agyBridge.ts b/apps/server/src/provider/acp/antigravity/agyBridge.ts new file mode 100644 index 00000000000..5e51c8fced3 --- /dev/null +++ b/apps/server/src/provider/acp/antigravity/agyBridge.ts @@ -0,0 +1,646 @@ +/** + * ACP compatibility bridge for the Antigravity CLI (`agy`). + * + * Antigravity exposes no native agent protocol. This module speaks the slice + * of ACP that `AcpSessionRuntime` uses over stdio and executes each turn + * through Antigravity's documented non-interactive `--print` mode, while + * reconstructing a live event stream from hooks and the trajectory transcript + * (see `agyEvents.ts` and `agyTranscript.ts`). + * + * Runs as a subcommand of the server binary (`t3 agy-acp`) so it ships inside + * the same bundle rather than as a loose script. + * + * @module provider/acp/antigravity/agyBridge + */ +// @effect-diagnostics nodeBuiltinImport:off - Standalone stdio bridge process, not an Effect runtime. +// @effect-diagnostics globalTimers:off - Polls Antigravity hook output outside any Effect runtime. +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import packageJson from "../../../../package.json" with { type: "json" }; +import { + agyHookResponse, + agyTargetPath, + agyToolKind, + hookSessionUpdate, + makeAgyTurnState, + type AgyHookEvent, + type AgyHookPayload, + type AgySessionUpdate, + type AgyTurnState, +} from "./agyEvents.ts"; +import { + AgyTranscriptCursor, + parseTranscriptLine, + transcriptRecordUpdates, +} from "./agyTranscript.ts"; + +const HOOK_DIR_ENV = "T3_AGY_HOOK_DIR"; +const HOOK_POLL_INTERVAL_MS = 50; +const DEFAULT_PRINT_TIMEOUT = "2h"; +const HOOKS_KEY = "t3code-antigravity-observer"; + +interface BridgeSession { + readonly cwd: string; + systemPrompt: string | undefined; + conversationId: string | undefined; +} + +const sessions = new Map(); + +// ── Session id ⇄ Antigravity conversation id ────────────────────────── +// +// `session/new` must return an id before the first turn runs, which is before +// Antigravity has created a trajectory. The mapping is persisted so a later +// `session/load` — potentially in a fresh bridge process — can still resume +// the right conversation. + +function stateFilePath(): string { + const appDataDir = + process.env["T3_AGY_APP_DATA_DIR"]?.trim() || + NodePath.join(NodeOS.homedir(), ".gemini", "antigravity-cli"); + return NodePath.join(appDataDir, "t3code-acp-sessions.json"); +} + +function readSessionMap(): Record { + try { + const parsed: unknown = JSON.parse(NodeFS.readFileSync(stateFilePath(), "utf8")); + return typeof parsed === "object" && parsed !== null ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +function persistConversationId(sessionId: string, conversationId: string): void { + try { + const target = stateFilePath(); + NodeFS.mkdirSync(NodePath.dirname(target), { recursive: true }); + const map = readSessionMap(); + if (map[sessionId] === conversationId) { + return; + } + map[sessionId] = conversationId; + NodeFS.writeFileSync(target, JSON.stringify(map, null, 2)); + } catch { + // Losing the mapping costs conversation continuity on the next resume, + // which is not worth failing a turn over. + } +} + +function lookupConversationId(sessionId: string): string | undefined { + const mapped = readSessionMap()[sessionId]?.trim(); + if (mapped) { + return mapped; + } + // A caller may hand back an Antigravity conversation UUID directly. + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId) + ? sessionId + : undefined; +} + +// ── JSON-RPC plumbing ───────────────────────────────────────────────── + +function writeMessage(value: unknown): void { + process.stdout.write(`${JSON.stringify(value)}\n`); +} + +function sendResult(id: unknown, result: unknown): void { + writeMessage({ jsonrpc: "2.0", id, result }); +} + +function sendError(id: unknown, code: number, message: string): void { + writeMessage({ jsonrpc: "2.0", id, error: { code, message } }); +} + +function sendSessionUpdate(sessionId: string, update: AgySessionUpdate): void { + writeMessage({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId, update }, + }); +} + +// ── Hook observer ───────────────────────────────────────────────────── + +/** + * Hook command the bridge registers with Antigravity. Re-invokes this same + * binary so the observer always matches the running bridge. + */ +function hookCommandFor(event: string): string { + const entry = process.argv[1]; + const base = entry + ? `${quoteArg(process.execPath)} ${quoteArg(entry)}` + : quoteArg(process.execPath); + return `${base} agy-hook --event ${event}`; +} + +function quoteArg(value: string): string { + return /[\s"']/.test(value) ? `"${value.replace(/(["\\$`])/g, "\\$1")}"` : value; +} + +/** + * Build a throwaway workspace directory whose only purpose is carrying + * `.agents/hooks.json`. Antigravity loads `.agents` from every `--add-dir` + * path, so the observer attaches without writing anything into the user's + * repository. + */ +function createHookWorkspace(): string { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-agy-hooks-")); + const agentsDir = NodePath.join(dir, ".agents"); + NodeFS.mkdirSync(agentsDir, { recursive: true }); + const toolHook = (event: string) => [ + { matcher: "*", hooks: [{ type: "command", command: hookCommandFor(event), timeout: 10 }] }, + ]; + NodeFS.writeFileSync( + NodePath.join(agentsDir, "hooks.json"), + JSON.stringify( + { + [HOOKS_KEY]: { + PreToolUse: toolHook("pre-tool-use"), + PostToolUse: toolHook("post-tool-use"), + Stop: [{ type: "command", command: hookCommandFor("stop"), timeout: 10 }], + }, + }, + null, + 2, + ), + ); + return dir; +} + +function readHookEvents(hookDir: string, seen: Set): ReadonlyArray { + let entries: Array; + try { + entries = NodeFS.readdirSync(hookDir); + } catch { + return []; + } + const events: Array = []; + for (const name of entries.filter((n) => n.endsWith(".json")).sort()) { + if (seen.has(name)) { + continue; + } + seen.add(name); + try { + const raw = NodeFS.readFileSync(NodePath.join(hookDir, name), "utf8"); + const parsed: unknown = JSON.parse(raw); + if (typeof parsed === "object" && parsed !== null) { + events.push(parsed as AgyHookEvent); + } + } catch { + // A half-written hook file is picked up on the next poll. + seen.delete(name); + } + } + return events; +} + +/** + * Largest file the hook will inline into its event record. A diff of anything + * bigger is not worth the memory it would cost on both sides. + */ +const MAX_CAPTURED_FILE_BYTES = 2 * 1024 * 1024; + +function captureFileText(path: string | undefined): string | null { + if (!path) { + return null; + } + try { + const stats = NodeFS.statSync(path); + if (!stats.isFile() || stats.size > MAX_CAPTURED_FILE_BYTES) { + return null; + } + return NodeFS.readFileSync(path, "utf8"); + } catch { + // A new file has no prior contents; that is a valid diff with no oldText. + return null; + } +} + +/** Entry point for `t3 agy-hook `. */ +export async function runAgyHook(event: string): Promise { + let raw = ""; + process.stdin.setEncoding("utf8"); + for await (const chunk of process.stdin) { + raw += chunk; + } + + const hookDir = process.env[HOOK_DIR_ENV]?.trim(); + if (hookDir) { + try { + const payload = JSON.parse(raw) as AgyHookPayload; + const record: AgyHookEvent = { + event, + payload, + // Snapshot the file here, while the hook still brackets the tool call. + ...(agyToolKind(payload?.toolCall?.name) === "edit" + ? { capturedFileText: captureFileText(agyTargetPath(payload?.toolCall)) } + : {}), + }; + const name = `${process.hrtime.bigint().toString().padStart(24, "0")}-${event}.json`; + // Write then rename so the poller never observes a partial file. + const finalPath = NodePath.join(hookDir, name); + const tempPath = `${finalPath}.tmp`; + NodeFS.writeFileSync(tempPath, JSON.stringify(record)); + NodeFS.renameSync(tempPath, finalPath); + } catch { + // Observation is best-effort: a hook must never break a tool call. + } + } + + process.stdout.write(JSON.stringify(agyHookResponse(event, Boolean(hookDir)))); +} + +// ── Turn execution ──────────────────────────────────────────────────── + +function buildAgyArgs( + session: BridgeSession, + hookWorkspace: string, + prompt: string, +): Array { + const args = [ + "--dangerously-skip-permissions", + "--print-timeout", + process.env["T3_AGY_PRINT_TIMEOUT"]?.trim() || DEFAULT_PRINT_TIMEOUT, + ]; + const model = process.env["T3_AGY_MODEL"]?.trim(); + if (model) { + args.push("--model", model); + } + const effort = process.env["T3_AGY_EFFORT"]?.trim(); + if (effort) { + args.push("--effort", effort); + } + if (session.conversationId) { + args.push("--conversation", session.conversationId); + } + // Print mode does not infer workspace customizations from cwd alone. The + // session workspace is registered so its `.agents` skills and rules load; + // the hook workspace is registered so the observer attaches. + args.push("--add-dir", session.cwd, "--add-dir", hookWorkspace); + args.push("--print", prompt); + return args; +} + +function renderPrompt(session: BridgeSession, promptBlocks: unknown): string | null { + if (!Array.isArray(promptBlocks)) { + return null; + } + const text = promptBlocks + .filter( + (block): block is { type: string; text: string } => + typeof block === "object" && + block !== null && + (block as { type?: unknown }).type === "text" && + typeof (block as { text?: unknown }).text === "string", + ) + .map((block) => block.text) + .join("\n\n"); + if (text.trim().length === 0) { + return null; + } + const systemPrompt = session.systemPrompt?.trim(); + return systemPrompt ? `System instructions:\n${systemPrompt}\n\nRequest:\n${text}` : text; +} + +interface TurnOutcome { + readonly stopReason: "end_turn" | "cancelled"; + readonly failure?: string; +} + +let activeChild: NodeChildProcess.ChildProcess | null = null; +const cancelledSessions = new Set(); + +/** + * Drain everything Antigravity has produced so far and emit it as ACP updates. + * + * Hooks are read first so a tool call is always announced before the + * transcript record carrying its output is matched against it. + */ +function drain(input: { + readonly sessionId: string; + readonly hookDir: string; + readonly seenHooks: Set; + readonly state: AgyTurnState; + readonly cursor: AgyTranscriptCursor; + readonly transcriptOffset: { value: number }; + readonly assistantText: { emitted: boolean }; + readonly final: boolean; +}): void { + for (const hook of readHookEvents(input.hookDir, input.seenHooks)) { + // Diffing the file contents each hook captured, rather than the arguments + // of the edit, keeps this correct across tools whose argument shapes + // differ (`replace_file_content` sends a fragment, `write_to_file` sends + // the whole file). + const fileText = hook.capturedFileText ?? undefined; + const update = hookSessionUpdate(hook, input.state, fileText); + if (update) { + sendSessionUpdate(input.sessionId, update); + } + } + + const transcriptPath = resolveTranscriptPath(input.state); + if (transcriptPath) { + let chunk = ""; + try { + const stats = NodeFS.statSync(transcriptPath); + if (stats.size > input.transcriptOffset.value) { + const fd = NodeFS.openSync(transcriptPath, "r"); + try { + const length = stats.size - input.transcriptOffset.value; + const buffer = Buffer.alloc(length); + NodeFS.readSync(fd, buffer, 0, length, input.transcriptOffset.value); + chunk = buffer.toString("utf8"); + input.transcriptOffset.value = stats.size; + } finally { + NodeFS.closeSync(fd); + } + } + } catch { + chunk = ""; + } + + const lines = chunk.length > 0 ? input.cursor.push(chunk) : []; + const allLines = input.final ? [...lines, ...input.cursor.flush()] : lines; + for (const line of allLines) { + const record = parseTranscriptLine(line); + if (!record) { + continue; + } + const result = transcriptRecordUpdates(record, input.state); + for (const update of result.updates) { + sendSessionUpdate(input.sessionId, update); + } + if (result.emittedAssistantText) { + input.assistantText.emitted = true; + } + } + } +} + +/** + * Hooks report `transcript_full.jsonl`; the sibling `transcript.jsonl` holds + * the same steps without internal model chatter and is the better stream to + * render. + */ +function resolveTranscriptPath(state: AgyTurnState): string | undefined { + const reported = state.transcriptPath; + if (!reported) { + return undefined; + } + const condensed = reported.replace(/transcript_full\.jsonl$/, "transcript.jsonl"); + return NodeFS.existsSync(condensed) ? condensed : reported; +} + +async function runTurn( + sessionId: string, + session: BridgeSession, + prompt: string, +): Promise { + const hookDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-agy-hookout-")); + const hookWorkspace = createHookWorkspace(); + const command = process.env["T3_AGY_COMMAND"]?.trim() || "agy"; + const state = makeAgyTurnState(session.conversationId); + const seenHooks = new Set(); + const cursor = new AgyTranscriptCursor(); + const transcriptOffset = { value: 0 }; + const assistantText = { emitted: false }; + + const child = NodeChildProcess.spawn(command, buildAgyArgs(session, hookWorkspace, prompt), { + cwd: session.cwd, + env: { ...process.env, [HOOK_DIR_ENV]: hookDir }, + stdio: ["ignore", "pipe", "pipe"], + }); + activeChild = child; + + let stdout = ""; + let stderr = ""; + child.stdout?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { + stderr += chunk; + }); + + const poller = setInterval(() => { + drain({ + sessionId, + hookDir, + seenHooks, + state, + cursor, + transcriptOffset, + assistantText, + final: false, + }); + }, HOOK_POLL_INTERVAL_MS); + + const exitCode = await new Promise((resolve) => { + child.on("error", () => resolve(null)); + child.on("close", (code) => resolve(code)); + }); + + clearInterval(poller); + activeChild = null; + drain({ + sessionId, + hookDir, + seenHooks, + state, + cursor, + transcriptOffset, + assistantText, + final: true, + }); + + // Any tool still open at exit would otherwise render as spinning forever. + for (const [, active] of state.activeToolCalls) { + sendSessionUpdate(sessionId, { + sessionUpdate: "tool_call_update", + toolCallId: active.toolCallId, + status: "failed", + rawOutput: { isError: true, error: "Antigravity exited before the tool reported completion" }, + }); + } + state.activeToolCalls.clear(); + + if (state.conversationId) { + session.conversationId = state.conversationId; + persistConversationId(sessionId, state.conversationId); + } + + cleanupDir(hookDir); + cleanupDir(hookWorkspace); + + if (cancelledSessions.delete(sessionId)) { + return { stopReason: "cancelled" }; + } + if (exitCode !== 0) { + const detail = stderr.trim() || stdout.trim() || `agy exited with code ${exitCode}`; + return { stopReason: "end_turn", failure: detail }; + } + + // The transcript already streamed the assistant text. stdout is only used + // when transcript observation produced nothing, so the reply is never + // duplicated. + if (!assistantText.emitted && stdout.trim().length > 0) { + sendSessionUpdate(sessionId, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: stdout.trim() }, + }); + } + return { stopReason: "end_turn" }; +} + +function cleanupDir(dir: string): void { + try { + NodeFS.rmSync(dir, { recursive: true, force: true }); + } catch { + // Temp directories are reclaimed by the OS. + } +} + +// ── Request dispatch ────────────────────────────────────────────────── + +async function handleRequest(message: Record): Promise { + const method = typeof message["method"] === "string" ? message["method"] : undefined; + const id = message["id"]; + const params = (message["params"] ?? {}) as Record; + + if (!method) { + return; + } + + switch (method) { + case "initialize": { + const requested = + typeof params["protocolVersion"] === "number" ? params["protocolVersion"] : 1; + sendResult(id, { + protocolVersion: Math.min(requested, 1), + agentCapabilities: { + loadSession: true, + promptCapabilities: { image: false, audio: false, embeddedContext: false }, + mcpCapabilities: { http: false, sse: false }, + }, + authMethods: [], + // The bridge has no version of its own; it ships with the server, so + // that is the version worth reporting. The Antigravity CLI version is + // reported separately by the provider snapshot (`agy --version`). + agentInfo: { name: "Antigravity", version: packageJson.version }, + }); + return; + } + // Antigravity manages its own Google sign-in; there is nothing for the + // client to authenticate against, but the handshake still requires a + // successful reply. + case "authenticate": { + sendResult(id, {}); + return; + } + case "session/new": + case "session/load": { + const cwd = typeof params["cwd"] === "string" ? params["cwd"] : ""; + if (!cwd || !NodePath.isAbsolute(cwd)) { + sendError(id, -32602, `${method} requires an absolute cwd`); + return; + } + const requestedSessionId = + typeof params["sessionId"] === "string" ? params["sessionId"] : undefined; + const sessionId = + method === "session/load" && requestedSessionId + ? requestedSessionId + : NodeCrypto.randomUUID(); + sessions.set(sessionId, { + cwd, + systemPrompt: + typeof params["systemPrompt"] === "string" ? params["systemPrompt"] : undefined, + conversationId: requestedSessionId ? lookupConversationId(requestedSessionId) : undefined, + }); + sendResult(id, method === "session/load" ? {} : { sessionId }); + return; + } + case "session/prompt": { + const sessionId = typeof params["sessionId"] === "string" ? params["sessionId"] : undefined; + const session = sessionId ? sessions.get(sessionId) : undefined; + if (!sessionId || !session) { + sendError(id, -32602, "unknown sessionId"); + return; + } + const prompt = renderPrompt(session, params["prompt"]); + if (prompt === null) { + sendError(id, -32602, "session/prompt requires at least one text block"); + return; + } + const outcome = await runTurn(sessionId, session, prompt); + if (outcome.failure) { + sendError(id, -32000, `Antigravity turn failed: ${outcome.failure}`); + return; + } + sendResult(id, { stopReason: outcome.stopReason }); + return; + } + case "session/cancel": { + const sessionId = typeof params["sessionId"] === "string" ? params["sessionId"] : undefined; + if (sessionId) { + cancelledSessions.add(sessionId); + } + activeChild?.kill("SIGTERM"); + return; + } + default: { + if (id !== undefined) { + sendError(id, -32601, `method not found: ${method}`); + } + } + } +} + +/** Entry point for `t3 agy-acp`. */ +export async function runAgyBridge(): Promise { + let buffer = ""; + // Requests are handled strictly in order: a turn holds the agent busy, and + // ACP clients do not pipeline prompts for one session. + let queue: Promise = Promise.resolve(); + + process.stdin.setEncoding("utf8"); + for await (const chunk of process.stdin) { + buffer += chunk; + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + newlineIndex = buffer.indexOf("\n"); + if (line.trim().length === 0) { + continue; + } + + let message: Record; + try { + const parsed: unknown = JSON.parse(line); + if (typeof parsed !== "object" || parsed === null) { + continue; + } + message = parsed as Record; + } catch { + sendError(null, -32700, "invalid JSON"); + continue; + } + + // Cancellation must interrupt an in-flight turn, so it bypasses the + // queue that would otherwise make it wait for that turn to finish. + if (message["method"] === "session/cancel") { + void handleRequest(message); + continue; + } + queue = queue.then(() => handleRequest(message)).catch(() => undefined); + } + } + + await queue; + activeChild?.kill("SIGTERM"); +} diff --git a/apps/server/src/provider/acp/antigravity/agyEvents.test.ts b/apps/server/src/provider/acp/antigravity/agyEvents.test.ts new file mode 100644 index 00000000000..24d8fbe3133 --- /dev/null +++ b/apps/server/src/provider/acp/antigravity/agyEvents.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + agyHookResponse, + agyTargetPath, + agyToolCallId, + agyToolKind, + agyToolTitle, + hookSessionUpdate, + makeAgyTurnState, + type AgyHookEvent, +} from "./agyEvents.ts"; + +const preToolUse = (overrides: Record = {}): AgyHookEvent => ({ + event: "pre-tool-use", + payload: { + conversationId: "conversation-1", + stepIdx: 3, + transcriptPath: "/brain/conversation-1/.system_generated/logs/transcript_full.jsonl", + modelName: "gemini-3.1-pro-high", + toolCall: { name: "run_command", args: { CommandLine: "ls -la src" } }, + ...overrides, + }, +}); + +describe("agyToolKind", () => { + it("maps Antigravity tool names onto ACP kinds", () => { + expect(agyToolKind("replace_file_content")).toBe("edit"); + expect(agyToolKind("write_to_file")).toBe("edit"); + expect(agyToolKind("view_file")).toBe("read"); + expect(agyToolKind("list_dir")).toBe("read"); + expect(agyToolKind("grep_search")).toBe("search"); + expect(agyToolKind("run_command")).toBe("execute"); + expect(agyToolKind("delete_file")).toBe("delete"); + }); + + it("treats browser tools as fetch and unknown tools as other", () => { + expect(agyToolKind("browser_navigate")).toBe("fetch"); + expect(agyToolKind("some_future_tool")).toBe("other"); + expect(agyToolKind(undefined)).toBe("other"); + }); +}); + +describe("agyToolTitle", () => { + it("prefers Antigravity's own summary over the raw tool name", () => { + expect(agyToolTitle({ name: "run_command", args: { toolSummary: "List files in src" } })).toBe( + "List files in src", + ); + }); + + it("falls back through toolAction to the tool name", () => { + expect(agyToolTitle({ name: "run_command", args: { toolAction: "Listing files" } })).toBe( + "Listing files", + ); + expect(agyToolTitle({ name: "run_command", args: {} })).toBe("run_command"); + expect(agyToolTitle(null)).toBe("Antigravity tool"); + }); +}); + +describe("agyTargetPath", () => { + it("reads the path from whichever PascalCase key the tool used", () => { + expect(agyTargetPath({ name: "replace_file_content", args: { TargetFile: "/a/b.ts" } })).toBe( + "/a/b.ts", + ); + expect(agyTargetPath({ name: "view_file", args: { AbsolutePath: "/a/c.ts" } })).toBe("/a/c.ts"); + expect(agyTargetPath({ name: "list_dir", args: { DirectoryPath: "/a" } })).toBe("/a"); + expect(agyTargetPath({ name: "run_command", args: { CommandLine: "ls" } })).toBeUndefined(); + }); +}); + +describe("hookSessionUpdate", () => { + it("announces a tool call and records it as in flight", () => { + const state = makeAgyTurnState(); + const update = hookSessionUpdate(preToolUse(), state); + + expect(update).toMatchObject({ + sessionUpdate: "tool_call", + toolCallId: agyToolCallId("conversation-1", 3), + kind: "execute", + status: "in_progress", + rawInput: { CommandLine: "ls -la src" }, + }); + expect(state.activeToolCalls.has(3)).toBe(true); + }); + + it("learns the conversation and transcript from any hook, not just stop", () => { + const state = makeAgyTurnState(); + hookSessionUpdate(preToolUse(), state); + + expect(state.conversationId).toBe("conversation-1"); + expect(state.modelName).toBe("gemini-3.1-pro-high"); + expect(state.transcriptPath).toBe( + "/brain/conversation-1/.system_generated/logs/transcript_full.jsonl", + ); + }); + + it("ignores hook pairs for internal planner steps that carry no tool", () => { + const state = makeAgyTurnState(); + const update = hookSessionUpdate( + { event: "pre-tool-use", payload: { conversationId: "c", stepIdx: 1, toolCall: null } }, + state, + ); + + expect(update).toBeNull(); + expect(state.activeToolCalls.size).toBe(0); + }); + + it("completes a tool call that was announced", () => { + const state = makeAgyTurnState(); + hookSessionUpdate(preToolUse(), state); + const update = hookSessionUpdate( + { + event: "post-tool-use", + payload: { conversationId: "conversation-1", stepIdx: 3, error: "" }, + }, + state, + ); + + expect(update).toMatchObject({ + sessionUpdate: "tool_call_update", + status: "completed", + rawOutput: { isError: false }, + }); + expect(state.activeToolCalls.size).toBe(0); + }); + + it("marks a tool call failed and carries the error through", () => { + const state = makeAgyTurnState(); + hookSessionUpdate(preToolUse(), state); + const update = hookSessionUpdate( + { + event: "post-tool-use", + payload: { conversationId: "conversation-1", stepIdx: 3, error: "exit status 1" }, + }, + state, + ); + + expect(update).toMatchObject({ + status: "failed", + rawOutput: { isError: true, error: "exit status 1" }, + }); + }); + + it("drops a post hook whose pre hook was never observed", () => { + const state = makeAgyTurnState(); + const update = hookSessionUpdate( + { event: "post-tool-use", payload: { conversationId: "c", stepIdx: 9, error: "" } }, + state, + ); + + expect(update).toBeNull(); + }); + + it("emits a diff from the file contents captured around an edit", () => { + const state = makeAgyTurnState(); + hookSessionUpdate( + preToolUse({ + toolCall: { name: "replace_file_content", args: { TargetFile: "/repo/greet.js" } }, + }), + state, + 'return "hi";', + ); + const update = hookSessionUpdate( + { + event: "post-tool-use", + payload: { conversationId: "conversation-1", stepIdx: 3, error: "" }, + }, + state, + 'return "hello";', + ); + + expect(update).toMatchObject({ + status: "completed", + content: [ + { + type: "diff", + path: "/repo/greet.js", + oldText: 'return "hi";', + newText: 'return "hello";', + }, + ], + }); + }); + + it("omits the diff when an edit left the file byte-identical", () => { + const state = makeAgyTurnState(); + hookSessionUpdate( + preToolUse({ + toolCall: { name: "write_to_file", args: { TargetFile: "/repo/a.ts" } }, + }), + state, + "same", + ); + const update = hookSessionUpdate( + { + event: "post-tool-use", + payload: { conversationId: "conversation-1", stepIdx: 3, error: "" }, + }, + state, + "same", + ); + + expect(update).not.toHaveProperty("content"); + }); +}); + +describe("agyHookResponse", () => { + it("allows tools only while a bridge observer is attached", () => { + expect(agyHookResponse("pre-tool-use", true)).toEqual({ decision: "allow" }); + expect(agyHookResponse("pre-tool-use", false)).toMatchObject({ decision: "ask" }); + }); + + it("answers the stop contract with a decision", () => { + expect(agyHookResponse("stop", true)).toEqual({ decision: "stop" }); + expect(agyHookResponse("post-tool-use", true)).toEqual({}); + }); +}); diff --git a/apps/server/src/provider/acp/antigravity/agyEvents.ts b/apps/server/src/provider/acp/antigravity/agyEvents.ts new file mode 100644 index 00000000000..077ee0a66f8 --- /dev/null +++ b/apps/server/src/provider/acp/antigravity/agyEvents.ts @@ -0,0 +1,359 @@ +/** + * Pure translation layer for the Antigravity ACP bridge. + * + * Antigravity exposes no native agent protocol, so the bridge reconstructs an + * ACP event stream from two independent sources that Antigravity *does* + * document: + * + * - **Hooks** (`PreToolUse` / `PostToolUse` / `Stop`) deliver the tool name, + * its arguments, a human-readable summary, and completion status. + * - **The trajectory transcript** (`transcript.jsonl`) delivers assistant + * text and real tool output, appended progressively while the turn runs. + * + * The two correlate exactly: a hook's `stepIdx` is the transcript record's + * `step_index`. Neither source alone is sufficient — hooks never carry tool + * output, and the transcript never carries tool arguments. + * + * Everything here is pure so it can be tested without spawning `agy`. IO lives + * in `agyBridge.ts`. + * + * @module provider/acp/antigravity/agyEvents + */ + +/** Tool-call lifecycle kinds understood by ACP clients. */ +export type AcpToolKind = + | "read" + | "edit" + | "delete" + | "move" + | "search" + | "execute" + | "think" + | "fetch" + | "switch_mode" + | "other"; + +export type AgyHookEventName = "pre-tool-use" | "post-tool-use" | "stop"; + +export interface AgyToolCall { + readonly name?: string; + readonly args?: Record | null; +} + +/** + * Documented Antigravity hook payload. Every event carries `conversationId` + * and `transcriptPath`, which is what lets the bridge resume a trajectory and + * locate its transcript without guessing. + */ +export interface AgyHookPayload { + readonly conversationId?: string; + readonly stepIdx?: number; + readonly toolCall?: AgyToolCall | null; + readonly error?: string; + readonly modelName?: string; + readonly transcriptPath?: string; + readonly artifactDirectoryPath?: string; + readonly workspacePaths?: ReadonlyArray; + readonly fullyIdle?: boolean; + readonly terminationReason?: string; + readonly executionNum?: number; +} + +export interface AgyHookEvent { + readonly event: AgyHookEventName | string; + readonly payload: AgyHookPayload; + /** + * Contents of the tool's target file, captured by the hook process itself. + * + * This cannot be read by the bridge when it drains hook output: both hooks + * for a fast edit land inside a single poll interval, by which point the + * edit has already been written and "before" would read back as "after". + * The hook runs synchronously within the tool lifecycle, so it is the only + * place that observes the true pre-edit state. + */ + readonly capturedFileText?: string | null; +} + +/** + * Antigravity tool names, mapped onto ACP kinds so clients can pick the right + * affordance. Unknown tools degrade to `other` rather than being dropped. + */ +export function agyToolKind(name: string | undefined): AcpToolKind { + switch (name) { + case "write_to_file": + case "replace_file_content": + case "multi_replace_file_content": + case "edit_file": + return "edit"; + case "view_file": + case "view_code_item": + case "list_dir": + case "read_url_content": + return "read"; + case "grep_search": + case "find_by_name": + case "codebase_search": + case "search_web": + return "search"; + case "run_command": + case "command_status": + return "execute"; + case "delete_file": + return "delete"; + default: + if (name && name.startsWith("browser_")) { + return "fetch"; + } + return "other"; + } +} + +/** + * Stable identity for one tool call across its hook pair and its transcript + * record. `stepIdx` is unique within a conversation and is echoed verbatim as + * the transcript's `step_index`. + */ +export function agyToolCallId(conversationId: string | undefined, stepIdx: number): string { + return `agy-${conversationId ?? "unknown"}-${stepIdx}`; +} + +/** + * Prefer Antigravity's own human-readable summary over the raw tool name. + * `PostToolUse` enriches `args` with `toolSummary`/`toolAction`; `PreToolUse` + * usually has neither, so the tool name is the fallback. + */ +export function agyToolTitle(toolCall: AgyToolCall | null | undefined): string { + const args = toolCall?.args; + const summary = typeof args?.["toolSummary"] === "string" ? args["toolSummary"].trim() : ""; + if (summary.length > 0) { + return summary; + } + const action = typeof args?.["toolAction"] === "string" ? args["toolAction"].trim() : ""; + if (action.length > 0) { + return action; + } + return toolCall?.name?.trim() || "Antigravity tool"; +} + +/** + * File path a tool acts on, when it names one. Antigravity uses PascalCase + * argument keys and is not consistent about which one carries the path. + */ +export function agyTargetPath(toolCall: AgyToolCall | null | undefined): string | undefined { + const args = toolCall?.args; + if (!args) { + return undefined; + } + for (const key of ["TargetFile", "AbsolutePath", "FilePath", "Path", "DirectoryPath"]) { + const value = args[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + } + return undefined; +} + +/** Metadata echoed onto every emitted update for debugging and traceability. */ +export function agyUpdateMeta(event: string, payload: AgyHookPayload): Record { + return { + antigravity: { + event, + conversationId: payload.conversationId ?? null, + stepIdx: payload.stepIdx ?? null, + modelName: payload.modelName ?? null, + transcriptPath: payload.transcriptPath ?? null, + artifactDirectoryPath: payload.artifactDirectoryPath ?? null, + }, + }; +} + +export interface AgyActiveToolCall { + readonly toolCallId: string; + readonly name: string | undefined; + readonly kind: AcpToolKind; + readonly targetPath: string | undefined; + /** File contents captured at `PreToolUse`, used to build an edit diff. */ + readonly beforeText: string | undefined; +} + +/** + * Mutable bookkeeping shared by the hook and transcript readers for one turn. + */ +export interface AgyTurnState { + readonly activeToolCalls: Map; + conversationId: string | undefined; + transcriptPath: string | undefined; + modelName: string | undefined; +} + +export function makeAgyTurnState(conversationId?: string): AgyTurnState { + return { + activeToolCalls: new Map(), + conversationId, + transcriptPath: undefined, + modelName: undefined, + }; +} + +/** A `session/update` payload, shaped for ACP but kept as plain JSON. */ +export type AgySessionUpdate = Record; + +/** + * Translate one `PreToolUse` hook into an ACP `tool_call` announcement. + * + * `beforeText` is threaded in by the caller (which does the file read) so this + * function stays pure. + */ +export function preToolUseUpdate( + payload: AgyHookPayload, + state: AgyTurnState, + beforeText?: string, +): AgySessionUpdate | null { + const stepIdx = payload.stepIdx; + if (typeof stepIdx !== "number") { + return null; + } + const toolCall = payload.toolCall; + // Antigravity emits hook pairs for internal planner steps with no tool + // attached. Those are not tool calls and must not render as one. + if (!toolCall || !toolCall.name) { + return null; + } + + const toolCallId = agyToolCallId(payload.conversationId, stepIdx); + const kind = agyToolKind(toolCall.name); + const targetPath = agyTargetPath(toolCall); + state.activeToolCalls.set(stepIdx, { + toolCallId, + name: toolCall.name, + kind, + targetPath, + beforeText, + }); + + return { + sessionUpdate: "tool_call", + toolCallId, + title: agyToolTitle(toolCall), + kind, + status: "in_progress", + rawInput: toolCall.args ?? null, + ...(targetPath ? { locations: [{ path: targetPath }] } : {}), + _meta: agyUpdateMeta("pre-tool-use", payload), + }; +} + +/** + * Translate one `PostToolUse` hook into an ACP `tool_call_update`. + * + * `afterText` is the on-disk content of an edited file, read by the caller + * after the tool ran. Diffing captured before/after content sidesteps having + * to interpret Antigravity's edit arguments, whose semantics vary by tool. + */ +export function postToolUseUpdate( + payload: AgyHookPayload, + state: AgyTurnState, + afterText?: string, +): AgySessionUpdate | null { + const stepIdx = payload.stepIdx; + if (typeof stepIdx !== "number") { + return null; + } + const active = state.activeToolCalls.get(stepIdx); + // Only complete calls whose `PreToolUse` we actually observed; Antigravity + // emits unpaired post hooks for internal steps. + if (!active) { + return null; + } + state.activeToolCalls.delete(stepIdx); + + const error = typeof payload.error === "string" ? payload.error.trim() : ""; + const failed = error.length > 0; + + const content: Array> = []; + if ( + !failed && + active.kind === "edit" && + active.targetPath && + afterText !== undefined && + afterText !== active.beforeText + ) { + content.push({ + type: "diff", + path: active.targetPath, + ...(active.beforeText === undefined ? {} : { oldText: active.beforeText }), + newText: afterText, + }); + } + + return { + sessionUpdate: "tool_call_update", + toolCallId: active.toolCallId, + status: failed ? "failed" : "completed", + ...(content.length > 0 ? { content } : {}), + rawOutput: failed ? { isError: true, error } : { isError: false }, + _meta: agyUpdateMeta("post-tool-use", payload), + }; +} + +/** + * Fold a hook event into turn state, returning the update to emit (if any). + * + * `Stop` carries no user-visible update; it exists to publish the conversation + * id that lets the next turn resume the same trajectory. + */ +export function hookSessionUpdate( + hook: AgyHookEvent, + state: AgyTurnState, + fileText?: string, +): AgySessionUpdate | null { + const payload = hook.payload ?? {}; + // Every hook carries the conversation id, so the bridge learns it from the + // first event of the turn rather than waiting for `Stop`. + const conversationId = payload.conversationId?.trim(); + if (conversationId) { + state.conversationId = conversationId; + } + const transcriptPath = payload.transcriptPath?.trim(); + if (transcriptPath) { + state.transcriptPath = transcriptPath; + } + const modelName = payload.modelName?.trim(); + if (modelName) { + state.modelName = modelName; + } + + switch (hook.event) { + case "pre-tool-use": + return preToolUseUpdate(payload, state, fileText); + case "post-tool-use": + return postToolUseUpdate(payload, state, fileText); + default: + return null; + } +} + +/** + * Response Antigravity requires on stdout for each hook event. + * + * `pre-tool-use` must return a decision. When no bridge observer is attached + * the safe answer is `ask`: a hook that silently allowed every tool outside a + * managed turn would be a permission bypass. + */ +export function agyHookResponse(event: string, observerAttached: boolean): Record { + switch (event) { + case "pre-tool-use": + return observerAttached + ? { decision: "allow" } + : { + decision: "ask", + reason: "T3 Code hook observer is not attached to a managed Antigravity turn", + }; + // Antigravity's Stop contract requires a decision; anything other than + // "continue" lets the completed execution stop. + case "stop": + return { decision: "stop" }; + default: + return {}; + } +} diff --git a/apps/server/src/provider/acp/antigravity/agyTranscript.test.ts b/apps/server/src/provider/acp/antigravity/agyTranscript.test.ts new file mode 100644 index 00000000000..c1cddfba3f2 --- /dev/null +++ b/apps/server/src/provider/acp/antigravity/agyTranscript.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { hookSessionUpdate, makeAgyTurnState, type AgyTurnState } from "./agyEvents.ts"; +import { + AgyTranscriptCursor, + normalizeToolOutput, + parseTranscriptLine, + transcriptRecordUpdates, +} from "./agyTranscript.ts"; + +function stateWithActiveTool(stepIdx: number, name = "run_command"): AgyTurnState { + const state = makeAgyTurnState(); + hookSessionUpdate( + { + event: "pre-tool-use", + payload: { conversationId: "conversation-1", stepIdx, toolCall: { name, args: {} } }, + }, + state, + ); + return state; +} + +describe("parseTranscriptLine", () => { + it("returns null for blank and half-written lines", () => { + expect(parseTranscriptLine("")).toBeNull(); + expect(parseTranscriptLine('{"step_index": 1, "type": "RUN_COM')).toBeNull(); + }); + + it("parses a complete record", () => { + expect(parseTranscriptLine('{"step_index":2,"type":"PLANNER_RESPONSE"}')).toMatchObject({ + step_index: 2, + type: "PLANNER_RESPONSE", + }); + }); +}); + +describe("normalizeToolOutput", () => { + it("strips the timestamp preamble", () => { + const output = normalizeToolOutput( + "Created At: 2026-07-24T17:31:41-04:00\nCompleted At: 2026-07-24T17:31:41-04:00\nresult", + ); + expect(output).toBe("result"); + }); + + it("dedents Antigravity's tab-indented framing without touching flush-left output", () => { + // Antigravity indents its own framing with tabs but writes the tool's real + // output flush left, so a plain common-prefix dedent would find zero. + const output = normalizeToolOutput( + "Created At: x\n\n\t\t\t\tThe command exited with code 0.\n\t\t\t\tOutput:\n\t\t\t\ttotal 16\ndrwxr-xr-x 4 alex staff", + ); + expect(output).toBe( + "The command exited with code 0.\nOutput:\ntotal 16\ndrwxr-xr-x 4 alex staff", + ); + }); + + it("dedents tab-indented source uniformly", () => { + expect(normalizeToolOutput("\t\tconst a = 1;\n\t\t\tconst b = 2;")).toBe( + "const a = 1;\n\tconst b = 2;", + ); + }); +}); + +describe("transcriptRecordUpdates", () => { + it("streams assistant text as an agent message chunk", () => { + const result = transcriptRecordUpdates( + { step_index: 7, source: "MODEL", type: "PLANNER_RESPONSE", content: "All done." }, + makeAgyTurnState(), + ); + + expect(result.emittedAssistantText).toBe(true); + expect(result.updates[0]).toMatchObject({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "All done." }, + }); + }); + + it("skips empty planner responses", () => { + const result = transcriptRecordUpdates( + { step_index: 2, source: "MODEL", type: "PLANNER_RESPONSE", content: " " }, + makeAgyTurnState(), + ); + + expect(result.updates).toHaveLength(0); + expect(result.emittedAssistantText).toBe(false); + }); + + it("ignores bookkeeping records that would read as assistant output", () => { + for (const type of ["CHECKPOINT", "CONVERSATION_HISTORY", "USER_INPUT", "SYSTEM_MESSAGE"]) { + const result = transcriptRecordUpdates( + { step_index: 1, source: "SYSTEM", type, content: "internal" }, + makeAgyTurnState(), + ); + expect(result.updates).toHaveLength(0); + } + }); + + it("attaches tool output to the call the matching hook announced", () => { + const state = stateWithActiveTool(3); + const result = transcriptRecordUpdates( + { + step_index: 3, + source: "MODEL", + type: "RUN_COMMAND", + content: + "Created At: x\n\t\t\t\tThe command exited with code 0.\n\t\t\t\tOutput:\n\t\t\t\tok", + }, + state, + ); + + expect(result.updates[0]).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "agy-conversation-1-3", + content: [ + { + type: "content", + content: { type: "text", text: "The command exited with code 0.\nOutput:\nok" }, + }, + ], + }); + }); + + it("drops tool output with no announced call rather than inventing one", () => { + const result = transcriptRecordUpdates( + { step_index: 99, source: "MODEL", type: "RUN_COMMAND", content: "orphan" }, + makeAgyTurnState(), + ); + + expect(result.updates).toHaveLength(0); + }); +}); + +describe("AgyTranscriptCursor", () => { + it("holds back a partial trailing line until the rest arrives", () => { + const cursor = new AgyTranscriptCursor(); + + expect(cursor.push('{"a":1}\n{"b":')).toEqual(['{"a":1}']); + expect(cursor.push("2}\n")).toEqual(['{"b":2}']); + }); + + it("tracks the byte offset it has consumed", () => { + const cursor = new AgyTranscriptCursor(); + cursor.push("abc\n"); + + expect(cursor.bytesConsumed).toBe(4); + }); + + it("flushes the trailing line once the writer is finished", () => { + const cursor = new AgyTranscriptCursor(); + cursor.push('{"a":1}\n{"b":2}'); + + expect(cursor.flush()).toEqual(['{"b":2}']); + expect(cursor.flush()).toEqual([]); + }); +}); diff --git a/apps/server/src/provider/acp/antigravity/agyTranscript.ts b/apps/server/src/provider/acp/antigravity/agyTranscript.ts new file mode 100644 index 00000000000..4bfead4ff86 --- /dev/null +++ b/apps/server/src/provider/acp/antigravity/agyTranscript.ts @@ -0,0 +1,187 @@ +/** + * Reader for Antigravity's trajectory transcript. + * + * Antigravity appends one JSON record per step to + * `//.system_generated/logs/transcript.jsonl` while a + * turn is running, not just at the end. Tailing it is what lets the bridge + * stream assistant text and surface real tool output — neither of which the + * hook payloads carry. + * + * The record format is undocumented. Everything here is written to degrade to + * "emit nothing" rather than throw, so an Antigravity update that changes the + * shape costs observability but never breaks a turn. + * + * @module provider/acp/antigravity/agyTranscript + */ +import type { AgySessionUpdate, AgyTurnState } from "./agyEvents.ts"; + +export interface AgyTranscriptRecord { + readonly step_index?: number; + readonly source?: string; + readonly type?: string; + readonly status?: string; + readonly created_at?: string; + readonly content?: string; +} + +/** + * Record types that are bookkeeping rather than conversation. `CHECKPOINT` in + * particular holds a summary of truncated context and would read as the + * assistant talking to itself. + */ +const IGNORED_RECORD_TYPES = new Set([ + "CONVERSATION_HISTORY", + "CHECKPOINT", + "USER_INPUT", + "SYSTEM_MESSAGE", +]); + +export function parseTranscriptLine(line: string): AgyTranscriptRecord | null { + const trimmed = line.trim(); + if (trimmed.length === 0) { + return null; + } + try { + const parsed: unknown = JSON.parse(trimmed); + if (typeof parsed !== "object" || parsed === null) { + return null; + } + return parsed as AgyTranscriptRecord; + } catch { + // A partially-flushed final line is expected while tailing a live turn. + return null; + } +} + +/** + * Strip the `Created At:` / `Completed At:` preamble Antigravity prepends to + * tool records, then dedent. Tool output arrives indented with tabs, which + * would otherwise render as a code block in the client. + */ +export function normalizeToolOutput(content: string): string { + const withoutTimestamps = content + .split("\n") + .filter((line) => !/^\s*(Created At|Completed At):/.test(line)) + .join("\n"); + + // Antigravity indents its own framing ("The command exited with code 0.", + // "Output:") with tabs while leaving the tool's real output flush left, so a + // plain common-prefix dedent finds an indent of zero and does nothing. + // Dedent across tab-indented lines only: that strips the framing here, and + // still behaves like a normal dedent when the payload is tab-indented source. + const lines = withoutTimestamps.split("\n"); + const tabDepths = lines + .filter((line) => line.trim().length > 0 && line.startsWith("\t")) + .map((line) => line.match(/^\t*/)?.[0].length ?? 0); + const commonTabs = tabDepths.length > 0 ? Math.min(...tabDepths) : 0; + const dedented = + commonTabs > 0 + ? lines.map((line) => (line.startsWith("\t") ? line.slice(commonTabs) : line)).join("\n") + : withoutTimestamps; + + return dedented.trim(); +} + +export interface TranscriptUpdateResult { + readonly updates: ReadonlyArray; + /** True when the record produced assistant-visible text. */ + readonly emittedAssistantText: boolean; +} + +/** + * Translate one transcript record into ACP updates. + * + * Assistant text becomes an `agent_message_chunk`. A tool record is matched to + * the in-flight tool call announced by the matching `PreToolUse` hook — the + * hook's `stepIdx` and the record's `step_index` are the same number — and its + * body is attached as that call's output. + */ +export function transcriptRecordUpdates( + record: AgyTranscriptRecord, + state: AgyTurnState, +): TranscriptUpdateResult { + const type = record.type; + if (!type || IGNORED_RECORD_TYPES.has(type)) { + return { updates: [], emittedAssistantText: false }; + } + + const content = typeof record.content === "string" ? record.content : ""; + + if (type === "PLANNER_RESPONSE") { + const text = content.trim(); + if (text.length === 0) { + return { updates: [], emittedAssistantText: false }; + } + return { + updates: [ + { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + }, + ], + emittedAssistantText: true, + }; + } + + // Any other MODEL record is a tool step. Attach its body to the tool call + // the hook already announced; without a matching hook there is no tool call + // to update, so the output is dropped rather than invented. + const stepIndex = record.step_index; + if (typeof stepIndex !== "number") { + return { updates: [], emittedAssistantText: false }; + } + const active = state.activeToolCalls.get(stepIndex); + if (!active) { + return { updates: [], emittedAssistantText: false }; + } + const output = normalizeToolOutput(content); + if (output.length === 0) { + return { updates: [], emittedAssistantText: false }; + } + + return { + updates: [ + { + sessionUpdate: "tool_call_update", + toolCallId: active.toolCallId, + content: [{ type: "content", content: { type: "text", text: output } }], + _meta: { antigravity: { event: "transcript", recordType: type, stepIdx: stepIndex } }, + }, + ], + emittedAssistantText: false, + }; +} + +/** + * Incremental transcript cursor. + * + * Tracks a byte offset and holds back a trailing partial line so a record that + * is still being written is parsed once, on the next read, rather than twice. + */ +export class AgyTranscriptCursor { + private offset = 0; + private carry = ""; + + get bytesConsumed(): number { + return this.offset; + } + + /** + * Feed a freshly-read chunk starting at the current offset, returning whole + * lines only. + */ + push(chunk: string): ReadonlyArray { + this.offset += Buffer.byteLength(chunk, "utf8"); + const combined = this.carry + chunk; + const lines = combined.split("\n"); + this.carry = lines.pop() ?? ""; + return lines; + } + + /** Flush the trailing line once the writer is known to be finished. */ + flush(): ReadonlyArray { + const remaining = this.carry; + this.carry = ""; + return remaining.trim().length > 0 ? [remaining] : []; + } +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3..f6d6625083a 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -20,6 +20,7 @@ * * @module provider/builtInDrivers */ +import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.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"; @@ -37,7 +38,8 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv - | OpenCodeDriverEnv; + | OpenCodeDriverEnv + | AntigravityDriverEnv; /** * Ordered list of built-in drivers. Order matters only for tie-breaking in @@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const outputRef = yield* Ref.make(""); + const runtime = yield* makeAntigravityAcpRuntime({ + antigravitySettings, + environment, + childProcessSpawner: commandSpawner, + cwd, + model: resolveAntigravityBaseModelId(modelSelection.model), + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + yield* runtime.start(); + return yield* runtime.prompt({ prompt: [{ type: "text", text: prompt }] }); + }).pipe( + Effect.timeoutOption(ANTIGRAVITY_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Antigravity request timed out.", + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Antigravity request failed.", + cause, + }), + ), + ); + + const trimmed = (yield* Ref.get(outputRef)).trim(); + if (!trimmed) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Antigravity request was cancelled." + : "Antigravity returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(trimmed)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Antigravity returned invalid structured output.", + cause, + }), + ), + }), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Antigravity text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("AntigravityTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + const generated = yield* runAntigravityJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("AntigravityTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + + const generated = yield* runAntigravityJson({ + 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("AntigravityTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runAntigravityJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { branch: sanitizeBranchFragment(generated.branch) }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("AntigravityTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runAntigravityJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index f9e7a700716..137c0703e1d 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("antigravity")]: AntigravityIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d680..36ad0d0f45a 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -1,4 +1,5 @@ import { + AntigravitySettings, 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,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("antigravity"), + label: "Antigravity", + icon: AntigravityIcon, + badgeLabel: "Experimental", + settingsSchema: AntigravitySettings, + }, ]; export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5d5051f748e..5cb6e9a866e 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -51,6 +51,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("antigravity"), + label: "Antigravity", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 47fcfccb954..77188a8bd30 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 ANTIGRAVITY_DRIVER_KIND = ProviderDriverKind.make("antigravity"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -152,6 +153,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", + [ANTIGRAVITY_DRIVER_KIND]: "Antigravity", }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 06f7de3db67..1bdd716b22b 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -339,6 +339,51 @@ export const GrokSettings = makeProviderSettingsSchema( ); export type GrokSettings = typeof GrokSettings.Type; +export const AntigravitySettings = 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 CLI binary.", + providerSettingsForm: { placeholder: "agy", clearWhenEmpty: "omit" }, + }), + ), + // Antigravity has no native agent protocol, so every turn runs through + // `agy --print`. A turn holds one process open for its whole duration; + // the default matches the bridge's own ceiling. + printTimeout: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Print timeout", + description: "Max duration for a single turn, passed to `agy --print-timeout`.", + providerSettingsForm: { placeholder: "2h", clearWhenEmpty: "omit" }, + }), + ), + // Trajectory discovery reads this root only when the Stop hook does not + // report a conversation id. Overridable for non-default AGY installs. + appDataDir: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "App data directory", + description: "Overrides ~/.gemini/antigravity-cli for conversation discovery.", + providerSettingsForm: { placeholder: "~/.gemini/antigravity-cli", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "printTimeout", "appDataDir"], + }, +); +export type AntigravitySettings = typeof AntigravitySettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( @@ -433,6 +478,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({}))), + antigravity: AntigravitySettings.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 @@ -528,6 +574,14 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const AntigravitySettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + printTimeout: Schema.optionalKey(TrimmedString), + appDataDir: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -558,6 +612,7 @@ export const ServerSettingsPatch = Schema.Struct({ cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), + antigravity: Schema.optionalKey(AntigravitySettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual From 9e113d7409802477ed465505d38d3dd187a889fd Mon Sep 17 00:00:00 2001 From: Agent <191493048+agentool@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:20:40 -0400 Subject: [PATCH 2/9] Fix five Antigravity bridge defects found in review - session/load no longer treats a UUID-shaped session id as an Antigravity conversation id. Bridge session ids are random UUIDs themselves, so the fallback could not tell them apart and resumed a conversation that never existed whenever the session map was missing. The map is now the only authority; an unknown id starts fresh. - The transcript file is pinned for the turn. transcript.jsonl appearing after streaming began from transcript_full.jsonl used to switch files while keeping the old byte offset, misaligning reads or re-emitting records already streamed. - Tool call bookkeeping survives PostToolUse. Hooks are drained before the transcript, so for a fast tool both arrive in one poll; deleting the step on completion dropped the real output permanently, with no fallback since post hooks carry no tool body. Records are now marked completed and the exit sweep only fails the ones still open. - session/cancel only records a cancellation for the turn actually running. Cancels bypass the request queue, so one arriving after a turn finished stayed set and made the next successful turn report stopReason cancelled. - The adapter validates the prompt before emitting turn.started, so an attachment-only or whitespace-only prompt no longer leaves a turn that never completes in the UI. --- .../src/provider/Layers/AntigravityAdapter.ts | 33 ++++++------ .../src/provider/acp/antigravity/agyBridge.ts | 54 ++++++++++++++----- .../acp/antigravity/agyEvents.test.ts | 20 +++++-- .../src/provider/acp/antigravity/agyEvents.ts | 30 ++++++++--- .../acp/antigravity/agyTranscript.test.ts | 22 ++++++++ .../provider/acp/antigravity/agyTranscript.ts | 5 +- 6 files changed, 124 insertions(+), 40 deletions(-) diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index 7ea4237c647..ee1ee50376b 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -485,6 +485,24 @@ export function makeAntigravityAdapter( const sendTurn: AntigravityAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { const ctx = yield* requireSession(input.threadId); + + // The bridge declares no image capability: `agy --print` takes text + // only, so attachments cannot be forwarded. This runs before any + // `turn.started` is offered — a rejected prompt that had already + // announced a turn would leave the UI with one that never completes. + const promptParts: Array = []; + if (input.input?.trim()) { + promptParts.push({ type: "text", text: input.input.trim() }); + } + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: + "Turn requires non-empty text. Antigravity print mode does not accept attachments.", + }); + } + // A sendTurn while a prompt is in flight is a steer: the new prompt // folds into the ongoing work, so the active turn id is reused. const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; @@ -510,21 +528,6 @@ export function makeAntigravityAdapter( }); } - // The bridge declares no image capability: `agy --print` takes text - // only, so attachments cannot be forwarded. - const promptParts: Array = []; - if (input.input?.trim()) { - promptParts.push({ type: "text", text: input.input.trim() }); - } - if (promptParts.length === 0) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "sendTurn", - issue: - "Turn requires non-empty text. Antigravity print mode does not accept attachments.", - }); - } - const result = yield* ctx.acp .prompt({ prompt: promptParts }) .pipe( diff --git a/apps/server/src/provider/acp/antigravity/agyBridge.ts b/apps/server/src/provider/acp/antigravity/agyBridge.ts index 5e51c8fced3..e9365746cae 100644 --- a/apps/server/src/provider/acp/antigravity/agyBridge.ts +++ b/apps/server/src/provider/acp/antigravity/agyBridge.ts @@ -90,15 +90,18 @@ function persistConversationId(sessionId: string, conversationId: string): void } } +/** + * Map a bridge session id to the Antigravity conversation it should resume. + * + * The persisted map is the only authority. Bridge session ids are themselves + * random UUIDs, so an id that merely looks like a conversation id is + * indistinguishable from one the bridge minted — falling back to the shape of + * the string would make `session/load` resume a conversation that never + * existed whenever the map is missing or unreadable. Returning `undefined` + * starts a fresh conversation, which is the recoverable outcome. + */ function lookupConversationId(sessionId: string): string | undefined { - const mapped = readSessionMap()[sessionId]?.trim(); - if (mapped) { - return mapped; - } - // A caller may hand back an Antigravity conversation UUID directly. - return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId) - ? sessionId - : undefined; + return readSessionMap()[sessionId]?.trim() || undefined; } // ── JSON-RPC plumbing ───────────────────────────────────────────────── @@ -312,6 +315,8 @@ interface TurnOutcome { } let activeChild: NodeChildProcess.ChildProcess | null = null; +/** Session whose turn is currently running, if any. Gates `session/cancel`. */ +let activeTurnSessionId: string | null = null; const cancelledSessions = new Set(); /** @@ -385,14 +390,24 @@ function drain(input: { * Hooks report `transcript_full.jsonl`; the sibling `transcript.jsonl` holds * the same steps without internal model chatter and is the better stream to * render. + * + * The choice is pinned for the rest of the turn. `transcriptOffset` and the + * line cursor are byte positions into whichever file was picked, so switching + * once the condensed file appears would resume reading at an offset that means + * nothing in the new file — skipping records, or re-emitting ones already + * streamed from the other one. */ function resolveTranscriptPath(state: AgyTurnState): string | undefined { + if (state.resolvedTranscriptPath) { + return state.resolvedTranscriptPath; + } const reported = state.transcriptPath; if (!reported) { return undefined; } const condensed = reported.replace(/transcript_full\.jsonl$/, "transcript.jsonl"); - return NodeFS.existsSync(condensed) ? condensed : reported; + state.resolvedTranscriptPath = NodeFS.existsSync(condensed) ? condensed : reported; + return state.resolvedTranscriptPath; } async function runTurn( @@ -400,6 +415,8 @@ async function runTurn( session: BridgeSession, prompt: string, ): Promise { + // A cancel that raced the end of an earlier turn must not decide this one. + cancelledSessions.delete(sessionId); const hookDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-agy-hookout-")); const hookWorkspace = createHookWorkspace(); const command = process.env["T3_AGY_COMMAND"]?.trim() || "agy"; @@ -415,6 +432,7 @@ async function runTurn( stdio: ["ignore", "pipe", "pipe"], }); activeChild = child; + activeTurnSessionId = sessionId; let stdout = ""; let stderr = ""; @@ -447,6 +465,7 @@ async function runTurn( clearInterval(poller); activeChild = null; + activeTurnSessionId = null; drain({ sessionId, hookDir, @@ -459,15 +478,18 @@ async function runTurn( }); // Any tool still open at exit would otherwise render as spinning forever. - for (const [, active] of state.activeToolCalls) { + for (const [, call] of state.toolCalls) { + if (call.completed) { + continue; + } sendSessionUpdate(sessionId, { sessionUpdate: "tool_call_update", - toolCallId: active.toolCallId, + toolCallId: call.toolCallId, status: "failed", rawOutput: { isError: true, error: "Antigravity exited before the tool reported completion" }, }); } - state.activeToolCalls.clear(); + state.toolCalls.clear(); if (state.conversationId) { session.conversationId = state.conversationId; @@ -586,10 +608,14 @@ async function handleRequest(message: Record): Promise { } case "session/cancel": { const sessionId = typeof params["sessionId"] === "string" ? params["sessionId"] : undefined; - if (sessionId) { + // Only a cancel aimed at the turn actually running can decide its stop + // reason. Cancels bypass the request queue, so one that arrives after a + // turn has already finished — or targets an idle session — would + // otherwise sit in the set and mark the next successful turn cancelled. + if (sessionId && sessionId === activeTurnSessionId) { cancelledSessions.add(sessionId); + activeChild?.kill("SIGTERM"); } - activeChild?.kill("SIGTERM"); return; } default: { diff --git a/apps/server/src/provider/acp/antigravity/agyEvents.test.ts b/apps/server/src/provider/acp/antigravity/agyEvents.test.ts index 24d8fbe3133..9d1a30c739c 100644 --- a/apps/server/src/provider/acp/antigravity/agyEvents.test.ts +++ b/apps/server/src/provider/acp/antigravity/agyEvents.test.ts @@ -80,7 +80,7 @@ describe("hookSessionUpdate", () => { status: "in_progress", rawInput: { CommandLine: "ls -la src" }, }); - expect(state.activeToolCalls.has(3)).toBe(true); + expect(state.toolCalls.get(3)?.completed).toBe(false); }); it("learns the conversation and transcript from any hook, not just stop", () => { @@ -102,7 +102,7 @@ describe("hookSessionUpdate", () => { ); expect(update).toBeNull(); - expect(state.activeToolCalls.size).toBe(0); + expect(state.toolCalls.size).toBe(0); }); it("completes a tool call that was announced", () => { @@ -121,7 +121,21 @@ describe("hookSessionUpdate", () => { status: "completed", rawOutput: { isError: false }, }); - expect(state.activeToolCalls.size).toBe(0); + // Retained, not deleted: the transcript record carrying this step's output + // is often read in the same drain pass and still needs the tool call id. + expect(state.toolCalls.get(3)?.completed).toBe(true); + }); + + it("ignores a duplicate post hook for an already completed step", () => { + const state = makeAgyTurnState(); + hookSessionUpdate(preToolUse(), state); + const post = { + event: "post-tool-use", + payload: { conversationId: "conversation-1", stepIdx: 3, error: "" }, + } as const; + + expect(hookSessionUpdate(post, state)).not.toBeNull(); + expect(hookSessionUpdate(post, state)).toBeNull(); }); it("marks a tool call failed and carries the error through", () => { diff --git a/apps/server/src/provider/acp/antigravity/agyEvents.ts b/apps/server/src/provider/acp/antigravity/agyEvents.ts index 077ee0a66f8..9539d708ac6 100644 --- a/apps/server/src/provider/acp/antigravity/agyEvents.ts +++ b/apps/server/src/provider/acp/antigravity/agyEvents.ts @@ -167,30 +167,42 @@ export function agyUpdateMeta(event: string, payload: AgyHookPayload): Record; + /** + * Every tool call seen this turn, keyed by step index — completed ones + * included. The two event sources are drained in the same pass, so a step + * must stay addressable after its `PostToolUse` hook or its transcript + * output would be dropped whenever both arrive within one poll. + */ + readonly toolCalls: Map; conversationId: string | undefined; transcriptPath: string | undefined; + /** Transcript file pinned for the turn; see `resolveTranscriptPath`. */ + resolvedTranscriptPath: string | undefined; modelName: string | undefined; } export function makeAgyTurnState(conversationId?: string): AgyTurnState { return { - activeToolCalls: new Map(), + toolCalls: new Map(), conversationId, transcriptPath: undefined, + resolvedTranscriptPath: undefined, modelName: undefined, }; } @@ -223,12 +235,13 @@ export function preToolUseUpdate( const toolCallId = agyToolCallId(payload.conversationId, stepIdx); const kind = agyToolKind(toolCall.name); const targetPath = agyTargetPath(toolCall); - state.activeToolCalls.set(stepIdx, { + state.toolCalls.set(stepIdx, { toolCallId, name: toolCall.name, kind, targetPath, beforeText, + completed: false, }); return { @@ -259,13 +272,16 @@ export function postToolUseUpdate( if (typeof stepIdx !== "number") { return null; } - const active = state.activeToolCalls.get(stepIdx); + const active = state.toolCalls.get(stepIdx); // Only complete calls whose `PreToolUse` we actually observed; Antigravity // emits unpaired post hooks for internal steps. - if (!active) { + if (!active || active.completed) { return null; } - state.activeToolCalls.delete(stepIdx); + // Marked rather than removed: the transcript record carrying this step's + // output is often read in the same drain pass, and dropping the entry here + // would leave it with no tool call to attach to. + active.completed = true; const error = typeof payload.error === "string" ? payload.error.trim() : ""; const failed = error.length > 0; diff --git a/apps/server/src/provider/acp/antigravity/agyTranscript.test.ts b/apps/server/src/provider/acp/antigravity/agyTranscript.test.ts index c1cddfba3f2..b13d91aceb4 100644 --- a/apps/server/src/provider/acp/antigravity/agyTranscript.test.ts +++ b/apps/server/src/provider/acp/antigravity/agyTranscript.test.ts @@ -119,6 +119,28 @@ describe("transcriptRecordUpdates", () => { }); }); + it("still attaches output after the post hook completed the call", () => { + // One drain pass reads hooks before the transcript, so for a fast tool the + // PostToolUse hook and the record carrying its output arrive together. If + // completing a call dropped its bookkeeping, that output would be lost. + const state = stateWithActiveTool(3); + hookSessionUpdate( + { event: "post-tool-use", payload: { conversationId: "conversation-1", stepIdx: 3 } }, + state, + ); + + const result = transcriptRecordUpdates( + { step_index: 3, source: "MODEL", type: "RUN_COMMAND", content: "Created At: x\nok" }, + state, + ); + + expect(result.updates[0]).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "agy-conversation-1-3", + content: [{ type: "content", content: { type: "text", text: "ok" } }], + }); + }); + it("drops tool output with no announced call rather than inventing one", () => { const result = transcriptRecordUpdates( { step_index: 99, source: "MODEL", type: "RUN_COMMAND", content: "orphan" }, diff --git a/apps/server/src/provider/acp/antigravity/agyTranscript.ts b/apps/server/src/provider/acp/antigravity/agyTranscript.ts index 4bfead4ff86..caf3d9ae250 100644 --- a/apps/server/src/provider/acp/antigravity/agyTranscript.ts +++ b/apps/server/src/provider/acp/antigravity/agyTranscript.ts @@ -130,7 +130,10 @@ export function transcriptRecordUpdates( if (typeof stepIndex !== "number") { return { updates: [], emittedAssistantText: false }; } - const active = state.activeToolCalls.get(stepIndex); + // Completed calls stay in the map precisely so this lookup still resolves: + // a fast tool's `PostToolUse` hook and its transcript record routinely land + // in the same drain pass, and hooks are read first. + const active = state.toolCalls.get(stepIndex); if (!active) { return { updates: [], emittedAssistantText: false }; } From c73d5daa00c51cb34723c841cad1ae672d62249e Mon Sep 17 00:00:00 2001 From: Agent <191493048+agentool@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:29:27 -0400 Subject: [PATCH 3/9] Antigravity: fix review findings, add model switching and attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-two fixes from automated review: - Attachments are staged into a per-turn temp directory instead of adding the attachment store to the workspace, which would have handed an auto-approving agent read access to every thread's uploads. - Resumed conversations no longer replay history. One conversation keeps a single append-only transcript and reading starts at byte 0, so the first batch is trimmed to what follows the last USER_INPUT record — verified against real two-turn transcripts. - The session map is written through a temp file and renamed. It is now the sole resume authority, and a torn write read back as empty JSON would silently start a fresh conversation. - Non-string values in that map are discarded rather than cast, and an unhandled handler failure now answers the request instead of leaving the client blocked forever. - Turn setup releases its claim and removes its temp directories if it throws, and a failed turn clears the public session's active turn id. - File URIs go through pathToFileURL, which escapes characters that would otherwise truncate the path on the way back. Two limitations removed after checking the CLI rather than assuming: - Model switching works. `--model` composes with `--conversation`: a resumed conversation answers on the new model with its history intact, so the bridge takes `session/set_model` and the capability is now "in-session". - Attachments work. `agy --print` has no attachment flag, so files travel as `resource_link` blocks and the bridge names the staged paths in the prompt. --- AGENTS.md | 7 +- apps/server/src/attachmentStore.ts | 14 + .../src/provider/Layers/AntigravityAdapter.ts | 139 +++++++-- .../src/provider/acp/antigravity/agyBridge.ts | 270 +++++++++++++++--- .../src/provider/acp/antigravity/agyEvents.ts | 6 + .../acp/antigravity/agyTranscript.test.ts | 38 +++ .../provider/acp/antigravity/agyTranscript.ts | 23 ++ 7 files changed, 443 insertions(+), 54 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6a40825f5d8..e7dcc83f88c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,12 @@ step index (`stepIdx` in a hook == `step_index` in the transcript): Known constraints, all inherent to print mode: - Tools are auto-approved (`--dangerously-skip-permissions`); no approval flow is possible. -- No image attachments, and no session modes. +- Attachments are passed by path, not inline: the adapter sends `resource_link` blocks for + files the attachment store already wrote to disk, and the bridge names those paths in the + prompt and adds their directory to the workspace. `agy` has no attachment flag, so inline + image capability stays off. +- No session modes, and no provider-side rollback — a resumed conversation keeps its full + trajectory, so `rollbackThread` fails rather than silently truncating local state only. - The model binds when the bridge spawns `agy`, so `sessionModelSwitch` is `"unsupported"`. - Any `agy` subcommand spawned for a probe must set `stdin: "ignore"`. `agy` starts a language server and will not emit output while stdin stays open, so the default `"pipe"` hangs it. diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 3d5b531db21..f85a769409b 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -1,5 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeCrypto from "node:crypto"; +import * as NodeURL from "node:url"; import * as NodeFS from "node:fs"; import type { ChatAttachment } from "@t3tools/contracts"; @@ -76,6 +77,19 @@ export function resolveAttachmentPath(input: { }); } +/** + * `file:` URL for a resolved attachment path. + * + * Providers that hand attachments to an external agent by reference need a URI + * rather than a bare path. `pathToFileURL` is used rather than string + * concatenation because it escapes `#` and `?` — which would otherwise + * truncate the path on the way back through `fileURLToPath` — and handles + * Windows drive letters. + */ +export function attachmentFileUrl(attachmentPath: string): string { + return NodeURL.pathToFileURL(attachmentPath).href; +} + export function resolveAttachmentPathById(input: { readonly attachmentsDir: string; readonly attachmentId: string; diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index ee1ee50376b..6e731da540f 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -9,9 +9,12 @@ * `--dangerously-skip-permissions` because print mode cannot prompt, so * tools are auto-approved and no approval flow can be surfaced. * - **No session modes.** Print mode has no plan/ask distinction to switch. - * - **Model is fixed per session.** The bridge passes `--model` when it - * spawns `agy`, so changing models requires a new session - * (`sessionModelSwitch: "unsupported"`). + * - **Attachments travel by reference.** `agy --print` has no attachment + * flag, so files are sent as `resource_link` blocks and the bridge stages + * them into a directory it grants the CLI access to for that turn. + * - **Model changes apply from the next turn.** `--model` is a per-spawn + * flag that composes with `--conversation`, so a switch keeps the + * trajectory rather than needing a new session. * * @module AntigravityAdapterLive */ @@ -42,6 +45,8 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import type * as EffectAcpSchema from "effect-acp/schema"; +import { attachmentFileUrl, resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { ProviderAdapterProcessError, @@ -93,6 +98,8 @@ interface AntigravitySessionContext { notificationFiber: Fiber.Fiber | undefined; readonly turns: Array<{ id: TurnId; items: Array }>; activeTurnId: TurnId | undefined; + /** Model the bridge will pass to `agy --model` on the next turn. */ + currentModelId: string | undefined; /** * Number of prompts in flight. >0 means a turn is running, so a new * sendTurn steers the existing turn rather than opening a new one. @@ -135,6 +142,7 @@ export function makeAntigravityAdapter( ) { return Effect.gen(function* () { const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("antigravity"); + const serverConfig = yield* Effect.service(ServerConfig); const path = yield* Path.Path; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const crypto = yield* Crypto.Crypto; @@ -362,6 +370,7 @@ export function makeAntigravityAdapter( notificationFiber: undefined, turns: [], activeTurnId: undefined, + currentModelId: boundModel, promptsInFlight: 0, stopped: false, }; @@ -486,20 +495,50 @@ export function makeAntigravityAdapter( Effect.gen(function* () { const ctx = yield* requireSession(input.threadId); - // The bridge declares no image capability: `agy --print` takes text - // only, so attachments cannot be forwarded. This runs before any - // `turn.started` is offered — a rejected prompt that had already - // announced a turn would leave the UI with one that never completes. - const promptParts: Array = []; - if (input.input?.trim()) { - promptParts.push({ type: "text", text: input.input.trim() }); - } + // `agy --print` takes a single text prompt, so attachments travel as + // `resource_link` blocks (ACP baseline, no capability needed) pointing + // at the files the attachment store already wrote to disk. The bridge + // renders those paths into the prompt and grants `agy` read access to + // stages just those files into a per-turn directory it can grant `agy` + // access to. Nothing is re-encoded. + // + // This runs before any `turn.started` is offered — a rejected prompt + // that had already announced a turn would leave the UI with one that + // never completes. + const text = input.input?.trim(); + const attachmentParts = yield* Effect.forEach(input.attachments ?? [], (attachment) => + Effect.gen(function* () { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + return { + type: "resource_link", + // `pathToFileURL` rather than hand-built escaping: it handles + // Windows drive letters and escapes `#`/`?`, which would + // otherwise truncate the path when the bridge parses it back. + uri: attachmentFileUrl(attachmentPath), + name: path.basename(attachmentPath), + ...(attachment.mimeType ? { mimeType: attachment.mimeType } : {}), + } satisfies EffectAcpSchema.ContentBlock; + }), + ); + const promptParts: Array = [ + ...(text ? [{ type: "text" as const, text }] : []), + ...attachmentParts, + ]; if (promptParts.length === 0) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, operation: "sendTurn", - issue: - "Turn requires non-empty text. Antigravity print mode does not accept attachments.", + issue: "Turn requires non-empty text or attachments.", }); } @@ -508,6 +547,9 @@ export function makeAntigravityAdapter( const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); ctx.promptsInFlight += 1; + // Tracks whether a terminal event was published on the success path, so + // the teardown below can tell "already reported" from "died silently". + let settled = false; return yield* Effect.gen(function* () { ctx.activeTurnId = turnId; @@ -528,6 +570,28 @@ export function makeAntigravityAdapter( }); } + // `agy` binds the model with a `--model` flag on each spawn, and that + // flag composes with `--conversation` — verified against the CLI: a + // resumed conversation answers on the new model with its history + // intact. So a switch is applied to the bridge session here and takes + // effect on the turn about to run. + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const requestedModelId = turnModelSelection?.model + ? resolveAntigravityBaseModelId(turnModelSelection.model) + : undefined; + if (requestedModelId !== undefined && requestedModelId !== ctx.currentModelId) { + yield* ctx.acp + .setSessionModel(requestedModelId) + .pipe( + Effect.mapError((cause) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause), + ), + ); + ctx.currentModelId = requestedModelId; + ctx.session = { ...ctx.session, model: turnModelSelection?.model ?? ctx.session.model }; + } + const result = yield* ctx.acp .prompt({ prompt: promptParts }) .pipe( @@ -562,6 +626,7 @@ export function makeAntigravityAdapter( stopReason: result.stopReason ?? null, }, }); + settled = true; } return { @@ -571,9 +636,34 @@ export function makeAntigravityAdapter( }; }).pipe( Effect.ensuring( - Effect.sync(() => { - ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); - }), + Effect.gen(function* () { + const remaining = Math.max(0, ctx.promptsInFlight - 1); + ctx.promptsInFlight = remaining; + // A prompt that failed or was interrupted after `turn.started` + // was announced still owes consumers a terminal event; without + // one the turn renders as running forever even though sendTurn + // has already returned an error. + if (settled || remaining > 0) { + return; + } + if (ctx.activeTurnId === turnId) { + ctx.activeTurnId = undefined; + } + // The public session field is what `listSessions` and the reaper + // read, so leaving it set would advertise a turn that has ended. + if (ctx.session.activeTurnId === turnId) { + const { activeTurnId: _endedTurnId, ...endedSession } = ctx.session; + ctx.session = { ...endedSession, status: "ready", updatedAt: yield* nowIso }; + } + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { state: "failed", stopReason: null }, + }); + }).pipe(Effect.catch(() => Effect.void)), ), ); }); @@ -625,7 +715,7 @@ export function makeAntigravityAdapter( const rollbackThread: AntigravityAdapterShape["rollbackThread"] = (threadId, numTurns) => Effect.gen(function* () { - const ctx = yield* requireSession(threadId); + yield* requireSession(threadId); if (!Number.isInteger(numTurns) || numTurns < 1) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, @@ -633,9 +723,16 @@ export function makeAntigravityAdapter( issue: "numTurns must be an integer >= 1.", }); } - const nextLength = Math.max(0, ctx.turns.length - numTurns); - ctx.turns.splice(nextLength); - return { threadId, turns: ctx.turns }; + // Truncating the local turn list would report success while leaving the + // Antigravity trajectory untouched: the next turn resumes the same + // `--conversation` and still sees the rolled-back exchanges, so the + // model would answer from history the user believes is gone. Print mode + // exposes no rewind primitive, so this fails loudly instead. + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/rollback", + detail: "Antigravity conversations do not support provider-side rollback.", + }); }); const stopSession: AntigravityAdapterShape["stopSession"] = (threadId) => @@ -675,7 +772,7 @@ export function makeAntigravityAdapter( provider: PROVIDER, // `agy --model` is applied when the bridge spawns the CLI, so switching // models mid-session is not possible. - capabilities: { sessionModelSwitch: "unsupported" }, + capabilities: { sessionModelSwitch: "in-session" }, startSession, sendTurn, interruptTurn, diff --git a/apps/server/src/provider/acp/antigravity/agyBridge.ts b/apps/server/src/provider/acp/antigravity/agyBridge.ts index e9365746cae..acf35cd1fe2 100644 --- a/apps/server/src/provider/acp/antigravity/agyBridge.ts +++ b/apps/server/src/provider/acp/antigravity/agyBridge.ts @@ -19,6 +19,7 @@ import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import packageJson from "../../../../package.json" with { type: "json" }; import { @@ -34,6 +35,7 @@ import { } from "./agyEvents.ts"; import { AgyTranscriptCursor, + dropPriorTurnRecords, parseTranscriptLine, transcriptRecordUpdates, } from "./agyTranscript.ts"; @@ -47,6 +49,14 @@ interface BridgeSession { readonly cwd: string; systemPrompt: string | undefined; conversationId: string | undefined; + /** + * Model and effort for the next turn. Seeded from the spawn environment and + * replaced by `session/set_model`; both are command-line flags on every + * spawn, so a change takes effect on the following turn without disturbing + * the conversation it resumes. + */ + model: string | undefined; + effort: string | undefined; } const sessions = new Map(); @@ -68,7 +78,19 @@ function stateFilePath(): string { function readSessionMap(): Record { try { const parsed: unknown = JSON.parse(NodeFS.readFileSync(stateFilePath(), "utf8")); - return typeof parsed === "object" && parsed !== null ? (parsed as Record) : {}; + if (typeof parsed !== "object" || parsed === null) { + return {}; + } + // The bridge does not exclusively own this file, so entries are validated + // rather than cast. A non-string value reaching a caller would throw and + // leave the request it came from unanswered. + const map: Record = {}; + for (const [key, value] of Object.entries(parsed as Record)) { + if (typeof value === "string") { + map[key] = value; + } + } + return map; } catch { return {}; } @@ -83,7 +105,14 @@ function persistConversationId(sessionId: string, conversationId: string): void return; } map[sessionId] = conversationId; - NodeFS.writeFileSync(target, JSON.stringify(map, null, 2)); + // Written through a uniquely-named temp file and renamed into place. The + // map is now the sole resume authority and several bridge processes can + // finish turns at once; a partial write would be read back as empty JSON + // and silently start a fresh conversation. Rename is atomic within a + // filesystem, so a reader sees either the old file or the complete new one. + const staging = `${target}.${process.pid}.${NodeCrypto.randomUUID()}.tmp`; + NodeFS.writeFileSync(staging, JSON.stringify(map, null, 2)); + NodeFS.renameSync(staging, target); } catch { // Losing the mapping costs conversation continuity on the next resume, // which is not worth failing a turn over. @@ -259,21 +288,25 @@ export async function runAgyHook(event: string): Promise { // ── Turn execution ──────────────────────────────────────────────────── -function buildAgyArgs( - session: BridgeSession, - hookWorkspace: string, - prompt: string, -): Array { +function buildAgyArgs(input: { + readonly session: BridgeSession; + readonly hookWorkspace: string; + readonly attachmentDir: string | undefined; + readonly promptText: string; +}): Array { + const { session, hookWorkspace, attachmentDir } = input; const args = [ "--dangerously-skip-permissions", "--print-timeout", process.env["T3_AGY_PRINT_TIMEOUT"]?.trim() || DEFAULT_PRINT_TIMEOUT, ]; - const model = process.env["T3_AGY_MODEL"]?.trim(); + // Per session, not per process: `--model` applies to the turn being spawned + // and composes with `--conversation`, so the trajectory survives a switch. + const model = session.model?.trim(); if (model) { args.push("--model", model); } - const effort = process.env["T3_AGY_EFFORT"]?.trim(); + const effort = session.effort?.trim(); if (effort) { args.push("--effort", effort); } @@ -284,29 +317,127 @@ function buildAgyArgs( // session workspace is registered so its `.agents` skills and rules load; // the hook workspace is registered so the observer attaches. args.push("--add-dir", session.cwd, "--add-dir", hookWorkspace); - args.push("--print", prompt); + if (attachmentDir) { + args.push("--add-dir", attachmentDir); + } + args.push("--print", input.promptText); return args; } -function renderPrompt(session: BridgeSession, promptBlocks: unknown): string | null { +/** A local file referenced by a `resource_link` prompt block. */ +interface PromptAttachment { + readonly path: string; + readonly name: string; + readonly mimeType: string | undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** + * Extract local files from `resource_link` blocks. + * + * Only `file:` URIs are taken: a remote URL is left in the prompt text for the + * agent's own fetch tool, and passing one to `--add-dir` would be meaningless. + */ +function collectAttachments(promptBlocks: ReadonlyArray): ReadonlyArray { + const attachments: Array = []; + for (const block of promptBlocks) { + if (!isRecord(block) || block["type"] !== "resource_link") { + continue; + } + const uri = block["uri"]; + if (typeof uri !== "string" || !uri.startsWith("file://")) { + continue; + } + let filePath: string; + try { + filePath = NodeURL.fileURLToPath(uri); + } catch { + continue; + } + const name = typeof block["name"] === "string" ? block["name"] : NodePath.basename(filePath); + attachments.push({ + path: filePath, + name, + mimeType: typeof block["mimeType"] === "string" ? block["mimeType"] : undefined, + }); + } + return attachments; +} + +interface RenderedPrompt { + readonly baseText: string; + readonly attachments: ReadonlyArray; +} + +function renderPrompt(session: BridgeSession, promptBlocks: unknown): RenderedPrompt | null { if (!Array.isArray(promptBlocks)) { return null; } const text = promptBlocks .filter( (block): block is { type: string; text: string } => - typeof block === "object" && - block !== null && - (block as { type?: unknown }).type === "text" && - typeof (block as { text?: unknown }).text === "string", + isRecord(block) && block["type"] === "text" && typeof block["text"] === "string", ) .map((block) => block.text) - .join("\n\n"); - if (text.trim().length === 0) { + .join("\n\n") + .trim(); + + const attachments = collectAttachments(promptBlocks); + if (text.length === 0 && attachments.length === 0) { return null; } + const systemPrompt = session.systemPrompt?.trim(); - return systemPrompt ? `System instructions:\n${systemPrompt}\n\nRequest:\n${text}` : text; + return { + baseText: systemPrompt ? `System instructions:\n${systemPrompt}\n\nRequest:\n${text}` : text, + attachments, + }; +} + +/** + * Copy this turn's attachments into a throwaway directory. + * + * `agy --print` has no attachment flag, so files must be named by path with + * their directory registered via `--add-dir`. Registering the attachment store + * itself would hand an auto-approving agent read access to every thread's + * uploads, so only the files this turn references are staged, and the staging + * directory dies with the turn. + */ +function stageAttachments(attachments: ReadonlyArray): { + readonly dir: string | undefined; + readonly staged: ReadonlyArray; +} { + if (attachments.length === 0) { + return { dir: undefined, staged: [] }; + } + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-agy-attach-")); + const staged: Array = []; + attachments.forEach((attachment, index) => { + // Index-prefixed and sanitised so two attachments sharing a basename cannot + // collide and a crafted name cannot escape the staging directory. + const safeName = `${index}-${NodePath.basename(attachment.name).replace(/[^\w.-]+/g, "_")}`; + const target = NodePath.join(dir, safeName); + try { + NodeFS.copyFileSync(attachment.path, target); + } catch { + // An unreadable attachment is dropped rather than failing the whole turn. + return; + } + staged.push({ path: target, name: safeName, mimeType: attachment.mimeType }); + }); + return { dir, staged }; +} + +function composePromptText(baseText: string, staged: ReadonlyArray): string { + if (staged.length === 0) { + return baseText; + } + const list = staged.map((a) => `- ${a.path}${a.mimeType ? ` (${a.mimeType})` : ""}`).join("\n"); + const block = `Attached files (read them from these paths):\n${list}`; + return baseText.length > 0 ? `${baseText}\n\n${block}` : block; } interface TurnOutcome { @@ -369,7 +500,13 @@ function drain(input: { } const lines = chunk.length > 0 ? input.cursor.push(chunk) : []; - const allLines = input.final ? [...lines, ...input.cursor.flush()] : lines; + let allLines = input.final ? [...lines, ...input.cursor.flush()] : lines; + // Reading always starts at byte 0, so the first batch of a resumed + // conversation carries every prior turn. Trim once, then stream. + if (!input.state.transcriptPrimed && allLines.length > 0) { + allLines = [...dropPriorTurnRecords(allLines)]; + input.state.transcriptPrimed = true; + } for (const line of allLines) { const record = parseTranscriptLine(line); if (!record) { @@ -413,26 +550,59 @@ function resolveTranscriptPath(state: AgyTurnState): string | undefined { async function runTurn( sessionId: string, session: BridgeSession, - prompt: string, + prompt: RenderedPrompt, ): Promise { // A cancel that raced the end of an earlier turn must not decide this one. cancelledSessions.delete(sessionId); - const hookDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-agy-hookout-")); - const hookWorkspace = createHookWorkspace(); - const command = process.env["T3_AGY_COMMAND"]?.trim() || "agy"; + // Claimed before any setup work: spawning `agy` takes long enough that a + // cancel can land first, and cancels are only honoured for the session + // holding this claim. Leaving it unset until after the spawn would silently + // drop those, letting an auto-approving child run on past a cancelled turn. + activeTurnSessionId = sessionId; + let hookDir: string | undefined; + let hookWorkspace: string | undefined; + let attachmentDir: string | undefined; + let child: NodeChildProcess.ChildProcess; + try { + hookDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-agy-hookout-")); + hookWorkspace = createHookWorkspace(); + const attachments = stageAttachments(prompt.attachments); + attachmentDir = attachments.dir; + const command = process.env["T3_AGY_COMMAND"]?.trim() || "agy"; + child = NodeChildProcess.spawn( + command, + buildAgyArgs({ + session, + hookWorkspace, + attachmentDir, + promptText: composePromptText(prompt.baseText, attachments.staged), + }), + { + cwd: session.cwd, + env: { ...process.env, [HOOK_DIR_ENV]: hookDir }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + } catch (error) { + // Setup failed, so no turn is running: release the claim and reclaim the + // directories, or repeated failures would leak one set each time. + activeTurnSessionId = null; + cleanupDir(hookDir); + cleanupDir(hookWorkspace); + cleanupDir(attachmentDir); + throw error; + } + const state = makeAgyTurnState(session.conversationId); const seenHooks = new Set(); const cursor = new AgyTranscriptCursor(); const transcriptOffset = { value: 0 }; const assistantText = { emitted: false }; - - const child = NodeChildProcess.spawn(command, buildAgyArgs(session, hookWorkspace, prompt), { - cwd: session.cwd, - env: { ...process.env, [HOOK_DIR_ENV]: hookDir }, - stdio: ["ignore", "pipe", "pipe"], - }); activeChild = child; - activeTurnSessionId = sessionId; + // A cancel during startup had no process to signal; deliver it now. + if (cancelledSessions.has(sessionId)) { + child.kill("SIGTERM"); + } let stdout = ""; let stderr = ""; @@ -498,6 +668,7 @@ async function runTurn( cleanupDir(hookDir); cleanupDir(hookWorkspace); + cleanupDir(attachmentDir); if (cancelledSessions.delete(sessionId)) { return { stopReason: "cancelled" }; @@ -519,7 +690,10 @@ async function runTurn( return { stopReason: "end_turn" }; } -function cleanupDir(dir: string): void { +function cleanupDir(dir: string | undefined): void { + if (!dir) { + return; + } try { NodeFS.rmSync(dir, { recursive: true, force: true }); } catch { @@ -546,6 +720,8 @@ async function handleRequest(message: Record): Promise { protocolVersion: Math.min(requested, 1), agentCapabilities: { loadSession: true, + // Images ride in as `resource_link` blocks (an ACP baseline type) + // rather than inline base64, so the `image` capability stays off. promptCapabilities: { image: false, audio: false, embeddedContext: false }, mcpCapabilities: { http: false, sse: false }, }, @@ -582,10 +758,30 @@ async function handleRequest(message: Record): Promise { systemPrompt: typeof params["systemPrompt"] === "string" ? params["systemPrompt"] : undefined, conversationId: requestedSessionId ? lookupConversationId(requestedSessionId) : undefined, + model: process.env["T3_AGY_MODEL"]?.trim() || undefined, + effort: process.env["T3_AGY_EFFORT"]?.trim() || undefined, }); sendResult(id, method === "session/load" ? {} : { sessionId }); return; } + // `--model` is a per-spawn flag that composes with `--conversation`, so a + // switch applies from the next turn while the trajectory carries over. + case "session/set_model": { + const sessionId = typeof params["sessionId"] === "string" ? params["sessionId"] : undefined; + const session = sessionId ? sessions.get(sessionId) : undefined; + if (!session) { + sendError(id, -32602, "unknown sessionId"); + return; + } + const modelId = typeof params["modelId"] === "string" ? params["modelId"].trim() : ""; + if (modelId.length === 0) { + sendError(id, -32602, "session/set_model requires a modelId"); + return; + } + session.model = modelId; + sendResult(id, {}); + return; + } case "session/prompt": { const sessionId = typeof params["sessionId"] === "string" ? params["sessionId"] : undefined; const session = sessionId ? sessions.get(sessionId) : undefined; @@ -663,7 +859,17 @@ export async function runAgyBridge(): Promise { void handleRequest(message); continue; } - queue = queue.then(() => handleRequest(message)).catch(() => undefined); + const pending = message; + queue = queue + .then(() => handleRequest(pending)) + .catch((error: unknown) => { + // Every request must be answered. Swallowing a handler failure would + // leave the client blocked on a response that never arrives. + if (pending["id"] !== undefined) { + const detail = error instanceof Error ? error.message : String(error); + sendError(pending["id"], -32603, `internal bridge error: ${detail}`); + } + }); } } diff --git a/apps/server/src/provider/acp/antigravity/agyEvents.ts b/apps/server/src/provider/acp/antigravity/agyEvents.ts index 9539d708ac6..3256541245d 100644 --- a/apps/server/src/provider/acp/antigravity/agyEvents.ts +++ b/apps/server/src/provider/acp/antigravity/agyEvents.ts @@ -194,6 +194,11 @@ export interface AgyTurnState { transcriptPath: string | undefined; /** Transcript file pinned for the turn; see `resolveTranscriptPath`. */ resolvedTranscriptPath: string | undefined; + /** + * Whether records predating this turn have been discarded. Reading starts at + * byte 0, which on a resumed conversation is the start of the whole history. + */ + transcriptPrimed: boolean; modelName: string | undefined; } @@ -203,6 +208,7 @@ export function makeAgyTurnState(conversationId?: string): AgyTurnState { conversationId, transcriptPath: undefined, resolvedTranscriptPath: undefined, + transcriptPrimed: false, modelName: undefined, }; } diff --git a/apps/server/src/provider/acp/antigravity/agyTranscript.test.ts b/apps/server/src/provider/acp/antigravity/agyTranscript.test.ts index b13d91aceb4..7d921a136cc 100644 --- a/apps/server/src/provider/acp/antigravity/agyTranscript.test.ts +++ b/apps/server/src/provider/acp/antigravity/agyTranscript.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { hookSessionUpdate, makeAgyTurnState, type AgyTurnState } from "./agyEvents.ts"; import { AgyTranscriptCursor, + dropPriorTurnRecords, normalizeToolOutput, parseTranscriptLine, transcriptRecordUpdates, @@ -151,6 +152,43 @@ describe("transcriptRecordUpdates", () => { }); }); +describe("dropPriorTurnRecords", () => { + const line = (step: number, type: string) => JSON.stringify({ step_index: step, type }); + + it("keeps only what follows the last USER_INPUT", () => { + // Shape taken from a real two-turn transcript: one append-only file per + // conversation, each turn opening with USER_INPUT. + const lines = [ + line(0, "USER_INPUT"), + line(1, "CONVERSATION_HISTORY"), + line(2, "PLANNER_RESPONSE"), + line(3, "LIST_DIRECTORY"), + line(4, "CHECKPOINT"), + line(5, "PLANNER_RESPONSE"), + line(6, "USER_INPUT"), + line(7, "SYSTEM_MESSAGE"), + line(8, "PLANNER_RESPONSE"), + ]; + + expect(dropPriorTurnRecords(lines)).toEqual([ + line(7, "SYSTEM_MESSAGE"), + line(8, "PLANNER_RESPONSE"), + ]); + }); + + it("drops this turn's own opening record on a fresh conversation", () => { + expect(dropPriorTurnRecords([line(0, "USER_INPUT"), line(1, "PLANNER_RESPONSE")])).toEqual([ + line(1, "PLANNER_RESPONSE"), + ]); + }); + + it("keeps everything when the opening record has not been written yet", () => { + const lines = [line(2, "PLANNER_RESPONSE")]; + expect(dropPriorTurnRecords(lines)).toEqual(lines); + expect(dropPriorTurnRecords([])).toEqual([]); + }); +}); + describe("AgyTranscriptCursor", () => { it("holds back a partial trailing line until the rest arrives", () => { const cursor = new AgyTranscriptCursor(); diff --git a/apps/server/src/provider/acp/antigravity/agyTranscript.ts b/apps/server/src/provider/acp/antigravity/agyTranscript.ts index caf3d9ae250..bd965c10218 100644 --- a/apps/server/src/provider/acp/antigravity/agyTranscript.ts +++ b/apps/server/src/provider/acp/antigravity/agyTranscript.ts @@ -155,6 +155,29 @@ export function transcriptRecordUpdates( }; } +/** + * Drop transcript records belonging to earlier turns. + * + * One conversation keeps a single append-only transcript, and every turn opens + * with a `USER_INPUT` record, so the last one in the file marks where the + * current turn begins. Resuming a conversation starts reading at byte 0 — the + * turn's own records cannot be located any other way — which without this trim + * would replay every prior assistant message as new output. + * + * Applied only to the first batch of a turn. Returns the input unchanged when + * no `USER_INPUT` is present, which is the correct read for a transcript whose + * opening record has not been written yet. + */ +export function dropPriorTurnRecords(lines: ReadonlyArray): ReadonlyArray { + let lastUserInput = -1; + for (let index = 0; index < lines.length; index += 1) { + if (parseTranscriptLine(lines[index] ?? "")?.type === "USER_INPUT") { + lastUserInput = index; + } + } + return lastUserInput === -1 ? lines : lines.slice(lastUserInput + 1); +} + /** * Incremental transcript cursor. * From c2ac39692b4b03cd0c6fa72ddc41654a755e29d1 Mon Sep 17 00:00:00 2001 From: Agent <191493048+agentool@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:37:41 -0400 Subject: [PATCH 4/9] Antigravity: gate tool calls on user approval via the PreToolUse hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Print mode cannot prompt, so `agy` has to run with permissions skipped. That does not mean tools must be auto-approved: a PreToolUse hook returning `{"decision":"deny"}` genuinely blocks the call and Antigravity reports the reason back to the model. Verified against the CLI before building on it. The hook process becomes the gate. It writes its event, then blocks polling for a decision file. The bridge — which is the ACP agent — issues `session/request_permission` to T3 Code, and writes the answer out when the user replies. The adapter registers the matching callback and resolves it from `respondToRequest`, the same path the other ACP providers use. Fails closed everywhere it can: a cancelled prompt, an unknown option, a malformed reply, a lost bridge, or a timeout all deny rather than allow. Antigravity's own hook timeout is set above the bridge's wait so the deny lands rather than the CLI abandoning the hook first. Off by default via `requireToolApproval`, because every approval blocks the turn until a human answers. Verified end to end against the real CLI through the built bundle: asked to overwrite a file, the bridge raised two permission requests (run_command, then write_to_file), both were rejected, and the file was unchanged. --- AGENTS.md | 9 +- .../src/provider/Layers/AntigravityAdapter.ts | 153 +++++++++++-- .../src/provider/acp/AntigravityAcpSupport.ts | 5 +- .../src/provider/acp/antigravity/agyBridge.ts | 215 +++++++++++++++++- .../acp/antigravity/agyEvents.test.ts | 26 +++ .../src/provider/acp/antigravity/agyEvents.ts | 32 +++ packages/contracts/src/settings.ts | 14 +- 7 files changed, 429 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e7dcc83f88c..d30a3eac34c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,14 +40,19 @@ step index (`stepIdx` in a hook == `step_index` in the transcript): Known constraints, all inherent to print mode: -- Tools are auto-approved (`--dangerously-skip-permissions`); no approval flow is possible. +- `agy` always runs with `--dangerously-skip-permissions` because print mode cannot prompt. + Approvals instead come from the `PreToolUse` hook, which blocks the tool until T3 Code + answers — a `deny` decision genuinely stops it and is reported back to the model. Enable + with the `requireToolApproval` setting; it is off by default because each approval blocks + the turn on a human, and it fails closed on timeout or a lost bridge. - Attachments are passed by path, not inline: the adapter sends `resource_link` blocks for files the attachment store already wrote to disk, and the bridge names those paths in the prompt and adds their directory to the workspace. `agy` has no attachment flag, so inline image capability stays off. - No session modes, and no provider-side rollback — a resumed conversation keeps its full trajectory, so `rollbackThread` fails rather than silently truncating local state only. -- The model binds when the bridge spawns `agy`, so `sessionModelSwitch` is `"unsupported"`. +- `--model` is a per-spawn flag that composes with `--conversation`, so a model switch keeps + the trajectory and applies from the next turn (`sessionModelSwitch: "in-session"`). - Any `agy` subcommand spawned for a probe must set `stdin: "ignore"`. `agy` starts a language server and will not emit output while stdin stays open, so the default `"pipe"` hangs it. diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index 6e731da540f..1b43b838ece 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -5,9 +5,12 @@ * to is T3 Code's own bridge (`t3 agy-acp`). That shapes three deliberate * differences from the CLI-native ACP adapters: * - * - **No permission requests.** The bridge drives `agy` with - * `--dangerously-skip-permissions` because print mode cannot prompt, so - * tools are auto-approved and no approval flow can be surfaced. + * - **Approvals come from the hook, not the CLI.** `agy` always runs with + * `--dangerously-skip-permissions` because print mode cannot prompt. When + * `requireToolApproval` is set, the bridge's `PreToolUse` hook becomes the + * gate instead: it blocks the tool until this adapter answers, and a + * denial is reported back to the model. Off by default, since every + * approval stops the turn until a human replies. * - **No session modes.** Print mode has no plan/ask distinction to switch. * - **Attachments travel by reference.** `agy --print` has no attachment * flag, so files are sent as `resource_link` blocks and the bridge stages @@ -21,11 +24,14 @@ import { type AntigravitySettings, + ApprovalRequestId, EventId, + type ProviderApprovalDecision, type ProviderRuntimeEvent, type ProviderSession, ProviderDriverKind, ProviderInstanceId, + RuntimeRequestId, type ThreadId, TurnId, } from "@t3tools/contracts"; @@ -43,6 +49,7 @@ import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpSchema from "effect-acp/schema"; import { attachmentFileUrl, resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -60,8 +67,11 @@ import { makeAcpAssistantItemEvent, makeAcpContentDeltaEvent, makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, makeAcpToolCallEvent, } from "../acp/AcpCoreRuntimeEvents.ts"; +import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; import { makeAntigravityAcpRuntime, @@ -136,6 +146,32 @@ function resolveEffortSelection( return effort === "low" || effort === "medium" || effort === "high" ? effort : undefined; } +interface PendingApproval { + readonly decision: Deferred.Deferred; +} + +function selectPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: Exclude, +): string | undefined { + const kind = + decision === "acceptForSession" + ? "allow_always" + : decision === "accept" + ? "allow_once" + : "reject_once"; + return request.options.find((entry) => entry.kind === kind)?.optionId.trim() || undefined; +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + return ( + selectPermissionOptionId(request, "acceptForSession") ?? + selectPermissionOptionId(request, "accept") + ); +} + export function makeAntigravityAdapter( antigravitySettings: AntigravitySettings, options?: AntigravityAdapterLiveOptions, @@ -156,6 +192,12 @@ export function makeAntigravityAdapter( const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); const sessions = new Map(); + /** + * Approvals awaiting a user decision, per thread. Held outside the session + * context because the permission callback is registered before the context + * exists, and `respondToRequest` resolves entries from a different call. + */ + const approvalsByThread = new Map>(); const threadLocksRef = yield* SynchronizedRef.make(new Map()); const runtimeEventPubSub = yield* PubSub.unbounded(); @@ -173,6 +215,16 @@ export function makeAntigravityAdapter( ); const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const mapAcpCallbackFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process Antigravity ACP callback.", + cause, + }), + ), + ); const offerRuntimeEvent = (event: ProviderRuntimeEvent) => PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); @@ -235,6 +287,16 @@ export function makeAntigravityAdapter( Effect.gen(function* () { if (ctx.stopped) return; ctx.stopped = true; + // Anything still waiting on a human is cancelled, which the bridge + // turns into a denial. Left pending, the hook would block its tool + // until its own timeout with no one able to answer. + const pending = approvalsByThread.get(ctx.threadId); + if (pending) { + for (const [, approval] of pending) { + yield* Deferred.succeed(approval.decision, "cancel"); + } + approvalsByThread.delete(ctx.threadId); + } if (ctx.notificationFiber) { yield* Fiber.interrupt(ctx.notificationFiber); } @@ -337,6 +399,64 @@ export function makeAntigravityAdapter( ), ); + // With approvals enabled the bridge blocks each tool on this callback, + // so it must be registered before `start()` — the first tool call can + // arrive as soon as the first turn begins. + const pendingApprovals = approvalsByThread.get(input.threadId) ?? new Map(); + approvalsByThread.set(input.threadId, pendingApprovals); + yield* acp.handleRequestPermission((params) => + mapAcpCallbackFailure( + Effect.gen(function* () { + if (input.runtimeMode === "full-access") { + const autoApproved = selectAutoApprovedPermissionOption(params); + if (autoApproved !== undefined) { + return { outcome: { outcome: "selected" as const, optionId: autoApproved } }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const decision = yield* Deferred.make(); + const turnId = sessions.get(input.threadId)?.activeTurnId; + pendingApprovals.set(requestId, { decision }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: RuntimeRequestId.make(requestId), + permissionRequest, + detail: permissionRequest.detail ?? "Antigravity tool call", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId, + requestId: RuntimeRequestId.make(requestId), + permissionRequest, + decision: resolved, + }), + ); + const optionId = + resolved === "cancel" ? undefined : selectPermissionOptionId(params, resolved); + return { + outcome: optionId + ? { outcome: "selected" as const, optionId } + : ({ outcome: "cancelled" } as const), + }; + }), + ), + ); + const started = yield* acp .start() .pipe( @@ -680,18 +800,25 @@ export function makeAntigravityAdapter( ); }); - // Antigravity runs every turn with `--dangerously-skip-permissions`, so - // the bridge never opens an approval or question request. Reaching either - // responder means the caller is tracking a request this provider cannot - // have produced. - const respondToRequest: AntigravityAdapterShape["respondToRequest"] = (threadId, requestId) => + // `agy` itself always runs with permissions skipped — print mode cannot + // prompt — so when approvals are enabled the bridge's PreToolUse hook is + // the gate, and this resolves the decision it is blocked on. + const respondToRequest: AntigravityAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => Effect.gen(function* () { yield* requireSession(threadId); - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session/request_permission", - detail: `Antigravity auto-approves tools and never opens approvals; unknown request: ${requestId}`, - }); + const pending = approvalsByThread.get(threadId)?.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); }); const respondToUserInput: AntigravityAdapterShape["respondToUserInput"] = ( diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index c3ded2e3584..a041e23bb1d 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -29,7 +29,7 @@ export const DEFAULT_ANTIGRAVITY_MODEL = "gemini-3.1-pro-high"; type AntigravityAcpRuntimeSettings = Pick< AntigravitySettings, - "binaryPath" | "printTimeout" | "appDataDir" + "binaryPath" | "printTimeout" | "appDataDir" | "requireToolApproval" >; interface AntigravityAcpRuntimeInput extends Omit< @@ -79,6 +79,9 @@ export function buildAntigravityAcpSpawnInput(input: { if (settings?.appDataDir?.trim()) { env["T3_AGY_APP_DATA_DIR"] = settings.appDataDir.trim(); } + if (settings?.requireToolApproval) { + env["T3_AGY_REQUIRE_APPROVAL"] = "1"; + } if (input.model?.trim()) { env["T3_AGY_MODEL"] = input.model.trim(); } diff --git a/apps/server/src/provider/acp/antigravity/agyBridge.ts b/apps/server/src/provider/acp/antigravity/agyBridge.ts index acf35cd1fe2..eb7bc35fd4e 100644 --- a/apps/server/src/provider/acp/antigravity/agyBridge.ts +++ b/apps/server/src/provider/acp/antigravity/agyBridge.ts @@ -24,10 +24,14 @@ import * as NodeURL from "node:url"; import packageJson from "../../../../package.json" with { type: "json" }; import { agyHookResponse, + approvalOutcomeToDecision, agyTargetPath, + agyToolCallId, agyToolKind, + agyToolTitle, hookSessionUpdate, makeAgyTurnState, + type AgyHookDecision, type AgyHookEvent, type AgyHookPayload, type AgySessionUpdate, @@ -41,6 +45,17 @@ import { } from "./agyTranscript.ts"; const HOOK_DIR_ENV = "T3_AGY_HOOK_DIR"; +const REQUIRE_APPROVAL_ENV = "T3_AGY_REQUIRE_APPROVAL"; +/** Suffix of the file the bridge writes to answer a waiting `PreToolUse` hook. */ +const DECISION_SUFFIX = ".decision"; +/** + * How long a `PreToolUse` hook blocks waiting for the user. Antigravity's own + * hook timeout is set above this so the deny below is what actually lands, + * rather than the CLI abandoning the hook first. + */ +const APPROVAL_WAIT_MS = 10 * 60 * 1000; +const APPROVAL_HOOK_TIMEOUT_SECONDS = 11 * 60; +const APPROVAL_POLL_INTERVAL_MS = 50; const HOOK_POLL_INTERVAL_MS = 50; const DEFAULT_PRINT_TIMEOUT = "2h"; const HOOKS_KEY = "t3code-antigravity-observer"; @@ -155,6 +170,58 @@ function sendSessionUpdate(sessionId: string, update: AgySessionUpdate): void { }); } +// ── Outbound requests ───────────────────────────────────────────────── +// +// The bridge is the ACP agent, so asking the client to approve a tool means +// issuing a request in the other direction and correlating the reply. + +let nextOutboundId = 1; +const pendingOutbound = new Map< + number, + { readonly resolve: (value: unknown) => void; readonly reject: (error: Error) => void } +>(); + +function sendRequest(method: string, params: unknown): Promise { + const id = nextOutboundId; + nextOutboundId += 1; + return new Promise((resolve, reject) => { + pendingOutbound.set(id, { resolve, reject }); + writeMessage({ jsonrpc: "2.0", id, method, params }); + }); +} + +/** + * Settle an outbound request from a client reply. Returns false when the id is + * not one of ours, so the caller can treat the message as a request instead. + */ +function resolveOutbound(message: Record): boolean { + const id = message["id"]; + if (typeof id !== "number" || !pendingOutbound.has(id)) { + return false; + } + const pending = pendingOutbound.get(id); + pendingOutbound.delete(id); + if (!pending) { + return false; + } + const error = message["error"]; + if (isRecord(error)) { + const detail = typeof error["message"] === "string" ? error["message"] : "request failed"; + pending.reject(new Error(detail)); + } else { + pending.resolve(message["result"]); + } + return true; +} + +/** Reject everything still outstanding; used when the client goes away. */ +function failPendingOutbound(reason: string): void { + for (const [, pending] of pendingOutbound) { + pending.reject(new Error(reason)); + } + pendingOutbound.clear(); +} + // ── Hook observer ───────────────────────────────────────────────────── /** @@ -183,16 +250,19 @@ function createHookWorkspace(): string { const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-agy-hooks-")); const agentsDir = NodePath.join(dir, ".agents"); NodeFS.mkdirSync(agentsDir, { recursive: true }); - const toolHook = (event: string) => [ - { matcher: "*", hooks: [{ type: "command", command: hookCommandFor(event), timeout: 10 }] }, + // A gating PreToolUse hook blocks while the user decides, so its timeout has + // to outlast the bridge's own wait; observation-only hooks stay short. + const preToolTimeout = approvalRequired() ? APPROVAL_HOOK_TIMEOUT_SECONDS : 10; + const toolHook = (event: string, timeout: number) => [ + { matcher: "*", hooks: [{ type: "command", command: hookCommandFor(event), timeout }] }, ]; NodeFS.writeFileSync( NodePath.join(agentsDir, "hooks.json"), JSON.stringify( { [HOOKS_KEY]: { - PreToolUse: toolHook("pre-tool-use"), - PostToolUse: toolHook("post-tool-use"), + PreToolUse: toolHook("pre-tool-use", preToolTimeout), + PostToolUse: toolHook("post-tool-use", 10), Stop: [{ type: "command", command: hookCommandFor("stop"), timeout: 10 }], }, }, @@ -203,14 +273,20 @@ function createHookWorkspace(): string { return dir; } -function readHookEvents(hookDir: string, seen: Set): ReadonlyArray { +/** A hook event plus the file it came from, which keys its decision reply. */ +interface ObservedHook { + readonly name: string; + readonly event: AgyHookEvent; +} + +function readHookEvents(hookDir: string, seen: Set): ReadonlyArray { let entries: Array; try { entries = NodeFS.readdirSync(hookDir); } catch { return []; } - const events: Array = []; + const events: Array = []; for (const name of entries.filter((n) => n.endsWith(".json")).sort()) { if (seen.has(name)) { continue; @@ -220,7 +296,7 @@ function readHookEvents(hookDir: string, seen: Set): ReadonlyArray): ReadonlyArray { + writeDecision( + input.hookDir, + input.hookName, + approvalOutcomeToDecision(result, APPROVE_OPTION), + ); + }) + .catch(() => { + writeDecision(input.hookDir, input.hookName, { + decision: "deny", + reason: "T3 Code could not obtain approval for this tool call", + }); + }); +} + /** * Largest file the hook will inline into its event record. A diff of anything * bigger is not worth the memory it would cost on both sides. @@ -278,6 +424,15 @@ export async function runAgyHook(event: string): Promise { const tempPath = `${finalPath}.tmp`; NodeFS.writeFileSync(tempPath, JSON.stringify(record)); NodeFS.renameSync(tempPath, finalPath); + + // With approvals on, this process is the gate: block until the bridge + // reports the user's answer. `deny` here genuinely stops the tool — + // Antigravity reports the reason back to the model. + if (approvalRequired() && event === "pre-tool-use" && payload?.toolCall) { + const decision = await awaitDecision(hookDir, name); + process.stdout.write(JSON.stringify(decision)); + return; + } } catch { // Observation is best-effort: a hook must never break a tool call. } @@ -286,6 +441,30 @@ export async function runAgyHook(event: string): Promise { process.stdout.write(JSON.stringify(agyHookResponse(event, Boolean(hookDir)))); } +/** + * Wait for the bridge's answer to one `PreToolUse` hook. + * + * Fails closed: if the bridge dies, the client never answers, or the deadline + * passes, the tool is denied rather than quietly allowed. + */ +async function awaitDecision(hookDir: string, hookName: string): Promise { + const target = decisionPath(hookDir, hookName); + const deadline = Date.now() + APPROVAL_WAIT_MS; + while (Date.now() < deadline) { + try { + const raw = NodeFS.readFileSync(target, "utf8"); + const parsed: unknown = JSON.parse(raw); + if (isRecord(parsed) && (parsed["decision"] === "allow" || parsed["decision"] === "deny")) { + return parsed as unknown as AgyHookDecision; + } + } catch { + // Not written yet, or written partially; try again. + } + await new Promise((resolve) => setTimeout(resolve, APPROVAL_POLL_INTERVAL_MS)); + } + return { decision: "deny", reason: "Timed out waiting for approval in T3 Code" }; +} + // ── Turn execution ──────────────────────────────────────────────────── function buildAgyArgs(input: { @@ -466,7 +645,7 @@ function drain(input: { readonly assistantText: { emitted: boolean }; readonly final: boolean; }): void { - for (const hook of readHookEvents(input.hookDir, input.seenHooks)) { + for (const { name, event: hook } of readHookEvents(input.hookDir, input.seenHooks)) { // Diffing the file contents each hook captured, rather than the arguments // of the edit, keeps this correct across tools whose argument shapes // differ (`replace_file_content` sends a fragment, `write_to_file` sends @@ -476,6 +655,16 @@ function drain(input: { if (update) { sendSessionUpdate(input.sessionId, update); } + // The hook process is blocked until a decision file appears, so this has + // to be started for every announced tool call. + if (approvalRequired() && hook.event === "pre-tool-use" && hook.payload?.toolCall) { + requestToolApproval({ + sessionId: input.sessionId, + hookDir: input.hookDir, + hookName: name, + payload: hook.payload, + }); + } } const transcriptPath = resolveTranscriptPath(input.state); @@ -853,6 +1042,13 @@ export async function runAgyBridge(): Promise { continue; } + // A reply to one of the bridge's own requests (tool approval) carries an + // id but no method, and must never enter the request queue — the turn + // holding that queue is exactly what is waiting on the answer. + if (message["method"] === undefined && resolveOutbound(message)) { + continue; + } + // Cancellation must interrupt an in-flight turn, so it bypasses the // queue that would otherwise make it wait for that turn to finish. if (message["method"] === "session/cancel") { @@ -873,6 +1069,9 @@ export async function runAgyBridge(): Promise { } } + // stdin closed: no approval can still be answered, so unblock the hooks + // waiting on them (they fail closed) rather than letting them time out. + failPendingOutbound("T3 Code disconnected before approving this tool call"); await queue; activeChild?.kill("SIGTERM"); } diff --git a/apps/server/src/provider/acp/antigravity/agyEvents.test.ts b/apps/server/src/provider/acp/antigravity/agyEvents.test.ts index 9d1a30c739c..8b8bcab713f 100644 --- a/apps/server/src/provider/acp/antigravity/agyEvents.test.ts +++ b/apps/server/src/provider/acp/antigravity/agyEvents.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { agyHookResponse, + approvalOutcomeToDecision, agyTargetPath, agyToolCallId, agyToolKind, @@ -218,6 +219,31 @@ describe("hookSessionUpdate", () => { }); }); +describe("approvalOutcomeToDecision", () => { + it("allows only an explicit selection of the approve option", () => { + expect( + approvalOutcomeToDecision({ outcome: { outcome: "selected", optionId: "allow" } }, "allow"), + ).toEqual({ decision: "allow" }); + }); + + it("denies every other reply", () => { + // This is the only gate in front of a CLI already told to skip its own + // permission prompts, so anything ambiguous has to deny. + for (const reply of [ + { outcome: { outcome: "selected", optionId: "reject" } }, + { outcome: { outcome: "cancelled" } }, + { outcome: { outcome: "selected" } }, + { outcome: null }, + {}, + null, + undefined, + "allow", + ]) { + expect(approvalOutcomeToDecision(reply, "allow").decision).toBe("deny"); + } + }); +}); + describe("agyHookResponse", () => { it("allows tools only while a bridge observer is attached", () => { expect(agyHookResponse("pre-tool-use", true)).toEqual({ decision: "allow" }); diff --git a/apps/server/src/provider/acp/antigravity/agyEvents.ts b/apps/server/src/provider/acp/antigravity/agyEvents.ts index 3256541245d..b22e6bf8bd2 100644 --- a/apps/server/src/provider/acp/antigravity/agyEvents.ts +++ b/apps/server/src/provider/acp/antigravity/agyEvents.ts @@ -362,6 +362,38 @@ export function hookSessionUpdate( * the safe answer is `ask`: a hook that silently allowed every tool outside a * managed turn would be a permission bypass. */ +export interface AgyHookDecision { + readonly decision: "allow" | "deny" | "ask"; + readonly reason?: string; +} + +/** + * Translate the client's `session/request_permission` reply into the decision + * the blocked `PreToolUse` hook will print. + * + * Fails closed: only an explicit selection of the approve option allows the + * tool. A cancelled prompt, an unknown option id, or a malformed reply all + * deny, because this is the only thing standing between the model and a tool + * that `agy` has already been told to run without asking. + */ +export function approvalOutcomeToDecision( + result: unknown, + approveOptionId: string, +): AgyHookDecision { + const outcome = + typeof result === "object" && result !== null + ? (result as { outcome?: unknown }).outcome + : undefined; + const selected = + typeof outcome === "object" && outcome !== null + ? (outcome as { outcome?: unknown; optionId?: unknown }) + : undefined; + if (selected?.outcome === "selected" && selected.optionId === approveOptionId) { + return { decision: "allow" }; + } + return { decision: "deny", reason: "Rejected in T3 Code" }; +} + export function agyHookResponse(event: string, observerAttached: boolean): Record { switch (event) { case "pre-tool-use": diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 1bdd716b22b..c4789c9a0e2 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -373,13 +373,25 @@ export const AntigravitySettings = makeProviderSettingsSchema( providerSettingsForm: { placeholder: "~/.gemini/antigravity-cli", clearWhenEmpty: "omit" }, }), ), + // Print mode cannot prompt, so `agy` runs with permissions skipped and the + // bridge's PreToolUse hook becomes the gate instead: it blocks the tool + // until T3 Code answers, and a denial is reported back to the model. + // Off by default because every approval blocks the turn on a human. + requireToolApproval: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ + title: "Ask before running tools", + description: + "Approve each tool call before Antigravity runs it. Turns block until you answer.", + }), + ), customModels: Schema.Array(Schema.String).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), }, { - order: ["binaryPath", "printTimeout", "appDataDir"], + order: ["binaryPath", "printTimeout", "appDataDir", "requireToolApproval"], }, ); export type AntigravitySettings = typeof AntigravitySettings.Type; From de530001b791093569dae3587fc5ab3a1d54d56d Mon Sep 17 00:00:00 2001 From: Agent <191493048+agentool@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:53:35 -0400 Subject: [PATCH 5/9] Antigravity: fix event ordering, map contention, and turn settlement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit More findings from automated review: - Tool output no longer arrives after the call is completed. Hooks and the transcript are drained in one pass, so for a fast tool the completion and its output were emitted back to back; AcpSessionRuntime drops tool state on completion, so the output resurfaced as a second, never-completed item. Terminal updates are now held until that step's transcript record has been streamed, or until the final drain if none arrives. - Session-to-conversation mappings are one file per session. A shared map meant a read-modify-write, so two bridge processes finishing turns at once would each rebuild it from a stale snapshot and the later write would drop the other's entry, losing a thread's history. Atomic rename alone did not fix that; independent files do not contend at all. - Terminal turn events are published from exactly one place. The old success path tested `promptsInFlight === 1` and then yielded before publishing, so a steer arriving in that window could make two prompts each believe they owned the turn and emit a completion. Decrement and the last-prompt test are now a single synchronous step. - A model switch is applied before `turn.started`, so the announced model is the one the turn runs on rather than the previous one. - Non-`file:` resource links are rendered into the prompt for Antigravity's fetch tool instead of being dropped, and an attachment that cannot be staged now fails the turn — silently dropping it would run the turn against a prompt the user did not write. --- .../src/provider/Layers/AntigravityAdapter.ts | 73 ++++---- .../src/provider/acp/antigravity/agyBridge.ts | 175 ++++++++++++------ .../src/provider/acp/antigravity/agyEvents.ts | 8 + 3 files changed, 158 insertions(+), 98 deletions(-) diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index 1b43b838ece..03fdf77e123 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -667,9 +667,11 @@ export function makeAntigravityAdapter( const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); ctx.promptsInFlight += 1; - // Tracks whether a terminal event was published on the success path, so - // the teardown below can tell "already reported" from "died silently". - let settled = false; + // Terminal-event bookkeeping. Publication happens in exactly one place + // (the teardown below) so that the decision cannot race a steer. + let turnStarted = steeringTurnId !== undefined; + let stopReason: string | null = null; + let promptSucceeded = false; return yield* Effect.gen(function* () { ctx.activeTurnId = turnId; @@ -679,22 +681,11 @@ export function makeAntigravityAdapter( updatedAt: yield* nowIso, }; - if (steeringTurnId === undefined) { - yield* offerRuntimeEvent({ - type: "turn.started", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId, - payload: { model: ctx.session.model }, - }); - } - // `agy` binds the model with a `--model` flag on each spawn, and that // flag composes with `--conversation` — verified against the CLI: a // resumed conversation answers on the new model with its history - // intact. So a switch is applied to the bridge session here and takes - // effect on the turn about to run. + // intact. Applied before `turn.started` so the announced model is the + // one the turn actually runs on. const turnModelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const requestedModelId = turnModelSelection?.model @@ -712,6 +703,18 @@ export function makeAntigravityAdapter( ctx.session = { ...ctx.session, model: turnModelSelection?.model ?? ctx.session.model }; } + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model: ctx.session.model }, + }); + turnStarted = true; + } + const result = yield* ctx.acp .prompt({ prompt: promptParts }) .pipe( @@ -732,22 +735,8 @@ export function makeAntigravityAdapter( updatedAt: yield* nowIso, }; - // Only the last remaining prompt settles the turn, so a steer- - // superseded prompt resolving does not end the merged turn. - if (ctx.promptsInFlight === 1) { - yield* offerRuntimeEvent({ - type: "turn.completed", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - turnId, - payload: { - state: result.stopReason === "cancelled" ? "cancelled" : "completed", - stopReason: result.stopReason ?? null, - }, - }); - settled = true; - } + promptSucceeded = true; + stopReason = result.stopReason ?? null; return { threadId: input.threadId, @@ -757,13 +746,12 @@ export function makeAntigravityAdapter( }).pipe( Effect.ensuring( Effect.gen(function* () { + // Decrement and the last-prompt test are one synchronous step, so + // a steer arriving mid-settlement cannot make two prompts both + // believe they own the turn and publish a terminal event each. const remaining = Math.max(0, ctx.promptsInFlight - 1); ctx.promptsInFlight = remaining; - // A prompt that failed or was interrupted after `turn.started` - // was announced still owes consumers a terminal event; without - // one the turn renders as running forever even though sendTurn - // has already returned an error. - if (settled || remaining > 0) { + if (remaining > 0 || !turnStarted) { return; } if (ctx.activeTurnId === turnId) { @@ -775,13 +763,22 @@ export function makeAntigravityAdapter( const { activeTurnId: _endedTurnId, ...endedSession } = ctx.session; ctx.session = { ...endedSession, status: "ready", updatedAt: yield* nowIso }; } + // A prompt that failed or was interrupted after `turn.started` + // still owes consumers a terminal event; without one the turn + // renders as running forever even though sendTurn already + // returned an error. + const state = !promptSucceeded + ? "failed" + : stopReason === "cancelled" + ? "cancelled" + : "completed"; yield* offerRuntimeEvent({ type: "turn.completed", ...(yield* makeEventStamp()), provider: PROVIDER, threadId: input.threadId, turnId, - payload: { state: "failed", stopReason: null }, + payload: { state, stopReason }, }); }).pipe(Effect.catch(() => Effect.void)), ), diff --git a/apps/server/src/provider/acp/antigravity/agyBridge.ts b/apps/server/src/provider/acp/antigravity/agyBridge.ts index eb7bc35fd4e..fc6e2efa32f 100644 --- a/apps/server/src/provider/acp/antigravity/agyBridge.ts +++ b/apps/server/src/provider/acp/antigravity/agyBridge.ts @@ -83,50 +83,40 @@ const sessions = new Map(); // `session/load` — potentially in a fresh bridge process — can still resume // the right conversation. -function stateFilePath(): string { +function stateDirPath(): string { const appDataDir = process.env["T3_AGY_APP_DATA_DIR"]?.trim() || NodePath.join(NodeOS.homedir(), ".gemini", "antigravity-cli"); - return NodePath.join(appDataDir, "t3code-acp-sessions.json"); + return NodePath.join(appDataDir, "t3code-acp-sessions"); } -function readSessionMap(): Record { - try { - const parsed: unknown = JSON.parse(NodeFS.readFileSync(stateFilePath(), "utf8")); - if (typeof parsed !== "object" || parsed === null) { - return {}; - } - // The bridge does not exclusively own this file, so entries are validated - // rather than cast. A non-string value reaching a caller would throw and - // leave the request it came from unanswered. - const map: Record = {}; - for (const [key, value] of Object.entries(parsed as Record)) { - if (typeof value === "string") { - map[key] = value; - } - } - return map; - } catch { - return {}; - } +/** + * One file per session rather than a shared map. + * + * Several bridge processes can finish turns at once, and a shared map means a + * read-modify-write: two writers would each rebuild it from their own stale + * snapshot and the later rename would drop the other's entry, silently losing + * a thread's conversation. Independent files never contend. + * + * Returns undefined for a session id that is not a plain token, since the id + * becomes a filename and is supplied by the client. + */ +function sessionFilePath(sessionId: string): string | undefined { + return /^[A-Za-z0-9_-]{1,128}$/.test(sessionId) + ? NodePath.join(stateDirPath(), `${sessionId}.json`) + : undefined; } function persistConversationId(sessionId: string, conversationId: string): void { + const target = sessionFilePath(sessionId); + if (!target) { + return; + } try { - const target = stateFilePath(); NodeFS.mkdirSync(NodePath.dirname(target), { recursive: true }); - const map = readSessionMap(); - if (map[sessionId] === conversationId) { - return; - } - map[sessionId] = conversationId; - // Written through a uniquely-named temp file and renamed into place. The - // map is now the sole resume authority and several bridge processes can - // finish turns at once; a partial write would be read back as empty JSON - // and silently start a fresh conversation. Rename is atomic within a - // filesystem, so a reader sees either the old file or the complete new one. + // Written aside and renamed so a reader never sees a partial file. const staging = `${target}.${process.pid}.${NodeCrypto.randomUUID()}.tmp`; - NodeFS.writeFileSync(staging, JSON.stringify(map, null, 2)); + NodeFS.writeFileSync(staging, JSON.stringify({ conversationId })); NodeFS.renameSync(staging, target); } catch { // Losing the mapping costs conversation continuity on the next resume, @@ -137,15 +127,30 @@ function persistConversationId(sessionId: string, conversationId: string): void /** * Map a bridge session id to the Antigravity conversation it should resume. * - * The persisted map is the only authority. Bridge session ids are themselves - * random UUIDs, so an id that merely looks like a conversation id is + * The persisted record is the only authority. Bridge session ids are random + * UUIDs themselves, so an id that merely looks like a conversation id is * indistinguishable from one the bridge minted — falling back to the shape of * the string would make `session/load` resume a conversation that never - * existed whenever the map is missing or unreadable. Returning `undefined` - * starts a fresh conversation, which is the recoverable outcome. + * existed. Returning undefined starts a fresh one, which is recoverable. */ function lookupConversationId(sessionId: string): string | undefined { - return readSessionMap()[sessionId]?.trim() || undefined; + const target = sessionFilePath(sessionId); + if (!target) { + return undefined; + } + try { + const parsed: unknown = JSON.parse(NodeFS.readFileSync(target, "utf8")); + if (typeof parsed !== "object" || parsed === null) { + return undefined; + } + // Validated rather than cast: the bridge does not exclusively own this + // file, and a non-string value would throw and leave the request that read + // it unanswered. + const value = (parsed as { conversationId?: unknown }).conversationId; + return typeof value === "string" ? value.trim() || undefined : undefined; + } catch { + return undefined; + } } // ── JSON-RPC plumbing ───────────────────────────────────────────────── @@ -520,35 +525,47 @@ function isRecord(value: unknown): value is Record { * Only `file:` URIs are taken: a remote URL is left in the prompt text for the * agent's own fetch tool, and passing one to `--add-dir` would be meaningless. */ -function collectAttachments(promptBlocks: ReadonlyArray): ReadonlyArray { - const attachments: Array = []; +function collectAttachments(promptBlocks: ReadonlyArray): { + readonly files: ReadonlyArray; + readonly remoteUris: ReadonlyArray; +} { + const files: Array = []; + const remoteUris: Array = []; for (const block of promptBlocks) { if (!isRecord(block) || block["type"] !== "resource_link") { continue; } const uri = block["uri"]; - if (typeof uri !== "string" || !uri.startsWith("file://")) { + if (typeof uri !== "string" || uri.length === 0) { + continue; + } + if (!uri.startsWith("file://")) { + // `resource_link` is baseline ACP, so a remote link has to reach the + // agent: it goes into the prompt text for Antigravity's own fetch tool. + remoteUris.push(uri); continue; } let filePath: string; try { filePath = NodeURL.fileURLToPath(uri); } catch { + remoteUris.push(uri); continue; } const name = typeof block["name"] === "string" ? block["name"] : NodePath.basename(filePath); - attachments.push({ + files.push({ path: filePath, name, mimeType: typeof block["mimeType"] === "string" ? block["mimeType"] : undefined, }); } - return attachments; + return { files, remoteUris }; } interface RenderedPrompt { readonly baseText: string; readonly attachments: ReadonlyArray; + readonly remoteUris: ReadonlyArray; } function renderPrompt(session: BridgeSession, promptBlocks: unknown): RenderedPrompt | null { @@ -564,15 +581,16 @@ function renderPrompt(session: BridgeSession, promptBlocks: unknown): RenderedPr .join("\n\n") .trim(); - const attachments = collectAttachments(promptBlocks); - if (text.length === 0 && attachments.length === 0) { + const { files, remoteUris } = collectAttachments(promptBlocks); + if (text.length === 0 && files.length === 0 && remoteUris.length === 0) { return null; } const systemPrompt = session.systemPrompt?.trim(); return { baseText: systemPrompt ? `System instructions:\n${systemPrompt}\n\nRequest:\n${text}` : text, - attachments, + attachments: files, + remoteUris, }; } @@ -599,24 +617,29 @@ function stageAttachments(attachments: ReadonlyArray): { // collide and a crafted name cannot escape the staging directory. const safeName = `${index}-${NodePath.basename(attachment.name).replace(/[^\w.-]+/g, "_")}`; const target = NodePath.join(dir, safeName); - try { - NodeFS.copyFileSync(attachment.path, target); - } catch { - // An unreadable attachment is dropped rather than failing the whole turn. - return; - } + // Not swallowed: silently dropping an attachment would run the turn + // against a prompt the user did not write, and an attachment-only request + // would become an empty prompt that still reports success. + NodeFS.copyFileSync(attachment.path, target); staged.push({ path: target, name: safeName, mimeType: attachment.mimeType }); }); return { dir, staged }; } -function composePromptText(baseText: string, staged: ReadonlyArray): string { - if (staged.length === 0) { - return baseText; +function composePromptText( + baseText: string, + staged: ReadonlyArray, + remoteUris: ReadonlyArray, +): string { + const blocks: Array = []; + if (staged.length > 0) { + const list = staged.map((a) => `- ${a.path}${a.mimeType ? ` (${a.mimeType})` : ""}`).join("\n"); + blocks.push(`Attached files (read them from these paths):\n${list}`); + } + if (remoteUris.length > 0) { + blocks.push(`Attached links (fetch them):\n${remoteUris.map((u) => `- ${u}`).join("\n")}`); } - const list = staged.map((a) => `- ${a.path}${a.mimeType ? ` (${a.mimeType})` : ""}`).join("\n"); - const block = `Attached files (read them from these paths):\n${list}`; - return baseText.length > 0 ? `${baseText}\n\n${block}` : block; + return [baseText, ...blocks].filter((part) => part.length > 0).join("\n\n"); } interface TurnOutcome { @@ -653,7 +676,15 @@ function drain(input: { const fileText = hook.capturedFileText ?? undefined; const update = hookSessionUpdate(hook, input.state, fileText); if (update) { - sendSessionUpdate(input.sessionId, update); + const stepIdx = hook.payload?.stepIdx; + if (hook.event === "post-tool-use" && typeof stepIdx === "number") { + // Held back rather than sent: the transcript record carrying this + // step's real output is usually read later in this same pass, and a + // completed call can no longer take content. + input.state.pendingTerminal.set(stepIdx, update); + } else { + sendSessionUpdate(input.sessionId, update); + } } // The hook process is blocked until a decision file appears, so this has // to be started for every announced tool call. @@ -708,10 +739,34 @@ function drain(input: { if (result.emittedAssistantText) { input.assistantText.emitted = true; } + // This step's output is out; the call can be completed now. + const stepIndex = record.step_index; + if (typeof stepIndex === "number") { + flushTerminal(input.sessionId, input.state, stepIndex); + } + } + } + + // Nothing more will arrive, so anything still waiting on a transcript record + // that never came is completed without output rather than left spinning. + if (input.final) { + const remaining = [...input.state.pendingTerminal.values()]; + input.state.pendingTerminal.clear(); + for (const terminal of remaining) { + sendSessionUpdate(input.sessionId, terminal); } } } +function flushTerminal(sessionId: string, state: AgyTurnState, stepIdx: number): void { + const terminal = state.pendingTerminal.get(stepIdx); + if (!terminal) { + return; + } + state.pendingTerminal.delete(stepIdx); + sendSessionUpdate(sessionId, terminal); +} + /** * Hooks report `transcript_full.jsonl`; the sibling `transcript.jsonl` holds * the same steps without internal model chatter and is the better stream to @@ -764,7 +819,7 @@ async function runTurn( session, hookWorkspace, attachmentDir, - promptText: composePromptText(prompt.baseText, attachments.staged), + promptText: composePromptText(prompt.baseText, attachments.staged, prompt.remoteUris), }), { cwd: session.cwd, diff --git a/apps/server/src/provider/acp/antigravity/agyEvents.ts b/apps/server/src/provider/acp/antigravity/agyEvents.ts index b22e6bf8bd2..7922d1401ba 100644 --- a/apps/server/src/provider/acp/antigravity/agyEvents.ts +++ b/apps/server/src/provider/acp/antigravity/agyEvents.ts @@ -190,6 +190,13 @@ export interface AgyTurnState { * output would be dropped whenever both arrive within one poll. */ readonly toolCalls: Map; + /** + * Terminal `tool_call_update`s held back until the transcript record with + * that step's output has been streamed. `AcpSessionRuntime` drops its tool + * state once a call completes, so output emitted afterwards would surface as + * a second, never-completed item. + */ + readonly pendingTerminal: Map; conversationId: string | undefined; transcriptPath: string | undefined; /** Transcript file pinned for the turn; see `resolveTranscriptPath`. */ @@ -205,6 +212,7 @@ export interface AgyTurnState { export function makeAgyTurnState(conversationId?: string): AgyTurnState { return { toolCalls: new Map(), + pendingTerminal: new Map(), conversationId, transcriptPath: undefined, resolvedTranscriptPath: undefined, From 4fc5329be5e65a350c7afca9ca9bf1105095def9 Mon Sep 17 00:00:00 2001 From: Agent <191493048+agentool@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:20:40 -0400 Subject: [PATCH 6/9] Antigravity: close approval fail-open and cancel gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final review round, mostly against the approval gate added in the previous commit: - The gate no longer fails open. A hook that could not parse its payload or write its event file fell through to the observation-only response, which answers "allow" — so a full disk or an unwritable directory ran the tool with nobody having approved it. Every PreToolUse failure now denies; only a validated decision allows. - Cancelling stops prompts that have not started yet. Turns are serialized, so a steer queued behind a long turn used to begin executing tools after the user pressed Stop. Cancels are counted per session and each prompt records the count when it is accepted, which invalidates queued prompts without reviving the stale-cancel bug a plain flag caused. - Approval requests carry a deadline and are settled on interrupt, so a client that never answers cannot pin an entry for the life of the process, and a cancelled turn releases the hook waiting behind it. - No approval is opened during the final drain: the child has already exited, so nothing is waiting on the answer. - A tool whose transcript output arrived before its PostToolUse hook is completed immediately rather than at end of turn. The transcript is read once by byte offset, so that record never comes round again. - A failed model switch clears the active turn instead of leaving one advertised to listSessions and the reaper; staging cleans up copied uploads if a later copy fails; the approvals map is registered only after startup succeeds. Re-verified end to end through the built bundle: reject path blocks the tool and leaves the file untouched; approve path runs it and emits in_progress -> content -> completed. --- .../src/provider/Layers/AntigravityAdapter.ts | 28 +++- .../src/provider/acp/antigravity/agyBridge.ts | 151 ++++++++++++++---- .../src/provider/acp/antigravity/agyEvents.ts | 8 + .../acp/antigravity/agyTranscript.test.ts | 13 ++ .../provider/acp/antigravity/agyTranscript.ts | 4 + 5 files changed, 173 insertions(+), 31 deletions(-) diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index 03fdf77e123..f6a4abc3b72 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -402,8 +402,10 @@ export function makeAntigravityAdapter( // With approvals enabled the bridge blocks each tool on this callback, // so it must be registered before `start()` — the first tool call can // arrive as soon as the first turn begins. - const pendingApprovals = approvalsByThread.get(input.threadId) ?? new Map(); - approvalsByThread.set(input.threadId, pendingApprovals); + // Not published to `approvalsByThread` yet: if startup fails there is + // no session context for teardown to clean up, and repeated failures + // across thread ids would grow the map. Registered after `start()`. + const pendingApprovals = new Map(); yield* acp.handleRequestPermission((params) => mapAcpCallbackFailure( Effect.gen(function* () { @@ -495,6 +497,8 @@ export function makeAntigravityAdapter( stopped: false, }; + approvalsByThread.set(input.threadId, pendingApprovals); + const nf = yield* Stream.runDrain( Stream.mapEffect(acp.getEvents(), (event) => Effect.gen(function* () { @@ -751,9 +755,13 @@ export function makeAntigravityAdapter( // believe they own the turn and publish a terminal event each. const remaining = Math.max(0, ctx.promptsInFlight - 1); ctx.promptsInFlight = remaining; - if (remaining > 0 || !turnStarted) { + if (remaining > 0) { return; } + // Cleared even when the turn never started — a model switch can + // fail after `activeTurnId` is installed, and leaving it set + // advertises a turn to `listSessions` and the reaper that no + // longer exists. Only the event below depends on having started. if (ctx.activeTurnId === turnId) { ctx.activeTurnId = undefined; } @@ -767,6 +775,9 @@ export function makeAntigravityAdapter( // still owes consumers a terminal event; without one the turn // renders as running forever even though sendTurn already // returned an error. + if (!turnStarted) { + return; + } const state = !promptSucceeded ? "failed" : stopReason === "cancelled" @@ -788,6 +799,17 @@ export function makeAntigravityAdapter( const interruptTurn: AntigravityAdapterShape["interruptTurn"] = (threadId) => Effect.gen(function* () { const ctx = yield* requireSession(threadId); + // Settled before the cancel goes out, as ACP requires: an outstanding + // permission request has a hook process blocked behind it, and the + // bridge turns "cancel" into a denial. Leaving it pending would hold + // that tool until the hook's own timeout. + const pending = approvalsByThread.get(threadId); + if (pending) { + for (const [, approval] of pending) { + yield* Deferred.succeed(approval.decision, "cancel"); + } + pending.clear(); + } yield* Effect.ignore( ctx.acp.cancel.pipe( Effect.mapError((error) => diff --git a/apps/server/src/provider/acp/antigravity/agyBridge.ts b/apps/server/src/provider/acp/antigravity/agyBridge.ts index fc6e2efa32f..d92f098df4f 100644 --- a/apps/server/src/provider/acp/antigravity/agyBridge.ts +++ b/apps/server/src/provider/acp/antigravity/agyBridge.ts @@ -186,11 +186,21 @@ const pendingOutbound = new Map< { readonly resolve: (value: unknown) => void; readonly reject: (error: Error) => void } >(); -function sendRequest(method: string, params: unknown): Promise { +function sendRequest(method: string, params: unknown, timeoutMs?: number): Promise { const id = nextOutboundId; nextOutboundId += 1; return new Promise((resolve, reject) => { pendingOutbound.set(id, { resolve, reject }); + if (timeoutMs !== undefined) { + // A client that never answers would otherwise pin this entry for the + // life of the bridge process. + const timer = setTimeout(() => { + if (pendingOutbound.delete(id)) { + reject(new Error(`${method} timed out`)); + } + }, timeoutMs); + timer.unref?.(); + } writeMessage({ jsonrpc: "2.0", id, method, params }); }); } @@ -351,21 +361,25 @@ function requestToolApproval(input: { }): void { const toolCall = input.payload.toolCall; const stepIdx = typeof input.payload.stepIdx === "number" ? input.payload.stepIdx : 0; - void sendRequest("session/request_permission", { - sessionId: input.sessionId, - toolCall: { - toolCallId: agyToolCallId(input.payload.conversationId, stepIdx), - title: agyToolTitle(toolCall), - kind: agyToolKind(toolCall?.name), - status: "pending", - rawInput: toolCall?.args ?? null, - ...(agyTargetPath(toolCall) ? { locations: [{ path: agyTargetPath(toolCall) }] } : {}), + void sendRequest( + "session/request_permission", + { + sessionId: input.sessionId, + toolCall: { + toolCallId: agyToolCallId(input.payload.conversationId, stepIdx), + title: agyToolTitle(toolCall), + kind: agyToolKind(toolCall?.name), + status: "pending", + rawInput: toolCall?.args ?? null, + ...(agyTargetPath(toolCall) ? { locations: [{ path: agyTargetPath(toolCall) }] } : {}), + }, + options: [ + { optionId: APPROVE_OPTION, name: "Allow", kind: "allow_once" }, + { optionId: REJECT_OPTION, name: "Reject", kind: "reject_once" }, + ], }, - options: [ - { optionId: APPROVE_OPTION, name: "Allow", kind: "allow_once" }, - { optionId: REJECT_OPTION, name: "Reject", kind: "reject_once" }, - ], - }) + APPROVAL_WAIT_MS, + ) .then((result) => { writeDecision( input.hookDir, @@ -412,6 +426,12 @@ export async function runAgyHook(event: string): Promise { } const hookDir = process.env[HOOK_DIR_ENV]?.trim(); + // When approvals are required this process is a security gate, not an + // observer. Any failure below — unparseable payload, unwritable hook + // directory, a bridge that never answers — must deny, because the CLI has + // already been told to skip its own permission prompts and nothing else + // stands between the model and the tool. + const gating = Boolean(hookDir) && approvalRequired() && event === "pre-tool-use"; if (hookDir) { try { const payload = JSON.parse(raw) as AgyHookPayload; @@ -433,13 +453,23 @@ export async function runAgyHook(event: string): Promise { // With approvals on, this process is the gate: block until the bridge // reports the user's answer. `deny` here genuinely stops the tool — // Antigravity reports the reason back to the model. - if (approvalRequired() && event === "pre-tool-use" && payload?.toolCall) { + if (gating && payload?.toolCall) { const decision = await awaitDecision(hookDir, name); process.stdout.write(JSON.stringify(decision)); return; } } catch { - // Observation is best-effort: a hook must never break a tool call. + // Observation is best-effort: a hook must never break a tool call — + // unless it is the approval gate, handled below. + if (gating) { + process.stdout.write( + JSON.stringify({ + decision: "deny", + reason: "T3 Code could not record this tool call for approval", + } satisfies AgyHookDecision), + ); + return; + } } } @@ -612,6 +642,22 @@ function stageAttachments(attachments: ReadonlyArray): { } const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-agy-attach-")); const staged: Array = []; + try { + stageInto(dir, attachments, staged); + } catch (error) { + // Copies that already succeeded are user uploads; leaving them in /tmp + // because a later one failed would outlive the turn that needed them. + cleanupDir(dir); + throw error; + } + return { dir, staged }; +} + +function stageInto( + dir: string, + attachments: ReadonlyArray, + staged: Array, +): void { attachments.forEach((attachment, index) => { // Index-prefixed and sanitised so two attachments sharing a basename cannot // collide and a crafted name cannot escape the staging directory. @@ -623,7 +669,6 @@ function stageAttachments(attachments: ReadonlyArray): { NodeFS.copyFileSync(attachment.path, target); staged.push({ path: target, name: safeName, mimeType: attachment.mimeType }); }); - return { dir, staged }; } function composePromptText( @@ -651,6 +696,23 @@ let activeChild: NodeChildProcess.ChildProcess | null = null; /** Session whose turn is currently running, if any. Gates `session/cancel`. */ let activeTurnSessionId: string | null = null; const cancelledSessions = new Set(); +/** + * Cancels seen per session, counted rather than flagged. + * + * A prompt records this value when it is queued. If the count has moved by the + * time the prompt reaches the front, a cancel arrived while it waited and the + * prompt must not run — turns are serialized, so a steer queued behind a long + * turn would otherwise start executing tools after the user pressed Stop. A + * counter rather than a flag is what keeps this from also swallowing a cancel + * that lands harmlessly between two turns. + */ +const cancelGenerations = new Map(); +/** Cancel generation captured when each queued prompt was accepted, by JSON-RPC id. */ +const queuedPromptGenerations = new Map(); + +function cancelGeneration(sessionId: string): number { + return cancelGenerations.get(sessionId) ?? 0; +} /** * Drain everything Antigravity has produced so far and emit it as ACP updates. @@ -677,10 +739,16 @@ function drain(input: { const update = hookSessionUpdate(hook, input.state, fileText); if (update) { const stepIdx = hook.payload?.stepIdx; - if (hook.event === "post-tool-use" && typeof stepIdx === "number") { + if ( + hook.event === "post-tool-use" && + typeof stepIdx === "number" && + !input.state.transcriptSeenSteps.has(stepIdx) + ) { // Held back rather than sent: the transcript record carrying this // step's real output is usually read later in this same pass, and a - // completed call can no longer take content. + // completed call can no longer take content. Once that record has been + // seen there is nothing left to wait for — and nothing to wait on, + // since the transcript is read once by byte offset. input.state.pendingTerminal.set(stepIdx, update); } else { sendSessionUpdate(input.sessionId, update); @@ -688,7 +756,14 @@ function drain(input: { } // The hook process is blocked until a decision file appears, so this has // to be started for every announced tool call. - if (approvalRequired() && hook.event === "pre-tool-use" && hook.payload?.toolCall) { + // Not during the final drain: the child has already exited, so no tool is + // waiting on the answer and the request would never be settled. + if ( + !input.final && + approvalRequired() && + hook.event === "pre-tool-use" && + hook.payload?.toolCall + ) { requestToolApproval({ sessionId: input.sessionId, hookDir: input.hookDir, @@ -1033,6 +1108,13 @@ async function handleRequest(message: Record): Promise { sendError(id, -32602, "unknown sessionId"); return; } + const queuedAt = queuedPromptGenerations.get(id); + queuedPromptGenerations.delete(id); + if (queuedAt !== undefined && queuedAt !== cancelGeneration(sessionId)) { + // Cancelled while it sat in the queue; never spawn `agy` for it. + sendResult(id, { stopReason: "cancelled" }); + return; + } const prompt = renderPrompt(session, params["prompt"]); if (prompt === null) { sendError(id, -32602, "session/prompt requires at least one text block"); @@ -1048,13 +1130,18 @@ async function handleRequest(message: Record): Promise { } case "session/cancel": { const sessionId = typeof params["sessionId"] === "string" ? params["sessionId"] : undefined; - // Only a cancel aimed at the turn actually running can decide its stop - // reason. Cancels bypass the request queue, so one that arrives after a - // turn has already finished — or targets an idle session — would - // otherwise sit in the set and mark the next successful turn cancelled. - if (sessionId && sessionId === activeTurnSessionId) { - cancelledSessions.add(sessionId); - activeChild?.kill("SIGTERM"); + if (sessionId) { + // Always recorded, so prompts already queued for this session can see + // that a cancel happened and refuse to start. + cancelGenerations.set(sessionId, cancelGeneration(sessionId) + 1); + // Only a cancel aimed at the turn actually running decides its stop + // reason. Cancels bypass the request queue, so one arriving after a + // turn finished — or targeting an idle session — must not sit in the + // set and mark the next successful turn cancelled. + if (sessionId === activeTurnSessionId) { + cancelledSessions.add(sessionId); + activeChild?.kill("SIGTERM"); + } } return; } @@ -1111,6 +1198,14 @@ export async function runAgyBridge(): Promise { continue; } const pending = message; + // Captured now, not when the turn starts: the gap between the two is + // exactly the window a cancel has to arrive in while this prompt waits. + if (pending["method"] === "session/prompt") { + const target = (pending["params"] as Record | undefined)?.["sessionId"]; + if (typeof target === "string" && pending["id"] !== undefined) { + queuedPromptGenerations.set(pending["id"], cancelGeneration(target)); + } + } queue = queue .then(() => handleRequest(pending)) .catch((error: unknown) => { diff --git a/apps/server/src/provider/acp/antigravity/agyEvents.ts b/apps/server/src/provider/acp/antigravity/agyEvents.ts index 7922d1401ba..b90d6c81c83 100644 --- a/apps/server/src/provider/acp/antigravity/agyEvents.ts +++ b/apps/server/src/provider/acp/antigravity/agyEvents.ts @@ -197,6 +197,13 @@ export interface AgyTurnState { * a second, never-completed item. */ readonly pendingTerminal: Map; + /** + * Steps whose transcript output has already been streamed. The transcript is + * read once by byte offset, so a record consumed before its `PostToolUse` + * hook appeared will never be revisited — without this the tool would render + * as running until the turn ended. + */ + readonly transcriptSeenSteps: Set; conversationId: string | undefined; transcriptPath: string | undefined; /** Transcript file pinned for the turn; see `resolveTranscriptPath`. */ @@ -213,6 +220,7 @@ export function makeAgyTurnState(conversationId?: string): AgyTurnState { return { toolCalls: new Map(), pendingTerminal: new Map(), + transcriptSeenSteps: new Set(), conversationId, transcriptPath: undefined, resolvedTranscriptPath: undefined, diff --git a/apps/server/src/provider/acp/antigravity/agyTranscript.test.ts b/apps/server/src/provider/acp/antigravity/agyTranscript.test.ts index 7d921a136cc..79960e6fa95 100644 --- a/apps/server/src/provider/acp/antigravity/agyTranscript.test.ts +++ b/apps/server/src/provider/acp/antigravity/agyTranscript.test.ts @@ -142,6 +142,19 @@ describe("transcriptRecordUpdates", () => { }); }); + it("records the steps whose output has been streamed", () => { + // The transcript is read once by byte offset, so a step consumed before + // its PostToolUse hook appears is never revisited; the bridge uses this to + // complete the call immediately instead of waiting for a second record. + const state = stateWithActiveTool(3); + transcriptRecordUpdates( + { step_index: 3, source: "MODEL", type: "RUN_COMMAND", content: "Created At: x\nok" }, + state, + ); + + expect(state.transcriptSeenSteps.has(3)).toBe(true); + }); + it("drops tool output with no announced call rather than inventing one", () => { const result = transcriptRecordUpdates( { step_index: 99, source: "MODEL", type: "RUN_COMMAND", content: "orphan" }, diff --git a/apps/server/src/provider/acp/antigravity/agyTranscript.ts b/apps/server/src/provider/acp/antigravity/agyTranscript.ts index bd965c10218..18f15de33c7 100644 --- a/apps/server/src/provider/acp/antigravity/agyTranscript.ts +++ b/apps/server/src/provider/acp/antigravity/agyTranscript.ts @@ -130,6 +130,10 @@ export function transcriptRecordUpdates( if (typeof stepIndex !== "number") { return { updates: [], emittedAssistantText: false }; } + // Recorded before the early returns below: the transcript is read once by + // byte offset, so "this step's record has gone by" holds even when it + // carried nothing worth emitting. + state.transcriptSeenSteps.add(stepIndex); // Completed calls stay in the map precisely so this lookup still resolves: // a fast tool's `PostToolUse` hook and its transcript record routinely land // in the same drain pass, and hooks are read first. From fa32df52f98d264832ea1ead8148bc0e5dd1ccfa Mon Sep 17 00:00:00 2001 From: Agent <191493048+agentool@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:12:57 -0400 Subject: [PATCH 7/9] Antigravity: require tool approval by default, fix approval and resume bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `requireToolApproval` now defaults to on. `agy` runs with permissions skipped either way, so the hook is the only thing between the model and a tool; a gate that has to be discovered protects nobody. Fixes from the latest review round: - "Allow for this session" no longer denies the tool. The bridge advertised only allow_once/reject_once, so the client mapped that choice to no option id, the reply read as cancelled, and the gate denied — the button did the opposite of what it said. An allow_always option is offered and honoured for the rest of the session. - `requireToolApproval` was missing from AntigravitySettingsPatch, so the setting was stripped on every settings update and could never be changed from its default. - `requiresNewThreadForModelChange` was still true, which kept the UI from offering the in-session model switch the adapter had just gained. - A resumed transcript whose current-turn USER_INPUT had not been written yet could replay the previous turn's trailing records as live output. Those lines are now retained and re-examined instead of emitted. - Transcript bytes are decoded through a streaming decoder. A write landing mid-multibyte-character used to be replaced with U+FFFD and skipped, corrupting non-ASCII assistant text and tool output. - Two concurrent sendTurns could both read promptsInFlight as zero and open rival turns; the id is now minted before the read so claiming is atomic. - Turn settlement catches defects, not only typed failures, and cancelling an already-settled approval no longer aborts teardown. Verified end to end through the built bundle: reject blocks the tool and leaves the file untouched; allow and allow-for-session both run it and emit in_progress -> content -> completed. --- AGENTS.md | 6 +- .../src/provider/Layers/AntigravityAdapter.ts | 24 ++++++-- .../provider/Layers/AntigravityProvider.ts | 6 +- .../src/provider/acp/antigravity/agyBridge.ts | 60 ++++++++++++++++--- .../acp/antigravity/agyEvents.test.ts | 15 ++++- .../src/provider/acp/antigravity/agyEvents.ts | 14 ++++- .../provider/acp/antigravity/agyTranscript.ts | 11 ++++ packages/contracts/src/settings.ts | 6 +- 8 files changed, 116 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d30a3eac34c..86419209688 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,9 +42,9 @@ Known constraints, all inherent to print mode: - `agy` always runs with `--dangerously-skip-permissions` because print mode cannot prompt. Approvals instead come from the `PreToolUse` hook, which blocks the tool until T3 Code - answers — a `deny` decision genuinely stops it and is reported back to the model. Enable - with the `requireToolApproval` setting; it is off by default because each approval blocks - the turn on a human, and it fails closed on timeout or a lost bridge. + answers — a `deny` decision genuinely stops it and is reported back to the model. Controlled + by the `requireToolApproval` setting, on by default; it fails closed on timeout, a lost + bridge, or any hook failure. Turn it off to let tools run unattended. - Attachments are passed by path, not inline: the adapter sends `resource_link` blocks for files the attachment store already wrote to disk, and the bridge names those paths in the prompt and adds their directory to the workspace. `agy` has no attachment flag, so inline diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index f6a4abc3b72..c007a1a98ee 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -9,8 +9,8 @@ * `--dangerously-skip-permissions` because print mode cannot prompt. When * `requireToolApproval` is set, the bridge's `PreToolUse` hook becomes the * gate instead: it blocks the tool until this adapter answers, and a - * denial is reported back to the model. Off by default, since every - * approval stops the turn until a human replies. + * denial is reported back to the model. On by default: without it nothing + * stands between the model and an auto-approved tool. * - **No session modes.** Print mode has no plan/ask distinction to switch. * - **Attachments travel by reference.** `agy --print` has no attachment * flag, so files are sent as `resource_link` blocks and the bridge stages @@ -293,7 +293,9 @@ export function makeAntigravityAdapter( const pending = approvalsByThread.get(ctx.threadId); if (pending) { for (const [, approval] of pending) { - yield* Deferred.succeed(approval.decision, "cancel"); + // Ignored: an answer racing session stop may have settled this + // already, and that must not abort the rest of teardown. + yield* Effect.ignore(Deferred.succeed(approval.decision, "cancel")); } approvalsByThread.delete(ctx.threadId); } @@ -668,8 +670,15 @@ export function makeAntigravityAdapter( // A sendTurn while a prompt is in flight is a steer: the new prompt // folds into the ongoing work, so the active turn id is reused. + // + // The id is minted first, before anything is read, so that reading + // `promptsInFlight` and claiming it are one synchronous step. Yielding + // between the two — which generating the id here used to do — let two + // concurrent calls both observe zero and open rival turns over the + // same thread. + const freshTurnId = TurnId.make(yield* randomUUIDv4); const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; - const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + const turnId = steeringTurnId ?? freshTurnId; ctx.promptsInFlight += 1; // Terminal-event bookkeeping. Publication happens in exactly one place // (the teardown below) so that the decision cannot race a steer. @@ -791,7 +800,10 @@ export function makeAntigravityAdapter( turnId, payload: { state, stopReason }, }); - }).pipe(Effect.catch(() => Effect.void)), + // `catchCause` rather than `catch`: a defect while stamping or + // publishing would otherwise escape after `promptsInFlight` was + // already decremented, stranding the turn as running. + }).pipe(Effect.catchCause(() => Effect.void)), ), ); }); @@ -806,7 +818,7 @@ export function makeAntigravityAdapter( const pending = approvalsByThread.get(threadId); if (pending) { for (const [, approval] of pending) { - yield* Deferred.succeed(approval.decision, "cancel"); + yield* Effect.ignore(Deferred.succeed(approval.decision, "cancel")); } pending.clear(); } diff --git a/apps/server/src/provider/Layers/AntigravityProvider.ts b/apps/server/src/provider/Layers/AntigravityProvider.ts index 50783e975d4..3a6ad6ac10d 100644 --- a/apps/server/src/provider/Layers/AntigravityProvider.ts +++ b/apps/server/src/provider/Layers/AntigravityProvider.ts @@ -43,9 +43,9 @@ const ANTIGRAVITY_PRESENTATION = { displayName: "Antigravity", badgeLabel: "Experimental", showInteractionModeToggle: false, - // The bridge passes `--model` when it spawns `agy`, so a model change only - // takes effect on a new session. - requiresNewThreadForModelChange: true, + // `--model` is a per-spawn flag that composes with `--conversation`, so the + // bridge applies a switch to the next turn without losing the trajectory. + requiresNewThreadForModelChange: false, } as const; const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [] }); diff --git a/apps/server/src/provider/acp/antigravity/agyBridge.ts b/apps/server/src/provider/acp/antigravity/agyBridge.ts index d92f098df4f..7ba9f6d948d 100644 --- a/apps/server/src/provider/acp/antigravity/agyBridge.ts +++ b/apps/server/src/provider/acp/antigravity/agyBridge.ts @@ -19,6 +19,7 @@ import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeStringDecoder from "node:string_decoder"; import * as NodeURL from "node:url"; import packageJson from "../../../../package.json" with { type: "json" }; @@ -324,7 +325,16 @@ function readHookEvents(hookDir: string, seen: Set): ReadonlyArray(); function approvalRequired(): boolean { return process.env[REQUIRE_APPROVAL_ENV]?.trim() === "1"; @@ -353,12 +363,23 @@ function writeDecision(hookDir: string, hookName: string, decision: AgyHookDecis * Fire-and-forget by design: `drain` runs on a timer and must not block the * transcript reader while a human decides. */ +function selectedOptionId(result: unknown): string | undefined { + const outcome = isRecord(result) ? result["outcome"] : undefined; + const optionId = isRecord(outcome) ? outcome["optionId"] : undefined; + return typeof optionId === "string" ? optionId : undefined; +} + function requestToolApproval(input: { readonly sessionId: string; readonly hookDir: string; readonly hookName: string; readonly payload: AgyHookPayload; }): void { + // Already approved for the whole session: answer without troubling the user. + if (sessionWideApprovals.has(input.sessionId)) { + writeDecision(input.hookDir, input.hookName, { decision: "allow" }); + return; + } const toolCall = input.payload.toolCall; const stepIdx = typeof input.payload.stepIdx === "number" ? input.payload.stepIdx : 0; void sendRequest( @@ -375,17 +396,22 @@ function requestToolApproval(input: { }, options: [ { optionId: APPROVE_OPTION, name: "Allow", kind: "allow_once" }, + { + optionId: APPROVE_SESSION_OPTION, + name: "Allow for this session", + kind: "allow_always", + }, { optionId: REJECT_OPTION, name: "Reject", kind: "reject_once" }, ], }, APPROVAL_WAIT_MS, ) .then((result) => { - writeDecision( - input.hookDir, - input.hookName, - approvalOutcomeToDecision(result, APPROVE_OPTION), - ); + const decision = approvalOutcomeToDecision(result, [APPROVE_OPTION, APPROVE_SESSION_OPTION]); + if (decision.decision === "allow" && selectedOptionId(result) === APPROVE_SESSION_OPTION) { + sessionWideApprovals.add(input.sessionId); + } + writeDecision(input.hookDir, input.hookName, decision); }) .catch(() => { writeDecision(input.hookDir, input.hookName, { @@ -726,6 +752,7 @@ function drain(input: { readonly seenHooks: Set; readonly state: AgyTurnState; readonly cursor: AgyTranscriptCursor; + readonly decoder: NodeStringDecoder.StringDecoder; readonly transcriptOffset: { value: number }; readonly assistantText: { emitted: boolean }; readonly final: boolean; @@ -784,7 +811,11 @@ function drain(input: { const length = stats.size - input.transcriptOffset.value; const buffer = Buffer.alloc(length); NodeFS.readSync(fd, buffer, 0, length, input.transcriptOffset.value); - chunk = buffer.toString("utf8"); + // Decoded through the turn's streaming decoder, not `toString`: a + // write can land mid-multibyte-character, and decoding each slice + // independently would replace the partial sequence with U+FFFD and + // advance past it, silently corrupting non-ASCII output. + chunk = input.decoder.write(buffer); input.transcriptOffset.value = stats.size; } finally { NodeFS.closeSync(fd); @@ -799,8 +830,18 @@ function drain(input: { // Reading always starts at byte 0, so the first batch of a resumed // conversation carries every prior turn. Trim once, then stream. if (!input.state.transcriptPrimed && allLines.length > 0) { - allLines = [...dropPriorTurnRecords(allLines)]; - input.state.transcriptPrimed = true; + const trimmed = dropPriorTurnRecords(allLines); + if (trimmed.length === allLines.length && input.state.resumedConversation && !input.final) { + // Resuming, and this batch holds no `USER_INPUT` — so the current + // turn's opening record has not been written yet and everything here + // belongs to a previous turn. Emitting it would replay old output; + // hold off and re-examine once more has been appended. + input.cursor.retain(allLines); + allLines = []; + } else { + allLines = [...trimmed]; + input.state.transcriptPrimed = true; + } } for (const line of allLines) { const record = parseTranscriptLine(line); @@ -915,6 +956,7 @@ async function runTurn( const state = makeAgyTurnState(session.conversationId); const seenHooks = new Set(); const cursor = new AgyTranscriptCursor(); + const decoder = new NodeStringDecoder.StringDecoder("utf8"); const transcriptOffset = { value: 0 }; const assistantText = { emitted: false }; activeChild = child; @@ -941,6 +983,7 @@ async function runTurn( seenHooks, state, cursor, + decoder, transcriptOffset, assistantText, final: false, @@ -961,6 +1004,7 @@ async function runTurn( seenHooks, state, cursor, + decoder, transcriptOffset, assistantText, final: true, diff --git a/apps/server/src/provider/acp/antigravity/agyEvents.test.ts b/apps/server/src/provider/acp/antigravity/agyEvents.test.ts index 8b8bcab713f..437e2f77f79 100644 --- a/apps/server/src/provider/acp/antigravity/agyEvents.test.ts +++ b/apps/server/src/provider/acp/antigravity/agyEvents.test.ts @@ -220,9 +220,20 @@ describe("hookSessionUpdate", () => { }); describe("approvalOutcomeToDecision", () => { + it("accepts any of the configured approve options", () => { + // The client renders an "always allow" choice; without it in this list a + // user pressing it would have their tool denied. + expect( + approvalOutcomeToDecision({ outcome: { outcome: "selected", optionId: "allow-session" } }, [ + "allow", + "allow-session", + ]).decision, + ).toBe("allow"); + }); + it("allows only an explicit selection of the approve option", () => { expect( - approvalOutcomeToDecision({ outcome: { outcome: "selected", optionId: "allow" } }, "allow"), + approvalOutcomeToDecision({ outcome: { outcome: "selected", optionId: "allow" } }, ["allow"]), ).toEqual({ decision: "allow" }); }); @@ -239,7 +250,7 @@ describe("approvalOutcomeToDecision", () => { undefined, "allow", ]) { - expect(approvalOutcomeToDecision(reply, "allow").decision).toBe("deny"); + expect(approvalOutcomeToDecision(reply, ["allow"]).decision).toBe("deny"); } }); }); diff --git a/apps/server/src/provider/acp/antigravity/agyEvents.ts b/apps/server/src/provider/acp/antigravity/agyEvents.ts index b90d6c81c83..af44842ac51 100644 --- a/apps/server/src/provider/acp/antigravity/agyEvents.ts +++ b/apps/server/src/provider/acp/antigravity/agyEvents.ts @@ -213,6 +213,11 @@ export interface AgyTurnState { * byte 0, which on a resumed conversation is the start of the whole history. */ transcriptPrimed: boolean; + /** + * Whether this turn resumed an existing conversation. Only then can the + * transcript already hold other turns' records at byte 0. + */ + readonly resumedConversation: boolean; modelName: string | undefined; } @@ -225,6 +230,7 @@ export function makeAgyTurnState(conversationId?: string): AgyTurnState { transcriptPath: undefined, resolvedTranscriptPath: undefined, transcriptPrimed: false, + resumedConversation: conversationId !== undefined, modelName: undefined, }; } @@ -394,7 +400,7 @@ export interface AgyHookDecision { */ export function approvalOutcomeToDecision( result: unknown, - approveOptionId: string, + approveOptionIds: ReadonlyArray, ): AgyHookDecision { const outcome = typeof result === "object" && result !== null @@ -404,7 +410,11 @@ export function approvalOutcomeToDecision( typeof outcome === "object" && outcome !== null ? (outcome as { outcome?: unknown; optionId?: unknown }) : undefined; - if (selected?.outcome === "selected" && selected.optionId === approveOptionId) { + if ( + selected?.outcome === "selected" && + typeof selected.optionId === "string" && + approveOptionIds.includes(selected.optionId) + ) { return { decision: "allow" }; } return { decision: "deny", reason: "Rejected in T3 Code" }; diff --git a/apps/server/src/provider/acp/antigravity/agyTranscript.ts b/apps/server/src/provider/acp/antigravity/agyTranscript.ts index 18f15de33c7..f9529a12bc5 100644 --- a/apps/server/src/provider/acp/antigravity/agyTranscript.ts +++ b/apps/server/src/provider/acp/antigravity/agyTranscript.ts @@ -208,6 +208,17 @@ export class AgyTranscriptCursor { return lines; } + /** + * Push whole lines back to the front of the stream. + * + * Used when a batch cannot be interpreted yet — on a resumed conversation the + * current turn's opening record may not have been written — so the same lines + * are re-examined against the next read rather than emitted or discarded. + */ + retain(lines: ReadonlyArray): void { + this.carry = lines.length > 0 ? `${lines.join("\n")}\n${this.carry}` : this.carry; + } + /** Flush the trailing line once the writer is known to be finished. */ flush(): ReadonlyArray { const remaining = this.carry; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index c4789c9a0e2..b3f2066bc8f 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -376,9 +376,10 @@ export const AntigravitySettings = makeProviderSettingsSchema( // Print mode cannot prompt, so `agy` runs with permissions skipped and the // bridge's PreToolUse hook becomes the gate instead: it blocks the tool // until T3 Code answers, and a denial is reported back to the model. - // Off by default because every approval blocks the turn on a human. + // On by default: without it nothing stands between the model and an + // auto-approved tool, and a gate that has to be discovered protects no one. requireToolApproval: Schema.Boolean.pipe( - Schema.withDecodingDefault(Effect.succeed(false)), + Schema.withDecodingDefault(Effect.succeed(true)), Schema.annotateKey({ title: "Ask before running tools", description: @@ -591,6 +592,7 @@ const AntigravitySettingsPatch = Schema.Struct({ binaryPath: Schema.optionalKey(TrimmedString), printTimeout: Schema.optionalKey(TrimmedString), appDataDir: Schema.optionalKey(TrimmedString), + requireToolApproval: Schema.optionalKey(Schema.Boolean), customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); From 14633ddf8d5cd111eeeeec3f3c1589450a3f5af3 Mon Sep 17 00:00:00 2001 From: Agent <191493048+agentool@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:22:11 -0400 Subject: [PATCH 8/9] Antigravity: make cancellation authoritative and bound approval waits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A cancel now stops prompts already accepted but still queued upstream. The bridge's own generation check was too late: the ACP runtime serializes prompts behind a semaphore, so a steer could still be waiting there when Stop was pressed, reach the bridge after the cancel, capture the post-cancel state and run anyway. The adapter records a cancel epoch when it accepts a prompt and rechecks it immediately before submitting. - `turn.started` is claimed from shared per-turn state rather than from "am I a steer". A steer assumed the prompt it folded into had already announced the turn, which is false if that prompt failed during preflight — it would then emit content and a completion for a turn nobody started. - The adapter's approval handler races its own deadline and cleans up in an `ensuring`. The bridge forgetting a timed-out request only freed its side; this one stayed blocked on the deferred with its UI prompt open, and those accumulated across turns. An unanswered request resolves to cancel, which denies. - `approvalsByThread` is registered after the session, so an interrupted startup cannot leave an entry with no context to clean it up, and `createHookWorkspace` removes its directory if setup throws — the caller never receives the path and cannot do it. Re-verified end to end: reject blocks the tool, allow runs it with in_progress -> content -> completed. --- .../src/provider/Layers/AntigravityAdapter.ts | 78 ++++++++++++++++--- .../src/provider/acp/antigravity/agyBridge.ts | 10 +++ 2 files changed, 76 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index c007a1a98ee..e3a055c5704 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -81,6 +81,14 @@ import { type AntigravityAdapterShape } from "../Services/AntigravityAdapter.ts" import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = ProviderDriverKind.make("antigravity"); +/** + * How long this side waits for a tool-approval answer. + * + * Deliberately longer than the bridge's own wait: the bridge denies first and + * the blocked hook is released by that, so this only exists to stop an + * unanswered request pinning its entry and its UI prompt forever. + */ +const APPROVAL_WAIT_MS = 11 * 60 * 1000; const ANTIGRAVITY_RESUME_VERSION = 1 as const; export interface AntigravityAdapterLiveOptions { @@ -110,6 +118,20 @@ interface AntigravitySessionContext { activeTurnId: TurnId | undefined; /** Model the bridge will pass to `agy --model` on the next turn. */ currentModelId: string | undefined; + /** + * Bumped by every interrupt. A prompt records this when it is accepted and + * rechecks it immediately before submitting: the ACP runtime serializes + * prompts behind a semaphore, so a steer can still be waiting there when + * Stop is pressed and would otherwise reach the bridge *after* the cancel, + * capture the post-cancel state, and run anyway. + */ + cancelEpoch: number; + /** + * Turns for which `turn.started` has actually been published. Shared rather + * than per-call, so a steer cannot assume the prompt it folded into already + * announced the turn — that prompt may have failed during preflight. + */ + readonly startedTurnIds: Set; /** * Number of prompts in flight. >0 means a turn is running, so a new * sendTurn steers the existing turn rather than opening a new one. @@ -437,8 +459,18 @@ export function makeAntigravityAdapter( rawPayload: params, }), ); - const resolved = yield* Deferred.await(decision); - pendingApprovals.delete(requestId); + // Raced against a deadline and cleaned up unconditionally: the + // bridge forgets its side of a timed-out request, so without + // this the handler would stay blocked and the entry retained. + const answered = yield* Deferred.await(decision).pipe( + Effect.timeoutOption(APPROVAL_WAIT_MS), + Effect.ensuring(Effect.sync(() => pendingApprovals.delete(requestId))), + ); + // An unanswered request denies, matching the bridge's own + // timeout: this gate must never resolve to "allow" by default. + const resolved: ProviderApprovalDecision = Option.isSome(answered) + ? answered.value + : "cancel"; yield* offerRuntimeEvent( makeAcpRequestResolvedEvent({ stamp: yield* makeEventStamp(), @@ -495,12 +527,12 @@ export function makeAntigravityAdapter( turns: [], activeTurnId: undefined, currentModelId: boundModel, + cancelEpoch: 0, + startedTurnIds: new Set(), promptsInFlight: 0, stopped: false, }; - approvalsByThread.set(input.threadId, pendingApprovals); - const nf = yield* Stream.runDrain( Stream.mapEffect(acp.getEvents(), (event) => Effect.gen(function* () { @@ -589,6 +621,10 @@ export function makeAntigravityAdapter( ctx.notificationFiber = nf; sessions.set(input.threadId, ctx); + // Published only now: teardown finds pending approvals through the + // session context, so registering earlier would leak the entry if + // startup were interrupted before the session existed. + approvalsByThread.set(input.threadId, pendingApprovals); sessionScopeTransferred = true; yield* offerRuntimeEvent({ @@ -679,10 +715,11 @@ export function makeAntigravityAdapter( const freshTurnId = TurnId.make(yield* randomUUIDv4); const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; const turnId = steeringTurnId ?? freshTurnId; + const acceptedEpoch = ctx.cancelEpoch; ctx.promptsInFlight += 1; // Terminal-event bookkeeping. Publication happens in exactly one place // (the teardown below) so that the decision cannot race a steer. - let turnStarted = steeringTurnId !== undefined; + let turnStarted = false; let stopReason: string | null = null; let promptSucceeded = false; @@ -716,7 +753,11 @@ export function makeAntigravityAdapter( ctx.session = { ...ctx.session, model: turnModelSelection?.model ?? ctx.session.model }; } - if (steeringTurnId === undefined) { + // Claimed from shared state, not from "am I a steer": the prompt this + // one folded into may have failed before it announced anything, and + // emitting content for a turn that never started strands the UI. + if (!ctx.startedTurnIds.has(turnId)) { + ctx.startedTurnIds.add(turnId); yield* offerRuntimeEvent({ type: "turn.started", ...(yield* makeEventStamp()), @@ -725,7 +766,19 @@ export function makeAntigravityAdapter( turnId, payload: { model: ctx.session.model }, }); - turnStarted = true; + } + turnStarted = ctx.startedTurnIds.has(turnId); + + // Rechecked here rather than only at accept time: everything above + // yields, and an interrupt during any of it means this prompt must + // not reach the agent. + if (ctx.cancelEpoch !== acceptedEpoch) { + stopReason = "cancelled"; + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; } const result = yield* ctx.acp @@ -774,6 +827,7 @@ export function makeAntigravityAdapter( if (ctx.activeTurnId === turnId) { ctx.activeTurnId = undefined; } + ctx.startedTurnIds.delete(turnId); // The public session field is what `listSessions` and the reaper // read, so leaving it set would advertise a turn that has ended. if (ctx.session.activeTurnId === turnId) { @@ -787,11 +841,8 @@ export function makeAntigravityAdapter( if (!turnStarted) { return; } - const state = !promptSucceeded - ? "failed" - : stopReason === "cancelled" - ? "cancelled" - : "completed"; + const state = + stopReason === "cancelled" ? "cancelled" : promptSucceeded ? "completed" : "failed"; yield* offerRuntimeEvent({ type: "turn.completed", ...(yield* makeEventStamp()), @@ -811,6 +862,9 @@ export function makeAntigravityAdapter( const interruptTurn: AntigravityAdapterShape["interruptTurn"] = (threadId) => Effect.gen(function* () { const ctx = yield* requireSession(threadId); + // Recorded before anything else: prompts already accepted but still + // queued upstream compare against this and refuse to submit. + ctx.cancelEpoch += 1; // Settled before the cancel goes out, as ACP requires: an outstanding // permission request has a hook process blocked behind it, and the // bridge turns "cancel" into a denial. Leaving it pending would hold diff --git a/apps/server/src/provider/acp/antigravity/agyBridge.ts b/apps/server/src/provider/acp/antigravity/agyBridge.ts index 7ba9f6d948d..64c2b1fa719 100644 --- a/apps/server/src/provider/acp/antigravity/agyBridge.ts +++ b/apps/server/src/provider/acp/antigravity/agyBridge.ts @@ -264,6 +264,16 @@ function quoteArg(value: string): string { */ function createHookWorkspace(): string { const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-agy-hooks-")); + try { + return writeHookWorkspace(dir); + } catch (error) { + // The caller never received the path, so it cannot clean this up itself. + cleanupDir(dir); + throw error; + } +} + +function writeHookWorkspace(dir: string): string { const agentsDir = NodePath.join(dir, ".agents"); NodeFS.mkdirSync(agentsDir, { recursive: true }); // A gating PreToolUse hook blocks while the user decides, so its timeout has From d87551f3aab4d9e54b38adf8b3602466699fcc41 Mon Sep 17 00:00:00 2001 From: Agent <191493048+agentool@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:42:37 -0400 Subject: [PATCH 9/9] Antigravity: baseline resumed transcripts, tighten gate and turn claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The approval gate answers on every gating path. A PreToolUse payload carrying no `toolCall` still fell through to the observation-only response, which allows — so a change in Antigravity's payload shape would have run tools unapproved. Missing or unidentifiable tool calls now deny. - Resumed conversations use a byte baseline taken before `agy` starts instead of inferring the turn boundary from the last USER_INPUT record. The transcript always holds earlier USER_INPUT records, so a poll landing before the new one was written treated a previous turn's output as live. The conversation's log directory is derivable from its id, so the boundary can be measured rather than guessed; the marker heuristic remains as a fallback when the file cannot be read. - The turn id is bound in the same synchronous step as the prompt claim. A concurrent call could otherwise observe promptsInFlight > 0 with no active id yet and open a rival turn. - Turn settlement reads shared per-turn state rather than the settling call's own flag, and a turn is marked started only after the event is actually published. Otherwise a steer that became the last outstanding prompt could drop the completion, or emit one for a turn nobody saw start. Known and unfixed, both structural: a steer can still pass the adapter's cancel check and then wait on the ACP runtime's prompt semaphore, reaching the bridge after a cancel; and an interrupted startup between scope transfer and the first published event leaves the session registered. Both live in shared runtime code this provider only borrows. --- .../src/provider/Layers/AntigravityAdapter.ts | 19 ++++-- .../src/provider/acp/antigravity/agyBridge.ts | 60 ++++++++++++++++++- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index e3a055c5704..3534e866b15 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -716,15 +716,17 @@ export function makeAntigravityAdapter( const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; const turnId = steeringTurnId ?? freshTurnId; const acceptedEpoch = ctx.cancelEpoch; + // Claimed together, without an intervening yield: assigning the active + // turn id later let a concurrent call see `promptsInFlight > 0` with no + // id yet and open a rival turn. ctx.promptsInFlight += 1; + ctx.activeTurnId = turnId; // Terminal-event bookkeeping. Publication happens in exactly one place // (the teardown below) so that the decision cannot race a steer. - let turnStarted = false; let stopReason: string | null = null; let promptSucceeded = false; return yield* Effect.gen(function* () { - ctx.activeTurnId = turnId; ctx.session = { ...ctx.session, activeTurnId: turnId, @@ -757,7 +759,6 @@ export function makeAntigravityAdapter( // one folded into may have failed before it announced anything, and // emitting content for a turn that never started strands the UI. if (!ctx.startedTurnIds.has(turnId)) { - ctx.startedTurnIds.add(turnId); yield* offerRuntimeEvent({ type: "turn.started", ...(yield* makeEventStamp()), @@ -766,9 +767,11 @@ export function makeAntigravityAdapter( turnId, payload: { model: ctx.session.model }, }); + // Marked only after the event is actually out, so a failed publish + // cannot let another prompt emit a completion for a turn nobody + // saw start. + ctx.startedTurnIds.add(turnId); } - turnStarted = ctx.startedTurnIds.has(turnId); - // Rechecked here rather than only at accept time: everything above // yields, and an interrupt during any of it means this prompt must // not reach the agent. @@ -827,6 +830,10 @@ export function makeAntigravityAdapter( if (ctx.activeTurnId === turnId) { ctx.activeTurnId = undefined; } + // Read from shared state rather than this call's local flag: the + // prompt that published `turn.started` may not be the one that + // settles the turn, and the settler still owes the completion. + const published = ctx.startedTurnIds.has(turnId); ctx.startedTurnIds.delete(turnId); // The public session field is what `listSessions` and the reaper // read, so leaving it set would advertise a turn that has ended. @@ -838,7 +845,7 @@ export function makeAntigravityAdapter( // still owes consumers a terminal event; without one the turn // renders as running forever even though sendTurn already // returned an error. - if (!turnStarted) { + if (!published) { return; } const state = diff --git a/apps/server/src/provider/acp/antigravity/agyBridge.ts b/apps/server/src/provider/acp/antigravity/agyBridge.ts index 64c2b1fa719..8ceae275986 100644 --- a/apps/server/src/provider/acp/antigravity/agyBridge.ts +++ b/apps/server/src/provider/acp/antigravity/agyBridge.ts @@ -84,6 +84,44 @@ const sessions = new Map(); // `session/load` — potentially in a fresh bridge process — can still resume // the right conversation. +/** + * Where Antigravity keeps a conversation's trajectory. + * + * Layout confirmed against real conversations: + * `/brain//.system_generated/logs/`. + */ +function transcriptDirFor(conversationId: string): string { + const appDataDir = + process.env["T3_AGY_APP_DATA_DIR"]?.trim() || + NodePath.join(NodeOS.homedir(), ".gemini", "antigravity-cli"); + return NodePath.join(appDataDir, "brain", conversationId, ".system_generated", "logs"); +} + +/** + * Bytes already in a resumed conversation's transcript before this turn runs. + * + * A positive baseline, taken before `agy` is spawned, is what makes "records + * from earlier turns" precise. Inferring the boundary from the last + * `USER_INPUT` record cannot: the file always contains previous ones, so a + * poll landing before the new marker is written would treat an earlier turn's + * output as live. Returns 0 when the file cannot be measured, which falls back + * to the marker heuristic rather than skipping the turn's own output. + */ +function transcriptBaseline(conversationId: string | undefined): number { + if (!conversationId) { + return 0; + } + const dir = transcriptDirFor(conversationId); + for (const name of ["transcript.jsonl", "transcript_full.jsonl"]) { + try { + return NodeFS.statSync(NodePath.join(dir, name)).size; + } catch { + // Try the sibling, then give up. + } + } + return 0; +} + function stateDirPath(): string { const appDataDir = process.env["T3_AGY_APP_DATA_DIR"]?.trim() || @@ -489,8 +527,16 @@ export async function runAgyHook(event: string): Promise { // With approvals on, this process is the gate: block until the bridge // reports the user's answer. `deny` here genuinely stops the tool — // Antigravity reports the reason back to the model. - if (gating && payload?.toolCall) { - const decision = await awaitDecision(hookDir, name); + if (gating) { + // Every gating path answers here. A payload without `toolCall` used to + // fall through to the observation-only response, which allows — so a + // change in Antigravity's payload shape would have run tools unapproved. + const decision = payload?.toolCall + ? await awaitDecision(hookDir, name) + : ({ + decision: "deny", + reason: "T3 Code could not identify this tool call for approval", + } satisfies AgyHookDecision); process.stdout.write(JSON.stringify(decision)); return; } @@ -929,6 +975,9 @@ async function runTurn( // holding this claim. Leaving it unset until after the spawn would silently // drop those, letting an auto-approving child run on past a cancelled turn. activeTurnSessionId = sessionId; + // Measured before `agy` starts: whatever the transcript already holds + // belongs to earlier turns of the conversation being resumed. + const baseline = transcriptBaseline(session.conversationId); let hookDir: string | undefined; let hookWorkspace: string | undefined; let attachmentDir: string | undefined; @@ -967,7 +1016,12 @@ async function runTurn( const seenHooks = new Set(); const cursor = new AgyTranscriptCursor(); const decoder = new NodeStringDecoder.StringDecoder("utf8"); - const transcriptOffset = { value: 0 }; + const transcriptOffset = { value: baseline }; + if (baseline > 0) { + // Reading starts past every earlier turn, so there is no prior-turn + // content left for the `USER_INPUT` heuristic to guess at. + state.transcriptPrimed = true; + } const assistantText = { emitted: false }; activeChild = child; // A cancel during startup had no process to signal; deliver it now.