authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-03 02:34:48-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 02:34:48-07:00
log06336460c9988f29c71e839c4c9c840c4552e077
tree478dec832b8a765d27e98184359f4de24291fdb1
parent652515a349741d234111b85f27597be3265d1ffc
signature Signed by PGP key B5690EEEBB952194

feat(providers): add Google Antigravity via the official ACP agent (#9348)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

169 files changed, 21470 insertions(+), 1388 deletions(-)

AGENTS.md+2-2
......@@ -1,6 +1,6 @@
11# T3 Code
22
3T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs (Codex, Claude Code, Cursor, Grok, OpenCode) and serves web, desktop, and mobile clients.
3T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs and agents (Codex, Claude Code, Cursor, Grok, OpenCode, Antigravity) and serves web, desktop, and mobile clients.
44
55You can think of T3 Code as an open source "bring-your-own-subscription" alternative to apps like Claude Desktop, Codex App, Cursor Glass and Conductor.
66
......@@ -68,7 +68,7 @@ The most common defect in this repo is a change that works on the path you teste
6868
6969- **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature.
7070- **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime`
71- **Providers.** Codex, Claude, Cursor, Grok, and OpenCode each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here".
71- **Providers.** Codex, Claude, Cursor, Grok, OpenCode, and Antigravity each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here".
7272- **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow.
7373- **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug.
7474- **Connection modes.** Local, remote/relay, and tunnel behave differently. Multi-device and multi-environment cases are real.
README.md+3-2
......@@ -2,7 +2,7 @@
22
33T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes).
44
5Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them.
5Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, and Google Antigravity. If they're set up on your computer, T3 Code can control them.
66
77## "Wait, what are you selling me?"
88
......@@ -13,13 +13,14 @@ We wanted something performant, remote-ready, and truly open. If we ever go the
1313## Installation
1414
1515> [!WARNING]
16> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use:
16> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode, and Antigravity. Install and authenticate at least one provider before use:
1717>
1818> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`
1919> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login`
2020> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login`
2121> - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login`
2222> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login`
23> - Antigravity: enable it in Settings, then use **Install Antigravity** and **Sign in with Google**. No CLI is required.
2324
2425### Try it out (install-free)
2526
apps/mobile/assets/antigravity.png created
Binary files /dev/null and b/apps/mobile/assets/antigravity.png differ
apps/mobile/src/Stack.tsx+8
......@@ -56,6 +56,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc
5656import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
5757import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
5858import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen";
59import { SettingsProviderSetupRouteScreen } from "./features/settings/SettingsProviderSetupRouteScreen";
5960import { UsageRouteScreen } from "./features/usage/UsageRouteScreen";
6061import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
6162import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator";
......@@ -164,6 +165,13 @@ const SettingsContentStack = createNativeStackNavigator({
164165 title: "Add Environment",
165166 },
166167 }),
168 SettingsProviderSetup: createNativeStackScreen({
169 screen: SettingsProviderSetupRouteScreen,
170 linking: "providers/:environmentId/:instanceId",
171 options: {
172 title: "Antigravity",
173 },
174 }),
167175 SettingsArchive: createNativeStackScreen({
168176 screen: ArchivedThreadsRouteScreen,
169177 linking: "archive",
apps/mobile/src/components/ProviderIcon.tsx+11
......@@ -1,3 +1,4 @@
1import { Image } from "expo-image";
12import { Path, Svg } from "react-native-svg";
23import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
34
......@@ -12,6 +13,16 @@ export function ProviderIcon(props: ProviderIconProps) {
1213 const size = props.size ?? 16;
1314 const mono = isDarkMode ? "#e5e5e5" : "#171717";
1415
16 if (props.provider === "antigravity") {
17 return (
18 <Image
19 source={require("../../assets/antigravity.png")}
20 style={{ width: size, height: size }}
21 contentFit="contain"
22 />
23 );
24 }
25
1526 if (props.provider === "claudeAgent") {
1627 return (
1728 <Svg width={size} height={size} viewBox="0 0 256 257" fill="none">
apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx+6
......@@ -111,6 +111,12 @@ function ConfiguredConnectOnboardingRouteScreen() {
111111 <CloudEnvironmentRows
112112 connectedCloudEnvironments={connectedCloudEnvironments}
113113 onReconnectEnvironment={onReconnectEnvironment}
114 onSetupProvider={(params) =>
115 navigation.navigate("SettingsSheet", {
116 screen: "SettingsContent",
117 params: { screen: "SettingsProviderSetup", params },
118 })
119 }
114120 showHeader={false}
115121 />
116122 ) : (
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+42-18
......@@ -26,6 +26,8 @@ import { cn } from "../../lib/cn";
2626import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
2727import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
2828import { serverEnvironment } from "../../state/server";
29import { ProviderSetupLink } from "../settings/ProviderSetupLink";
30import type { ProviderSetupRouteParams } from "../settings/SettingsProviderSetupRouteScreen";
2931import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation";
3032import { hasCloudPublicConfig } from "../cloud/publicConfig";
3133import { ConnectionStatusDot } from "./ConnectionStatusDot";
......@@ -34,6 +36,7 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect
3436interface CloudEnvironmentRowsProps {
3537 readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>;
3638 readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void;
39 readonly onSetupProvider?: (target: ProviderSetupRouteParams) => void;
3740 readonly showcaseAvailableEnvironments?: ReadonlyArray<RelayEnvironmentView>;
3841 readonly showcaseSignedIn?: boolean;
3942 /**
......@@ -148,6 +151,7 @@ function CloudEnvironmentRowsContent(
148151 onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)}
149152 errorExpanded={expandedErrorId === environment.environmentId}
150153 onToggleError={() => handleToggleCloudError(environment.environmentId)}
154 onSetupProvider={props.onSetupProvider}
151155 />
152156 ))}
153157 {availableCloudEnvironments.map((environment, index) => (
......@@ -211,29 +215,49 @@ function ConnectedCloudEnvironmentRow(props: {
211215 readonly onConnect: () => void;
212216 readonly onDisconnect: () => void;
213217 readonly onToggleError: () => void;
218 readonly onSetupProvider?: (target: ProviderSetupRouteParams) => void;
214219}) {
215220 const serverConfig = useAtomValue(
216221 serverEnvironment.configValueAtom(props.environment.environmentId),
217222 );
218223 return (
219 <CloudEnvironmentRowShell
220 borderTop={props.borderTop}
221 connectionError={props.environment.connectionError}
222 connectionErrorTraceId={props.environment.connectionErrorTraceId}
223 connectionState={props.environment.connectionState}
224 errorExpanded={props.errorExpanded}
225 label={props.environment.environmentLabel}
226 machine={resolveEnvironmentMachineKind(serverConfig)}
227 onValueChange={(enabled) => {
228 if (enabled) {
229 props.onConnect();
230 return;
231 }
232 props.onDisconnect();
233 }}
234 onToggleError={props.onToggleError}
235 value={props.environment.connectionState !== "available"}
236 />
224 <View>
225 <CloudEnvironmentRowShell
226 borderTop={props.borderTop}
227 connectionError={props.environment.connectionError}
228 connectionErrorTraceId={props.environment.connectionErrorTraceId}
229 connectionState={props.environment.connectionState}
230 errorExpanded={props.errorExpanded}
231 label={props.environment.environmentLabel}
232 machine={resolveEnvironmentMachineKind(serverConfig)}
233 onValueChange={(enabled) => {
234 if (enabled) {
235 props.onConnect();
236 return;
237 }
238 props.onDisconnect();
239 }}
240 onToggleError={props.onToggleError}
241 value={props.environment.connectionState !== "available"}
242 />
243 {props.onSetupProvider
244 ? serverConfig?.providers
245 .filter((provider) => provider.setup?.canAuthenticate || provider.setup?.canInstall)
246 .map((provider) => (
247 <ProviderSetupLink
248 key={provider.instanceId}
249 provider={provider}
250 disabled={props.environment.connectionState !== "connected"}
251 onPress={() =>
252 props.onSetupProvider?.({
253 environmentId: props.environment.environmentId,
254 instanceId: provider.instanceId,
255 })
256 }
257 />
258 ))
259 : null}
260 </View>
237261 );
238262}
239263
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+19
......@@ -15,6 +15,8 @@ import { cn } from "../../lib/cn";
1515import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
1616import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
1717import { serverEnvironment } from "../../state/server";
18import { ProviderSetupLink } from "../settings/ProviderSetupLink";
19import type { ProviderSetupRouteParams } from "../settings/SettingsProviderSetupRouteScreen";
1820import { ConnectionStatusDot } from "./ConnectionStatusDot";
1921
2022function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {
......@@ -31,6 +33,7 @@ export function ConnectionEnvironmentRow(props: {
3133 readonly onToggle: () => void;
3234 readonly onReconnect: (environmentId: EnvironmentId) => void;
3335 readonly onRemove: (environmentId: EnvironmentId) => void;
36 readonly onSetupProvider: (target: ProviderSetupRouteParams) => void;
3437 readonly onUpdate: (
3538 environmentId: EnvironmentId,
3639 updates: { readonly label: string; readonly displayUrl: string },
......@@ -179,6 +182,22 @@ export function ConnectionEnvironmentRow(props: {
179182 </>
180183 )}
181184
185 {serverConfig?.providers
186 .filter((provider) => provider.setup?.canAuthenticate || provider.setup?.canInstall)
187 .map((provider) => (
188 <ProviderSetupLink
189 key={provider.instanceId}
190 provider={provider}
191 disabled={props.environment.connectionState !== "connected"}
192 onPress={() =>
193 props.onSetupProvider({
194 environmentId: props.environment.environmentId,
195 instanceId: provider.instanceId,
196 })
197 }
198 />
199 ))}
200
182201 <View className="flex-row justify-end gap-2">
183202 {props.environment.isRelayManaged ? null : (
184203 <Pressable
apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx+6
......@@ -75,6 +75,12 @@ export function ConnectionsRouteScreen() {
7575 onReconnect={onReconnectEnvironment}
7676 onRemove={onRemoveEnvironmentPress}
7777 onUpdate={onUpdateEnvironment}
78 onSetupProvider={(params) =>
79 navigation.navigate("SettingsSheet", {
80 screen: "SettingsContent",
81 params: { screen: "SettingsProviderSetup", params },
82 })
83 }
7884 />
7985 </View>
8086 ))}
apps/mobile/src/features/settings/ProviderSetupLink.tsx created+29
......@@ -0,0 +1,29 @@
1import type { ServerProvider } from "@t3tools/contracts";
2import { Pressable } from "react-native";
3
4import { AppText as Text } from "../../components/AppText";
5import { SymbolView } from "../../components/AppSymbol";
6import { ProviderIcon } from "../../components/ProviderIcon";
7import { providerNeedsSetup } from "./provider-setup-state";
8
9export function ProviderSetupLink(props: {
10 readonly provider: ServerProvider;
11 readonly disabled?: boolean;
12 readonly onPress: () => void;
13}) {
14 const label = props.provider.displayName ?? props.provider.driver;
15 return (
16 <Pressable
17 accessibilityRole="button"
18 disabled={props.disabled}
19 onPress={props.onPress}
20 className="min-h-12 flex-row items-center gap-3 px-4 py-3 active:opacity-70 disabled:opacity-40"
21 >
22 <ProviderIcon provider={props.provider.driver} size={18} />
23 <Text className="min-w-0 flex-1 text-base text-foreground">
24 {providerNeedsSetup(props.provider) ? `Set up ${label}` : `Manage ${label}`}
25 </Text>
26 <SymbolView name="chevron.right" size={12} tintColorClassName="accent-foreground" />
27 </Pressable>
28 );
29}
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+12
......@@ -137,6 +137,12 @@ export function SettingsEnvironmentsRouteScreen() {
137137 onReconnect={onReconnectEnvironment}
138138 onRemove={onRemoveEnvironmentPress}
139139 onUpdate={handleUpdateEnvironment}
140 onSetupProvider={(params) =>
141 navigation.navigate("SettingsSheet", {
142 screen: "SettingsContent",
143 params: { screen: "SettingsProviderSetup", params },
144 })
145 }
140146 />
141147 </View>
142148 ))}
......@@ -164,6 +170,12 @@ export function SettingsEnvironmentsRouteScreen() {
164170 <CloudEnvironmentRows
165171 connectedCloudEnvironments={connectedCloudEnvironments}
166172 onReconnectEnvironment={onReconnectEnvironment}
173 onSetupProvider={(params) =>
174 navigation.navigate("SettingsSheet", {
175 screen: "SettingsContent",
176 params: { screen: "SettingsProviderSetup", params },
177 })
178 }
167179 {...(SHOWCASE_ENABLED
168180 ? {
169181 showcaseAvailableEnvironments: SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS,
apps/mobile/src/features/settings/SettingsProviderSetupRouteScreen.tsx created+574
......@@ -0,0 +1,574 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
3import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
4import {
5 ANTIGRAVITY_AUTH_METHODS,
6 AuthOrchestrationOperateScope,
7 EnvironmentId,
8 ProviderInstanceId,
9} from "@t3tools/contracts";
10import * as Schema from "effect/Schema";
11import { AsyncResult } from "effect/unstable/reactivity";
12import { useCallback, useEffect, useRef, useState } from "react";
13import { Alert, Platform, Pressable, ScrollView, View } from "react-native";
14import { useSafeAreaInsets } from "react-native-safe-area-context";
15
16import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
17import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
18import { ProviderIcon } from "../../components/ProviderIcon";
19import { cn } from "../../lib/cn";
20import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
21import { tryOpenExternalUrl } from "../../lib/openExternalUrl";
22import { NativeStackScreenOptions } from "../../native/StackHeader";
23import { appAtomRegistry } from "../../state/atom-registry";
24import { useEnvironments } from "../../state/environments";
25import { useEnvironmentQuery } from "../../state/query";
26import { serverEnvironment } from "../../state/server";
27import { environmentSession } from "../../state/session";
28import { useAtomCommand } from "../../state/use-atom-command";
29import {
30 antigravityEnabledPatch,
31 readAntigravityAuthMethod,
32 resolveProviderSignInPresentation,
33} from "./provider-setup-state";
34
35export type ProviderSetupRouteParams = {
36 readonly environmentId: EnvironmentId;
37 readonly instanceId: ProviderInstanceId;
38};
39
40const PRIVATE_COMMAND_OPTIONS = { reportFailure: false, reportDefect: false } as const;
41const isEnvironmentId = Schema.is(EnvironmentId);
42const isProviderInstanceId = Schema.is(ProviderInstanceId);
43
44function SetupButton(props: {
45 readonly label: string;
46 readonly disabled?: boolean;
47 readonly primary?: boolean;
48 readonly destructive?: boolean;
49 readonly onPress: () => void;
50}) {
51 return (
52 <Pressable
53 accessibilityRole="button"
54 disabled={props.disabled}
55 onPress={props.onPress}
56 className={cn(
57 "min-h-11 items-center justify-center border border-white/25 px-4 py-3 active:opacity-70 disabled:opacity-40",
58 props.primary && "bg-white",
59 )}
60 >
61 <Text
62 className={cn(
63 "text-base font-t3-medium text-white",
64 props.primary && "text-black",
65 props.destructive && "text-red-400",
66 )}
67 >
68 {props.label}
69 </Text>
70 </Pressable>
71 );
72}
73
74/** The same screen stays inside Settings or the current model picker. */
75export function SettingsProviderSetupRouteScreen({
76 route,
77}: StaticScreenProps<ProviderSetupRouteParams>) {
78 if (
79 !isEnvironmentId(route.params?.environmentId) ||
80 !isProviderInstanceId(route.params?.instanceId)
81 ) {
82 return (
83 <View className="flex-1 bg-black p-5">
84 <Text className="text-base text-white">This provider link is not valid.</Text>
85 </View>
86 );
87 }
88 return (
89 <ProviderSetupScreen
90 key={`${route.params.environmentId}:${route.params.instanceId}`}
91 {...route.params}
92 />
93 );
94}
95
96function ProviderSetupScreen({ environmentId, instanceId }: ProviderSetupRouteParams) {
97 const navigation = useNavigation();
98 const insets = useSafeAreaInsets();
99 const { presentationById } = useEnvironments();
100 const environment = presentationById.get(environmentId);
101 const environmentLabel = environment?.entry.target.label ?? "this environment";
102 const isConnected = environment?.connection.phase === "connected";
103 const config = useAtomValue(serverEnvironment.configValueAtom(environmentId));
104 const provider = config?.providers.find((item) => item.instanceId === instanceId);
105 const access = useEnvironmentQuery(environmentSession.sessionStateAtom(environmentId));
106 const canOperate =
107 access.data?.authenticated === true &&
108 access.data.scopes?.includes(AuthOrchestrationOperateScope) === true;
109 const target = { environmentId, input: { instanceId } };
110 const authQuery = useEnvironmentQuery(
111 canOperate && provider?.setup?.canAuthenticate
112 ? serverEnvironment.providerAuthState(target)
113 : null,
114 );
115 const installQuery = useEnvironmentQuery(
116 canOperate && provider?.setup?.canInstall
117 ? serverEnvironment.providerInstallState(target)
118 : null,
119 );
120 const auth = authQuery.data;
121 const installation = installQuery.data;
122 const startAuth = useAtomCommand(serverEnvironment.startProviderAuth, PRIVATE_COMMAND_OPTIONS);
123 const completeAuth = useAtomCommand(
124 serverEnvironment.completeProviderAuth,
125 PRIVATE_COMMAND_OPTIONS,
126 );
127 const cancelAuth = useAtomCommand(serverEnvironment.cancelProviderAuth, PRIVATE_COMMAND_OPTIONS);
128 const logout = useAtomCommand(serverEnvironment.logoutProviderAuth, PRIVATE_COMMAND_OPTIONS);
129 const startInstall = useAtomCommand(
130 serverEnvironment.startProviderInstall,
131 PRIVATE_COMMAND_OPTIONS,
132 );
133 const cancelInstall = useAtomCommand(
134 serverEnvironment.cancelProviderInstall,
135 PRIVATE_COMMAND_OPTIONS,
136 );
137 const removeInstall = useAtomCommand(
138 serverEnvironment.removeProviderInstallation,
139 PRIVATE_COMMAND_OPTIONS,
140 );
141 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, PRIVATE_COMMAND_OPTIONS);
142 const [callbackUrl, setCallbackUrl] = useState("");
143 const [error, setError] = useState<string | null>(null);
144 const [busy, setBusy] = useState(false);
145 const busyRef = useRef(false);
146 const authActive =
147 auth?.phase === "starting" || auth?.phase === "waiting" || auth?.phase === "verifying";
148 const installActive =
149 installation?.phase === "downloading" ||
150 installation?.phase === "extracting" ||
151 installation?.phase === "verifying";
152 const controlsDisabled = busy || !isConnected || !canOperate;
153 const {
154 signedIn,
155 showSignOut,
156 message: authMessage,
157 } = resolveProviderSignInPresentation(provider, auth);
158 const authMethod = readAntigravityAuthMethod(
159 config?.settings.providerInstances[instanceId]?.config ??
160 (instanceId === "antigravity" ? config?.settings.providers.antigravity : undefined),
161 );
162 const usesBrowser = authMethod === "oauth-personal" || authMethod === "oauth-business";
163 const methodLabel =
164 ANTIGRAVITY_AUTH_METHODS.find((method) => method.value === authMethod)?.label ??
165 "Google account";
166
167 // Return URLs never enter saved drafts, navigation params, or diagnostics.
168 useEffect(() => {
169 setCallbackUrl("");
170 }, [auth?.flowId, auth?.phase]);
171
172 const perform = useCallback(
173 async (command: () => Promise<AtomCommandResult<unknown, unknown>>, failureMessage: string) => {
174 if (busyRef.current || !isConnected || !canOperate) return false;
175 busyRef.current = true;
176 setBusy(true);
177 setError(null);
178 try {
179 const result = await command();
180 if (!AsyncResult.isSuccess(result)) {
181 setError(failureMessage);
182 return false;
183 }
184 return true;
185 } catch {
186 setError(failureMessage);
187 return false;
188 } finally {
189 busyRef.current = false;
190 setBusy(false);
191 }
192 },
193 [canOperate, isConnected],
194 );
195
196 const setEnabled = (enabled: boolean) => {
197 const currentConfig = appAtomRegistry.get(serverEnvironment.configValueAtom(environmentId));
198 const currentProvider = currentConfig?.providers.find((item) => item.instanceId === instanceId);
199 if (!currentConfig || !currentProvider) return;
200 const patch = antigravityEnabledPatch(currentConfig.settings, currentProvider, enabled);
201 if (!patch) return;
202 void perform(
203 () => updateSettings({ environmentId, input: { patch } }),
204 "Could not change this provider. Reconnect and try again.",
205 );
206 };
207
208 const title = provider?.displayName ?? "Antigravity";
209 return (
210 <View className="flex-1 bg-black">
211 <NativeStackScreenOptions
212 options={{
213 title,
214 headerShown: Platform.OS !== "android",
215 headerTransparent: false,
216 headerStyle: { backgroundColor: "#000" },
217 headerTintColor: "#fff",
218 contentStyle: { backgroundColor: "#000" },
219 }}
220 />
221 {Platform.OS === "android" ? (
222 <AndroidScreenHeader title={title} onBack={() => navigation.goBack()} />
223 ) : null}
224 <ScrollView
225 className="flex-1"
226 contentInsetAdjustmentBehavior="automatic"
227 contentContainerClassName="gap-5 px-5 pt-5"
228 contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
229 keyboardShouldPersistTaps="handled"
230 showsVerticalScrollIndicator={false}
231 >
232 <View className="flex-row items-center gap-3">
233 <ProviderIcon provider="antigravity" size={28} />
234 <Text className="min-w-0 flex-1 text-base text-white">{environmentLabel}</Text>
235 </View>
236 {!isConnected ? (
237 <Text className="text-base text-white">
238 Reconnect to this environment to continue setup.
239 </Text>
240 ) : access.error ? (
241 <View className="gap-3">
242 <Text className="text-base text-white">Could not check your environment access.</Text>
243 <SetupButton label="Retry" onPress={access.refresh} />
244 </View>
245 ) : !access.data ? (
246 <Text className="text-base text-white">Checking environment access.</Text>
247 ) : !canOperate ? (
248 <Text className="text-base text-white">
249 This connection cannot manage providers. Pair again with permission to operate this
250 environment.
251 </Text>
252 ) : null}
253 {!config ? (
254 <Text className="text-base text-white">Loading provider settings.</Text>
255 ) : !provider || provider.driver !== "antigravity" ? (
256 <Text className="text-base text-white">
257 This provider is not available on this environment.
258 </Text>
259 ) : (
260 <>
261 <View className="gap-3">
262 <Text className="text-base text-white">
263 {provider.enabled ? "Enabled" : "Disabled"}
264 {provider.installed
265 ? `. Installed${provider.version ? ` ${provider.version}` : ""}`
266 : ". Not installed"}
267 </Text>
268 <SetupButton
269 label={provider.enabled ? "Disable Antigravity" : "Enable Antigravity"}
270 disabled={controlsDisabled}
271 onPress={() => {
272 if (!provider.enabled) {
273 setEnabled(true);
274 return;
275 }
276 Alert.alert(
277 "Disable Antigravity?",
278 `This stops Antigravity sessions on ${environmentLabel}. Google credentials stay saved.`,
279 [
280 { text: "Cancel", style: "cancel" },
281 { text: "Disable", style: "destructive", onPress: () => setEnabled(false) },
282 ],
283 );
284 }}
285 />
286 </View>
287 {provider.setup?.canInstall ? (
288 <View className="gap-3 border-t border-white/20 pt-4">
289 {installActive ? (
290 <>
291 <Text accessibilityLiveRegion="polite" className="text-base text-white">
292 {installation.phase === "downloading"
293 ? `Downloading ${Math.round(installation.downloadedBytes / 1_048_576)}${installation.totalBytes === null ? "" : ` of ${Math.round(installation.totalBytes / 1_048_576)}`} MB`
294 : installation.phase === "extracting"
295 ? "Extracting Antigravity files."
296 : "Checking the Antigravity installation."}
297 </Text>
298 <SetupButton
299 label="Cancel install"
300 disabled={controlsDisabled || installation.operationId === null}
301 onPress={() => {
302 const operationId = installation.operationId;
303 if (!operationId) return;
304 void perform(
305 () =>
306 cancelInstall({ environmentId, input: { instanceId, operationId } }),
307 "Could not cancel the install. Try again.",
308 );
309 }}
310 />
311 </>
312 ) : !provider.installed ? (
313 <SetupButton
314 label={
315 installation?.phase === "failed" || installation?.phase === "cancelled"
316 ? "Retry install"
317 : "Install Antigravity"
318 }
319 primary
320 disabled={controlsDisabled || authActive}
321 onPress={() =>
322 void perform(
323 () => startInstall(target),
324 "Could not start the install. Try again.",
325 )
326 }
327 />
328 ) : null}
329 {installation?.message ? (
330 <Text className="text-sm text-white">{installation.message}</Text>
331 ) : null}
332 {installation?.canRemove && !installActive ? (
333 <SetupButton
334 label="Remove managed install"
335 destructive
336 disabled={controlsDisabled || authActive}
337 onPress={() => {
338 Alert.alert(
339 "Remove the Antigravity install?",
340 `This removes T3's managed install from ${environmentLabel}. Providers that use it will need it installed again. Google credentials and threads stay.`,
341 [
342 { text: "Cancel", style: "cancel" },
343 {
344 text: "Remove",
345 style: "destructive",
346 onPress: () =>
347 void perform(
348 () => removeInstall(target),
349 "Could not remove the managed install. Try again.",
350 ),
351 },
352 ],
353 );
354 }}
355 />
356 ) : null}
357 {installQuery.error ? (
358 <Text className="text-sm text-white">
359 Install status is unavailable. Reconnect and try again.
360 </Text>
361 ) : null}
362 </View>
363 ) : !provider.installed ? (
364 <Text className="text-base text-white">
365 Antigravity cannot be installed on this environment. Use a supported server host or
366 set an executable path in provider settings.
367 </Text>
368 ) : null}
369 {provider.setup?.canAuthenticate ? (
370 <View className="gap-3 border-t border-white/20 pt-4">
371 <Text className="text-lg font-t3-medium text-white">{methodLabel}</Text>
372 <Text className="text-base text-white">
373 {signedIn
374 ? "Signed in. Credentials stay on this environment."
375 : provider.auth.status === "unknown"
376 ? "Sign-in has not been checked."
377 : usesBrowser
378 ? "Sign in with the Google account you use for Antigravity."
379 : "Connect with the credentials set in provider settings on web or desktop."}
380 </Text>
381 {authActive ? (
382 <>
383 <Text accessibilityLiveRegion="polite" className="text-base text-white">
384 {auth.phase === "starting"
385 ? usesBrowser
386 ? "Starting Google sign-in."
387 : "Checking credentials."
388 : auth.phase === "verifying"
389 ? usesBrowser
390 ? "Checking Google sign-in."
391 : "Checking credentials."
392 : auth.authorizationUrl
393 ? "Complete sign-in in your browser."
394 : "Sign-in is open on another client. Complete it there or wait for it to expire."}
395 </Text>
396 {auth.authorizationUrl && auth.phase === "waiting" ? (
397 <>
398 <SetupButton
399 label="Open Google sign-in"
400 primary
401 disabled={controlsDisabled}
402 onPress={() => {
403 if (!auth.authorizationUrl) return;
404 void tryOpenExternalUrl(auth.authorizationUrl, "provider-auth").then(
405 (opened) => {
406 if (!opened)
407 setError(
408 "Could not open Google sign-in. Copy the link and open it in your browser.",
409 );
410 },
411 );
412 }}
413 />
414 <SetupButton
415 label="Copy sign-in link"
416 disabled={controlsDisabled}
417 onPress={() => {
418 if (!auth.authorizationUrl) return;
419 void tryCopyTextWithHaptic(auth.authorizationUrl, {
420 target: "provider-sign-in-link",
421 }).then((copied) => {
422 if (!copied) setError("Could not copy the sign-in link.");
423 });
424 }}
425 />
426 <Text className="text-sm leading-5 text-white">
427 After sign-in, the browser will open a 127.0.0.1 address that cannot load
428 on your phone. Copy that full address and paste it here.
429 </Text>
430 <TextInput
431 accessibilityLabel="Google sign-in return URL"
432 autoCapitalize="none"
433 autoComplete="off"
434 autoCorrect={false}
435 className="min-h-12 rounded-none border border-white/25 bg-black px-3 py-3 text-base text-white"
436 editable={!controlsDisabled}
437 keyboardType="url"
438 onChangeText={setCallbackUrl}
439 placeholder="http://127.0.0.1:.../"
440 textContentType="none"
441 value={callbackUrl}
442 />
443 <SetupButton
444 label="Complete sign-in"
445 primary
446 disabled={
447 controlsDisabled || callbackUrl.trim().length === 0 || !auth.flowId
448 }
449 onPress={() => {
450 const flowId = auth.flowId;
451 if (!flowId) return;
452 const submittedUrl = callbackUrl.trim();
453 setCallbackUrl("");
454 void perform(
455 () =>
456 completeAuth({
457 environmentId,
458 input: { instanceId, flowId, callbackUrl: submittedUrl },
459 }),
460 "Could not complete Google sign-in. Check the return URL and try again.",
461 );
462 }}
463 />
464 </>
465 ) : null}
466 {auth.expiresAt ? (
467 <Text className="text-sm text-white">
468 Sign-in expires at{" "}
469 {new Date(auth.expiresAt).toLocaleTimeString([], {
470 hour: "numeric",
471 minute: "2-digit",
472 })}
473 .
474 </Text>
475 ) : null}
476 {auth.flowId ? (
477 <SetupButton
478 label="Cancel sign-in"
479 disabled={controlsDisabled}
480 onPress={() => {
481 const flowId = auth.flowId;
482 if (!flowId) return;
483 setCallbackUrl("");
484 void perform(
485 () => cancelAuth({ environmentId, input: { instanceId, flowId } }),
486 "Could not cancel sign-in. Try again.",
487 );
488 }}
489 />
490 ) : null}
491 </>
492 ) : showSignOut ? (
493 <SetupButton
494 label={usesBrowser ? "Sign out of Google" : "Disconnect"}
495 destructive
496 disabled={controlsDisabled}
497 onPress={() => {
498 Alert.alert(
499 usesBrowser ? "Sign out of Google?" : "Disconnect Antigravity?",
500 `This stops Antigravity sessions for ${title} on ${environmentLabel}. Threads and files stay.`,
501 [
502 { text: "Cancel", style: "cancel" },
503 {
504 text: "Sign out",
505 style: "destructive",
506 onPress: () =>
507 void perform(
508 () => logout(target),
509 "Could not sign out of Google. Try again.",
510 ),
511 },
512 ],
513 );
514 }}
515 />
516 ) : (
517 <SetupButton
518 label={
519 usesBrowser
520 ? auth?.phase === "failed" || auth?.phase === "cancelled"
521 ? "Retry Google sign-in"
522 : "Sign in with Google"
523 : auth?.phase === "failed" || auth?.phase === "cancelled"
524 ? "Retry connection"
525 : "Connect"
526 }
527 primary
528 disabled={
529 controlsDisabled ||
530 !provider.enabled ||
531 !provider.installed ||
532 installActive ||
533 authQuery.isPending
534 }
535 onPress={() =>
536 void perform(
537 () => startAuth(target),
538 "Could not start Google sign-in. Try again.",
539 )
540 }
541 />
542 )}
543 {!provider.enabled && !signedIn ? (
544 <Text className="text-sm text-white">Enable Antigravity to sign in.</Text>
545 ) : !provider.installed && !signedIn ? (
546 <Text className="text-sm text-white">Install Antigravity to sign in.</Text>
547 ) : null}
548 {authMessage ? <Text className="text-sm text-white">{authMessage}</Text> : null}
549 {authQuery.error ? (
550 <Text className="text-sm text-white">
551 Sign-in status is unavailable. Reconnect and try again.
552 </Text>
553 ) : null}
554 </View>
555 ) : null}
556 {provider.message ? (
557 <Text className="text-sm text-white">{provider.message}</Text>
558 ) : null}
559 </>
560 )}
561 {error ? (
562 <Text accessibilityRole="alert" className="text-base text-red-400">
563 {error}
564 </Text>
565 ) : null}
566 {busy ? (
567 <Text accessibilityLiveRegion="polite" className="text-sm text-white">
568 Waiting for the environment.
569 </Text>
570 ) : null}
571 </ScrollView>
572 </View>
573 );
574}
apps/mobile/src/features/settings/provider-setup-state.test.ts created+159
......@@ -0,0 +1,159 @@
1import {
2 DEFAULT_SERVER_SETTINGS,
3 ProviderDriverKind,
4 ProviderInstanceId,
5 ServerProvider,
6 resolveProviderInstanceEnabled,
7} from "@t3tools/contracts";
8import * as Schema from "effect/Schema";
9import { describe, expect, it } from "vite-plus/test";
10
11import { antigravityEnabledPatch, resolveProviderSignInPresentation } from "./provider-setup-state";
12
13const provider = Schema.decodeSync(ServerProvider)({
14 instanceId: "antigravity",
15 driver: "antigravity",
16 enabled: false,
17 installed: false,
18 version: null,
19 status: "disabled",
20 auth: { status: "unauthenticated" },
21 checkedAt: "2026-09-02T00:00:00.000Z",
22 models: [],
23});
24
25describe("resolveProviderSignInPresentation", () => {
26 const completedFlow = { phase: "succeeded", message: "Google sign-in is complete." } as const;
27
28 it("offers sign-in after saved credentials expire, even if the old flow succeeded", () => {
29 expect(
30 resolveProviderSignInPresentation(
31 {
32 enabled: true,
33 auth: { status: "authenticated" },
34 },
35 completedFlow,
36 ),
37 ).toEqual({
38 signedIn: true,
39 showSignOut: true,
40 message: completedFlow.message,
41 });
42
43 expect(
44 resolveProviderSignInPresentation(
45 {
46 enabled: true,
47 auth: { status: "unauthenticated" },
48 },
49 completedFlow,
50 ),
51 ).toEqual({
52 signedIn: false,
53 showSignOut: false,
54 message: null,
55 });
56 });
57
58 it("allows credential cleanup without claiming a disabled unknown account is signed in", () => {
59 expect(
60 resolveProviderSignInPresentation(
61 {
62 enabled: false,
63 auth: { status: "unknown" },
64 },
65 completedFlow,
66 ),
67 ).toEqual({
68 signedIn: false,
69 showSignOut: true,
70 message: null,
71 });
72 });
73
74 it("keeps progress while Google sign-in is still pending", () => {
75 const flow = { phase: "verifying", message: "Checking Google sign-in." } as const;
76 expect(
77 resolveProviderSignInPresentation(
78 {
79 enabled: true,
80 auth: { status: "unauthenticated" },
81 },
82 flow,
83 ),
84 ).toEqual({
85 signedIn: false,
86 showSignOut: false,
87 message: flow.message,
88 });
89 });
90});
91
92describe("antigravityEnabledPatch", () => {
93 it("enables a legacy instance without losing its executable path or models", () => {
94 const settings = {
95 ...DEFAULT_SERVER_SETTINGS,
96 providers: {
97 ...DEFAULT_SERVER_SETTINGS.providers,
98 antigravity: {
99 ...DEFAULT_SERVER_SETTINGS.providers.antigravity,
100 enabled: false,
101 binaryPath: "/opt/google/agy-acp",
102 customModels: ["gemini-native"],
103 },
104 },
105 };
106 const patch = antigravityEnabledPatch(settings, provider, true);
107 const instance = patch?.providerInstances?.[provider.instanceId];
108
109 expect(instance).toMatchObject({
110 enabled: true,
111 config: { binaryPath: "/opt/google/agy-acp", customModels: ["gemini-native"] },
112 });
113 expect(instance?.config).not.toHaveProperty("enabled");
114 expect(instance && resolveProviderInstanceEnabled(instance)).toBe(true);
115 expect(settings.providers.antigravity.enabled).toBe(false);
116 });
117
118 it("keeps separate accounts and environment overrides when enabling a custom instance", () => {
119 const workId = ProviderInstanceId.make("google_work");
120 const personalId = ProviderInstanceId.make("google_personal");
121 const personal = { driver: ProviderDriverKind.make("antigravity"), enabled: true };
122 const settings = {
123 ...DEFAULT_SERVER_SETTINGS,
124 providerInstances: {
125 [personalId]: personal,
126 [workId]: {
127 driver: ProviderDriverKind.make("antigravity"),
128 displayName: "Work Google",
129 enabled: false,
130 config: { enabled: false, binaryPath: "/work/agy-acp", futureSetting: "keep" },
131 environment: [{ name: "WORK_PROXY", value: "http://proxy", sensitive: false }],
132 },
133 },
134 };
135 const patch = antigravityEnabledPatch(settings, { ...provider, instanceId: workId }, true);
136
137 expect(patch?.providerInstances?.[personalId]).toBe(personal);
138 expect(patch?.providerInstances?.[workId]).toMatchObject({
139 displayName: "Work Google",
140 enabled: true,
141 config: { binaryPath: "/work/agy-acp", futureSetting: "keep" },
142 environment: [{ name: "WORK_PROXY", value: "http://proxy", sensitive: false }],
143 });
144 expect(patch?.providers).toBeUndefined();
145 });
146
147 it("does not change a different driver", () => {
148 expect(
149 antigravityEnabledPatch(
150 DEFAULT_SERVER_SETTINGS,
151 {
152 ...provider,
153 driver: ProviderDriverKind.make("codex"),
154 },
155 true,
156 ),
157 ).toBeNull();
158 });
159});
apps/mobile/src/features/settings/provider-setup-state.ts created+76
......@@ -0,0 +1,76 @@
1import {
2 ANTIGRAVITY_AUTH_METHODS,
3 type AntigravityAuthMethod,
4 DEFAULT_SERVER_SETTINGS,
5 type ProviderAuthState,
6 type ServerProvider,
7 type ServerSettings,
8 type ServerSettingsPatch,
9} from "@t3tools/contracts";
10
11/** Read the configured method from an instance config. Unknown values fall back to personal. */
12export function readAntigravityAuthMethod(config: unknown): AntigravityAuthMethod {
13 const value =
14 config !== null && typeof config === "object" && "authMethod" in config
15 ? config.authMethod
16 : undefined;
17 return (
18 ANTIGRAVITY_AUTH_METHODS.find((method) => method.value === value)?.value ?? "oauth-personal"
19 );
20}
21
22/** A completed sign-in flow does not prove that saved credentials are still valid. */
23export function resolveProviderSignInPresentation(
24 provider: Pick<ServerProvider, "enabled" | "auth"> | undefined,
25 flow: Pick<ProviderAuthState, "phase" | "message"> | null,
26) {
27 const signedIn = provider?.auth.status === "authenticated";
28 return {
29 signedIn,
30 showSignOut: signedIn || (provider?.enabled === false && provider.auth.status === "unknown"),
31 message: flow?.phase === "succeeded" && !signedIn ? null : (flow?.message ?? null),
32 };
33}
34
35/** Keep one enabled flag when a legacy provider becomes an explicit instance. */
36export function antigravityEnabledPatch(
37 settings: ServerSettings,
38 provider: ServerProvider,
39 enabled: boolean,
40): ServerSettingsPatch | null {
41 if (provider.driver !== "antigravity") return null;
42
43 const { enabled: _legacyEnabled, ...legacyConfig } = settings.providers.antigravity;
44 const instance = settings.providerInstances[provider.instanceId] ?? {
45 driver: provider.driver,
46 config: legacyConfig,
47 };
48 const config =
49 instance.config !== null &&
50 typeof instance.config === "object" &&
51 !Array.isArray(instance.config)
52 ? Object.fromEntries(Object.entries(instance.config).filter(([key]) => key !== "enabled"))
53 : instance.config;
54
55 return {
56 ...(provider.instanceId === "antigravity"
57 ? { providers: { antigravity: DEFAULT_SERVER_SETTINGS.providers.antigravity } }
58 : {}),
59 providerInstances: {
60 ...settings.providerInstances,
61 [provider.instanceId]: { ...instance, enabled, config },
62 },
63 };
64}
65
66/** Setup remains available when the provider has no selectable models. */
67export function providerNeedsSetup(provider: ServerProvider): boolean {
68 return (
69 (provider.setup?.canAuthenticate === true || provider.setup?.canInstall === true) &&
70 (!provider.enabled ||
71 !provider.installed ||
72 provider.auth.status !== "authenticated" ||
73 provider.availability === "unavailable" ||
74 provider.models.length === 0)
75 );
76}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+36-7
......@@ -84,7 +84,11 @@ import {
8484 type ComposerDraft,
8585} from "../../state/use-composer-drafts";
8686import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
87import { resolveSelectableModelSelection } from "../../lib/modelOptions";
87import {
88 isModelSelectionUnavailable,
89 resolveSelectableModelSelection,
90} from "../../lib/modelOptions";
91import { resolveProviderInteractionMode } from "./legacy-plan-mode";
8892import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
8993import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
9094import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
......@@ -173,6 +177,7 @@ export function NewTaskDraftScreen(props: {
173177 connectedEnvironments.find(
174178 (environment) => environment.environmentId === selectedProject.environmentId,
175179 )?.connectionState === "connected";
180 const modelUnavailable = environmentConnected && flow.selectedModelOption?.isUnavailable === true;
176181 const uploadStates = useAtomValue(composerAttachmentUploadsAtom);
177182 const attachmentBlockReason = selectedProject
178183 ? composerAttachmentUploadBlockReason({
......@@ -862,9 +867,8 @@ export function NewTaskDraftScreen(props: {
862867 return;
863868 }
864869 const draft = getComposerDraftSnapshot(draftKey);
865 // Snapshot read keeps just-typed selector state; the availability gate
866 // still applies so a stored selection on a disabled provider falls back
867 // to the flow's resolved model.
870 // Read the latest explicit pick. Antigravity selections stay unchanged
871 // when setup or a catalog change makes them unavailable.
868872 const modelSelection =
869873 resolveSelectableModelSelection(
870874 selectedEnvironmentServerConfig,
......@@ -876,9 +880,12 @@ export function NewTaskDraftScreen(props: {
876880 draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath;
877881 const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin;
878882 const runtimeMode = draft.runtimeMode ?? flow.runtimeMode;
879 const interactionMode = flow.planModeEnabled
880 ? (draft.interactionMode ?? flow.interactionMode)
881 : "default";
883 const interactionMode = resolveProviderInteractionMode(
884 selectedEnvironmentServerConfig?.providers.find(
885 (provider) => provider.instanceId === modelSelection?.instanceId,
886 ),
887 flow.planModeEnabled ? (draft.interactionMode ?? flow.interactionMode) : "default",
888 );
882889 const initialMessageText = draft.text.trim();
883890
884891 if (
......@@ -890,6 +897,16 @@ export function NewTaskDraftScreen(props: {
890897 ) {
891898 return;
892899 }
900 if (
901 environmentConnected &&
902 isModelSelectionUnavailable(selectedEnvironmentServerConfig, modelSelection)
903 ) {
904 Alert.alert(
905 "Antigravity model unavailable",
906 "Open model settings to finish setup or choose another model.",
907 );
908 return;
909 }
893910 // A failed-send restore can leave the draft over the cap on purpose (it
894911 // never drops the user's files); starting anyway would upload everything
895912 // and have the server reject the turn.
......@@ -1038,6 +1055,7 @@ export function NewTaskDraftScreen(props: {
10381055 const isAndroid = Platform.OS === "android";
10391056 const canStart =
10401057 attachmentBlockReason === null &&
1058 !modelUnavailable &&
10411059 Boolean(flow.selectedProject) &&
10421060 Boolean(flow.selectedModel) &&
10431061 flow.prompt.trim().length > 0 &&
......@@ -1231,6 +1249,17 @@ export function NewTaskDraftScreen(props: {
12311249 ) : null}
12321250 <View className="pb-1">{workspaceControls}</View>
12331251
1252 {modelUnavailable ? (
1253 <Pressable
1254 accessibilityRole="button"
1255 className="px-3 py-2"
1256 disabled={isComposerInteractionLocked}
1257 onPress={settingsSheetPresentation.open}
1258 >
1259 <Text className="text-xs text-foreground">Model unavailable. Open model settings.</Text>
1260 </Pressable>
1261 ) : null}
1262
12341263 <ComposerSurface
12351264 style={{
12361265 borderRadius: 26,
apps/mobile/src/features/threads/PendingApprovalCard.tsx+10-3
......@@ -17,14 +17,16 @@ export interface PendingApprovalCardProps {
1717 ) => Promise<unknown>;
1818}
1919
20const DEFAULT_APPROVAL_OPTIONS = [
20const DEFAULT_APPROVAL_OPTIONS: ReadonlyArray<ProviderApprovalOption> = [
2121 { decision: "accept", label: "Allow once" },
2222 { decision: "acceptForSession", label: "Allow session" },
2323 { decision: "decline", label: "Decline" },
24] satisfies ReadonlyArray<ProviderApprovalOption>;
24];
2525
2626export function PendingApprovalCard(props: PendingApprovalCardProps) {
27 const options = props.approval.options ?? DEFAULT_APPROVAL_OPTIONS;
27 const options: ReadonlyArray<ProviderApprovalOption> =
28 props.approval.options ?? DEFAULT_APPROVAL_OPTIONS;
29 const warning = options.find((option) => option.warning)?.warning;
2830 // Opaque for the same reason as PendingUserInputCard: nothing blurs the feed
2931 // behind this card, so a translucent surface bleeds messages through it.
3032 return (
......@@ -40,6 +42,11 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {
4042 {props.approval.detail}
4143 </Text>
4244 ) : null}
45 {warning ? (
46 <Text className="font-sans text-xs leading-normal text-adaptive-amber-700-300">
47 {warning}
48 </Text>
49 ) : null}
4350 <View className="flex-row flex-wrap gap-2.5">
4451 {options.map((option) => (
4552 <Pressable
apps/mobile/src/features/threads/PendingUserInputCard.tsx+17-14
......@@ -58,7 +58,7 @@ export interface PendingUserInputCardProps {
5858 readonly onSelectOption: (
5959 requestId: ApprovalRequestId,
6060 question: UserInputQuestion,
61 label: string,
61 value: string,
6262 ) => void;
6363 readonly onChangeCustomAnswer: (
6464 requestId: ApprovalRequestId,
......@@ -267,12 +267,13 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
267267 </Text>
268268 <View className="gap-2">
269269 {question.options.map((option) => {
270 const selected = isPendingUserInputOptionSelected(draft, option.label);
270 const optionValue = option.value ?? option.label.trim();
271 const selected = isPendingUserInputOptionSelected(question, draft, optionValue);
271272 const description =
272273 option.description !== option.label ? option.description : undefined;
273274 return (
274275 <Pressable
275 key={option.label}
276 key={optionValue}
276277 className={cn(
277278 "min-h-12 w-full rounded-2xl border px-3.5 py-3",
278279 selected
......@@ -283,7 +284,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
283284 props.onSelectOption(
284285 props.pendingUserInput.requestId,
285286 question,
286 option.label,
287 optionValue,
287288 )
288289 }
289290 >
......@@ -308,16 +309,18 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
308309 );
309310 })}
310311 </View>
311 <TextInput
312 value={draft?.customAnswer ?? ""}
313 onChangeText={(value) =>
314 props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value)
315 }
316 onFocus={() => props.onInputFocusChange?.(true)}
317 onBlur={() => props.onInputFocusChange?.(false)}
318 placeholder="Or type a custom answer"
319 className="min-h-[54px] rounded-2xl border border-adaptive-neutral-200-white-a8 bg-adaptive-white-neutral-950-a70 px-3.5 py-3 font-sans text-base text-adaptive-neutral-950-50"
320 />
312 {question.allowCustomAnswer !== false ? (
313 <TextInput
314 value={draft?.customAnswer ?? ""}
315 onChangeText={(value) =>
316 props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value)
317 }
318 onFocus={() => props.onInputFocusChange?.(true)}
319 onBlur={() => props.onInputFocusChange?.(false)}
320 placeholder="Or type a custom answer"
321 className="min-h-[54px] rounded-2xl border border-adaptive-neutral-200-white-a8 bg-adaptive-white-neutral-950-a70 px-3.5 py-3 font-sans text-base text-adaptive-neutral-950-50"
322 />
323 ) : null}
321324 </View>
322325 );
323326 })}
apps/mobile/src/features/threads/ThreadComposer.tsx+24-3
......@@ -60,7 +60,11 @@ import type {
6060 DraftComposerAttachment,
6161 DraftComposerFileAttachment,
6262} from "../../lib/composerImages";
63import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
63import {
64 buildModelOptions,
65 groupByProvider,
66 isModelSelectionUnavailable,
67} from "../../lib/modelOptions";
6468import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
6569import type { RemoteClientConnectionState } from "../../lib/connection";
6670import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
......@@ -333,6 +337,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
333337 props.connectionState !== "connected" || props.queueCount > 0 ? "Queue" : "Send";
334338 const currentModelSelection = props.selectedThread.modelSelection;
335339 const currentRuntimeMode = props.selectedThread.runtimeMode;
340 const modelUnavailable =
341 props.connectionState === "connected" &&
342 isModelSelectionUnavailable(props.serverConfig, currentModelSelection);
336343 const connectionStatus = composerConnectionStatus({
337344 connectionError: props.connectionError,
338345 connectionState: props.connectionState,
......@@ -357,7 +364,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
357364 selectedProviderStatus,
358365 hasThread: true,
359366 onChangeDraftMessage: props.onChangeDraftMessage,
360 onUpdateInteractionMode: props.onUpdateInteractionMode,
367 onUpdateInteractionMode:
368 selectedProviderStatus?.showInteractionModeToggle === false
369 ? undefined
370 : props.onUpdateInteractionMode,
361371 });
362372 const voiceInput = useVoiceInputController({
363373 ownerKey: composerOwnerKey,
......@@ -383,7 +393,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
383393 serverConfig: props.serverConfig,
384394 states: uploadStates,
385395 });
386 const canSend = hasContent && !voiceInput.blocksSubmission && attachmentBlockReason === null;
396 const canSend =
397 hasContent &&
398 !voiceInput.blocksSubmission &&
399 attachmentBlockReason === null &&
400 !modelUnavailable;
387401
388402 // Keep the feed inset aligned with the card or compact dictation strip.
389403 useEffect(() => {
......@@ -496,6 +510,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
496510 () => ({
497511 ownerId: settingsOwnerId,
498512 environmentId: props.environmentId,
513 providerInstanceId: currentModelSelection.instanceId,
499514 providerGroups: threadProviderGroups,
500515 selectedModel: currentModelSelection,
501516 onSelectModel: (option) => props.onUpdateModelSelection(option.selection),
......@@ -595,6 +610,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
595610 />
596611 ) : null}
597612
613 {modelUnavailable ? (
614 <Pressable accessibilityRole="button" className="px-3 py-2" onPress={openSettings}>
615 <Text className="text-xs text-foreground">Model unavailable. Open model settings.</Text>
616 </Pressable>
617 ) : null}
618
598619 <ComposerSurface
599620 style={
600621 isExpanded
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+1-1
......@@ -139,7 +139,7 @@ export interface ThreadDetailScreenProps {
139139 readonly onSelectUserInputOption: (
140140 requestId: ApprovalRequestId,
141141 question: UserInputQuestion,
142 label: string,
142 value: string,
143143 ) => void;
144144 readonly onChangeUserInputCustomAnswer: (
145145 requestId: ApprovalRequestId,
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+83-11
......@@ -1,10 +1,13 @@
11import type {
22 EnvironmentId,
33 ModelSelection,
4 ProviderInstanceId,
45 ProviderOptionDescriptor,
56 ProviderOptionSelection,
67 RuntimeMode,
8 ServerProvider,
79} from "@t3tools/contracts";
10import { useAtomValue } from "@effect/atom-react";
811import type { LegendListRenderItemProps } from "@legendapp/list/react-native";
912import { AnimatedLegendList } from "@legendapp/list/reanimated";
1013import { HeaderHeightContext } from "@react-navigation/elements";
......@@ -49,6 +52,11 @@ import {
4952} from "../../native/StackHeader";
5053import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
5154import { serverEnvironment } from "../../state/server";
55import { ProviderSetupLink } from "../settings/ProviderSetupLink";
56import {
57 SettingsProviderSetupRouteScreen,
58 type ProviderSetupRouteParams,
59} from "../settings/SettingsProviderSetupRouteScreen";
5260import { useAtomCommand } from "../../state/use-atom-command";
5361import { useNewTaskFlow } from "./new-task-flow-provider";
5462import {
......@@ -62,8 +70,10 @@ import {
6270} from "../layout/native-mail-search-toolbar";
6371import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-options";
6472import {
73 canCommitPendingModel,
6574 modelMatchesCatalogQuery,
6675 pendingModelAfterPress,
76 providerSetupCandidates,
6777 providerSectionIsCollapsed,
6878} from "./thread-settings-sheet-state";
6979
......@@ -72,7 +82,11 @@ import {
7282 * and friends) starts folded so a 300-model catalog cannot bury the list. All
7383 * provider headers remain user-collapsible.
7484 */
75const PRIMARY_PROVIDER_DRIVERS: ReadonlySet<string> = new Set(["claudeAgent", "codex"]);
85const PRIMARY_PROVIDER_DRIVERS: ReadonlySet<string> = new Set([
86 "claudeAgent",
87 "codex",
88 "antigravity",
89]);
7690/**
7791 * Keep measured row changes stable, but let catalog mutations use the list's
7892 * native bounds so a filtered catalog that underflows returns to the top.
......@@ -102,7 +116,11 @@ function ModelRow(props: {
102116 <Pressable
103117 accessibilityLabel={[props.option.label, props.option.subtitle].filter(Boolean).join(", ")}
104118 accessibilityRole="radio"
105 accessibilityState={{ checked: props.selected }}
119 accessibilityState={{
120 checked: props.selected,
121 disabled: props.option.isUnavailable === true,
122 }}
123 disabled={props.option.isUnavailable}
106124 onPress={props.onPress}
107125 className={cn(
108126 "mx-4 min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle",
......@@ -128,6 +146,9 @@ function ModelRow(props: {
128146 <Text className="text-3xs font-t3-bold text-foreground-muted">Legacy</Text>
129147 </View>
130148 ) : null}
149 {props.option.isUnavailable ? (
150 <Text className="text-xs text-foreground">Unavailable</Text>
151 ) : null}
131152 </View>
132153 {props.option.subtitle ? (
133154 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
......@@ -301,6 +322,7 @@ type ThreadSettingsSubmenuPage =
301322
302323type ThreadSettingsSessionProps = {
303324 readonly environmentId: EnvironmentId | null;
325 readonly providerInstanceId?: ProviderInstanceId;
304326 readonly providerGroups: ReadonlyArray<ProviderGroup>;
305327 readonly selectedModel: ModelSelection | null;
306328 readonly onSelectModel: (option: ModelOption) => void;
......@@ -353,6 +375,7 @@ export function useExistingThreadSettingsRoutePresentation() {
353375
354376type ThreadSettingsSessionValue = {
355377 readonly environmentId: EnvironmentId | null;
378 readonly providerInstanceId?: ProviderInstanceId;
356379 readonly providerGroups: ReadonlyArray<ProviderGroup>;
357380 readonly runtimeMode: RuntimeMode;
358381 readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;
......@@ -364,7 +387,7 @@ type ThreadSettingsSessionValue = {
364387 readonly searchQuery: string;
365388 readonly showLegacy: boolean;
366389 readonly applyOptionChange: (id: string, value: string | boolean) => void;
367 readonly commitPendingModel: () => void;
390 readonly commitPendingModel: () => boolean;
368391 readonly isApplied: (option: ModelOption) => boolean;
369392 readonly isDisplayed: (option: ModelOption) => boolean;
370393 readonly pressModel: (option: ModelOption) => void;
......@@ -422,10 +445,15 @@ function ThreadSettingsSessionProvider(
422445 );
423446 const commitPendingModel = useCallback(() => {
424447 if (pendingModel) {
448 if (!canCommitPendingModel(pendingModel, props.providerGroups)) {
449 Alert.alert("Model unavailable", "Complete provider setup or select another model.");
450 return false;
451 }
425452 void Haptics.selectionAsync();
426453 props.onSelectModel(pendingModel);
427454 }
428 }, [pendingModel, props.onSelectModel]);
455 return true;
456 }, [pendingModel, props.onSelectModel, props.providerGroups]);
429457
430458 const applyOptionChange = useCallback(
431459 (id: string, value: string | boolean) => {
......@@ -472,6 +500,7 @@ function ThreadSettingsSessionProvider(
472500 const value = useMemo<ThreadSettingsSessionValue>(
473501 () => ({
474502 environmentId: props.environmentId,
503 providerInstanceId: props.providerInstanceId,
475504 providerGroups: props.providerGroups,
476505 runtimeMode: props.runtimeMode,
477506 onUpdateRuntimeMode: props.onUpdateRuntimeMode,
......@@ -501,6 +530,7 @@ function ThreadSettingsSessionProvider(
501530 isApplied,
502531 isDisplayed,
503532 props.environmentId,
533 props.providerInstanceId,
504534 pendingModel,
505535 pressModel,
506536 providerFilter,
......@@ -551,6 +581,11 @@ type ThreadSettingsCatalogItem =
551581 readonly isFirst: boolean;
552582 readonly isLast: boolean;
553583 }
584 | {
585 readonly kind: "setup";
586 readonly key: string;
587 readonly provider: ServerProvider;
588 }
554589 | {
555590 readonly kind: "empty";
556591 readonly key: "empty";
......@@ -760,22 +795,37 @@ function ThreadSettingsOptionsItem(props: {
760795/** One native scroll owner for the model catalog and its related settings. */
761796function ThreadSettingsMainContent(props: {
762797 readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void;
798 readonly onOpenProviderSetup: (instanceId: ProviderInstanceId) => void;
763799}) {
764800 const session = useThreadSettingsSession();
801 const config = useAtomValue(serverEnvironment.configValueAtom(session.environmentId));
765802 const catalogItems = useThreadSettingsCatalogItems(session);
766803 const [animationsReady, setAnimationsReady] = useState(false);
767804 const nativeHeaderHeight = use(HeaderHeightContext) ?? 0;
768805 const hasActiveCatalogFilter =
769806 session.providerFilter !== null || session.searchQuery.trim().length > 0;
770807 const usesTransparentNativeHeader = Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED;
808 const setupProviders = useMemo(
809 () =>
810 providerSetupCandidates({
811 providers: config?.providers ?? [],
812 instanceId: session.providerInstanceId,
813 providerFilter: session.providerFilter,
814 query: session.searchQuery,
815 }),
816 [config?.providers, session.providerInstanceId, session.providerFilter, session.searchQuery],
817 );
771818 const listItems = useMemo<ReadonlyArray<ThreadSettingsCatalogItem>>(
772819 () => [
773 ...(catalogItems.length === 0 && hasActiveCatalogFilter
774 ? ([{ kind: "empty", key: "empty" }] as const)
775 : catalogItems),
820 ...(catalogItems.length === 0 ? ([{ kind: "empty", key: "empty" }] as const) : catalogItems),
821 ...setupProviders.map((provider) => ({
822 kind: "setup" as const,
823 key: `setup:${provider.instanceId}`,
824 provider,
825 })),
776826 { kind: "options", key: "options" },
777827 ],
778 [catalogItems, hasActiveCatalogFilter],
828 [catalogItems, setupProviders],
779829 );
780830 const renderCatalogItem = useCallback(
781831 (itemProps: LegendListRenderItemProps<ThreadSettingsCatalogItem>) => {
......@@ -792,10 +842,19 @@ function ThreadSettingsMainContent(props: {
792842 option={item.option}
793843 />
794844 );
845 } else if (item.kind === "setup") {
846 content = (
847 <ProviderSetupLink
848 provider={item.provider}
849 onPress={() => props.onOpenProviderSetup(item.provider.instanceId)}
850 />
851 );
795852 } else if (item.kind === "empty") {
796853 content = (
797854 <View className="items-center px-8 py-14">
798 <Text className="text-center text-sm text-foreground-muted">No matching models</Text>
855 <Text className="text-center text-sm text-foreground-muted">
856 {hasActiveCatalogFilter ? "No matching models" : "No available models"}
857 </Text>
799858 </View>
800859 );
801860 } else {
......@@ -817,7 +876,7 @@ function ThreadSettingsMainContent(props: {
817876 </Animated.View>
818877 );
819878 },
820 [animationsReady, props.onOpenSubmenu],
879 [animationsReady, hasActiveCatalogFilter, props.onOpenProviderSetup, props.onOpenSubmenu],
821880 );
822881
823882 return (
......@@ -943,6 +1002,7 @@ function ThreadSettingsChoiceContent(props: {
9431002type ThreadSettingsPickerStackParams = {
9441003 ThreadSettingsModels: undefined;
9451004 ThreadSettingsChoice: ThreadSettingsSubmenuPage & { readonly title: string };
1005 ThreadSettingsProviderSetup: ProviderSetupRouteParams;
9461006};
9471007
9481008type ThreadSettingsPickerPresentation = {
......@@ -987,7 +1047,7 @@ function ThreadSettingsModelsScreen() {
9871047 });
9881048 }, [isRefreshingProviders, refreshProviderCatalog, session.environmentId]);
9891049 const commitAndClose = useCallback(() => {
990 session.commitPendingModel();
1050 if (!session.commitPendingModel()) return;
9911051 presentation.onClose();
9921052 }, [presentation, session]);
9931053 const filterMenu = useMemo(
......@@ -1086,6 +1146,13 @@ function ThreadSettingsModelsScreen() {
10861146 }}
10871147 />
10881148 <ThreadSettingsMainContent
1149 onOpenProviderSetup={(instanceId) => {
1150 if (!session.environmentId) return;
1151 navigation.navigate("ThreadSettingsProviderSetup", {
1152 environmentId: session.environmentId,
1153 instanceId,
1154 });
1155 }}
10891156 onOpenSubmenu={(submenu) => {
10901157 const title =
10911158 submenu.kind === "runtime"
......@@ -1220,6 +1287,11 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation)
12201287 component={ThreadSettingsChoiceScreen}
12211288 options={({ route }) => ({ title: route.params.title })}
12221289 />
1290 <ThreadSettingsPickerStack.Screen
1291 name="ThreadSettingsProviderSetup"
1292 component={SettingsProviderSetupRouteScreen}
1293 options={{ title: "Antigravity" }}
1294 />
12231295 </ThreadSettingsPickerStack.Navigator>
12241296 </ThreadSettingsPickerPresentationContext.Provider>
12251297 );
apps/mobile/src/features/threads/legacy-plan-mode.test.ts+36-1
......@@ -1,8 +1,43 @@
11import { describe, expect, it } from "@effect/vitest";
22
3import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode";
3import {
4 resolvePendingTaskInteractionMode,
5 resolveProviderInteractionMode,
6} from "./legacy-plan-mode";
7
8describe("resolveProviderInteractionMode", () => {
9 it("clears saved plan mode when the provider cannot use T3 interaction modes", () => {
10 expect(resolveProviderInteractionMode({ showInteractionModeToggle: false }, "plan")).toBe(
11 "default",
12 );
13 });
14
15 it("keeps supported choices and remains compatible with older server status", () => {
16 expect(resolveProviderInteractionMode({ showInteractionModeToggle: true }, "plan")).toBe(
17 "plan",
18 );
19 expect(resolveProviderInteractionMode({}, "plan")).toBe("plan");
20 expect(resolveProviderInteractionMode(null, "plan")).toBe("plan");
21 expect(resolveProviderInteractionMode(undefined, undefined)).toBe("default");
22 });
23});
424
525describe("resolvePendingTaskInteractionMode", () => {
26 it.each([false, true])(
27 "clears a queued unsupported plan mode with preferenceLoaded=%s",
28 (preferenceLoaded) => {
29 expect(
30 resolvePendingTaskInteractionMode({
31 preferenceLoaded,
32 planModeEnabled: true,
33 draftInteractionMode: "plan",
34 queuedInteractionMode: "plan",
35 provider: { showInteractionModeToggle: false },
36 }),
37 ).toBe("default");
38 },
39 );
40
641 it("preserves a queued plan task while the preference is still loading", () => {
742 expect(
843 resolvePendingTaskInteractionMode({
apps/mobile/src/features/threads/legacy-plan-mode.ts+17
......@@ -1,8 +1,21 @@
11import {
22 DEFAULT_PROVIDER_INTERACTION_MODE,
33 type ProviderInteractionMode,
4 type ServerProvider,
45} from "@t3tools/contracts";
56
7type InteractionModeProvider = Pick<ServerProvider, "showInteractionModeToggle">;
8
9/** Normalize saved T3 mode choices without changing native slash commands. */
10export function resolveProviderInteractionMode(
11 provider: InteractionModeProvider | null | undefined,
12 interactionMode: ProviderInteractionMode | null | undefined,
13): ProviderInteractionMode {
14 return provider?.showInteractionModeToggle === false
15 ? DEFAULT_PROVIDER_INTERACTION_MODE
16 : (interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE);
17}
18
619export function resolveLegacyPlanModeEnabled(input: {
720 readonly loaded: boolean;
821 readonly preference: boolean | undefined;
......@@ -15,7 +28,11 @@ export function resolvePendingTaskInteractionMode(input: {
1528 readonly planModeEnabled: boolean;
1629 readonly draftInteractionMode: ProviderInteractionMode | undefined;
1730 readonly queuedInteractionMode: ProviderInteractionMode | undefined;
31 readonly provider?: InteractionModeProvider | null;
1832}): ProviderInteractionMode {
33 if (input.provider?.showInteractionModeToggle === false) {
34 return DEFAULT_PROVIDER_INTERACTION_MODE;
35 }
1936 if (input.planModeEnabled) {
2037 return input.draftInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE;
2138 }
apps/mobile/src/features/threads/new-task-flow-provider.tsx+35-18
......@@ -83,7 +83,10 @@ import {
8383 type HomeProjectScope,
8484} from "../home/homeThreadList";
8585import { useMobileProjectGroupingSettings } from "../../state/project-grouping";
86import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode";
86import {
87 resolvePendingTaskInteractionMode,
88 resolveProviderInteractionMode,
89} from "./legacy-plan-mode";
8790import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled";
8891import {
8992 resolveNewTaskBranchWorktreePath,
......@@ -202,7 +205,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
202205 const threads = useThreadShells();
203206 const { savedConnectionsById } = useSavedRemoteConnections();
204207 const groupingSettings = useMobileProjectGroupingSettings();
205 const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = useLegacyPlanModeState();
208 const { enabled: legacyPlanModeEnabled, loaded: planModePreferenceLoaded } =
209 useLegacyPlanModeState();
206210 const projectScopes = useMemo(
207211 () =>
208212 sortHomeProjectScopes({
......@@ -412,15 +416,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
412416 selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ??
413417 true;
414418 const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;
415 const interactionMode = planModeEnabled
416 ? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE)
417 : DEFAULT_PROVIDER_INTERACTION_MODE;
418419
419 // Stored selections only count while their provider is usable on the
420 // server; otherwise the server's default model wins instead of silently
421 // targeting a disabled provider. The draft selection is an explicit pick
422 // and passes through as-is; the project default (last used, possibly from
423 // desktop) is implicit and additionally never resolves to a legacy model.
420 // Antigravity keeps unavailable selections so sign-out or a catalog change
421 // cannot switch the user's model. Other providers retain their fallback
422 // rules. Implicit defaults also exclude legacy models for those providers.
424423 const draftModelSelection = resolveSelectableModelSelection(
425424 selectedEnvironmentServerConfig,
426425 selectedProjectDraft.modelSelection ?? null,
......@@ -474,6 +473,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
474473 ) ?? null,
475474 [selectedEnvironmentServerConfig, selectedModel?.instanceId],
476475 );
476 const planModeEnabled =
477 legacyPlanModeEnabled && selectedProviderStatus?.showInteractionModeToggle !== false;
478 const interactionMode = planModeEnabled
479 ? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE)
480 : DEFAULT_PROVIDER_INTERACTION_MODE;
477481 const setSelectedModelKey = useCallback(
478482 // Options ride along in the same write: a follow-up setSelectedModelOptions
479483 // call would rebuild the selection from the stale pre-switch model.
......@@ -486,10 +490,18 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
486490 return;
487491 }
488492 const selection = options ? { ...option.selection, options } : option.selection;
489 updateComposerDraftSettings(selectedProjectDraftKey, { modelSelection: selection });
493 const provider = selectedEnvironmentServerConfig?.providers.find(
494 (candidate) => candidate.instanceId === selection.instanceId,
495 );
496 updateComposerDraftSettings(selectedProjectDraftKey, {
497 modelSelection: selection,
498 ...(provider?.showInteractionModeToggle === false
499 ? { interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE }
500 : {}),
501 });
490502 setStickyComposerModelSelection(selection);
491503 },
492 [modelOptions, selectedProjectDraftKey],
504 [modelOptions, selectedEnvironmentServerConfig, selectedProjectDraftKey],
493505 );
494506 const setSelectedModelOptions = useCallback(
495507 (options: ReadonlyArray<ProviderOptionSelection> | undefined) => {
......@@ -813,10 +825,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
813825 const setInteractionMode = useCallback(
814826 (value: ProviderInteractionMode) => {
815827 if (selectedProjectDraftKey) {
816 updateComposerDraftSettings(selectedProjectDraftKey, { interactionMode: value });
828 updateComposerDraftSettings(selectedProjectDraftKey, {
829 interactionMode: resolveProviderInteractionMode(selectedProviderStatus, value),
830 });
817831 }
818832 },
819 [selectedProjectDraftKey],
833 [selectedProjectDraftKey, selectedProviderStatus],
820834 );
821835
822836 const beginEditingPendingTask = useCallback((messageId: string): boolean => {
......@@ -859,8 +873,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
859873 }
860874 const draft = getComposerDraftSnapshot(selectedProjectDraftKey);
861875 const text = draft.text.trim();
862 // Same availability gate the composer display applies: a stored
863 // selection targeting a disabled provider must not ride into the queue.
876 // Use the displayed selection rules without substituting an unavailable
877 // Antigravity model while the task is queued.
864878 const draftModelSelection =
865879 resolveSelectableModelSelection(
866880 selectedEnvironmentServerConfig,
......@@ -895,9 +909,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
895909 runtimeMode: draft.runtimeMode ?? DEFAULT_RUNTIME_MODE,
896910 interactionMode: resolvePendingTaskInteractionMode({
897911 preferenceLoaded: planModePreferenceLoaded,
898 planModeEnabled,
912 planModeEnabled: legacyPlanModeEnabled,
899913 draftInteractionMode: draft.interactionMode,
900914 queuedInteractionMode: editingPendingTask?.interactionMode,
915 provider: selectedEnvironmentServerConfig?.providers.find(
916 (candidate) => candidate.instanceId === draftModelSelection.instanceId,
917 ),
901918 }),
902919 creation: {
903920 projectId: selectedProject.id,
......@@ -927,7 +944,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
927944 selectedModel,
928945 selectedProject,
929946 selectedProjectDraftKey,
930 planModeEnabled,
947 legacyPlanModeEnabled,
931948 planModePreferenceLoaded,
932949 startFromOrigin,
933950 workspaceMode,
apps/mobile/src/features/threads/provider-catalog-refresh.test.ts+5-2
......@@ -9,7 +9,7 @@ import {
99} from "./provider-catalog-refresh";
1010
1111describe("mobile provider catalog refresh", () => {
12 it("calls server discovery for the selected environment and deduplicates pending taps", async () => {
12 it("requests model discovery for the selected environment and deduplicates pending taps", async () => {
1313 let resolveRefresh: ((value: "refreshed") => void) | undefined;
1414 const refreshProviders = vi.fn(
1515 () =>
......@@ -25,7 +25,10 @@ describe("mobile provider catalog refresh", () => {
2525
2626 expect(second).toBe(first);
2727 expect(refreshProviders).toHaveBeenCalledOnce();
28 expect(refreshProviders).toHaveBeenCalledWith({ environmentId, input: {} });
28 expect(refreshProviders).toHaveBeenCalledWith({
29 environmentId,
30 input: { refreshModels: true },
31 });
2932
3033 resolveRefresh?.("refreshed");
3134 await expect(first).resolves.toBe("refreshed");
apps/mobile/src/features/threads/provider-catalog-refresh.ts+3-3
......@@ -7,10 +7,10 @@ import {
77
88type RefreshProvidersTarget = {
99 readonly environmentId: EnvironmentId;
10 readonly input: Record<never, never>;
10 readonly input: { readonly refreshModels: true };
1111};
1212
13/** Deduplicates taps while the server refresh command is still running. */
13/** Explicit model-picker refresh. Repeated taps share the pending discovery. */
1414export function createProviderCatalogRefreshRunner<Result>(
1515 refreshProviders: (target: RefreshProvidersTarget) => Promise<Result>,
1616) {
......@@ -18,7 +18,7 @@ export function createProviderCatalogRefreshRunner<Result>(
1818
1919 return (environmentId: EnvironmentId): Promise<Result> => {
2020 if (pending) return pending;
21 pending = refreshProviders({ environmentId, input: {} }).finally(() => {
21 pending = refreshProviders({ environmentId, input: { refreshModels: true } }).finally(() => {
2222 pending = null;
2323 });
2424 return pending;
apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts+102-2
......@@ -1,9 +1,19 @@
11import { describe, expect, it } from "vite-plus/test";
22
3import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts";
3import {
4 ProviderInstanceId,
5 ServerProvider,
6 type ProviderOptionSelection,
7} from "@t3tools/contracts";
8import * as Schema from "effect/Schema";
49
510import type { ModelOption } from "../../lib/modelOptions";
6import { modelMatchesCatalogQuery, pendingModelAfterPress } from "./thread-settings-sheet-state";
11import {
12 canCommitPendingModel,
13 modelMatchesCatalogQuery,
14 pendingModelAfterPress,
15 providerSetupCandidates,
16} from "./thread-settings-sheet-state";
717
818function modelOption(
919 model: string,
......@@ -96,4 +106,94 @@ describe("thread settings sheet state", () => {
96106 }),
97107 ).toBe(pressed);
98108 });
109
110 it("cannot save a staged model after sign-out removes it from the catalog", () => {
111 const pending = modelOption("gemini-native");
112 const group = { providerKey: "codex", providerLabel: "Codex", models: [pending] };
113
114 expect(canCommitPendingModel(pending, [group])).toBe(true);
115 expect(canCommitPendingModel(pending, [])).toBe(false);
116 expect(
117 canCommitPendingModel(pending, [
118 {
119 ...group,
120 models: [{ ...pending, isUnavailable: true }],
121 },
122 ]),
123 ).toBe(false);
124 });
125});
126
127const decodeServerProvider = Schema.decodeSync(ServerProvider);
128
129function setupProvider(overrides: Partial<ServerProvider> = {}): ServerProvider {
130 return decodeServerProvider({
131 instanceId: "antigravity",
132 driver: "antigravity",
133 displayName: "Antigravity",
134 enabled: false,
135 installed: false,
136 version: null,
137 status: "disabled",
138 auth: { status: "unauthenticated" },
139 checkedAt: "2026-09-02T00:00:00.000Z",
140 setup: { canAuthenticate: true, canInstall: true },
141 models: [],
142 ...overrides,
143 });
144}
145
146describe("providerSetupCandidates", () => {
147 const unfiltered = { providerFilter: null, query: "" };
148
149 it("offers setup without a selectable model and after sign-out", () => {
150 const disabled = setupProvider();
151 const signedOut = setupProvider({ enabled: true, installed: true });
152
153 expect(providerSetupCandidates({ providers: [disabled], ...unfiltered })).toEqual([disabled]);
154 expect(providerSetupCandidates({ providers: [signedOut], ...unfiltered })).toEqual([signedOut]);
155 });
156
157 it("uses the selected environment's status for identical instance IDs", () => {
158 const offlineAccount = setupProvider();
159 const readyAccount = setupProvider({
160 enabled: true,
161 installed: true,
162 auth: { status: "authenticated" },
163 models: [{ slug: "gemini-native", name: "Gemini", isCustom: false, capabilities: null }],
164 });
165
166 expect(providerSetupCandidates({ providers: [offlineAccount], ...unfiltered })).toHaveLength(1);
167 expect(providerSetupCandidates({ providers: [readyAccount], ...unfiltered })).toEqual([]);
168 });
169
170 it("limits existing threads to their provider and respects search", () => {
171 const personal = setupProvider();
172 const work = setupProvider({
173 instanceId: ProviderInstanceId.make("google_work"),
174 displayName: "Work Google",
175 });
176
177 expect(
178 providerSetupCandidates({
179 providers: [personal, work],
180 ...unfiltered,
181 instanceId: work.instanceId,
182 }),
183 ).toEqual([work]);
184 expect(
185 providerSetupCandidates({
186 providers: [personal, work],
187 providerFilter: work.instanceId,
188 query: "work",
189 }),
190 ).toEqual([work]);
191 expect(
192 providerSetupCandidates({
193 providers: [personal, work],
194 providerFilter: null,
195 query: "no-match",
196 }),
197 ).toEqual([]);
198 });
99199});
apps/mobile/src/features/threads/thread-settings-sheet-state.ts+33-1
......@@ -1,4 +1,26 @@
1import type { ModelOption } from "../../lib/modelOptions";
1import type { ProviderInstanceId, ServerProvider } from "@t3tools/contracts";
2import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
3import { providerNeedsSetup } from "../settings/provider-setup-state";
4
5/** Read setup choices from this environment, not the selectable model list. */
6export function providerSetupCandidates(input: {
7 readonly providers: ReadonlyArray<ServerProvider>;
8 readonly instanceId?: ProviderInstanceId;
9 readonly providerFilter: string | null;
10 readonly query: string;
11}): ReadonlyArray<ServerProvider> {
12 const query = input.query.trim().toLocaleLowerCase();
13 return input.providers.filter(
14 (provider) =>
15 providerNeedsSetup(provider) &&
16 (input.instanceId === undefined || provider.instanceId === input.instanceId) &&
17 (input.providerFilter === null || provider.instanceId === input.providerFilter) &&
18 (query.length === 0 ||
19 [provider.displayName ?? "", provider.driver, provider.instanceId].some((label) =>
20 label.toLocaleLowerCase().includes(query),
21 )),
22 );
23}
224
325/** Match the terms a user can actually see or recognize in the model picker. */
426export function modelMatchesCatalogQuery(input: {
......@@ -31,6 +53,16 @@ export function pendingModelAfterPress(input: {
3153 return input.current?.key === input.pressed.key ? input.current : input.pressed;
3254}
3355
56/** A model can disappear while its setup page is open inside the picker. */
57export function canCommitPendingModel(
58 pending: ModelOption,
59 groups: ReadonlyArray<ProviderGroup>,
60): boolean {
61 return groups.some((group) =>
62 group.models.some((model) => model.key === pending.key && !model.isUnavailable),
63 );
64}
65
3466/**
3567 * Primary and selected providers start open; all other catalogs start closed.
3668 * A user's disclosure tap inverts that default until the picker is dismissed.
apps/mobile/src/features/threads/use-composer-command-menu.test.ts+85-1
......@@ -1,4 +1,5 @@
11import { describe, expect, it, vi } from "vite-plus/test";
2import { ProviderDriverKind } from "@t3tools/contracts";
23
34vi.mock("../../state/queries", () => ({
45 useComposerPathSearch: () => ({ entries: [], isPending: false }),
......@@ -10,10 +11,93 @@ vi.mock("../../state/use-atom-command", () => ({
1011 useAtomCommand: () => vi.fn(),
1112}));
1213
13import { composerSelectionAtEnd } from "./use-composer-command-menu";
14import {
15 buildComposerSlashCommandItems,
16 composerSelectionAtEnd,
17 resolveComposerCommandSelection,
18} from "./use-composer-command-menu";
1419
1520describe("composerSelectionAtEnd", () => {
1621 it("resets a changed draft owner to the new draft end", () => {
1722 expect(composerSelectionAtEnd("queued task 🧪")).toEqual({ start: 14, end: 14 });
1823 });
1924});
25
26describe("mobile slash commands", () => {
27 const antigravity = {
28 driver: ProviderDriverKind.make("antigravity"),
29 showInteractionModeToggle: false,
30 slashCommands: [{ name: "plan", description: "Plan with Antigravity" }],
31 };
32
33 it.each([false, true])(
34 "keeps native /plan with legacy mode enabled=%s",
35 (allowInteractionMode) => {
36 const items = buildComposerSlashCommandItems({
37 query: "pl",
38 atMessageStart: true,
39 hasThread: true,
40 allowInteractionMode,
41 selectedProviderStatus: antigravity,
42 });
43
44 expect(items).toHaveLength(1);
45 expect(items[0]?.type).toBe("provider-slash-command");
46 const item = items[0];
47 if (!item) throw new Error("Expected the native plan command");
48 expect(
49 resolveComposerCommandSelection({
50 draftMessage: "/pl",
51 trigger: { rangeStart: 0, rangeEnd: 3 },
52 item,
53 allowInteractionMode,
54 }),
55 ).toEqual({ text: "/plan ", cursor: 6, interactionMode: null });
56 },
57 );
58
59 it("does not offer a native command inside the message", () => {
60 expect(
61 buildComposerSlashCommandItems({
62 query: "plan",
63 atMessageStart: false,
64 hasThread: false,
65 allowInteractionMode: true,
66 selectedProviderStatus: antigravity,
67 }),
68 ).toEqual([]);
69 });
70
71 it("still applies the T3 plan command for supported providers", () => {
72 const items = buildComposerSlashCommandItems({
73 query: "plan",
74 atMessageStart: true,
75 hasThread: true,
76 allowInteractionMode: true,
77 selectedProviderStatus: {
78 driver: ProviderDriverKind.make("codex"),
79 slashCommands: [],
80 },
81 });
82 const item = items[0];
83 if (!item) throw new Error("Expected the T3 plan command");
84 expect(
85 resolveComposerCommandSelection({
86 draftMessage: "/plan",
87 trigger: { rangeStart: 0, rangeEnd: 5 },
88 item,
89 allowInteractionMode: true,
90 }),
91 ).toEqual({ text: "", cursor: 0, interactionMode: "plan" });
92
93 // A provider switch can invalidate an open menu before a tap arrives.
94 expect(
95 resolveComposerCommandSelection({
96 draftMessage: "/plan",
97 trigger: { rangeStart: 0, rangeEnd: 5 },
98 item,
99 allowInteractionMode: false,
100 }),
101 ).toEqual({ text: "/plan ", cursor: 6, interactionMode: null });
102 });
103});
apps/mobile/src/features/threads/use-composer-command-menu.ts+127-84
......@@ -3,6 +3,7 @@ import {
33 detectComposerTrigger,
44 replaceTextRange,
55 serializeComposerFileLink,
6 type ComposerTrigger,
67} from "@t3tools/shared/composerTrigger";
78import {
89 insertRankedSearchResult,
......@@ -30,6 +31,107 @@ export function composerSelectionAtEnd(draftMessage: string): ComposerEditorSele
3031 return { start: draftMessage.length, end: draftMessage.length };
3132}
3233
34export function buildComposerSlashCommandItems(input: {
35 readonly query: string;
36 readonly atMessageStart: boolean;
37 readonly hasThread: boolean;
38 readonly allowInteractionMode: boolean;
39 readonly selectedProviderStatus: Pick<
40 ServerProvider,
41 "driver" | "slashCommands" | "showInteractionModeToggle"
42 > | null;
43}): ComposerCommandItem[] {
44 const query = input.query.toLowerCase();
45 const allowInteractionMode =
46 input.allowInteractionMode && input.selectedProviderStatus?.showInteractionModeToggle !== false;
47 const builtIn = [
48 {
49 id: "cmd:model",
50 type: "slash-command",
51 command: "model",
52 label: "/model",
53 description: "Switch model",
54 },
55 {
56 id: "cmd:plan",
57 type: "slash-command",
58 command: "plan",
59 label: "/plan",
60 description: "Switch to plan mode",
61 },
62 {
63 id: "cmd:default",
64 type: "slash-command",
65 command: "default",
66 label: "/default",
67 description: "Switch to default mode",
68 },
69 ] satisfies ComposerCommandItem[];
70 const items: ComposerCommandItem[] = builtIn.filter(
71 (item) => item.command.includes(query) && (item.command === "model" || allowInteractionMode),
72 );
73
74 // Providers expand commands only at the start of a message. T3 commands
75 // change local state and do not have this restriction.
76 if (!input.atMessageStart) return items;
77 for (const command of input.selectedProviderStatus?.slashCommands ?? []) {
78 if (!command.name.toLowerCase().includes(query)) continue;
79 if (
80 !input.hasThread &&
81 input.selectedProviderStatus?.driver === "codex" &&
82 command.name === "feedback"
83 ) {
84 continue;
85 }
86 items.push({
87 id: `pcmd:${command.name}`,
88 type: "provider-slash-command",
89 command,
90 label: `/${command.name}`,
91 description: command.description ?? "",
92 });
93 }
94 return items;
95}
96
97export function resolveComposerCommandSelection(input: {
98 readonly draftMessage: string;
99 readonly trigger: Pick<ComposerTrigger, "rangeStart" | "rangeEnd">;
100 readonly item: ComposerCommandItem;
101 readonly allowInteractionMode: boolean;
102}): {
103 readonly text: string;
104 readonly cursor: number;
105 readonly interactionMode: ProviderInteractionMode | null;
106} {
107 const { draftMessage, trigger, item } = input;
108 if (
109 input.allowInteractionMode &&
110 item.type === "slash-command" &&
111 (item.command === "plan" || item.command === "default")
112 ) {
113 return {
114 ...replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, ""),
115 interactionMode: item.command,
116 };
117 }
118
119 let replacement = "";
120 if (item.type === "path") {
121 replacement = `${serializeComposerFileLink(item.path)} `;
122 } else if (item.type === "skill") {
123 replacement = `$${item.skill.name} `;
124 } else if (item.type === "slash-command") {
125 replacement = `/${item.command} `;
126 } else if (item.type === "provider-slash-command") {
127 replacement = `/${item.command.name} `;
128 }
129 return {
130 ...replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, replacement),
131 interactionMode: null,
132 };
133}
134
33135/** Shared autocomplete for thread composers and unsent new-task drafts. */
34136export function useComposerCommandMenu({
35137 draftMessage,
......@@ -79,7 +181,6 @@ export function useComposerCommandMenu({
79181 selectedProviderStatus ? resolveProviderSkillsForCwd(selectedProviderStatus, projectCwd) : [],
80182 [projectCwd, selectedProviderStatus],
81183 );
82 const slashCommands = selectedProviderStatus?.slashCommands ?? [];
83184 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
84185 reportFailure: false,
85186 });
......@@ -157,59 +258,13 @@ export function useComposerCommandMenu({
157258
158259 if (trigger.kind === "slash-command") {
159260 const q = trigger.query.toLowerCase();
160 const allBuiltIn = [
161 {
162 id: "cmd:model",
163 type: "slash-command" as const,
164 command: "model",
165 label: "/model",
166 description: "Switch model",
167 },
168 {
169 id: "cmd:plan",
170 type: "slash-command" as const,
171 command: "plan",
172 label: "/plan",
173 description: "Switch to plan mode",
174 },
175 {
176 id: "cmd:default",
177 type: "slash-command" as const,
178 command: "default",
179 label: "/default",
180 description: "Switch to default mode",
181 },
182 ];
183 const builtIn = allBuiltIn.filter(
184 (item) =>
185 item.command.includes(q) &&
186 (item.command === "model" || onUpdateInteractionMode !== undefined),
187 );
188
189 // A provider expands a slash command only when it opens the whole
190 // message; elsewhere it arrives as literal text. Built-ins apply
191 // locally and skills insert a `$` mention the server dispatches from
192 // any position, so only provider commands are position-gated.
193 const providerCommands: ComposerCommandItem[] = [];
194 const expandableCommands = trigger.rangeStart === 0 ? slashCommands : [];
195 for (const command of expandableCommands) {
196 if (!command.name.toLowerCase().includes(q)) continue;
197 // Codex feedback uploads an existing thread's session and logs.
198 if (
199 !hasThread &&
200 selectedProviderStatus?.driver === "codex" &&
201 command.name === "feedback"
202 ) {
203 continue;
204 }
205 providerCommands.push({
206 id: `pcmd:${command.name}`,
207 type: "provider-slash-command",
208 command,
209 label: `/${command.name}`,
210 description: command.description ?? "",
211 });
212 }
261 const commandItems = buildComposerSlashCommandItems({
262 query: q,
263 atMessageStart: trigger.rangeStart === 0,
264 hasThread,
265 allowInteractionMode: onUpdateInteractionMode !== undefined,
266 selectedProviderStatus,
267 });
213268
214269 const skillItems = getProviderSkillsForSlashMenu(skills, true)
215270 .filter((skill) => matchesSlashSkillQuery(skill, q))
......@@ -221,7 +276,7 @@ export function useComposerCommandMenu({
221276 description: skill.shortDescription ?? skill.description ?? "",
222277 }));
223278
224 return [...builtIn, ...providerCommands, ...skillItems];
279 return [...commandItems, ...skillItems];
225280 }
226281
227282 if (trigger.kind === "skill") {
......@@ -328,7 +383,6 @@ export function useComposerCommandMenu({
328383 pathSearch.entries,
329384 selectedProviderStatus,
330385 skills,
331 slashCommands,
332386 trigger,
333387 ]);
334388
......@@ -336,38 +390,27 @@ export function useComposerCommandMenu({
336390 (item: ComposerCommandItem) => {
337391 if (!trigger) return;
338392
339 if (
340 item.type === "slash-command" &&
341 (item.command === "plan" || item.command === "default")
342 ) {
343 const result = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, "");
344 setSelection({ start: result.cursor, end: result.cursor });
345 onChangeDraftMessage(result.text);
346 onUpdateInteractionMode?.(item.command);
347 return;
348 }
349
350 let replacement = "";
351 if (item.type === "path") {
352 replacement = `${serializeComposerFileLink(item.path)} `;
353 } else if (item.type === "skill") {
354 replacement = `$${item.skill.name} `;
355 } else if (item.type === "slash-command") {
356 replacement = `/${item.command} `;
357 } else if (item.type === "provider-slash-command") {
358 replacement = `/${item.command.name} `;
359 }
360
361 const result = replaceTextRange(
393 const result = resolveComposerCommandSelection({
362394 draftMessage,
363 trigger.rangeStart,
364 trigger.rangeEnd,
365 replacement,
366 );
395 trigger,
396 item,
397 allowInteractionMode:
398 onUpdateInteractionMode !== undefined &&
399 selectedProviderStatus?.showInteractionModeToggle !== false,
400 });
367401 setSelection({ start: result.cursor, end: result.cursor });
368402 onChangeDraftMessage(result.text);
403 if (result.interactionMode !== null) {
404 onUpdateInteractionMode?.(result.interactionMode);
405 }
369406 },
370 [draftMessage, onChangeDraftMessage, onUpdateInteractionMode, trigger],
407 [
408 draftMessage,
409 onChangeDraftMessage,
410 onUpdateInteractionMode,
411 selectedProviderStatus?.showInteractionModeToggle,
412 trigger,
413 ],
371414 );
372415
373416 return {
apps/mobile/src/features/threads/use-project-actions.ts+19-1
......@@ -19,12 +19,14 @@ import { prepareTurnAttachments, validateDraftFileAttachments } from "../../lib/
1919import { makeTurnCommandMetadata, type TurnCommandMetadata } from "../../lib/commandMetadata";
2020import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn";
2121import { randomHex } from "../../lib/uuid";
22import { isModelSelectionUnavailable } from "../../lib/modelOptions";
2223import { useAtomCommand } from "../../state/use-atom-command";
2324import { scheduleUnusedComposerAttachmentCleanup } from "../../state/use-composer-drafts";
2425import { setPendingConnectionError } from "../../state/use-remote-environment-registry";
2526import { validateProjectThreadCreation } from "./projectThreadCreationValidation";
2627import { appAtomRegistry } from "../../state/atom-registry";
2728import { serverEnvironment } from "../../state/server";
29import { resolveProviderInteractionMode } from "./legacy-plan-mode";
2830
2931export function useCreateProjectThread() {
3032 const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
......@@ -110,6 +112,22 @@ export function useCreateProjectThread() {
110112 return AsyncResult.failure(Cause.fail(new Error(preparedAttachmentError)));
111113 }
112114
115 const serverConfig = appAtomRegistry.get(
116 serverEnvironment.configValueAtom(input.project.environmentId),
117 );
118 const providerError = !serverConfig
119 ? "Provider settings are still loading. Try again."
120 : isModelSelectionUnavailable(serverConfig, input.modelSelection)
121 ? "Antigravity model unavailable. Open model settings to finish setup or choose another model."
122 : null;
123 if (providerError !== null) {
124 setPendingConnectionError(providerError);
125 return AsyncResult.failure(Cause.fail(new Error(providerError)));
126 }
127 const provider = serverConfig?.providers.find(
128 (candidate) => candidate.instanceId === input.modelSelection.instanceId,
129 );
130
113131 const result = await startTurn({
114132 environmentId: input.project.environmentId,
115133 input: buildProjectThreadStartTurnInput({
......@@ -124,7 +142,7 @@ export function useCreateProjectThread() {
124142 uploadedAttachments: prepared.attachments,
125143 modelSelection: input.modelSelection,
126144 runtimeMode: input.runtimeMode,
127 interactionMode: input.interactionMode,
145 interactionMode: resolveProviderInteractionMode(provider, input.interactionMode),
128146 workspaceMode: input.envMode,
129147 branch: input.branch,
130148 worktreePath: input.worktreePath,
apps/mobile/src/lib/copyTextWithHaptic.test.ts+19-31
......@@ -18,12 +18,7 @@ vi.mock("expo-haptics", () => ({
1818 selectionAsync: mocks.selectionAsync,
1919}));
2020
21import {
22 CopyTextClipboardWriteError,
23 CopyTextHapticFeedbackError,
24 copyTextWithHaptic,
25 tryCopyTextWithHaptic,
26} from "./copyTextWithHaptic";
21import { copyTextWithHaptic, tryCopyTextWithHaptic } from "./copyTextWithHaptic";
2722
2823describe("copyTextWithHaptic", () => {
2924 beforeEach(() => {
......@@ -69,36 +64,29 @@ describe("copyTextWithHaptic", () => {
6964 });
7065
7166 it("reports structured failures without including clipboard contents", async () => {
72 const clipboardCause = new Error("native clipboard failure");
73 const hapticCause = new Error("native haptic failure");
67 const content = "https://accounts.google.com/auth?state=private-state&code=private-code";
68 const clipboardCause = new Error(`Cannot copy ${content}`);
69 const hapticCause = new Error(`Native failure for ${content}`);
7470 const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
7571 mocks.setStringAsync.mockRejectedValueOnce(clipboardCause);
7672 mocks.impactAsync.mockRejectedValueOnce(hapticCause);
7773
78 copyTextWithHaptic("secret clipboard contents", { target: "connection-trace-id" });
74 await tryCopyTextWithHaptic(content, { target: "provider-sign-in-link" });
7975
80 await vi.waitFor(() => {
81 expect(consoleError).toHaveBeenCalledTimes(2);
82 });
83
84 const failures = consoleError.mock.calls.map(([failure]) => failure);
85 const clipboardError = failures.find(
86 (failure) => failure instanceof CopyTextClipboardWriteError,
76 expect(consoleError).toHaveBeenCalledWith(
77 "Failed to copy provider-sign-in-link to the clipboard.",
78 expect.objectContaining({
79 _tag: "CopyTextClipboardWriteError",
80 target: "provider-sign-in-link",
81 }),
8782 );
88 expect(clipboardError).toBeInstanceOf(CopyTextClipboardWriteError);
89 expect(clipboardError).toMatchObject({
90 target: "connection-trace-id",
91 cause: clipboardCause,
92 });
93 expect((clipboardError as Error).message).not.toContain("secret clipboard contents");
94
95 const hapticError = failures.find((failure) => failure instanceof CopyTextHapticFeedbackError);
96 expect(hapticError).toBeInstanceOf(CopyTextHapticFeedbackError);
97 expect(hapticError).toMatchObject({
98 target: "connection-trace-id",
99 feedback: "light-impact",
100 cause: hapticCause,
101 });
102 expect((hapticError as Error).message).not.toContain("secret clipboard contents");
83 expect(consoleError).toHaveBeenCalledWith(
84 "Failed to trigger light-impact haptic feedback after copying provider-sign-in-link.",
85 expect.objectContaining({ _tag: "CopyTextHapticFeedbackError", feedback: "light-impact" }),
86 );
87 const diagnostics = JSON.stringify(consoleError.mock.calls);
88 expect(diagnostics).not.toContain("private-state");
89 expect(diagnostics).not.toContain("private-code");
90 expect(diagnostics).not.toContain("cause");
10391 });
10492});
apps/mobile/src/lib/copyTextWithHaptic.ts+4-13
......@@ -44,12 +44,8 @@ export async function tryCopyTextWithHaptic(
4444 await Clipboard.setStringAsync(value);
4545 return true;
4646 } catch (cause) {
47 console.error(
48 new CopyTextClipboardWriteError({
49 target,
50 cause,
51 }),
52 );
47 const error = new CopyTextClipboardWriteError({ target, cause });
48 console.error(error.message, { _tag: error._tag, target, stack: error.stack });
5349 return false;
5450 }
5551 })();
......@@ -62,13 +58,8 @@ export async function tryCopyTextWithHaptic(
6258 await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
6359 }
6460 } catch (cause) {
65 console.error(
66 new CopyTextHapticFeedbackError({
67 target,
68 feedback,
69 cause,
70 }),
71 );
61 const error = new CopyTextHapticFeedbackError({ target, feedback, cause });
62 console.error(error.message, { _tag: error._tag, target, feedback, stack: error.stack });
7263 }
7364 })();
7465
apps/mobile/src/lib/modelOptions.test.ts+155-1
......@@ -5,6 +5,7 @@ import { ProviderInstanceId, type ModelSelection, type ServerConfig } from "@t3t
55import {
66 buildModelOptions,
77 groupByProvider,
8 isModelSelectionUnavailable,
89 resolveDefaultableModelSelection,
910 resolveNewTaskModelSelection,
1011 resolveSelectableModelSelection,
......@@ -191,10 +192,153 @@ describe("mobile model options", () => {
191192 expect(resolveSelectableModelSelection(config, usable)).toBe(usable);
192193 expect(resolveSelectableModelSelection(config, disabled)).toBeNull();
193194 expect(resolveSelectableModelSelection(config, removed)).toBeNull();
194 // No config (environment offline) — nothing to validate against.
195 expect(isModelSelectionUnavailable(config, disabled)).toBe(false);
196 // An offline environment has no config to validate.
195197 expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled);
196198 });
197199
200 describe("Antigravity selections", () => {
201 const selection = {
202 instanceId: ProviderInstanceId.make("google_work"),
203 model: "gemini-3.1-pro-high",
204 options: [{ id: "native-option", value: "saved/opaque-choice" }],
205 };
206 const model = {
207 slug: selection.model,
208 name: "Gemini 3.1 Pro High",
209 subProvider: "Google",
210 isCustom: false,
211 isDefault: true,
212 isLegacy: true,
213 capabilities: {
214 optionDescriptors: [
215 {
216 id: "native-option",
217 label: "Native option",
218 type: "select",
219 options: [{ id: "current/default", label: "Default", isDefault: true }],
220 currentValue: "current/default",
221 },
222 ],
223 },
224 };
225 const config = {
226 providers: [
227 {
228 instanceId: selection.instanceId,
229 driver: "antigravity",
230 displayName: "Google Work",
231 enabled: true,
232 installed: true,
233 auth: { status: "authenticated" },
234 models: [model],
235 },
236 ],
237 } as unknown as ServerConfig;
238
239 it.each([
240 ["disabled", { enabled: false }],
241 ["uninstalled", { installed: false }],
242 ["signed out", { auth: { status: "unauthenticated" } }],
243 ["unavailable", { availability: "unavailable" }],
244 ] as const)("keeps a %s provider's selection and known model details", (_state, update) => {
245 const unavailableConfig = {
246 ...config,
247 providers: config.providers.map((provider) => ({ ...provider, ...update })),
248 };
249
250 expect(resolveSelectableModelSelection(unavailableConfig, selection)).toBe(selection);
251 expect(resolveDefaultableModelSelection(unavailableConfig, selection)).toBe(selection);
252 expect(isModelSelectionUnavailable(unavailableConfig, selection)).toBe(true);
253 expect(buildModelOptions(unavailableConfig, null)).toEqual([]);
254 const [option] = buildModelOptions(unavailableConfig, selection);
255 expect(option).toMatchObject({
256 key: `google_work:${selection.model}`,
257 label: model.name,
258 subtitle: "Google",
259 providerKey: "google_work",
260 providerLabel: "Google Work",
261 providerDriver: "antigravity",
262 isDefault: false,
263 isLegacy: true,
264 isUnavailable: true,
265 capabilities: model.capabilities,
266 });
267 expect(option?.selection).toBe(selection);
268 });
269
270 it("keeps an exact selection when its model leaves and returns to the catalog", () => {
271 const changedConfig = {
272 ...config,
273 providers: config.providers.map((provider) => ({
274 ...provider,
275 models: provider.models.map((model) => ({ ...model, slug: "gemini-3.1-pro-low" })),
276 })),
277 };
278
279 expect(resolveDefaultableModelSelection(changedConfig, selection)).toBe(selection);
280 expect(isModelSelectionUnavailable(changedConfig, selection)).toBe(true);
281 const options = buildModelOptions(changedConfig, selection);
282 const missing = options.find((option) => option.selection.model === selection.model);
283 expect(missing).toMatchObject({
284 label: selection.model,
285 providerLabel: "Google Work",
286 providerDriver: "antigravity",
287 isUnavailable: true,
288 capabilities: null,
289 });
290 expect(missing?.selection).toBe(selection);
291 expect(
292 resolveNewTaskModelSelection({
293 draftSelection: null,
294 projectDefaultSelection: resolveDefaultableModelSelection(changedConfig, selection),
295 stickySelection: null,
296 modelOptions: options,
297 }),
298 ).toBe(selection);
299
300 const [restored] = buildModelOptions(config, selection);
301 expect(isModelSelectionUnavailable(config, selection)).toBe(false);
302 expect(restored?.isUnavailable).not.toBe(true);
303 expect(restored?.selection).toBe(selection);
304 expect(resolveDefaultableModelSelection(config, selection)).toBe(selection);
305 expect(buildModelOptions(config, null)[0]?.selection.options).toBeUndefined();
306 });
307
308 it("uses configured instance metadata when provider status is missing", () => {
309 const missingStatusConfig = {
310 providers: [],
311 settings: {
312 providerInstances: {
313 [selection.instanceId]: { driver: "antigravity", displayName: "Google Work" },
314 },
315 },
316 } as unknown as ServerConfig;
317
318 expect(resolveDefaultableModelSelection(missingStatusConfig, selection)).toBe(selection);
319 expect(isModelSelectionUnavailable(missingStatusConfig, selection)).toBe(true);
320 expect(buildModelOptions(missingStatusConfig, selection)).toMatchObject([
321 {
322 providerDriver: "antigravity",
323 providerLabel: "Google Work",
324 isUnavailable: true,
325 selection,
326 },
327 ]);
328 });
329
330 it("keeps offline selections without assuming that an unknown instance is Antigravity", () => {
331 const unknownConfig = { ...config, providers: [] };
332
333 expect(resolveDefaultableModelSelection(null, selection)).toBe(selection);
334 expect(isModelSelectionUnavailable(null, selection)).toBe(false);
335 expect(buildModelOptions(null, selection)[0]?.selection).toBe(selection);
336 expect(buildModelOptions(null, selection)[0]?.isUnavailable).not.toBe(true);
337 expect(isModelSelectionUnavailable(unknownConfig, selection)).toBe(false);
338 expect(resolveSelectableModelSelection(unknownConfig, selection)).toBeNull();
339 });
340 });
341
198342 it("keeps legacy models out of implicit defaults", () => {
199343 const config = {
200344 providers: [
......@@ -253,5 +397,15 @@ describe("mobile model options", () => {
253397 expect(resolve(null, project, sticky)).toBe(project);
254398 expect(resolve(null, null, sticky)).toBe(sticky);
255399 expect(resolve(null, null, null)).toBe(providerDefault.selection);
400
401 const unavailable = { ...providerDefault, isUnavailable: true };
402 expect(
403 resolveNewTaskModelSelection({
404 draftSelection: null,
405 projectDefaultSelection: null,
406 stickySelection: null,
407 modelOptions: [unavailable],
408 }),
409 ).toBeNull();
256410 });
257411});
apps/mobile/src/lib/modelOptions.ts+71-21
......@@ -17,6 +17,7 @@ export type ModelOption = {
1717 readonly providerDriver: string;
1818 readonly isDefault: boolean;
1919 readonly isLegacy: boolean;
20 readonly isUnavailable?: boolean;
2021 readonly capabilities: ModelCapabilities | null;
2122 readonly selection: ModelSelection;
2223};
......@@ -60,12 +61,34 @@ function normalizeSelectionOptions(
6061 };
6162}
6263
64/** Whether a known Antigravity selection needs setup or a different model. */
65export function isModelSelectionUnavailable(
66 config: T3ServerConfig | null | undefined,
67 selection: ModelSelection | null | undefined,
68): boolean {
69 if (!config || !selection) {
70 return false;
71 }
72 const provider = config.providers.find(
73 (candidate) => candidate.instanceId === selection.instanceId,
74 );
75 const driver =
76 provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver;
77 return (
78 driver === "antigravity" &&
79 (!provider ||
80 !provider.enabled ||
81 !provider.installed ||
82 provider.auth.status === "unauthenticated" ||
83 provider.availability === "unavailable" ||
84 !provider.models.some((model) => model.slug === selection.model))
85 );
86}
87
6388/**
64 * A stored model selection is only usable when its provider instance is
65 * currently enabled, installed, and authenticated on the server. Returns the
66 * selection unchanged when usable, otherwise `null` so callers fall through to
67 * the server's default model. A missing config (environment offline) cannot be
68 * validated, so stored selections pass through untouched.
89 * Keep Antigravity selections when setup or catalog changes make them
90 * unavailable. Other providers fall through to the server default when they
91 * are disabled, missing, or signed out. Without config, keep stored selections.
6992 */
7093export function resolveSelectableModelSelection(
7194 config: T3ServerConfig | null | undefined,
......@@ -77,6 +100,11 @@ export function resolveSelectableModelSelection(
77100 const provider = config.providers.find(
78101 (candidate) => candidate.instanceId === selection.instanceId,
79102 );
103 const driver =
104 provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver;
105 if (driver === "antigravity") {
106 return selection;
107 }
80108 return provider &&
81109 provider.enabled &&
82110 provider.installed &&
......@@ -86,11 +114,9 @@ export function resolveSelectableModelSelection(
86114}
87115
88116/**
89 * Like resolveSelectableModelSelection, but additionally rejects legacy
90 * models. Used for implicit defaults (stored draft, project last-used): a
91 * new thread should never quietly start on a legacy model, so those fall
92 * through to the provider's default instead. Explicit picks in the settings
93 * sheet are unaffected.
117 * Reject legacy models for implicit defaults, except Antigravity selections,
118 * which must not silently change after a catalog update. Explicit picks in
119 * the settings sheet are unaffected.
94120 */
95121export function resolveDefaultableModelSelection(
96122 config: T3ServerConfig | null | undefined,
......@@ -102,7 +128,7 @@ export function resolveDefaultableModelSelection(
102128 }
103129 const provider = config.providers.find((candidate) => candidate.instanceId === usable.instanceId);
104130 const model = provider?.models.find((candidate) => candidate.slug === usable.model);
105 return model?.isLegacy === true ? null : usable;
131 return provider?.driver !== "antigravity" && model?.isLegacy === true ? null : usable;
106132}
107133
108134export function resolveNewTaskModelSelection(input: {
......@@ -115,8 +141,8 @@ export function resolveNewTaskModelSelection(input: {
115141 input.draftSelection ??
116142 input.projectDefaultSelection ??
117143 input.stickySelection ??
118 input.modelOptions.find((option) => option.isDefault)?.selection ??
119 input.modelOptions[0]?.selection ??
144 input.modelOptions.find((option) => option.isDefault && !option.isUnavailable)?.selection ??
145 input.modelOptions.find((option) => !option.isUnavailable)?.selection ??
120146 null
121147 );
122148}
......@@ -128,7 +154,12 @@ export function buildModelOptions(
128154 const options = new Map<string, ModelOption>();
129155
130156 for (const provider of config?.providers ?? []) {
131 if (!provider.enabled || !provider.installed || provider.auth.status === "unauthenticated") {
157 if (
158 !provider.enabled ||
159 !provider.installed ||
160 provider.auth.status === "unauthenticated" ||
161 (provider.driver === "antigravity" && provider.availability === "unavailable")
162 ) {
132163 continue;
133164 }
134165
......@@ -162,20 +193,39 @@ export function buildModelOptions(
162193 if (existing) {
163194 options.set(key, {
164195 ...existing,
165 selection: normalizeSelectionOptions(fallbackModelSelection, existing.capabilities),
196 selection:
197 existing.providerDriver === "antigravity"
198 ? fallbackModelSelection
199 : normalizeSelectionOptions(fallbackModelSelection, existing.capabilities),
166200 });
167201 } else {
168 const providerLabel = fallbackModelSelection.instanceId;
202 const provider = config?.providers.find(
203 (candidate) => candidate.instanceId === fallbackModelSelection.instanceId,
204 );
205 const instanceConfig = config?.settings?.providerInstances[fallbackModelSelection.instanceId];
206 const model = provider?.models.find(
207 (candidate) => candidate.slug === fallbackModelSelection.model,
208 );
209 const providerDriver =
210 provider?.driver ?? instanceConfig?.driver ?? fallbackModelSelection.instanceId;
211 const providerLabel = providerDisplayLabel({
212 driver: providerDriver,
213 displayName: provider?.displayName ?? instanceConfig?.displayName,
214 instanceId: fallbackModelSelection.instanceId,
215 });
169216 options.set(key, {
170217 key,
171 label: fallbackModelSelection.model,
172 subtitle: "",
218 label: model?.name ?? fallbackModelSelection.model,
219 subtitle: model?.subProvider ?? "",
173220 providerKey: fallbackModelSelection.instanceId,
174221 providerLabel,
175 providerDriver: fallbackModelSelection.instanceId,
222 providerDriver,
176223 isDefault: false,
177 isLegacy: false,
178 capabilities: null,
224 isLegacy: model?.isLegacy === true,
225 ...(isModelSelectionUnavailable(config, fallbackModelSelection)
226 ? { isUnavailable: true }
227 : {}),
228 capabilities: model?.capabilities ?? null,
179229 selection: fallbackModelSelection,
180230 });
181231 }
apps/mobile/src/lib/openExternalUrl.test.ts+16
......@@ -55,4 +55,20 @@ describe("tryOpenExternalUrl", () => {
5555 expect(diagnosticText).not.toContain("token=secret");
5656 expect(diagnosticText).not.toContain("browser-unavailable-secret-sentinel");
5757 });
58
59 it("keeps provider sign-in URLs unchanged and out of failure logs", async () => {
60 const url =
61 "https://accounts.google.com/o/oauth2/v2/auth?state=private-state&code_challenge=private-challenge&redirect_uri=http%3A%2F%2F127.0.0.1%3A43123%2F";
62 openURL.mockRejectedValue(new Error(`Cannot open ${url}`));
63 const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
64
65 await expect(tryOpenExternalUrl(url, "provider-auth")).resolves.toBe(false);
66
67 expect(openURL).toHaveBeenCalledWith(url);
68 const diagnostics = JSON.stringify(consoleError.mock.calls);
69 expect(diagnostics).toContain("accounts.google.com");
70 expect(diagnostics).not.toContain("private-state");
71 expect(diagnostics).not.toContain("private-challenge");
72 expect(diagnostics).not.toContain("43123");
73 });
5874});
apps/mobile/src/lib/openExternalUrl.ts+6-1
......@@ -1,7 +1,12 @@
11import * as Schema from "effect/Schema";
22import { Linking } from "react-native";
33
4const ExternalUrlTarget = Schema.Literals(["file-preview", "markdown-link", "pull-request"]);
4const ExternalUrlTarget = Schema.Literals([
5 "file-preview",
6 "markdown-link",
7 "pull-request",
8 "provider-auth",
9]);
510
611export type ExternalUrlTarget = typeof ExternalUrlTarget.Type;
712
apps/mobile/src/lib/threadActivity.test.ts+107-11
......@@ -16,6 +16,7 @@ import {
1616 buildPendingUserInputAnswers,
1717 buildThreadFeed,
1818 derivePendingApprovals,
19 derivePendingUserInputs,
1920 deriveThreadFeedPresentation,
2021 isPendingUserInputOptionSelected,
2122 setPendingUserInputCustomAnswer,
......@@ -74,15 +75,48 @@ const multiSelectQuestion = {
7475 multiSelect: true,
7576} as const;
7677
78const nativeQuestion = {
79 id: "choice",
80 header: "File",
81 question: "Which file should be used?",
82 options: [
83 { label: "Use this", description: "First file", value: " choice " },
84 { label: "Use this", description: "Second file", value: "choice" },
85 ],
86 multiSelect: false,
87 allowCustomAnswer: false,
88} as const;
89
7790describe("pending user input answers", () => {
91 it("preserves native choice values and custom-answer rules from activities", () => {
92 const requested = makeActivity({
93 id: EventId.make("native-question"),
94 kind: "user-input.requested",
95 summary: "User input requested",
96 createdAt: "2026-09-02T00:00:00.000Z",
97 payload: {
98 requestId: "interaction_1",
99 questions: [nativeQuestion, singleSelectQuestion],
100 },
101 });
102
103 expect(derivePendingUserInputs([requested])).toEqual([
104 {
105 requestId: "interaction_1",
106 createdAt: requested.createdAt,
107 questions: [nativeQuestion, singleSelectQuestion],
108 },
109 ]);
110 });
111
78112 it("replaces single-select options and toggles multi-select options", () => {
79113 expect(
80114 togglePendingUserInputOptionSelection(
81115 singleSelectQuestion,
82 { selectedOptionLabels: ["Go"] },
116 { selectedOptionValues: ["Go"] },
83117 "Node.js",
84118 ),
85 ).toEqual({ customAnswer: "", selectedOptionLabels: ["Node.js"] });
119 ).toEqual({ customAnswer: "", selectedOptionValues: ["Node.js"] });
86120
87121 const orders = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Orders");
88122 const ordersAndListings = togglePendingUserInputOptionSelection(
......@@ -92,18 +126,18 @@ describe("pending user input answers", () => {
92126 );
93127 expect(ordersAndListings).toEqual({
94128 customAnswer: "",
95 selectedOptionLabels: ["Orders", "Listings"],
129 selectedOptionValues: ["Orders", "Listings"],
96130 });
97131 expect(
98132 togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"),
99 ).toEqual({ customAnswer: "", selectedOptionLabels: ["Listings"] });
133 ).toEqual({ customAnswer: "", selectedOptionValues: ["Listings"] });
100134
101135 const paddedOrders = togglePendingUserInputOptionSelection(
102136 multiSelectQuestion,
103137 undefined,
104138 " Orders ",
105139 );
106 expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionLabels: ["Orders"] });
140 expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionValues: ["Orders"] });
107141 expect(
108142 togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "),
109143 ).toEqual({ customAnswer: "" });
......@@ -112,8 +146,8 @@ describe("pending user input answers", () => {
112146 it("builds array answers for multi-select questions", () => {
113147 expect(
114148 buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], {
115 runtime: { selectedOptionLabels: ["Go"] },
116 scope: { selectedOptionLabels: ["Orders", "Listings"] },
149 runtime: { selectedOptionValues: ["Go"] },
150 scope: { selectedOptionValues: ["Orders", "Listings"] },
117151 }),
118152 ).toEqual({
119153 runtime: "Go",
......@@ -124,23 +158,85 @@ describe("pending user input answers", () => {
124158 it("clears selected options while a custom answer is active", () => {
125159 expect(
126160 setPendingUserInputCustomAnswer(
127 { selectedOptionLabels: ["Orders", "Listings"] },
161 multiSelectQuestion,
162 { selectedOptionValues: ["Orders", "Listings"] },
128163 "Orders first",
129164 ),
130165 ).toEqual({ customAnswer: "Orders first" });
131166 });
132167
133 it("matches selected chips against normalized option labels", () => {
168 it("matches selected options against normalized legacy labels", () => {
134169 expect(
135 isPendingUserInputOptionSelected({ selectedOptionLabels: ["Orders"] }, " Orders "),
170 isPendingUserInputOptionSelected(
171 multiSelectQuestion,
172 { selectedOptionValues: ["Orders"] },
173 " Orders ",
174 ),
136175 ).toBe(true);
137176 expect(
138177 isPendingUserInputOptionSelected(
139 { selectedOptionLabels: ["Orders"], customAnswer: "Orders first" },
178 multiSelectQuestion,
179 { selectedOptionValues: ["Orders"], customAnswer: "Orders first" },
140180 " Orders ",
141181 ),
142182 ).toBe(false);
143183 });
184
185 it("keeps custom answers enabled for legacy questions", () => {
186 expect(
187 buildPendingUserInputAnswers([singleSelectQuestion], {
188 runtime: { selectedOptionValues: ["Go"], customAnswer: " Use Bun " },
189 }),
190 ).toEqual({ runtime: "Use Bun" });
191 });
192
193 it("keeps duplicate labels and whitespace-sensitive native values separate", () => {
194 const first = togglePendingUserInputOptionSelection(nativeQuestion, undefined, " choice ");
195 expect(isPendingUserInputOptionSelected(nativeQuestion, first, " choice ")).toBe(true);
196 expect(isPendingUserInputOptionSelected(nativeQuestion, first, "choice")).toBe(false);
197 expect(buildPendingUserInputAnswers([nativeQuestion], { choice: first })).toEqual({
198 choice: " choice ",
199 });
200
201 const second = togglePendingUserInputOptionSelection(nativeQuestion, first, "choice");
202 expect(isPendingUserInputOptionSelected(nativeQuestion, second, " choice ")).toBe(false);
203 expect(isPendingUserInputOptionSelected(nativeQuestion, second, "choice")).toBe(true);
204 expect(buildPendingUserInputAnswers([nativeQuestion], { choice: second })).toEqual({
205 choice: "choice",
206 });
207 });
208
209 it("keeps exact native values in multi-select answers", () => {
210 const question = { ...nativeQuestion, multiSelect: true };
211 const first = togglePendingUserInputOptionSelection(question, undefined, " choice ");
212 const both = togglePendingUserInputOptionSelection(question, first, "choice");
213 expect(buildPendingUserInputAnswers([question], { choice: both })).toEqual({
214 choice: [" choice ", "choice"],
215 });
216
217 const second = togglePendingUserInputOptionSelection(question, both, " choice ");
218 expect(buildPendingUserInputAnswers([question], { choice: second })).toEqual({
219 choice: ["choice"],
220 });
221 });
222
223 it("ignores custom answers when a question only accepts choices", () => {
224 const draft = { selectedOptionValues: [" choice "], customAnswer: "Other" };
225 expect(setPendingUserInputCustomAnswer(nativeQuestion, draft, "Custom text")).toBe(draft);
226 expect(isPendingUserInputOptionSelected(nativeQuestion, draft, " choice ")).toBe(true);
227 expect(buildPendingUserInputAnswers([nativeQuestion], { choice: draft })).toEqual({
228 choice: " choice ",
229 });
230 });
231
232 it.each([
233 { customAnswer: "Other" },
234 { selectedOptionValues: ["Use this"] },
235 { selectedOptionValues: ["not offered"] },
236 { selectedOptionValues: [" choice "] },
237 ])("requires an offered value for a choice-only question: %j", (draft) => {
238 expect(buildPendingUserInputAnswers([nativeQuestion], { choice: draft })).toBeNull();
239 });
144240});
145241
146242describe("pending approvals", () => {
apps/mobile/src/lib/threadActivity.ts+66-23
......@@ -52,7 +52,7 @@ export interface PendingUserInput {
5252}
5353
5454export interface PendingUserInputDraftAnswer {
55 readonly selectedOptionLabels?: ReadonlyArray<string>;
55 readonly selectedOptionValues?: ReadonlyArray<string>;
5656 readonly customAnswer?: string;
5757}
5858
......@@ -241,6 +241,7 @@ function parseUserInputQuestions(
241241 return {
242242 label: record.label,
243243 description: record.description,
244 ...(typeof record.value === "string" ? { value: record.value } : {}),
244245 };
245246 })
246247 .filter((option): option is UserInputQuestion["options"][number] => option !== null);
......@@ -253,6 +254,9 @@ function parseUserInputQuestions(
253254 question: question.question,
254255 options,
255256 multiSelect: question.multiSelect === true,
257 ...(typeof question.allowCustomAnswer === "boolean"
258 ? { allowCustomAnswer: question.allowCustomAnswer }
259 : {}),
256260 };
257261 })
258262 .filter((question): question is UserInputQuestion => question !== null);
......@@ -268,7 +272,23 @@ function normalizeDraftAnswer(value: string | undefined): string | null {
268272 return trimmed.length > 0 ? trimmed : null;
269273}
270274
271function normalizeSelectedOptionLabels(
275function resolvePendingUserInputOptionValue(
276 question: UserInputQuestion,
277 value: string,
278): string | null {
279 if (question.options.some((option) => option.value === value)) {
280 return value;
281 }
282
283 const label = value.trim();
284 return label.length > 0 &&
285 question.options.some((option) => option.value === undefined && option.label.trim() === label)
286 ? label
287 : null;
288}
289
290function normalizeSelectedOptionValues(
291 question: UserInputQuestion,
272292 value: ReadonlyArray<string> | undefined,
273293): ReadonlyArray<string> {
274294 if (!Array.isArray(value)) {
......@@ -276,7 +296,11 @@ function normalizeSelectedOptionLabels(
276296 }
277297
278298 return Array.from(
279 new Set(value.map((entry) => entry.trim()).filter((entry) => entry.length > 0)),
299 new Set(
300 value
301 .map((entry) => resolvePendingUserInputOptionValue(question, entry))
302 .filter((entry): entry is string => entry !== null),
303 ),
280304 );
281305}
282306
......@@ -284,16 +308,17 @@ function resolvePendingUserInputAnswer(
284308 question: UserInputQuestion,
285309 draft: PendingUserInputDraftAnswer | undefined,
286310): string | ReadonlyArray<string> | null {
287 const customAnswer = normalizeDraftAnswer(draft?.customAnswer);
311 const customAnswer =
312 question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer);
288313 if (customAnswer) {
289314 return customAnswer;
290315 }
291316
292 const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels);
317 const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues);
293318 if (question.multiSelect) {
294 return selectedOptionLabels.length > 0 ? selectedOptionLabels : null;
319 return selectedOptionValues.length > 0 ? selectedOptionValues : null;
295320 }
296 return selectedOptionLabels[0] ?? null;
321 return selectedOptionValues[0] ?? null;
297322}
298323
299324/** Codex children settle via task.updated (idle/failed/interrupted), never
......@@ -1759,54 +1784,72 @@ export function derivePendingUserInputs(
17591784}
17601785
17611786export function setPendingUserInputCustomAnswer(
1787 question: UserInputQuestion,
17621788 draft: PendingUserInputDraftAnswer | undefined,
17631789 customAnswer: string,
17641790): PendingUserInputDraftAnswer {
1765 const selectedOptionLabels =
1791 if (question.allowCustomAnswer === false) {
1792 return draft ?? {};
1793 }
1794
1795 const selectedOptionValues =
17661796 customAnswer.trim().length > 0
17671797 ? undefined
1768 : normalizeSelectedOptionLabels(draft?.selectedOptionLabels);
1798 : normalizeSelectedOptionValues(question, draft?.selectedOptionValues);
17691799 return {
17701800 customAnswer,
1771 ...(selectedOptionLabels && selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}),
1801 ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}),
17721802 };
17731803}
17741804
17751805export function isPendingUserInputOptionSelected(
1806 question: UserInputQuestion,
17761807 draft: PendingUserInputDraftAnswer | undefined,
1777 optionLabel: string,
1808 optionValue: string,
17781809): boolean {
1779 if (normalizeDraftAnswer(draft?.customAnswer)) {
1810 if (question.allowCustomAnswer !== false && normalizeDraftAnswer(draft?.customAnswer)) {
17801811 return false;
17811812 }
17821813
1783 return normalizeSelectedOptionLabels(draft?.selectedOptionLabels).includes(optionLabel.trim());
1814 const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue);
1815 return (
1816 resolvedOptionValue !== null &&
1817 normalizeSelectedOptionValues(question, draft?.selectedOptionValues).includes(
1818 resolvedOptionValue,
1819 )
1820 );
17841821}
17851822
17861823export function togglePendingUserInputOptionSelection(
17871824 question: UserInputQuestion,
17881825 draft: PendingUserInputDraftAnswer | undefined,
1789 optionLabel: string,
1826 optionValue: string,
17901827): PendingUserInputDraftAnswer {
1791 const normalizedOptionLabel = optionLabel.trim();
1828 const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue);
1829 if (resolvedOptionValue === null) {
1830 return draft ?? {};
1831 }
17921832
17931833 if (question.multiSelect) {
1794 const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels);
1795 const nextSelectedOptionLabels = selectedOptionLabels.includes(normalizedOptionLabel)
1796 ? selectedOptionLabels.filter((label) => label !== normalizedOptionLabel)
1797 : [...selectedOptionLabels, normalizedOptionLabel];
1834 const selectedOptionValues = normalizeSelectedOptionValues(
1835 question,
1836 draft?.selectedOptionValues,
1837 );
1838 const nextSelectedOptionValues = selectedOptionValues.includes(resolvedOptionValue)
1839 ? selectedOptionValues.filter((value) => value !== resolvedOptionValue)
1840 : [...selectedOptionValues, resolvedOptionValue];
17981841
17991842 return {
18001843 customAnswer: "",
1801 ...(nextSelectedOptionLabels.length > 0
1802 ? { selectedOptionLabels: nextSelectedOptionLabels }
1844 ...(nextSelectedOptionValues.length > 0
1845 ? { selectedOptionValues: nextSelectedOptionValues }
18031846 : {}),
18041847 };
18051848 }
18061849
18071850 return {
18081851 customAnswer: "",
1809 selectedOptionLabels: [normalizedOptionLabel],
1852 selectedOptionValues: [resolvedOptionValue],
18101853 };
18111854}
18121855
......@@ -1818,7 +1861,7 @@ export function buildPendingUserInputAnswers(
18181861
18191862 for (const question of questions) {
18201863 const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]);
1821 if (!answer) {
1864 if (answer === null) {
18221865 return null;
18231866 }
18241867 answers[question.id] = answer;
apps/mobile/src/state/thread-outbox-model.ts+18-10
......@@ -18,12 +18,14 @@ import {
1818 type ProjectId as ProjectIdType,
1919 type ProviderInteractionMode as ProviderInteractionModeType,
2020 type RuntimeMode as RuntimeModeType,
21 type ServerProvider,
2122} from "@t3tools/contracts";
2223import * as Schema from "effect/Schema";
2324
2425import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema";
2526import type { DraftComposerAttachment } from "../lib/composerImages";
2627import { scopedThreadKey } from "../lib/scopedEntities";
28import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode";
2729
2830const THREAD_OUTBOX_SCHEMA_VERSION = 3;
2931const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000;
......@@ -93,11 +95,19 @@ export interface ThreadSettingsSnapshot {
9395export function resolveQueuedThreadSettings(
9496 message: QueuedThreadMessage,
9597 thread: ThreadSettingsSnapshot,
98 providers: ReadonlyArray<Pick<ServerProvider, "instanceId" | "showInteractionModeToggle">> = [],
9699): ThreadSettingsSnapshot {
100 const modelSelection = message.modelSelection ?? thread.modelSelection;
101 const provider = providers.find(
102 (candidate) => candidate.instanceId === modelSelection.instanceId,
103 );
97104 return {
98 modelSelection: message.modelSelection ?? thread.modelSelection,
105 modelSelection,
99106 runtimeMode: message.runtimeMode ?? thread.runtimeMode,
100 interactionMode: message.interactionMode ?? thread.interactionMode,
107 interactionMode: resolveProviderInteractionMode(
108 provider,
109 message.interactionMode ?? thread.interactionMode,
110 ),
101111 };
102112}
103113
......@@ -184,11 +194,9 @@ export type ThreadOutboxDispatchStep =
184194 | { readonly step: "send" };
185195
186196/**
187 * Orders the resolved delivery action against the file-capability gate. The
188 * gate applies only to a message that will send: a message whose thread
189 * already exists (or is gone) must be removed even while the server config is
190 * still loading, and a missing config defers with a retry instead of parking
191 * the message forever.
197 * Wait for provider and file capabilities before sending. Cleanup does not
198 * need config: a creation whose thread exists, or a message whose thread is
199 * gone, can still be removed while config loads.
192200 */
193201export function resolveThreadOutboxDispatchStep(input: {
194202 readonly deliveryAction: ThreadOutboxDeliveryAction;
......@@ -199,12 +207,12 @@ export function resolveThreadOutboxDispatchStep(input: {
199207 if (input.deliveryAction !== "send") {
200208 return { step: input.deliveryAction };
201209 }
202 if (input.fileAttachments.length === 0) {
203 return { step: "send" };
204 }
205210 if (input.serverConfig === null) {
206211 return { step: "retry" };
207212 }
213 if (input.fileAttachments.length === 0) {
214 return { step: "send" };
215 }
208216 const maxBytes = input.serverConfig.maxFileUploadBytes;
209217 if (maxBytes === undefined) {
210218 return { step: "restore", reason: "This server does not support file attachments." };
apps/mobile/src/state/thread-outbox.test.ts+64-1
......@@ -150,6 +150,62 @@ describe("thread outbox", () => {
150150 ).toBe(false);
151151 });
152152
153 it("normalizes queued plan mode against the queued provider, not the current thread", () => {
154 const codex = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.6-sol" };
155 const antigravity = {
156 instanceId: ProviderInstanceId.make("google-personal"),
157 model: "gemini-test-thinking",
158 options: [{ id: "native-option", value: "keep-this-choice" }],
159 };
160 const providers = [
161 { instanceId: codex.instanceId, showInteractionModeToggle: true },
162 { instanceId: antigravity.instanceId, showInteractionModeToggle: false },
163 ];
164 const message = {
165 ...queuedMessage({ messageId: "queued-plan", createdAt: "2026-09-02T10:00:00.000Z" }),
166 text: "/plan inspect the project",
167 modelSelection: antigravity,
168 interactionMode: "plan",
169 } satisfies QueuedThreadMessage;
170
171 expect(
172 resolveQueuedThreadSettings(
173 message,
174 { modelSelection: codex, runtimeMode: "approval-required", interactionMode: "plan" },
175 providers,
176 ),
177 ).toEqual({
178 modelSelection: antigravity,
179 runtimeMode: "approval-required",
180 interactionMode: "default",
181 });
182 expect(
183 resolveQueuedThreadSettings(
184 { ...message, modelSelection: codex },
185 {
186 modelSelection: antigravity,
187 runtimeMode: "approval-required",
188 interactionMode: "default",
189 },
190 providers,
191 ).interactionMode,
192 ).toBe("plan");
193 });
194
195 it("normalizes a legacy queued message that inherits unsupported plan mode", () => {
196 const modelSelection = {
197 instanceId: ProviderInstanceId.make("google-personal"),
198 model: "gemini-test-thinking",
199 };
200 expect(
201 resolveQueuedThreadSettings(
202 queuedMessage({ messageId: "legacy-plan", createdAt: "2026-09-02T10:00:00.000Z" }),
203 { modelSelection, runtimeMode: "approval-required", interactionMode: "plan" },
204 [{ instanceId: modelSelection.instanceId, showInteractionModeToggle: false }],
205 ).interactionMode,
206 ).toBe("default");
207 });
208
153209 it("backs off queued delivery retries and caps them at sixteen seconds", () => {
154210 expect([1, 2, 3, 4, 5, 6].map(threadOutboxRetryDelayMs)).toEqual([
155211 1_000, 2_000, 4_000, 8_000, 16_000, 16_000,
......@@ -938,13 +994,20 @@ describe("thread outbox", () => {
938994 ).toEqual({ step: "send" });
939995 });
940996
941 it("sends a message without file attachments before the server config loads", () => {
997 it("waits for provider capabilities before sending text-only queued messages", () => {
942998 expect(
943999 resolveThreadOutboxDispatchStep({
9441000 deliveryAction: "send",
9451001 fileAttachments: [],
9461002 serverConfig: null,
9471003 }),
1004 ).toEqual({ step: "retry" });
1005 expect(
1006 resolveThreadOutboxDispatchStep({
1007 deliveryAction: "send",
1008 fileAttachments: [],
1009 serverConfig: { maxFileUploadBytes: undefined },
1010 }),
9481011 ).toEqual({ step: "send" });
9491012 });
9501013
apps/mobile/src/state/use-selected-thread-requests.ts+14-10
......@@ -31,7 +31,7 @@ const userInputDraftsByRequestKeyAtom = Atom.make<
3131function setUserInputDraftOption(
3232 requestKey: string,
3333 question: UserInputQuestion,
34 label: string,
34 value: string,
3535): void {
3636 const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom);
3737 appAtomRegistry.set(userInputDraftsByRequestKeyAtom, {
......@@ -41,7 +41,7 @@ function setUserInputDraftOption(
4141 [question.id]: togglePendingUserInputOptionSelection(
4242 question,
4343 current[requestKey]?.[question.id],
44 label,
44 value,
4545 ),
4646 },
4747 });
......@@ -49,7 +49,7 @@ function setUserInputDraftOption(
4949
5050function setUserInputDraftCustomAnswer(
5151 requestKey: string,
52 questionId: string,
52 question: UserInputQuestion,
5353 customAnswer: string,
5454): void {
5555 const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom);
......@@ -57,8 +57,9 @@ function setUserInputDraftCustomAnswer(
5757 ...current,
5858 [requestKey]: {
5959 ...current[requestKey],
60 [questionId]: setPendingUserInputCustomAnswer(
61 current[requestKey]?.[questionId],
60 [question.id]: setPendingUserInputCustomAnswer(
61 question,
62 current[requestKey]?.[question.id],
6263 customAnswer,
6364 ),
6465 },
......@@ -108,27 +109,30 @@ export function useSelectedThreadRequests() {
108109 : null;
109110
110111 const onSelectUserInputOption = useCallback(
111 (requestId: ApprovalRequestId, question: UserInputQuestion, label: string) => {
112 (requestId: ApprovalRequestId, question: UserInputQuestion, value: string) => {
112113 if (!selectedThreadShell) {
113114 return;
114115 }
115116
116117 const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId);
117 setUserInputDraftOption(requestKey, question, label);
118 setUserInputDraftOption(requestKey, question, value);
118119 },
119120 [selectedThreadShell],
120121 );
121122
122123 const onChangeUserInputCustomAnswer = useCallback(
123124 (requestId: ApprovalRequestId, questionId: string, customAnswer: string) => {
124 if (!selectedThreadShell) {
125 const question = activePendingUserInputs
126 .find((request) => request.requestId === requestId)
127 ?.questions.find((entry) => entry.id === questionId);
128 if (!selectedThreadShell || !question) {
125129 return;
126130 }
127131
128132 const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId);
129 setUserInputDraftCustomAnswer(requestKey, questionId, customAnswer);
133 setUserInputDraftCustomAnswer(requestKey, question, customAnswer);
130134 },
131 [selectedThreadShell],
135 [activePendingUserInputs, selectedThreadShell],
132136 );
133137
134138 const onRespondToApproval = useCallback(
apps/mobile/src/state/use-thread-composer-state.ts+51-9
......@@ -5,6 +5,7 @@ import * as Cause from "effect/Cause";
55
66import {
77 CommandId,
8 DEFAULT_PROVIDER_INTERACTION_MODE,
89 MessageId,
910 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1011 type EnvironmentId,
......@@ -24,6 +25,8 @@ import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"
2425import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";
2526
2627import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
28import { isModelSelectionUnavailable } from "../lib/modelOptions";
29import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode";
2730import {
2831 convertPastedImagesToAttachments,
2932 pasteComposerClipboard,
......@@ -145,7 +148,15 @@ export function useThreadComposerState() {
145148 const selectedThread = selectedThreadDetail ?? selectedThreadShell;
146149 const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null;
147150 const runtimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null;
148 const interactionMode = selectedDraft?.interactionMode ?? selectedThread?.interactionMode ?? null;
151 const selectedProvider = selectedEnvironmentRuntime?.serverConfig?.providers.find(
152 (provider) => provider.instanceId === modelSelection?.instanceId,
153 );
154 const interactionMode = selectedThread
155 ? resolveProviderInteractionMode(
156 selectedProvider,
157 selectedDraft?.interactionMode ?? selectedThread.interactionMode,
158 )
159 : null;
149160
150161 const selectedThreadSessionActivity = useMemo(() => {
151162 const selectedThread = selectedThreadDetail ?? selectedThreadShell;
......@@ -207,8 +218,20 @@ export function useThreadComposerState() {
207218 return null;
208219 }
209220
210 const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find(
211 (entry) => entry.instanceId === thread.modelSelection.instanceId,
221 const modelSelection = draft.modelSelection ?? thread.modelSelection;
222 const serverConfig = selectedEnvironmentRuntime?.serverConfig;
223 if (
224 selectedEnvironmentRuntime?.connectionState === "connected" &&
225 isModelSelectionUnavailable(serverConfig, modelSelection)
226 ) {
227 Alert.alert(
228 "Antigravity model unavailable",
229 "Open model settings to finish setup or choose another model.",
230 );
231 return null;
232 }
233 const provider = serverConfig?.providers.find(
234 (entry) => entry.instanceId === modelSelection.instanceId,
212235 );
213236 const feedbackCommand =
214237 attachments.length === 0 &&
......@@ -285,9 +308,12 @@ export function useThreadComposerState() {
285308 commandId: CommandId.make(metadata.commandId),
286309 text,
287310 attachments,
288 modelSelection: draft.modelSelection ?? thread.modelSelection,
311 modelSelection,
289312 runtimeMode: draft.runtimeMode ?? thread.runtimeMode,
290 interactionMode: draft.interactionMode ?? thread.interactionMode,
313 interactionMode: resolveProviderInteractionMode(
314 provider,
315 draft.interactionMode ?? thread.interactionMode,
316 ),
291317 createdAt: metadata.createdAt,
292318 });
293319 clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true });
......@@ -455,9 +481,17 @@ export function useThreadComposerState() {
455481 if (!selectedThreadKey) {
456482 return;
457483 }
458 updateComposerDraftSettings(selectedThreadKey, { modelSelection: value });
484 const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find(
485 (candidate) => candidate.instanceId === value.instanceId,
486 );
487 updateComposerDraftSettings(selectedThreadKey, {
488 modelSelection: value,
489 ...(provider?.showInteractionModeToggle === false
490 ? { interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE }
491 : {}),
492 });
459493 },
460 [selectedThreadKey],
494 [selectedEnvironmentRuntime?.serverConfig, selectedThreadKey],
461495 );
462496
463497 const onUpdateRuntimeMode = useCallback(
......@@ -475,9 +509,17 @@ export function useThreadComposerState() {
475509 if (!selectedThreadKey) {
476510 return;
477511 }
478 updateComposerDraftSettings(selectedThreadKey, { interactionMode: value });
512 const modelSelection =
513 getComposerDraftSnapshot(selectedThreadKey).modelSelection ??
514 selectedThread?.modelSelection;
515 const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find(
516 (candidate) => candidate.instanceId === modelSelection?.instanceId,
517 );
518 updateComposerDraftSettings(selectedThreadKey, {
519 interactionMode: resolveProviderInteractionMode(provider, value),
520 });
479521 },
480 [selectedThreadKey],
522 [selectedEnvironmentRuntime?.serverConfig, selectedThread?.modelSelection, selectedThreadKey],
481523 );
482524
483525 return {
apps/mobile/src/state/use-thread-outbox-drain.test.ts+5
......@@ -80,6 +80,11 @@ vi.mock("./entities", () => ({
8080 useThreadShells: () => [],
8181}));
8282
83vi.mock("./server", async () => {
84 const { Atom } = await import("effect/unstable/reactivity");
85 return { serverEnvironment: { configValueAtom: Atom.family(() => Atom.make(null)) } };
86});
87
8388vi.mock("./threads", () => ({
8489 threadEnvironment: {},
8590}));
apps/mobile/src/state/use-thread-outbox-drain.ts+73-15
......@@ -20,8 +20,10 @@ import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities";
2020import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn";
2121import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload";
2222import { randomHex } from "../lib/uuid";
23import { isModelSelectionUnavailable } from "../lib/modelOptions";
2324import { appAtomRegistry } from "./atom-registry";
2425import { useProjects, useServerConfigs, useThreadShells } from "./entities";
26import { serverEnvironment } from "./server";
2527import {
2628 confirmThreadOutboxMessageQueued,
2729 ensureThreadOutboxLoaded,
......@@ -630,7 +632,17 @@ export function useThreadOutboxDrain(): void {
630632
631633 const sendQueuedMessage = useCallback(
632634 async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => {
633 const settings = resolveQueuedThreadSettings(queuedMessage, thread);
635 const serverConfig = appAtomRegistry.get(
636 serverEnvironment.configValueAtom(queuedMessage.environmentId),
637 );
638 if (!serverConfig) return false;
639 const settings = resolveQueuedThreadSettings(queuedMessage, thread, serverConfig.providers);
640 if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) {
641 return restoreQueuedMessage(
642 queuedMessage,
643 "Antigravity model unavailable. Open model settings to finish setup or choose another model.",
644 );
645 }
634646 const { reportFailure } = makeDeliveryHelpers(queuedMessage);
635647
636648 if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) {
......@@ -686,8 +698,7 @@ export function useThreadOutboxDrain(): void {
686698 try {
687699 const preparedResult = await prepareQueuedMessageAttachments(
688700 queuedMessage,
689 serverConfigs.get(queuedMessage.environmentId)?.environment.capabilities
690 .attachmentUploads === true,
701 serverConfig.environment.capabilities.attachmentUploads === true,
691702 );
692703 if (preparedResult.status === "abandoned") {
693704 return true;
......@@ -715,6 +726,21 @@ export function useThreadOutboxDrain(): void {
715726 if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) {
716727 return true;
717728 }
729 const currentConfig = appAtomRegistry.get(
730 serverEnvironment.configValueAtom(queuedMessage.environmentId),
731 );
732 if (!currentConfig) return false;
733 if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) {
734 return restoreQueuedMessage(
735 persistedMessage,
736 "Antigravity model unavailable. Open model settings to finish setup or choose another model.",
737 );
738 }
739 const sendSettings = resolveQueuedThreadSettings(
740 queuedMessage,
741 settings,
742 currentConfig.providers,
743 );
718744 const deliveryResult = await startTurn({
719745 environmentId: queuedMessage.environmentId,
720746 input: {
......@@ -726,9 +752,9 @@ export function useThreadOutboxDrain(): void {
726752 text: queuedMessage.text,
727753 attachments: prepared.attachments,
728754 },
729 modelSelection: settings.modelSelection,
730 runtimeMode: settings.runtimeMode,
731 interactionMode: settings.interactionMode,
755 modelSelection: sendSettings.modelSelection,
756 runtimeMode: sendSettings.runtimeMode,
757 interactionMode: sendSettings.interactionMode,
732758 createdAt: queuedMessage.createdAt,
733759 },
734760 });
......@@ -760,7 +786,6 @@ export function useThreadOutboxDrain(): void {
760786 startTurn,
761787 updateThreadMetadata,
762788 restoreQueuedMessage,
763 serverConfigs,
764789 ],
765790 );
766791
......@@ -774,14 +799,32 @@ export function useThreadOutboxDrain(): void {
774799 if (modelSelection === undefined) {
775800 return false;
776801 }
802 const serverConfig = appAtomRegistry.get(
803 serverEnvironment.configValueAtom(queuedMessage.environmentId),
804 );
805 if (!serverConfig) return false;
806 const settings = resolveQueuedThreadSettings(
807 queuedMessage,
808 {
809 modelSelection,
810 runtimeMode: DEFAULT_RUNTIME_MODE,
811 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
812 },
813 serverConfig.providers,
814 );
815 if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) {
816 return restoreQueuedMessage(
817 queuedMessage,
818 "Antigravity model unavailable. Open model settings to finish setup or choose another model.",
819 );
820 }
777821 let prepared: PreparedTurnAttachments;
778822 let persistedMessage: QueuedThreadMessage;
779823 let deliveryRevision: number;
780824 try {
781825 const preparedResult = await prepareQueuedMessageAttachments(
782826 queuedMessage,
783 serverConfigs.get(queuedMessage.environmentId)?.environment.capabilities
784 .attachmentUploads === true,
827 serverConfig.environment.capabilities.attachmentUploads === true,
785828 );
786829 if (preparedResult.status === "abandoned") {
787830 return true;
......@@ -809,6 +852,21 @@ export function useThreadOutboxDrain(): void {
809852 if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) {
810853 return true;
811854 }
855 const currentConfig = appAtomRegistry.get(
856 serverEnvironment.configValueAtom(queuedMessage.environmentId),
857 );
858 if (!currentConfig) return false;
859 if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) {
860 return restoreQueuedMessage(
861 persistedMessage,
862 "Antigravity model unavailable. Open model settings to finish setup or choose another model.",
863 );
864 }
865 const sendSettings = resolveQueuedThreadSettings(
866 queuedMessage,
867 settings,
868 currentConfig.providers,
869 );
812870 const deliveryResult = await startTurn({
813871 environmentId: queuedMessage.environmentId,
814872 input: buildProjectThreadStartTurnInput({
......@@ -821,9 +879,9 @@ export function useThreadOutboxDrain(): void {
821879 text: queuedMessage.text.trim(),
822880 attachments: queuedMessage.attachments,
823881 uploadedAttachments: prepared.attachments,
824 modelSelection,
825 runtimeMode: queuedMessage.runtimeMode ?? DEFAULT_RUNTIME_MODE,
826 interactionMode: queuedMessage.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE,
882 modelSelection: sendSettings.modelSelection,
883 runtimeMode: sendSettings.runtimeMode,
884 interactionMode: sendSettings.interactionMode,
827885 workspaceMode: creation.workspaceMode,
828886 branch: creation.branch,
829887 worktreePath: creation.worktreePath,
......@@ -859,7 +917,7 @@ export function useThreadOutboxDrain(): void {
859917 }
860918 return false;
861919 },
862 [makeDeliveryHelpers, restoreQueuedMessage, serverConfigs, startTurn],
920 [makeDeliveryHelpers, restoreQueuedMessage, startTurn],
863921 );
864922
865923 useEffect(() => {
......@@ -945,8 +1003,8 @@ export function useThreadOutboxDrain(): void {
9451003 environmentConnected: environment?.connectionState === "connected",
9461004 threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting",
9471005 });
948 // The delivery action resolves first; the file-capability gate applies
949 // only to a message that will send. Gating earlier would restore a
1006 // The delivery action resolves first; capability checks apply only to
1007 // a message that will send. Checking earlier would restore a
9501008 // creation whose startTurn already made the thread as a duplicate draft
9511009 // instead of removing it.
9521010 const serverConfig = serverConfigs.get(nextQueuedMessage.environmentId);
apps/server/integration/OrchestrationEngineHarness.integration.ts+6
......@@ -46,6 +46,7 @@ import {
4646 ProviderEventLoggers,
4747} from "../src/provider/Layers/ProviderEventLoggers.ts";
4848import { ProviderService } from "../src/provider/Services/ProviderService.ts";
49import { ProviderAuthService } from "../src/provider/Services/ProviderAuthService.ts";
4950import { AnalyticsService } from "../src/telemetry/AnalyticsService.ts";
5051import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";
5152import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityResolver.ts";
......@@ -337,6 +338,11 @@ export const makeOrchestrationIntegrationHarness = (
337338 generateThreadTitle: () => Effect.succeed({ title: "New thread" }),
338339 } as unknown as TextGeneration["Service"]);
339340 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
341 Layer.provide(
342 Layer.mock(ProviderAuthService)({
343 tryHandlePromptCommand: () => Effect.succeed(false),
344 }),
345 ),
340346 Layer.provideMerge(runtimeServicesLayer),
341347 Layer.provideMerge(gitWorkflowLayer),
342348 Layer.provideMerge(textGenerationLayer),
apps/server/integration/orphanedProviderSessionStartup.integration.test.ts+1
......@@ -116,6 +116,7 @@ const startupDependencies = Layer.mergeAll(
116116 stopSession: () => Effect.die("unused"),
117117 listSessions: () => Effect.succeed([]),
118118 getCapabilities: () => Effect.die("unused"),
119 assertConversationRollbackSupported: () => Effect.die("unused"),
119120 getInstanceInfo: () => Effect.die("unused"),
120121 rollbackConversation: () => Effect.die("unused"),
121122 uploadFeedback: () => Effect.die("unused"),
apps/server/package.json+3-1
......@@ -32,7 +32,8 @@
3232 "effect": "catalog:",
3333 "msgpackr-extract": "3.0.4",
3434 "node-pty": "^1.1.0",
35 "yaml": "catalog:"
35 "yaml": "catalog:",
36 "yauzl": "^3.4.0"
3637 },
3738 "devDependencies": {
3839 "@effect/vitest": "catalog:",
......@@ -42,6 +43,7 @@
4243 "@t3tools/web": "workspace:*",
4344 "@types/bun": "1.3.14",
4445 "@types/node": "catalog:",
46 "@types/yauzl": "^3.4.0",
4547 "effect-acp": "workspace:*",
4648 "effect-codex-app-server": "workspace:*",
4749 "vite-plus": "catalog:"
apps/server/scripts/acp-mock-agent.ts+246-28
......@@ -3,6 +3,7 @@
33import * as NodeFS from "node:fs";
44
55import * as Effect from "effect/Effect";
6import * as Deferred from "effect/Deferred";
67
78import * as NodeServices from "@effect/platform-node/NodeServices";
89import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
......@@ -13,6 +14,7 @@ import type * as AcpSchema from "effect-acp/schema";
1314
1415const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH;
1516const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH;
17const antigravityProfile = process.env.T3_ACP_ANTIGRAVITY === "1";
1618const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1";
1719const emitInterleavedAssistantToolCalls =
1820 process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1";
......@@ -29,6 +31,9 @@ const emitContentThenHang = process.env.T3_ACP_EMIT_CONTENT_THEN_HANG === "1";
2931const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1";
3032const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1";
3133const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1";
34const waitForResumeRelease = process.env.T3_ACP_WAIT_FOR_RESUME_RELEASE === "1";
35const completeFirstPromptOnCancel = process.env.T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL === "1";
36const floodStderr = process.env.T3_ACP_FLOOD_STDERR === "1";
3237const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1";
3338const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1";
3439const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL === "1";
......@@ -62,8 +67,8 @@ const permissionRequestCount = Math.max(
6267);
6368const sessionId = "mock-session-1";
6469
65let currentModeId = "ask";
66let currentModelId = "default";
70let currentModeId = antigravityProfile ? "default" : "ask";
71let currentModelId = antigravityProfile ? "gemini-test-low" : "default";
6772let parameterizedModelPicker = false;
6873let currentReasoning = "medium";
6974let currentContext = "272k";
......@@ -109,6 +114,26 @@ process.once("exit", (code) => {
109114});
110115
111116function configOptions(): ReadonlyArray<AcpSchema.SessionConfigOption> {
117 if (antigravityProfile) {
118 return [
119 {
120 id: "model",
121 name: "Model",
122 category: "model",
123 type: "select",
124 currentValue: currentModelId,
125 options: antigravityModels.map((model) => ({ value: model.modelId, name: model.name })),
126 },
127 {
128 id: "mode",
129 name: "Mode",
130 category: "mode",
131 type: "select",
132 currentValue: currentModeId,
133 options: availableModes.map((mode) => ({ value: mode.id, name: mode.name })),
134 },
135 ];
136 }
112137 if (parameterizedModelPicker) {
113138 const baseOptions: Array<AcpSchema.SessionConfigOption> = [
114139 {
......@@ -268,23 +293,34 @@ function availableModels(): ReadonlyArray<{
268293 }));
269294}
270295
271const availableModes: ReadonlyArray<AcpSchema.SessionMode> = [
272 {
273 id: "ask",
274 name: "Ask",
275 description: "Request permission before making any changes",
276 },
277 {
278 id: "architect",
279 name: "Architect",
280 description: "Design and plan software systems without implementation",
281 },
282 {
283 id: "code",
284 name: "Code",
285 description: "Write and modify code with full tool access",
286 },
287];
296const antigravityModels = [
297 { modelId: "gemini-test-low", name: "Gemini Test Low" },
298 { modelId: "gemini-test-high", name: "Gemini Test High" },
299] satisfies ReadonlyArray<AcpSchema.ModelInfo>;
300
301const availableModes: ReadonlyArray<AcpSchema.SessionMode> = antigravityProfile
302 ? [
303 { id: "default", name: "Default" },
304 { id: "auto_edit", name: "Auto edit" },
305 { id: "yolo", name: "YOLO" },
306 ]
307 : [
308 {
309 id: "ask",
310 name: "Ask",
311 description: "Request permission before making any changes",
312 },
313 {
314 id: "architect",
315 name: "Architect",
316 description: "Design and plan software systems without implementation",
317 },
318 {
319 id: "code",
320 name: "Code",
321 description: "Write and modify code with full tool access",
322 },
323 ];
288324
289325function modeState(): AcpSchema.SessionModeState {
290326 return {
......@@ -314,6 +350,9 @@ const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [
314350];
315351
316352function modelState(): AcpSchema.SessionModelState {
353 if (antigravityProfile) {
354 return { currentModelId, availableModels: antigravityModels };
355 }
317356 const modelId = grokAcpModels.some((model) => model.modelId === currentModelId)
318357 ? currentModelId
319358 : "grok-4.6";
......@@ -325,14 +364,49 @@ function modelState(): AcpSchema.SessionModelState {
325364
326365const program = Effect.gen(function* () {
327366 const agent = yield* EffectAcpAgent.AcpAgent;
367 const resumeRelease = yield* Deferred.make<void>();
368 const nativeCancelRequested = yield* Deferred.make<void>();
369 const nativeCancelRelease = yield* Deferred.make<void>();
370 const publishAntigravityCommands = (targetSessionId: string) =>
371 agent.client.sessionUpdate({
372 sessionId: targetSessionId,
373 update: {
374 sessionUpdate: "available_commands_update",
375 availableCommands: [
376 { name: "plan", description: "Plan a task", input: { hint: "task" } },
377 { name: "logout", description: "Sign out" },
378 ],
379 },
380 });
328381
329382 yield* agent.handleInitialize((request) =>
330 Effect.sync(() => {
383 Effect.gen(function* () {
384 if (floodStderr) {
385 yield* Effect.promise(
386 () =>
387 new Promise<void>((resolve) => {
388 process.stderr.write("stderr".repeat(350_000), () => resolve());
389 }),
390 );
391 }
331392 parameterizedModelPicker =
332393 request.clientCapabilities?._meta?.parameterizedModelPicker === true;
394 if (antigravityProfile) {
395 return {
396 protocolVersion: 1,
397 agentInfo: { name: "antigravity-acp", version: "mock" },
398 agentCapabilities: {
399 loadSession: true,
400 sessionCapabilities: { resume: {} },
401 auth: { logout: {} },
402 promptCapabilities: { image: true, embeddedContext: true },
403 },
404 authMethods: [{ id: "oauth-personal", name: "Sign in with Google" }],
405 };
406 }
333407 return {
334408 protocolVersion: 1,
335 agentCapabilities: { loadSession: true },
409 agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } },
336410 // Grok advertises model state before any session exists; the provider
337411 // health check reads it from here without authenticating.
338412 _meta: { modelState: modelState() },
......@@ -340,14 +414,58 @@ const program = Effect.gen(function* () {
340414 }),
341415 );
342416
343 yield* agent.handleAuthenticate(() => Effect.succeed({}));
417 // Mirrors the real agent: the API key method reads GEMINI_API_KEY from the
418 // process environment and rejects when it is missing.
419 yield* agent.handleAuthenticate((request) =>
420 !antigravityProfile || request.methodId === "oauth-personal"
421 ? Effect.succeed({})
422 : request.methodId === "gemini-api-key" && process.env.GEMINI_API_KEY
423 ? Effect.succeed({})
424 : Effect.fail(
425 AcpError.AcpRequestError.invalidParams(
426 `Mock Antigravity rejected auth method ${request.methodId}.`,
427 ),
428 ),
429 );
430 if (antigravityProfile) {
431 yield* agent.handleLogout(() => Effect.succeed({}));
432 }
344433
345434 yield* agent.handleCreateSession(() =>
346 Effect.succeed({
347 sessionId,
348 modes: modeState(),
349 models: modelState(),
350 configOptions: configOptions(),
435 Effect.gen(function* () {
436 if (antigravityProfile) {
437 yield* publishAntigravityCommands(sessionId);
438 }
439 return {
440 sessionId,
441 modes: modeState(),
442 models: modelState(),
443 configOptions: configOptions(),
444 };
445 }),
446 );
447
448 yield* agent.handleResumeSession((request) =>
449 Effect.gen(function* () {
450 yield* agent.client.sessionUpdate({
451 sessionId: request.sessionId,
452 update: {
453 sessionUpdate: "user_message_chunk",
454 content: { type: "text", text: "native-resume-started" },
455 },
456 });
457 if (waitForResumeRelease) {
458 yield* Deferred.await(resumeRelease);
459 }
460 if (antigravityProfile) {
461 yield* publishAntigravityCommands(request.sessionId);
462 }
463 return {
464 modes: modeState(),
465 models: modelState(),
466 configOptions: configOptions(),
467 _meta: { nativeResume: true },
468 };
351469 }),
352470 );
353471
......@@ -415,7 +533,7 @@ const program = Effect.gen(function* () {
415533
416534 yield* agent.handleSetSessionModel((request) =>
417535 Effect.gen(function* () {
418 if (!grokAcpModels.some((model) => model.modelId === request.modelId)) {
536 if (!modelState().availableModels.some((model) => model.modelId === request.modelId)) {
419537 return yield* AcpError.AcpRequestError.invalidParams(
420538 `Unknown mock model id: ${request.modelId}`,
421539 {
......@@ -470,6 +588,16 @@ const program = Effect.gen(function* () {
470588 Effect.gen(function* () {
471589 const cancelledSessionId = String(sessionId ?? "mock-session-1");
472590 cancelledSessions.add(cancelledSessionId);
591 if (completeFirstPromptOnCancel) {
592 yield* Deferred.succeed(nativeCancelRequested, undefined);
593 yield* agent.client.sessionUpdate({
594 sessionId: cancelledSessionId,
595 update: {
596 sessionUpdate: "agent_thought_chunk",
597 content: { type: "text", text: "native-cancel-received" },
598 },
599 });
600 }
473601 if (emitLateUpdateAfterCancel) {
474602 yield* Effect.sleep("50 millis");
475603 yield* Effect.sync(() => {
......@@ -490,6 +618,38 @@ const program = Effect.gen(function* () {
490618 const requestedSessionId = String(request.sessionId ?? sessionId);
491619 promptCount += 1;
492620
621 if (completeFirstPromptOnCancel && promptCount === 1) {
622 yield* agent.client.sessionUpdate({
623 sessionId: requestedSessionId,
624 update: {
625 sessionUpdate: "tool_call",
626 toolCallId: "native-cancel-tool",
627 title: "Long command",
628 kind: "execute",
629 status: "in_progress",
630 },
631 });
632 yield* Deferred.await(nativeCancelRequested);
633 yield* Deferred.await(nativeCancelRelease);
634 yield* agent.client.sessionUpdate({
635 sessionId: requestedSessionId,
636 update: {
637 sessionUpdate: "tool_call_update",
638 toolCallId: "native-cancel-tool",
639 status: "failed",
640 content: [{ type: "content", content: { type: "text", text: "Cancelled." } }],
641 },
642 });
643 yield* agent.client.sessionUpdate({
644 sessionId: requestedSessionId,
645 update: {
646 sessionUpdate: "agent_message_chunk",
647 content: { type: "text", text: "Request cancelled." },
648 },
649 });
650 return { stopReason: "cancelled", _meta: { nativeCancel: true } };
651 }
652
493653 if (Number.isFinite(promptDelayMs) && promptDelayMs > 0) {
494654 yield* Effect.sleep(`${promptDelayMs} millis`);
495655 }
......@@ -1072,6 +1232,60 @@ const program = Effect.gen(function* () {
10721232 );
10731233
10741234 yield* agent.handleUnknownExtRequest((method, params) => {
1235 if (method === "_test/environment") {
1236 return Effect.succeed({
1237 inherited: process.env.T3_ACP_RUNTIME_AMBIENT === "sentinel",
1238 explicit: process.env.T3_ACP_RUNTIME_EXPLICIT === "kept",
1239 });
1240 }
1241 if (method === "_test/release-resume") {
1242 return Deferred.succeed(resumeRelease, undefined).pipe(Effect.as({}));
1243 }
1244 if (method === "_test/finish-cancel") {
1245 return Deferred.succeed(nativeCancelRelease, undefined).pipe(Effect.as({}));
1246 }
1247 if (method === "_test/startup-metadata") {
1248 return Effect.gen(function* () {
1249 for (const [metadataSessionId, commandName, modeId] of [
1250 [sessionId, "plan", "code"],
1251 ["child-session", "foreign-command", "ask"],
1252 ] as const) {
1253 yield* agent.client.sessionUpdate({
1254 sessionId: metadataSessionId,
1255 update: {
1256 sessionUpdate: "available_commands_update",
1257 availableCommands: [{ name: commandName, description: "Native command" }],
1258 },
1259 });
1260 yield* agent.client.sessionUpdate({
1261 sessionId: metadataSessionId,
1262 update: { sessionUpdate: "current_mode_update", currentModeId: modeId },
1263 });
1264 yield* agent.client.sessionUpdate({
1265 sessionId: metadataSessionId,
1266 update: {
1267 sessionUpdate: "config_option_update",
1268 configOptions: configOptions().map((option) =>
1269 option.type === "select" && option.category === "model"
1270 ? {
1271 ...option,
1272 currentValue: metadataSessionId === sessionId ? "gpt-5.4" : "default",
1273 }
1274 : option,
1275 ),
1276 },
1277 });
1278 yield* agent.client.sessionUpdate({
1279 sessionId: metadataSessionId,
1280 update: {
1281 sessionUpdate: "agent_message_chunk",
1282 content: { type: "text", text: "Startup transcript must not replay." },
1283 },
1284 });
1285 }
1286 return {};
1287 });
1288 }
10751289 if (method === "cursor/list_available_models") {
10761290 return Effect.succeed({
10771291 models: availableModels(),
......@@ -1118,6 +1332,10 @@ const program = Effect.gen(function* () {
11181332 return Effect.succeed({});
11191333 });
11201334
1335 yield* agent.handleUnknownExtNotification((method) =>
1336 method === "_test/exit" ? Effect.sync(() => process.exit(19)) : Effect.void,
1337 );
1338
11211339 return yield* Effect.never;
11221340}).pipe(
11231341 Effect.provide(
apps/server/src/auth/RpcAuthorization.ts+9
......@@ -33,6 +33,15 @@ export const RPC_REQUIRED_SCOPES = {
3333 [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope,
3434 [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope,
3535 [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope,
36 [WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope,
37 [WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope,
38 [WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope,
39 [WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope,
40 [WS_METHODS.providerAuthSubscribe]: AuthOrchestrationOperateScope,
41 [WS_METHODS.providerInstallStart]: AuthOrchestrationOperateScope,
42 [WS_METHODS.providerInstallCancel]: AuthOrchestrationOperateScope,
43 [WS_METHODS.providerInstallSubscribe]: AuthOrchestrationReadScope,
44 [WS_METHODS.providerInstallRemove]: AuthOrchestrationOperateScope,
3645 [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope,
3746 [WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope,
3847 [WS_METHODS.serverCommitDesktopUpdate]: AuthOrchestrationOperateScope,
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+91
......@@ -21,6 +21,7 @@ import {
2121} from "@t3tools/contracts";
2222import * as NodeServices from "@effect/platform-node/NodeServices";
2323import * as Clock from "effect/Clock";
24import * as Deferred from "effect/Deferred";
2425import * as Effect from "effect/Effect";
2526import * as Exit from "effect/Exit";
2627import * as Layer from "effect/Layer";
......@@ -28,6 +29,7 @@ import * as ManagedRuntime from "effect/ManagedRuntime";
2829import * as PubSub from "effect/PubSub";
2930import * as Scope from "effect/Scope";
3031import * as Stream from "effect/Stream";
32import { it as effectIt } from "@effect/vitest";
3133import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3234
3335import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts";
......@@ -56,6 +58,7 @@ import {
5658 type ProviderServiceShape,
5759} from "../../provider/Services/ProviderService.ts";
5860import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts";
61import { ProviderValidationError } from "../../provider/Errors.ts";
5962import { ServerConfig } from "../../config.ts";
6063import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts";
6164import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts";
......@@ -87,6 +90,9 @@ function createProviderServiceHarness(
8790 const rollbackConversation = vi.fn(
8891 (_input: { readonly threadId: ThreadId; readonly numTurns: number }) => Effect.void,
8992 );
93 const assertConversationRollbackSupported = vi.fn<
94 ProviderServiceShape["assertConversationRollbackSupported"]
95 >(() => Effect.void);
9096
9197 const unsupported = <A>() =>
9298 Effect.die(new Error("Unsupported provider call in test")) as Effect.Effect<A, never>;
......@@ -113,6 +119,7 @@ function createProviderServiceHarness(
113119 stopSession: () => unsupported(),
114120 listSessions,
115121 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
122 assertConversationRollbackSupported,
116123 getInstanceInfo: (instanceId) =>
117124 Effect.succeed({
118125 instanceId,
......@@ -137,6 +144,7 @@ function createProviderServiceHarness(
137144
138145 return {
139146 service,
147 assertConversationRollbackSupported,
140148 rollbackConversation,
141149 emit,
142150 };
......@@ -1002,6 +1010,89 @@ describe("CheckpointReactor", () => {
10021010 ).toBe(true);
10031011 });
10041012
1013 effectIt.effect("rejects unsupported rewind before changing files, checkpoints, or history", () =>
1014 Effect.gen(function* () {
1015 const harness = yield* Effect.promise(() =>
1016 createHarness({ providerName: ProviderDriverKind.make("antigravity") }),
1017 );
1018 const threadId = ThreadId.make("thread-1");
1019 const createdAt = "2026-01-01T00:00:00.000Z";
1020 const checked = yield* Deferred.make<void>();
1021 harness.provider.assertConversationRollbackSupported.mockImplementation(() =>
1022 Deferred.succeed(checked, undefined).pipe(
1023 Effect.andThen(
1024 Effect.fail(
1025 new ProviderValidationError({
1026 operation: "ProviderService.assertConversationRollbackSupported",
1027 issue: "Provider 'antigravity' does not support conversation rewind.",
1028 }),
1029 ),
1030 ),
1031 ),
1032 );
1033
1034 for (const turnCount of [1, 2]) {
1035 yield* harness.engine.dispatch({
1036 type: "thread.turn.start",
1037 commandId: CommandId.make(`cmd-unsupported-rewind-message-${turnCount}`),
1038 threadId,
1039 message: {
1040 messageId: MessageId.make(`message-unsupported-rewind-${turnCount}`),
1041 role: "user",
1042 text: `Keep message ${turnCount}`,
1043 attachments: [],
1044 },
1045 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1046 runtimeMode: "approval-required",
1047 createdAt,
1048 });
1049 yield* harness.engine.dispatch({
1050 type: "thread.turn.diff.complete",
1051 commandId: CommandId.make(`cmd-unsupported-rewind-diff-${turnCount}`),
1052 threadId,
1053 turnId: asTurnId(`turn-unsupported-rewind-${turnCount}`),
1054 completedAt: createdAt,
1055 checkpointRef: checkpointRefForThreadTurn(threadId, turnCount),
1056 status: "ready",
1057 files: [],
1058 checkpointTurnCount: turnCount,
1059 createdAt,
1060 });
1061 }
1062 const before = (yield* Effect.promise(() => harness.readModel())).threads.find(
1063 (thread) => thread.id === threadId,
1064 );
1065
1066 yield* harness.engine.dispatch({
1067 type: "thread.checkpoint.revert",
1068 commandId: CommandId.make("cmd-unsupported-rewind"),
1069 threadId,
1070 turnCount: 1,
1071 createdAt,
1072 });
1073 yield* Deferred.await(checked);
1074 yield* Effect.promise(() => harness.drain());
1075
1076 const after = (yield* Effect.promise(() => harness.readModel())).threads.find(
1077 (thread) => thread.id === threadId,
1078 );
1079 expect(after?.checkpoints).toEqual(before?.checkpoints);
1080 expect(after?.messages).toEqual(before?.messages);
1081 expect(after?.latestTurn).toEqual(before?.latestTurn);
1082 expect(after?.activities).toContainEqual(
1083 expect.objectContaining({
1084 kind: "checkpoint.revert.failed",
1085 payload: expect.objectContaining({
1086 detail: expect.stringContaining("does not support conversation rewind"),
1087 }),
1088 }),
1089 );
1090 expect(harness.provider.rollbackConversation).not.toHaveBeenCalled();
1091 expect(NodeFS.readFileSync(NodePath.join(harness.cwd, "README.md"), "utf8")).toBe("v3\n");
1092 expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(threadId, 2))).toBe(true);
1093 }),
1094 );
1095
10051096 it("executes provider revert and emits thread.reverted for checkpoint revert requests", async () => {
10061097 const harness = await createHarness();
10071098 const createdAt = "2026-01-01T00:00:00.000Z";
apps/server/src/orchestration/Layers/CheckpointReactor.ts+2
......@@ -755,6 +755,8 @@ const make = Effect.gen(function* () {
755755 return;
756756 }
757757
758 yield* providerService.assertConversationRollbackSupported(event.payload.threadId);
759
758760 const restored = yield* checkpointStore.restoreCheckpoint({
759761 cwd: sessionRuntime.value.cwd,
760762 checkpointRef: targetCheckpointRef,
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+247-2
......@@ -9,6 +9,7 @@ import {
99 ProviderSession,
1010 ProviderDriverKind,
1111 ProviderInstanceId,
12 ProviderSetupError,
1213} from "@t3tools/contracts";
1314import { createModelSelection } from "@t3tools/shared/model";
1415import {
......@@ -31,6 +32,7 @@ import * as ManagedRuntime from "effect/ManagedRuntime";
3132import * as PubSub from "effect/PubSub";
3233import * as Scope from "effect/Scope";
3334import * as Stream from "effect/Stream";
35import * as SqlClient from "effect/unstable/sql/SqlClient";
3436import { it as effectIt } from "@effect/vitest";
3537import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3638
......@@ -44,6 +46,7 @@ import {
4446 ProviderService,
4547 type ProviderServiceShape,
4648} from "../../provider/Services/ProviderService.ts";
49import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts";
4750import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts";
4851import { TextGeneration } from "../../textGeneration/TextGeneration.ts";
4952import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
......@@ -109,7 +112,10 @@ async function waitFor(
109112
110113describe("ProviderCommandReactor", () => {
111114 let runtime: ManagedRuntime.ManagedRuntime<
112 OrchestrationEngineService | ProviderCommandReactor | ProjectionSnapshotQuery,
115 | OrchestrationEngineService
116 | ProviderCommandReactor
117 | ProjectionSnapshotQuery
118 | SqlClient.SqlClient,
113119 unknown
114120 > | null = null;
115121 let scope: Scope.Closeable | null = null;
......@@ -172,6 +178,7 @@ describe("ProviderCommandReactor", () => {
172178 readonly startSessionEffect?: (
173179 session: ProviderSession,
174180 ) => Effect.Effect<ProviderSession, ProviderAdapterRequestError>;
181 readonly tryHandlePromptCommandEffect?: ProviderAuthService["Service"]["tryHandlePromptCommand"];
175182 }) {
176183 const now = "2026-01-01T00:00:00.000Z";
177184 const baseDir =
......@@ -180,6 +187,9 @@ describe("ProviderCommandReactor", () => {
180187 const { stateDir } = deriveServerPathsSync(baseDir, undefined);
181188 createdStateDirs.add(stateDir);
182189 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
190 const tryHandlePromptCommand = vi.fn<ProviderAuthService["Service"]["tryHandlePromptCommand"]>(
191 input?.tryHandlePromptCommandEffect ?? (() => Effect.succeed(false)),
192 );
183193 let nextSessionIndex = 1;
184194 const runtimeSessions: Array<ProviderSession> = [];
185195 const modelSelection = input?.threadModelSelection ?? {
......@@ -348,10 +358,17 @@ describe("ProviderCommandReactor", () => {
348358 Effect.succeed({
349359 sessionModelSwitch: input?.sessionModelSwitch ?? "in-session",
350360 }),
361 assertConversationRollbackSupported: () => unsupported(),
351362 getInstanceInfo: (instanceId) => {
352363 const raw = String(instanceId);
353364 const driverKind = ProviderDriverKind.make(
354 raw.startsWith("claude") ? "claudeAgent" : raw.startsWith("codex") ? "codex" : raw,
365 raw.startsWith("claude")
366 ? "claudeAgent"
367 : raw.startsWith("codex")
368 ? "codex"
369 : raw.startsWith("antigravity")
370 ? "antigravity"
371 : raw,
355372 );
356373 return Effect.succeed({
357374 instanceId,
......@@ -421,6 +438,7 @@ describe("ProviderCommandReactor", () => {
421438 Layer.provideMerge(reactorOrchestrationLayer),
422439 Layer.provideMerge(projectionSnapshotLayer),
423440 Layer.provideMerge(Layer.succeed(ProviderService, service)),
441 Layer.provide(Layer.mock(ProviderAuthService, { tryHandlePromptCommand })),
424442 Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)),
425443 Layer.provideMerge(
426444 Layer.mock(GitWorkflowService.GitWorkflowService)({
......@@ -445,6 +463,7 @@ describe("ProviderCommandReactor", () => {
445463 }),
446464 ),
447465 Layer.provideMerge(ServerSettingsService.layerTest()),
466 Layer.provideMerge(SqlitePersistenceMemory),
448467 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
449468 Layer.provideMerge(NodeServices.layer),
450469 );
......@@ -531,6 +550,18 @@ describe("ProviderCommandReactor", () => {
531550 return {
532551 engine,
533552 readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()),
553 readPendingTurnStarts: () =>
554 runtime!.runPromise(
555 Effect.gen(function* () {
556 const sql = yield* SqlClient.SqlClient;
557 return yield* sql<{ readonly threadId: string }>`
558 SELECT thread_id AS "threadId"
559 FROM projection_turns
560 WHERE turn_id IS NULL AND state = 'pending'
561 `;
562 }),
563 ),
564 tryHandlePromptCommand,
534565 startSession,
535566 sendTurn,
536567 interruptTurn,
......@@ -553,6 +584,220 @@ describe("ProviderCommandReactor", () => {
553584 };
554585 }
555586
587 effectIt.effect.each(["new", "ready", "stopped"] as const)(
588 "handles sign-out for a %s thread before worktree repair, text helpers, or startup",
589 (sessionStatus) =>
590 Effect.gen(function* () {
591 const instanceId = ProviderInstanceId.make("antigravity-personal");
592 const handled = yield* Deferred.make<void>();
593 const harness = yield* Effect.promise(() =>
594 createHarness({
595 ...(sessionStatus === "new"
596 ? {}
597 : {
598 threadModelSelection: { instanceId, model: "gemini-3.1-pro" },
599 }),
600 tryHandlePromptCommandEffect: () =>
601 Deferred.succeed(handled, undefined).pipe(Effect.as(true)),
602 }),
603 );
604 const threadId = ThreadId.make("thread-1");
605 const createdAt = "2026-01-01T00:00:00.000Z";
606 if (sessionStatus !== "new") {
607 yield* harness.engine.dispatch({
608 type: "thread.session.set",
609 commandId: CommandId.make("cmd-sign-out-bound-session"),
610 threadId,
611 session: {
612 threadId,
613 providerInstanceId: instanceId,
614 providerName: "antigravity",
615 status: sessionStatus,
616 runtimeMode: "approval-required",
617 activeTurnId: null,
618 lastError: null,
619 updatedAt: createdAt,
620 },
621 createdAt,
622 });
623 }
624 yield* harness.engine.dispatch({
625 type: "thread.meta.update",
626 commandId: CommandId.make("cmd-sign-out-worktree"),
627 threadId,
628 title: "New thread",
629 branch: "t3code/1234abcd",
630 worktreePath: NodePath.join(harness.stateDir, "missing-worktree"),
631 });
632
633 yield* harness.engine.dispatch({
634 type: "thread.turn.start",
635 commandId: CommandId.make("cmd-provider-sign-out"),
636 threadId,
637 message: {
638 messageId: MessageId.make("message-provider-sign-out"),
639 role: "user",
640 text: "/logout",
641 attachments: [],
642 },
643 modelSelection: {
644 instanceId:
645 sessionStatus === "new" ? instanceId : ProviderInstanceId.make("antigravity-other"),
646 model: "gemini-3.1-pro",
647 },
648 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
649 runtimeMode: "approval-required",
650 createdAt,
651 });
652 yield* Deferred.await(handled);
653 yield* Effect.promise(() => harness.drain());
654
655 const thread = (yield* Effect.promise(() => harness.readModel())).threads.find(
656 (entry) => entry.id === threadId,
657 );
658 expect(thread?.session).toMatchObject({
659 status: "stopped",
660 providerName: "antigravity",
661 providerInstanceId: instanceId,
662 activeTurnId: null,
663 lastError: null,
664 });
665 expect(thread?.messages.map((message) => message.text)).toEqual(["/logout"]);
666 expect(thread?.activities).toContainEqual(
667 expect.objectContaining({ kind: "provider.auth.signed-out", tone: "info", turnId: null }),
668 );
669 expect(yield* Effect.promise(() => harness.readPendingTurnStarts())).toEqual([]);
670 expect(harness.tryHandlePromptCommand).toHaveBeenCalledWith({
671 instanceId,
672 text: "/logout",
673 hasAttachments: false,
674 });
675 expect(harness.pruneWorktrees).not.toHaveBeenCalled();
676 expect(harness.createWorktree).not.toHaveBeenCalled();
677 expect(harness.generateThreadTitle).not.toHaveBeenCalled();
678 expect(harness.generateBranchName).not.toHaveBeenCalled();
679 expect(harness.startSession).not.toHaveBeenCalled();
680 expect(harness.sendTurn).not.toHaveBeenCalled();
681 }),
682 );
683
684 effectIt.effect("clears a failed sign-out request without sending it as a prompt", () =>
685 Effect.gen(function* () {
686 const instanceId = ProviderInstanceId.make("antigravity-personal");
687 const handled = yield* Deferred.make<void>();
688 const harness = yield* Effect.promise(() =>
689 createHarness({
690 threadModelSelection: { instanceId, model: "gemini-3.1-pro" },
691 tryHandlePromptCommandEffect: () =>
692 Deferred.succeed(handled, undefined).pipe(
693 Effect.andThen(
694 Effect.fail(
695 new ProviderSetupError({
696 instanceId,
697 operation: "logout",
698 detail: "The provider could not sign out. Try again.",
699 }),
700 ),
701 ),
702 ),
703 }),
704 );
705 const threadId = ThreadId.make("thread-1");
706
707 yield* harness.engine.dispatch({
708 type: "thread.turn.start",
709 commandId: CommandId.make("cmd-provider-sign-out-failed"),
710 threadId,
711 message: {
712 messageId: MessageId.make("message-provider-sign-out-failed"),
713 role: "user",
714 text: "/logout",
715 attachments: [],
716 },
717 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
718 runtimeMode: "approval-required",
719 createdAt: "2026-01-01T00:00:00.000Z",
720 });
721 yield* Deferred.await(handled);
722 yield* Effect.promise(() => harness.drain());
723
724 const thread = (yield* Effect.promise(() => harness.readModel())).threads.find(
725 (entry) => entry.id === threadId,
726 );
727 expect(thread?.session).toMatchObject({
728 status: "error",
729 activeTurnId: null,
730 lastError: expect.stringContaining("The provider could not sign out. Try again."),
731 });
732 expect(thread?.activities).toContainEqual(
733 expect.objectContaining({ kind: "provider.turn.start.failed", tone: "error" }),
734 );
735 expect(
736 thread?.activities.some((activity) => activity.kind === "provider.auth.signed-out"),
737 ).toBe(false);
738 expect(yield* Effect.promise(() => harness.readPendingTurnStarts())).toEqual([]);
739 expect(harness.startSession).not.toHaveBeenCalled();
740 expect(harness.sendTurn).not.toHaveBeenCalled();
741 }),
742 );
743
744 effectIt.effect.each([
745 { label: "a command mention", text: "What does /logout do?", attachments: [] },
746 {
747 label: "a command with an attachment",
748 text: "/logout",
749 attachments: [
750 {
751 type: "file" as const,
752 id: "attached-notes",
753 name: "notes.txt",
754 mimeType: "text/plain",
755 sizeBytes: 8,
756 },
757 ],
758 },
759 { label: "another provider's command", text: "/logout", attachments: [] },
760 ])("sends $label when the provider auth handler leaves it unhandled", ({ text, attachments }) =>
761 Effect.gen(function* () {
762 const started = yield* Deferred.make<void>();
763 const harness = yield* Effect.promise(() =>
764 createHarness({
765 startSessionEffect: (session) =>
766 Deferred.succeed(started, undefined).pipe(Effect.as(session)),
767 }),
768 );
769
770 yield* harness.engine.dispatch({
771 type: "thread.turn.start",
772 commandId: CommandId.make("cmd-provider-command-unhandled"),
773 threadId: ThreadId.make("thread-1"),
774 message: {
775 messageId: MessageId.make("message-provider-command-unhandled"),
776 role: "user",
777 text,
778 attachments,
779 },
780 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
781 runtimeMode: "approval-required",
782 createdAt: "2026-01-01T00:00:00.000Z",
783 });
784 yield* Deferred.await(started);
785 yield* Effect.promise(() => harness.drain());
786
787 expect(harness.tryHandlePromptCommand).toHaveBeenCalledWith({
788 instanceId: ProviderInstanceId.make("codex"),
789 text,
790 hasAttachments: attachments.length > 0,
791 });
792 expect(harness.sendTurn).toHaveBeenCalledWith(
793 expect.objectContaining({
794 input: text,
795 ...(attachments.length > 0 ? { attachments } : {}),
796 }),
797 );
798 }),
799 );
800
556801 it("reacts to thread.turn.start by ensuring session and sending provider turn", async () => {
557802 const harness = await createHarness();
558803 const now = "2026-01-01T00:00:00.000Z";
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+86-33
......@@ -33,6 +33,7 @@ import { increment, orchestrationEventsProcessedTotal } from "../../observabilit
3333import { ProviderAdapterRequestError } from "../../provider/Errors.ts";
3434import type { ProviderServiceError } from "../../provider/Errors.ts";
3535import { TextGeneration } from "../../textGeneration/TextGeneration.ts";
36import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts";
3637import { ProviderService } from "../../provider/Services/ProviderService.ts";
3738import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts";
3839import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
......@@ -308,6 +309,7 @@ const make = Effect.gen(function* () {
308309 const crypto = yield* Crypto.Crypto;
309310 const orchestrationEngine = yield* OrchestrationEngineService;
310311 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
312 const providerAuthService = yield* ProviderAuthService;
311313 const providerService = yield* ProviderService;
312314 const providerRegistry = yield* ProviderRegistry;
313315 const gitWorkflow = yield* GitWorkflowService;
......@@ -1151,39 +1153,6 @@ const make = Effect.gen(function* () {
11511153 return;
11521154 }
11531155
1154 yield* ensureThreadWorktree(thread);
1155
1156 const isFirstUserMessageTurn =
1157 thread.messages.filter((entry) => entry.role === "user").length === 1;
1158 if (isFirstUserMessageTurn) {
1159 const project = yield* resolveProject(thread.projectId);
1160 const generationCwd =
1161 resolveThreadWorkspaceCwd({
1162 thread,
1163 projects: project ? [project] : [],
1164 }) ?? process.cwd();
1165 const generationInput = {
1166 messageText: assistantCitationsToPlainText(message.text),
1167 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
1168 ...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}),
1169 };
1170
1171 yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({
1172 threadId: event.payload.threadId,
1173 branch: thread.branch,
1174 worktreePath: thread.worktreePath,
1175 ...generationInput,
1176 }).pipe(Effect.forkScoped);
1177
1178 if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) {
1179 yield* maybeGenerateThreadTitleForFirstTurn({
1180 threadId: event.payload.threadId,
1181 cwd: generationCwd,
1182 ...generationInput,
1183 }).pipe(Effect.forkScoped);
1184 }
1185 }
1186
11871156 const handleTurnStartFailure = (cause: Cause.Cause<unknown>) => {
11881157 if (Cause.hasInterruptsOnly(cause)) {
11891158 return Effect.void;
......@@ -1220,6 +1189,90 @@ const make = Effect.gen(function* () {
12201189 ),
12211190 );
12221191
1192 const authCommandHandled = yield* Effect.gen(function* () {
1193 // Native account commands belong to the thread's existing provider session.
1194 const instanceId =
1195 thread.session?.providerInstanceId ??
1196 event.payload.modelSelection?.instanceId ??
1197 thread.modelSelection.instanceId;
1198 const handled = yield* providerAuthService.tryHandlePromptCommand({
1199 instanceId,
1200 text: message.text,
1201 hasAttachments: (message.attachments?.length ?? 0) > 0,
1202 });
1203 if (!handled) {
1204 return false;
1205 }
1206
1207 const instanceInfo = yield* providerService.getInstanceInfo(instanceId);
1208 yield* setThreadSession({
1209 threadId: thread.id,
1210 session: {
1211 threadId: thread.id,
1212 status: "stopped",
1213 providerName: instanceInfo.driverKind,
1214 providerInstanceId: instanceId,
1215 runtimeMode: thread.runtimeMode,
1216 activeTurnId: null,
1217 lastError: null,
1218 updatedAt: event.payload.createdAt,
1219 },
1220 createdAt: event.payload.createdAt,
1221 });
1222 yield* orchestrationEngine.dispatch({
1223 type: "thread.activity.append",
1224 commandId: yield* serverCommandId("provider-sign-out"),
1225 threadId: thread.id,
1226 activity: {
1227 id: yield* serverEventId(),
1228 tone: "info",
1229 kind: "provider.auth.signed-out",
1230 summary: "Provider signed out",
1231 payload: { providerInstanceId: instanceId },
1232 turnId: null,
1233 createdAt: event.payload.createdAt,
1234 },
1235 createdAt: event.payload.createdAt,
1236 });
1237 return true;
1238 }).pipe(Effect.catchCause((cause) => recoverTurnStartFailure(cause).pipe(Effect.as(true))));
1239 if (authCommandHandled) {
1240 return;
1241 }
1242
1243 yield* ensureThreadWorktree(thread);
1244
1245 const isFirstUserMessageTurn =
1246 thread.messages.filter((entry) => entry.role === "user").length === 1;
1247 if (isFirstUserMessageTurn) {
1248 const project = yield* resolveProject(thread.projectId);
1249 const generationCwd =
1250 resolveThreadWorkspaceCwd({
1251 thread,
1252 projects: project ? [project] : [],
1253 }) ?? process.cwd();
1254 const generationInput = {
1255 messageText: assistantCitationsToPlainText(message.text),
1256 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
1257 ...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}),
1258 };
1259
1260 yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({
1261 threadId: event.payload.threadId,
1262 branch: thread.branch,
1263 worktreePath: thread.worktreePath,
1264 ...generationInput,
1265 }).pipe(Effect.forkScoped);
1266
1267 if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) {
1268 yield* maybeGenerateThreadTitleForFirstTurn({
1269 threadId: event.payload.threadId,
1270 cwd: generationCwd,
1271 ...generationInput,
1272 }).pipe(Effect.forkScoped);
1273 }
1274 }
1275
12231276 const sendTurnRequest = yield* buildSendTurnRequestForThread({
12241277 threadId: event.payload.threadId,
12251278 messageText: message.text,
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+1
......@@ -111,6 +111,7 @@ function createProviderServiceHarness() {
111111 stopSession: () => unsupported(),
112112 listSessions: () => Effect.succeed([...runtimeSessions]),
113113 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
114 assertConversationRollbackSupported: () => unsupported(),
114115 getInstanceInfo: (instanceId) => {
115116 const driverKind = ProviderDriverKind.make(String(instanceId));
116117 return Effect.succeed({
apps/server/src/provider/AntigravityAuth.test.ts created+427
......@@ -0,0 +1,427 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import { ProviderInstanceId, ProviderSetupError, type ProviderAuthState } from "@t3tools/contracts";
4import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as Fiber from "effect/Fiber";
8import * as Option from "effect/Option";
9import * as Scope from "effect/Scope";
10import * as Stream from "effect/Stream";
11import * as TestClock from "effect/testing/TestClock";
12import * as AcpErrors from "effect-acp/errors";
13import type * as AcpSchema from "effect-acp/schema";
14
15import {
16 makeAntigravityAuth,
17 type AntigravityAuth,
18 type AntigravityAuthRuntime,
19} from "./AntigravityAuth.ts";
20import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts";
21
22const instanceId = ProviderInstanceId.make("antigravity-auth-test");
23const owner = "t3-auth-session-owner";
24const otherOwner = "t3-auth-session-other";
25const authorizationUrl =
26 "https://accounts.google.com/o/oauth2/v2/auth?response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A51234%2F&state=test-state";
27const callbackUrl = "http://127.0.0.1:51234/?state=test-state&code=test-code";
28
29const initialized = {
30 protocolVersion: 1,
31 authMethods: [{ id: "oauth-personal", name: "Log in with Google" }],
32 agentCapabilities: { auth: { logout: {} } },
33} satisfies AcpSchema.InitializeResponse;
34const started: AcpSessionRuntimeStartResult = {
35 sessionId: "native-session",
36 initializeResult: initialized,
37 sessionSetupResult: {
38 sessionId: "native-session",
39 models: {
40 currentModelId: "gemini-test",
41 availableModels: [{ modelId: "gemini-test", name: "Gemini test" }],
42 },
43 },
44 modelConfigId: "model",
45};
46
47const phase = (auth: AntigravityAuth, value: ProviderAuthState["phase"], sessionId = owner) =>
48 auth.controller.subscribe(sessionId).pipe(
49 Stream.filter((state) => state.phase === value),
50 Stream.runHead,
51 Effect.map(Option.getOrThrow),
52 );
53
54const makeHarness = Effect.fn("makeAuthTestHarness")(function* (
55 options: {
56 readonly interactive?: boolean;
57 readonly supportsLogout?: boolean;
58 readonly forwardCallback?: Effect.Effect<void, ProviderSetupError>;
59 } = {},
60) {
61 const authenticated = yield* Deferred.make<void, AcpErrors.AcpError>();
62 const discovered = yield* Deferred.make<void>();
63 const closed = yield* Deferred.make<void>();
64 const events: string[] = [];
65 let forwarded = 0;
66 let catalog = ["previous-account-model"];
67 const auth = yield* makeAntigravityAuth({
68 instanceId,
69 makeRuntime: (input) =>
70 Effect.gen(function* () {
71 events.push("process-open");
72 yield* Effect.addFinalizer(() =>
73 Effect.gen(function* () {
74 events.push("process-close");
75 yield* Deferred.succeed(closed, undefined);
76 }),
77 );
78 return {
79 initialize: () =>
80 Effect.sync(() => {
81 events.push("initialize");
82 return options.supportsLogout === false
83 ? { ...initialized, agentCapabilities: {} }
84 : initialized;
85 }),
86 start: () =>
87 Effect.gen(function* () {
88 events.push("authenticate");
89 if (options.interactive !== false && input.onAuthorizationUrl) {
90 yield* input.onAuthorizationUrl(authorizationUrl);
91 }
92 yield* Deferred.await(authenticated);
93 events.push("session-new");
94 yield* Deferred.await(discovered);
95 return started;
96 }),
97 request: (method) =>
98 Effect.sync(() => {
99 events.push(method);
100 return {};
101 }),
102 } satisfies AntigravityAuthRuntime;
103 }),
104 onAuthenticated: () =>
105 Effect.sync(() => {
106 catalog = ["gemini-test"];
107 events.push("catalog-published");
108 }),
109 onSignedOut: Effect.sync(() => {
110 catalog = [];
111 events.push("catalog-cleared");
112 }),
113 forwardCallback: () =>
114 options.forwardCallback ??
115 Effect.sync(() => {
116 forwarded += 1;
117 }),
118 });
119 return {
120 auth,
121 authenticated,
122 discovered,
123 closed,
124 events,
125 catalog: () => catalog,
126 forwarded: () => forwarded,
127 };
128});
129
130it.layer(NodeServices.layer)("AntigravityAuth", (it) => {
131 it.effect("keeps a remote flow private and waits for native auth and catalog discovery", () =>
132 Effect.gen(function* () {
133 const harness = yield* makeHarness();
134 const state = yield* harness.auth.controller.start(owner);
135 assert.isNotNull(state.flowId);
136 const waiting = yield* phase(harness.auth, "waiting");
137 assert.equal(waiting.authorizationUrl, authorizationUrl);
138
139 const other = yield* phase(harness.auth, "waiting", otherOwner);
140 assert.isNull(other.authorizationUrl);
141 assert.isNull(other.flowId);
142 const stolen = yield* harness.auth.controller
143 .complete(otherOwner, { flowId: state.flowId!, callbackUrl })
144 .pipe(Effect.exit);
145 assert.isTrue(Exit.isFailure(stolen));
146 assert.equal(harness.forwarded(), 0);
147
148 const verifying = yield* harness.auth.controller.complete(owner, {
149 flowId: state.flowId!,
150 callbackUrl,
151 });
152 assert.equal(verifying.phase, "verifying");
153 assert.equal(harness.forwarded(), 1);
154 assert.deepEqual(harness.catalog(), ["previous-account-model"]);
155
156 yield* Deferred.succeed(harness.authenticated, undefined);
157 yield* Deferred.succeed(harness.discovered, undefined);
158 const succeeded = yield* phase(harness.auth, "succeeded");
159 assert.deepEqual(harness.catalog(), ["gemini-test"]);
160 assert.isNull(succeeded.authorizationUrl);
161 assert.isNull(succeeded.expiresAt);
162 assert.equal(harness.events.at(-1), "process-close");
163 }),
164 );
165
166 it.effect("does not call callback HTTP success a successful Google sign-in", () =>
167 Effect.gen(function* () {
168 const harness = yield* makeHarness();
169 const state = yield* harness.auth.controller.start(owner);
170 yield* phase(harness.auth, "waiting");
171 yield* harness.auth.controller.complete(owner, { flowId: state.flowId!, callbackUrl });
172 yield* Deferred.fail(
173 harness.authenticated,
174 AcpErrors.AcpRequestError.internalError(`access_denied ${callbackUrl}`),
175 );
176 const failed = yield* phase(harness.auth, "failed");
177 assert.include(failed.message ?? "", "not approved");
178 assert.notInclude(failed.message ?? "", "test-code");
179 assert.isNull(failed.authorizationUrl);
180 assert.deepEqual(harness.catalog(), ["previous-account-model"]);
181 yield* Deferred.await(harness.closed);
182 }),
183 );
184
185 it.effect("accepts direct local or cached completion without a callback RPC", () =>
186 Effect.gen(function* () {
187 const harness = yield* makeHarness({ interactive: false });
188 yield* harness.auth.controller.start(owner);
189 yield* Deferred.succeed(harness.authenticated, undefined);
190 yield* Deferred.succeed(harness.discovered, undefined);
191 yield* phase(harness.auth, "succeeded");
192 assert.equal(harness.forwarded(), 0);
193 assert.deepEqual(harness.catalog(), ["gemini-test"]);
194 }),
195 );
196
197 it.effect("rejects mismatched callbacks without sending any HTTP request", () =>
198 Effect.gen(function* () {
199 const harness = yield* makeHarness();
200 const state = yield* harness.auth.controller.start(owner);
201 yield* phase(harness.auth, "waiting");
202 for (const invalidUrl of [
203 callbackUrl.replace("51234", "51235"),
204 callbackUrl.replace("test-state", "wrong-state"),
205 callbackUrl.replace("/?", "/other?"),
206 `${callbackUrl}&state=test-state`,
207 ]) {
208 const result = yield* harness.auth.controller
209 .complete(owner, { flowId: state.flowId!, callbackUrl: invalidUrl })
210 .pipe(Effect.exit);
211 assert.isTrue(Exit.isFailure(result));
212 }
213 assert.equal(harness.forwarded(), 0);
214 assert.equal((yield* phase(harness.auth, "waiting")).authorizationUrl, authorizationUrl);
215 yield* harness.auth.controller.cancel(owner, state.flowId!);
216 }),
217 );
218
219 it.effect("fails the flow when delivery fails after the requesting client disconnects", () =>
220 Effect.gen(function* () {
221 const deliveryGate = yield* Deferred.make<void>();
222 const harness = yield* makeHarness({
223 forwardCallback: Deferred.await(deliveryGate).pipe(
224 Effect.andThen(
225 Effect.fail(
226 new ProviderSetupError({
227 instanceId,
228 operation: "complete",
229 detail: "loopback refused",
230 }),
231 ),
232 ),
233 ),
234 });
235 const state = yield* harness.auth.controller.start(owner);
236 yield* phase(harness.auth, "waiting");
237 // The client sends the callback, then its socket drops before Google answers.
238 const request = yield* harness.auth.controller
239 .complete(owner, { flowId: state.flowId!, callbackUrl })
240 .pipe(Effect.forkScoped);
241 yield* phase(harness.auth, "verifying");
242 yield* Fiber.interrupt(request);
243 yield* Deferred.succeed(deliveryGate, undefined);
244 const failed = yield* phase(harness.auth, "failed");
245 assert.include(failed.message ?? "", "Could not deliver");
246 assert.isTrue(harness.events.includes("process-close"));
247 }),
248 );
249
250 it.effect("cancel closes the owned process without forwarding a denial", () =>
251 Effect.gen(function* () {
252 const harness = yield* makeHarness();
253 const state = yield* harness.auth.controller.start(owner);
254 yield* phase(harness.auth, "waiting");
255 const wrongOwner = yield* harness.auth.controller
256 .cancel(otherOwner, state.flowId!)
257 .pipe(Effect.exit);
258 assert.isTrue(Exit.isFailure(wrongOwner));
259 const cancelled = yield* harness.auth.controller.cancel(owner, state.flowId!);
260 assert.equal(cancelled.phase, "cancelled");
261 assert.isNull(cancelled.authorizationUrl);
262 assert.equal(harness.forwarded(), 0);
263 yield* Deferred.await(harness.closed);
264 assert.deepEqual(harness.catalog(), ["previous-account-model"]);
265 }),
266 );
267
268 it.effect("expires the flow at the official deadline and removes its URL", () =>
269 Effect.gen(function* () {
270 const harness = yield* makeHarness();
271 const state = yield* harness.auth.controller.start(owner);
272 yield* phase(harness.auth, "waiting");
273 yield* TestClock.adjust("300 seconds");
274 const failed = yield* phase(harness.auth, "failed");
275 assert.include(failed.message ?? "", "expired");
276 assert.isNull(failed.authorizationUrl);
277 yield* Deferred.await(harness.closed);
278 const late = yield* harness.auth.controller
279 .complete(owner, { flowId: state.flowId!, callbackUrl })
280 .pipe(Effect.exit);
281 assert.isTrue(Exit.isFailure(late));
282 assert.equal(harness.forwarded(), 0);
283 }),
284 );
285
286 it.effect("survives subscriber disconnect and does not replace a competing client's flow", () =>
287 Effect.gen(function* () {
288 const harness = yield* makeHarness();
289 const first = yield* harness.auth.controller.start(owner);
290 yield* phase(harness.auth, "waiting");
291 const second = yield* harness.auth.controller.start(owner);
292 assert.equal(first.flowId, second.flowId);
293 const competing = yield* harness.auth.controller.start(otherOwner).pipe(Effect.exit);
294 assert.isTrue(Exit.isFailure(competing));
295 const resumed = yield* phase(harness.auth, "waiting");
296 assert.equal(resumed.flowId, first.flowId);
297 assert.deepEqual(harness.events, ["process-open", "authenticate"]);
298 yield* harness.auth.controller.cancel(owner, first.flowId!);
299 }),
300 );
301
302 it.effect("sign-out closes admission and every process before fresh native logout", () =>
303 Effect.gen(function* () {
304 const harness = yield* makeHarness();
305 const processScope = yield* Scope.make();
306 yield* Effect.addFinalizer(() => Scope.close(processScope, Exit.void));
307 const stop = Effect.gen(function* () {
308 harness.events.push("chat-close");
309 yield* Scope.close(processScope, Exit.void);
310 });
311 yield* harness.auth.withProcess(stop, Effect.void).pipe(Scope.provide(processScope));
312 const stopSessions = Effect.gen(function* () {
313 harness.events.push("sessions-stop");
314 const denied = yield* harness.auth
315 .withProcess(
316 Effect.void,
317 Effect.sync(() => harness.events.push("late-process")),
318 )
319 .pipe(Effect.scoped, Effect.exit);
320 assert.isTrue(Exit.isFailure(denied));
321 });
322 const result = yield* harness.auth.controller.logout(stopSessions);
323 assert.equal(result.phase, "idle");
324 assert.deepEqual(harness.events, [
325 "sessions-stop",
326 "chat-close",
327 "process-open",
328 "initialize",
329 "logout",
330 "catalog-cleared",
331 "process-close",
332 ]);
333 assert.deepEqual(harness.catalog(), []);
334 yield* harness.auth.withProcess(Effect.void, Effect.void).pipe(Effect.scoped);
335 }),
336 );
337
338 it.effect(
339 "sign-out interrupts startup without interrupting its caller after startup returns",
340 () =>
341 Effect.gen(function* () {
342 const harness = yield* makeHarness();
343 const entering = yield* Deferred.make<void>();
344 const continueStartup = yield* Deferred.make<void>();
345 const processScope = yield* Scope.make();
346 yield* Effect.addFinalizer(() => Scope.close(processScope, Exit.void));
347 const task = Effect.gen(function* () {
348 yield* Deferred.succeed(entering, undefined);
349 yield* Deferred.await(continueStartup);
350 harness.events.push("late-spawn");
351 });
352 const startup = yield* harness.auth
353 .withProcess(Scope.close(processScope, Exit.void), task)
354 .pipe(Scope.provide(processScope), Effect.forkScoped);
355 yield* Deferred.await(entering);
356 yield* harness.auth.controller.logout(Effect.void);
357 yield* Deferred.succeed(continueStartup, undefined);
358 assert.isTrue(Exit.isFailure(yield* Fiber.await(startup)));
359 assert.notInclude(harness.events, "late-spawn");
360 assert.include(harness.events, "logout");
361 }),
362 );
363
364 it.effect("failed session stopping still closes owned processes and skips native logout", () =>
365 Effect.gen(function* () {
366 const harness = yield* makeHarness();
367 const processScope = yield* Scope.make();
368 yield* Effect.addFinalizer(() => Scope.close(processScope, Exit.void));
369 const stop = Effect.gen(function* () {
370 harness.events.push("chat-close");
371 yield* Scope.close(processScope, Exit.void);
372 });
373 yield* harness.auth.withProcess(stop, Effect.void).pipe(Scope.provide(processScope));
374 const result = yield* harness.auth.controller
375 .logout(
376 Effect.fail(
377 new ProviderSetupError({
378 instanceId,
379 operation: "stopSessions",
380 detail: "Stop failed.",
381 }),
382 ),
383 )
384 .pipe(Effect.exit);
385 assert.isTrue(Exit.isFailure(result));
386 assert.deepEqual(harness.events, ["chat-close"]);
387 assert.deepEqual(harness.catalog(), ["previous-account-model"]);
388 }),
389 );
390
391 it.effect("finishes sign-out when the requesting client disconnects", () =>
392 Effect.gen(function* () {
393 const harness = yield* makeHarness();
394 const stopping = yield* Deferred.make<void>();
395 const continueStop = yield* Deferred.make<void>();
396 const request = yield* harness.auth.controller
397 .logout(
398 Effect.gen(function* () {
399 yield* Deferred.succeed(stopping, undefined);
400 yield* Deferred.await(continueStop);
401 }),
402 )
403 .pipe(Effect.forkScoped);
404 yield* Deferred.await(stopping);
405 yield* Fiber.interrupt(request);
406 yield* Deferred.succeed(continueStop, undefined);
407 const result = yield* harness.auth.controller.subscribe(owner).pipe(
408 Stream.filter((state) => state.message === "Signed out of Google."),
409 Stream.runHead,
410 Effect.map(Option.getOrThrow),
411 );
412 assert.equal(result.phase, "idle");
413 assert.deepEqual(harness.catalog(), []);
414 assert.include(harness.events, "logout");
415 }),
416 );
417
418 it.effect("does not call logout unless the official process advertises it", () =>
419 Effect.gen(function* () {
420 const harness = yield* makeHarness({ supportsLogout: false });
421 const result = yield* harness.auth.controller.logout(Effect.void).pipe(Effect.exit);
422 assert.isTrue(Exit.isFailure(result));
423 assert.deepEqual(harness.events, ["process-open", "initialize", "process-close"]);
424 assert.deepEqual(harness.catalog(), ["previous-account-model"]);
425 }),
426 );
427});
apps/server/src/provider/AntigravityAuth.ts created+536
......@@ -0,0 +1,536 @@
1import {
2 ProviderSetupError,
3 type ProviderAuthState,
4 type ProviderInstanceId,
5} from "@t3tools/contracts";
6import * as Cause from "effect/Cause";
7import * as Clock from "effect/Clock";
8import * as Crypto from "effect/Crypto";
9import * as DateTime from "effect/DateTime";
10import * as Deferred from "effect/Deferred";
11import * as Effect from "effect/Effect";
12import * as Exit from "effect/Exit";
13import * as Fiber from "effect/Fiber";
14import * as Option from "effect/Option";
15import * as Schema from "effect/Schema";
16import * as Scope from "effect/Scope";
17import * as Semaphore from "effect/Semaphore";
18import * as Stream from "effect/Stream";
19import * as SubscriptionRef from "effect/SubscriptionRef";
20import * as AcpErrors from "effect-acp/errors";
21
22import type { AcpSessionRuntime, AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts";
23import { parseAntigravityAuthorizationUrl } from "./antigravityAuthSupport.ts";
24import {
25 forwardAntigravityCallback,
26 validateAntigravityCallbackUrl,
27 type AntigravityPendingCallback,
28} from "./antigravityCallback.ts";
29import type { ProviderAuthController } from "./Services/ProviderAuthService.ts";
30
31const AUTH_TIMEOUT_MS = 300_000;
32const FORWARDING_FAILED_MESSAGE = "Could not deliver the sign-in response. Start sign-in again.";
33const isSetupError = Schema.is(ProviderSetupError);
34const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError);
35
36interface AuthSnapshot {
37 readonly ownerSessionId: string | null;
38 readonly state: ProviderAuthState;
39}
40
41interface AuthFlow {
42 readonly id: string;
43 readonly ownerSessionId: string;
44 readonly expiresAtMillis: number;
45 state: ProviderAuthState;
46 pending: AntigravityPendingCallback | undefined;
47 callbackSent: boolean;
48 fiber: Fiber.Fiber<void> | undefined;
49 forwarding: Fiber.Fiber<void, ProviderSetupError> | undefined;
50}
51
52interface OwnedProcess {
53 readonly stop: Effect.Effect<void>;
54 startup: Fiber.Fiber<unknown, unknown> | undefined;
55}
56
57export interface AntigravityAuth {
58 readonly controller: ProviderAuthController;
59 /** Tracks startup and the process scope so sign-out cannot leave cached credentials in memory. */
60 readonly withProcess: <A, E, R>(
61 stop: Effect.Effect<void>,
62 task: Effect.Effect<A, E, R>,
63 ) => Effect.Effect<A, E | ProviderSetupError, R | Scope.Scope>;
64}
65
66export type AntigravityAuthRuntime = Pick<
67 AcpSessionRuntime["Service"],
68 "initialize" | "start" | "request"
69>;
70
71export interface AntigravityAuthOptions<
72 Runtime extends AntigravityAuthRuntime = AcpSessionRuntime["Service"],
73> {
74 readonly instanceId: ProviderInstanceId;
75 readonly makeRuntime: (input: {
76 readonly onAuthorizationUrl?: (url: string) => Effect.Effect<void, AcpErrors.AcpError>;
77 }) => Effect.Effect<Runtime, AcpErrors.AcpError | ProviderSetupError, Scope.Scope>;
78 readonly onAuthenticated: (
79 result: AcpSessionRuntimeStartResult,
80 runtime: Runtime,
81 ) => Effect.Effect<void>;
82 readonly onSignedOut: Effect.Effect<void>;
83 readonly forwardCallback?: (callback: URL) => Effect.Effect<void, ProviderSetupError>;
84 /** False for API key methods, which authenticate without a Google sign-in page. */
85 readonly usesBrowser?: boolean;
86}
87
88function visibleSnapshot(snapshot: AuthSnapshot, ownerSessionId: string): ProviderAuthState {
89 if (snapshot.ownerSessionId === null || snapshot.ownerSessionId === ownerSessionId) {
90 return snapshot.state;
91 }
92 const busy = ["starting", "waiting", "verifying"].includes(snapshot.state.phase);
93 return {
94 ...snapshot.state,
95 flowId: null,
96 authorizationUrl: null,
97 expiresAt: null,
98 ...(busy ? { message: "Sign-in is in progress in another client." } : {}),
99 };
100}
101
102function safeAuthFailure(cause: Cause.Cause<unknown>, usesBrowser: boolean): string {
103 const error = Cause.findErrorOption(cause);
104 if (Option.isSome(error)) {
105 if (isSetupError(error.value)) {
106 return error.value.detail;
107 }
108 if (isAcpRequestError(error.value)) {
109 if (error.value.errorMessage.includes("SUBSCRIPTION_REQUIRED")) {
110 return "Google requires an eligible Antigravity subscription for this account.";
111 }
112 if (/access_denied|denied access|cancelled/i.test(error.value.errorMessage)) {
113 return "Google sign-in was not approved. Start sign-in again.";
114 }
115 if (!usesBrowser && error.value.code === -32602) {
116 return "Antigravity rejected the configured credentials. Check the provider settings.";
117 }
118 }
119 }
120 return usesBrowser
121 ? "Google sign-in failed. Start sign-in again."
122 : "Antigravity could not authenticate with the configured credentials.";
123}
124
125/** Owns one instance's explicit sign-in and all process admission around sign-out. */
126export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* <
127 Runtime extends AntigravityAuthRuntime,
128>(
129 options: AntigravityAuthOptions<Runtime>,
130): Effect.fn.Return<AntigravityAuth, never, Crypto.Crypto | Scope.Scope> {
131 const crypto = yield* Crypto.Crypto;
132 const instanceScope = yield* Scope.Scope;
133 const usesBrowser = options.usesBrowser ?? true;
134 const lock = yield* Semaphore.make(1);
135 const closed = yield* Deferred.make<void>();
136 const emptyState: ProviderAuthState = {
137 instanceId: options.instanceId,
138 phase: "idle",
139 flowId: null,
140 authorizationUrl: null,
141 expiresAt: null,
142 message: null,
143 };
144 const snapshot = yield* SubscriptionRef.make<AuthSnapshot>({
145 ownerSessionId: null,
146 state: emptyState,
147 });
148 const processes = new Set<OwnedProcess>();
149 let activeFlow: AuthFlow | undefined;
150 let operation: "idle" | "auth" | "logout" | "cancel" | "closed" = "idle";
151
152 const setupError = (name: string, detail: string) =>
153 new ProviderSetupError({ instanceId: options.instanceId, operation: name, detail });
154 const currentState = (ownerSessionId: string) =>
155 SubscriptionRef.get(snapshot).pipe(
156 Effect.map((value) => visibleSnapshot(value, ownerSessionId)),
157 );
158 const publishFlow = (flow: AuthFlow, state: ProviderAuthState) => {
159 flow.state = state;
160 return SubscriptionRef.set(snapshot, { ownerSessionId: flow.ownerSessionId, state });
161 };
162 const stopOwnedProcesses = Effect.suspend(() =>
163 Effect.forEach(
164 Array.from(processes),
165 (owned) =>
166 Effect.gen(function* () {
167 if (owned.startup) {
168 yield* Fiber.interrupt(owned.startup);
169 }
170 yield* owned.stop;
171 }),
172 { discard: true, concurrency: "unbounded" },
173 ),
174 );
175
176 const withProcess: AntigravityAuth["withProcess"] = (stop, task) =>
177 Effect.uninterruptibleMask((restore) =>
178 Effect.gen(function* () {
179 const scope = yield* Scope.Scope;
180 const owned: OwnedProcess = { stop, startup: undefined };
181 const fiber = yield* lock.withPermits(1)(
182 Effect.gen(function* () {
183 if (operation !== "idle") {
184 return yield* setupError(
185 "startProcess",
186 "Antigravity sign-in or sign-out is in progress. Try again after it finishes.",
187 );
188 }
189 processes.add(owned);
190 yield* Scope.addFinalizer(
191 scope,
192 Effect.sync(() => {
193 processes.delete(owned);
194 }),
195 );
196 const child = yield* restore(task).pipe(Effect.forkIn(scope));
197 owned.startup = child;
198 return child;
199 }),
200 );
201 // Propagate interruption after the exit wait so concurrent stop waiters stay attached.
202 return yield* restore(Fiber.await(fiber)).pipe(
203 Effect.flatMap((result) => result),
204 Effect.ensuring(Fiber.interrupt(fiber)),
205 Effect.ensuring(
206 Effect.sync(() => {
207 owned.startup = undefined;
208 }),
209 ),
210 );
211 }),
212 );
213
214 const finishFlow = (flow: AuthFlow, result: Exit.Exit<void, unknown>) =>
215 lock.withPermits(1)(
216 Effect.gen(function* () {
217 if (activeFlow !== flow) return;
218 activeFlow = undefined;
219 operation = "idle";
220 flow.pending = undefined;
221 yield* publishFlow(flow, {
222 ...flow.state,
223 phase: Exit.isSuccess(result) ? "succeeded" : "failed",
224 authorizationUrl: null,
225 expiresAt: null,
226 message: Exit.isSuccess(result)
227 ? usesBrowser
228 ? "Signed in with Google."
229 : "Connected to Antigravity."
230 : safeAuthFailure(result.cause, usesBrowser),
231 });
232 }),
233 );
234
235 const receiveAuthorizationUrl = (flow: AuthFlow, url: string) =>
236 parseAntigravityAuthorizationUrl(url).pipe(
237 Effect.flatMap((authorization) =>
238 lock.withPermits(1)(
239 Effect.gen(function* () {
240 if (activeFlow !== flow || operation !== "auth") return;
241 if (flow.pending) {
242 return yield* new AcpErrors.AcpTransportError({
243 detail: "Antigravity started more than one Google sign-in request.",
244 cause: undefined,
245 });
246 }
247 flow.pending = authorization;
248 yield* publishFlow(flow, {
249 ...flow.state,
250 phase: "waiting",
251 authorizationUrl: authorization.authorizationUrl,
252 message:
253 "Open the Google sign-in link. If you are remote, paste the redirect URL here.",
254 });
255 }),
256 ),
257 ),
258 );
259
260 const runSignIn = (flow: AuthFlow, stopSessions: Effect.Effect<void, ProviderSetupError>) =>
261 Effect.gen(function* () {
262 yield* stopSessions.pipe(Effect.ensuring(stopOwnedProcesses));
263 const runtime = yield* options.makeRuntime({
264 onAuthorizationUrl: (url) => receiveAuthorizationUrl(flow, url),
265 });
266 const started = yield* runtime.start();
267 yield* lock.withPermits(1)(
268 Effect.gen(function* () {
269 if (activeFlow !== flow) return;
270 flow.pending = undefined;
271 yield* publishFlow(flow, {
272 ...flow.state,
273 phase: "verifying",
274 authorizationUrl: null,
275 message: "Checking Antigravity access and models.",
276 });
277 }),
278 );
279 yield* options.onAuthenticated(started, runtime);
280 }).pipe(
281 Effect.scoped,
282 Effect.timeoutOrElse({
283 duration: AUTH_TIMEOUT_MS,
284 orElse: () =>
285 Effect.fail(setupError("start", "Google sign-in expired. Start sign-in again.")),
286 }),
287 Effect.exit,
288 Effect.flatMap((result) => finishFlow(flow, result)),
289 );
290
291 const stopFlow = (flow: AuthFlow, phase: "cancelled" | "failed", message: string) =>
292 Effect.uninterruptible(
293 Effect.gen(function* () {
294 const detached = yield* lock.withPermits(1)(
295 Effect.gen(function* () {
296 if (activeFlow !== flow) return false;
297 activeFlow = undefined;
298 operation = "cancel";
299 flow.pending = undefined;
300 yield* publishFlow(flow, {
301 ...flow.state,
302 phase,
303 authorizationUrl: null,
304 expiresAt: null,
305 message,
306 });
307 return true;
308 }),
309 );
310 if (!detached) return;
311 if (flow.forwarding) yield* Fiber.interrupt(flow.forwarding);
312 if (flow.fiber) yield* Fiber.interrupt(flow.fiber);
313 yield* lock.withPermits(1)(
314 Effect.sync(() => {
315 if (operation === "cancel") operation = "idle";
316 }),
317 );
318 }),
319 );
320
321 const requireFlow = (ownerSessionId: string, flowId: string, name: string) =>
322 Effect.gen(function* () {
323 const flow = activeFlow;
324 if (!flow || flow.id !== flowId || flow.ownerSessionId !== ownerSessionId) {
325 return yield* setupError(name, "This sign-in is no longer active in this client.");
326 }
327 const now = yield* Clock.currentTimeMillis;
328 if (now >= flow.expiresAtMillis) {
329 return yield* setupError(name, "Google sign-in expired. Start sign-in again.");
330 }
331 return flow;
332 });
333
334 const controller: ProviderAuthController = {
335 start: (ownerSessionId, stopSessions = Effect.void) =>
336 lock.withPermits(1)(
337 Effect.uninterruptible(
338 Effect.gen(function* () {
339 if (activeFlow?.ownerSessionId === ownerSessionId && operation === "auth") {
340 return activeFlow.state;
341 }
342 if (operation !== "idle") {
343 return yield* setupError("start", "Antigravity setup is already in progress.");
344 }
345 const flowId = yield* crypto.randomUUIDv4.pipe(
346 Effect.mapError(() =>
347 setupError("start", "Could not start Google sign-in. Try again."),
348 ),
349 );
350 const expiresAtMillis = (yield* Clock.currentTimeMillis) + AUTH_TIMEOUT_MS;
351 const state: ProviderAuthState = {
352 ...emptyState,
353 phase: "starting",
354 flowId,
355 expiresAt: DateTime.formatIso(DateTime.makeUnsafe(expiresAtMillis)),
356 message: usesBrowser ? "Starting Google sign-in." : "Checking credentials.",
357 };
358 const flow: AuthFlow = {
359 id: flowId,
360 ownerSessionId,
361 expiresAtMillis,
362 state,
363 pending: undefined,
364 callbackSent: false,
365 fiber: undefined,
366 forwarding: undefined,
367 };
368 activeFlow = flow;
369 operation = "auth";
370 yield* publishFlow(flow, state);
371 flow.fiber = yield* runSignIn(flow, stopSessions).pipe(
372 Effect.interruptible,
373 Effect.forkIn(instanceScope),
374 );
375 return state;
376 }),
377 ),
378 ),
379 complete: Effect.fn("AntigravityAuth.complete")(function* (ownerSessionId, input) {
380 const pending = yield* lock.withPermits(1)(
381 Effect.gen(function* () {
382 const flow = yield* requireFlow(ownerSessionId, input.flowId, "complete");
383 if (!flow.pending || flow.callbackSent) {
384 return yield* setupError(
385 "complete",
386 flow.callbackSent
387 ? "The sign-in response was already sent. Wait for Google to finish."
388 : "Wait for the Google sign-in link before you send a redirect URL.",
389 );
390 }
391 const callback = yield* validateAntigravityCallbackUrl(
392 options.instanceId,
393 flow.pending,
394 input.callbackUrl,
395 );
396 flow.callbackSent = true;
397 yield* publishFlow(flow, {
398 ...flow.state,
399 phase: "verifying",
400 authorizationUrl: null,
401 message: "Waiting for Google to finish sign-in.",
402 });
403 // The instance owns delivery and its failure handling. The RPC that
404 // sent the callback may disconnect before Google answers, and the
405 // flow must still settle instead of sitting at "verifying" until
406 // the deadline.
407 const forwarding = yield* (
408 options.forwardCallback?.(callback) ??
409 forwardAntigravityCallback(options.instanceId, callback)
410 ).pipe(
411 // stopFlow interrupts this fiber, so it runs from a sibling fiber.
412 Effect.tapError(() =>
413 stopFlow(flow, "failed", FORWARDING_FAILED_MESSAGE).pipe(
414 Effect.forkIn(instanceScope),
415 ),
416 ),
417 Effect.interruptible,
418 Effect.forkIn(instanceScope),
419 );
420 flow.forwarding = forwarding;
421 return { flow, forwarding };
422 }),
423 );
424 const forwarded = yield* Fiber.await(pending.forwarding);
425 if (Exit.isFailure(forwarded)) {
426 return yield* setupError("complete", FORWARDING_FAILED_MESSAGE);
427 }
428 return pending.flow.state;
429 }),
430 cancel: Effect.fn("AntigravityAuth.cancel")(function* (ownerSessionId, flowId) {
431 const flow = yield* lock.withPermits(1)(requireFlow(ownerSessionId, flowId, "cancel"));
432 yield* stopFlow(flow, "cancelled", "Google sign-in was cancelled.");
433 return flow.state;
434 }),
435 logout: Effect.fn("AntigravityAuth.logout")(function* (stopSessions) {
436 const task = Effect.uninterruptibleMask((restore) =>
437 Effect.gen(function* () {
438 const flow = yield* lock.withPermits(1)(
439 Effect.gen(function* () {
440 if (operation !== "idle" && operation !== "auth") {
441 return yield* setupError("logout", "Antigravity setup is already stopping.");
442 }
443 operation = "logout";
444 const currentFlow = activeFlow;
445 activeFlow = undefined;
446 if (currentFlow) {
447 currentFlow.pending = undefined;
448 yield* publishFlow(currentFlow, {
449 ...currentFlow.state,
450 phase: "cancelled",
451 authorizationUrl: null,
452 expiresAt: null,
453 message: "Google sign-in was cancelled by sign-out.",
454 });
455 }
456 return currentFlow;
457 }),
458 );
459 const stopRemaining = Effect.gen(function* () {
460 if (flow?.forwarding) yield* Fiber.interrupt(flow.forwarding);
461 if (flow?.fiber) yield* Fiber.interrupt(flow.fiber);
462 yield* stopOwnedProcesses;
463 });
464 const result = yield* restore(
465 Effect.gen(function* () {
466 yield* stopSessions.pipe(Effect.ensuring(stopRemaining));
467 const runtime = yield* options.makeRuntime({});
468 const initialized = yield* runtime.initialize();
469 if (!initialized.agentCapabilities?.auth?.logout) {
470 return yield* setupError(
471 "logout",
472 "This Antigravity version does not support sign-out. Update the provider.",
473 );
474 }
475 yield* runtime.request("logout", {});
476 yield* options.onSignedOut;
477 }).pipe(
478 Effect.scoped,
479 Effect.timeoutOrElse({
480 duration: "30 seconds",
481 orElse: () => Effect.fail(setupError("logout", "Antigravity sign-out timed out.")),
482 }),
483 ),
484 ).pipe(Effect.exit);
485 yield* lock.withPermits(1)(
486 Effect.gen(function* () {
487 operation = "idle";
488 yield* SubscriptionRef.set(snapshot, {
489 ownerSessionId: null,
490 state: {
491 ...emptyState,
492 phase: Exit.isSuccess(result) ? "idle" : "failed",
493 message: Exit.isSuccess(result)
494 ? "Signed out of Google."
495 : "Antigravity sign-out failed. Try again.",
496 },
497 });
498 }),
499 );
500 if (Exit.isFailure(result)) {
501 const failure = Cause.findErrorOption(result.cause);
502 return yield* Option.isSome(failure) && isSetupError(failure.value)
503 ? failure.value
504 : setupError("logout", "Antigravity sign-out failed. Try again.");
505 }
506 return yield* currentState("");
507 }),
508 );
509 const worker = yield* task.pipe(Effect.forkIn(instanceScope));
510 return yield* Fiber.await(worker).pipe(Effect.flatMap((result) => result));
511 }),
512 subscribe: (ownerSessionId) =>
513 SubscriptionRef.changes(snapshot).pipe(
514 Stream.map((value) => visibleSnapshot(value, ownerSessionId)),
515 Stream.interruptWhen(Deferred.await(closed)),
516 ),
517 isLogoutPrompt: (text, hasAttachments) => !hasAttachments && text.trim() === "/logout",
518 };
519
520 yield* Effect.addFinalizer(() =>
521 Effect.gen(function* () {
522 operation = "closed";
523 const flow = activeFlow;
524 activeFlow = undefined;
525 if (flow) {
526 flow.pending = undefined;
527 if (flow.forwarding) yield* Fiber.interrupt(flow.forwarding);
528 if (flow.fiber) yield* Fiber.interrupt(flow.fiber);
529 }
530 yield* stopOwnedProcesses;
531 yield* Deferred.succeed(closed, undefined);
532 }),
533 );
534
535 return { controller, withProcess };
536});
apps/server/src/provider/AntigravityInstallation.test.ts created+909
......@@ -0,0 +1,909 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import {
4 HostProcessArchitecture,
5 HostProcessEnvironment,
6 HostProcessPlatform,
7} from "@t3tools/shared/hostProcess";
8import * as Deferred from "effect/Deferred";
9import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";
11import * as Fiber from "effect/Fiber";
12import * as FileSystem from "effect/FileSystem";
13import * as Option from "effect/Option";
14import * as Path from "effect/Path";
15import * as PlatformError from "effect/PlatformError";
16import * as Queue from "effect/Queue";
17import * as Schema from "effect/Schema";
18import * as Scope from "effect/Scope";
19import * as Sink from "effect/Sink";
20import * as Stream from "effect/Stream";
21import { HttpClient, HttpClientResponse } from "effect/unstable/http";
22import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
23import * as NodeCrypto from "node:crypto";
24
25import {
26 makeAntigravityInstallation,
27 type AntigravityExecutable,
28 type AntigravityInstallation,
29 type AntigravityInstallationOptions,
30} from "./AntigravityInstallation.ts";
31import { ANTIGRAVITY_AUTH_BROWSER_MARKER } from "./antigravityAuthSupport.ts";
32import type { AntigravityReleaseAsset } from "./antigravityRelease.ts";
33
34const serverContents = "antigravity runtime\n";
35const harnessContents = "local harness\n";
36const previousReleaseId = "1".repeat(64);
37const previousVersion = "fixture-old";
38const encodeJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
39
40// Small ZIPs made with Python's zipfile module. The unsafe entries are intentional.
41const zipFixtures = {
42 complete:
43 "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXV9yAykQAAAADgAAABUAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWzLyU9OzFHISCzKSy0u5gIAUEsBAhQDFAAAAAgAAAAiXXMTL+gUAAAAFAAAABIAAAAAAAAAAAAAAO2BAAAAAGFneV9hY3Bfc2VydmVyLnBhclBLAQIUAxQAAAAIAAAAIl1fcgMpEAAAAA4AAAAVAAAAAAAAAAAAAADtgUQAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWxQSwUGAAAAAAIAAgCDAAAAhwAAAAAA",
44 missingHarness:
45 "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuQBQSwECFAMUAAAACAAAACJdcxMv6BQAAAAUAAAAEgAAAAAAAAAAAAAA7YEAAAAAYWd5X2FjcF9zZXJ2ZXIucGFyUEsFBgAAAAABAAEAQAAAAEQAAAAAAA==",
46 duplicate:
47 "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXXMTL+gUAAAAFAAAABIAAABhZ3lfYWNwX3NlcnZlci5wYXJLzCvJTC9KLMssqVQoKgVyclO5AFBLAQIUAxQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAAAAAAAAAAADtgQAAAABhZ3lfYWNwX3NlcnZlci5wYXJQSwECFAMUAAAACAAAACJdcxMv6BQAAAAUAAAAEgAAAAAAAAAAAAAA7YFEAAAAYWd5X2FjcF9zZXJ2ZXIucGFyUEsFBgAAAAACAAIAgAAAAIgAAAAAAA==",
48 traversal:
49 "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAAVAAAALi5cYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXV9yAykQAAAADgAAABUAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWzLyU9OzFHISCzKSy0u5gIAUEsBAhQDFAAAAAgAAAAiXXMTL+gUAAAAFAAAABUAAAAAAAAAAAAAAO2BAAAAAC4uXGFneV9hY3Bfc2VydmVyLnBhclBLAQIUAxQAAAAIAAAAIl1fcgMpEAAAAA4AAAAVAAAAAAAAAAAAAADtgUcAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWxQSwUGAAAAAAIAAgCGAAAAigAAAAAA",
50 symlink:
51 "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXV9yAykQAAAADgAAABUAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWzLyU9OzFHISCzKSy0u5gIAUEsBAhQDFAAAAAgAAAAiXXMTL+gUAAAAFAAAABIAAAAAAAAAAAAAAO2BAAAAAGFneV9hY3Bfc2VydmVyLnBhclBLAQIUAxQAAAAIAAAAIl1fcgMpEAAAAA4AAAAVAAAAAAAAAAAAAAD/oUQAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWxQSwUGAAAAAAIAAgCDAAAAhwAAAAAA",
52 oversizedMember:
53 "UEsDBBQAAAAIAAAAIl0WGThFFQAAABUAAAASAAAAYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuSoAUEsDBBQAAAAIAAAAIl1fcgMpEAAAAA4AAAAVAAAAbG9jYWxoYXJuZXNzX2V4dGVybmFsy8lPTsxRyEgsykstLuYCAFBLAQIUAxQAAAAIAAAAIl0WGThFFQAAABUAAAASAAAAAAAAAAAAAADtgQAAAABhZ3lfYWNwX3NlcnZlci5wYXJQSwECFAMUAAAACAAAACJdX3IDKRAAAAAOAAAAFQAAAAAAAAAAAAAA7YFFAAAAbG9jYWxoYXJuZXNzX2V4dGVybmFsUEsFBgAAAAACAAIAgwAAAIgAAAAAAA==",
54 windows:
55 "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIuZXhlS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXV9yAykQAAAADgAAABkAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWwuZXhly8lPTsxRyEgsykstLuYCAFBLAQIUAxQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAAAAAAAAAAADtgQAAAABhZ3lfYWNwX3NlcnZlci5leGVQSwECFAMUAAAACAAAACJdX3IDKRAAAAAOAAAAGQAAAAAAAAAAAAAA7YFEAAAAbG9jYWxoYXJuZXNzX2V4dGVybmFsLmV4ZVBLBQYAAAAAAgACAIcAAACLAAAAAAA=",
56};
57
58const completeArchive = Buffer.from(zipFixtures.complete, "base64");
59
60function releaseAsset(archive: Uint8Array = completeArchive, platform: NodeJS.Platform = "linux") {
61 return {
62 version: "fixture-new",
63 url: "https://dl.google.com/antigravity-test.zip",
64 sha256: NodeCrypto.createHash("sha256").update(archive).digest("hex"),
65 archiveBytes: archive.byteLength,
66 executable: {
67 name: platform === "win32" ? "agy_acp_server.exe" : "agy_acp_server.par",
68 bytes: Buffer.byteLength(serverContents),
69 },
70 harness: {
71 name: platform === "win32" ? "localharness_external.exe" : "localharness_external",
72 bytes: Buffer.byteLength(harnessContents),
73 },
74 } satisfies AntigravityReleaseAsset;
75}
76
77const writeRelease = Effect.fn("test.writeAntigravityRelease")(function* (
78 managedDirectory: string,
79 asset: AntigravityReleaseAsset,
80 active = true,
81) {
82 const fs = yield* FileSystem.FileSystem;
83 const path = yield* Path.Path;
84 const directory = path.join(managedDirectory, "versions", asset.sha256);
85 yield* fs.makeDirectory(directory, { recursive: true });
86 yield* fs.writeFileString(path.join(directory, asset.executable.name), serverContents, {
87 mode: 0o755,
88 });
89 yield* fs.writeFileString(path.join(directory, asset.harness.name), harnessContents, {
90 mode: 0o755,
91 });
92 yield* fs.writeFileString(
93 path.join(directory, ".install-complete.json"),
94 encodeJsonString({
95 releaseId: asset.sha256,
96 version: asset.version,
97 executable: asset.executable,
98 harness: asset.harness,
99 }),
100 );
101 if (active) {
102 yield* fs.writeFileString(
103 path.join(managedDirectory, "active.json"),
104 encodeJsonString({ releaseId: asset.sha256 }),
105 );
106 }
107});
108
109interface HarnessOptions {
110 readonly baseDir?: string;
111 readonly asset?: AntigravityReleaseAsset | null;
112 readonly archive?: Buffer;
113 readonly body?: Stream.Stream<Uint8Array> | undefined;
114 readonly contentLength?: number;
115 readonly contentEncoding?: string;
116 readonly platform?: NodeJS.Platform;
117 readonly path?: string;
118 readonly previous?: boolean;
119 readonly fileSystem?: FileSystem.FileSystem;
120 readonly validate?: AntigravityInstallationOptions["validate"];
121 readonly useDefaultValidation?: boolean;
122}
123
124const makeHarness = Effect.fn("test.makeAntigravityInstallation")(function* (
125 options: HarnessOptions = {},
126) {
127 const fs = yield* FileSystem.FileSystem;
128 const path = yield* Path.Path;
129 const baseDir =
130 options.baseDir ?? (yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-test-" }));
131 const platform = options.platform ?? "linux";
132 const archive = options.archive ?? completeArchive;
133 const asset = options.asset === undefined ? releaseAsset(archive, platform) : options.asset;
134 const managedDirectory = path.join(baseDir, "tools", "antigravity-acp", `${platform}-x64`);
135 if (options.previous) {
136 yield* writeRelease(managedDirectory, {
137 ...releaseAsset(archive, platform),
138 sha256: previousReleaseId,
139 version: previousVersion,
140 });
141 }
142 const stagingReleased = yield* Deferred.make<void>();
143 const requests: string[] = [];
144 const validations: Array<{ executable: AntigravityExecutable; version: string }> = [];
145 const installationFs = options.fileSystem ?? fs;
146 const trackedFs = FileSystem.FileSystem.of({
147 ...installationFs,
148 makeTempDirectoryScoped: (settings) =>
149 settings?.prefix === ".install-"
150 ? Effect.acquireRelease(installationFs.makeTempDirectory(settings), (directory) =>
151 fs
152 .remove(directory, { recursive: true, force: true })
153 .pipe(Effect.orDie, Effect.andThen(Deferred.succeed(stagingReleased, undefined))),
154 )
155 : installationFs.makeTempDirectoryScoped(settings),
156 });
157 const installation = yield* makeAntigravityInstallation({
158 baseDir,
159 releaseAsset: asset,
160 ...(options.useDefaultValidation
161 ? {}
162 : {
163 validate: (executable: AntigravityExecutable, version: string) =>
164 Effect.sync(() => validations.push({ executable, version })).pipe(
165 Effect.andThen(options.validate?.(executable, version) ?? Effect.void),
166 ),
167 }),
168 }).pipe(
169 Effect.provideService(FileSystem.FileSystem, trackedFs),
170 Effect.provideService(HostProcessPlatform, platform),
171 Effect.provideService(HostProcessArchitecture, "x64"),
172 Effect.provideService(HostProcessEnvironment, { PATH: options.path ?? "" }),
173 Effect.provideService(
174 HttpClient.HttpClient,
175 HttpClient.make((request) =>
176 Effect.sync(() => {
177 requests.push(request.url);
178 const response = HttpClientResponse.fromWeb(
179 request,
180 new Response(null, {
181 headers: {
182 ...(options.contentLength === undefined
183 ? {}
184 : { "content-length": String(options.contentLength) }),
185 ...(options.contentEncoding === undefined
186 ? {}
187 : { "content-encoding": options.contentEncoding }),
188 },
189 }),
190 );
191 return Object.defineProperty(response, "stream", {
192 value:
193 options.body ??
194 Stream.make(
195 archive.subarray(0, 31),
196 archive.subarray(31, 149),
197 archive.subarray(149),
198 ),
199 });
200 }),
201 ),
202 ),
203 );
204 return { installation, fs, path, baseDir, requests, validations, stagingReleased };
205});
206
207const terminalState = (installation: AntigravityInstallation["Service"]) =>
208 installation.changes.pipe(
209 Stream.filter((state) => ["succeeded", "failed", "cancelled"].includes(state.phase)),
210 Stream.runHead,
211 Effect.map(Option.getOrThrow),
212 );
213
214const expectPreviousRelease = Effect.fn("test.expectPreviousAntigravityRelease")(function* (
215 installation: AntigravityInstallation["Service"],
216) {
217 const fs = yield* FileSystem.FileSystem;
218 const path = yield* Path.Path;
219 const resolved = yield* installation.resolve();
220 expect(resolved.version).toBe(previousVersion);
221 expect(resolved.managedVersionDirectory).toBe(
222 path.join(installation.managedDirectory, "versions", previousReleaseId),
223 );
224 expect(yield* fs.readFileString(resolved.executablePath)).toBe(serverContents);
225 expect(yield* fs.readFileString(resolved.harnessPath)).toBe(harnessContents);
226 expect((yield* installation.state).installedVersion).toBe(previousVersion);
227});
228
229it.layer(NodeServices.layer)("Antigravity installation", (it) => {
230 it.effect("verifies both files before activating a streamed download", () =>
231 Effect.gen(function* () {
232 const enteredValidation = yield* Deferred.make<void>();
233 const finishValidation = yield* Deferred.make<void>();
234 const { installation, fs, path, validations, requests, stagingReleased } = yield* makeHarness(
235 {
236 previous: true,
237 validate: () =>
238 Deferred.succeed(enteredValidation, undefined).pipe(
239 Effect.andThen(Deferred.await(finishValidation)),
240 ),
241 },
242 );
243 const initial = yield* installation.changes.pipe(
244 Stream.runHead,
245 Effect.map(Option.getOrThrow),
246 );
247 expect(initial).toMatchObject({ phase: "idle", installedVersion: previousVersion });
248 const started = yield* installation.start;
249 expect(started).toMatchObject({ phase: "downloading", downloadedBytes: 0 });
250 yield* Deferred.await(enteredValidation);
251 yield* expectPreviousRelease(installation);
252 const validation = validations[0];
253 expect(validation?.version).toBe("fixture-new");
254 if (!validation) return yield* Effect.die("Expected runtime validation.");
255 expect(yield* fs.readFileString(validation.executable.executablePath)).toBe(serverContents);
256 expect(yield* fs.readFileString(validation.executable.harnessPath)).toBe(harnessContents);
257
258 yield* Deferred.succeed(finishValidation, undefined);
259 expect(yield* terminalState(installation)).toMatchObject({
260 phase: "succeeded",
261 operationId: started.operationId,
262 downloadedBytes: completeArchive.byteLength,
263 installedVersion: "fixture-new",
264 });
265 yield* Deferred.await(stagingReleased);
266 const selected = yield* installation.resolve();
267 expect(selected).toMatchObject({ source: "managed", version: "fixture-new" });
268 expect(path.dirname(selected.harnessPath)).toBe(path.dirname(selected.executablePath));
269 expect(yield* fs.readFileString(selected.executablePath)).toBe(serverContents);
270 expect(yield* fs.readFileString(selected.harnessPath)).toBe(harnessContents);
271 expect(yield* fs.readDirectory(path.join(installation.managedDirectory, "versions"))).toEqual(
272 expect.arrayContaining([previousReleaseId, releaseAsset().sha256]),
273 );
274 expect(requests).toEqual([releaseAsset().url]);
275 }),
276 );
277
278 it.effect.each([
279 {
280 name: "the expected release",
281 agentName: "antigravity-acp",
282 version: "fixture-new",
283 valid: true,
284 },
285 { name: "a different agent", agentName: "other-agent", version: "fixture-new", valid: false },
286 {
287 name: "a different version",
288 agentName: "antigravity-acp",
289 version: "other-version",
290 valid: false,
291 },
292 ])("validates $name with initialize only and removes the disposable profile", (testCase) =>
293 Effect.gen(function* () {
294 const fs = yield* FileSystem.FileSystem;
295 const encoder = new TextEncoder();
296 const decodeRequest = Schema.decodeUnknownEffect(
297 Schema.fromJsonString(
298 Schema.Struct({
299 id: Schema.Union([Schema.String, Schema.Number]),
300 method: Schema.String,
301 }),
302 ),
303 );
304 const methods: string[] = [];
305 const profiles = new Set<string>();
306 let closedRuntimes = 0;
307 const spawner = ChildProcessSpawner.make(
308 Effect.fn("test.spawnAntigravityValidator")(function* (command) {
309 if (command._tag !== "StandardCommand") {
310 return yield* Effect.die("Expected one validation process.");
311 }
312 const profile = command.options.env?.GEMINI_HOME;
313 if (!profile) return yield* Effect.die("Expected a disposable validation profile.");
314 profiles.add(profile);
315 const helper = command.args[0] === "-e";
316 const output = yield* Queue.unbounded<Uint8Array>();
317 const exited = yield* Deferred.make<ChildProcessSpawner.ExitCode>();
318 const terminate = Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)).pipe(
319 Effect.asVoid,
320 );
321 yield* Effect.addFinalizer(() =>
322 terminate.pipe(
323 Effect.andThen(Queue.shutdown(output)),
324 Effect.andThen(
325 Effect.sync(() => {
326 if (!helper) closedRuntimes += 1;
327 }),
328 ),
329 ),
330 );
331 return ChildProcessSpawner.makeHandle({
332 pid: ChildProcessSpawner.ProcessId(helper ? 1 : 2),
333 exitCode: helper
334 ? Effect.succeed(ChildProcessSpawner.ExitCode(0))
335 : Deferred.await(exited),
336 isRunning: Deferred.isDone(exited).pipe(Effect.map((done) => !done)),
337 kill: () => terminate,
338 unref: Effect.succeed(Effect.void),
339 stdin: Sink.forEach((bytes: Uint8Array) =>
340 Effect.gen(function* () {
341 const request = yield* decodeRequest(new TextDecoder().decode(bytes)).pipe(
342 Effect.orDie,
343 );
344 methods.push(request.method);
345 yield* Queue.offer(
346 output,
347 encoder.encode(
348 `${encodeJsonString({
349 jsonrpc: "2.0",
350 id: request.id,
351 ...(request.method === "initialize"
352 ? {
353 result: {
354 protocolVersion: 1,
355 agentInfo: { name: testCase.agentName, version: testCase.version },
356 agentCapabilities: {
357 loadSession: true,
358 sessionCapabilities: { resume: {} },
359 auth: { logout: {} },
360 },
361 authMethods: [{ id: "oauth-personal", name: "Google" }],
362 },
363 }
364 : {
365 error: {
366 code: -32601,
367 message: "Validation must not sign in or create sessions.",
368 },
369 }),
370 })}\n`,
371 ),
372 );
373 }),
374 ),
375 stdout: helper ? Stream.empty : Stream.fromQueue(output),
376 stderr: helper
377 ? Stream.make(
378 encoder.encode(
379 `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeJsonString(command.args.at(-1))}\n`,
380 ),
381 )
382 : Stream.empty,
383 all: Stream.empty,
384 getInputFd: () => Sink.drain,
385 getOutputFd: () => Stream.empty,
386 });
387 }),
388 );
389 const { installation, stagingReleased } = yield* makeHarness({
390 previous: true,
391 useDefaultValidation: true,
392 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner));
393 yield* installation.start;
394 expect((yield* terminalState(installation)).phase).toBe(
395 testCase.valid ? "succeeded" : "failed",
396 );
397 yield* Deferred.await(stagingReleased);
398 expect(methods).toEqual(["initialize"]);
399 expect(closedRuntimes).toBe(1);
400 expect(profiles.size).toBe(1);
401 for (const profile of profiles) {
402 expect(yield* fs.exists(profile)).toBe(false);
403 }
404 if (testCase.valid) {
405 expect((yield* installation.resolve()).version).toBe("fixture-new");
406 } else {
407 yield* expectPreviousRelease(installation);
408 }
409 }),
410 );
411
412 it.effect("accepts an encoded Content-Length when the body is compressed", () =>
413 Effect.gen(function* () {
414 // dl.google.com gzips the archive and reports the encoded size. The decoded
415 // stream is still checked byte for byte and by hash.
416 const { installation, validations } = yield* makeHarness({
417 contentLength: completeArchive.byteLength - 1_000,
418 contentEncoding: "gzip",
419 });
420 yield* installation.start;
421 const state = yield* terminalState(installation);
422 expect(state.phase).toBe("succeeded");
423 expect(validations).toHaveLength(1);
424 }),
425 );
426
427 it.effect.each([
428 { name: "checksum mismatch", asset: { ...releaseAsset(), sha256: "2".repeat(64) } },
429 { name: "short download", archive: completeArchive.subarray(0, -1), asset: releaseAsset() },
430 {
431 name: "oversized download",
432 archive: Buffer.concat([completeArchive, Buffer.from("extra")]),
433 asset: releaseAsset(),
434 },
435 { name: "wrong Content-Length", contentLength: completeArchive.byteLength + 1 },
436 { name: "missing harness", archive: Buffer.from(zipFixtures.missingHarness, "base64") },
437 { name: "duplicate executable", archive: Buffer.from(zipFixtures.duplicate, "base64") },
438 { name: "path traversal", archive: Buffer.from(zipFixtures.traversal, "base64") },
439 { name: "symbolic link", archive: Buffer.from(zipFixtures.symlink, "base64") },
440 { name: "oversized member", archive: Buffer.from(zipFixtures.oversizedMember, "base64") },
441 ])("rejects $name before runtime validation", (options) =>
442 Effect.gen(function* () {
443 const { installation, validations, stagingReleased, fs, path } = yield* makeHarness({
444 ...options,
445 previous: true,
446 });
447 yield* installation.start;
448 const state = yield* terminalState(installation);
449 expect(state.phase).toBe("failed");
450 expect(state.message).toBeTruthy();
451 expect(validations).toEqual([]);
452 yield* Deferred.await(stagingReleased);
453 yield* expectPreviousRelease(installation);
454 expect(yield* fs.readDirectory(path.join(installation.managedDirectory, "versions"))).toEqual(
455 [previousReleaseId],
456 );
457 }),
458 );
459
460 it.effect.each(["download", "extract", "active pointer"] as const)(
461 "preserves the old runtime after an ENOSPC error during %s",
462 (stage) =>
463 Effect.gen(function* () {
464 const fs = yield* FileSystem.FileSystem;
465 const noSpace = PlatformError.systemError({
466 _tag: "Unknown",
467 module: "FileSystem",
468 method: "write",
469 description: "ENOSPC: no space left on device",
470 });
471 const fileSystem = FileSystem.FileSystem.of({
472 ...fs,
473 sink: (target, options) =>
474 (stage === "download" && target.endsWith("download.zip")) ||
475 (stage === "extract" && target.endsWith("agy_acp_server.par"))
476 ? fs.sink(target, options).pipe(Sink.mapInputEffect(() => Effect.fail(noSpace)))
477 : fs.sink(target, options),
478 writeFileString: (target, content, options) =>
479 stage === "active pointer" && target.endsWith("contents.tmp")
480 ? Effect.fail(noSpace)
481 : fs.writeFileString(target, content, options),
482 });
483 const { installation, stagingReleased, path } = yield* makeHarness({
484 previous: true,
485 fileSystem,
486 });
487 yield* installation.start;
488 expect((yield* terminalState(installation)).phase).toBe("failed");
489 yield* Deferred.await(stagingReleased);
490 yield* expectPreviousRelease(installation);
491 expect(
492 (yield* fs.readDirectory(path.join(installation.managedDirectory, "versions"))).some(
493 (name) => name.startsWith(".install-"),
494 ),
495 ).toBe(false);
496 expect(yield* fs.readDirectory(installation.managedDirectory)).toEqual([
497 "active.json",
498 "versions",
499 ]);
500 }),
501 );
502
503 it.effect.each(["downloading", "extracting", "verifying"] as const)(
504 "cancels during %s and waits for open resources to close",
505 (phase) =>
506 Effect.gen(function* () {
507 const fs = yield* FileSystem.FileSystem;
508 const entered = yield* Deferred.make<void>();
509 const release = yield* Deferred.make<void>();
510 const interrupted = yield* Deferred.make<void>();
511 const barrier = Deferred.succeed(entered, undefined).pipe(
512 Effect.andThen(Deferred.await(release)),
513 Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
514 );
515 const { installation, stagingReleased, path, validations } = yield* makeHarness({
516 previous: true,
517 body:
518 phase === "downloading"
519 ? Stream.concat(
520 Stream.make(completeArchive.subarray(0, 31)),
521 Stream.fromEffect(barrier.pipe(Effect.as(completeArchive.subarray(31)))),
522 )
523 : undefined,
524 validate: phase === "verifying" ? () => barrier : undefined,
525 fileSystem: FileSystem.FileSystem.of({
526 ...fs,
527 sink: (target, options) =>
528 phase === "extracting" && target.endsWith("agy_acp_server.par")
529 ? fs
530 .sink(target, options)
531 .pipe(
532 Sink.mapInputEffect((chunk: Uint8Array) => barrier.pipe(Effect.as(chunk))),
533 )
534 : fs.sink(target, options),
535 }),
536 });
537 const started = yield* installation.start;
538 yield* Deferred.await(entered);
539 expect((yield* installation.state).phase).toBe(phase);
540 expect(
541 (yield* installation.cancel(started.operationId ?? "missing-operation-id")).phase,
542 ).toBe("cancelled");
543 yield* Deferred.await(interrupted);
544 yield* Deferred.await(stagingReleased);
545 yield* expectPreviousRelease(installation);
546 expect(validations).toHaveLength(phase === "verifying" ? 1 : 0);
547 expect(
548 yield* fs.readDirectory(path.join(installation.managedDirectory, "versions")),
549 ).toEqual([previousReleaseId]);
550 }),
551 );
552
553 it.effect.each([
554 { name: "the committed install", restart: false },
555 { name: "a newer install", restart: true },
556 ])("does not fail $name when old pointer cleanup fails", (testCase) =>
557 Effect.gen(function* () {
558 const fs = yield* FileSystem.FileSystem;
559 const cleanupStarted = yield* Deferred.make<void>();
560 const releaseCleanup = yield* Deferred.make<void>();
561 const nextValidationStarted = yield* Deferred.make<void>();
562 const releaseNextValidation = yield* Deferred.make<void>();
563 const firstWorker = yield* Deferred.make<Fiber.Fiber<unknown, unknown>>();
564 const cleanupError = PlatformError.systemError({
565 _tag: "PermissionDenied",
566 module: "FileSystem",
567 method: "remove",
568 description: "EPERM: pointer temp directory is in use",
569 });
570 let firstPointer = true;
571 let validationCount = 0;
572 const { installation, stagingReleased } = yield* makeHarness({
573 previous: true,
574 validate: () => {
575 validationCount += 1;
576 return validationCount === 2
577 ? Deferred.succeed(nextValidationStarted, undefined).pipe(
578 Effect.andThen(Deferred.await(releaseNextValidation)),
579 )
580 : Effect.void;
581 },
582 fileSystem: FileSystem.FileSystem.of({
583 ...fs,
584 makeTempDirectoryScoped: (settings) => {
585 if (settings?.prefix !== "active.json." || !firstPointer) {
586 return fs.makeTempDirectoryScoped(settings);
587 }
588 firstPointer = false;
589 return Effect.fiber.pipe(
590 Effect.tap((worker) => Deferred.succeed(firstWorker, worker)),
591 Effect.andThen(
592 Effect.acquireRelease(fs.makeTempDirectory(settings), () =>
593 Deferred.succeed(cleanupStarted, undefined).pipe(
594 Effect.andThen(Deferred.await(releaseCleanup)),
595 Effect.andThen(Effect.die(cleanupError)),
596 ),
597 ),
598 ),
599 );
600 },
601 }),
602 });
603 yield* Effect.gen(function* () {
604 const first = yield* installation.start;
605 yield* Deferred.await(cleanupStarted);
606 expect(yield* installation.state).toMatchObject({
607 operationId: first.operationId,
608 phase: "succeeded",
609 installedVersion: "fixture-new",
610 });
611 const current = testCase.restart ? yield* installation.start : first;
612 if (testCase.restart) yield* Deferred.await(nextValidationStarted);
613
614 yield* Deferred.succeed(releaseCleanup, undefined);
615 yield* Fiber.await(yield* Deferred.await(firstWorker));
616 yield* Deferred.await(stagingReleased);
617 expect(yield* installation.state).toMatchObject({
618 operationId: current.operationId,
619 phase: testCase.restart ? "verifying" : "succeeded",
620 installedVersion: "fixture-new",
621 });
622 expect((yield* installation.resolve()).version).toBe("fixture-new");
623
624 yield* Deferred.succeed(releaseNextValidation, undefined);
625 expect(yield* terminalState(installation)).toMatchObject({
626 operationId: current.operationId,
627 phase: "succeeded",
628 installedVersion: "fixture-new",
629 });
630 }).pipe(
631 Effect.ensuring(
632 Deferred.succeed(releaseCleanup, undefined).pipe(
633 Effect.andThen(Deferred.succeed(releaseNextValidation, undefined)),
634 ),
635 ),
636 );
637 }),
638 );
639
640 it.effect(
641 "shares one install across callers and keeps it alive after the caller scope closes",
642 () =>
643 Effect.gen(function* () {
644 const entered = yield* Deferred.make<void>();
645 const release = yield* Deferred.make<void>();
646 const { installation, requests, stagingReleased } = yield* makeHarness({
647 body: Stream.fromEffect(
648 Deferred.succeed(entered, undefined).pipe(
649 Effect.andThen(Deferred.await(release)),
650 Effect.as(completeArchive),
651 ),
652 ),
653 });
654 const callerScope = yield* Scope.make();
655 const started = yield* installation.start.pipe(Scope.provide(callerScope));
656 yield* Deferred.await(entered);
657 yield* Scope.close(callerScope, Exit.void);
658 const concurrent = yield* Effect.all([installation.start, installation.start], {
659 concurrency: "unbounded",
660 });
661 expect(concurrent.map((state) => state.operationId)).toEqual([
662 started.operationId,
663 started.operationId,
664 ]);
665 expect(
666 yield* installation.changes.pipe(Stream.runHead, Effect.map(Option.getOrThrow)),
667 ).toMatchObject({ phase: "downloading", operationId: started.operationId });
668 yield* Deferred.succeed(release, undefined);
669 expect((yield* terminalState(installation)).phase).toBe("succeeded");
670 yield* Deferred.await(stagingReleased);
671
672 const next = yield* installation.start;
673 expect(next.operationId).not.toBe(started.operationId);
674 expect(
675 yield* installation
676 .cancel(started.operationId ?? "missing-operation-id")
677 .pipe(Effect.flip),
678 ).toMatchObject({ operation: "cancel" });
679 expect((yield* terminalState(installation)).phase).toBe("succeeded");
680 expect(requests).toHaveLength(1);
681 }),
682 );
683
684 it.effect("honors explicit paths and reports invalid overrides without falling back", () =>
685 Effect.gen(function* () {
686 const fs = yield* FileSystem.FileSystem;
687 const path = yield* Path.Path;
688 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-path-test-" });
689 const externalDirectory = path.join(baseDir, "external");
690 const externalExecutable = path.join(externalDirectory, "agy_acp_server.par");
691 const externalHarness = path.join(externalDirectory, "localharness_external");
692 yield* fs.makeDirectory(externalDirectory);
693 yield* fs.writeFileString(externalExecutable, "external server", { mode: 0o755 });
694 yield* fs.writeFileString(externalHarness, "external harness", { mode: 0o755 });
695 const { installation } = yield* makeHarness({
696 baseDir,
697 path: externalDirectory,
698 previous: true,
699 });
700 yield* expectPreviousRelease(installation);
701 expect(yield* installation.resolve(undefined, { PATH: externalDirectory })).toMatchObject({
702 source: "managed",
703 version: previousVersion,
704 });
705 expect(yield* installation.resolve(externalExecutable)).toMatchObject({
706 executablePath: externalExecutable,
707 source: "override",
708 managedVersionDirectory: null,
709 });
710 expect(yield* installation.resolve("agy_acp_server.par")).toMatchObject({
711 source: "override",
712 });
713 yield* fs.remove(externalHarness);
714 expect(yield* installation.resolve(externalExecutable).pipe(Effect.flip)).toMatchObject({
715 operation: "resolve",
716 });
717 expect(
718 yield* installation.resolve(path.join(baseDir, "missing")).pipe(Effect.flip),
719 ).toMatchObject({
720 operation: "resolve",
721 });
722 yield* expectPreviousRelease(installation);
723 yield* fs.writeFileString(externalHarness, "external harness", { mode: 0o755 });
724 yield* installation.remove();
725 expect(yield* installation.resolve()).toMatchObject({
726 source: "path",
727 executablePath: externalExecutable,
728 });
729 const isolated = yield* makeHarness({ baseDir });
730 expect(yield* isolated.installation.resolve().pipe(Effect.flip)).toMatchObject({
731 operation: "resolve",
732 });
733 expect(
734 yield* isolated.installation.resolve(undefined, { PATH: externalDirectory }),
735 ).toMatchObject({
736 source: "path",
737 executablePath: externalExecutable,
738 });
739 expect(
740 yield* isolated.installation.resolve("agy_acp_server.par", { PATH: externalDirectory }),
741 ).toMatchObject({ source: "override", executablePath: externalExecutable });
742 }),
743 );
744
745 it.effect("keeps leased releases available while new sessions resolve the new release", () =>
746 Effect.gen(function* () {
747 const { installation, fs, stagingReleased } = yield* makeHarness({ previous: true });
748 const processScope = yield* Scope.make();
749 const oldExecutable = yield* installation.acquire().pipe(Scope.provide(processScope));
750 yield* installation.start;
751 expect((yield* terminalState(installation)).phase).toBe("succeeded");
752 yield* Deferred.await(stagingReleased);
753 const current = yield* installation.resolve();
754 expect(current.version).toBe("fixture-new");
755 expect(current.executablePath).not.toBe(oldExecutable.executablePath);
756 expect(yield* fs.readFileString(oldExecutable.executablePath)).toBe(serverContents);
757 expect(yield* installation.remove().pipe(Effect.flip)).toMatchObject({ operation: "remove" });
758 yield* Scope.close(processScope, Exit.void);
759 yield* installation.remove();
760 expect(yield* fs.exists(installation.managedDirectory)).toBe(false);
761 }),
762 );
763
764 it.effect(
765 "removes an incomplete active release before reinstalling without a PATH fallback",
766 () =>
767 Effect.gen(function* () {
768 const { installation, baseDir, fs, path } = yield* makeHarness({ previous: true });
769 const previous = yield* installation.resolve();
770 yield* fs.remove(previous.harnessPath);
771 const externalDirectory = path.join(baseDir, "external");
772 yield* fs.makeDirectory(externalDirectory);
773 yield* fs.writeFileString(
774 path.join(externalDirectory, "agy_acp_server.par"),
775 "external server",
776 {
777 mode: 0o755,
778 },
779 );
780 yield* fs.writeFileString(
781 path.join(externalDirectory, "localharness_external"),
782 "external harness",
783 {
784 mode: 0o755,
785 },
786 );
787 const restarted = yield* makeHarness({ baseDir, path: externalDirectory });
788 expect(yield* restarted.installation.state).toMatchObject({
789 phase: "failed",
790 installedVersion: null,
791 canRemove: true,
792 });
793 expect(yield* restarted.installation.resolve().pipe(Effect.flip)).toMatchObject({
794 operation: "resolve",
795 });
796 yield* restarted.installation.remove();
797 expect(yield* restarted.installation.state).toMatchObject({
798 phase: "idle",
799 canRemove: false,
800 });
801 expect(yield* fs.exists(installation.managedDirectory)).toBe(false);
802 yield* restarted.installation.start;
803 expect(yield* terminalState(restarted.installation)).toMatchObject({
804 phase: "succeeded",
805 installedVersion: "fixture-new",
806 canRemove: true,
807 });
808 yield* Deferred.await(restarted.stagingReleased);
809 expect((yield* restarted.installation.resolve()).source).toBe("managed");
810 }),
811 );
812
813 it.effect(
814 "blocks removal of custom managed paths and leaves external executables and profiles intact",
815 () =>
816 Effect.gen(function* () {
817 const { installation, fs, path, baseDir } = yield* makeHarness({ previous: true });
818 const managed = yield* installation.resolve();
819 const externalDirectory = path.join(baseDir, "external");
820 const profileDirectory = path.join(baseDir, "providers", "antigravity", "profile");
821 yield* fs.makeDirectory(externalDirectory);
822 yield* fs.makeDirectory(profileDirectory, { recursive: true });
823 const externalExecutable = path.join(externalDirectory, "agy_acp_server.par");
824 const externalHarness = path.join(externalDirectory, "localharness_external");
825 const profilePath = path.join(profileDirectory, "preferences.json");
826 yield* fs.writeFileString(externalExecutable, "external server", { mode: 0o755 });
827 yield* fs.writeFileString(externalHarness, "external harness", { mode: 0o755 });
828 yield* fs.writeFileString(profilePath, "{}");
829 expect(
830 yield* installation.remove([managed.executablePath]).pipe(Effect.flip),
831 ).toMatchObject({
832 operation: "remove",
833 });
834 yield* expectPreviousRelease(installation);
835 yield* installation.remove([externalExecutable]);
836 expect(yield* installation.state).toMatchObject({
837 phase: "idle",
838 operationId: null,
839 installedVersion: null,
840 });
841 expect(yield* fs.readFileString(externalExecutable)).toBe("external server");
842 expect(yield* fs.readFileString(externalHarness)).toBe("external harness");
843 expect(yield* fs.readFileString(profilePath)).toBe("{}");
844 }),
845 );
846
847 it.effect(
848 "reuses an immutable Windows release and preserves the pointer when rename is denied",
849 () =>
850 Effect.gen(function* () {
851 const fs = yield* FileSystem.FileSystem;
852 const path = yield* Path.Path;
853 const archive = Buffer.from(zipFixtures.windows, "base64");
854 const asset = releaseAsset(archive, "win32");
855 let denyPointerRename = true;
856 const renameTargets: string[] = [];
857 const { installation, requests, validations } = yield* makeHarness({
858 previous: true,
859 platform: "win32",
860 archive,
861 fileSystem: FileSystem.FileSystem.of({
862 ...fs,
863 rename: (source, target) => {
864 renameTargets.push(target);
865 return denyPointerRename
866 ? Effect.fail(
867 PlatformError.systemError({
868 _tag: "PermissionDenied",
869 module: "FileSystem",
870 method: "rename",
871 pathOrDescriptor: target,
872 description: "EPERM: file is in use",
873 }),
874 )
875 : fs.rename(source, target);
876 },
877 }),
878 });
879 yield* writeRelease(installation.managedDirectory, asset, false);
880 yield* installation.start;
881 expect((yield* terminalState(installation)).phase).toBe("failed");
882 yield* expectPreviousRelease(installation);
883 expect(renameTargets).toEqual([path.join(installation.managedDirectory, "active.json")]);
884
885 denyPointerRename = false;
886 yield* installation.start;
887 expect((yield* terminalState(installation)).phase).toBe("succeeded");
888 expect((yield* installation.resolve()).version).toBe("fixture-new");
889 expect(requests).toEqual([]);
890 expect(validations).toHaveLength(2);
891 expect(renameTargets).toEqual([
892 path.join(installation.managedDirectory, "active.json"),
893 path.join(installation.managedDirectory, "active.json"),
894 ]);
895 }),
896 );
897
898 it.effect("reports unsupported hosts without downloading or changing state", () =>
899 Effect.gen(function* () {
900 const { installation, requests } = yield* makeHarness({ asset: null, platform: "darwin" });
901 expect(yield* installation.start.pipe(Effect.flip)).toMatchObject({ operation: "start" });
902 expect(yield* installation.resolve().pipe(Effect.flip)).toMatchObject({
903 operation: "resolve",
904 });
905 expect(yield* installation.state).toMatchObject({ phase: "idle", operationId: null });
906 expect(requests).toEqual([]);
907 }),
908 );
909});
apps/server/src/provider/AntigravityInstallation.ts created+950
......@@ -0,0 +1,950 @@
1// @effect-diagnostics nodeBuiltinImport:off - Effect has no incremental digest or free-space query.
2import * as EffectNodeStream from "@effect/platform-node/NodeStream";
3import { ProviderDriverKind, type ProviderInstallState } from "@t3tools/contracts";
4import {
5 HostProcessArchitecture,
6 HostProcessEnvironment,
7 HostProcessPlatform,
8} from "@t3tools/shared/hostProcess";
9import * as Clock from "effect/Clock";
10import * as Cause from "effect/Cause";
11import * as Context from "effect/Context";
12import * as Crypto from "effect/Crypto";
13import * as Effect from "effect/Effect";
14import * as Exit from "effect/Exit";
15import * as Fiber from "effect/Fiber";
16import * as FileSystem from "effect/FileSystem";
17import * as Layer from "effect/Layer";
18import * as Option from "effect/Option";
19import * as Path from "effect/Path";
20import * as Schema from "effect/Schema";
21import * as Scope from "effect/Scope";
22import * as Semaphore from "effect/Semaphore";
23import * as Stream from "effect/Stream";
24import * as SubscriptionRef from "effect/SubscriptionRef";
25import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
26import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
27import * as NodeCrypto from "node:crypto";
28import * as NodeFSP from "node:fs/promises";
29import type * as NodeStream from "node:stream";
30import * as Yauzl from "yauzl";
31
32import { ServerConfig } from "../config.ts";
33import { makeAntigravityAcpRuntime } from "./acp/AntigravityAcpSupport.ts";
34import {
35 buildAntigravityAcpSpawnInput,
36 prepareAntigravityProfile,
37} from "./antigravityAuthSupport.ts";
38import {
39 resolveAntigravityReleaseAsset,
40 type AntigravityReleaseAsset,
41} from "./antigravityRelease.ts";
42
43const DRIVER = ProviderDriverKind.make("antigravity");
44const DOWNLOAD_TIMEOUT = "45 minutes";
45const VALIDATION_TIMEOUT = "90 seconds";
46const FREE_SPACE_MARGIN = 256 * 1024 * 1024;
47const RECORD_MAX_BYTES = 8 * 1024;
48const RELEASE_RECORD = ".install-complete.json";
49
50const ReleaseId = Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/u));
51const ActiveRelease = Schema.Struct({ releaseId: ReleaseId });
52const InstalledRelease = Schema.Struct({
53 releaseId: ReleaseId,
54 version: Schema.String,
55 executable: Schema.Struct({ name: Schema.String, bytes: Schema.Number }),
56 harness: Schema.Struct({ name: Schema.String, bytes: Schema.Number }),
57});
58type InstalledRelease = typeof InstalledRelease.Type;
59const encodeActiveRelease = Schema.encodeEffect(Schema.fromJsonString(ActiveRelease));
60const encodeInstalledRelease = Schema.encodeEffect(Schema.fromJsonString(InstalledRelease));
61
62export class AntigravityInstallationError extends Schema.TaggedErrorClass<AntigravityInstallationError>()(
63 "AntigravityInstallationError",
64 {
65 operation: Schema.String,
66 detail: Schema.String,
67 cause: Schema.optional(Schema.Defect()),
68 },
69) {
70 override get message() {
71 return this.detail;
72 }
73}
74const isInstallationError = Schema.is(AntigravityInstallationError);
75
76export interface AntigravityExecutable {
77 readonly executablePath: string;
78 readonly harnessPath: string;
79 readonly source: "override" | "managed" | "path";
80 readonly version: string | null;
81 readonly managedVersionDirectory: string | null;
82}
83
84interface AntigravityInstallationService {
85 readonly managedDirectory: string;
86 readonly resolve: (
87 binaryPath?: string,
88 environment?: NodeJS.ProcessEnv,
89 ) => Effect.Effect<AntigravityExecutable, AntigravityInstallationError>;
90 /** Hold the lease until the spawned process has exited. */
91 readonly acquire: (
92 binaryPath?: string,
93 environment?: NodeJS.ProcessEnv,
94 ) => Effect.Effect<AntigravityExecutable, AntigravityInstallationError, Scope.Scope>;
95 readonly start: Effect.Effect<ProviderInstallState, AntigravityInstallationError>;
96 readonly cancel: (
97 operationId: string,
98 ) => Effect.Effect<ProviderInstallState, AntigravityInstallationError>;
99 readonly state: Effect.Effect<ProviderInstallState>;
100 readonly changes: Stream.Stream<ProviderInstallState>;
101 readonly remove: (
102 protectedBinaryPaths?: ReadonlyArray<string>,
103 ) => Effect.Effect<void, AntigravityInstallationError>;
104}
105
106export class AntigravityInstallation extends Context.Service<
107 AntigravityInstallation,
108 AntigravityInstallationService
109>()("t3/provider/AntigravityInstallation") {
110 static readonly layer = Layer.effect(
111 AntigravityInstallation,
112 Effect.gen(function* () {
113 const config = yield* ServerConfig;
114 return yield* makeAntigravityInstallation({ baseDir: config.baseDir });
115 }),
116 );
117}
118
119export interface AntigravityInstallationOptions {
120 readonly baseDir: string;
121 readonly releaseAsset?: AntigravityReleaseAsset | null;
122 readonly validate?: (
123 executable: AntigravityExecutable,
124 expectedVersion: string,
125 ) => Effect.Effect<void, AntigravityInstallationError, Scope.Scope>;
126}
127
128const installationError = (operation: string, detail: string, cause?: unknown) =>
129 new AntigravityInstallationError({
130 operation,
131 detail,
132 ...(cause === undefined ? {} : { cause }),
133 });
134
135const wrapFailure = (operation: string, detail: string) => (cause: unknown) =>
136 isInstallationError(cause) ? cause : installationError(operation, detail, cause);
137
138function executableNames(platform: NodeJS.Platform) {
139 return platform === "win32"
140 ? { executable: "agy_acp_server.exe", harness: "localharness_external.exe" }
141 : { executable: "agy_acp_server.par", harness: "localharness_external" };
142}
143
144function isRunning(state: ProviderInstallState) {
145 return (
146 state.phase === "downloading" || state.phase === "extracting" || state.phase === "verifying"
147 );
148}
149
150/** Open only a verified local archive. Entries stay lazy and extraction stays bounded. */
151const openArchive = Effect.fn("AntigravityInstallation.openArchive")(function* (
152 archivePath: string,
153) {
154 const opened = yield* Effect.acquireRelease(
155 Effect.callback<
156 {
157 readonly zip: Yauzl.ZipFile;
158 readonly error: () => AntigravityInstallationError | undefined;
159 readonly close: Effect.Effect<void>;
160 },
161 AntigravityInstallationError
162 >((resume) => {
163 Yauzl.open(
164 archivePath,
165 { lazyEntries: true, autoClose: false, validateEntrySizes: true, strictFileNames: true },
166 (error, zip) => {
167 if (error || !zip) {
168 resume(
169 Effect.fail(
170 installationError("extract", "Could not open the verified archive.", error),
171 ),
172 );
173 return;
174 }
175 let closed = false;
176 let archiveError: AntigravityInstallationError | undefined;
177 zip.on("close", () => {
178 closed = true;
179 });
180 zip.on("error", (cause: unknown) => {
181 archiveError = installationError("extract", "The archive could not be read.", cause);
182 });
183 resume(
184 Effect.succeed({
185 zip,
186 error: () => archiveError,
187 close: Effect.callback<void>((finish) => {
188 if (closed) {
189 finish(Effect.void);
190 return;
191 }
192 const onClose = () => {
193 zip.removeListener("error", onError);
194 finish(Effect.void);
195 };
196 const onError = (cause: unknown) => {
197 zip.removeListener("close", onClose);
198 finish(
199 Effect.die(installationError("extract", "Could not close the archive.", cause)),
200 );
201 };
202 zip.once("close", onClose);
203 zip.once("error", onError);
204 zip.close();
205 }),
206 }),
207 );
208 },
209 );
210 }),
211 (opened) => opened.close,
212 );
213
214 const next = Effect.callback<Yauzl.Entry | null, AntigravityInstallationError>((resume) => {
215 const existingError = opened.error();
216 if (existingError) {
217 resume(Effect.fail(existingError));
218 return;
219 }
220 const cleanup = () => {
221 opened.zip.removeListener("entry", onEntry);
222 opened.zip.removeListener("end", onEnd);
223 opened.zip.removeListener("error", onError);
224 };
225 const onEntry = (entry: Yauzl.Entry) => {
226 cleanup();
227 resume(Effect.succeed(entry));
228 };
229 const onEnd = () => {
230 cleanup();
231 resume(Effect.succeed(null));
232 };
233 const onError = (cause: unknown) => {
234 cleanup();
235 resume(Effect.fail(installationError("extract", "The archive could not be read.", cause)));
236 };
237 opened.zip.once("entry", onEntry);
238 opened.zip.once("end", onEnd);
239 opened.zip.once("error", onError);
240 opened.zip.readEntry();
241 return Effect.sync(cleanup);
242 });
243
244 const streamEntry = (entry: Yauzl.Entry) =>
245 Effect.acquireRelease(
246 Effect.callback<NodeStream.Readable, AntigravityInstallationError>((resume) => {
247 opened.zip.openReadStream(entry, (cause, readable) => {
248 resume(
249 cause || !readable
250 ? Effect.fail(
251 installationError("extract", "Could not read an archive member.", cause),
252 )
253 : Effect.succeed(readable),
254 );
255 });
256 }),
257 (readable) =>
258 Effect.sync(() => {
259 readable.destroy();
260 }),
261 );
262 return { entryCount: opened.zip.entryCount, next, streamEntry };
263});
264
265export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.make")(function* (
266 options: AntigravityInstallationOptions,
267) {
268 const fs = yield* FileSystem.FileSystem;
269 const path = yield* Path.Path;
270 const crypto = yield* Crypto.Crypto;
271 const http = yield* HttpClient.HttpClient;
272 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
273 const serviceScope = yield* Effect.scope;
274 const platform = yield* HostProcessPlatform;
275 const arch = yield* HostProcessArchitecture;
276 const environment = yield* HostProcessEnvironment;
277 const releaseAsset =
278 options.releaseAsset === undefined
279 ? resolveAntigravityReleaseAsset(platform, arch)
280 : options.releaseAsset;
281 const names = executableNames(platform);
282 const managedDirectory = path.join(
283 options.baseDir,
284 "tools",
285 "antigravity-acp",
286 `${platform}-${arch}`,
287 );
288 const versionsDirectory = path.join(managedDirectory, "versions");
289 const activePath = path.join(managedDirectory, "active.json");
290 const gate = yield* Semaphore.make(1);
291 const leases = new Map<string, number>();
292 let running: { readonly operationId: string; readonly fiber: Fiber.Fiber<void> } | undefined;
293 const state = yield* SubscriptionRef.make<ProviderInstallState>({
294 driver: DRIVER,
295 operationId: null,
296 phase: "idle",
297 downloadedBytes: 0,
298 totalBytes: releaseAsset?.archiveBytes ?? null,
299 version: releaseAsset?.version ?? null,
300 installedVersion: null,
301 canRemove: false,
302 message: null,
303 });
304
305 const readRecord = Effect.fn("AntigravityInstallation.readRecord")(function* <A>(
306 filePath: string,
307 schema: Schema.Codec<A>,
308 ) {
309 const info = yield* fs.stat(filePath);
310 if (info.type !== "File" || Number(info.size) > RECORD_MAX_BYTES) {
311 return yield* installationError(
312 "resolve",
313 "The managed runtime record is invalid. Reinstall Antigravity.",
314 );
315 }
316 const contents = yield* fs.readFileString(filePath);
317 return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(contents);
318 });
319
320 const executableFile = Effect.fn("AntigravityInstallation.executableFile")(function* (
321 filePath: string,
322 bytes?: number,
323 ) {
324 const info = yield* fs.stat(filePath).pipe(Effect.option);
325 return (
326 Option.isSome(info) &&
327 info.value.type === "File" &&
328 (bytes === undefined || Number(info.value.size) === bytes) &&
329 (platform === "win32" || (info.value.mode & 0o111) !== 0)
330 );
331 });
332
333 const completedRelease = Effect.fn("AntigravityInstallation.completedRelease")(function* (
334 releaseId: string,
335 ) {
336 const directory = path.join(versionsDirectory, releaseId);
337 const record = yield* readRecord(path.join(directory, RELEASE_RECORD), InstalledRelease);
338 if (
339 record.releaseId !== releaseId ||
340 record.executable.name !== names.executable ||
341 record.harness.name !== names.harness ||
342 !Number.isSafeInteger(record.executable.bytes) ||
343 record.executable.bytes <= 0 ||
344 !Number.isSafeInteger(record.harness.bytes) ||
345 record.harness.bytes <= 0 ||
346 !record.version.trim() ||
347 !(yield* executableFile(path.join(directory, names.executable), record.executable.bytes)) ||
348 !(yield* executableFile(path.join(directory, names.harness), record.harness.bytes))
349 ) {
350 return yield* installationError(
351 "resolve",
352 "The managed Antigravity runtime is incomplete. Reinstall it.",
353 );
354 }
355 return {
356 executablePath: path.join(directory, names.executable),
357 harnessPath: path.join(directory, names.harness),
358 source: "managed",
359 version: record.version,
360 managedVersionDirectory: directory,
361 } satisfies AntigravityExecutable;
362 });
363
364 const fromExternal = Effect.fn("AntigravityInstallation.fromExternal")(function* (
365 candidate: string,
366 source: "override" | "path",
367 ) {
368 if (!(yield* executableFile(candidate))) return null;
369 const executablePath = yield* fs.realPath(candidate);
370 const directory = path.dirname(executablePath);
371 const harnessPath = path.join(directory, names.harness);
372 if (!(yield* executableFile(harnessPath))) return null;
373 const realVersions = yield* fs.realPath(versionsDirectory).pipe(Effect.option);
374 if (
375 Option.isSome(realVersions) &&
376 path.dirname(directory) === realVersions.value &&
377 /^[a-f0-9]{64}$/u.test(path.basename(directory))
378 ) {
379 const installed = yield* completedRelease(path.basename(directory));
380 return { ...installed, executablePath, harnessPath, source } satisfies AntigravityExecutable;
381 }
382 return {
383 executablePath,
384 harnessPath,
385 source,
386 version: null,
387 managedVersionDirectory: null,
388 } satisfies AntigravityExecutable;
389 });
390
391 const pathCandidates = (binary: string, processEnvironment = environment) => {
392 const pathValue =
393 platform === "win32"
394 ? Object.entries(processEnvironment).findLast(([key]) => key.toUpperCase() === "PATH")?.[1]
395 : processEnvironment.PATH;
396 return (pathValue ?? "")
397 .split(platform === "win32" ? ";" : ":")
398 .map((directory) => directory.trim().replace(/^"|"$/gu, ""))
399 .filter((directory) => directory.length > 0)
400 .map((directory) => path.resolve(directory, binary));
401 };
402
403 const resolve: AntigravityInstallationService["resolve"] = Effect.fn(
404 "AntigravityInstallation.resolve",
405 )(
406 function* (binaryPath?: string, processEnvironment?: NodeJS.ProcessEnv) {
407 const override = binaryPath?.trim();
408 if (override) {
409 const candidates =
410 path.isAbsolute(override) || override.includes("/") || override.includes("\\")
411 ? [path.resolve(override)]
412 : pathCandidates(override, processEnvironment);
413 for (const candidate of candidates) {
414 const selected = yield* fromExternal(candidate, "override");
415 if (selected) return selected;
416 }
417 return yield* installationError(
418 "resolve",
419 "The custom Antigravity executable or its localharness_external sibling is missing or not executable.",
420 );
421 }
422 if (yield* fs.exists(activePath)) {
423 const active = yield* readRecord(activePath, ActiveRelease);
424 return yield* completedRelease(active.releaseId);
425 }
426 for (const candidate of pathCandidates(names.executable, processEnvironment)) {
427 const selected = yield* fromExternal(candidate, "path");
428 if (selected) return selected;
429 }
430 return yield* installationError(
431 "resolve",
432 releaseAsset
433 ? "Antigravity is not installed. Install it in this environment or set a custom executable path."
434 : `Google does not publish an Antigravity runtime for ${platform}-${arch}. Use a supported environment or a custom executable.`,
435 );
436 },
437 Effect.mapError(
438 wrapFailure(
439 "resolve",
440 "Could not read the Antigravity installation. Reinstall it or set a custom executable path.",
441 ),
442 ),
443 );
444
445 const acquire = (binaryPath?: string, processEnvironment?: NodeJS.ProcessEnv) =>
446 Effect.acquireRelease(
447 gate.withPermit(
448 Effect.gen(function* () {
449 const executable = yield* resolve(binaryPath, processEnvironment);
450 const directory = executable.managedVersionDirectory;
451 if (directory) leases.set(directory, (leases.get(directory) ?? 0) + 1);
452 return executable;
453 }),
454 ),
455 (executable) =>
456 gate.withPermit(
457 Effect.sync(() => {
458 const directory = executable.managedVersionDirectory;
459 if (!directory) return;
460 const remaining = (leases.get(directory) ?? 1) - 1;
461 if (remaining > 0) leases.set(directory, remaining);
462 else leases.delete(directory);
463 }),
464 ),
465 );
466
467 const validate =
468 options.validate ??
469 Effect.fn("AntigravityInstallation.validate")(
470 function* (executable: AntigravityExecutable, expectedVersion: string) {
471 const profileDirectory = yield* fs.makeTempDirectoryScoped({
472 prefix: "t3-antigravity-validate-",
473 });
474 const profile = yield* prepareAntigravityProfile({
475 profileDirectory,
476 platform,
477 baseEnv: environment,
478 });
479 const runtime = yield* makeAntigravityAcpRuntime({
480 spawn: buildAntigravityAcpSpawnInput({
481 installation: executable,
482 profile,
483 cwd: profileDirectory,
484 baseEnv: environment,
485 }),
486 cwd: profileDirectory,
487 childProcessSpawner: spawner,
488 clientInfo: { name: "t3-code", version: "0.0.0" },
489 });
490 const initialized = yield* runtime.initialize();
491 if (
492 initialized.agentInfo?.name !== "antigravity-acp" ||
493 initialized.agentInfo.version !== expectedVersion ||
494 initialized.protocolVersion !== 1 ||
495 initialized.agentCapabilities?.loadSession !== true ||
496 !initialized.agentCapabilities.sessionCapabilities?.resume ||
497 !initialized.agentCapabilities.auth?.logout ||
498 !initialized.authMethods?.some((method) => method.id === "oauth-personal")
499 ) {
500 return yield* installationError(
501 "verify",
502 "The downloaded runtime did not identify as the expected Google Antigravity release.",
503 );
504 }
505 },
506 Effect.provideService(FileSystem.FileSystem, fs),
507 Effect.provideService(Path.Path, path),
508 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
509 Effect.provideService(Crypto.Crypto, crypto),
510 Effect.mapError(
511 wrapFailure(
512 "verify",
513 "The downloaded Antigravity runtime could not start in this environment.",
514 ),
515 ),
516 );
517
518 const install = Effect.fn("AntigravityInstallation.install")(
519 function* (asset: AntigravityReleaseAsset) {
520 const report = (phase: ProviderInstallState["phase"], message: string | null) =>
521 SubscriptionRef.update(state, (current) => ({ ...current, phase, message }));
522 yield* fs.makeDirectory(versionsDirectory, { recursive: true });
523 yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove: true }));
524 const destination = path.join(versionsDirectory, asset.sha256);
525 const activate = Effect.fn("AntigravityInstallation.activate")(
526 function* () {
527 const pointerDirectory = yield* fs.makeTempDirectoryScoped({
528 directory: managedDirectory,
529 prefix: "active.json.",
530 });
531 const pointerPath = path.join(pointerDirectory, "contents.tmp");
532 yield* fs.writeFileString(
533 pointerPath,
534 yield* encodeActiveRelease({ releaseId: asset.sha256 }),
535 { flag: "wx", mode: 0o600 },
536 );
537 yield* fs.rename(pointerPath, activePath);
538 // The pointer commits the install. Later temp cleanup cannot undo it.
539 yield* SubscriptionRef.update(
540 state,
541 (current) =>
542 ({
543 ...current,
544 phase: "succeeded",
545 installedVersion: asset.version,
546 message: null,
547 }) satisfies ProviderInstallState,
548 );
549 },
550 Effect.scoped,
551 Effect.mapError(
552 wrapFailure(
553 "activate",
554 "Could not activate Antigravity. The previous runtime is unchanged. Check for locked files and try again.",
555 ),
556 ),
557 Effect.uninterruptible,
558 );
559
560 if (yield* fs.exists(destination)) {
561 const existing = yield* completedRelease(asset.sha256);
562 if (existing.version !== asset.version) {
563 return yield* installationError(
564 "verify",
565 "The existing managed release has the wrong version. Remove it before reinstalling.",
566 );
567 }
568 yield* report("verifying", "Checking the installed runtime.");
569 yield* validate(existing, asset.version).pipe(
570 Effect.scoped,
571 Effect.timeout(VALIDATION_TIMEOUT),
572 );
573 yield* activate();
574 return;
575 }
576
577 const available = yield* Effect.tryPromise(() =>
578 NodeFSP.statfs(versionsDirectory, { bigint: true }),
579 ).pipe(Effect.option);
580 const required =
581 asset.archiveBytes + asset.executable.bytes + asset.harness.bytes + FREE_SPACE_MARGIN;
582 if (
583 Option.isSome(available) &&
584 available.value.bavail * available.value.bsize < BigInt(required)
585 ) {
586 return yield* installationError(
587 "download",
588 `Antigravity needs at least ${Math.ceil(required / 1024 / 1024)} MiB of free space to install.`,
589 );
590 }
591 const staging = yield* fs.makeTempDirectoryScoped({
592 directory: versionsDirectory,
593 prefix: ".install-",
594 });
595 const archivePath = path.join(staging, "download.zip");
596 const pairDirectory = path.join(staging, "runtime");
597 yield* fs.makeDirectory(pairDirectory);
598 const hash = NodeCrypto.createHash("sha256");
599 let downloadedBytes = 0;
600 let lastProgressAt = yield* Clock.currentTimeMillis;
601 yield* Effect.gen(function* () {
602 const response = yield* http
603 .execute(HttpClientRequest.get(asset.url))
604 .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk));
605 // dl.google.com gzips the zip when the client accepts it, so
606 // `content-length` is the encoded size. The decoded stream is still
607 // checked against the pinned byte count and hash below.
608 const contentLength = response.headers["content-length"];
609 const contentEncoding = response.headers["content-encoding"]?.trim().toLowerCase();
610 const identityBody = contentEncoding === undefined || contentEncoding === "identity";
611 if (
612 identityBody &&
613 contentLength !== undefined &&
614 Number(contentLength) !== asset.archiveBytes
615 ) {
616 return yield* installationError(
617 "download",
618 "The Antigravity download size did not match the pinned release.",
619 );
620 }
621 yield* response.stream.pipe(
622 Stream.tap((chunk) =>
623 Effect.gen(function* () {
624 downloadedBytes += chunk.byteLength;
625 if (downloadedBytes > asset.archiveBytes) {
626 return yield* installationError(
627 "download",
628 "The Antigravity download exceeded the pinned release size.",
629 );
630 }
631 hash.update(chunk);
632 const now = yield* Clock.currentTimeMillis;
633 if (now - lastProgressAt >= 250 || downloadedBytes === asset.archiveBytes) {
634 lastProgressAt = now;
635 yield* SubscriptionRef.update(state, (current) => ({
636 ...current,
637 downloadedBytes,
638 }));
639 }
640 }),
641 ),
642 Stream.run(fs.sink(archivePath, { flag: "wx", mode: 0o600 })),
643 );
644 }).pipe(Effect.timeout(DOWNLOAD_TIMEOUT));
645 if (downloadedBytes !== asset.archiveBytes || hash.digest("hex") !== asset.sha256) {
646 return yield* installationError(
647 "download",
648 "The Antigravity download failed its size or SHA-256 check. Nothing was installed.",
649 );
650 }
651
652 yield* report("extracting", "Extracting the verified runtime.");
653 yield* Effect.gen(function* () {
654 const archive = yield* openArchive(archivePath);
655 if (archive.entryCount !== 2) {
656 return yield* installationError(
657 "extract",
658 "The archive must contain exactly the Antigravity executable and its harness.",
659 );
660 }
661 const seen = new Set<string>();
662 for (;;) {
663 const entry = yield* archive.next;
664 if (!entry) break;
665 const expected = [asset.executable, asset.harness].find(
666 (file) => file.name === entry.fileName,
667 );
668 const unixType = (entry.externalFileAttributes >>> 16) & 0o170000;
669 if (
670 !expected ||
671 seen.has(entry.fileName) ||
672 entry.fileName.includes("/") ||
673 entry.fileName.includes("\\") ||
674 (unixType !== 0 && unixType !== 0o100000) ||
675 (entry.externalFileAttributes & 0x10) !== 0 ||
676 (entry.generalPurposeBitFlag & 1) !== 0 ||
677 ![0, 8].includes(entry.compressionMethod) ||
678 entry.uncompressedSize !== expected.bytes
679 ) {
680 return yield* installationError(
681 "extract",
682 "The archive contains an unexpected, unsafe, or incorrectly sized member.",
683 );
684 }
685 seen.add(entry.fileName);
686 yield* Effect.gen(function* () {
687 const readable = yield* archive.streamEntry(entry);
688 let extractedBytes = 0;
689 yield* EffectNodeStream.fromReadable<Uint8Array, AntigravityInstallationError>({
690 evaluate: () => readable,
691 onError: wrapFailure("extract", "Could not extract the Antigravity runtime."),
692 }).pipe(
693 Stream.tap((chunk) =>
694 Effect.gen(function* () {
695 extractedBytes += chunk.byteLength;
696 if (extractedBytes > expected.bytes) {
697 return yield* installationError(
698 "extract",
699 "An archive member exceeded its pinned size.",
700 );
701 }
702 }),
703 ),
704 Stream.run(
705 fs.sink(path.join(pairDirectory, entry.fileName), { flag: "wx", mode: 0o700 }),
706 ),
707 );
708 if (extractedBytes !== expected.bytes) {
709 return yield* installationError("extract", "An archive member was truncated.");
710 }
711 }).pipe(Effect.scoped);
712 }
713 if (!seen.has(asset.executable.name) || !seen.has(asset.harness.name)) {
714 return yield* installationError(
715 "extract",
716 "The archive is missing the Antigravity executable or its harness.",
717 );
718 }
719 }).pipe(Effect.scoped);
720 yield* fs.remove(archivePath);
721 if (platform !== "win32") {
722 yield* fs.chmod(path.join(pairDirectory, asset.executable.name), 0o755);
723 yield* fs.chmod(path.join(pairDirectory, asset.harness.name), 0o755);
724 }
725 yield* report("verifying", "Checking the downloaded runtime.");
726 yield* validate(
727 {
728 executablePath: path.join(pairDirectory, asset.executable.name),
729 harnessPath: path.join(pairDirectory, asset.harness.name),
730 source: "managed",
731 version: asset.version,
732 managedVersionDirectory: pairDirectory,
733 },
734 asset.version,
735 ).pipe(Effect.scoped, Effect.timeout(VALIDATION_TIMEOUT));
736 const record: InstalledRelease = {
737 releaseId: asset.sha256,
738 version: asset.version,
739 executable: asset.executable,
740 harness: asset.harness,
741 };
742 yield* fs.writeFileString(
743 path.join(pairDirectory, RELEASE_RECORD),
744 yield* encodeInstalledRelease(record),
745 { flag: "wx", mode: 0o600 },
746 );
747 yield* fs.rename(pairDirectory, destination).pipe(
748 Effect.catch((cause) =>
749 completedRelease(asset.sha256).pipe(
750 Effect.flatMap((existing) =>
751 existing.version === asset.version
752 ? validate(existing, asset.version).pipe(
753 Effect.scoped,
754 Effect.timeout(VALIDATION_TIMEOUT),
755 )
756 : Effect.fail(
757 installationError(
758 "activate",
759 "Another installation published a different Antigravity release.",
760 ),
761 ),
762 ),
763 Effect.mapError(() =>
764 installationError(
765 "activate",
766 "Could not publish the Antigravity runtime. The previous release is unchanged. Try again.",
767 cause,
768 ),
769 ),
770 ),
771 ),
772 );
773 yield* activate();
774 },
775 Effect.scoped,
776 Effect.mapError(
777 wrapFailure(
778 "install",
779 "Could not install Antigravity. Check free disk space and directory access, then try again.",
780 ),
781 ),
782 );
783
784 const start = gate
785 .withPermit(
786 Effect.gen(function* () {
787 const current = yield* SubscriptionRef.get(state);
788 if (isRunning(current)) return current;
789 if (!releaseAsset) {
790 return yield* installationError(
791 "start",
792 `Google does not publish an Antigravity runtime for ${platform}-${arch}. Use a supported remote environment or a custom executable.`,
793 );
794 }
795 const operationId = yield* crypto.randomUUIDv4;
796 const next: ProviderInstallState = {
797 driver: DRIVER,
798 operationId,
799 phase: "downloading",
800 downloadedBytes: 0,
801 totalBytes: releaseAsset.archiveBytes,
802 version: releaseAsset.version,
803 installedVersion: current.installedVersion,
804 canRemove: current.canRemove,
805 message: "Downloading Google's official Antigravity runtime.",
806 };
807 yield* SubscriptionRef.set(state, next);
808 const work = install(releaseAsset).pipe(
809 Effect.onExit((exit) =>
810 Exit.isFailure(exit)
811 ? SubscriptionRef.update(state, (value) => {
812 if (value.operationId !== operationId || value.phase === "succeeded")
813 return value;
814 const error = Cause.findErrorOption(exit.cause);
815 const cancelled = Cause.hasInterruptsOnly(exit.cause);
816 return {
817 ...value,
818 phase: cancelled ? "cancelled" : "failed",
819 message: cancelled
820 ? "Installation cancelled. The previous runtime is unchanged."
821 : Option.isSome(error)
822 ? error.value.detail
823 : "Could not finish the Antigravity installation. Check disk space and directory access.",
824 } satisfies ProviderInstallState;
825 })
826 : Effect.void,
827 ),
828 Effect.ignoreCause,
829 Effect.ensuring(
830 Effect.sync(() => {
831 if (running?.operationId === operationId) running = undefined;
832 }),
833 ),
834 );
835 const fiber = yield* Effect.forkIn(Effect.interruptible(work), serviceScope);
836 running = { operationId, fiber };
837 return next;
838 }).pipe(Effect.uninterruptible),
839 )
840 .pipe(Effect.mapError(wrapFailure("start", "Could not start the Antigravity installation.")));
841
842 const cancel = Effect.fn("AntigravityInstallation.cancel")(function* (operationId: string) {
843 return yield* gate.withPermit(
844 Effect.gen(function* () {
845 const current = yield* SubscriptionRef.get(state);
846 if (current.operationId !== operationId) {
847 return yield* installationError(
848 "cancel",
849 "This installation is no longer current. Refresh its status before cancelling.",
850 );
851 }
852 if (running?.operationId === operationId && isRunning(current)) {
853 yield* Fiber.interrupt(running.fiber);
854 }
855 return yield* SubscriptionRef.get(state);
856 }),
857 );
858 });
859
860 const remove = Effect.fn("AntigravityInstallation.remove")(
861 function* (protectedBinaryPaths: ReadonlyArray<string> = []) {
862 yield* gate.withPermit(
863 Effect.gen(function* () {
864 if (isRunning(yield* SubscriptionRef.get(state)) || leases.size > 0) {
865 return yield* installationError(
866 "remove",
867 "Stop Antigravity sessions and sign-in flows before removing its managed runtime.",
868 );
869 }
870 const realManaged = yield* fs.realPath(managedDirectory).pipe(Effect.option);
871 if (Option.isSome(realManaged)) {
872 for (const binaryPath of protectedBinaryPaths) {
873 if (!binaryPath.trim()) continue;
874 const selected = yield* resolve(binaryPath).pipe(Effect.option);
875 if (Option.isSome(selected) && selected.value.managedVersionDirectory) {
876 return yield* installationError(
877 "remove",
878 "A provider instance has a custom path inside this managed runtime. Clear that path before removing it.",
879 );
880 }
881 const resolved = yield* fs.realPath(binaryPath).pipe(Effect.option);
882 const candidate = Option.getOrElse(resolved, () => path.resolve(binaryPath));
883 if (candidate.startsWith(`${realManaged.value}${path.sep}`)) {
884 return yield* installationError(
885 "remove",
886 "A provider instance has a custom path inside this managed runtime. Clear that path before removing it.",
887 );
888 }
889 }
890 }
891 yield* fs.remove(managedDirectory, { recursive: true, force: true });
892 yield* SubscriptionRef.update(
893 state,
894 (current) =>
895 ({
896 ...current,
897 operationId: null,
898 phase: "idle",
899 downloadedBytes: 0,
900 installedVersion: null,
901 canRemove: false,
902 message: null,
903 }) satisfies ProviderInstallState,
904 );
905 }).pipe(Effect.uninterruptible),
906 );
907 },
908 Effect.mapError(
909 wrapFailure(
910 "remove",
911 "Could not remove the managed Antigravity runtime. Check for open processes and try again.",
912 ),
913 ),
914 );
915
916 yield* Effect.gen(function* () {
917 const canRemove = yield* fs.exists(managedDirectory);
918 yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove }));
919 if (!(yield* fs.exists(activePath))) return;
920 const active = yield* readRecord(activePath, ActiveRelease);
921 const installed = yield* completedRelease(active.releaseId);
922 yield* SubscriptionRef.update(state, (current) => ({
923 ...current,
924 installedVersion: installed.version,
925 }));
926 }).pipe(
927 Effect.catch(() =>
928 SubscriptionRef.update(
929 state,
930 (current) =>
931 ({
932 ...current,
933 phase: "failed",
934 message: "The managed Antigravity runtime is incomplete. Remove it and reinstall.",
935 }) satisfies ProviderInstallState,
936 ),
937 ),
938 );
939
940 return AntigravityInstallation.of({
941 managedDirectory,
942 resolve,
943 acquire,
944 start,
945 cancel,
946 state: SubscriptionRef.get(state),
947 changes: SubscriptionRef.changes(state),
948 remove,
949 });
950});
apps/server/src/provider/Drivers/AntigravityDriver.test.ts created+414
......@@ -0,0 +1,414 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import {
4 ANTIGRAVITY_DEFAULT_MODEL,
5 ProviderInstanceId,
6 type AntigravitySettings,
7} from "@t3tools/contracts";
8import {
9 HostProcessEnvironment,
10 HostProcessExecutablePath,
11 HostProcessPlatform,
12} from "@t3tools/shared/hostProcess";
13import * as Effect from "effect/Effect";
14import * as FileSystem from "effect/FileSystem";
15import * as Layer from "effect/Layer";
16import * as Path from "effect/Path";
17import * as Schema from "effect/Schema";
18import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
19
20import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
21import { ServerConfig } from "../../config.ts";
22import { ServerSettingsService } from "../../serverSettings.ts";
23import {
24 AntigravityInstallation,
25 AntigravityInstallationError,
26 type AntigravityExecutable,
27} from "../AntigravityInstallation.ts";
28import {
29 ANTIGRAVITY_AUTH_STDOUT_PREFIX,
30 resolveAntigravityProfileDirectory,
31} from "../antigravityAuthSupport.ts";
32import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
33import * as ModelManifest from "../ModelManifest.ts";
34import { AntigravityDriver } from "./AntigravityDriver.ts";
35
36const hostPlatform = HostProcessPlatform.defaultValue();
37const windowsHost = hostPlatform === "win32";
38const decodeRequest = Schema.decodeEffect(
39 Schema.fromJsonString(
40 Schema.Struct({
41 method: Schema.optional(Schema.String),
42 params: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
43 }),
44 ),
45);
46const blockedCredentialKeys = new Set([
47 "GEMINI_API_KEY",
48 "GOOGLE_API_KEY",
49 "GOOGLE_APPLICATION_CREDENTIALS",
50 "GOOGLE_GENAI_USE_VERTEXAI",
51]);
52
53function shellQuote(value: string): string {
54 return `'${value.replaceAll("'", `'"'"'`)}'`;
55}
56
57const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* (
58 options: { readonly config?: Partial<AntigravitySettings> } = {},
59) {
60 const fs = yield* FileSystem.FileSystem;
61 const path = yield* Path.Path;
62 const config = yield* ServerConfig;
63 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
64 const nodePath = yield* HostProcessExecutablePath;
65 const baseEnv = yield* HostProcessEnvironment;
66 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-antigravity-driver-" });
67 const instanceId = ProviderInstanceId.make(path.basename(root));
68 const mockAgentPath = yield* path.fromFileUrl(
69 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
70 );
71 const requestLog = path.join(root, "requests.jsonl");
72 const profileDirectory = resolveAntigravityProfileDirectory(config.stateDir, instanceId);
73 const instancePath = `${path.join(root, "instance-bin")}:${baseEnv.PATH ?? ""}`;
74
75 const makeExecutable = Effect.fn("AntigravityDriverTest.makeExecutable")(function* (
76 name: string,
77 loginRequired = false,
78 ) {
79 const directory = path.join(root, name);
80 const executablePath = path.join(directory, "agy_acp_server.par");
81 const harnessPath = path.join(directory, "localharness_external");
82 yield* fs.makeDirectory(directory, { recursive: true });
83 const authorizationUrl =
84 "https://accounts.google.com/o/oauth2/v2/auth?response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A51234%2F&state=fixture-state";
85 yield* fs.writeFileString(
86 executablePath,
87 [
88 "#!/bin/sh",
89 ...(loginRequired
90 ? [`printf '%s\\n' ${shellQuote(ANTIGRAVITY_AUTH_STDOUT_PREFIX + authorizationUrl)}`]
91 : []),
92 `exec ${shellQuote(nodePath)} ${shellQuote(mockAgentPath)} "$@"`,
93 "",
94 ].join("\n"),
95 );
96 yield* fs.writeFileString(harnessPath, "#!/bin/sh\nexit 99\n");
97 yield* fs.chmod(executablePath, 0o755);
98 yield* fs.chmod(harnessPath, 0o755);
99 return {
100 executablePath,
101 harnessPath,
102 source: "managed",
103 version: name,
104 managedVersionDirectory: directory,
105 } satisfies AntigravityExecutable;
106 });
107
108 const first = yield* makeExecutable("runtime 'one");
109 const second = yield* makeExecutable("runtime two");
110 const signedOut = yield* makeExecutable("runtime signed-out", true);
111 const controls = { selected: first, failResolution: false };
112 const acquisitions: Array<{ binaryPath: string | undefined; path: string | undefined }> = [];
113 const releases: Array<string | null> = [];
114 const launches: Array<{
115 command: string;
116 args: ReadonlyArray<string>;
117 cwd: string | undefined;
118 extendEnv: boolean | undefined;
119 profileDirectory: string | undefined;
120 harnessPath: string | undefined;
121 forceFileStorage: string | undefined;
122 credentialKeys: ReadonlyArray<string>;
123 geminiApiKey: string | undefined;
124 handle: ChildProcessSpawner.ChildProcessHandle;
125 }> = [];
126
127 const installation = Layer.mock(AntigravityInstallation)({
128 managedDirectory: root,
129 acquire: (binaryPath, environment) =>
130 Effect.gen(function* () {
131 acquisitions.push({ binaryPath, path: environment?.PATH });
132 if (controls.failResolution) {
133 return yield* new AntigravityInstallationError({
134 operation: "resolve",
135 detail: "Fixture resolution failed.",
136 });
137 }
138 const selected = controls.selected;
139 yield* Effect.addFinalizer(() =>
140 Effect.sync(() => {
141 releases.push(selected.version);
142 }),
143 );
144 return selected;
145 }),
146 });
147 const observedSpawner = ChildProcessSpawner.make((command) =>
148 Effect.gen(function* () {
149 if (command._tag !== "StandardCommand")
150 return yield* Effect.die("Unexpected process pipeline.");
151 const handle = yield* spawner.spawn(command);
152 const environment = command.options.env ?? {};
153 launches.push({
154 command: command.command,
155 args: command.args,
156 cwd: command.options.cwd,
157 extendEnv: command.options.extendEnv,
158 profileDirectory: environment.GEMINI_HOME,
159 harnessPath: environment.ANTIGRAVITY_HARNESS_PATH,
160 forceFileStorage: environment.AGY_ACP_FORCE_FILE_STORAGE,
161 credentialKeys: Object.keys(environment).filter((key) =>
162 blockedCredentialKeys.has(key.toUpperCase()),
163 ),
164 geminiApiKey: environment.GEMINI_API_KEY,
165 handle,
166 });
167 return handle;
168 }),
169 );
170 const instance = yield* AntigravityDriver.create({
171 instanceId,
172 displayName: "Google test account",
173 enabled: false,
174 config: { ...AntigravityDriver.defaultConfig(), ...options.config },
175 environment: [
176 { name: "PATH", value: instancePath },
177 { name: "T3_ACP_ANTIGRAVITY", value: "1" },
178 { name: "T3_ACP_REQUEST_LOG_PATH", value: requestLog },
179 { name: "GEMINI_API_KEY", value: "must-not-be-used" },
180 { name: "google_api_key", value: "must-not-be-used" },
181 { name: "GOOGLE_APPLICATION_CREDENTIALS", value: "/must-not-be-used.json" },
182 { name: "GOOGLE_GENAI_USE_VERTEXAI", value: "true" },
183 { name: "GEMINI_HOME", value: "/must-not-be-used" },
184 { name: "ANTIGRAVITY_HARNESS_PATH", value: "/must-not-be-used" },
185 { name: "BROWSER", value: "must-not-run" },
186 ].map((variable) => ({ ...variable, sensitive: false })),
187 }).pipe(
188 Effect.provide(installation),
189 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, observedSpawner),
190 );
191 const refresh = instance.refreshModels;
192 if (!refresh) return yield* Effect.die("Antigravity does not expose model refresh.");
193 const readRequests = Effect.gen(function* () {
194 if (!(yield* fs.exists(requestLog))) return [];
195 const text = yield* fs.readFileString(requestLog);
196 return yield* Effect.forEach(text.split(/\r?\n/u).filter(Boolean), (line) =>
197 decodeRequest(line),
198 );
199 });
200 const assertClosed = Effect.gen(function* () {
201 for (const launch of launches) {
202 // Cancelled startup can report a signal instead of a numeric exit code.
203 yield* launch.handle.exitCode.pipe(Effect.ignore);
204 expect(yield* launch.handle.isRunning).toBe(false);
205 if (launch.cwd) expect(yield* fs.exists(launch.cwd)).toBe(false);
206 }
207 });
208 return {
209 instance,
210 refresh,
211 fs,
212 profileDirectory,
213 instancePath,
214 first,
215 second,
216 signedOut,
217 controls,
218 acquisitions,
219 releases,
220 launches,
221 readRequests,
222 assertClosed,
223 };
224});
225
226const testLayer = ServerConfig.layerTest(process.cwd(), {
227 prefix: "t3-antigravity-driver-config-",
228}).pipe(
229 Layer.provideMerge(NodeServices.layer),
230 Layer.provideMerge(ServerSettingsService.layerTest()),
231 Layer.provideMerge(
232 Layer.mock(BackgroundPolicy.BackgroundPolicy)({
233 shouldRunScopeWork: () => Effect.succeed(false),
234 }),
235 ),
236 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
237 Layer.provideMerge(ModelManifest.layerTest),
238);
239
240it.layer(testLayer)("AntigravityDriver", (it) => {
241 it.effect.skipIf(windowsHost)("does not launch a process for a disabled instance", () =>
242 Effect.gen(function* () {
243 const h = yield* makeHarness();
244 const snapshot = yield* h.instance.snapshot.refresh;
245 expect(snapshot.status).toBe("disabled");
246 expect(h.acquisitions).toEqual([]);
247 expect(h.launches).toEqual([]);
248 expect(yield* h.fs.exists(h.profileDirectory)).toBe(false);
249 }).pipe(Effect.scoped),
250 );
251
252 it.effect.skipIf(windowsHost)(
253 "refreshes a disabled instance through the selected executable and personal Google ACP",
254 () =>
255 Effect.gen(function* () {
256 const h = yield* makeHarness();
257 yield* h.refresh();
258 const snapshot = yield* h.instance.snapshot.getSnapshot;
259 expect(snapshot.status).toBe("disabled");
260 expect(snapshot.auth.status).toBe("authenticated");
261 expect(snapshot.models.map((model) => model.slug)).toEqual([
262 "gemini-test-low",
263 "gemini-test-high",
264 ]);
265 expect(snapshot.models[0]?.aliases).toContain(ANTIGRAVITY_DEFAULT_MODEL);
266 // The mock catalog is not in the manifest's current list, so it folds
267 // under the legacy section like an old Codex model would.
268 expect(snapshot.models.every((model) => model.isLegacy === true)).toBe(true);
269 expect(snapshot.slashCommands.map((command) => command.name)).toEqual(["plan", "logout"]);
270 expect(snapshot.supportsTextGeneration).toBe(true);
271 h.controls.selected = h.second;
272 yield* h.refresh();
273
274 expect(h.acquisitions).toEqual([
275 { binaryPath: "", path: h.instancePath },
276 { binaryPath: "", path: h.instancePath },
277 ]);
278 const nativeLaunches = h.launches.filter((launch) => launch.harnessPath !== undefined);
279 expect(nativeLaunches.map((launch) => launch.command)).toEqual([
280 h.first.executablePath,
281 h.second.executablePath,
282 ]);
283 expect(nativeLaunches.map((launch) => launch.harnessPath)).toEqual([
284 h.first.harnessPath,
285 h.second.harnessPath,
286 ]);
287 for (const launch of nativeLaunches) {
288 expect(launch.args).toEqual(hostPlatform === "linux" ? ["--uid="] : []);
289 expect(launch.profileDirectory).toBe(h.profileDirectory);
290 expect(launch.forceFileStorage).toBe("1");
291 expect(launch.extendEnv).toBe(false);
292 }
293 for (const launch of h.launches) expect(launch.credentialKeys).toEqual([]);
294 expect(h.releases).toEqual([h.first.version, h.second.version]);
295 const requests = yield* h.readRequests;
296 expect(requests.map((request) => request.method)).toEqual([
297 "initialize",
298 "authenticate",
299 "session/new",
300 "initialize",
301 "authenticate",
302 "session/new",
303 ]);
304 expect(
305 requests
306 .filter((request) => request.method === "authenticate")
307 .map((request) => request.params?.methodId),
308 ).toEqual(["oauth-personal", "oauth-personal"]);
309 expect(
310 requests
311 .filter((request) => request.method === "session/new")
312 .map((request) => request.params?.mcpServers),
313 ).toEqual([[], []]);
314 yield* h.assertClosed;
315 }).pipe(Effect.scoped),
316 );
317
318 it.effect.skipIf(windowsHost)(
319 "authenticates with the configured API key method and labels the account by method",
320 () =>
321 Effect.gen(function* () {
322 const h = yield* makeHarness({
323 config: { authMethod: "gemini-api-key", apiKey: "fixture-gemini-key" },
324 });
325 yield* h.refresh();
326 const snapshot = yield* h.instance.snapshot.getSnapshot;
327 expect(snapshot.auth).toMatchObject({
328 status: "authenticated",
329 type: "gemini-api-key",
330 label: "Gemini API key",
331 });
332 expect(snapshot.models.length).toBeGreaterThan(0);
333 const nativeLaunches = h.launches.filter((launch) => launch.harnessPath !== undefined);
334 expect(nativeLaunches.map((launch) => launch.geminiApiKey)).toEqual(["fixture-gemini-key"]);
335 const requests = yield* h.readRequests;
336 expect(
337 requests
338 .filter((request) => request.method === "authenticate")
339 .map((request) => request.params?.methodId),
340 ).toEqual(["gemini-api-key"]);
341 yield* h.assertClosed;
342 }).pipe(Effect.scoped),
343 );
344
345 it.effect.skipIf(windowsHost)("reports the missing credential before launching a process", () =>
346 Effect.gen(function* () {
347 const h = yield* makeHarness({ config: { authMethod: "gemini-api-key" } });
348 const error = yield* h.refresh().pipe(Effect.flip);
349 expect(error.detail).toContain("API key");
350 expect(h.launches.filter((launch) => launch.harnessPath !== undefined)).toEqual([]);
351 }).pipe(Effect.scoped),
352 );
353
354 it.effect.skipIf(windowsHost)(
355 "closes refresh processes and clears account metadata when Google sign-in is required",
356 () =>
357 Effect.gen(function* () {
358 const h = yield* makeHarness();
359 yield* h.refresh();
360 h.controls.selected = h.signedOut;
361 const error = yield* h.refresh().pipe(Effect.flip);
362 expect(error.detail).toContain("Sign in to Antigravity");
363 const snapshot = yield* h.instance.snapshot.getSnapshot;
364 expect(snapshot.auth.status).toBe("unauthenticated");
365 expect(snapshot.models).toEqual([]);
366 expect(snapshot.slashCommands).toEqual([]);
367 expect(snapshot.supportsTextGeneration).toBe(false);
368 expect(h.acquisitions).toHaveLength(2);
369 expect(h.releases).toEqual([h.first.version, h.signedOut.version]);
370 yield* h.assertClosed;
371 }).pipe(Effect.scoped),
372 );
373
374 it.effect.skipIf(windowsHost)(
375 "clears account metadata when a text helper needs Google sign-in",
376 () =>
377 Effect.gen(function* () {
378 const h = yield* makeHarness();
379 yield* h.refresh();
380 h.controls.selected = h.signedOut;
381 const error = yield* h.instance.textGeneration
382 .generateThreadTitle({
383 cwd: h.profileDirectory,
384 message: "Repair Google login",
385 modelSelection: { instanceId: h.instance.instanceId, model: "gemini-test-low" },
386 })
387 .pipe(Effect.flip);
388 expect(error._tag).toBe("TextGenerationError");
389 const snapshot = yield* h.instance.snapshot.getSnapshot;
390 expect(snapshot.auth.status).toBe("unauthenticated");
391 expect(snapshot.models).toEqual([]);
392 expect(snapshot.supportsTextGeneration).toBe(false);
393 expect(h.releases).toEqual([h.first.version, h.signedOut.version]);
394 yield* h.assertClosed;
395 }).pipe(Effect.scoped),
396 );
397
398 it.effect.skipIf(windowsHost)("keeps the previous catalog when executable resolution fails", () =>
399 Effect.gen(function* () {
400 const h = yield* makeHarness();
401 yield* h.refresh();
402 const before = yield* h.instance.snapshot.getSnapshot;
403 h.controls.failResolution = true;
404 const error = yield* h.refresh().pipe(Effect.flip);
405 expect(error.detail).toContain("previous model list is unchanged");
406 const after = yield* h.instance.snapshot.getSnapshot;
407 expect(after.models).toEqual(before.models);
408 expect(after.auth).toEqual(before.auth);
409 expect(h.acquisitions).toHaveLength(2);
410 expect(h.releases).toEqual([h.first.version]);
411 yield* h.assertClosed;
412 }).pipe(Effect.scoped),
413 );
414});
apps/server/src/provider/Drivers/AntigravityDriver.ts created+391
......@@ -0,0 +1,391 @@
1import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts";
2import * as Crypto from "effect/Crypto";
3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9import * as Scope from "effect/Scope";
10import * as Stream from "effect/Stream";
11import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
12import type { AcpError } from "effect-acp/errors";
13
14import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
15import { ServerConfig } from "../../config.ts";
16import { ServerSettingsService } from "../../serverSettings.ts";
17import {
18 isAntigravityTextGenerationAvailable,
19 makeAntigravityTextGeneration,
20} from "../../textGeneration/AntigravityTextGeneration.ts";
21import { makeAntigravityAuth, type AntigravityAuth } from "../AntigravityAuth.ts";
22import { AntigravityInstallation } from "../AntigravityInstallation.ts";
23import {
24 antigravityAuthConfigIssue,
25 antigravityAuthLabel,
26 antigravityAuthUsesBrowser,
27 buildAntigravityAcpSpawnInput,
28 isAntigravitySignInRequiredError,
29 prepareAntigravityProfile,
30 resolveAntigravityProfileDirectory,
31 type AntigravityAuthConfig,
32} from "../antigravityAuthSupport.ts";
33import {
34 makeAntigravityAcpRuntime,
35 type AntigravityAcpRuntimeInput,
36} from "../acp/AntigravityAcpSupport.ts";
37import type { AcpSessionRuntime, AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts";
38import type { ServerProviderDraft } from "../providerSnapshot.ts";
39import { removeAntigravitySessionFiles } from "../acp/AntigravitySessionFiles.ts";
40import { ProviderDriverError } from "../Errors.ts";
41import { makeAntigravityAdapter } from "../Layers/AntigravityAdapter.ts";
42import { makeAntigravityProvider } from "../Layers/AntigravityProvider.ts";
43import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
44import * as ModelManifest from "../ModelManifest.ts";
45import {
46 defaultProviderContinuationIdentity,
47 type ProviderDriver,
48 type ProviderInstance,
49} from "../ProviderDriver.ts";
50import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
51import { withInstanceIdentity } from "./instanceIdentity.ts";
52import { discoverAntigravitySkills } from "./AntigravitySkills.ts";
53
54const DRIVER = ProviderDriverKind.make("antigravity");
55const decodeSettings = Schema.decodeSync(AntigravitySettings);
56
57export type AntigravityDriverEnv =
58 | AntigravityInstallation
59 | BackgroundPolicy.BackgroundPolicy
60 | ChildProcessSpawner.ChildProcessSpawner
61 | Crypto.Crypto
62 | FileSystem.FileSystem
63 | ModelManifest.ModelManifest
64 | Path.Path
65 | ProviderEventLoggers
66 | ServerConfig
67 | ServerSettingsService;
68
69/** Each instance owns its Google profile. Executable releases are shared by the environment. */
70export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityDriverEnv> = {
71 driverKind: DRIVER,
72 metadata: { displayName: "Antigravity", supportsMultipleInstances: true },
73 configSchema: AntigravitySettings,
74 defaultConfig: () => decodeSettings({}),
75 create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
76 Effect.gen(function* () {
77 const crypto = yield* Crypto.Crypto;
78 const fileSystem = yield* FileSystem.FileSystem;
79 const path = yield* Path.Path;
80 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
81 const serverConfig = yield* ServerConfig;
82 const installation = yield* AntigravityInstallation;
83 const loggers = yield* ProviderEventLoggers;
84 const modelManifest = yield* ModelManifest.ModelManifest;
85 const settings = { ...config, enabled } satisfies AntigravitySettings;
86 const auth: AntigravityAuthConfig = {
87 authMethod: settings.authMethod,
88 apiKey: settings.apiKey,
89 gcpProject: settings.gcpProject,
90 gcpLocation: settings.gcpLocation,
91 };
92 const authConfigIssue = antigravityAuthConfigIssue(auth);
93 const processEnvironment = mergeProviderInstanceEnvironment(environment);
94 const profileDirectory = resolveAntigravityProfileDirectory(
95 serverConfig.stateDir,
96 instanceId,
97 );
98 const continuationIdentity = defaultProviderContinuationIdentity({
99 driverKind: DRIVER,
100 instanceId,
101 });
102 const stampIdentity = withInstanceIdentity({
103 instanceId,
104 driverKind: DRIVER,
105 displayName,
106 accentColor,
107 continuationGroupKey: continuationIdentity.continuationKey,
108 });
109 // Google returns every model the account can use, including older
110 // Gemini generations. The manifest names the current ones so the picker
111 // folds the rest under its legacy section, as it does for Codex.
112 const classifyModels = (draft: ServerProviderDraft) =>
113 modelManifest.current.pipe(
114 Effect.map((manifest) =>
115 stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER)),
116 ),
117 );
118
119 const makeRuntime = Effect.fn("AntigravityDriver.makeRuntime")(function* (
120 input: Omit<AntigravityAcpRuntimeInput, "spawn" | "childProcessSpawner">,
121 ): Effect.fn.Return<
122 AcpSessionRuntime["Service"],
123 AcpError | ProviderSetupError,
124 Scope.Scope
125 > {
126 if (authConfigIssue !== null) {
127 return yield* new ProviderSetupError({
128 instanceId,
129 operation: "configure",
130 detail: authConfigIssue,
131 });
132 }
133 const executable = yield* installation
134 .acquire(settings.binaryPath, processEnvironment)
135 .pipe(
136 Effect.mapError(
137 (cause) =>
138 new ProviderSetupError({
139 instanceId,
140 operation: "resolve",
141 detail: cause.detail,
142 }),
143 ),
144 );
145 const profile = yield* prepareAntigravityProfile({
146 profileDirectory,
147 baseEnv: processEnvironment,
148 auth,
149 }).pipe(
150 Effect.provideService(FileSystem.FileSystem, fileSystem),
151 Effect.provideService(Path.Path, path),
152 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
153 );
154 const runtime = yield* makeAntigravityAcpRuntime({
155 ...input,
156 authMethod: auth.authMethod,
157 childProcessSpawner: spawner,
158 spawn: buildAntigravityAcpSpawnInput({
159 installation: executable,
160 profile,
161 cwd: input.cwd,
162 baseEnv: processEnvironment,
163 auth,
164 }),
165 }).pipe(Effect.provideService(Crypto.Crypto, crypto));
166 return {
167 ...runtime,
168 start: () =>
169 runtime
170 .start()
171 .pipe(
172 Effect.tapError((cause): Effect.Effect<void> =>
173 input.onAuthorizationUrl === undefined && isAntigravitySignInRequiredError(cause)
174 ? provider.onAuthRequired
175 : Effect.void,
176 ),
177 ),
178 };
179 });
180
181 const makeDisposableRuntime = Effect.fn("AntigravityDriver.makeDisposableRuntime")(function* (
182 input: Pick<AntigravityAcpRuntimeInput, "onAuthorizationUrl">,
183 ) {
184 const cwd = yield* fileSystem
185 .makeTempDirectoryScoped({ prefix: "t3-antigravity-setup-" })
186 .pipe(
187 Effect.mapError(
188 () =>
189 new ProviderSetupError({
190 instanceId,
191 operation: "start",
192 detail: "Could not create an Antigravity setup workspace.",
193 }),
194 ),
195 );
196 let sessionId: string | undefined;
197 yield* Effect.addFinalizer(() =>
198 removeAntigravitySessionFiles({
199 profileDirectory,
200 sessionId,
201 cwd,
202 }).pipe(
203 Effect.provideService(FileSystem.FileSystem, fileSystem),
204 Effect.provideService(Path.Path, path),
205 ),
206 );
207 const runtime = yield* makeRuntime({
208 cwd,
209 clientInfo: { name: "t3-code-provider-setup", version: "0.0.0" },
210 mcpServers: [],
211 ...(input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {}),
212 });
213 return {
214 ...runtime,
215 start: () =>
216 runtime.start().pipe(
217 Effect.tap((started) =>
218 Effect.sync(() => {
219 sessionId = started.sessionId;
220 }),
221 ),
222 ),
223 };
224 });
225
226 const publishCatalog = (
227 started: AcpSessionRuntimeStartResult,
228 runtime: Pick<AcpSessionRuntime["Service"], "getEvents" | "drainEvents">,
229 ): Effect.Effect<void> =>
230 Effect.gen(function* () {
231 yield* provider.onSessionStarted(started);
232 yield* Stream.runForEach(runtime.getEvents(), (event) => {
233 if (event._tag === "EventStreamBarrier") {
234 return Deferred.succeed(event.acknowledge, undefined).pipe(Effect.asVoid);
235 }
236 return event._tag === "AvailableCommandsUpdated"
237 ? provider.onAvailableCommands(event.availableCommands)
238 : Effect.void;
239 }).pipe(Effect.forkScoped);
240 yield* runtime.drainEvents;
241 }).pipe(Effect.scoped);
242
243 const authFlow: AntigravityAuth = yield* makeAntigravityAuth({
244 instanceId,
245 makeRuntime: makeDisposableRuntime,
246 onAuthenticated: publishCatalog,
247 onSignedOut: Effect.suspend(() => provider.onSignedOut),
248 usesBrowser: antigravityAuthUsesBrowser(auth.authMethod),
249 });
250
251 const probe = Effect.gen(function* () {
252 const processScope = yield* Scope.make();
253 yield* Effect.addFinalizer((exit) => Scope.close(processScope, exit));
254 return yield* authFlow
255 .withProcess(
256 Scope.close(processScope, Exit.void),
257 Effect.gen(function* () {
258 const runtime = yield* makeRuntime({
259 cwd: serverConfig.stateDir,
260 clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" },
261 mcpServers: [],
262 });
263 return yield* runtime.initialize();
264 }),
265 )
266 .pipe(Effect.provideService(Scope.Scope, processScope));
267 }).pipe(Effect.scoped);
268
269 const provider = yield* makeAntigravityProvider(settings, {
270 stampIdentity: classifyModels,
271 probe,
272 auth: { type: auth.authMethod, label: antigravityAuthLabel(auth.authMethod) },
273 supportsTextGeneration: isAntigravityTextGenerationAvailable(profileDirectory).pipe(
274 Effect.provideService(FileSystem.FileSystem, fileSystem),
275 Effect.provideService(Path.Path, path),
276 Effect.orElseSucceed(() => false),
277 ),
278 }).pipe(
279 Effect.mapError(
280 (cause) =>
281 new ProviderDriverError({
282 driver: DRIVER,
283 instanceId,
284 detail: "Could not prepare the Antigravity provider status.",
285 cause,
286 }),
287 ),
288 );
289 const defaultModel = modelManifest.current.pipe(
290 Effect.map((manifest) => ModelManifest.manifestDefaultModel(manifest, DRIVER)),
291 );
292 const adapter = yield* makeAntigravityAdapter(settings, {
293 instanceId,
294 makeRuntime,
295 withProcess: authFlow.withProcess,
296 defaultModel,
297 onSessionStarted: provider.onSessionStarted,
298 onAvailableCommands: provider.onAvailableCommands,
299 onAuthRequired: provider.onAuthRequired,
300 ...(loggers.native ? { nativeEventLogger: loggers.native } : {}),
301 });
302 const textGeneration = yield* makeAntigravityTextGeneration({
303 profileDirectory,
304 defaultModel,
305 withProcess: authFlow.withProcess,
306 makeRuntime: (cwd) =>
307 makeRuntime({
308 cwd,
309 clientInfo: { name: "t3-code-text", version: "0.0.0" },
310 mcpServers: [],
311 }),
312 });
313
314 const refreshModels = Effect.fn("AntigravityDriver.refreshModels")(
315 function* () {
316 const processScope = yield* Scope.make();
317 yield* Effect.addFinalizer((exit) => Scope.close(processScope, exit));
318 yield* authFlow
319 .withProcess(
320 Scope.close(processScope, Exit.void),
321 Effect.gen(function* () {
322 const runtime = yield* makeDisposableRuntime({});
323 const started = yield* runtime.start();
324 yield* publishCatalog(started, runtime);
325 }),
326 )
327 .pipe(Effect.provideService(Scope.Scope, processScope));
328 },
329 Effect.scoped,
330 Effect.timeoutOrElse({
331 duration: "60 seconds",
332 orElse: () =>
333 Effect.fail(
334 new ProviderDriverError({
335 driver: DRIVER,
336 instanceId,
337 detail: "Antigravity model refresh timed out. Try again or check Google sign-in.",
338 }),
339 ),
340 }),
341 Effect.tapError((cause) =>
342 isAntigravitySignInRequiredError(cause) ? provider.onAuthRequired : Effect.void,
343 ),
344 Effect.mapError((cause) =>
345 cause._tag === "ProviderDriverError"
346 ? cause
347 : new ProviderDriverError({
348 driver: DRIVER,
349 instanceId,
350 detail: isAntigravitySignInRequiredError(cause)
351 ? "Sign in to Antigravity in provider settings before refreshing models."
352 : cause._tag === "ProviderSetupError" && cause.operation === "configure"
353 ? cause.detail
354 : "Could not refresh Antigravity models. The previous model list is unchanged.",
355 cause,
356 }),
357 ),
358 );
359
360 return {
361 instanceId,
362 driverKind: DRIVER,
363 continuationIdentity,
364 displayName,
365 accentColor,
366 enabled,
367 snapshot: provider.snapshot,
368 snapshotForCwd: (cwd) =>
369 !enabled
370 ? provider.snapshot.getSnapshot
371 : discoverAntigravitySkills({ cwd, profileDirectory }).pipe(
372 Effect.provideService(FileSystem.FileSystem, fileSystem),
373 Effect.provideService(Path.Path, path),
374 Effect.flatMap((skills) => provider.snapshotForCwd(cwd, skills)),
375 Effect.mapError(
376 (cause) =>
377 new ProviderDriverError({
378 driver: DRIVER,
379 instanceId,
380 detail: "Could not read Antigravity workspace skills.",
381 cause,
382 }),
383 ),
384 ),
385 adapter,
386 textGeneration,
387 auth: authFlow.controller,
388 refreshModels,
389 } satisfies ProviderInstance;
390 }),
391};
apps/server/src/provider/Drivers/AntigravitySkills.test.ts created+281
......@@ -0,0 +1,281 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6
7import { discoverAntigravitySkills } from "./AntigravitySkills.ts";
8
9const writeSkill = Effect.fn("writeSkill")(function* (directory: string, contents: string) {
10 const fileSystem = yield* FileSystem.FileSystem;
11 const path = yield* Path.Path;
12 yield* fileSystem.makeDirectory(directory, { recursive: true });
13 const skillPath = path.join(directory, "SKILL.md");
14 yield* fileSystem.writeFileString(skillPath, contents);
15 return skillPath;
16});
17
18const makeWorkspace = Effect.fn("makeWorkspace")(function* () {
19 const fileSystem = yield* FileSystem.FileSystem;
20 const path = yield* Path.Path;
21 const temporaryDirectory = yield* fileSystem.makeTempDirectoryScoped({
22 prefix: "t3-antigravity-skills-",
23 });
24 return {
25 cwd: path.join(temporaryDirectory, "workspace"),
26 profileDirectory: path.join(temporaryDirectory, "profile"),
27 };
28});
29
30it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => {
31 it.effect("reads skill names, descriptions and paths from all four native roots", () =>
32 Effect.gen(function* () {
33 const path = yield* Path.Path;
34 const input = yield* makeWorkspace();
35 const roots = [
36 { directory: path.join(input.profileDirectory, "config", "skills"), scope: "user" },
37 { directory: path.join(input.cwd, ".gemini", "skills"), scope: "project" },
38 {
39 directory: path.join(input.profileDirectory, "antigravity-cli", "skills"),
40 scope: "user",
41 },
42 { directory: path.join(input.cwd, ".agents", "skills"), scope: "project" },
43 ];
44 const expected = [];
45 for (const [index, root] of roots.entries()) {
46 const name = `review-${index}`;
47 const description = `Review changes in root ${index}.`;
48 const skillPath = yield* writeSkill(
49 path.join(root.directory, name),
50 `---\nname: ${name}\ndescription: ${description}\n---\n# Review\n`,
51 );
52 expected.push({ name, description, path: skillPath, scope: root.scope, enabled: true });
53 }
54
55 assert.deepEqual(yield* discoverAntigravitySkills(input), expected);
56 }),
57 );
58
59 it.effect("returns no skills when the native roots are missing", () =>
60 Effect.gen(function* () {
61 const input = yield* makeWorkspace();
62 assert.deepEqual(yield* discoverAntigravitySkills(input), []);
63 }),
64 );
65
66 it.effect("uses native root order for duplicate names", () =>
67 Effect.gen(function* () {
68 const fileSystem = yield* FileSystem.FileSystem;
69 const path = yield* Path.Path;
70 const input = yield* makeWorkspace();
71 const roots = [
72 path.join(input.profileDirectory, "config", "skills"),
73 path.join(input.cwd, ".gemini", "skills"),
74 path.join(input.profileDirectory, "antigravity-cli", "skills"),
75 path.join(input.cwd, ".agents", "skills"),
76 ];
77 for (const [index, root] of roots.entries()) {
78 yield* writeSkill(
79 path.join(root, `copy-${index}`),
80 `---\nname: review\ndescription: Copy ${index}.\n---\n`,
81 );
82 }
83
84 for (const [index, root] of roots.entries()) {
85 const skills = yield* discoverAntigravitySkills(input);
86 assert.equal(skills.length, 1);
87 assert.equal(skills[0]?.path, path.join(root, `copy-${index}`, "SKILL.md"));
88 yield* fileSystem.remove(root, { recursive: true });
89 }
90 }),
91 );
92
93 it.effect("loads a root skill without scanning its children", () =>
94 Effect.gen(function* () {
95 const fileSystem = yield* FileSystem.FileSystem;
96 const path = yield* Path.Path;
97 const input = yield* makeWorkspace();
98 const root = path.join(input.cwd, ".agents", "skills");
99 const skillPath = yield* writeSkill(root, "---\nname: root-skill\n---\n");
100 yield* writeSkill(path.join(root, "child"), "---\nname: child-skill\n---\n");
101
102 assert.deepEqual(yield* discoverAntigravitySkills(input), [
103 { name: "root-skill", path: skillPath, scope: "project", enabled: true },
104 ]);
105
106 yield* fileSystem.writeFileString(skillPath, "---\nname: [invalid\n---\n");
107 assert.deepEqual(yield* discoverAntigravitySkills(input), []);
108 }),
109 );
110
111 it.effect("uses the filename when valid frontmatter has no name", () =>
112 Effect.gen(function* () {
113 const fileSystem = yield* FileSystem.FileSystem;
114 const path = yield* Path.Path;
115 const input = yield* makeWorkspace();
116 const skillPath = yield* writeSkill(
117 path.join(input.cwd, ".agents", "skills", "not-the-name"),
118 "---\n---\n# Skill body\n",
119 );
120
121 assert.deepEqual(yield* discoverAntigravitySkills(input), [
122 { name: "SKILL", path: skillPath, scope: "project", enabled: true },
123 ]);
124
125 const lowerCasePath = path.join(path.dirname(skillPath), "skill.md");
126 yield* fileSystem.rename(skillPath, lowerCasePath);
127 assert.deepEqual(yield* discoverAntigravitySkills(input), [
128 { name: "skill", path: lowerCasePath, scope: "project", enabled: true },
129 ]);
130
131 yield* fileSystem.rename(lowerCasePath, path.join(path.dirname(skillPath), "SKILL.MD"));
132 assert.deepEqual(yield* discoverAntigravitySkills(input), []);
133 }),
134 );
135
136 it.effect("accepts native metadata delimiters after leading text", () =>
137 Effect.gen(function* () {
138 const path = yield* Path.Path;
139 const input = yield* makeWorkspace();
140 const skillPath = yield* writeSkill(
141 path.join(input.cwd, ".agents", "skills", "review"),
142 "Leading text.---\nname: review\ndescription: null\n---Skill body.",
143 );
144
145 assert.deepEqual(yield* discoverAntigravitySkills(input), [
146 { name: "review", path: skillPath, scope: "project", enabled: true },
147 ]);
148 }),
149 );
150
151 it.effect("ignores invalid files and deeper directories but keeps native hidden skills", () =>
152 Effect.gen(function* () {
153 const fileSystem = yield* FileSystem.FileSystem;
154 const path = yield* Path.Path;
155 const input = yield* makeWorkspace();
156 const root = path.join(input.cwd, ".agents", "skills");
157 const invalidSkills = [
158 ["plain", "# Missing frontmatter\n"],
159 ["broken", "---\nname: [unclosed\n---\n"],
160 ["scalar", "---\n42\n---\n"],
161 ["wrong-type", "---\nname: invalid\ndescription: {}\n---\n"],
162 ["blank-name", '---\nname: " "\n---\n'],
163 ] as const;
164 for (const [name, contents] of invalidSkills) {
165 yield* writeSkill(path.join(root, name), contents);
166 }
167 yield* writeSkill(path.join(root, "nested", "deep"), "---\nname: too-deep\n---\n");
168 yield* writeSkill(
169 path.join(input.cwd, ".claude", "skills", "wrong-provider"),
170 "---\nname: wrong-provider\n---\n",
171 );
172 yield* fileSystem.makeDirectory(path.join(root, ".not-a-skill"));
173 yield* fileSystem.writeFileString(path.join(root, "README.md"), "Not a skill.");
174 const skillPath = yield* writeSkill(
175 path.join(root, ".native-hidden-skill"),
176 "---\nname: native-name\ndescription: >\n Review the code\n and run tests.\n---\n",
177 );
178
179 assert.deepEqual(yield* discoverAntigravitySkills(input), [
180 {
181 name: "native-name",
182 description: "Review the code and run tests.",
183 path: skillPath,
184 scope: "project",
185 enabled: true,
186 },
187 ]);
188 }),
189 );
190
191 it.effect("uses native URI order within a root and skips an invalid higher root", () =>
192 Effect.gen(function* () {
193 const fileSystem = yield* FileSystem.FileSystem;
194 const path = yield* Path.Path;
195 const input = yield* makeWorkspace();
196 const root = path.join(input.cwd, ".agents", "skills");
197 yield* writeSkill(
198 path.join(input.profileDirectory, "config", "skills", "review"),
199 "---\nname: [invalid\n---\n",
200 );
201 const nativeOrder = [" space-copy", "!-copy", "ø-copy", "a-copy"];
202 for (const name of nativeOrder) {
203 yield* writeSkill(path.join(root, name), "---\nname: review\n---\n");
204 }
205
206 for (const name of nativeOrder) {
207 assert.deepEqual(yield* discoverAntigravitySkills(input), [
208 {
209 name: "review",
210 path: path.join(root, name, "SKILL.md"),
211 scope: "project",
212 enabled: true,
213 },
214 ]);
215 yield* fileSystem.remove(path.join(root, name), { recursive: true });
216 }
217 }),
218 );
219
220 it.effect("follows directory symlinks used to install shared skills", () =>
221 Effect.gen(function* () {
222 const fileSystem = yield* FileSystem.FileSystem;
223 const path = yield* Path.Path;
224 const input = yield* makeWorkspace();
225 const sourceDirectory = path.join(input.profileDirectory, "shared-review");
226 yield* writeSkill(sourceDirectory, "---\nname: review\n---\n");
227 const root = path.join(input.cwd, ".agents", "skills");
228 const linkedDirectory = path.join(root, "review");
229 yield* fileSystem.makeDirectory(root, { recursive: true });
230 yield* fileSystem.symlink(sourceDirectory, linkedDirectory);
231
232 assert.deepEqual(yield* discoverAntigravitySkills(input), [
233 {
234 name: "review",
235 path: path.join(linkedDirectory, "SKILL.md"),
236 scope: "project",
237 enabled: true,
238 },
239 ]);
240 }),
241 );
242
243 it.effect("rejects an oversized skill instead of returning an incomplete catalog", () =>
244 Effect.gen(function* () {
245 const path = yield* Path.Path;
246 const input = yield* makeWorkspace();
247 const skillPath = yield* writeSkill(
248 path.join(input.cwd, ".agents", "skills", "oversized"),
249 `---\nname: oversized\ndescription: Read a large skill.\n---\n${"x".repeat(1_000_000)}`,
250 );
251
252 const result = yield* discoverAntigravitySkills(input).pipe(Effect.result);
253 assert.equal(result._tag, "Failure");
254 if (result._tag === "Failure") {
255 assert.equal(result.failure.reason, "scan-budget-exhausted");
256 assert.equal(result.failure.path, skillPath);
257 }
258 }),
259 );
260
261 it.effect("bounds the total read size across skills", () =>
262 Effect.gen(function* () {
263 const path = yield* Path.Path;
264 const input = yield* makeWorkspace();
265 const root = path.join(input.cwd, ".agents", "skills");
266 for (let index = 0; index < 9; index += 1) {
267 yield* writeSkill(
268 path.join(root, `large-${index}`),
269 `---\nname: large-${index}\n---\n${"x".repeat(900_000)}`,
270 );
271 }
272
273 const result = yield* discoverAntigravitySkills(input).pipe(Effect.result);
274 assert.equal(result._tag, "Failure");
275 if (result._tag === "Failure") {
276 assert.equal(result.failure.reason, "scan-budget-exhausted");
277 assert.equal(result.failure.path, path.join(root, "large-8", "SKILL.md"));
278 }
279 }),
280 );
281});
apps/server/src/provider/Drivers/AntigravitySkills.ts created+194
......@@ -0,0 +1,194 @@
1import type { ServerProviderSkill } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import type * as PlatformError from "effect/PlatformError";
6import * as Schema from "effect/Schema";
7import * as Stream from "effect/Stream";
8import { parse as parseYamlDocument } from "yaml";
9
10const MAX_SKILL_BYTES = 1_000_000;
11const MAX_SCAN_BYTES = 8_000_000;
12const MAX_SCAN_ENTRIES = 10_000;
13
14const SkillFrontmatter = Schema.Struct({
15 name: Schema.optional(Schema.NullOr(Schema.String)),
16 description: Schema.optional(Schema.NullOr(Schema.String)),
17});
18const decodeSkillFrontmatter = Schema.decodeUnknownSync(SkillFrontmatter);
19
20export class AntigravitySkillsProbeError extends Schema.TaggedErrorClass<AntigravitySkillsProbeError>()(
21 "AntigravitySkillsProbeError",
22 {
23 reason: Schema.Literals(["scan-budget-exhausted", "filesystem-error"]),
24 path: Schema.String,
25 cause: Schema.optional(Schema.Defect()),
26 },
27) {
28 override get message(): string {
29 return this.reason === "scan-budget-exhausted"
30 ? `Antigravity skill discovery exceeded its scan limit at '${this.path}'.`
31 : `Antigravity could not read skills at '${this.path}'.`;
32 }
33}
34
35interface ScanBudget {
36 remainingBytes: number;
37 remainingEntries: number;
38}
39
40const readIfPresent = <A, R>(
41 effect: Effect.Effect<A, PlatformError.PlatformError, R>,
42 path: string,
43) =>
44 effect.pipe(
45 Effect.catchTags({
46 PlatformError: (cause) =>
47 cause.reason._tag === "NotFound"
48 ? Effect.succeed(undefined)
49 : Effect.fail(
50 new AntigravitySkillsProbeError({ reason: "filesystem-error", path, cause }),
51 ),
52 }),
53 );
54
55function parseSkillFrontmatter(contents: string, fileName: string) {
56 const start = contents.indexOf("---");
57 if (start === -1) return undefined;
58 const end = contents.indexOf("---", start + 3);
59 if (end === -1) return undefined;
60 try {
61 const frontmatter = decodeSkillFrontmatter(
62 parseYamlDocument(contents.slice(start + 3, end).trim()) ?? {},
63 );
64 const name = frontmatter.name || fileName.slice(0, -3);
65 const description = frontmatter.description?.trim();
66 // Native names are not trimmed. Do not rename one to fit the picker contract.
67 if (!name || name !== name.trim()) return undefined;
68 return { name, ...(description ? { description } : {}) };
69 } catch {
70 return undefined;
71 }
72}
73
74/** The native loader orders child paths with Go's URL.EscapedPath encoding. */
75function skillPathSortKey(entry: string) {
76 return encodeURI(entry).replace(
77 /[!'()*?#]/g,
78 (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
79 );
80}
81
82/** Read only regular skill files, with a byte limit that applies during the read. */
83const readSkill = Effect.fn("readAntigravitySkill")(function* (
84 skillPath: string,
85 budget: ScanBudget,
86) {
87 const fileSystem = yield* FileSystem.FileSystem;
88 const info = yield* readIfPresent(fileSystem.stat(skillPath), skillPath);
89 if (info?.type !== "File") return undefined;
90
91 const byteLimit = Math.min(MAX_SKILL_BYTES, budget.remainingBytes);
92 if (info.size > BigInt(byteLimit)) {
93 return yield* new AntigravitySkillsProbeError({
94 reason: "scan-budget-exhausted",
95 path: skillPath,
96 });
97 }
98 const chunks = yield* readIfPresent(
99 fileSystem.stream(skillPath, { bytesToRead: byteLimit + 1 }).pipe(Stream.runCollect),
100 skillPath,
101 );
102 if (chunks === undefined) return undefined;
103 const bytes = Buffer.concat(chunks);
104 if (bytes.byteLength > byteLimit) {
105 return yield* new AntigravitySkillsProbeError({
106 reason: "scan-budget-exhausted",
107 path: skillPath,
108 });
109 }
110 budget.remainingBytes -= bytes.byteLength;
111 return bytes.toString("utf8");
112});
113
114/**
115 * Match the official ACP's explicit skill roots. The first valid same-name skill
116 * wins. Each root loads its own SKILL.md or those in its immediate subdirectories.
117 * Read failures remain typed so workspace snapshots do not cache partial results.
118 */
119export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")(function* (input: {
120 readonly cwd: string;
121 readonly profileDirectory: string;
122}): Effect.fn.Return<
123 ReadonlyArray<ServerProviderSkill>,
124 AntigravitySkillsProbeError,
125 FileSystem.FileSystem | Path.Path
126> {
127 const fileSystem = yield* FileSystem.FileSystem;
128 const path = yield* Path.Path;
129 const roots = [
130 { directory: path.resolve(input.profileDirectory, "config", "skills"), scope: "user" },
131 { directory: path.resolve(input.cwd, ".gemini", "skills"), scope: "project" },
132 {
133 directory: path.resolve(input.profileDirectory, "antigravity-cli", "skills"),
134 scope: "user",
135 },
136 { directory: path.resolve(input.cwd, ".agents", "skills"), scope: "project" },
137 ];
138 const budget: ScanBudget = {
139 remainingBytes: MAX_SCAN_BYTES,
140 remainingEntries: MAX_SCAN_ENTRIES,
141 };
142 const skillsByName = new Map<string, ServerProviderSkill>();
143
144 const scanDirectory = Effect.fn("scanAntigravitySkillDirectory")(function* (
145 directory: string,
146 scope: string,
147 scanChildren: boolean,
148 ): Effect.fn.Return<void, AntigravitySkillsProbeError, FileSystem.FileSystem> {
149 const info = yield* readIfPresent(fileSystem.stat(directory), directory);
150 if (info?.type !== "Directory") return;
151 const entries = yield* readIfPresent(fileSystem.readDirectory(directory), directory);
152 if (entries === undefined) return;
153 if (entries.length > budget.remainingEntries) {
154 return yield* new AntigravitySkillsProbeError({
155 reason: "scan-budget-exhausted",
156 path: directory,
157 });
158 }
159 budget.remainingEntries -= entries.length;
160
161 const sortedEntries = entries.toSorted();
162 const skillFileName = sortedEntries.find((entry) => entry.toLowerCase() === "skill.md");
163 if (skillFileName !== undefined) {
164 if (!skillFileName.endsWith(".md")) return;
165 const skillPath = path.join(directory, skillFileName);
166 const contents = yield* readSkill(skillPath, budget);
167 if (contents === undefined) return;
168 const skill = parseSkillFrontmatter(contents, skillFileName);
169 if (!skill || skillsByName.has(skill.name)) return;
170 skillsByName.set(skill.name, {
171 ...skill,
172 path: skillPath,
173 scope,
174 enabled: true,
175 });
176 return;
177 }
178 if (scanChildren) {
179 const children = sortedEntries
180 .map((entry) => ({ entry, sortKey: skillPathSortKey(entry) }))
181 .sort((left, right) =>
182 left.sortKey < right.sortKey ? -1 : left.sortKey > right.sortKey ? 1 : 0,
183 );
184 for (const { entry } of children) {
185 yield* scanDirectory(path.join(directory, entry), scope, false);
186 }
187 }
188 });
189
190 for (const root of roots) {
191 yield* scanDirectory(root.directory, root.scope, true);
192 }
193 return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name));
194});
apps/server/src/provider/Layers/AntigravityAdapter.test.ts created+914
......@@ -0,0 +1,914 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import {
4 AntigravitySettings,
5 ApprovalRequestId,
6 ProviderInstanceId,
7 ThreadId,
8 type ProviderRuntimeEvent,
9} from "@t3tools/contracts";
10import * as Deferred from "effect/Deferred";
11import * as Crypto from "effect/Crypto";
12import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";
14import * as Fiber from "effect/Fiber";
15import * as FileSystem from "effect/FileSystem";
16import * as Layer from "effect/Layer";
17import * as Path from "effect/Path";
18import * as Queue from "effect/Queue";
19import * as Schema from "effect/Schema";
20import * as Stream from "effect/Stream";
21import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
22import * as AcpErrors from "effect-acp/errors";
23import type * as AcpSchema from "effect-acp/schema";
24
25import { ServerConfig } from "../../config.ts";
26import { ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE } from "../antigravityAuthSupport.ts";
27import type { AcpSessionRuntimeEvent } from "../acp/AcpSessionRuntime.ts";
28import { makeAntigravityAcpRuntime } from "../acp/AntigravityAcpSupport.ts";
29import { makeAntigravityAdapter, type AntigravityAdapterOptions } from "./AntigravityAdapter.ts";
30
31const instanceId = ProviderInstanceId.make("antigravity-test");
32const threadId = ThreadId.make("antigravity-thread");
33const nativeSessionId = "b75db7e9-cd99-40e5-aa63-ac2b4674a6a9";
34const nativeDefault = "gemini-test-low";
35const nativeAlternative = "gemini-test-high";
36const decodeSettings = Schema.decodeSync(AntigravitySettings);
37const decodeRequestLog = Schema.decodeEffect(
38 Schema.Array(
39 Schema.fromJsonString(
40 Schema.Struct({ method: Schema.String, params: Schema.optional(Schema.Unknown) }),
41 ),
42 ),
43);
44
45interface NativePrompt {
46 readonly index: number;
47 readonly result: Deferred.Deferred<AcpSchema.PromptResponse, AcpErrors.AcpError>;
48}
49
50type Runtime = Effect.Success<ReturnType<AntigravityAdapterOptions["makeRuntime"]>>;
51
52const makeHarness = Effect.fn("makeAntigravityAdapterHarness")(function* (options?: {
53 readonly enabled?: boolean;
54 readonly holdCancel?: boolean;
55 readonly holdClose?: boolean;
56 readonly holdDispatch?: boolean;
57}) {
58 const runtimeEvents = yield* Queue.unbounded<AcpSessionRuntimeEvent>();
59 const canonicalEvents = yield* Queue.unbounded<ProviderRuntimeEvent>();
60 const prompts = yield* Queue.unbounded<NativePrompt>();
61 const cancellations = yield* Queue.unbounded<number>();
62 const cancelRelease = yield* Deferred.make<void>();
63 const closeStarted = yield* Deferred.make<void>();
64 const closeRelease = yield* Deferred.make<void>();
65 const dispatchStarted = yield* Deferred.make<void>();
66 const dispatchRelease = yield* Deferred.make<void>();
67 const seen: ProviderRuntimeEvent[] = [];
68 const calls: string[] = [];
69 const launches: Array<Parameters<AntigravityAdapterOptions["makeRuntime"]>[0]> = [];
70 const stops: Array<Effect.Effect<void>> = [];
71 const controls = { failModel: false, failAuth: false, authInvalidations: 0, closed: 0 };
72 let currentModel = nativeDefault;
73 let promptIndex = 0;
74 let active: NativePrompt | undefined;
75 const fileHandlers: {
76 read?: Parameters<Runtime["handleReadTextFile"]>[0];
77 write?: Parameters<Runtime["handleWriteTextFile"]>[0];
78 } = {};
79 let permissionHandler:
80 | ((
81 request: AcpSchema.RequestPermissionRequest,
82 ) => Effect.Effect<AcpSchema.RequestPermissionResponse, AcpErrors.AcpError>)
83 | undefined;
84
85 const configOptions = (): ReadonlyArray<AcpSchema.SessionConfigOption> => [
86 {
87 id: "model",
88 name: "Model",
89 type: "select",
90 category: "model",
91 currentValue: currentModel,
92 options: [
93 { value: nativeDefault, name: "Gemini test low" },
94 { value: nativeAlternative, name: "Gemini test high" },
95 ],
96 },
97 ];
98 const drainEvents = Effect.gen(function* () {
99 const acknowledge = yield* Deferred.make<void>();
100 yield* Queue.offer(runtimeEvents, { _tag: "EventStreamBarrier", acknowledge });
101 yield* Deferred.await(acknowledge);
102 });
103 const emitNative = (event: AcpSessionRuntimeEvent) =>
104 Queue.offer(runtimeEvents, event).pipe(Effect.asVoid);
105 const runtime: Effect.Success<ReturnType<AntigravityAdapterOptions["makeRuntime"]>> = {
106 handleRequestPermission: (handler) =>
107 Effect.sync(() => {
108 permissionHandler = handler;
109 }),
110 handleReadTextFile: (handler) =>
111 Effect.sync(() => {
112 fileHandlers.read = handler;
113 }),
114 handleWriteTextFile: (handler) =>
115 Effect.sync(() => {
116 fileHandlers.write = handler;
117 }),
118 start: () =>
119 Effect.gen(function* () {
120 if (controls.failAuth) {
121 return yield* new AcpErrors.AcpTransportError({
122 detail: ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE,
123 cause: undefined,
124 });
125 }
126 currentModel = nativeDefault;
127 calls.push("start");
128 yield* emitNative({
129 _tag: "AvailableCommandsUpdated",
130 availableCommands: [
131 { name: "plan", description: "Create a plan" },
132 { name: "logout", description: "Sign out" },
133 ],
134 rawPayload: {},
135 });
136 return {
137 sessionId: nativeSessionId,
138 initializeResult: {
139 protocolVersion: 1,
140 agentCapabilities: { sessionCapabilities: { resume: {} } },
141 },
142 sessionSetupResult: { sessionId: nativeSessionId, configOptions: configOptions() },
143 modelConfigId: "model",
144 };
145 }),
146 getConfigOptions: Effect.sync(configOptions),
147 setModel: (model) =>
148 Effect.gen(function* () {
149 calls.push(`model:${model}`);
150 if (controls.failModel) {
151 controls.failModel = false;
152 return yield* AcpErrors.AcpRequestError.invalidParams("Native model selection failed.");
153 }
154 currentModel = model;
155 }),
156 setMode: (mode) =>
157 Effect.sync(() => {
158 calls.push(`mode:${mode}`);
159 return {};
160 }),
161 getEvents: () => Stream.fromQueue(runtimeEvents),
162 drainEvents,
163 prompt: (_payload, promptOptions) =>
164 Effect.gen(function* () {
165 yield* Deferred.succeed(dispatchStarted, undefined);
166 if (options?.holdDispatch) yield* Deferred.await(dispatchRelease);
167 const prompt: NativePrompt = {
168 index: ++promptIndex,
169 result: yield* Deferred.make<AcpSchema.PromptResponse, AcpErrors.AcpError>(),
170 };
171 active = prompt;
172 calls.push(`prompt:${prompt.index}`);
173 if (promptOptions?.dispatched) yield* Deferred.succeed(promptOptions.dispatched, undefined);
174 yield* Queue.offer(prompts, prompt);
175 return yield* Deferred.await(prompt.result).pipe(
176 Effect.ensuring(
177 Effect.sync(() => {
178 if (active === prompt) active = undefined;
179 }),
180 ),
181 );
182 }),
183 cancel: Effect.gen(function* () {
184 const prompt = active;
185 if (!prompt) return;
186 calls.push(`cancel:${prompt.index}`);
187 yield* Queue.offer(cancellations, prompt.index);
188 if (options?.holdCancel) yield* Deferred.await(cancelRelease);
189 yield* Deferred.succeed(prompt.result, { stopReason: "cancelled" });
190 yield* Deferred.await(prompt.result);
191 yield* drainEvents;
192 calls.push(`drained:${prompt.index}`);
193 }),
194 };
195 const commandUpdates: Array<ReadonlyArray<AcpSchema.AvailableCommand>> = [];
196 const adapter = yield* makeAntigravityAdapter(
197 decodeSettings({ enabled: options?.enabled ?? true }),
198 {
199 instanceId,
200 makeRuntime: (input) =>
201 Effect.gen(function* () {
202 launches.push(input);
203 yield* Effect.addFinalizer(() =>
204 Effect.gen(function* () {
205 yield* Deferred.succeed(closeStarted, undefined);
206 if (options?.holdClose) yield* Deferred.await(closeRelease);
207 controls.closed += 1;
208 }),
209 );
210 return runtime;
211 }),
212 withProcess: (stop, task) =>
213 Effect.suspend(() => {
214 stops.push(stop);
215 return task;
216 }),
217 onAvailableCommands: (commands) =>
218 Effect.sync(() => {
219 commandUpdates.push(commands);
220 }),
221 onAuthRequired: Effect.sync(() => {
222 controls.authInvalidations += 1;
223 }),
224 },
225 );
226 yield* adapter.streamEvents.pipe(
227 Stream.runForEach((event) =>
228 Effect.sync(() => {
229 seen.push(event);
230 }).pipe(Effect.andThen(Queue.offer(canonicalEvents, event))),
231 ),
232 Effect.forkScoped({ startImmediately: true }),
233 );
234 yield* Effect.addFinalizer(() =>
235 Effect.all([
236 Deferred.succeed(cancelRelease, undefined),
237 Deferred.succeed(closeRelease, undefined),
238 Deferred.succeed(dispatchRelease, undefined),
239 ]).pipe(Effect.asVoid),
240 );
241 const waitForEvent = Effect.fn("AntigravityAdapterTest.waitForEvent")(function* <
242 T extends ProviderRuntimeEvent,
243 >(predicate: (event: ProviderRuntimeEvent) => event is T) {
244 while (true) {
245 const event = yield* Queue.take(canonicalEvents);
246 if (predicate(event)) return event;
247 }
248 });
249 const invokePermission = (request: AcpSchema.RequestPermissionRequest) =>
250 Effect.suspend(() =>
251 permissionHandler
252 ? permissionHandler(request)
253 : Effect.die("Missing native permission handler"),
254 );
255 return {
256 fileHandlers,
257 adapter,
258 calls,
259 launches,
260 commandUpdates,
261 controls,
262 seen,
263 stops,
264 waitForEvent,
265 emitNative,
266 invokePermission,
267 closeStarted,
268 closeRelease,
269 cancelRelease,
270 dispatchStarted,
271 dispatchRelease,
272 nextPrompt: Queue.take(prompts),
273 nextCancellation: Queue.take(cancellations),
274 drainEvents,
275 hasActivePrompt: () => active !== undefined,
276 };
277});
278
279const layer = ServerConfig.layerTest(process.cwd(), {
280 prefix: "t3-antigravity-adapter-test-",
281}).pipe(Layer.provideMerge(NodeServices.layer));
282
283it.layer(layer)("AntigravityAdapter", (it) => {
284 it.effect(
285 "runs native auth, resume, models, commands, and streaming through the ACP transport",
286 () =>
287 Effect.gen(function* () {
288 const fileSystem = yield* FileSystem.FileSystem;
289 const path = yield* Path.Path;
290 const crypto = yield* Crypto.Crypto;
291 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
292 const cwd = yield* fileSystem.makeTempDirectoryScoped({
293 prefix: "t3-antigravity-transport-",
294 });
295 const mockAgentPath = yield* path.fromFileUrl(
296 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
297 );
298 const requestLog = path.join(cwd, "requests.ndjson");
299 const commands: string[] = [];
300 const observed: ProviderRuntimeEvent[] = [];
301 const completed = yield* Deferred.make<void>();
302 const adapter = yield* makeAntigravityAdapter(decodeSettings({ enabled: true }), {
303 instanceId,
304 withProcess: (_stop, task) => task,
305 makeRuntime: (input) =>
306 makeAntigravityAcpRuntime({
307 ...input,
308 childProcessSpawner,
309 spawn: {
310 command: process.execPath,
311 args: [mockAgentPath],
312 cwd: input.cwd,
313 env: {
314 ...process.env,
315 T3_ACP_ANTIGRAVITY: "1",
316 T3_ACP_REQUEST_LOG_PATH: requestLog,
317 },
318 extendEnv: false,
319 },
320 }).pipe(Effect.provideService(Crypto.Crypto, crypto)),
321 onAvailableCommands: (available) =>
322 Effect.sync(() => {
323 commands.push(...available.map((command) => command.name));
324 }),
325 });
326 yield* adapter.streamEvents.pipe(
327 Stream.runForEach((event) =>
328 Effect.gen(function* () {
329 observed.push(event);
330 if (event.type === "turn.completed") yield* Deferred.succeed(completed, undefined);
331 }),
332 ),
333 Effect.forkScoped({ startImmediately: true }),
334 );
335 const original = yield* adapter.startSession({
336 threadId,
337 cwd,
338 runtimeMode: "auto-accept-edits",
339 modelSelection: { instanceId, model: nativeAlternative },
340 });
341 yield* adapter.stopSession(threadId);
342 const resumed = yield* adapter.startSession({
343 threadId,
344 cwd,
345 runtimeMode: "auto-accept-edits",
346 modelSelection: { instanceId, model: nativeAlternative },
347 resumeCursor: original.resumeCursor,
348 });
349 expect(resumed.model).toBe(nativeAlternative);
350 yield* adapter.sendTurn({ threadId, input: "Reply with one short line." });
351 yield* Deferred.await(completed);
352 expect(commands).toEqual(["plan", "logout", "plan", "logout"]);
353 expect(
354 observed
355 .filter((event) => event.type === "content.delta")
356 .map((event) => event.payload.delta)
357 .join(""),
358 ).toBe("hello from mock");
359 const lines = (yield* fileSystem.readFileString(requestLog)).trim().split("\n");
360 const requests = yield* decodeRequestLog(lines);
361 expect(
362 requests
363 .filter((request) => request.method === "authenticate")
364 .map((request) => request.params),
365 ).toEqual([{ methodId: "oauth-personal" }, { methodId: "oauth-personal" }]);
366 expect(requests.some((request) => request.method === "session/resume")).toBe(true);
367 expect(requests.some((request) => request.method === "session/load")).toBe(false);
368 expect(
369 requests
370 .filter((request) => request.method === "session/set_config_option")
371 .map((request) => request.params),
372 ).toContainEqual({ sessionId: "mock-session-1", configId: "mode", value: "auto_edit" });
373 }),
374 );
375
376 it.effect("reapplies the exact saved model and mode after a native resume", () =>
377 Effect.gen(function* () {
378 const h = yield* makeHarness();
379 const first = yield* h.adapter.startSession({
380 threadId,
381 cwd: process.cwd(),
382 runtimeMode: "auto-accept-edits",
383 modelSelection: { instanceId, model: nativeAlternative },
384 });
385 expect(first.model).toBe(nativeAlternative);
386 yield* h.adapter.stopSession(threadId);
387 const second = yield* h.adapter.startSession({
388 threadId,
389 cwd: "/tmp",
390 runtimeMode: "auto-accept-edits",
391 resumeCursor: first.resumeCursor,
392 modelSelection: { instanceId, model: nativeAlternative },
393 });
394 expect(second.model).toBe(nativeAlternative);
395 expect(second.cwd).toBe("/tmp");
396 expect(h.launches[1]?.resumeSessionId).toBe(nativeSessionId);
397 expect(h.calls).toEqual([
398 "start",
399 `model:${nativeAlternative}`,
400 "mode:auto_edit",
401 "start",
402 `model:${nativeAlternative}`,
403 "mode:auto_edit",
404 ]);
405 expect(h.commandUpdates.at(-1)?.map((command) => command.name)).toEqual(["plan", "logout"]);
406 expect(h.adapter.capabilities.supportsConversationRollback).toBe(false);
407 const rollback = yield* h.adapter.rollbackThread(threadId, 1).pipe(Effect.exit);
408 expect(Exit.isFailure(rollback)).toBe(true);
409 }),
410 );
411
412 it.effect("keeps thoughts, native command results, and replies on the active turn", () =>
413 Effect.gen(function* () {
414 const h = yield* makeHarness();
415 yield* h.adapter.startSession({
416 threadId,
417 cwd: process.cwd(),
418 runtimeMode: "approval-required",
419 });
420 const sending = yield* h.adapter
421 .sendTurn({ threadId, input: "Read the file" })
422 .pipe(Effect.forkChild);
423 const prompt = yield* h.nextPrompt;
424 yield* h.emitNative({ _tag: "ThoughtDelta", text: "I will read it.", rawPayload: {} });
425 yield* h.emitNative({
426 _tag: "ToolCallUpdated",
427 toolCall: {
428 toolCallId: "command-1",
429 kind: "execute",
430 status: "completed",
431 data: {
432 rawInput: { CommandLine: "cat probe.txt", Cwd: "/tmp" },
433 rawOutput: { combinedOutput: "after\n", exitCode: 0 },
434 },
435 },
436 rawPayload: {},
437 });
438 yield* h.emitNative({ _tag: "ContentDelta", text: "The file says after.", rawPayload: {} });
439 yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" });
440 const result = yield* Fiber.join(sending);
441 yield* h.waitForEvent((event) => event.type === "turn.completed");
442 const deltas = h.seen.filter((event) => event.type === "content.delta");
443 expect(deltas.map((event) => event.payload.streamKind)).toEqual([
444 "reasoning_text",
445 "assistant_text",
446 ]);
447 expect(deltas.every((event) => event.turnId === result.turnId)).toBe(true);
448 const tool = h.seen.find(
449 (event) =>
450 event.type === "item.completed" && event.payload.itemType === "command_execution",
451 );
452 expect(tool?.type === "item.completed" ? tool.payload.data : undefined).toMatchObject({
453 command: "cat probe.txt",
454 cwd: "/tmp",
455 item: { aggregatedOutput: "after\n", exitCode: 0 },
456 });
457 }),
458 );
459
460 it.effect("does not auto-approve a remaining native request in full access", () =>
461 Effect.gen(function* () {
462 const h = yield* makeHarness();
463 yield* h.adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" });
464 const permission = yield* h
465 .invokePermission({
466 sessionId: nativeSessionId,
467 toolCall: { toolCallId: "write-1", kind: "edit", title: "Write probe.txt" },
468 options: [
469 { optionId: "native:allow", name: "Allow", kind: "allow_once" },
470 { optionId: "native:deny", name: "Deny", kind: "reject_once" },
471 ],
472 })
473 .pipe(Effect.forkChild);
474 const opened = yield* h.waitForEvent((event) => event.type === "request.opened");
475 expect(h.calls).toContain("mode:yolo");
476 expect(opened.payload.options).toEqual([
477 { decision: "accept", label: "Allow once" },
478 { decision: "decline", label: "Deny" },
479 { decision: "cancel", label: "Cancel" },
480 ]);
481 expect(permission.pollUnsafe()).toBeUndefined();
482 const always = yield* h.adapter
483 .respondToRequest(threadId, ApprovalRequestId.make(opened.requestId!), "acceptAlways")
484 .pipe(Effect.exit);
485 expect(Exit.isFailure(always)).toBe(true);
486 yield* h.adapter.respondToRequest(
487 threadId,
488 ApprovalRequestId.make(opened.requestId!),
489 "decline",
490 );
491 expect(yield* Fiber.join(permission)).toEqual({
492 outcome: { outcome: "selected", optionId: "native:deny" },
493 });
494 }),
495 );
496
497 it.effect("returns opaque native question choices and rejects ambiguous labels", () =>
498 Effect.gen(function* () {
499 const h = yield* makeHarness();
500 yield* h.adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" });
501 const question = yield* h
502 .invokePermission({
503 sessionId: nativeSessionId,
504 toolCall: { toolCallId: "interaction_opaque", title: "Which target?" },
505 options: [
506 { optionId: "choice:a", name: "Same label", kind: "allow_once" },
507 { optionId: "choice:b", name: "Same label", kind: "allow_once" },
508 ],
509 })
510 .pipe(Effect.forkChild);
511 const opened = yield* h.waitForEvent((event) => event.type === "user-input.requested");
512 expect(opened.payload.questions[0]?.allowCustomAnswer).toBe(false);
513 expect(opened.payload.questions[0]?.options.map((option) => option.value)).toEqual([
514 "choice:a",
515 "choice:b",
516 ]);
517 const invalid = yield* h.adapter
518 .respondToUserInput(threadId, ApprovalRequestId.make(opened.requestId!), {
519 interaction_opaque: "Same label",
520 })
521 .pipe(Effect.exit);
522 expect(Exit.isFailure(invalid)).toBe(true);
523 expect(question.pollUnsafe()).toBeUndefined();
524 yield* h.adapter.respondToUserInput(threadId, ApprovalRequestId.make(opened.requestId!), {
525 interaction_opaque: "choice:b",
526 });
527 expect(yield* Fiber.join(question)).toEqual({
528 outcome: { outcome: "selected", optionId: "choice:b" },
529 });
530 }),
531 );
532
533 it.effect("cancels native questions before waiting for the prompt to end", () =>
534 Effect.gen(function* () {
535 const h = yield* makeHarness();
536 yield* h.adapter.startSession({
537 threadId,
538 cwd: process.cwd(),
539 runtimeMode: "approval-required",
540 });
541 const sending = yield* h.adapter
542 .sendTurn({ threadId, input: "Ask a question" })
543 .pipe(Effect.forkChild);
544 yield* h.nextPrompt;
545 const question = yield* h
546 .invokePermission({
547 sessionId: nativeSessionId,
548 toolCall: { toolCallId: "interaction_cancel", title: "Continue?" },
549 options: [{ optionId: "yes", name: "Yes", kind: "allow_once" }],
550 })
551 .pipe(Effect.forkChild);
552 yield* h.waitForEvent((event) => event.type === "user-input.requested");
553 yield* h.adapter.interruptTurn(threadId);
554 expect(yield* Fiber.join(question)).toEqual({ outcome: { outcome: "cancelled" } });
555 yield* Fiber.join(sending);
556 const ended = yield* h.waitForEvent((event) => event.type === "turn.completed");
557 expect(ended.payload.state).toBe("cancelled");
558 expect(h.seen.some((event) => event.type === "user-input.resolved")).toBe(true);
559 }),
560 );
561
562 it.effect("waits for native cancellation before a steer changes the model", () =>
563 Effect.gen(function* () {
564 const h = yield* makeHarness({ holdCancel: true });
565 yield* h.adapter.startSession({
566 threadId,
567 cwd: process.cwd(),
568 runtimeMode: "approval-required",
569 });
570 const first = yield* h.adapter
571 .sendTurn({ threadId, input: "First prompt" })
572 .pipe(Effect.forkChild);
573 yield* h.nextPrompt;
574 const marker = h.calls.length;
575 const second = yield* h.adapter
576 .sendTurn({
577 threadId,
578 input: "Steer the turn",
579 modelSelection: { instanceId, model: nativeAlternative },
580 })
581 .pipe(Effect.forkChild);
582 expect(yield* h.nextCancellation).toBe(1);
583 expect(h.calls.slice(marker)).toEqual(["cancel:1"]);
584 yield* h.emitNative({
585 _tag: "ContentDelta",
586 text: "The first prompt stopped.",
587 rawPayload: {},
588 });
589 yield* Deferred.succeed(h.cancelRelease, undefined);
590 const replacement = yield* h.nextPrompt;
591 expect(h.calls.slice(marker)).toEqual([
592 "cancel:1",
593 "drained:1",
594 `model:${nativeAlternative}`,
595 "mode:default",
596 "prompt:2",
597 ]);
598 yield* Deferred.succeed(replacement.result, { stopReason: "end_turn" });
599 const [oldResult, newResult] = yield* Effect.all([Fiber.join(first), Fiber.join(second)]);
600 expect(oldResult.turnId).toBe(newResult.turnId);
601 yield* h.waitForEvent((event) => event.type === "turn.completed");
602 expect(h.seen.filter((event) => event.type === "turn.completed")).toHaveLength(1);
603 expect((yield* h.adapter.listSessions())[0]).toMatchObject({
604 status: "ready",
605 activeTurnId: undefined,
606 model: nativeAlternative,
607 });
608 }),
609 );
610
611 it.effect("rejects an unavailable steer model without cancelling current work", () =>
612 Effect.gen(function* () {
613 const h = yield* makeHarness();
614 yield* h.adapter.startSession({
615 threadId,
616 cwd: process.cwd(),
617 runtimeMode: "approval-required",
618 });
619 const first = yield* h.adapter
620 .sendTurn({ threadId, input: "Keep working" })
621 .pipe(Effect.forkChild);
622 const prompt = yield* h.nextPrompt;
623 const invalid = yield* h.adapter
624 .sendTurn({
625 threadId,
626 input: "Change model",
627 modelSelection: { instanceId, model: "not-in-this-account" },
628 })
629 .pipe(Effect.exit);
630 expect(Exit.isFailure(invalid)).toBe(true);
631 expect(h.calls.some((call) => call.startsWith("cancel:"))).toBe(false);
632 expect(h.hasActivePrompt()).toBe(true);
633 yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" });
634 yield* Fiber.join(first);
635 }),
636 );
637
638 it.effect("settles a failed steer configuration and allows a later turn", () =>
639 Effect.gen(function* () {
640 const h = yield* makeHarness();
641 yield* h.adapter.startSession({
642 threadId,
643 cwd: process.cwd(),
644 runtimeMode: "approval-required",
645 });
646 const first = yield* h.adapter.sendTurn({ threadId, input: "First" }).pipe(Effect.forkChild);
647 yield* h.nextPrompt;
648 h.controls.failModel = true;
649 const failed = yield* h.adapter
650 .sendTurn({
651 threadId,
652 input: "Replacement",
653 modelSelection: { instanceId, model: nativeAlternative },
654 })
655 .pipe(Effect.exit);
656 expect(Exit.isFailure(failed)).toBe(true);
657 yield* Fiber.join(first);
658 const ended = yield* h.waitForEvent((event) => event.type === "turn.completed");
659 expect(ended.payload.state).toBe("failed");
660 expect((yield* h.adapter.listSessions())[0]).toMatchObject({
661 status: "error",
662 activeTurnId: undefined,
663 });
664 const later = yield* h.adapter
665 .sendTurn({ threadId, input: "Try again" })
666 .pipe(Effect.forkChild);
667 const prompt = yield* h.nextPrompt;
668 yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" });
669 const recovered = yield* Fiber.join(later);
670 expect(recovered.turnId).not.toBe(ended.turnId);
671 expect((yield* h.adapter.listSessions())[0]?.status).toBe("ready");
672 }),
673 );
674
675 it.effect("cancels the native prompt if its send caller is interrupted", () =>
676 Effect.gen(function* () {
677 const h = yield* makeHarness();
678 yield* h.adapter.startSession({
679 threadId,
680 cwd: process.cwd(),
681 runtimeMode: "approval-required",
682 });
683 const sending = yield* h.adapter
684 .sendTurn({ threadId, input: "Keep working" })
685 .pipe(Effect.forkChild);
686 yield* h.nextPrompt;
687 yield* Fiber.interrupt(sending);
688 const ended = yield* h.waitForEvent((event) => event.type === "turn.completed");
689 expect(ended.payload.state).toBe("cancelled");
690 expect(h.hasActivePrompt()).toBe(false);
691 expect((yield* h.adapter.listSessions())[0]).toMatchObject({
692 status: "ready",
693 activeTurnId: undefined,
694 });
695 }),
696 );
697
698 it.effect("tracks native commands that survive a turn and clears terminal tasks", () =>
699 Effect.gen(function* () {
700 const h = yield* makeHarness();
701 yield* h.adapter.startSession({
702 threadId,
703 cwd: process.cwd(),
704 runtimeMode: "approval-required",
705 });
706 const sending = yield* h.adapter
707 .sendTurn({ threadId, input: "Start a watcher" })
708 .pipe(Effect.forkChild);
709 const prompt = yield* h.nextPrompt;
710 yield* h.emitNative({
711 _tag: "ToolCallUpdated",
712 toolCall: {
713 toolCallId: "watcher-1",
714 kind: "execute",
715 status: "inProgress",
716 command: "watch files",
717 data: {},
718 },
719 rawPayload: {},
720 });
721 yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" });
722 const turn = yield* Fiber.join(sending);
723 const started = yield* h.waitForEvent((event) => event.type === "task.started");
724 expect(started.payload.taskType).toBe("local_bash");
725 expect(started.turnId).toBe(turn.turnId);
726 yield* h.emitNative({
727 _tag: "ToolCallUpdated",
728 toolCall: { toolCallId: "watcher-1", kind: "execute", status: "completed", data: {} },
729 rawPayload: {},
730 });
731 const ended = yield* h.waitForEvent((event) => event.type === "task.completed");
732 expect(ended.payload.taskId).toBe(started.payload.taskId);
733 expect(ended.payload.status).toBe("completed");
734 }),
735 );
736
737 it.effect("retires a prompt cancelled before native dispatch", () =>
738 Effect.gen(function* () {
739 const h = yield* makeHarness({ holdDispatch: true });
740 yield* h.adapter.startSession({
741 threadId,
742 cwd: process.cwd(),
743 runtimeMode: "approval-required",
744 });
745 const sending = yield* h.adapter
746 .sendTurn({ threadId, input: "Do not dispatch this prompt" })
747 .pipe(Effect.forkChild);
748 yield* Deferred.await(h.dispatchStarted);
749 yield* Fiber.interrupt(sending);
750 yield* Deferred.succeed(h.dispatchRelease, undefined);
751 const cancelled = yield* h.waitForEvent((event) => event.type === "turn.completed");
752 expect(cancelled.payload.state).toBe("cancelled");
753 expect(h.calls.some((call) => call.startsWith("prompt:"))).toBe(false);
754 expect(h.hasActivePrompt()).toBe(false);
755 const later = yield* h.adapter
756 .sendTurn({ threadId, input: "This prompt can run" })
757 .pipe(Effect.forkChild);
758 const prompt = yield* h.nextPrompt;
759 yield* Deferred.succeed(prompt.result, { stopReason: "end_turn" });
760 const result = yield* Fiber.join(later);
761 expect(result.turnId).not.toBe(cancelled.turnId);
762 expect(h.calls.filter((call) => call.startsWith("prompt:"))).toEqual(["prompt:1"]);
763 }),
764 );
765
766 it.effect("awaits full process cleanup for concurrent stop requests", () =>
767 Effect.gen(function* () {
768 const h = yield* makeHarness({ holdClose: true });
769 yield* h.adapter.startSession({
770 threadId,
771 cwd: process.cwd(),
772 runtimeMode: "approval-required",
773 });
774 const stopping = yield* h.adapter.stopSession(threadId).pipe(Effect.forkChild);
775 yield* Deferred.await(h.closeStarted);
776 const registeredStop = h.stops[0];
777 if (!registeredStop) return yield* Effect.die("Missing process cleanup registration");
778 const signOutStop = yield* registeredStop.pipe(Effect.forkChild({ startImmediately: true }));
779 expect(signOutStop.pollUnsafe()).toBeUndefined();
780 yield* Deferred.succeed(h.closeRelease, undefined);
781 yield* Effect.all([Fiber.join(stopping), Fiber.join(signOutStop)]);
782 yield* h.waitForEvent((event) => event.type === "session.exited");
783 expect(h.controls.closed).toBe(1);
784 expect(h.seen.filter((event) => event.type === "session.exited")).toHaveLength(1);
785 expect(yield* h.adapter.hasSession(threadId)).toBe(false);
786 }),
787 );
788
789 it.effect("stops a session while its prompt is waiting to dispatch", () =>
790 Effect.gen(function* () {
791 const h = yield* makeHarness({ holdDispatch: true });
792 yield* h.adapter.startSession({
793 threadId,
794 cwd: process.cwd(),
795 runtimeMode: "approval-required",
796 });
797 const sending = yield* h.adapter
798 .sendTurn({ threadId, input: "Do not dispatch after stop" })
799 .pipe(Effect.forkChild);
800 yield* Deferred.await(h.dispatchStarted);
801 yield* h.adapter.stopSession(threadId);
802 yield* Fiber.await(sending);
803 yield* Deferred.succeed(h.dispatchRelease, undefined);
804 expect(h.calls.some((call) => call.startsWith("prompt:"))).toBe(false);
805 expect(h.controls.closed).toBe(1);
806 expect(yield* h.adapter.hasSession(threadId)).toBe(false);
807 }),
808 );
809
810 it.effect("propagates idle process exits and rejects stale session use", () =>
811 Effect.gen(function* () {
812 const h = yield* makeHarness();
813 yield* h.adapter.startSession({
814 threadId,
815 cwd: process.cwd(),
816 runtimeMode: "approval-required",
817 });
818 yield* h.emitNative({
819 _tag: "ConnectionTerminated",
820 error: new AcpErrors.AcpTransportError({ detail: "Process exited.", cause: undefined }),
821 });
822 const exited = yield* h.waitForEvent((event) => event.type === "session.exited");
823 expect(exited.payload.exitKind).toBe("error");
824 expect(yield* h.adapter.hasSession(threadId)).toBe(false);
825 expect(
826 Exit.isFailure(yield* h.adapter.sendTurn({ threadId, input: "Hello" }).pipe(Effect.exit)),
827 ).toBe(true);
828 }),
829 );
830
831 it.effect("reports hidden login requests as sign-in required and clears account metadata", () =>
832 Effect.gen(function* () {
833 const h = yield* makeHarness();
834 h.controls.failAuth = true;
835 const started = yield* h.adapter
836 .startSession({ threadId, cwd: process.cwd(), runtimeMode: "approval-required" })
837 .pipe(Effect.exit);
838 expect(Exit.isFailure(started)).toBe(true);
839 expect(h.controls.authInvalidations).toBe(1);
840 expect(h.controls.closed).toBe(1);
841 expect(yield* h.adapter.hasSession(threadId)).toBe(false);
842 }),
843 );
844
845 it.effect("serves client file reads and writes only inside the session roots", () =>
846 Effect.gen(function* () {
847 const fs = yield* FileSystem.FileSystem;
848 const path = yield* Path.Path;
849 const h = yield* makeHarness();
850 const { attachmentsDir } = yield* ServerConfig;
851 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-fs-" });
852 const outside = yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-outside-" });
853 yield* fs.writeFileString(path.join(cwd, "notes.txt"), "one\ntwo\nthree\n");
854 yield* h.adapter.startSession({ threadId, cwd, runtimeMode: "approval-required" });
855 expect(h.launches[0]?.clientFileSystem).toBe(true);
856 expect(h.launches[0]?.additionalDirectories).toEqual([attachmentsDir]);
857 const read = h.fileHandlers.read;
858 const write = h.fileHandlers.write;
859 if (!read || !write) return yield* Effect.die("File handlers were not registered.");
860
861 const full = yield* read({ sessionId: nativeSessionId, path: path.join(cwd, "notes.txt") });
862 expect(full.content).toBe("one\ntwo\nthree\n");
863 const window = yield* read({
864 sessionId: nativeSessionId,
865 path: path.join(cwd, "notes.txt"),
866 line: 2,
867 limit: 1,
868 });
869 expect(window.content).toBe("two");
870
871 yield* write({
872 sessionId: nativeSessionId,
873 path: path.join(cwd, "nested", "new.txt"),
874 content: "created",
875 });
876 expect(yield* fs.readFileString(path.join(cwd, "nested", "new.txt"))).toBe("created");
877
878 const escape = yield* write({
879 sessionId: nativeSessionId,
880 path: path.join(outside, "escape.txt"),
881 content: "nope",
882 }).pipe(Effect.flip);
883 expect(escape._tag).toBe("AcpRequestError");
884 expect(yield* fs.exists(path.join(outside, "escape.txt"))).toBe(false);
885 const missing = yield* read({
886 sessionId: nativeSessionId,
887 path: path.join(cwd, "missing.txt"),
888 }).pipe(Effect.flip);
889 expect(missing._tag).toBe("AcpRequestError");
890 }).pipe(Effect.scoped),
891 );
892
893 it.effect("does not launch a process for a disabled instance or invalid resume cursor", () =>
894 Effect.gen(function* () {
895 const disabled = yield* makeHarness({ enabled: false });
896 const rejected = yield* disabled.adapter
897 .startSession({ threadId, cwd: process.cwd(), runtimeMode: "approval-required" })
898 .pipe(Effect.exit);
899 expect(Exit.isFailure(rejected)).toBe(true);
900 expect(disabled.launches).toHaveLength(0);
901 const active = yield* makeHarness();
902 const stale = yield* active.adapter
903 .startSession({
904 threadId,
905 cwd: process.cwd(),
906 runtimeMode: "approval-required",
907 resumeCursor: { sessionId: nativeSessionId },
908 })
909 .pipe(Effect.exit);
910 expect(Exit.isFailure(stale)).toBe(true);
911 expect(active.launches).toHaveLength(0);
912 }),
913 );
914});
apps/server/src/provider/Layers/AntigravityAdapter.ts created+1121
......@@ -0,0 +1,1121 @@
1import {
2 ANTIGRAVITY_DEFAULT_MODEL,
3 ApprovalRequestId,
4 EventId,
5 ProviderDriverKind,
6 ProviderInstanceId,
7 RuntimeRequestId,
8 RuntimeTaskId,
9 TurnId,
10 type AntigravitySettings,
11 type ProviderApprovalDecision,
12 type ProviderRuntimeEvent,
13 type ProviderSession,
14 type ProviderSetupError,
15 type ProviderUserInputAnswers,
16 type ThreadId,
17 type TurnCompletedPayload,
18} from "@t3tools/contracts";
19import * as Cause from "effect/Cause";
20import * as Crypto from "effect/Crypto";
21import * as DateTime from "effect/DateTime";
22import * as Deferred from "effect/Deferred";
23import * as Effect from "effect/Effect";
24import * as Exit from "effect/Exit";
25import * as Fiber from "effect/Fiber";
26import * as FileSystem from "effect/FileSystem";
27import * as Option from "effect/Option";
28import * as Path from "effect/Path";
29import * as PubSub from "effect/PubSub";
30import * as Schema from "effect/Schema";
31import * as Scope from "effect/Scope";
32import * as Semaphore from "effect/Semaphore";
33import * as Stream from "effect/Stream";
34import * as SynchronizedRef from "effect/SynchronizedRef";
35import * as EffectAcpErrors from "effect-acp/errors";
36import type * as EffectAcpSchema from "effect-acp/schema";
37
38import { ServerConfig } from "../../config.ts";
39import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
40import type { AntigravityAuth } from "../AntigravityAuth.ts";
41import {
42 ProviderAdapterRequestError,
43 ProviderAdapterSessionClosedError,
44 ProviderAdapterSessionNotFoundError,
45 ProviderAdapterValidationError,
46 type ProviderAdapterError,
47} from "../Errors.ts";
48import {
49 ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE,
50 isAntigravitySignInRequiredError,
51} from "../antigravityAuthSupport.ts";
52import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts";
53import {
54 makeAcpAssistantItemEvent,
55 makeAcpContentDeltaEvent,
56 makeAcpPlanUpdatedEvent,
57 makeAcpRequestOpenedEvent,
58 makeAcpRequestResolvedEvent,
59 makeAcpToolCallEvent,
60} from "../acp/AcpCoreRuntimeEvents.ts";
61import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts";
62import { parsePermissionRequest, type AcpToolCallState } from "../acp/AcpRuntimeModel.ts";
63import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts";
64import {
65 antigravityPermissionMode,
66 antigravityModelOptions,
67 applyAntigravityAcpModelSelection,
68 buildAntigravityPrompt,
69 type AntigravityAcpRuntimeInput,
70 resolveAntigravityModel,
71} from "../acp/AntigravityAcpSupport.ts";
72import {
73 antigravityApprovalOptions,
74 extractAntigravityUserInputQuestion,
75 isAntigravityOpenCommand,
76 isAntigravityUserInputRequest,
77 makeAntigravityUserInputResponse,
78 normalizeAntigravityToolCall,
79 sanitizeAntigravityToolPayload,
80 selectAntigravityPermissionOptionId,
81} from "../acp/AntigravityProtocol.ts";
82import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
83import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts";
84
85const PROVIDER = ProviderDriverKind.make("antigravity");
86const ResumeCursor = Schema.Struct({
87 schemaVersion: Schema.Literal(1),
88 sessionId: Schema.NonEmptyString,
89});
90const decodeResumeCursor = Schema.decodeUnknownOption(ResumeCursor);
91const isAcpError = Schema.is(EffectAcpErrors.AcpError);
92
93type Adapter = ProviderAdapterShape<ProviderAdapterError>;
94type Runtime = Pick<
95 AcpSessionRuntime.AcpSessionRuntime["Service"],
96 | "handleRequestPermission"
97 | "handleReadTextFile"
98 | "handleWriteTextFile"
99 | "start"
100 | "setMode"
101 | "setModel"
102 | "getConfigOptions"
103 | "getEvents"
104 | "drainEvents"
105 | "prompt"
106 | "cancel"
107>;
108type NativePermission = EffectAcpSchema.RequestPermissionRequest;
109type NativePermissionResponse = EffectAcpSchema.RequestPermissionResponse;
110
111function mapAntigravityError(threadId: ThreadId, method: string, cause: EffectAcpErrors.AcpError) {
112 return isAntigravitySignInRequiredError(cause)
113 ? new ProviderAdapterRequestError({
114 provider: PROVIDER,
115 method,
116 detail: ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE,
117 cause,
118 })
119 : mapAcpToAdapterError(PROVIDER, threadId, method, cause);
120}
121
122export interface AntigravityAdapterOptions {
123 readonly instanceId: ProviderInstanceId;
124 readonly makeRuntime: (
125 input: Omit<AntigravityAcpRuntimeInput, "spawn" | "childProcessSpawner" | "onAuthorizationUrl">,
126 ) => Effect.Effect<Runtime, EffectAcpErrors.AcpError | ProviderSetupError, Scope.Scope>;
127 readonly withProcess: AntigravityAuth["withProcess"];
128 readonly onSessionStarted?: (
129 started: AcpSessionRuntime.AcpSessionRuntimeStartResult,
130 cwd: string,
131 ) => Effect.Effect<void>;
132 readonly onAvailableCommands?: (
133 commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>,
134 cwd: string,
135 ) => Effect.Effect<void>;
136 readonly onAuthRequired?: Effect.Effect<void>;
137 /** Model the provider default alias selects, when the account offers it. */
138 readonly defaultModel?: Effect.Effect<string | undefined>;
139 readonly nativeEventLogger?: EventNdjsonLogger;
140}
141
142interface PendingApproval {
143 readonly request: NativePermission;
144 readonly response: Deferred.Deferred<{
145 readonly decision: ProviderApprovalDecision;
146 readonly result: NativePermissionResponse;
147 }>;
148}
149
150interface PendingQuestion {
151 readonly request: NativePermission;
152 readonly response: Deferred.Deferred<{
153 readonly answers: ProviderUserInputAnswers;
154 readonly result: NativePermissionResponse;
155 }>;
156}
157
158interface OpenCommand {
159 readonly toolCall: AcpToolCallState;
160 readonly turnId: TurnId | undefined;
161 readonly promoted: boolean;
162}
163
164interface TurnIntent {
165 readonly turnId: TurnId;
166 readonly generation: number;
167 settled: boolean;
168}
169
170interface SessionContext {
171 readonly threadId: ThreadId;
172 readonly cwd: string;
173 readonly nativeSessionId: string;
174 readonly scope: Scope.Closeable;
175 readonly runtime: Runtime;
176 readonly promptLock: Semaphore.Semaphore;
177 readonly stopLock: Semaphore.Semaphore;
178 readonly commandLock: Semaphore.Semaphore;
179 readonly approvals: Map<ApprovalRequestId, PendingApproval>;
180 readonly questions: Map<ApprovalRequestId, PendingQuestion>;
181 readonly commands: Map<string, OpenCommand>;
182 readonly turns: Array<{ id: TurnId; items: Array<unknown> }>;
183 session: ProviderSession;
184 activeTurnId: TurnId | undefined;
185 promptFiber: Fiber.Fiber<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError> | undefined;
186 generation: number;
187 stopped: boolean;
188 closed: boolean;
189 disconnected: boolean;
190}
191
192const CLIENT_FILE_MAX_BYTES = 8 * 1024 * 1024;
193
194function isInsideRoot(path: Path.Path, root: string, candidate: string): boolean {
195 const relative = path.relative(root, candidate);
196 return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
197}
198
199/** Resolves an agent-supplied path and rejects anything outside the session roots. */
200const resolveClientFilePath = Effect.fn("AntigravityAdapter.resolveClientFilePath")(
201 function* (input: {
202 readonly fileSystem: FileSystem.FileSystem;
203 readonly path: Path.Path;
204 readonly allowedRoots: ReadonlyArray<string>;
205 readonly requestPath: string;
206 }) {
207 const { path } = input;
208 const resolved = path.resolve(input.requestPath);
209 // Follow symlinks on the parent so a link out of the workspace cannot escape it.
210 const parent = yield* input.fileSystem
211 .realPath(path.dirname(resolved))
212 .pipe(Effect.orElseSucceed(() => path.dirname(resolved)));
213 const real = path.join(parent, path.basename(resolved));
214 const roots = yield* Effect.forEach(input.allowedRoots, (root) =>
215 input.fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)),
216 );
217 if (!roots.some((root) => isInsideRoot(path, root, real))) {
218 return yield* EffectAcpErrors.AcpRequestError.invalidParams(
219 `Path '${input.requestPath}' is outside the session workspace.`,
220 );
221 }
222 return real;
223 },
224);
225
226const readClientTextFile = Effect.fn("AntigravityAdapter.readClientTextFile")(function* (input: {
227 readonly fileSystem: FileSystem.FileSystem;
228 readonly path: Path.Path;
229 readonly allowedRoots: ReadonlyArray<string>;
230 readonly request: EffectAcpSchema.ReadTextFileRequest;
231}): Effect.fn.Return<EffectAcpSchema.ReadTextFileResponse, EffectAcpErrors.AcpError> {
232 const filePath = yield* resolveClientFilePath({ ...input, requestPath: input.request.path });
233 const info = yield* input.fileSystem
234 .stat(filePath)
235 .pipe(
236 Effect.mapError(() =>
237 EffectAcpErrors.AcpRequestError.resourceNotFound(`File '${input.request.path}' not found.`),
238 ),
239 );
240 if (info.type !== "File" || Number(info.size) > CLIENT_FILE_MAX_BYTES) {
241 return yield* EffectAcpErrors.AcpRequestError.invalidParams(
242 `File '${input.request.path}' is not a readable text file under ${CLIENT_FILE_MAX_BYTES} bytes.`,
243 );
244 }
245 const text = yield* input.fileSystem
246 .readFileString(filePath)
247 .pipe(
248 Effect.mapError(() =>
249 EffectAcpErrors.AcpRequestError.internalError(`Could not read '${input.request.path}'.`),
250 ),
251 );
252 const line = input.request.line ?? undefined;
253 const limit = input.request.limit ?? undefined;
254 if (line === undefined && limit === undefined) {
255 return { content: text };
256 }
257 // ACP lines are 1-indexed. `limit` is a line count.
258 const lines = text.split("\n");
259 const start = Math.max(0, (line ?? 1) - 1);
260 const end = limit === undefined ? lines.length : Math.min(lines.length, start + limit);
261 return { content: lines.slice(start, end).join("\n") };
262});
263
264const writeClientTextFile = Effect.fn("AntigravityAdapter.writeClientTextFile")(function* (input: {
265 readonly fileSystem: FileSystem.FileSystem;
266 readonly path: Path.Path;
267 readonly allowedRoots: ReadonlyArray<string>;
268 readonly request: EffectAcpSchema.WriteTextFileRequest;
269}): Effect.fn.Return<EffectAcpSchema.WriteTextFileResponse, EffectAcpErrors.AcpError> {
270 const filePath = yield* resolveClientFilePath({ ...input, requestPath: input.request.path });
271 yield* input.fileSystem.makeDirectory(input.path.dirname(filePath), { recursive: true }).pipe(
272 Effect.andThen(input.fileSystem.writeFileString(filePath, input.request.content)),
273 Effect.mapError(() =>
274 EffectAcpErrors.AcpRequestError.internalError(`Could not write '${input.request.path}'.`),
275 ),
276 );
277 return {};
278});
279
280/** Keeps one official ACP process per thread and drains a cancelled prompt before steering. */
281export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(function* (
282 settings: AntigravitySettings,
283 options: AntigravityAdapterOptions,
284) {
285 const crypto = yield* Crypto.Crypto;
286 const fileSystem = yield* FileSystem.FileSystem;
287 const path = yield* Path.Path;
288 const serverConfig = yield* ServerConfig;
289 const ownerScope = yield* Effect.scope;
290 const makeNativeLoggers = yield* makeAcpNativeLoggerFactory();
291 const sessions = new Map<ThreadId, SessionContext>();
292 const locks = yield* SynchronizedRef.make(new Map<ThreadId, Semaphore.Semaphore>());
293 const events = yield* PubSub.unbounded<ProviderRuntimeEvent>();
294 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
295 const randomId = crypto.randomUUIDv4.pipe(
296 Effect.mapError(
297 (cause) =>
298 new ProviderAdapterRequestError({
299 provider: PROVIDER,
300 method: "crypto/randomUUIDv4",
301 detail: "Could not create an Antigravity event ID.",
302 cause,
303 }),
304 ),
305 );
306 const stamp = Effect.all({
307 eventId: Effect.map(randomId, EventId.make),
308 createdAt: nowIso,
309 });
310 const emit = (event: ProviderRuntimeEvent) => PubSub.publish(events, event).pipe(Effect.asVoid);
311
312 const withThreadLock = <A, E, R>(threadId: ThreadId, task: Effect.Effect<A, E, R>) =>
313 SynchronizedRef.modifyEffect(locks, (current) => {
314 const existing = current.get(threadId);
315 if (existing) return Effect.succeed([existing, current] as const);
316 return Semaphore.make(1).pipe(
317 Effect.map((lock) => [lock, new Map(current).set(threadId, lock)] as const),
318 );
319 }).pipe(Effect.flatMap((lock) => lock.withPermit(task)));
320
321 const requireSession = (threadId: ThreadId) => {
322 const context = sessions.get(threadId);
323 return context && !context.stopped
324 ? Effect.succeed(context)
325 : Effect.fail(new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }));
326 };
327
328 const cancelRequests = Effect.fn("AntigravityAdapter.cancelRequests")(function* (
329 context: SessionContext,
330 ) {
331 for (const pending of context.approvals.values()) {
332 yield* Deferred.succeed(pending.response, {
333 decision: "cancel",
334 result: { outcome: { outcome: "cancelled" } },
335 });
336 }
337 for (const pending of context.questions.values()) {
338 yield* Deferred.succeed(pending.response, {
339 answers: {},
340 result: { outcome: { outcome: "cancelled" } },
341 });
342 }
343 });
344
345 const finishBackgroundCommands = (context: SessionContext) =>
346 context.commandLock.withPermit(
347 Effect.gen(function* () {
348 for (const [id, command] of context.commands) {
349 if (!command.promoted) continue;
350 yield* emit({
351 type: "task.completed",
352 ...(yield* stamp),
353 provider: PROVIDER,
354 threadId: context.threadId,
355 turnId: command.turnId,
356 payload: {
357 taskId: RuntimeTaskId.make(id),
358 taskType: "local_bash",
359 toolUseId: id,
360 status: "stopped",
361 },
362 });
363 }
364 context.commands.clear();
365 }),
366 );
367
368 const stopContext = (context: SessionContext) =>
369 context.stopLock
370 .withPermit(
371 Effect.gen(function* () {
372 if (context.closed) return;
373 context.stopped = true;
374 yield* Effect.gen(function* () {
375 yield* cancelRequests(context);
376 if (context.promptFiber && !context.disconnected) {
377 yield* Effect.ignore(context.runtime.cancel);
378 }
379 }).pipe(Effect.ensuring(Scope.close(context.scope, Exit.void)));
380 context.closed = true;
381 if (sessions.get(context.threadId) === context) sessions.delete(context.threadId);
382 yield* finishBackgroundCommands(context);
383 yield* emit({
384 type: "session.exited",
385 ...(yield* stamp),
386 provider: PROVIDER,
387 threadId: context.threadId,
388 payload: {
389 exitKind: context.disconnected ? "error" : "graceful",
390 ...(context.disconnected ? { reason: "Antigravity process stopped." } : {}),
391 },
392 });
393 }),
394 )
395 .pipe(Effect.uninterruptible);
396
397 const handlePermission = Effect.fn("AntigravityAdapter.handlePermission")(function* (
398 context: SessionContext,
399 request: NativePermission,
400 ): Effect.fn.Return<NativePermissionResponse, ProviderAdapterError> {
401 if (context.stopped || request.sessionId !== context.nativeSessionId) {
402 return { outcome: { outcome: "cancelled" } };
403 }
404 const requestId = ApprovalRequestId.make(yield* randomId);
405 const runtimeRequestId = RuntimeRequestId.make(requestId);
406 const turnId = context.activeTurnId;
407 const rawPayload = sanitizeAntigravityToolPayload(request);
408
409 if (isAntigravityUserInputRequest(request)) {
410 const question = extractAntigravityUserInputQuestion(request);
411 if (!question) return { outcome: { outcome: "cancelled" } };
412 const response = yield* Deferred.make<{
413 answers: ProviderUserInputAnswers;
414 result: NativePermissionResponse;
415 }>();
416 context.questions.set(requestId, { request, response });
417 return yield* Effect.gen(function* () {
418 yield* emit({
419 type: "user-input.requested",
420 ...(yield* stamp),
421 provider: PROVIDER,
422 threadId: context.threadId,
423 turnId,
424 requestId: runtimeRequestId,
425 payload: { questions: [question] },
426 raw: { source: "acp.jsonrpc", method: "session/request_permission", payload: rawPayload },
427 });
428 const answer = yield* Deferred.await(response);
429 yield* emit({
430 type: "user-input.resolved",
431 ...(yield* stamp),
432 provider: PROVIDER,
433 threadId: context.threadId,
434 turnId,
435 requestId: runtimeRequestId,
436 payload: { answers: answer.answers },
437 });
438 return answer.result;
439 }).pipe(Effect.ensuring(Effect.sync(() => context.questions.delete(requestId))));
440 }
441
442 const response = yield* Deferred.make<{
443 decision: ProviderApprovalDecision;
444 result: NativePermissionResponse;
445 }>();
446 context.approvals.set(requestId, { request, response });
447 const parsed = parsePermissionRequest(request);
448 const toolCall = parsed.toolCall ? normalizeAntigravityToolCall(parsed.toolCall) : undefined;
449 const permissionRequest = {
450 ...parsed,
451 ...(toolCall ? { toolCall } : {}),
452 detail:
453 toolCall?.command ??
454 toolCall?.detail ??
455 toolCall?.title ??
456 "Antigravity requests permission.",
457 };
458 return yield* Effect.gen(function* () {
459 yield* emit(
460 makeAcpRequestOpenedEvent({
461 stamp: yield* stamp,
462 provider: PROVIDER,
463 threadId: context.threadId,
464 turnId,
465 requestId: runtimeRequestId,
466 permissionRequest,
467 approvalOptions: antigravityApprovalOptions(request),
468 detail: permissionRequest.detail ?? "Antigravity requests permission.",
469 args: rawPayload,
470 source: "acp.jsonrpc",
471 method: "session/request_permission",
472 rawPayload,
473 }),
474 );
475 const answer = yield* Deferred.await(response);
476 yield* emit(
477 makeAcpRequestResolvedEvent({
478 stamp: yield* stamp,
479 provider: PROVIDER,
480 threadId: context.threadId,
481 turnId,
482 requestId: runtimeRequestId,
483 permissionRequest,
484 decision: answer.decision,
485 }),
486 );
487 return answer.result;
488 }).pipe(Effect.ensuring(Effect.sync(() => context.approvals.delete(requestId))));
489 });
490
491 const handleEvent = Effect.fn("AntigravityAdapter.handleEvent")(function* (
492 context: SessionContext,
493 event: AcpSessionRuntime.AcpSessionRuntimeEvent,
494 ) {
495 if (event._tag === "EventStreamBarrier") {
496 yield* Deferred.succeed(event.acknowledge, undefined);
497 return;
498 }
499 if (context.stopped) return;
500 switch (event._tag) {
501 case "ModeChanged":
502 return;
503 case "AvailableCommandsUpdated":
504 yield* options.onAvailableCommands?.(event.availableCommands, context.cwd) ?? Effect.void;
505 return;
506 case "ConnectionTerminated":
507 context.stopped = true;
508 context.disconnected = true;
509 yield* stopContext(context).pipe(Effect.forkIn(ownerScope));
510 return;
511 case "AssistantItemStarted":
512 case "AssistantItemCompleted":
513 yield* emit(
514 makeAcpAssistantItemEvent({
515 stamp: yield* stamp,
516 provider: PROVIDER,
517 threadId: context.threadId,
518 turnId: context.activeTurnId,
519 itemId: event.itemId,
520 lifecycle: event._tag === "AssistantItemStarted" ? "item.started" : "item.completed",
521 }),
522 );
523 return;
524 case "ThoughtDelta":
525 case "ContentDelta":
526 yield* emit(
527 makeAcpContentDeltaEvent({
528 stamp: yield* stamp,
529 provider: PROVIDER,
530 threadId: context.threadId,
531 turnId: context.activeTurnId,
532 ...(event._tag === "ContentDelta" && event.itemId ? { itemId: event.itemId } : {}),
533 ...(event._tag === "ThoughtDelta" ? { streamKind: "reasoning_text" } : {}),
534 text: event.text,
535 rawPayload: sanitizeAntigravityToolPayload(event.rawPayload),
536 }),
537 );
538 return;
539 case "PlanUpdated":
540 yield* emit(
541 makeAcpPlanUpdatedEvent({
542 stamp: yield* stamp,
543 provider: PROVIDER,
544 threadId: context.threadId,
545 turnId: context.activeTurnId,
546 payload: event.payload,
547 source: "acp.jsonrpc",
548 method: "session/update",
549 rawPayload: sanitizeAntigravityToolPayload(event.rawPayload),
550 }),
551 );
552 return;
553 case "ToolCallUpdated":
554 yield* context.commandLock.withPermit(
555 Effect.gen(function* () {
556 const toolCall = normalizeAntigravityToolCall(event.toolCall);
557 const existing = context.commands.get(toolCall.toolCallId);
558 yield* emit(
559 makeAcpToolCallEvent({
560 stamp: yield* stamp,
561 provider: PROVIDER,
562 threadId: context.threadId,
563 turnId: existing?.turnId ?? context.activeTurnId,
564 toolCall,
565 rawPayload: sanitizeAntigravityToolPayload(event.rawPayload),
566 }),
567 );
568 if (isAntigravityOpenCommand(toolCall)) {
569 context.commands.set(toolCall.toolCallId, {
570 toolCall,
571 turnId: existing?.turnId ?? context.activeTurnId,
572 promoted: existing?.promoted ?? false,
573 });
574 } else if (toolCall.status === "completed" || toolCall.status === "failed") {
575 context.commands.delete(toolCall.toolCallId);
576 if (existing?.promoted) {
577 yield* emit({
578 type: "task.completed",
579 ...(yield* stamp),
580 provider: PROVIDER,
581 threadId: context.threadId,
582 turnId: existing.turnId,
583 payload: {
584 taskId: RuntimeTaskId.make(toolCall.toolCallId),
585 taskType: "local_bash",
586 toolUseId: toolCall.toolCallId,
587 status: toolCall.status === "failed" ? "failed" : "completed",
588 },
589 });
590 }
591 }
592 }),
593 );
594 return;
595 }
596 });
597
598 const startSession: Adapter["startSession"] = (input) =>
599 withThreadLock(
600 input.threadId,
601 Effect.gen(function* () {
602 if (!settings.enabled) {
603 return yield* new ProviderAdapterValidationError({
604 provider: PROVIDER,
605 operation: "startSession",
606 issue: "Enable Antigravity in provider settings before starting a thread.",
607 });
608 }
609 if (
610 (input.provider !== undefined && input.provider !== PROVIDER) ||
611 (input.providerInstanceId !== undefined &&
612 input.providerInstanceId !== options.instanceId) ||
613 (input.modelSelection !== undefined &&
614 input.modelSelection.instanceId !== options.instanceId)
615 ) {
616 return yield* new ProviderAdapterValidationError({
617 provider: PROVIDER,
618 operation: "startSession",
619 issue: "The Antigravity provider instance does not match the requested session.",
620 });
621 }
622 if (!input.cwd?.trim()) {
623 return yield* new ProviderAdapterValidationError({
624 provider: PROVIDER,
625 operation: "startSession",
626 issue: "The session requires a workspace directory.",
627 });
628 }
629 const cursor = decodeResumeCursor(input.resumeCursor);
630 if (input.resumeCursor !== undefined && Option.isNone(cursor)) {
631 return yield* new ProviderAdapterValidationError({
632 provider: PROVIDER,
633 operation: "startSession",
634 issue: "The saved Antigravity session is invalid. Start a new thread.",
635 });
636 }
637 const previous = sessions.get(input.threadId);
638 if (previous) yield* stopContext(previous);
639 const cwd = path.resolve(input.cwd);
640 const sessionScope = yield* Scope.make("sequential");
641 let transferred = false;
642 let context: SessionContext | undefined;
643 yield* Effect.addFinalizer(() => {
644 if (transferred) return Effect.void;
645 sessions.delete(input.threadId);
646 return Scope.close(sessionScope, Exit.void);
647 });
648 const stopOwned = Effect.suspend(() =>
649 context ? stopContext(context).pipe(Effect.ignore) : Scope.close(sessionScope, Exit.void),
650 );
651
652 return yield* options
653 .withProcess(
654 stopOwned,
655 Effect.gen(function* () {
656 const mcp = McpProviderSession.readMcpProviderSession(input.threadId);
657 // The attachments dir grant lets the agent read pasted files at
658 // the paths ProviderService injects into the turn text. It is a
659 // leaf directory holding only uploads.
660 const runtime = yield* options.makeRuntime({
661 cwd,
662 clientInfo: { name: "t3-code", version: "0.0.0" },
663 clientFileSystem: true,
664 additionalDirectories: [serverConfig.attachmentsDir],
665 ...(Option.isSome(cursor) ? { resumeSessionId: cursor.value.sessionId } : {}),
666 mcpServers: mcp
667 ? [
668 {
669 type: "http",
670 name: "t3-code",
671 url: mcp.endpoint,
672 headers: [{ name: "Authorization", value: mcp.authorizationHeader }],
673 },
674 ]
675 : [],
676 ...makeNativeLoggers({
677 nativeEventLogger: options.nativeEventLogger,
678 provider: PROVIDER,
679 threadId: input.threadId,
680 }),
681 });
682 // Workspace file access requested through the client fs
683 // capability. The agent gates each write behind
684 // `session/request_permission`, so only path containment is
685 // checked here.
686 const allowedRoots = [cwd, serverConfig.attachmentsDir];
687 yield* runtime.handleReadTextFile((request) =>
688 readClientTextFile({ fileSystem, path, allowedRoots, request }),
689 );
690 yield* runtime.handleWriteTextFile((request) =>
691 writeClientTextFile({ fileSystem, path, allowedRoots, request }),
692 );
693 yield* runtime.handleRequestPermission((request) =>
694 context
695 ? handlePermission(context, request).pipe(
696 Effect.mapError((cause) =>
697 EffectAcpErrors.AcpRequestError.internalError(
698 "Could not process an Antigravity permission request.",
699 undefined,
700 { cause },
701 ),
702 ),
703 )
704 : Effect.succeed({
705 outcome: { outcome: "cancelled" },
706 } satisfies NativePermissionResponse),
707 );
708 const started = yield* runtime.start();
709 const model = yield* applyAntigravityAcpModelSelection({
710 runtime,
711 model: input.modelSelection?.model,
712 defaultModel: yield* options.defaultModel ?? Effect.succeed(undefined),
713 mapError: (cause) => cause,
714 });
715 yield* runtime.setMode(antigravityPermissionMode(input.runtimeMode));
716 yield* options.onSessionStarted?.(started, cwd) ?? Effect.void;
717 const createdAt = yield* nowIso;
718 const session: ProviderSession = {
719 provider: PROVIDER,
720 providerInstanceId: options.instanceId,
721 threadId: input.threadId,
722 cwd,
723 status: "ready",
724 runtimeMode: input.runtimeMode,
725 ...(model ? { model } : {}),
726 resumeCursor: { schemaVersion: 1, sessionId: started.sessionId },
727 createdAt,
728 updatedAt: createdAt,
729 };
730 context = {
731 threadId: input.threadId,
732 cwd,
733 nativeSessionId: started.sessionId,
734 scope: sessionScope,
735 runtime,
736 promptLock: yield* Semaphore.make(1),
737 stopLock: yield* Semaphore.make(1),
738 commandLock: yield* Semaphore.make(1),
739 approvals: new Map(),
740 questions: new Map(),
741 commands: new Map(),
742 turns: [],
743 session,
744 activeTurnId: undefined,
745 promptFiber: undefined,
746 generation: 0,
747 stopped: false,
748 closed: false,
749 disconnected: false,
750 };
751 const running = context;
752 sessions.set(input.threadId, running);
753 yield* Stream.runForEach(runtime.getEvents(), (event) =>
754 handleEvent(running, event),
755 ).pipe(
756 Effect.catchCause(() =>
757 Effect.logError("Could not process an Antigravity runtime event."),
758 ),
759 Effect.forkIn(sessionScope),
760 );
761 yield* emit({
762 type: "session.started",
763 ...(yield* stamp),
764 provider: PROVIDER,
765 threadId: input.threadId,
766 payload: { resume: started.initializeResult },
767 });
768 yield* emit({
769 type: "session.state.changed",
770 ...(yield* stamp),
771 provider: PROVIDER,
772 threadId: input.threadId,
773 payload: { state: "ready", reason: "Antigravity ACP session ready" },
774 });
775 yield* emit({
776 type: "thread.started",
777 ...(yield* stamp),
778 provider: PROVIDER,
779 threadId: input.threadId,
780 payload: { providerThreadId: started.sessionId },
781 });
782 yield* runtime.drainEvents;
783 if (running.stopped) {
784 return yield* new ProviderAdapterSessionClosedError({
785 provider: PROVIDER,
786 threadId: input.threadId,
787 });
788 }
789 transferred = true;
790 return session;
791 }),
792 )
793 .pipe(
794 Effect.provideService(Scope.Scope, sessionScope),
795 Effect.tapError((cause) =>
796 isAntigravitySignInRequiredError(cause)
797 ? (options.onAuthRequired ?? Effect.void)
798 : Effect.void,
799 ),
800 Effect.mapError((cause) =>
801 isAcpError(cause)
802 ? mapAntigravityError(input.threadId, "session/start", cause)
803 : new ProviderAdapterRequestError({
804 provider: PROVIDER,
805 method: "session/start",
806 detail: "Could not start Antigravity. Check the provider setup status.",
807 cause,
808 }),
809 ),
810 );
811 }).pipe(Effect.scoped),
812 );
813
814 const promoteBackgroundCommands = (context: SessionContext) =>
815 context.commandLock.withPermit(
816 Effect.gen(function* () {
817 for (const [id, command] of context.commands) {
818 if (command.promoted) continue;
819 yield* emit({
820 type: "task.started",
821 ...(yield* stamp),
822 provider: PROVIDER,
823 threadId: context.threadId,
824 turnId: command.turnId,
825 payload: {
826 taskId: RuntimeTaskId.make(id),
827 taskType: "local_bash",
828 toolUseId: id,
829 description:
830 command.toolCall.command ?? command.toolCall.title ?? "Antigravity command",
831 },
832 });
833 context.commands.set(id, { ...command, promoted: true });
834 }
835 }),
836 );
837
838 const sendTurn: Adapter["sendTurn"] = Effect.fn("AntigravityAdapter.sendTurn")(function* (input) {
839 const context = yield* requireSession(input.threadId);
840 if (input.modelSelection && input.modelSelection.instanceId !== options.instanceId) {
841 return yield* new ProviderAdapterValidationError({
842 provider: PROVIDER,
843 operation: "sendTurn",
844 issue: "The selected model belongs to another provider instance.",
845 });
846 }
847 const prompt = yield* buildAntigravityPrompt({
848 input: input.input,
849 attachments: input.attachments,
850 attachmentsDir: serverConfig.attachmentsDir,
851 }).pipe(
852 Effect.provideService(FileSystem.FileSystem, fileSystem),
853 Effect.provideService(Path.Path, path),
854 Effect.mapError((cause) => mapAntigravityError(input.threadId, "session/prompt", cause)),
855 );
856 let intent: TurnIntent | undefined;
857 // The caller holds promptLock while it changes or settles the active turn.
858 const finishTurn = (turn: TurnIntent, payload: TurnCompletedPayload) =>
859 Effect.gen(function* () {
860 if (turn.settled || context.stopped || context.generation !== turn.generation) return;
861 turn.settled = true;
862 yield* promoteBackgroundCommands(context);
863 context.activeTurnId = undefined;
864 context.promptFiber = undefined;
865 context.session = {
866 ...context.session,
867 status: payload.state === "failed" ? "error" : "ready",
868 activeTurnId: undefined,
869 updatedAt: yield* nowIso,
870 ...(payload.errorMessage
871 ? { lastError: payload.errorMessage }
872 : { lastError: undefined }),
873 };
874 yield* emit({
875 type: "turn.completed",
876 ...(yield* stamp),
877 provider: PROVIDER,
878 threadId: input.threadId,
879 turnId: turn.turnId,
880 payload,
881 });
882 }).pipe(Effect.uninterruptible);
883
884 return yield* Effect.gen(function* () {
885 const launch = yield* context.promptLock.withPermit(
886 Effect.gen(function* () {
887 yield* requireSession(input.threadId);
888 const requestedModel = input.modelSelection?.model ?? context.session.model;
889 const configOptions = yield* context.runtime.getConfigOptions;
890 const model = resolveAntigravityModel({
891 configOptions,
892 model: requestedModel,
893 defaultModel: yield* options.defaultModel ?? Effect.succeed(undefined),
894 });
895 const availableModels = antigravityModelOptions(configOptions);
896 if (model && !availableModels.some((option) => option.value === model)) {
897 return yield* EffectAcpErrors.AcpRequestError.invalidParams(
898 `Antigravity model '${model}' is unavailable for this Google account. Select an available model.`,
899 );
900 }
901 const turnId = context.activeTurnId ?? TurnId.make(yield* randomId);
902 const steering = context.activeTurnId !== undefined;
903 const turn: TurnIntent = { turnId, generation: ++context.generation, settled: false };
904 intent = turn;
905 context.activeTurnId = turnId;
906 if (!steering) {
907 yield* emit({
908 type: "turn.started",
909 ...(yield* stamp),
910 provider: PROVIDER,
911 threadId: input.threadId,
912 turnId,
913 payload: model ? { model } : {},
914 });
915 }
916 if (context.promptFiber) {
917 yield* cancelRequests(context);
918 yield* context.runtime.cancel;
919 yield* Fiber.await(context.promptFiber);
920 }
921 yield* applyAntigravityAcpModelSelection({
922 runtime: context.runtime,
923 model,
924 mapError: (cause) => cause,
925 });
926 yield* context.runtime.setMode(antigravityPermissionMode(context.session.runtimeMode));
927 context.session = {
928 ...context.session,
929 status: "running",
930 activeTurnId: turnId,
931 ...(model ? { model } : {}),
932 updatedAt: yield* nowIso,
933 };
934 const dispatched = yield* Deferred.make<void>();
935 const fiber = yield* context.runtime
936 .prompt({ prompt }, { dispatched })
937 .pipe(Effect.forkIn(context.scope));
938 context.promptFiber = fiber;
939 // Fiber.join can skip a scope-close waiter when the child is interrupted.
940 // Unwrap the Exit after Fiber.await returns.
941 yield* Effect.raceFirst(
942 Deferred.await(dispatched),
943 Fiber.await(fiber).pipe(
944 Effect.flatMap((exit) => exit),
945 Effect.asVoid,
946 ),
947 );
948 return { turn, fiber };
949 }),
950 );
951 const result = yield* Fiber.await(launch.fiber).pipe(Effect.flatMap((exit) => exit));
952 yield* context.runtime.drainEvents;
953 if (context.stopped) {
954 return yield* new ProviderAdapterSessionClosedError({
955 provider: PROVIDER,
956 threadId: input.threadId,
957 });
958 }
959 const record = context.turns.find((turn) => turn.id === launch.turn.turnId);
960 if (record) record.items.push(result);
961 else context.turns.push({ id: launch.turn.turnId, items: [result] });
962 yield* context.promptLock.withPermit(
963 finishTurn(launch.turn, {
964 state: result.stopReason === "cancelled" ? "cancelled" : "completed",
965 stopReason: result.stopReason,
966 }),
967 );
968 return {
969 threadId: input.threadId,
970 turnId: launch.turn.turnId,
971 resumeCursor: context.session.resumeCursor,
972 };
973 }).pipe(
974 Effect.tapError((cause) =>
975 isAntigravitySignInRequiredError(cause)
976 ? (options.onAuthRequired ?? Effect.void)
977 : Effect.void,
978 ),
979 Effect.mapError((cause) =>
980 isAcpError(cause) ? mapAntigravityError(input.threadId, "session/prompt", cause) : cause,
981 ),
982 Effect.tapError((cause) =>
983 Effect.suspend(() =>
984 intent
985 ? context.promptLock.withPermit(
986 finishTurn(intent, { state: "failed", errorMessage: cause.message }),
987 )
988 : Effect.void,
989 ),
990 ),
991 Effect.onInterrupt(() =>
992 context.promptLock.withPermit(
993 Effect.gen(function* () {
994 const turn = intent;
995 if (!turn || turn.settled || context.stopped || context.generation !== turn.generation)
996 return;
997 const promptFiber = context.promptFiber;
998 yield* cancelRequests(context);
999 yield* Effect.ignore(context.runtime.cancel);
1000 if (promptFiber) yield* Fiber.interrupt(promptFiber);
1001 yield* finishTurn(turn, { state: "cancelled", stopReason: "cancelled" });
1002 }),
1003 ),
1004 ),
1005 );
1006 });
1007
1008 const interruptTurn: Adapter["interruptTurn"] = (threadId) =>
1009 Effect.gen(function* () {
1010 const context = yield* requireSession(threadId);
1011 yield* context.promptLock
1012 .withPermit(
1013 Effect.gen(function* () {
1014 yield* cancelRequests(context);
1015 yield* context.runtime.cancel;
1016 }),
1017 )
1018 .pipe(Effect.mapError((cause) => mapAntigravityError(threadId, "session/cancel", cause)));
1019 });
1020
1021 const respondToRequest: Adapter["respondToRequest"] = (threadId, requestId, decision) =>
1022 Effect.gen(function* () {
1023 const context = yield* requireSession(threadId);
1024 const pending = context.approvals.get(requestId);
1025 if (!pending) {
1026 return yield* new ProviderAdapterRequestError({
1027 provider: PROVIDER,
1028 method: "session/request_permission",
1029 detail: "This approval request is no longer pending.",
1030 });
1031 }
1032 const optionId =
1033 decision === "cancel"
1034 ? undefined
1035 : selectAntigravityPermissionOptionId(pending.request, decision);
1036 if (decision !== "cancel" && optionId === undefined) {
1037 return yield* new ProviderAdapterValidationError({
1038 provider: PROVIDER,
1039 operation: "respondToRequest",
1040 issue:
1041 "Antigravity did not offer this permission choice. Select one of the available choices.",
1042 });
1043 }
1044 yield* Deferred.succeed(pending.response, {
1045 decision,
1046 result: {
1047 outcome:
1048 optionId === undefined ? { outcome: "cancelled" } : { outcome: "selected", optionId },
1049 },
1050 });
1051 });
1052
1053 const respondToUserInput: Adapter["respondToUserInput"] = (threadId, requestId, answers) =>
1054 Effect.gen(function* () {
1055 const context = yield* requireSession(threadId);
1056 const pending = context.questions.get(requestId);
1057 if (!pending) {
1058 return yield* new ProviderAdapterRequestError({
1059 provider: PROVIDER,
1060 method: "session/request_permission",
1061 detail: "This question is no longer pending.",
1062 });
1063 }
1064 const result = makeAntigravityUserInputResponse(pending.request, answers);
1065 if (!result) {
1066 return yield* new ProviderAdapterValidationError({
1067 provider: PROVIDER,
1068 operation: "respondToUserInput",
1069 issue:
1070 "Select one of Antigravity's offered answers. Custom answers are not supported for this question.",
1071 });
1072 }
1073 yield* Deferred.succeed(pending.response, { answers, result });
1074 });
1075
1076 const stopSession: Adapter["stopSession"] = (threadId) =>
1077 withThreadLock(threadId, Effect.flatMap(requireSession(threadId), stopContext));
1078 const stopAll: Adapter["stopAll"] = () =>
1079 Effect.forEach([...sessions.values()], stopContext, { discard: true });
1080 yield* Effect.addFinalizer(() =>
1081 stopAll().pipe(
1082 Effect.catchCause((cause) =>
1083 Cause.hasInterrupts(cause)
1084 ? Effect.void
1085 : Effect.logError("Could not stop an Antigravity session."),
1086 ),
1087 Effect.ensuring(PubSub.shutdown(events)),
1088 ),
1089 );
1090
1091 return {
1092 provider: PROVIDER,
1093 capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false },
1094 startSession,
1095 sendTurn,
1096 interruptTurn,
1097 respondToRequest,
1098 respondToUserInput,
1099 stopSession,
1100 stopAll,
1101 listSessions: () =>
1102 Effect.sync(() =>
1103 [...sessions.values()]
1104 .filter((context) => !context.stopped)
1105 .map((context) => ({ ...context.session })),
1106 ),
1107 hasSession: (threadId) =>
1108 Effect.sync(() => sessions.has(threadId) && !sessions.get(threadId)?.stopped),
1109 readThread: (threadId) =>
1110 Effect.map(requireSession(threadId), (context) => ({ threadId, turns: context.turns })),
1111 rollbackThread: (_threadId: ThreadId, _numTurns: number) =>
1112 Effect.fail(
1113 new ProviderAdapterValidationError({
1114 provider: PROVIDER,
1115 operation: "rollbackThread",
1116 issue: "Antigravity does not support conversation rewind. Start a new thread instead.",
1117 }),
1118 ),
1119 streamEvents: Stream.fromPubSub(events),
1120 } satisfies Adapter;
1121});
apps/server/src/provider/Layers/AntigravityProvider.test.ts created+541
......@@ -0,0 +1,541 @@
1import { describe, expect, it } from "@effect/vitest";
2import {
3 ANTIGRAVITY_DEFAULT_MODEL,
4 AntigravitySettings,
5 ProviderDriverKind,
6 ProviderInstanceId,
7 ProviderSetupError,
8} from "@t3tools/contracts";
9import * as Deferred from "effect/Deferred";
10import * as Effect from "effect/Effect";
11import * as Fiber from "effect/Fiber";
12import * as Layer from "effect/Layer";
13import * as Ref from "effect/Ref";
14import * as Schema from "effect/Schema";
15import * as Stream from "effect/Stream";
16import * as EffectAcpErrors from "effect-acp/errors";
17import type * as EffectAcpSchema from "effect-acp/schema";
18
19import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
20import { ServerSettingsService } from "../../serverSettings.ts";
21import type { AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts";
22import {
23 buildAntigravityModelsFromSession,
24 makeAntigravityProvider,
25} from "./AntigravityProvider.ts";
26
27const decodeSettings = Schema.decodeSync(AntigravitySettings);
28const instanceId = ProviderInstanceId.make("antigravity-test");
29const driver = ProviderDriverKind.make("antigravity");
30
31const initializeResult = {
32 protocolVersion: 1,
33 agentCapabilities: {
34 loadSession: true,
35 promptCapabilities: { image: true, audio: true, embeddedContext: true },
36 sessionCapabilities: { list: {}, resume: {} },
37 },
38 authMethods: [{ id: "oauth-personal", name: "Log in with Google" }],
39 agentInfo: {
40 name: "antigravity-acp",
41 title: "Google Antigravity",
42 version: "agy_acp_server_20260818_01_RC01",
43 },
44} satisfies EffectAcpSchema.InitializeResponse;
45
46const modelOptions = [
47 { value: "gemini-3.8-flash-high", name: "Gemini 3.8 Flash (High)" },
48 { value: "gemini-3.8-flash-medium", name: "Gemini 3.8 Flash (Medium)" },
49 { value: "gemini-3.8-flash-low", name: "Gemini 3.8 Flash (Low)" },
50 { value: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)" },
51 { value: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)" },
52 { value: "gemini-3.7-flash-low", name: "Gemini 3.7 Flash (Low)" },
53 { value: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)" },
54 { value: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)" },
55 { value: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)" },
56 { value: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)" },
57 { value: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" },
58];
59
60const modelConfig = {
61 id: "model",
62 name: "Model",
63 category: "model",
64 type: "select",
65 currentValue: "gemini-3.7-flash-high",
66 options: modelOptions,
67} satisfies EffectAcpSchema.SessionConfigOption;
68
69const sessionSetupResult = {
70 sessionId: "session-1",
71 models: {
72 currentModelId: "gemini-3.7-flash-high",
73 availableModels: modelOptions.map((option) => ({ modelId: option.value, name: option.name })),
74 },
75 configOptions: [
76 modelConfig,
77 {
78 id: "mode",
79 name: "Session Mode",
80 category: "mode",
81 type: "select",
82 currentValue: "default",
83 options: [
84 { value: "default", name: "Default" },
85 { value: "yolo", name: "YOLO" },
86 ],
87 },
88 ],
89} satisfies EffectAcpSchema.NewSessionResponse;
90
91const started = {
92 sessionId: "session-1",
93 initializeResult,
94 sessionSetupResult,
95 modelConfigId: "model",
96} satisfies AcpSessionRuntimeStartResult;
97
98const commands = [
99 { name: "plan", description: "Create a plan", input: { hint: "What to plan" } },
100 { name: "logout", description: "Sign out of Google" },
101] satisfies ReadonlyArray<EffectAcpSchema.AvailableCommand>;
102
103const testLayer = Layer.merge(
104 Layer.mock(BackgroundPolicy.BackgroundPolicy)({
105 shouldRunScopeWork: () => Effect.succeed(false),
106 }),
107 ServerSettingsService.layerTest(),
108);
109
110type ProbeError = EffectAcpErrors.AcpError | ProviderSetupError;
111
112const makeHarness = Effect.fn("makeAntigravityProviderHarness")(function* (
113 options: { readonly enabled?: boolean; readonly safe?: boolean } = {},
114) {
115 const initialProbe = yield* Deferred.make<EffectAcpSchema.InitializeResponse, ProbeError>();
116 const probeCalls = yield* Ref.make(0);
117 const safetyCalls = yield* Ref.make(0);
118 const probe = yield* Ref.make<Effect.Effect<EffectAcpSchema.InitializeResponse, ProbeError>>(
119 Deferred.await(initialProbe),
120 );
121 const safety = yield* Ref.make<Effect.Effect<boolean>>(Effect.succeed(options.safe ?? true));
122 const provider = yield* makeAntigravityProvider(
123 decodeSettings({ enabled: options.enabled ?? true, customModels: ["do-not-seed-me"] }),
124 {
125 stampIdentity: (snapshot) => Effect.succeed({ ...snapshot, instanceId, driver }),
126 probe: Ref.update(probeCalls, (count) => count + 1).pipe(
127 Effect.andThen(Ref.get(probe)),
128 Effect.flatten,
129 ),
130 supportsTextGeneration: Ref.update(safetyCalls, (count) => count + 1).pipe(
131 Effect.andThen(Ref.get(safety)),
132 Effect.flatten,
133 ),
134 },
135 );
136 const initialUpdate = yield* Stream.toPull(
137 provider.snapshot.streamChanges.pipe(
138 Stream.filter((snapshot) => snapshot.installed || snapshot.status === "error"),
139 ),
140 );
141 const initialize = Deferred.succeed(initialProbe, initializeResult).pipe(
142 Effect.andThen(initialUpdate),
143 Effect.asVoid,
144 );
145 return {
146 provider,
147 probe,
148 probeCalls,
149 safety,
150 safetyCalls,
151 initialProbe,
152 initialUpdate,
153 initialize,
154 };
155});
156
157describe("Antigravity model catalog", () => {
158 it("keeps the captured personal catalog's IDs, labels, order, and selected default", () => {
159 const models = buildAntigravityModelsFromSession(sessionSetupResult);
160 expect(models.map((model) => [model.slug, model.name])).toEqual(
161 modelOptions.map((option) => [option.value, option.name]),
162 );
163 expect(models.filter((model) => model.isDefault).map((model) => model.slug)).toEqual([
164 "gemini-3.7-flash-high",
165 ]);
166 expect(
167 models
168 .filter((model) => model.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL))
169 .map((model) => model.slug),
170 ).toEqual(["gemini-3.7-flash-high"]);
171 expect(models.every((model) => model.capabilities?.optionDescriptors?.length === 0)).toBe(true);
172 expect(models.every((model) => !model.isCustom)).toBe(true);
173 });
174
175 it("uses legacy session models only when model config is absent", () => {
176 const fromLegacy = buildAntigravityModelsFromSession({
177 sessionId: "legacy-session",
178 models: sessionSetupResult.models,
179 });
180 expect(fromLegacy).toEqual(buildAntigravityModelsFromSession(sessionSetupResult));
181 expect(
182 buildAntigravityModelsFromSession({
183 ...sessionSetupResult,
184 configOptions: [{ ...modelConfig, options: [] }],
185 }),
186 ).toEqual([]);
187 });
188
189 it("flattens native option groups without combining distinct model IDs", () => {
190 const models = buildAntigravityModelsFromSession({
191 sessionId: "grouped-session",
192 configOptions: [
193 {
194 ...modelConfig,
195 currentValue: "gemini-pro-agent",
196 options: [
197 { group: "Flash", name: "Flash", options: [modelOptions[3]!, modelOptions[4]!] },
198 { group: "Pro", name: "Pro", options: [modelOptions[9]!, modelOptions[3]!] },
199 ],
200 },
201 ],
202 });
203 expect(models.map((model) => model.slug)).toEqual([
204 "gemini-3.7-flash-high",
205 "gemini-3.7-flash-medium",
206 "gemini-pro-agent",
207 ]);
208 expect(models.find((model) => model.isDefault)?.slug).toBe("gemini-pro-agent");
209 expect(models.find((model) => model.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL))?.slug).toBe(
210 "gemini-pro-agent",
211 );
212 });
213});
214
215it.layer(testLayer)("Antigravity provider snapshots", (it) => {
216 it.effect("does not probe or run helper safety checks while disabled", () =>
217 Effect.scoped(
218 Effect.gen(function* () {
219 const harness = yield* makeHarness({ enabled: false });
220 const snapshot = yield* harness.provider.snapshot.refresh;
221 expect(snapshot).toMatchObject({
222 enabled: false,
223 installed: false,
224 status: "disabled",
225 auth: { status: "unknown" },
226 models: [],
227 setup: { canAuthenticate: true, canInstall: true },
228 showInteractionModeToggle: false,
229 supportsConversationRollback: false,
230 supportsTextGeneration: false,
231 });
232 expect(yield* Ref.get(harness.probeCalls)).toBe(0);
233 expect(yield* Ref.get(harness.safetyCalls)).toBe(0);
234 }),
235 ),
236 );
237
238 it.effect("records explicit sign-in while disabled without starting a health probe", () =>
239 Effect.scoped(
240 Effect.gen(function* () {
241 const harness = yield* makeHarness({ enabled: false });
242 const signedIn = yield* Stream.toPull(
243 harness.provider.snapshot.streamChanges.pipe(
244 Stream.filter(
245 (snapshot) =>
246 snapshot.auth.status === "authenticated" && snapshot.slashCommands.length > 0,
247 ),
248 ),
249 );
250 yield* harness.provider.onSessionStarted(started);
251 yield* harness.provider.onAvailableCommands(commands);
252 const [snapshot] = yield* signedIn;
253 expect(snapshot).toMatchObject({
254 enabled: false,
255 installed: true,
256 status: "disabled",
257 auth: { status: "authenticated", type: "oauth-personal" },
258 workspaceSnapshots: [],
259 });
260 expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult));
261 expect(snapshot.slashCommands).toEqual(commands);
262 expect((yield* harness.provider.snapshot.refresh).models).toEqual(snapshot.models);
263 expect(yield* Ref.get(harness.probeCalls)).toBe(0);
264 }),
265 ),
266 );
267
268 it.effect("treats initialize as installation proof, not account or model discovery", () =>
269 Effect.scoped(
270 Effect.gen(function* () {
271 const harness = yield* makeHarness();
272 expect(yield* harness.provider.snapshot.getSnapshot).toMatchObject({
273 installed: false,
274 status: "warning",
275 auth: { status: "unknown" },
276 models: [],
277 });
278 yield* harness.initialize;
279 const snapshot = yield* harness.provider.snapshot.getSnapshot;
280 expect(snapshot).toMatchObject({
281 installed: true,
282 status: "warning",
283 version: "agy_acp_server_20260818_01_RC01",
284 auth: { status: "unknown" },
285 models: [],
286 });
287 expect(yield* Ref.get(harness.probeCalls)).toBe(1);
288 }),
289 ),
290 );
291
292 it.effect("publishes session metadata and native commands without another health probe", () =>
293 Effect.scoped(
294 Effect.gen(function* () {
295 const harness = yield* makeHarness();
296 yield* harness.initialize;
297 const nextReady = yield* Stream.toPull(
298 harness.provider.snapshot.streamChanges.pipe(
299 Stream.filter(
300 (snapshot) =>
301 snapshot.auth.status === "authenticated" && snapshot.slashCommands.length > 0,
302 ),
303 ),
304 );
305 yield* harness.provider.onSessionStarted(started, "/workspace");
306 yield* harness.provider.onAvailableCommands(commands, "/workspace");
307 const [snapshot] = yield* nextReady;
308 expect(snapshot).toMatchObject({
309 status: "ready",
310 auth: { status: "authenticated", type: "oauth-personal" },
311 supportsTextGeneration: true,
312 });
313 expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult));
314 expect(snapshot.slashCommands).toEqual(commands);
315 expect((yield* harness.provider.snapshotForCwd("/workspace")).slashCommands).toEqual(
316 commands,
317 );
318 expect(yield* Ref.get(harness.probeCalls)).toBe(1);
319 }),
320 ),
321 );
322
323 it.effect("does not retain a disposable sign-in workspace", () =>
324 Effect.scoped(
325 Effect.gen(function* () {
326 const harness = yield* makeHarness();
327 yield* harness.initialize;
328 yield* harness.provider.onSessionStarted(started);
329 yield* harness.provider.onAvailableCommands(commands);
330 const snapshot = yield* harness.provider.snapshot.getSnapshot;
331 expect(snapshot.models).toHaveLength(11);
332 expect(snapshot.slashCommands).toEqual(commands);
333 expect(snapshot.workspaceSnapshots).toEqual([]);
334 }),
335 ),
336 );
337
338 it.effect("clears all account metadata on sign-out and authentication failure", () =>
339 Effect.scoped(
340 Effect.gen(function* () {
341 const harness = yield* makeHarness();
342 yield* harness.initialize;
343 for (const clear of [harness.provider.onSignedOut, harness.provider.onAuthRequired]) {
344 yield* harness.provider.onSessionStarted(started, "/workspace");
345 yield* harness.provider.onAvailableCommands(commands, "/workspace");
346 yield* clear;
347 expect(yield* harness.provider.snapshot.getSnapshot).toMatchObject({
348 installed: true,
349 status: "warning",
350 auth: { status: "unauthenticated" },
351 models: [],
352 slashCommands: [],
353 skills: [],
354 workspaceSnapshots: [],
355 supportsTextGeneration: false,
356 });
357 yield* harness.provider.onAvailableCommands(commands, "/workspace");
358 expect((yield* harness.provider.snapshotForCwd("/workspace")).slashCommands).toEqual([]);
359 }
360 const refreshed = yield* harness.provider.snapshot.refresh;
361 expect(refreshed.auth.status).toBe("unauthenticated");
362 expect(refreshed.supportsTextGeneration).toBe(false);
363 }),
364 ),
365 );
366
367 it.effect("replaces one account's catalog instead of combining accounts", () =>
368 Effect.scoped(
369 Effect.gen(function* () {
370 const harness = yield* makeHarness();
371 yield* harness.initialize;
372 yield* harness.provider.onSessionStarted(started);
373 yield* harness.provider.onSignedOut;
374 yield* harness.provider.onSessionStarted({
375 ...started,
376 sessionSetupResult: {
377 sessionId: "new-account-session",
378 configOptions: [
379 { ...modelConfig, currentValue: "gemini-pro-agent", options: [modelOptions[9]!] },
380 ],
381 },
382 });
383 expect(
384 (yield* harness.provider.snapshot.getSnapshot).models.map((model) => model.slug),
385 ).toEqual(["gemini-pro-agent"]);
386 }),
387 ),
388 );
389
390 it.effect("retains known account metadata when a local health check fails", () =>
391 Effect.scoped(
392 Effect.gen(function* () {
393 const harness = yield* makeHarness();
394 yield* harness.initialize;
395 yield* harness.provider.onSessionStarted(started, "/workspace");
396 yield* harness.provider.onAvailableCommands(commands, "/workspace");
397 yield* Ref.set(
398 harness.probe,
399 Effect.fail(EffectAcpErrors.AcpRequestError.internalError("probe failed")),
400 );
401 const snapshot = yield* harness.provider.snapshot.refresh;
402 expect(snapshot).toMatchObject({
403 installed: true,
404 status: "error",
405 auth: { status: "authenticated" },
406 });
407 expect(snapshot.models).toEqual(buildAntigravityModelsFromSession(sessionSetupResult));
408 expect(snapshot.slashCommands).toEqual(commands);
409 expect(snapshot.workspaceSnapshots?.[0]?.cwd).toBe("/workspace");
410 }),
411 ),
412 );
413
414 it.effect("distinguishes missing executables from a failed installed executable", () =>
415 Effect.scoped(
416 Effect.gen(function* () {
417 const harness = yield* makeHarness();
418 yield* harness.initialize;
419 const failures = [
420 {
421 error: new EffectAcpErrors.AcpSpawnError({ cause: { code: "ENOENT" } }),
422 installed: false,
423 },
424 {
425 error: new EffectAcpErrors.AcpSpawnError({ cause: { code: "EACCES" } }),
426 installed: true,
427 },
428 {
429 error: new ProviderSetupError({
430 instanceId,
431 operation: "resolve",
432 detail: "Antigravity is not installed.",
433 }),
434 installed: false,
435 },
436 ];
437 for (const { error, installed } of failures) {
438 yield* harness.provider.onSessionStarted(started, "/workspace");
439 yield* harness.provider.onAvailableCommands(commands, "/workspace");
440 yield* Ref.set(harness.probe, Effect.fail(error));
441 const snapshot = yield* harness.provider.snapshot.refresh;
442 expect(snapshot).toMatchObject({
443 installed,
444 status: "error",
445 auth: { status: "authenticated" },
446 });
447 expect(snapshot.models).toHaveLength(installed ? 11 : 0);
448 expect(snapshot.slashCommands).toHaveLength(installed ? 2 : 0);
449 expect(snapshot.workspaceSnapshots).toHaveLength(installed ? 1 : 0);
450 expect(snapshot.supportsTextGeneration).toBe(installed);
451 }
452 }),
453 ),
454 );
455
456 it.effect("does not let an old health result restore a signed-out account", () =>
457 Effect.scoped(
458 Effect.gen(function* () {
459 const harness = yield* makeHarness();
460 yield* harness.initialize;
461 yield* harness.provider.onSessionStarted(started);
462 const releaseProbe = yield* Deferred.make<EffectAcpSchema.InitializeResponse>();
463 const probeEntered = yield* Deferred.make<void>();
464 yield* Ref.set(
465 harness.probe,
466 Deferred.succeed(probeEntered, undefined).pipe(
467 Effect.andThen(Deferred.await(releaseProbe)),
468 ),
469 );
470 const refresh = yield* harness.provider.snapshot.refresh.pipe(Effect.forkChild);
471 yield* Deferred.await(probeEntered);
472 yield* harness.provider.onSignedOut;
473 yield* Deferred.succeed(releaseProbe, initializeResult);
474 const snapshot = yield* Fiber.join(refresh);
475 expect(snapshot).toMatchObject({
476 auth: { status: "unauthenticated" },
477 models: [],
478 supportsTextGeneration: false,
479 });
480 }),
481 ),
482 );
483
484 it.effect("exposes helper support only when the supplied safety check allows it", () =>
485 Effect.scoped(
486 Effect.gen(function* () {
487 const harness = yield* makeHarness({ safe: false });
488 yield* harness.initialize;
489 yield* harness.provider.onSessionStarted(started);
490 expect((yield* harness.provider.snapshot.getSnapshot).supportsTextGeneration).toBe(false);
491 yield* Ref.set(harness.safety, Effect.succeed(true));
492 yield* harness.provider.snapshot.refresh;
493 expect((yield* harness.provider.snapshot.getSnapshot).supportsTextGeneration).toBe(true);
494 }),
495 ),
496 );
497
498 it.effect("keeps discovered workspace skills through session and command updates", () =>
499 Effect.scoped(
500 Effect.gen(function* () {
501 const harness = yield* makeHarness();
502 yield* harness.initialize;
503 const skills = [
504 {
505 name: "deploy",
506 description: "Ship it",
507 path: "/workspace/.agent/skills/deploy",
508 enabled: true,
509 },
510 ];
511 const discovered = yield* harness.provider.snapshotForCwd("/workspace", skills);
512 expect(discovered.skills).toEqual(skills);
513 yield* harness.provider.onSessionStarted(started, "/workspace");
514 yield* harness.provider.onAvailableCommands(commands, "/workspace");
515 const after = yield* harness.provider.snapshot.getSnapshot;
516 expect(
517 after.workspaceSnapshots?.find((entry) => entry.cwd === "/workspace")?.skills,
518 ).toEqual(skills);
519 expect((yield* harness.provider.snapshotForCwd("/workspace")).skills).toEqual(skills);
520 }),
521 ),
522 );
523
524 it.effect("bounds workspace metadata without starting sessions for workspace lookup", () =>
525 Effect.scoped(
526 Effect.gen(function* () {
527 const harness = yield* makeHarness();
528 yield* harness.initialize;
529 yield* harness.provider.onSessionStarted(started);
530 for (let index = 0; index < 35; index++) {
531 yield* harness.provider.onAvailableCommands(commands, `/workspace-${index}`);
532 }
533 const snapshot = yield* harness.provider.snapshotForCwd("/workspace-34");
534 expect(snapshot.workspaceSnapshots).toHaveLength(32);
535 expect(snapshot.workspaceSnapshots?.[0]?.cwd).toBe("/workspace-3");
536 expect(snapshot.slashCommands).toEqual(commands);
537 expect(yield* Ref.get(harness.probeCalls)).toBe(1);
538 }),
539 ),
540 );
541});
apps/server/src/provider/Layers/AntigravityProvider.ts created+377
......@@ -0,0 +1,377 @@
1import {
2 ANTIGRAVITY_DEFAULT_MODEL,
3 ProviderDriverKind,
4 type AntigravitySettings,
5 type ProviderSetupError,
6 type ServerProvider,
7 type ServerProviderModel,
8 type ServerProviderSlashCommand,
9} from "@t3tools/contracts";
10import { createModelCapabilities } from "@t3tools/shared/model";
11import * as DateTime from "effect/DateTime";
12import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";
14import * as Predicate from "effect/Predicate";
15import * as Result from "effect/Result";
16import * as Stream from "effect/Stream";
17import * as SubscriptionRef from "effect/SubscriptionRef";
18import type * as EffectAcpErrors from "effect-acp/errors";
19import type * as EffectAcpSchema from "effect-acp/schema";
20
21import type { AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts";
22import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
23import {
24 makeManualOnlyProviderMaintenanceCapabilities,
25 type ProviderMaintenanceCapabilities,
26} from "../providerMaintenance.ts";
27import {
28 buildServerProvider,
29 isCommandMissingCause,
30 type ServerProviderDraft,
31} from "../providerSnapshot.ts";
32
33const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] });
34const MAX_WORKSPACE_SNAPSHOTS = 32;
35const SIGN_IN_MESSAGE = "Sign in with Google to use Antigravity.";
36const AUTH_UNCHECKED_MESSAGE =
37 "Antigravity is installed. Google account access is not checked yet.";
38
39type SessionSetupResult = AcpSessionRuntimeStartResult["sessionSetupResult"];
40
41/** Keep the native model IDs, including model-specific thinking levels. */
42export function buildAntigravityModelsFromSession(
43 setup: SessionSetupResult,
44): ReadonlyArray<ServerProviderModel> {
45 const config = setup.configOptions?.find(
46 (option) => option.id === "model" || option.category === "model",
47 );
48 const currentValue =
49 config?.type === "select" ? config.currentValue : setup.models?.currentModelId;
50 const entries =
51 config?.type === "select"
52 ? config.options.flatMap((entry) => ("value" in entry ? [entry] : entry.options))
53 : config === undefined
54 ? (setup.models?.availableModels.map((model) => ({
55 value: model.modelId,
56 name: model.name,
57 })) ?? [])
58 : [];
59 const seen = new Set<string>();
60 return entries.flatMap((entry): ServerProviderModel[] => {
61 if (!entry.value.trim() || seen.has(entry.value)) return [];
62 seen.add(entry.value);
63 return [
64 {
65 slug: entry.value,
66 name: entry.name.trim() ? entry.name : entry.value,
67 isCustom: false,
68 ...(entry.value === currentValue
69 ? { isDefault: true, aliases: [ANTIGRAVITY_DEFAULT_MODEL] }
70 : {}),
71 capabilities: EMPTY_MODEL_CAPABILITIES,
72 },
73 ];
74 });
75}
76
77function nativeCommands(
78 commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>,
79): ReadonlyArray<ServerProviderSlashCommand> {
80 const seen = new Set<string>();
81 return commands.flatMap((command): ServerProviderSlashCommand[] => {
82 if (!command.name.trim() || seen.has(command.name)) return [];
83 seen.add(command.name);
84 const description = command.description.trim();
85 const hint = command.input?.hint.trim();
86 return [
87 {
88 name: command.name,
89 ...(description ? { description } : {}),
90 ...(hint ? { input: { hint } } : {}),
91 },
92 ];
93 });
94}
95
96function isMissingInstallation(error: EffectAcpErrors.AcpError | ProviderSetupError): boolean {
97 if (error._tag === "AcpSpawnError") {
98 return (
99 isCommandMissingCause(error.cause) ||
100 (Predicate.isObject(error.cause) && error.cause.code === "ENOENT")
101 );
102 }
103 return (
104 error._tag === "ProviderSetupError" &&
105 error.operation === "resolve" &&
106 /not installed|missing|incomplete|does not publish/i.test(error.detail)
107 );
108}
109
110interface AntigravityProviderState {
111 readonly draft: ServerProviderDraft;
112 readonly authRevision: number;
113}
114
115interface AntigravityProviderOptions {
116 readonly stampIdentity: (snapshot: ServerProviderDraft) => Effect.Effect<ServerProvider>;
117 readonly probe: Effect.Effect<
118 EffectAcpSchema.InitializeResponse,
119 EffectAcpErrors.AcpError | ProviderSetupError
120 >;
121 readonly supportsTextGeneration: Effect.Effect<boolean>;
122 readonly maintenanceCapabilities?: ProviderMaintenanceCapabilities;
123 /** Auth type and label published once a session authenticates. */
124 readonly auth?: { readonly type: string; readonly label: string };
125}
126
127/** Health uses initialize only. Session callbacks supply account-specific metadata. */
128export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(function* (
129 settings: AntigravitySettings,
130 options: AntigravityProviderOptions,
131) {
132 const checkedAt = DateTime.formatIso(yield* DateTime.now);
133 const initialDraft = {
134 ...buildServerProvider({
135 presentation: { displayName: "Antigravity", showInteractionModeToggle: false },
136 enabled: settings.enabled,
137 checkedAt,
138 models: [],
139 probe: {
140 installed: false,
141 version: null,
142 status: "warning",
143 auth: { status: "unknown" },
144 message: settings.enabled
145 ? "Checking Antigravity availability."
146 : "Antigravity is disabled in T3 Code settings.",
147 },
148 }),
149 setup: { canAuthenticate: true, canInstall: true },
150 supportsConversationRollback: false,
151 supportsTextGeneration: false,
152 workspaceSnapshots: [],
153 } satisfies ServerProviderDraft;
154 const metadata = yield* SubscriptionRef.make<AntigravityProviderState>({
155 draft: initialDraft,
156 authRevision: 0,
157 });
158 // Skills the driver discovered on disk per workspace. Session callbacks
159 // rewrite the workspace entry with native commands and must keep these, or
160 // the registry drops the suggestions and never re-reads the workspace.
161 const discoveredSkills = new Map<string, ServerProvider["skills"]>();
162 const getSnapshot = SubscriptionRef.get(metadata).pipe(
163 Effect.flatMap((state) => options.stampIdentity(state.draft)),
164 );
165
166 const checkProvider = Effect.fn("checkAntigravityProvider")(function* () {
167 if (!settings.enabled) return yield* getSnapshot;
168 const before = yield* SubscriptionRef.get(metadata);
169 const result = yield* options.probe.pipe(Effect.timeoutOption("15 seconds"), Effect.result);
170 const initialized =
171 Result.isSuccess(result) && Option.isSome(result.success) ? result.success.value : undefined;
172 const failure = Result.isFailure(result) ? result.failure : undefined;
173 const missingInstallation = failure !== undefined && isMissingInstallation(failure);
174 const errorMessage =
175 initialized !== undefined
176 ? undefined
177 : failure?._tag === "ProviderSetupError"
178 ? failure.detail.trim() || "Antigravity could not complete its local health check."
179 : missingInstallation
180 ? "Antigravity is not installed or its executable could not be found."
181 : failure
182 ? "Antigravity could not complete its local health check."
183 : "Antigravity did not respond to its local health check within 15 seconds.";
184 const supportsTextGeneration =
185 initialized !== undefined ? yield* options.supportsTextGeneration : false;
186 const updatedAt = DateTime.formatIso(yield* DateTime.now);
187 const next = yield* SubscriptionRef.updateAndGet(metadata, (state) => {
188 if (state.authRevision !== before.authRevision) return state;
189 const { message: _previousMessage, ...draft } = state.draft;
190 const authenticated = draft.auth.status === "authenticated";
191 const message =
192 errorMessage ??
193 (authenticated
194 ? undefined
195 : draft.auth.status === "unauthenticated"
196 ? SIGN_IN_MESSAGE
197 : AUTH_UNCHECKED_MESSAGE);
198 return {
199 ...state,
200 draft: {
201 ...draft,
202 installed: !missingInstallation,
203 version: initialized?.agentInfo?.version || draft.version,
204 status: errorMessage ? "error" : authenticated ? "ready" : "warning",
205 checkedAt: updatedAt,
206 ...(missingInstallation
207 ? {
208 models: [],
209 slashCommands: [],
210 skills: [],
211 workspaceSnapshots: [],
212 supportsTextGeneration: false,
213 }
214 : {}),
215 ...(initialized !== undefined
216 ? {
217 supportsTextGeneration:
218 supportsTextGeneration && draft.auth.status !== "unauthenticated",
219 }
220 : {}),
221 ...(message ? { message } : {}),
222 },
223 } satisfies AntigravityProviderState;
224 });
225 return yield* options.stampIdentity(next.draft);
226 });
227
228 const managed = yield* makeManagedServerProvider({
229 maintenanceCapabilities:
230 options.maintenanceCapabilities ??
231 makeManualOnlyProviderMaintenanceCapabilities({
232 provider: ProviderDriverKind.make("antigravity"),
233 packageName: null,
234 }),
235 getSettings: Effect.succeed(settings),
236 streamSettings: Stream.empty,
237 haveSettingsChanged: () => false,
238 initialSnapshot: () => getSnapshot,
239 checkProvider: checkProvider(),
240 enrichSnapshot: ({ publishSnapshot }) =>
241 SubscriptionRef.changes(metadata).pipe(
242 Stream.runForEach((state) =>
243 options.stampIdentity(state.draft).pipe(Effect.flatMap(publishSnapshot)),
244 ),
245 ),
246 });
247
248 const onSessionStarted = Effect.fn("AntigravityProvider.onSessionStarted")(function* (
249 started: AcpSessionRuntimeStartResult,
250 cwd?: string,
251 ) {
252 const before = yield* SubscriptionRef.get(metadata);
253 const supportsTextGeneration = yield* options.supportsTextGeneration;
254 const updatedAt = DateTime.formatIso(yield* DateTime.now);
255 yield* SubscriptionRef.update(metadata, (state) => {
256 if (
257 state.authRevision !== before.authRevision &&
258 state.draft.auth.status === "unauthenticated"
259 ) {
260 return state;
261 }
262 const { message: _previousMessage, ...draft } = state.draft;
263 const workspaces = draft.workspaceSnapshots ?? [];
264 const workspace = cwd ? workspaces.find((entry) => entry.cwd === cwd) : undefined;
265 return {
266 authRevision: state.authRevision + 1,
267 draft: {
268 ...draft,
269 installed: true,
270 status: settings.enabled ? "ready" : "disabled",
271 version: started.initializeResult.agentInfo?.version || draft.version,
272 auth: {
273 status: "authenticated",
274 type: options.auth?.type ?? "oauth-personal",
275 label: options.auth?.label ?? "Google account",
276 },
277 checkedAt: updatedAt,
278 models: buildAntigravityModelsFromSession(started.sessionSetupResult),
279 supportsTextGeneration,
280 ...(cwd
281 ? {
282 workspaceSnapshots: [
283 ...workspaces.filter((entry) => entry.cwd !== cwd),
284 {
285 cwd,
286 checkedAt: updatedAt,
287 slashCommands: workspace?.slashCommands ?? draft.slashCommands,
288 skills: workspace?.skills ?? discoveredSkills.get(cwd) ?? [],
289 },
290 ].slice(-MAX_WORKSPACE_SNAPSHOTS),
291 }
292 : {}),
293 },
294 } satisfies AntigravityProviderState;
295 });
296 });
297
298 const onAvailableCommands = Effect.fn("AntigravityProvider.onAvailableCommands")(function* (
299 commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>,
300 cwd?: string,
301 ) {
302 const slashCommands = nativeCommands(commands);
303 const updatedAt = DateTime.formatIso(yield* DateTime.now);
304 yield* SubscriptionRef.update(metadata, (state) => {
305 if (state.draft.auth.status === "unauthenticated") return state;
306 return {
307 ...state,
308 draft: {
309 ...state.draft,
310 slashCommands,
311 ...(cwd
312 ? {
313 workspaceSnapshots: [
314 ...(state.draft.workspaceSnapshots ?? []).filter((entry) => entry.cwd !== cwd),
315 {
316 cwd,
317 checkedAt: updatedAt,
318 slashCommands,
319 skills:
320 state.draft.workspaceSnapshots?.find((entry) => entry.cwd === cwd)?.skills ??
321 discoveredSkills.get(cwd) ??
322 [],
323 },
324 ].slice(-MAX_WORKSPACE_SNAPSHOTS),
325 }
326 : {}),
327 },
328 };
329 });
330 });
331
332 const clearAccountMetadata = Effect.fn("AntigravityProvider.clearAccountMetadata")(function* () {
333 const updatedAt = DateTime.formatIso(yield* DateTime.now);
334 yield* SubscriptionRef.update(
335 metadata,
336 (state) =>
337 ({
338 authRevision: state.authRevision + 1,
339 draft: {
340 ...state.draft,
341 auth: { status: "unauthenticated" },
342 status: settings.enabled ? "warning" : "disabled",
343 message: SIGN_IN_MESSAGE,
344 checkedAt: updatedAt,
345 models: [],
346 slashCommands: [],
347 skills: [],
348 workspaceSnapshots: [],
349 supportsTextGeneration: false,
350 },
351 }) satisfies AntigravityProviderState,
352 );
353 discoveredSkills.clear();
354 });
355
356 const snapshotForCwd = Effect.fn("AntigravityProvider.snapshotForCwd")(function* (
357 cwd: string,
358 skills?: ServerProvider["skills"],
359 ) {
360 if (skills) discoveredSkills.set(cwd, skills);
361 const snapshot = yield* getSnapshot;
362 const workspace = snapshot.workspaceSnapshots?.find((entry) => entry.cwd === cwd);
363 const resolvedSkills = skills ?? workspace?.skills ?? discoveredSkills.get(cwd) ?? [];
364 return workspace
365 ? { ...snapshot, slashCommands: workspace.slashCommands, skills: resolvedSkills }
366 : { ...snapshot, skills: resolvedSkills };
367 });
368
369 return {
370 snapshot: { ...managed, getSnapshot },
371 onSessionStarted,
372 onAvailableCommands,
373 onSignedOut: clearAccountMetadata(),
374 onAuthRequired: clearAccountMetadata(),
375 snapshotForCwd,
376 };
377});
apps/server/src/provider/Layers/ProviderAuthService.test.ts created+608
......@@ -0,0 +1,608 @@
1import { assert, describe, it } from "@effect/vitest";
2import {
3 ProviderDriverKind,
4 ProviderInstanceId,
5 ProviderSetupError,
6 ThreadId,
7 type ProviderAuthState,
8 type ProviderSession,
9} from "@t3tools/contracts";
10import * as Effect from "effect/Effect";
11import * as Deferred from "effect/Deferred";
12import * as Fiber from "effect/Fiber";
13import * as Layer from "effect/Layer";
14import * as PubSub from "effect/PubSub";
15import * as Queue from "effect/Queue";
16import * as Stream from "effect/Stream";
17import * as SubscriptionRef from "effect/SubscriptionRef";
18
19import {
20 ProviderSessionDirectoryPersistenceError,
21 ProviderValidationError,
22 type ProviderServiceError,
23} from "../Errors.ts";
24import type { ProviderInstance } from "../ProviderDriver.ts";
25import type { ProviderAuthController } from "../Services/ProviderAuthService.ts";
26import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
27import { ProviderService } from "../Services/ProviderService.ts";
28import {
29 ProviderSessionDirectory,
30 type ProviderRuntimeBindingWithMetadata,
31} from "../Services/ProviderSessionDirectory.ts";
32import { makeProviderAuthService } from "./ProviderAuthService.ts";
33
34const instanceId = ProviderInstanceId.make("antigravity-personal");
35const otherInstanceId = ProviderInstanceId.make("antigravity-work");
36const unsupportedInstanceId = ProviderInstanceId.make("codex");
37const driverKind = ProviderDriverKind.make("antigravity");
38const owner = "paired-client-owner";
39const otherOwner = "paired-client-other";
40const flowId = "test-sign-in-flow";
41const callbackUrl = "http://127.0.0.1:48123/?state=test-state&code=test-code";
42const now = "2026-09-02T00:00:00.000Z";
43const idleAuthState: ProviderAuthState = {
44 instanceId,
45 phase: "idle",
46 flowId: null,
47 authorizationUrl: null,
48 expiresAt: null,
49 message: null,
50};
51const waitingAuthState: ProviderAuthState = {
52 ...idleAuthState,
53 phase: "waiting",
54 flowId,
55 authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=test-state",
56 expiresAt: "2026-09-02T00:05:00.000Z",
57};
58
59function makeInstance(input: {
60 instanceId: ProviderInstanceId;
61 enabled: boolean;
62 auth?: ProviderAuthController;
63}): ProviderInstance {
64 return {
65 ...input,
66 driverKind,
67 displayName: undefined,
68 continuationIdentity: { driverKind, continuationKey: input.instanceId },
69 get snapshot(): never {
70 throw new Error("Auth routing must not refresh the provider snapshot.");
71 },
72 get adapter(): never {
73 throw new Error("Auth routing must not start an adapter session.");
74 },
75 get textGeneration(): never {
76 throw new Error("Auth routing must not generate text.");
77 },
78 };
79}
80
81function makeBinding(
82 thread: string,
83 status: NonNullable<ProviderRuntimeBindingWithMetadata["status"]>,
84 providerInstanceId = instanceId,
85): ProviderRuntimeBindingWithMetadata {
86 return {
87 threadId: ThreadId.make(thread),
88 provider: driverKind,
89 providerInstanceId,
90 status,
91 lastSeenAt: now,
92 };
93}
94
95function makeSession(thread: string, providerInstanceId = instanceId): ProviderSession {
96 return {
97 threadId: ThreadId.make(thread),
98 provider: driverKind,
99 providerInstanceId,
100 status: "ready",
101 runtimeMode: "approval-required",
102 createdAt: now,
103 updatedAt: now,
104 };
105}
106
107const makeHarness = Effect.fn("ProviderAuthService.test.makeHarness")(function* (
108 input: {
109 enabled?: boolean;
110 bindings?: ReadonlyArray<ProviderRuntimeBindingWithMetadata>;
111 sessions?: ReadonlyArray<ProviderSession>;
112 directoryError?: ProviderSessionDirectoryPersistenceError;
113 stopError?: ProviderServiceError;
114 logoutError?: ProviderSetupError;
115 } = {},
116) {
117 const actions: string[] = [];
118 const registryChanges = yield* PubSub.unbounded<void>();
119 const sessions = new Map(input.sessions?.map((session) => [session.threadId, session]));
120 const bindings = new Map(input.bindings?.map((binding) => [binding.threadId, binding]));
121 const idle = idleAuthState;
122 let state = idle;
123 let flowOwner: string | undefined;
124 let gateClosed = false;
125
126 const checkOwner = Effect.fn("ProviderAuthService.test.checkOwner")(function* (
127 ownerSessionId: string,
128 requestedFlowId: string,
129 operation: string,
130 ) {
131 if (ownerSessionId !== flowOwner || requestedFlowId !== state.flowId) {
132 return yield* new ProviderSetupError({
133 instanceId,
134 operation,
135 detail: "This sign-in belongs to another client or has expired.",
136 });
137 }
138 });
139
140 const auth: ProviderAuthController = {
141 start: Effect.fn(function* (ownerSessionId, stopSessions) {
142 gateClosed = true;
143 actions.push("close-gate");
144 yield* stopSessions ?? Effect.void;
145 flowOwner = ownerSessionId;
146 state = waitingAuthState;
147 actions.push("start-sign-in");
148 return state;
149 }),
150 complete: Effect.fn(function* (ownerSessionId, request) {
151 yield* checkOwner(ownerSessionId, request.flowId, "complete");
152 if (request.callbackUrl !== callbackUrl) {
153 return yield* new ProviderSetupError({
154 instanceId,
155 operation: "complete",
156 detail: "The redirect URL does not match this sign-in.",
157 });
158 }
159 state = { ...idle, flowId, phase: "succeeded" };
160 return state;
161 }),
162 cancel: Effect.fn(function* (ownerSessionId, requestedFlowId) {
163 yield* checkOwner(ownerSessionId, requestedFlowId, "cancel");
164 state = { ...idle, flowId, phase: "cancelled" };
165 return state;
166 }),
167 logout: Effect.fn(function* (stopSessions) {
168 gateClosed = true;
169 actions.push("close-gate");
170 yield* stopSessions;
171 if (input.logoutError) return yield* input.logoutError;
172 actions.push("native-logout");
173 state = idle;
174 flowOwner = undefined;
175 return state;
176 }),
177 subscribe: (ownerSessionId) =>
178 Stream.fromEffect(Effect.sync(() => (ownerSessionId === flowOwner ? state : idle))),
179 isLogoutPrompt: (text, hasAttachments) => !hasAttachments && text.trim() === "/logout",
180 };
181 const instances = [
182 makeInstance({ instanceId, enabled: input.enabled ?? true, auth }),
183 makeInstance({ instanceId: unsupportedInstanceId, enabled: true }),
184 ];
185 const service = yield* makeProviderAuthService.pipe(
186 Effect.provide(
187 Layer.mergeAll(
188 Layer.mock(ProviderInstanceRegistry)({
189 getInstance: (id) =>
190 Effect.succeed(instances.find((instance) => instance.instanceId === id)),
191 subscribeChanges: PubSub.subscribe(registryChanges),
192 }),
193 Layer.mock(ProviderSessionDirectory)({
194 listBindings: () =>
195 Effect.suspend(() => {
196 assert.isTrue(gateClosed);
197 actions.push("list-bindings");
198 return input.directoryError
199 ? Effect.fail(input.directoryError)
200 : Effect.succeed([...bindings.values()]);
201 }),
202 }),
203 Layer.mock(ProviderService)({
204 listSessions: () =>
205 Effect.sync(() => {
206 assert.isTrue(gateClosed);
207 actions.push("list-sessions");
208 return [...sessions.values()];
209 }),
210 stopSession: ({ threadId }) =>
211 Effect.suspend(() => {
212 assert.isTrue(gateClosed);
213 actions.push(`stop:${threadId}`);
214 if (input.stopError) return Effect.fail(input.stopError);
215 sessions.delete(threadId);
216 const binding = bindings.get(threadId);
217 if (binding) bindings.set(threadId, { ...binding, status: "stopped" });
218 return Effect.void;
219 }),
220 }),
221 ),
222 ),
223 );
224 return { service, actions, sessions, bindings };
225});
226
227const makeStreamingController = Effect.fn("ProviderAuthService.test.makeStreamingController")(
228 function* (flowOwner: string) {
229 const state = yield* SubscriptionRef.make(idleAuthState);
230 const close = yield* Deferred.make<void>();
231 const closedSubscriptions = yield* Queue.unbounded<string>();
232 const unused = () => Effect.die("Unexpected auth operation in a subscription test.");
233 const auth: ProviderAuthController = {
234 start: unused,
235 complete: unused,
236 cancel: unused,
237 logout: unused,
238 subscribe: (ownerSessionId) =>
239 SubscriptionRef.changes(state).pipe(
240 Stream.map((current) => (ownerSessionId === flowOwner ? current : idleAuthState)),
241 Stream.interruptWhen(Deferred.await(close)),
242 Stream.ensuring(Queue.offer(closedSubscriptions, ownerSessionId)),
243 ),
244 };
245 return {
246 auth,
247 state,
248 closedSubscriptions,
249 close: Deferred.succeed(close, undefined),
250 };
251 },
252);
253
254const makeSubscriptionHarness = Effect.fn("ProviderAuthService.test.makeSubscriptionHarness")(
255 function* (initial: ProviderInstance, replaceDuringFirstLookup?: ProviderInstance) {
256 const changes = yield* PubSub.unbounded<void>();
257 let current: ProviderInstance | undefined = initial;
258 let pendingReplacement = replaceDuringFirstLookup;
259 let subscribed = false;
260 const service = yield* makeProviderAuthService.pipe(
261 Effect.provide(
262 Layer.mergeAll(
263 Layer.mock(ProviderInstanceRegistry)({
264 subscribeChanges: Effect.gen(function* () {
265 const subscription = yield* PubSub.subscribe(changes);
266 subscribed = true;
267 return subscription;
268 }),
269 getInstance: () =>
270 Effect.gen(function* () {
271 const instance = current;
272 if (pendingReplacement) {
273 assert.isTrue(subscribed, "Registry changes must be subscribed before lookup.");
274 current = pendingReplacement;
275 pendingReplacement = undefined;
276 yield* PubSub.publish(changes, undefined);
277 }
278 return instance;
279 }),
280 }),
281 Layer.mock(ProviderService)({}),
282 Layer.mock(ProviderSessionDirectory)({}),
283 ),
284 ),
285 );
286 return {
287 service,
288 replace: Effect.fn(function* (replacement: ProviderInstance | undefined) {
289 current = replacement;
290 yield* PubSub.publish(changes, undefined);
291 }),
292 };
293 },
294);
295
296const observeAuth = Effect.fn("ProviderAuthService.test.observeAuth")(function* (
297 stream: Stream.Stream<ProviderAuthState, ProviderSetupError>,
298) {
299 const states = yield* Queue.unbounded<ProviderAuthState>();
300 const fiber = yield* stream.pipe(
301 Stream.runForEach((state) => Queue.offer(states, state)),
302 Effect.forkScoped,
303 );
304 return { states, fiber };
305});
306
307describe("ProviderAuthService", () => {
308 it.effect("stops routed sessions before sign-in, including for a disabled instance", () =>
309 Effect.gen(function* () {
310 const { service, actions, sessions } = yield* makeHarness({
311 enabled: false,
312 sessions: [makeSession("active")],
313 });
314 const state = yield* service.start({ instanceId }, owner);
315
316 assert.strictEqual(state.instanceId, instanceId);
317 assert.strictEqual(state.phase, "waiting");
318 assert.strictEqual(sessions.size, 0);
319 assert.deepStrictEqual(actions, [
320 "close-gate",
321 "list-bindings",
322 "list-sessions",
323 "stop:active",
324 "start-sign-in",
325 ]);
326 }),
327 );
328
329 it.effect("keeps sign-in state private and accepts the owner's redirect URL", () =>
330 Effect.gen(function* () {
331 const { service } = yield* makeHarness();
332 const waiting = yield* service.start({ instanceId }, owner);
333 const ownerStates = yield* service
334 .subscribe({ instanceId }, owner)
335 .pipe(Stream.take(1), Stream.runCollect);
336 const otherStates = yield* service
337 .subscribe({ instanceId }, otherOwner)
338 .pipe(Stream.take(1), Stream.runCollect);
339
340 assert.deepStrictEqual(ownerStates, [waiting]);
341 assert.strictEqual(otherStates[0]?.authorizationUrl, null);
342 assert.strictEqual(otherStates[0]?.flowId, null);
343
344 const otherError = yield* Effect.flip(
345 service.complete({ instanceId, flowId, callbackUrl }, otherOwner),
346 );
347 assert.strictEqual(otherError.operation, "complete");
348 const complete = yield* service.complete({ instanceId, flowId, callbackUrl }, owner);
349 assert.strictEqual(complete.phase, "succeeded");
350 assert.strictEqual(complete.authorizationUrl, null);
351 }),
352 );
353
354 it.effect("lets only the flow owner cancel sign-in", () =>
355 Effect.gen(function* () {
356 const { service } = yield* makeHarness();
357 yield* service.start({ instanceId }, owner);
358 const error = yield* Effect.flip(service.cancel({ instanceId, flowId }, otherOwner));
359 assert.strictEqual(error.operation, "cancel");
360
361 const cancelled = yield* service.cancel({ instanceId, flowId }, owner);
362 assert.strictEqual(cancelled.phase, "cancelled");
363 assert.strictEqual(cancelled.authorizationUrl, null);
364 }),
365 );
366
367 it.effect.each([
368 { change: "enable", initialEnabled: false, closeBeforeReplacement: true },
369 { change: "config", initialEnabled: true, closeBeforeReplacement: false },
370 ])(
371 "keeps private auth subscriptions current after an instance $change change",
372 ({ initialEnabled, closeBeforeReplacement }) =>
373 Effect.gen(function* () {
374 const first = yield* makeStreamingController(owner);
375 const replacement = yield* makeStreamingController(otherOwner);
376 yield* SubscriptionRef.set(first.state, waitingAuthState);
377 const { service, replace } = yield* makeSubscriptionHarness(
378 makeInstance({ instanceId, enabled: initialEnabled, auth: first.auth }),
379 );
380 const firstClient = yield* observeAuth(service.subscribe({ instanceId }, owner));
381 const secondClient = yield* observeAuth(service.subscribe({ instanceId }, otherOwner));
382 assert.deepStrictEqual(yield* Queue.take(firstClient.states), waitingAuthState);
383 assert.deepStrictEqual(yield* Queue.take(secondClient.states), idleAuthState);
384
385 if (closeBeforeReplacement) {
386 yield* first.close;
387 const closed = yield* Queue.takeN(first.closedSubscriptions, 2);
388 assert.deepStrictEqual(new Set(closed), new Set([owner, otherOwner]));
389 }
390 yield* replace(makeInstance({ instanceId, enabled: true, auth: replacement.auth }));
391 assert.deepStrictEqual(yield* Queue.take(firstClient.states), idleAuthState);
392 assert.deepStrictEqual(yield* Queue.take(secondClient.states), idleAuthState);
393 if (!closeBeforeReplacement) {
394 const closed = yield* Queue.takeN(first.closedSubscriptions, 2);
395 assert.deepStrictEqual(new Set(closed), new Set([owner, otherOwner]));
396 }
397
398 const staleState: ProviderAuthState = { ...idleAuthState, phase: "cancelled" };
399 yield* SubscriptionRef.set(first.state, staleState);
400 const newWaiting: ProviderAuthState = {
401 ...waitingAuthState,
402 flowId: "replacement-flow",
403 authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=replacement-state",
404 };
405 yield* SubscriptionRef.set(replacement.state, newWaiting);
406 assert.deepStrictEqual(yield* Queue.take(firstClient.states), idleAuthState);
407 assert.deepStrictEqual(yield* Queue.take(secondClient.states), newWaiting);
408 }),
409 );
410
411 it.effect("does not miss a replacement during the first instance lookup", () =>
412 Effect.gen(function* () {
413 const first = yield* makeStreamingController(owner);
414 const replacement = yield* makeStreamingController(owner);
415 yield* SubscriptionRef.set(replacement.state, waitingAuthState);
416 const { service } = yield* makeSubscriptionHarness(
417 makeInstance({ instanceId, enabled: false, auth: first.auth }),
418 makeInstance({ instanceId, enabled: true, auth: replacement.auth }),
419 );
420
421 const states = yield* service.subscribe({ instanceId }, owner).pipe(
422 Stream.filter((state) => state.phase === "waiting"),
423 Stream.take(1),
424 Stream.runCollect,
425 );
426
427 assert.deepStrictEqual(states, [waitingAuthState]);
428 }),
429 );
430
431 it.effect("ends the subscription with a setup error when the instance is removed", () =>
432 Effect.gen(function* () {
433 const controller = yield* makeStreamingController(owner);
434 const { service, replace } = yield* makeSubscriptionHarness(
435 makeInstance({ instanceId, enabled: true, auth: controller.auth }),
436 );
437 const { states, fiber } = yield* observeAuth(service.subscribe({ instanceId }, owner));
438 assert.deepStrictEqual(yield* Queue.take(states), idleAuthState);
439
440 yield* replace(undefined);
441 const error = yield* Effect.flip(Fiber.join(fiber));
442
443 assert.instanceOf(error, ProviderSetupError);
444 assert.strictEqual(error.instanceId, instanceId);
445 assert.strictEqual(error.operation, "subscribe");
446 assert.include(error.detail, "no longer available");
447 assert.strictEqual(yield* Queue.take(controller.closedSubscriptions), owner);
448 }),
449 );
450
451 it.effect.each([
452 { id: ProviderInstanceId.make("missing"), detail: "no longer available" },
453 { id: unsupportedInstanceId, detail: "does not support sign-in" },
454 ])("rejects setup for unavailable or unsupported instance $id", ({ id, detail }) =>
455 Effect.gen(function* () {
456 const { service, actions } = yield* makeHarness();
457 const operations: Effect.Effect<
458 ProviderAuthState | ReadonlyArray<ProviderAuthState>,
459 ProviderSetupError
460 >[] = [
461 service.start({ instanceId: id }, owner),
462 service.complete({ instanceId: id, flowId, callbackUrl }, owner),
463 service.cancel({ instanceId: id, flowId }, owner),
464 service.logout({ instanceId: id }),
465 Stream.runCollect(service.subscribe({ instanceId: id }, owner)),
466 ];
467 for (const operation of operations) {
468 const error = yield* Effect.flip(operation);
469 assert.instanceOf(error, ProviderSetupError);
470 assert.strictEqual(error.instanceId, id);
471 assert.include(error.detail, detail);
472 }
473 assert.deepStrictEqual(actions, []);
474 }),
475 );
476
477 it.effect("stops active and persisted sessions once before native logout", () =>
478 Effect.gen(function* () {
479 const { service, actions, sessions, bindings } = yield* makeHarness({
480 bindings: [
481 makeBinding("shared", "running"),
482 makeBinding("starting", "starting"),
483 makeBinding("persisted", "running"),
484 makeBinding("already-stopped", "stopped"),
485 makeBinding("other-instance", "running", otherInstanceId),
486 ],
487 sessions: [
488 makeSession("shared"),
489 makeSession("runtime-only"),
490 makeSession("other-instance", otherInstanceId),
491 ],
492 });
493 const state = yield* service.logout({ instanceId });
494
495 assert.strictEqual(state.phase, "idle");
496 assert.deepStrictEqual(actions, [
497 "close-gate",
498 "list-bindings",
499 "list-sessions",
500 "stop:shared",
501 "stop:starting",
502 "stop:persisted",
503 "stop:runtime-only",
504 "native-logout",
505 ]);
506 assert.deepStrictEqual([...sessions.keys()], [ThreadId.make("other-instance")]);
507 assert.strictEqual(bindings.get(ThreadId.make("starting"))?.status, "stopped");
508 assert.strictEqual(bindings.get(ThreadId.make("persisted"))?.status, "stopped");
509 assert.strictEqual(bindings.get(ThreadId.make("other-instance"))?.status, "running");
510 }),
511 );
512
513 it.effect.each([
514 { text: "/logout", hasAttachments: false, handled: true },
515 { text: " \n/logout\t", hasAttachments: false, handled: true },
516 { text: "/logout", hasAttachments: true, handled: false },
517 { text: "/logout please", hasAttachments: false, handled: false },
518 { text: "Explain /logout", hasAttachments: false, handled: false },
519 { text: "/Logout", hasAttachments: false, handled: false },
520 ])("handles only a standalone logout command %#", ({ text, hasAttachments, handled }) =>
521 Effect.gen(function* () {
522 const { service, actions } = yield* makeHarness();
523 const result = yield* service.tryHandlePromptCommand({ instanceId, text, hasAttachments });
524
525 assert.strictEqual(result, handled);
526 assert.deepStrictEqual(
527 actions,
528 handled ? ["close-gate", "list-bindings", "list-sessions", "native-logout"] : [],
529 );
530 }),
531 );
532
533 it.effect("does not intercept commands for providers without an auth controller", () =>
534 Effect.gen(function* () {
535 const { service, actions } = yield* makeHarness();
536 for (const id of [unsupportedInstanceId, ProviderInstanceId.make("missing")]) {
537 assert.isFalse(
538 yield* service.tryHandlePromptCommand({
539 instanceId: id,
540 text: "/logout",
541 hasAttachments: false,
542 }),
543 );
544 }
545 assert.deepStrictEqual(actions, []);
546 }),
547 );
548
549 it.effect("does not log out when the session directory cannot be read", () =>
550 Effect.gen(function* () {
551 const { service, actions } = yield* makeHarness({
552 directoryError: new ProviderSessionDirectoryPersistenceError({
553 operation: "listBindings",
554 detail: "private database diagnostics",
555 }),
556 });
557 const error = yield* Effect.flip(service.logout({ instanceId }));
558
559 assert.instanceOf(error, ProviderSetupError);
560 assert.strictEqual(error.instanceId, instanceId);
561 assert.strictEqual(error.operation, "stopSessions");
562 assert.notInclude(error.detail, "private database diagnostics");
563 assert.deepStrictEqual(actions, ["close-gate", "list-bindings"]);
564 }),
565 );
566
567 it.effect("does not log out or consume the command when stopping a session fails", () =>
568 Effect.gen(function* () {
569 const { service, actions, sessions } = yield* makeHarness({
570 sessions: [makeSession("active")],
571 stopError: new ProviderValidationError({
572 operation: "stopSession",
573 issue: "private process diagnostics",
574 }),
575 });
576 const error = yield* Effect.flip(
577 service.tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }),
578 );
579
580 assert.instanceOf(error, ProviderSetupError);
581 assert.strictEqual(error.operation, "stopSessions");
582 assert.notInclude(error.detail, "private process diagnostics");
583 assert.strictEqual(sessions.size, 1);
584 assert.deepStrictEqual(actions, [
585 "close-gate",
586 "list-bindings",
587 "list-sessions",
588 "stop:active",
589 ]);
590 }),
591 );
592
593 it.effect("returns native logout failures to the command caller", () =>
594 Effect.gen(function* () {
595 const logoutError = new ProviderSetupError({
596 instanceId,
597 operation: "logout",
598 detail: "Native sign-out failed. Try again.",
599 });
600 const { service } = yield* makeHarness({ logoutError });
601 const error = yield* Effect.flip(
602 service.tryHandlePromptCommand({ instanceId, text: "/logout", hasAttachments: false }),
603 );
604
605 assert.strictEqual(error, logoutError);
606 }),
607 );
608});
apps/server/src/provider/Layers/ProviderAuthService.ts created+120
......@@ -0,0 +1,120 @@
1import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Stream from "effect/Stream";
5
6import { ProviderAuthService } from "../Services/ProviderAuthService.ts";
7import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
8import { ProviderService } from "../Services/ProviderService.ts";
9import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
10
11export const makeProviderAuthService = Effect.gen(function* () {
12 const registry = yield* ProviderInstanceRegistry;
13 const providers = yield* ProviderService;
14 const directory = yield* ProviderSessionDirectory;
15
16 const getController = Effect.fn("ProviderAuthService.getController")(function* (
17 instanceId: ProviderInstanceId,
18 operation: string,
19 ) {
20 const instance = yield* registry.getInstance(instanceId);
21 if (!instance?.auth) {
22 return yield* new ProviderSetupError({
23 instanceId,
24 operation,
25 detail: instance
26 ? "This provider does not support sign-in in T3 Code."
27 : "This provider instance is no longer available.",
28 });
29 }
30 return instance.auth;
31 });
32
33 const stopSessions = Effect.fn("ProviderAuthService.stopSessions")(function* (
34 instanceId: ProviderInstanceId,
35 ) {
36 const bindings = yield* directory.listBindings().pipe(
37 Effect.mapError(
38 () =>
39 new ProviderSetupError({
40 instanceId,
41 operation: "stopSessions",
42 detail: "Could not read the provider's active sessions. Try again.",
43 }),
44 ),
45 );
46 const sessions = yield* providers.listSessions();
47 const threadIds = new Set(
48 bindings
49 .filter(
50 (binding) => binding.providerInstanceId === instanceId && binding.status !== "stopped",
51 )
52 .map((binding) => binding.threadId),
53 );
54 for (const session of sessions) {
55 if (session.providerInstanceId === instanceId) {
56 threadIds.add(session.threadId);
57 }
58 }
59 yield* Effect.forEach(
60 threadIds,
61 (threadId) =>
62 providers.stopSession({ threadId }).pipe(
63 Effect.mapError(
64 () =>
65 new ProviderSetupError({
66 instanceId,
67 operation: "stopSessions",
68 detail: "Could not stop all sessions for this provider. Try again.",
69 }),
70 ),
71 ),
72 { discard: true },
73 );
74 });
75
76 return ProviderAuthService.of({
77 start: Effect.fn("ProviderAuthService.start")(function* (input, ownerSessionId) {
78 const auth = yield* getController(input.instanceId, "start");
79 return yield* auth.start(ownerSessionId, stopSessions(input.instanceId));
80 }),
81 complete: Effect.fn("ProviderAuthService.complete")(function* (input, ownerSessionId) {
82 const auth = yield* getController(input.instanceId, "complete");
83 return yield* auth.complete(ownerSessionId, input);
84 }),
85 cancel: Effect.fn("ProviderAuthService.cancel")(function* (input, ownerSessionId) {
86 const auth = yield* getController(input.instanceId, "cancel");
87 return yield* auth.cancel(ownerSessionId, input.flowId);
88 }),
89 logout: Effect.fn("ProviderAuthService.logout")(function* (input) {
90 const auth = yield* getController(input.instanceId, "logout");
91 return yield* auth.logout(stopSessions(input.instanceId));
92 }),
93 subscribe: (input, ownerSessionId) =>
94 Effect.gen(function* () {
95 const changes = yield* registry.subscribeChanges;
96 const initial = yield* getController(input.instanceId, "subscribe");
97 return Stream.concat(
98 Stream.succeed(initial),
99 Stream.fromSubscription(changes).pipe(
100 Stream.mapEffect(() => getController(input.instanceId, "subscribe")),
101 ),
102 ).pipe(
103 Stream.changesWith((previous, next) => previous === next),
104 Stream.switchMap((auth) => auth.subscribe(ownerSessionId)),
105 );
106 }).pipe(Stream.unwrap),
107 tryHandlePromptCommand: Effect.fn("ProviderAuthService.tryHandlePromptCommand")(
108 function* (input) {
109 const instance = yield* registry.getInstance(input.instanceId);
110 if (!instance?.auth?.isLogoutPrompt?.(input.text, input.hasAttachments)) {
111 return false;
112 }
113 yield* instance.auth.logout(stopSessions(input.instanceId));
114 return true;
115 },
116 ),
117 });
118});
119
120export const ProviderAuthServiceLive = Layer.effect(ProviderAuthService, makeProviderAuthService);
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+7-3
......@@ -42,6 +42,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";
4242
4343import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
4444import type { BuiltInDriversEnv } from "../builtInDrivers.ts";
45import { AntigravityInstallation } from "../AntigravityInstallation.ts";
4546import { ServerConfig } from "../../config.ts";
4647import { ServerSettingsService } from "../../serverSettings.ts";
4748import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts";
......@@ -308,9 +309,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
308309 // surfaced; that merged layer then provides `ServerConfig.layerTest`'s
309310 // `FileSystem` dep while keeping everything else surfaced to the test.
310311 const infraLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer));
311 const testLayer = ServerConfig.layerTest(process.cwd(), {
312 prefix: "provider-instance-registry-all-drivers-test",
313 }).pipe(
312 const testLayer = AntigravityInstallation.layer.pipe(
313 Layer.provideMerge(
314 ServerConfig.layerTest(process.cwd(), {
315 prefix: "provider-instance-registry-all-drivers-test",
316 }),
317 ),
314318 Layer.provideMerge(infraLayer),
315319 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
316320 Layer.provideMerge(ServerSettingsService.layerTest()),
apps/server/src/provider/Layers/ProviderRegistry.test.ts+157-54
......@@ -35,6 +35,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
3535import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts";
3636import { checkClaudeProviderStatus } from "./ClaudeProvider.ts";
3737import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
38import { AntigravityInstallation } from "../AntigravityInstallation.ts";
3839import * as ModelManifest from "../ModelManifest.ts";
3940import * as OpenCodeRuntime from "../opencodeRuntime.ts";
4041import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
......@@ -897,6 +898,119 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
897898 assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]);
898899 });
899900
901 describe("Antigravity model inventories", () => {
902 const previousProvider = {
903 instanceId: ProviderInstanceId.make("antigravity-personal"),
904 driver: ProviderDriverKind.make("antigravity"),
905 status: "ready",
906 enabled: true,
907 installed: true,
908 auth: { status: "authenticated" },
909 checkedAt: "2026-09-02T00:00:00.000Z",
910 version: "0.1.3",
911 models: [
912 {
913 slug: "gemini-3.1-pro-high",
914 name: "Gemini 3.1 Pro High",
915 isCustom: false,
916 capabilities: null,
917 },
918 {
919 slug: "gemini-3-flash",
920 name: "Gemini 3 Flash",
921 isCustom: false,
922 capabilities: null,
923 },
924 ],
925 slashCommands: [],
926 skills: [],
927 } as const satisfies ServerProvider;
928
929 it("removes unavailable models after a successful refresh", () => {
930 for (const status of ["ready", "warning"] as const) {
931 const refreshedProvider = {
932 ...previousProvider,
933 status,
934 checkedAt: "2026-09-02T00:01:00.000Z",
935 models: [previousProvider.models[1]],
936 } satisfies ServerProvider;
937 const afterRefresh = mergeProviderSnapshot(previousProvider, refreshedProvider);
938
939 assert.deepStrictEqual(afterRefresh.models, refreshedProvider.models);
940
941 const afterFailure = mergeProviderSnapshot(afterRefresh, {
942 ...refreshedProvider,
943 status: "error",
944 auth: { status: "unknown" },
945 models: [],
946 });
947 assert.deepStrictEqual(afterFailure.models, refreshedProvider.models);
948 }
949 });
950
951 it("keeps cached models during health checks and temporary failures", () => {
952 for (const installed of [false, true]) {
953 const pendingProvider = {
954 ...previousProvider,
955 status: "warning",
956 installed,
957 auth: { status: "unknown" },
958 checkedAt: "2026-09-02T00:01:00.000Z",
959 version: installed ? previousProvider.version : null,
960 models: [],
961 } satisfies ServerProvider;
962
963 assert.deepStrictEqual(
964 mergeProviderSnapshot(previousProvider, pendingProvider).models,
965 previousProvider.models,
966 );
967 }
968
969 for (const authStatus of ["unknown", "authenticated"] as const) {
970 const failedProvider = {
971 ...previousProvider,
972 status: "error",
973 auth: { status: authStatus },
974 checkedAt: "2026-09-02T00:02:00.000Z",
975 models: [],
976 } satisfies ServerProvider;
977
978 assert.deepStrictEqual(
979 mergeProviderSnapshot(previousProvider, failedProvider).models,
980 previousProvider.models,
981 );
982 }
983 });
984
985 it("clears models after sign-out, disable, uninstall, or an empty successful refresh", () => {
986 const emptyProvider = {
987 ...previousProvider,
988 checkedAt: "2026-09-02T00:01:00.000Z",
989 models: [],
990 } satisfies ServerProvider;
991 const clearedProviders = [
992 { ...emptyProvider, status: "warning", auth: { status: "unauthenticated" } },
993 { ...emptyProvider, status: "error", auth: { status: "unauthenticated" } },
994 { ...emptyProvider, status: "disabled", enabled: false },
995 { ...emptyProvider, status: "error", enabled: false },
996 { ...emptyProvider, status: "error", installed: false, auth: { status: "unknown" } },
997 emptyProvider,
998 ] satisfies ReadonlyArray<ServerProvider>;
999
1000 for (const provider of clearedProviders) {
1001 const afterRemoval = mergeProviderSnapshot(previousProvider, provider);
1002 assert.deepStrictEqual(afterRemoval.models, []);
1003
1004 const afterFailure = mergeProviderSnapshot(afterRemoval, {
1005 ...emptyProvider,
1006 status: "error",
1007 auth: { status: "unknown" },
1008 });
1009 assert.deepStrictEqual(afterFailure.models, []);
1010 }
1011 });
1012 });
1013
9001014 it("fills missing capabilities from the previous provider snapshot", () => {
9011015 const previousProvider = {
9021016 instanceId: ProviderInstanceId.make("cursor"),
......@@ -1867,6 +1981,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
18671981 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
18681982 const providerRegistryLayer = ProviderRegistryLive.pipe(
18691983 Layer.provideMerge(ProviderInstanceRegistryHydrationLive),
1984 Layer.provideMerge(AntigravityInstallation.layer),
18701985 Layer.provideMerge(
18711986 Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings),
18721987 ),
......@@ -1932,18 +2047,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
19322047 }),
19332048 );
19342049
1935 // Guards the second half of the reported bug: changing
1936 // `providers.codex.binaryPath` in settings must tear down the live
1937 // instance and rebuild it so a fresh probe runs with the new binary.
1938 // This test drives the real settings stream → registry reconcile →
1939 // aggregator sync pipeline and asserts that `getProviders` reflects
1940 // the new background probe's outcome.
1941 //
2050 // A binary path change must rebuild Codex and publish its new probe result.
19422051 it.effect("re-probes when settings change the codex binaryPath", () =>
19432052 Effect.gen(function* () {
19442053 const firstMissing = `t3code_codex_first_`;
19452054 const secondMissing = `t3code_codex_second_`;
19462055 const spawnedCommands: Array<string> = [];
2056 const secondProbeStarted = yield* Deferred.make<void>();
2057 const releaseSecondProbe = yield* Deferred.make<void>();
19472058 const allowLazySettingsStream = yield* Deferred.make<void>();
19482059 const mutableServerSettings = yield* makeMutableServerSettingsService(
19492060 decodeServerSettings(
......@@ -1970,6 +2081,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
19702081 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
19712082 const providerRegistryLayer = ProviderRegistryLive.pipe(
19722083 Layer.provideMerge(ProviderInstanceRegistryHydrationLive),
2084 Layer.provideMerge(AntigravityInstallation.layer),
19732085 Layer.provideMerge(
19742086 Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings),
19752087 ),
......@@ -1989,8 +2101,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
19892101 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
19902102 Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) =>
19912103 ChildProcessSpawner.make((command) => {
1992 spawnedCommands.push((command as { readonly command: string }).command);
1993 return spawner.spawn(command);
2104 if (command._tag !== "StandardCommand") return spawner.spawn(command);
2105 spawnedCommands.push(command.command);
2106 const beforeSpawn =
2107 command.command === secondMissing
2108 ? Deferred.succeed(secondProbeStarted, undefined).pipe(
2109 Effect.andThen(Deferred.await(releaseSecondProbe)),
2110 )
2111 : Effect.void;
2112 return beforeSpawn.pipe(Effect.andThen(spawner.spawn(command)));
19942113 }),
19952114 ),
19962115 Layer.provideMerge(NodeServices.layer),
......@@ -2002,36 +2121,29 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
20022121
20032122 yield* Effect.gen(function* () {
20042123 const registry = yield* ProviderRegistry.ProviderRegistry;
2005 // Boot-time probe: the default codex instance is enabled with
2006 // `firstMissing`, so the real spawner yields ENOENT and the
2007 // snapshot should be `status: "error"`.
2008 let initialProviders = yield* registry.getProviders;
2009 for (
2010 let attempts = 0;
2011 attempts < 50 &&
2012 initialProviders.find((provider) => provider.instanceId === "codex")?.status !==
2013 "error";
2014 attempts += 1
2015 ) {
2016 yield* TestClock.adjust("10 millis");
2017 yield* Effect.yieldNow;
2018 initialProviders = yield* registry.getProviders;
2019 }
2020 const initialCodex = initialProviders.find(
2124 const codexSnapshots = registry.streamChanges.pipe(
2125 Stream.map((providers) =>
2126 providers.find((provider) => provider.instanceId === "codex"),
2127 ),
2128 Stream.filter((provider): provider is ServerProvider => provider !== undefined),
2129 );
2130 const firstError = yield* Stream.toPull(
2131 codexSnapshots.pipe(Stream.filter((provider) => provider.status === "error")),
2132 );
2133 const currentCodex = (yield* registry.getProviders).find(
20212134 (provider) => provider.instanceId === "codex",
20222135 );
2136 const initialCodex =
2137 currentCodex?.status === "error" ? currentCodex : (yield* firstError)[0];
20232138 assert.strictEqual(initialCodex?.status, "error");
20242139 assert.strictEqual(initialCodex?.installed, false);
20252140 assert.deepStrictEqual(spawnedCommands, [firstMissing]);
20262141
2027 // Drive a settings change. The Hydration layer's
2028 // `SettingsWatcherLive` consumes this via `subscribeChanges`,
2029 // calls `reconcile`, which rebuilds the codex instance (the
2030 // envelope changed because `binaryPath` differs → `entryEqual`
2031 // is false). The registry's `Stream.runForEach(
2032 // instanceRegistry.streamChanges, () => syncLiveSources)`
2033 // fires `syncLiveSources`, which subscribes and launches a fresh
2034 // background refresh on the rebuilt instance.
2142 const pendingRebuild = yield* Stream.toPull(
2143 codexSnapshots.pipe(
2144 Stream.filter((provider) => provider.status === "warning" && !provider.installed),
2145 ),
2146 );
20352147 yield* serverSettings.updateSettings({
20362148 providers: {
20372149 codex: { enabled: true, binaryPath: secondMissing },
......@@ -2041,27 +2153,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
20412153 // not subscribe before forking has already lost this update.
20422154 yield* Deferred.succeed(allowLazySettingsStream, undefined);
20432155
2044 // Poll until the injected process boundary observes the new
2045 // executable. This verifies the public settings-to-probe behavior
2046 // without depending on timestamps assigned by TestClock.
2047 const refreshed = yield* Effect.gen(function* () {
2048 for (let attempts = 0; attempts < 60; attempts += 1) {
2049 const providers = yield* registry.getProviders;
2050 const codex = providers.find((provider) => provider.instanceId === "codex");
2051 if (
2052 codex !== undefined &&
2053 codex.status === "error" &&
2054 spawnedCommands.includes(secondMissing)
2055 ) {
2056 return providers;
2057 }
2058 yield* TestClock.adjust("50 millis");
2059 yield* Effect.yieldNow;
2060 }
2061 return yield* registry.getProviders;
2062 });
2063
2064 const reprobedCodex = refreshed.find((provider) => provider.instanceId === "codex");
2156 // Hold the second probe until the aggregator sees the rebuilt
2157 // instance. Its next error must come from the new executable.
2158 yield* Deferred.await(secondProbeStarted);
2159 yield* pendingRebuild;
2160 const rebuiltError = yield* Stream.toPull(
2161 codexSnapshots.pipe(Stream.filter((provider) => provider.status === "error")),
2162 );
2163 yield* Deferred.succeed(releaseSecondProbe, undefined);
2164 const [reprobedCodex] = yield* rebuiltError;
20652165 assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]);
20662166 assert.strictEqual(reprobedCodex?.status, "error");
20672167 assert.strictEqual(reprobedCodex?.installed, false);
......@@ -2096,6 +2196,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
20962196 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
20972197 const providerRegistryLayer = ProviderRegistryLive.pipe(
20982198 Layer.provideMerge(ProviderInstanceRegistryHydrationLive),
2199 Layer.provideMerge(AntigravityInstallation.layer),
20992200 Layer.provideMerge(
21002201 Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings),
21012202 ),
......@@ -2156,6 +2257,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
21562257 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
21572258 const providerRegistryLayer = ProviderRegistryLive.pipe(
21582259 Layer.provideMerge(ProviderInstanceRegistryHydrationLive),
2260 Layer.provideMerge(AntigravityInstallation.layer),
21592261 Layer.provideMerge(
21602262 Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings),
21612263 ),
......@@ -2213,6 +2315,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
22132315 );
22142316
22152317 assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [
2318 "antigravity",
22162319 "claudeAgent",
22172320 "codex",
22182321 "cursor",
apps/server/src/provider/Layers/ProviderRegistry.ts+13-8
......@@ -101,20 +101,25 @@ export function upsertProviderWorkspaceSnapshot(
101101}
102102
103103const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => {
104 if (provider.driver !== ProviderDriverKind.make("opencode")) {
104 const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity");
105 if (!isAntigravity && provider.driver !== ProviderDriverKind.make("opencode")) {
105106 return true;
106107 }
107108
108 // OpenCode's initial snapshot is deliberately non-authoritative while its
109 // first probe is still running. A probe error from an installed CLI/server
110 // is likewise partial: it could not establish the current inventory.
111 // Conversely, disabled and missing-CLI snapshots are authoritative removals,
112 // as are successful ready/warning inventories (including an empty one after
113 // logout or plugin removal).
109 if (isAntigravity && (!provider.enabled || provider.auth.status === "unauthenticated")) {
110 return false;
111 }
112
113 // Both drivers replace their inventories after successful catalog discovery.
114 // Antigravity's local health check does not authenticate or discover models.
115 const isPendingAntigravityAuthentication =
116 isAntigravity && provider.status === "warning" && provider.auth.status === "unknown";
114117 const isPendingInitialProbe =
115118 provider.enabled && !provider.installed && provider.status === "warning";
116119 const didInstalledProviderProbeFail = provider.installed && provider.status === "error";
117 return isPendingInitialProbe || didInstalledProviderProbeFail;
120 return (
121 isPendingAntigravityAuthentication || isPendingInitialProbe || didInstalledProviderProbeFail
122 );
118123};
119124
120125const shouldRetainMissingOpenCodeMetadata = (provider: ServerProvider): boolean =>
apps/server/src/provider/Layers/ProviderService.test.ts+131-7
......@@ -120,7 +120,10 @@ type LegacyProviderRuntimeEvent = {
120120 readonly [key: string]: unknown;
121121};
122122
123function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {
123function makeFakeCodexAdapter(
124 provider: ProviderDriverKind = CODEX_DRIVER,
125 supportsConversationRollback?: boolean,
126) {
124127 const sessions = new Map<ThreadId, ProviderSession>();
125128 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
126129
......@@ -243,6 +246,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {
243246 provider,
244247 capabilities: {
245248 sessionModelSwitch: "in-session",
249 ...(supportsConversationRollback !== undefined ? { supportsConversationRollback } : {}),
246250 ...(provider === CODEX_DRIVER ? { promptlessTurnContinuation: true } : {}),
247251 },
248252 startSession,
......@@ -313,16 +317,20 @@ const hasMetricSnapshot = (
313317function makeProviderServiceLayer(
314318 input: {
315319 readonly directory?: ProviderSessionDirectory.ProviderSessionDirectory["Service"];
320 readonly supportsConversationRollback?: boolean;
321 readonly registry?: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"];
316322 } = {},
317323) {
318 const codex = makeFakeCodexAdapter();
324 const codex = makeFakeCodexAdapter(CODEX_DRIVER, input.supportsConversationRollback);
319325 const claude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER);
320326 const cursor = makeFakeCodexAdapter(CURSOR_DRIVER);
321 const registry = makeAdapterRegistryMock({
322 [ProviderDriverKind.make("codex")]: codex.adapter,
323 [ProviderDriverKind.make("claudeAgent")]: claude.adapter,
324 [ProviderDriverKind.make("cursor")]: cursor.adapter,
325 });
327 const registry =
328 input.registry ??
329 makeAdapterRegistryMock({
330 [ProviderDriverKind.make("codex")]: codex.adapter,
331 [ProviderDriverKind.make("claudeAgent")]: claude.adapter,
332 [ProviderDriverKind.make("cursor")]: cursor.adapter,
333 });
326334
327335 const providerAdapterLayer = Layer.succeed(
328336 ProviderAdapterRegistry.ProviderAdapterRegistry,
......@@ -636,6 +644,119 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance
636644
637645const routing = makeProviderServiceLayer();
638646
647const antigravityDriver = ProviderDriverKind.make("antigravity");
648const replacementAntigravity = makeFakeCodexAdapter(antigravityDriver);
649const originalAntigravityInstanceId = ProviderInstanceId.make("antigravity-personal");
650const replacementAntigravityInstanceId = ProviderInstanceId.make("antigravity");
651const antigravityRegistry = makeAdapterRegistryMock({
652 [antigravityDriver]: replacementAntigravity.adapter,
653});
654let originalAntigravityInstanceAvailable = true;
655const antigravityInstanceRouting = makeProviderServiceLayer({
656 registry: {
657 ...antigravityRegistry,
658 getInstanceInfo: (instanceId) =>
659 instanceId === originalAntigravityInstanceId && originalAntigravityInstanceAvailable
660 ? Effect.succeed({
661 instanceId,
662 driverKind: antigravityDriver,
663 displayName: undefined,
664 enabled: true,
665 continuationIdentity: {
666 driverKind: antigravityDriver,
667 continuationKey: `${antigravityDriver}:instance:${instanceId}`,
668 },
669 })
670 : antigravityRegistry.getInstanceInfo(instanceId),
671 },
672});
673antigravityInstanceRouting.layer("ProviderServiceLive instance-owned conversations", (it) => {
674 it.effect(
675 "does not replace a native conversation with another instance or a removed-instance fallback",
676 () =>
677 Effect.gen(function* () {
678 const provider = yield* ProviderService.ProviderService;
679 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
680
681 for (const originalAvailable of [true, false]) {
682 originalAntigravityInstanceAvailable = originalAvailable;
683 for (const passCursor of [true, false]) {
684 const threadId = asThreadId(
685 `thread-antigravity-instance-${originalAvailable}-${passCursor}`,
686 );
687 const resumeCursor = { sessionId: "native-session" };
688 yield* directory.upsert({
689 threadId,
690 provider: antigravityDriver,
691 providerInstanceId: originalAntigravityInstanceId,
692 status: "stopped",
693 runtimeMode: "approval-required",
694 ...(passCursor ? {} : { resumeCursor }),
695 });
696 const originalBinding = yield* directory.getBinding(threadId);
697 replacementAntigravity.startSession.mockClear();
698
699 const error = yield* Effect.flip(
700 provider.startSession(threadId, {
701 providerInstanceId: replacementAntigravityInstanceId,
702 threadId,
703 runtimeMode: "approval-required",
704 ...(passCursor ? { resumeCursor } : {}),
705 }),
706 );
707
708 assert.equal(
709 error._tag,
710 originalAvailable ? "ProviderValidationError" : "ProviderUnsupportedError",
711 );
712 assert.equal(replacementAntigravity.startSession.mock.calls.length, 0);
713 assert.deepEqual(yield* directory.getBinding(threadId), originalBinding);
714 }
715 }
716 }),
717 );
718});
719
720const unsupportedRollback = makeProviderServiceLayer({ supportsConversationRollback: false });
721unsupportedRollback.layer("ProviderServiceLive unsupported rewind", (it) => {
722 it.effect("rejects rewind without starting or changing the provider conversation", () =>
723 Effect.gen(function* () {
724 const provider = yield* ProviderService.ProviderService;
725 const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
726
727 for (const active of [true, false]) {
728 const threadId = asThreadId(`thread-unsupported-rewind-${active}`);
729 yield* provider.startSession(threadId, {
730 providerInstanceId: codexInstanceId,
731 threadId,
732 cwd: "/tmp/project",
733 runtimeMode: "approval-required",
734 });
735 if (!active) {
736 yield* unsupportedRollback.codex.stopSession(threadId);
737 }
738 const originalBinding = yield* directory.getBinding(threadId);
739 unsupportedRollback.codex.startSession.mockClear();
740 unsupportedRollback.codex.rollbackThread.mockClear();
741
742 const preflightError = yield* Effect.flip(
743 provider.assertConversationRollbackSupported(threadId),
744 );
745 const rollbackError = yield* Effect.flip(
746 provider.rollbackConversation({ threadId, numTurns: 1 }),
747 );
748
749 assert.instanceOf(preflightError, ProviderValidationError);
750 assert.include(preflightError.message, "does not support conversation rewind");
751 assert.instanceOf(rollbackError, ProviderValidationError);
752 assert.equal(unsupportedRollback.codex.startSession.mock.calls.length, 0);
753 assert.equal(unsupportedRollback.codex.rollbackThread.mock.calls.length, 0);
754 assert.deepEqual(yield* directory.getBinding(threadId), originalBinding);
755 }
756 }),
757 );
758});
759
639760it.effect(
640761 "ProviderServiceLive uploads feedback through the adapter that recovered the session",
641762 () =>
......@@ -1271,6 +1392,9 @@ routing.layer("ProviderServiceLive routing", (it) => {
12711392 routing.codex.startSession.mockClear();
12721393 routing.codex.rollbackThread.mockClear();
12731394
1395 yield* provider.assertConversationRollbackSupported(initial.threadId);
1396 assert.equal(routing.codex.startSession.mock.calls.length, 0);
1397
12741398 yield* provider.rollbackConversation({
12751399 threadId: initial.threadId,
12761400 numTurns: 1,
apps/server/src/provider/Layers/ProviderService.ts+37
......@@ -620,6 +620,26 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
620620 );
621621 }
622622 const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId));
623 if (
624 persistedBinding?.provider === resolvedProvider &&
625 persistedBinding.providerInstanceId !== resolvedInstanceId &&
626 (input.resumeCursor != null || persistedBinding.resumeCursor != null)
627 ) {
628 const previousInstanceId = yield* requireBindingInstanceId(
629 "ProviderService.startSession",
630 persistedBinding,
631 );
632 const previousInfo = yield* registry.getInstanceInfo(previousInstanceId);
633 if (
634 previousInfo.continuationIdentity.continuationKey !==
635 instanceInfo.continuationIdentity.continuationKey
636 ) {
637 return yield* toValidationError(
638 "ProviderService.startSession",
639 `Thread '${threadId}' cannot switch from instance '${previousInstanceId}' to '${resolvedInstanceId}' because their provider resume state is incompatible.`,
640 );
641 }
642 }
623643 const effectiveResumeCursor =
624644 input.resumeCursor ??
625645 (persistedBinding?.providerInstanceId === resolvedInstanceId
......@@ -1104,6 +1124,21 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
11041124 const getInstanceInfo: ProviderServiceMethod<"getInstanceInfo"> = (instanceId) =>
11051125 registry.getInstanceInfo(instanceId);
11061126
1127 const assertConversationRollbackSupported: ProviderServiceMethod<"assertConversationRollbackSupported"> =
1128 Effect.fn("assertConversationRollbackSupported")(function* (threadId) {
1129 const routed = yield* resolveRoutableSession({
1130 threadId,
1131 operation: "ProviderService.assertConversationRollbackSupported",
1132 allowRecovery: false,
1133 });
1134 if (routed.adapter.capabilities.supportsConversationRollback === false) {
1135 return yield* toValidationError(
1136 "ProviderService.assertConversationRollbackSupported",
1137 `Provider '${routed.adapter.provider}' does not support conversation rewind.`,
1138 );
1139 }
1140 });
1141
11071142 const rollbackConversation: ProviderServiceMethod<"rollbackConversation"> = Effect.fn(
11081143 "rollbackConversation",
11091144 )(function* (rawInput) {
......@@ -1117,6 +1152,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
11171152 }
11181153 let metricProvider = "unknown";
11191154 return yield* Effect.gen(function* () {
1155 yield* assertConversationRollbackSupported(input.threadId);
11201156 const routed = yield* resolveRoutableSession({
11211157 threadId: input.threadId,
11221158 operation: "ProviderService.rollbackConversation",
......@@ -1256,6 +1292,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
12561292 listSessions,
12571293 getCapabilities,
12581294 getInstanceInfo,
1295 assertConversationRollbackSupported,
12591296 rollbackConversation,
12601297 uploadFeedback,
12611298 // Each access creates a fresh PubSub subscription so that multiple
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1
......@@ -170,6 +170,7 @@ describe("ProviderSessionReaper", () => {
170170 stopSession,
171171 listSessions: () => Effect.succeed([]),
172172 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
173 assertConversationRollbackSupported: () => unsupported(),
173174 getInstanceInfo: (instanceId) => {
174175 const driverKind = ProviderDriverKind.make(String(instanceId));
175176 return Effect.succeed({
apps/server/src/provider/ModelManifest.test.ts+31
......@@ -9,6 +9,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";
99import * as ServerConfig from "../config.ts";
1010import * as ServerSettings from "../serverSettings.ts";
1111import {
12 applyManifestDefault,
1213 BUNDLED_MODEL_MANIFEST,
1314 classifyModels,
1415 make,
......@@ -59,6 +60,36 @@ describe("classifyModels", () => {
5960 });
6061});
6162
63describe("applyManifestDefault", () => {
64 it("moves the default flag and its aliases to the manifest's chat default", () => {
65 const driver = ProviderDriverKind.make("antigravity");
66 const manifest: ModelManifestData = {
67 version: 1,
68 currentModels: {},
69 providers: {
70 antigravity: {
71 defaults: { chat: "gemini-new" },
72 profiles: {},
73 models: [{ slug: "gemini-new", name: "New", status: "current" }],
74 },
75 },
76 };
77 const models = [
78 model({ slug: "gemini-old", isDefault: true, aliases: ["antigravity-default"] }),
79 model({ slug: "gemini-new" }),
80 ];
81 assert.deepStrictEqual(applyManifestDefault(models, manifest, driver), [
82 model({ slug: "gemini-old" }),
83 model({ slug: "gemini-new", isDefault: true, aliases: ["antigravity-default"] }),
84 ]);
85 // The account does not offer the manifest default: keep the runtime's choice.
86 assert.deepStrictEqual(
87 applyManifestDefault(models.slice(0, 1), manifest, driver),
88 models.slice(0, 1),
89 );
90 });
91});
92
6293describe("resolveProviderCatalog", () => {
6394 it("resolves generic model presentation through a reusable profile", () => {
6495 const manifest: ModelManifestData = {
apps/server/src/provider/ModelManifest.ts+46-1
......@@ -216,7 +216,52 @@ export function applyModelManifest(
216216 manifest: ModelManifestData,
217217 driverKind: ProviderDriverKind,
218218): ServerProviderDraft {
219 return { ...draft, models: classifyModels(draft.models, manifest, driverKind) };
219 return {
220 ...draft,
221 models: applyManifestDefault(
222 classifyModels(draft.models, manifest, driverKind),
223 manifest,
224 driverKind,
225 ),
226 };
227}
228
229/** The manifest's chat default for `driverKind`, when it names one. */
230export function manifestDefaultModel(
231 manifest: ModelManifestData,
232 driverKind: ProviderDriverKind,
233): string | undefined {
234 return manifest.providers?.[driverKind]?.defaults?.chat;
235}
236
237/**
238 * Moves `isDefault` to the manifest's chat default when the catalog carries
239 * it. Providers that learn their default from the runtime (Antigravity takes
240 * Google's current model) can be overridden here without a release. Aliases
241 * that pointed at the old default move with the flag so the shared
242 * "provider default" alias keeps resolving.
243 */
244export function applyManifestDefault(
245 models: ReadonlyArray<ServerProviderModel>,
246 manifest: ModelManifestData,
247 driverKind: ProviderDriverKind,
248): ReadonlyArray<ServerProviderModel> {
249 const slug = manifestDefaultModel(manifest, driverKind);
250 if (slug === undefined || !models.some((model) => model.slug === slug)) return models;
251 const previous = models.find((model) => model.isDefault && model.slug !== slug);
252 if (!previous) return models;
253 const movedAliases = previous.aliases ?? [];
254 return models.map((model) => {
255 if (model.slug === previous.slug) {
256 const { isDefault: _isDefault, aliases: _aliases, ...rest } = model;
257 return rest;
258 }
259 if (model.slug === slug) {
260 const aliases = [...new Set([...(model.aliases ?? []), ...movedAliases])];
261 return { ...model, isDefault: true, ...(aliases.length > 0 ? { aliases } : {}) };
262 }
263 return model;
264 });
220265}
221266
222267/** Model-level half of `applyModelManifest`, exported for focused tests. */
apps/server/src/provider/ProviderDriver.ts+3
......@@ -35,6 +35,7 @@ import type * as TextGeneration from "../textGeneration/TextGeneration.ts";
3535import type { ProviderAdapterError, ProviderDriverError } from "./Errors.ts";
3636import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts";
3737import type { ServerProviderShape } from "./Services/ServerProvider.ts";
38import type { ProviderAuthController } from "./Services/ProviderAuthService.ts";
3839
3940/**
4041 * Static metadata advertised by a driver. Used for default presentation
......@@ -71,8 +72,10 @@ export interface ProviderInstance {
7172 readonly enabled: boolean;
7273 readonly snapshot: ServerProviderShape;
7374 readonly snapshotForCwd?: (cwd: string) => Effect.Effect<ServerProvider, ProviderDriverError>;
75 readonly refreshModels?: () => Effect.Effect<void, ProviderDriverError>;
7476 readonly adapter: ProviderAdapterShape<ProviderAdapterError>;
7577 readonly textGeneration: TextGeneration.TextGeneration["Service"];
78 readonly auth?: ProviderAuthController;
7679}
7780
7881export interface ProviderContinuationIdentity {
apps/server/src/provider/Services/ProviderAdapter.ts+2
......@@ -35,6 +35,8 @@ export interface ProviderAdapterCapabilities {
3535 /** Starts a resumed turn with no synthetic user prompt. Omitted means the
3636 adapter needs an explicit continuation instruction. */
3737 readonly promptlessTurnContinuation?: boolean;
38 /** False when native conversation history cannot be rewound. */
39 readonly supportsConversationRollback?: boolean;
3840}
3941
4042export interface ProviderThreadTurnSnapshot {
apps/server/src/provider/Services/ProviderAuthService.ts created+59
......@@ -0,0 +1,59 @@
1import type { ProviderAuthState, ProviderInstanceId, ProviderSetupError } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import type * as Effect from "effect/Effect";
4import type * as Stream from "effect/Stream";
5
6export interface ProviderAuthController {
7 readonly start: (
8 ownerSessionId: string,
9 stopSessions?: Effect.Effect<void, ProviderSetupError>,
10 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
11 readonly complete: (
12 ownerSessionId: string,
13 input: { readonly flowId: string; readonly callbackUrl: string },
14 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
15 readonly cancel: (
16 ownerSessionId: string,
17 flowId: string,
18 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
19 /** The controller closes process admission before it stops routed sessions. */
20 readonly logout: (
21 stopSessions: Effect.Effect<void, ProviderSetupError>,
22 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
23 readonly subscribe: (ownerSessionId: string) => Stream.Stream<ProviderAuthState>;
24 readonly isLogoutPrompt?: (text: string, hasAttachments: boolean) => boolean;
25}
26
27interface ProviderAuthTarget {
28 readonly instanceId: ProviderInstanceId;
29}
30
31export interface ProviderAuthServiceShape {
32 readonly start: (
33 input: ProviderAuthTarget,
34 ownerSessionId: string,
35 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
36 readonly complete: (
37 input: ProviderAuthTarget & { readonly flowId: string; readonly callbackUrl: string },
38 ownerSessionId: string,
39 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
40 readonly cancel: (
41 input: ProviderAuthTarget & { readonly flowId: string },
42 ownerSessionId: string,
43 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
44 readonly logout: (
45 input: ProviderAuthTarget,
46 ) => Effect.Effect<ProviderAuthState, ProviderSetupError>;
47 readonly subscribe: (
48 input: ProviderAuthTarget,
49 ownerSessionId: string,
50 ) => Stream.Stream<ProviderAuthState, ProviderSetupError>;
51 readonly tryHandlePromptCommand: (
52 input: ProviderAuthTarget & { readonly text: string; readonly hasAttachments: boolean },
53 ) => Effect.Effect<boolean, ProviderSetupError>;
54}
55
56export class ProviderAuthService extends Context.Service<
57 ProviderAuthService,
58 ProviderAuthServiceShape
59>()("t3/provider/Services/ProviderAuthService") {}
apps/server/src/provider/Services/ProviderService.ts+7
......@@ -99,6 +99,13 @@ export interface ProviderServiceShape {
9999 instanceId: ProviderInstanceId,
100100 ) => Effect.Effect<ProviderInstanceRoutingInfo, ProviderServiceError>;
101101
102 /**
103 * Reject unsupported rewind before files change, without resuming the session.
104 */
105 readonly assertConversationRollbackSupported: (
106 threadId: ThreadId,
107 ) => Effect.Effect<void, ProviderServiceError>;
108
102109 /**
103110 * Roll back provider conversation state by a number of turns.
104111 */
apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts+65-15
......@@ -27,27 +27,27 @@ describe("AcpCoreRuntimeEvents", () => {
2727 },
2828 };
2929
30 expect(
31 makeAcpRequestOpenedEvent({
32 stamp,
33 provider: ProviderDriverKind.make("cursor"),
34 threadId: "thread-1" as never,
35 turnId,
36 requestId: RuntimeRequestId.make("request-1"),
37 permissionRequest,
38 detail: "cat package.json",
39 args: { command: ["cat", "package.json"] },
40 source: "acp.jsonrpc",
41 method: "session/request_permission",
42 rawPayload: { sessionId: "session-1" },
43 }),
44 ).toMatchObject({
30 const openedEvent = makeAcpRequestOpenedEvent({
31 stamp,
32 provider: ProviderDriverKind.make("cursor"),
33 threadId: "thread-1" as never,
34 turnId,
35 requestId: RuntimeRequestId.make("request-1"),
36 permissionRequest,
37 detail: "cat package.json",
38 args: { command: ["cat", "package.json"] },
39 source: "acp.jsonrpc",
40 method: "session/request_permission",
41 rawPayload: { sessionId: "session-1" },
42 });
43 expect(openedEvent).toMatchObject({
4544 type: "request.opened",
4645 payload: {
4746 requestType: "exec_command_approval",
4847 detail: "cat package.json",
4948 },
5049 });
50 expect(openedEvent).not.toHaveProperty("payload.options");
5151
5252 expect(
5353 makeAcpRequestResolvedEvent({
......@@ -68,6 +68,36 @@ describe("AcpCoreRuntimeEvents", () => {
6868 });
6969 });
7070
71 it("preserves a native file approval without a remembered-allow choice", () => {
72 const event = makeAcpRequestOpenedEvent({
73 stamp: { eventId: "approval-1" as never, createdAt: "2026-09-02T00:00:00.000Z" },
74 provider: ProviderDriverKind.make("antigravity"),
75 threadId: "thread-1" as never,
76 turnId: TurnId.make("turn-1"),
77 requestId: RuntimeRequestId.make("request-1"),
78 permissionRequest: { kind: "edit" },
79 approvalOptions: [
80 { decision: "accept", label: "Allow once" },
81 { decision: "decline", label: "Reject" },
82 ],
83 detail: "Edit package.json",
84 args: {},
85 source: "acp.jsonrpc",
86 method: "session/request_permission",
87 rawPayload: { sessionId: "session-1" },
88 });
89
90 expect(event.payload).toEqual({
91 requestType: "file_change_approval",
92 detail: "Edit package.json",
93 args: {},
94 options: [
95 { decision: "accept", label: "Allow once" },
96 { decision: "decline", label: "Reject" },
97 ],
98 });
99 });
100
71101 it("maps generic ACP permission kinds to dynamic tool approvals", () => {
72102 const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" };
73103
......@@ -192,4 +222,24 @@ describe("AcpCoreRuntimeEvents", () => {
192222 },
193223 });
194224 });
225
226 it("maps thoughts to the reasoning stream", () => {
227 expect(
228 makeAcpContentDeltaEvent({
229 stamp: { eventId: "thought-1" as never, createdAt: "2026-09-02T00:00:00.000Z" },
230 provider: ProviderDriverKind.make("cursor"),
231 threadId: "thread-1" as never,
232 turnId: TurnId.make("turn-1"),
233 streamKind: "reasoning_text",
234 text: "Inspect the current implementation first.",
235 rawPayload: { sessionId: "session-1" },
236 }),
237 ).toMatchObject({
238 type: "content.delta",
239 payload: {
240 streamKind: "reasoning_text",
241 delta: "Inspect the current implementation first.",
242 },
243 });
244 });
195245});
apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts+5-1
......@@ -4,6 +4,7 @@ import {
44 type CanonicalRequestType,
55 type EventId,
66 type ProviderApprovalDecision,
7 type ProviderApprovalOption,
78 type ProviderDriverKind,
89 type ProviderRuntimeEvent,
910 type RuntimeRequestId,
......@@ -71,6 +72,7 @@ export function makeAcpRequestOpenedEvent(input: {
7172 readonly turnId: TurnId | undefined;
7273 readonly requestId: RuntimeRequestId;
7374 readonly permissionRequest: AcpPermissionRequest;
75 readonly approvalOptions?: ReadonlyArray<ProviderApprovalOption>;
7476 readonly detail: string;
7577 readonly args: unknown;
7678 readonly source: AcpAdapterRawSource;
......@@ -88,6 +90,7 @@ export function makeAcpRequestOpenedEvent(input: {
8890 requestType: canonicalRequestTypeFromAcpKind(input.permissionRequest.kind),
8991 detail: input.detail,
9092 args: input.args,
93 ...(input.approvalOptions !== undefined ? { options: input.approvalOptions } : {}),
9194 },
9295 raw: {
9396 source: input.source,
......@@ -207,6 +210,7 @@ export function makeAcpContentDeltaEvent(input: {
207210 readonly threadId: ThreadId;
208211 readonly turnId: TurnId | undefined;
209212 readonly itemId?: string;
213 readonly streamKind?: "assistant_text" | "reasoning_text";
210214 readonly text: string;
211215 readonly rawPayload: unknown;
212216}): ProviderRuntimeEvent {
......@@ -218,7 +222,7 @@ export function makeAcpContentDeltaEvent(input: {
218222 turnId: input.turnId,
219223 ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}),
220224 payload: {
221 streamKind: "assistant_text",
225 streamKind: input.streamKind ?? "assistant_text",
222226 delta: input.text,
223227 },
224228 raw: {
apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts+432
......@@ -6,9 +6,12 @@ import * as NodeFS from "node:fs";
66
77import * as NodeServices from "@effect/platform-node/NodeServices";
88import { it } from "@effect/vitest";
9import * as Deferred from "effect/Deferred";
910import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";
1012import * as Fiber from "effect/Fiber";
1113import * as Option from "effect/Option";
14import * as Scope from "effect/Scope";
1215import * as TestClock from "effect/testing/TestClock";
1316import * as Stream from "effect/Stream";
1417import { describe, expect } from "vite-plus/test";
......@@ -20,8 +23,437 @@ const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
2023const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");
2124const mockAgentCommand = "node";
2225const mockAgentArgs = [mockAgentPath];
26const mockRuntimeOptions = {
27 spawn: { command: mockAgentCommand, args: mockAgentArgs },
28 cwd: process.cwd(),
29 clientInfo: { name: "t3-test", version: "0.0.0" },
30 authMethodId: "test",
31} satisfies AcpSessionRuntime.AcpSessionRuntimeOptions;
2332
2433describe("AcpSessionRuntime", () => {
34 for (const setupMethod of ["session/new", "session/resume"] as const) {
35 it.effect(`buffers root metadata while ${setupMethod} startup is still pending`, () =>
36 Effect.gen(function* () {
37 const setupReplied = yield* Deferred.make<void>();
38 const allowStartup = yield* Deferred.make<void>();
39 const events: Array<AcpSessionRuntime.AcpSessionRuntimeEvent> = [];
40 const runtime = yield* AcpSessionRuntime.make({
41 ...mockRuntimeOptions,
42 ...(setupMethod === "session/resume"
43 ? { resumeSessionId: "mock-session-1", resumeMethod: "resume" as const }
44 : {}),
45 requestLogger: (event) =>
46 event.method === setupMethod && event.status === "succeeded"
47 ? Deferred.succeed(setupReplied, undefined).pipe(
48 Effect.andThen(Deferred.await(allowStartup)),
49 )
50 : Effect.void,
51 });
52 yield* runtime.getEvents().pipe(
53 Stream.runForEach((event) => {
54 if (event._tag === "EventStreamBarrier") {
55 return Deferred.succeed(event.acknowledge, undefined);
56 }
57 events.push(event);
58 return Effect.void;
59 }),
60 Effect.forkChild,
61 );
62 const startup = yield* runtime.start().pipe(Effect.forkChild);
63 yield* Deferred.await(setupReplied);
64 yield* runtime.request("_test/startup-metadata", {});
65 yield* Deferred.succeed(allowStartup, undefined);
66 yield* Fiber.join(startup);
67 yield* runtime.drainEvents;
68
69 expect(events.map((event) => event._tag)).toEqual([
70 "AvailableCommandsUpdated",
71 "ModeChanged",
72 ]);
73 expect(events[0]).toMatchObject({
74 availableCommands: [{ name: "plan", description: "Native command" }],
75 });
76 expect(yield* runtime.getModeState).toMatchObject({ currentModeId: "code" });
77 expect(
78 (yield* runtime.getConfigOptions).find((option) => option.category === "model"),
79 ).toMatchObject({ currentValue: "gpt-5.4" });
80 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
81 );
82 }
83
84 it.effect("awaits native resume instead of using the load replay idle fallback", () =>
85 Effect.gen(function* () {
86 const resumeStarted = yield* Deferred.make<void>();
87 const requestMethods: Array<string> = [];
88 const runtime = yield* AcpSessionRuntime.make({
89 ...mockRuntimeOptions,
90 spawn: {
91 ...mockRuntimeOptions.spawn,
92 env: { T3_ACP_WAIT_FOR_RESUME_RELEASE: "1" },
93 },
94 resumeSessionId: "mock-session-1",
95 resumeMethod: "resume",
96 sessionLoadReplayIdleGap: "1 second",
97 requestLogger: (event) =>
98 Effect.sync(() => {
99 if (event.status === "started") requestMethods.push(event.method);
100 }),
101 });
102 yield* runtime.handleSessionUpdate((notification) =>
103 notification.update.sessionUpdate === "user_message_chunk"
104 ? Deferred.succeed(resumeStarted, undefined).pipe(Effect.asVoid)
105 : Effect.void,
106 );
107 const startup = yield* runtime.start().pipe(Effect.forkChild);
108 yield* Deferred.await(resumeStarted);
109 yield* TestClock.adjust("3 seconds");
110 expect(startup.pollUnsafe()).toBeUndefined();
111 yield* runtime.request("_test/release-resume", {});
112 const started = yield* Fiber.join(startup);
113
114 expect(started.sessionSetupResult._meta).toEqual({ nativeResume: true });
115 expect(requestMethods).toContain("session/resume");
116 expect(requestMethods).not.toContain("session/load");
117 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
118 );
119
120 it.effect("waits for native cancellation and drains final updates before another prompt", () =>
121 Effect.gen(function* () {
122 const toolStarted = yield* Deferred.make<void>();
123 const cancelReceived = yield* Deferred.make<void>();
124 const events: Array<AcpSessionRuntime.AcpSessionRuntimeEvent> = [];
125 let promptRequests = 0;
126 const runtime = yield* AcpSessionRuntime.make({
127 ...mockRuntimeOptions,
128 spawn: {
129 ...mockRuntimeOptions.spawn,
130 env: { T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL: "1" },
131 },
132 cancelBehavior: "wait-for-prompt",
133 requestLogger: (event) =>
134 Effect.sync(() => {
135 if (event.method === "session/prompt" && event.status === "started")
136 promptRequests += 1;
137 }),
138 });
139 yield* runtime.getEvents().pipe(
140 Stream.runForEach((event) => {
141 if (event._tag === "EventStreamBarrier") {
142 return Deferred.succeed(event.acknowledge, undefined);
143 }
144 events.push(event);
145 if (event._tag === "ToolCallUpdated" && event.toolCall.status === "inProgress") {
146 return Deferred.succeed(toolStarted, undefined);
147 }
148 if (event._tag === "ThoughtDelta" && event.text === "native-cancel-received") {
149 return Deferred.succeed(cancelReceived, undefined);
150 }
151 return Effect.void;
152 }),
153 Effect.forkChild,
154 );
155 yield* runtime.start();
156 const prompt = yield* runtime
157 .prompt({
158 prompt: [{ type: "text", text: "first" }],
159 })
160 .pipe(Effect.forkChild);
161 yield* Deferred.await(toolStarted);
162 const cancellation = yield* runtime.cancel.pipe(Effect.forkChild);
163 yield* Deferred.await(cancelReceived);
164 const replacement = yield* runtime
165 .prompt({
166 prompt: [{ type: "text", text: "second" }],
167 })
168 .pipe(Effect.forkChild({ startImmediately: true }));
169
170 expect(prompt.pollUnsafe()).toBeUndefined();
171 expect(cancellation.pollUnsafe()).toBeUndefined();
172 expect(promptRequests).toBe(1);
173 yield* runtime.request("_test/finish-cancel", {});
174 yield* Fiber.join(cancellation);
175
176 expect(yield* Fiber.join(prompt)).toEqual({
177 stopReason: "cancelled",
178 _meta: { nativeCancel: true },
179 });
180 expect(
181 events.some(
182 (event) =>
183 event._tag === "ToolCallUpdated" &&
184 event.toolCall.status === "failed" &&
185 event.toolCall.detail === "Cancelled.",
186 ),
187 ).toBe(true);
188 const cancelledDelta = events.find(
189 (event) => event._tag === "ContentDelta" && event.text === "Request cancelled.",
190 );
191 expect(cancelledDelta?._tag).toBe("ContentDelta");
192 if (cancelledDelta?._tag === "ContentDelta") {
193 expect(
194 events.filter(
195 (event) =>
196 event._tag === "AssistantItemCompleted" && event.itemId === cancelledDelta.itemId,
197 ),
198 ).toHaveLength(1);
199 }
200 expect(yield* Fiber.join(replacement)).toMatchObject({ stopReason: "end_turn" });
201 expect(promptRequests).toBe(2);
202 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
203 );
204
205 it.effect("retires a process when native cancellation times out", () =>
206 Effect.gen(function* () {
207 const toolStarted = yield* Deferred.make<void>();
208 const cancelReceived = yield* Deferred.make<void>();
209 const runtime = yield* AcpSessionRuntime.make({
210 ...mockRuntimeOptions,
211 spawn: {
212 ...mockRuntimeOptions.spawn,
213 env: { T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL: "1" },
214 },
215 cancelBehavior: "wait-for-prompt",
216 cancelTimeout: "1 second",
217 });
218 yield* runtime.getEvents().pipe(
219 Stream.runForEach((event) => {
220 if (event._tag === "EventStreamBarrier") {
221 return Deferred.succeed(event.acknowledge, undefined);
222 }
223 if (event._tag === "ToolCallUpdated") {
224 return Deferred.succeed(toolStarted, undefined);
225 }
226 if (event._tag === "ThoughtDelta") {
227 return Deferred.succeed(cancelReceived, undefined);
228 }
229 return Effect.void;
230 }),
231 Effect.forkChild,
232 );
233 yield* runtime.start();
234 const prompt = yield* runtime
235 .prompt({
236 prompt: [{ type: "text", text: "first" }],
237 })
238 .pipe(Effect.forkChild);
239 yield* Deferred.await(toolStarted);
240 const cancellation = yield* runtime.cancel.pipe(Effect.forkChild);
241 yield* Deferred.await(cancelReceived);
242 yield* TestClock.adjust("2 seconds");
243
244 const error = yield* Fiber.join(cancellation).pipe(Effect.flip);
245 expect(error).toMatchObject({
246 _tag: "AcpTransportError",
247 method: "session/cancel",
248 });
249 expect(Exit.isFailure(yield* Fiber.await(prompt))).toBe(true);
250 expect(
251 yield* runtime
252 .prompt({
253 prompt: [{ type: "text", text: "must not run" }],
254 })
255 .pipe(Effect.flip),
256 ).toBe(error);
257 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
258 );
259
260 it.effect("reports an idle child exit and rejects later prompts", () =>
261 Effect.gen(function* () {
262 const runtime = yield* AcpSessionRuntime.make(mockRuntimeOptions);
263 yield* runtime.start();
264 yield* runtime.notify("_test/exit", {});
265 const events = yield* runtime.getEvents().pipe(Stream.take(1), Stream.runCollect);
266 const event = events[0];
267 expect(event).toMatchObject({ _tag: "ConnectionTerminated", error: { code: 19 } });
268 if (event?._tag !== "ConnectionTerminated") return;
269 expect(
270 yield* runtime
271 .prompt({
272 prompt: [{ type: "text", text: "must not run" }],
273 })
274 .pipe(Effect.flip),
275 ).toBe(event.error);
276 expect(yield* runtime.start().pipe(Effect.flip)).toBe(event.error);
277 expect(yield* runtime.initialize().pipe(Effect.flip)).toBe(event.error);
278 expect(
279 yield* runtime.request("_test/environment", {}).pipe(
280 Effect.match({
281 onFailure: (error) => error,
282 onSuccess: () => undefined,
283 }),
284 ),
285 ).toBe(event.error);
286 expect(yield* runtime.notify("_test/exit", {}).pipe(Effect.flip)).toBe(event.error);
287 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
288 );
289
290 it.effect("retires a native runtime when its prompt caller is interrupted", () =>
291 Effect.gen(function* () {
292 const dispatched = yield* Deferred.make<void>();
293 const runtime = yield* AcpSessionRuntime.make({
294 ...mockRuntimeOptions,
295 spawn: {
296 ...mockRuntimeOptions.spawn,
297 env: { T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL: "1" },
298 },
299 cancelBehavior: "wait-for-prompt",
300 });
301 yield* runtime.start();
302 const prompt = yield* runtime
303 .prompt(
304 {
305 prompt: [{ type: "text", text: "first" }],
306 },
307 { dispatched },
308 )
309 .pipe(Effect.forkChild);
310 yield* Deferred.await(dispatched);
311 yield* Fiber.interrupt(prompt);
312 const events = yield* runtime.getEvents().pipe(
313 Stream.filter((event) => event._tag === "ConnectionTerminated"),
314 Stream.take(1),
315 Stream.runCollect,
316 );
317 expect(events[0]).toMatchObject({
318 error: { _tag: "AcpTransportError", method: "session/prompt" },
319 });
320 expect(
321 yield* runtime
322 .prompt({
323 prompt: [{ type: "text", text: "must not run" }],
324 })
325 .pipe(Effect.flip),
326 ).toBe(events[0]?.error);
327 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
328 );
329
330 it.effect("drains large stderr output and bounds optional logging chunks", () =>
331 Effect.gen(function* () {
332 const lengths: Array<number> = [];
333 for (const logStderr of [false, true]) {
334 yield* Effect.gen(function* () {
335 const runtime = yield* AcpSessionRuntime.make({
336 ...mockRuntimeOptions,
337 spawn: { ...mockRuntimeOptions.spawn, env: { T3_ACP_FLOOD_STDERR: "1" } },
338 ...(logStderr
339 ? {
340 onStderr: (text: string) =>
341 Effect.sync(() => {
342 lengths.push(text.length);
343 }),
344 }
345 : {}),
346 });
347 expect(yield* runtime.initialize()).toMatchObject({ protocolVersion: 1 });
348 }).pipe(Effect.scoped);
349 }
350 expect(lengths.length).toBeGreaterThan(0);
351 expect(Math.max(...lengths)).toBeLessThanOrEqual(8_192);
352 }).pipe(Effect.provide(NodeServices.layer)),
353 );
354
355 it.effect("releases a queued event drain when its runtime scope closes", () =>
356 Effect.gen(function* () {
357 const scope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
358 Scope.close(scope, Exit.void),
359 );
360 const barrierReceived = yield* Deferred.make<void>();
361 const runtime = yield* AcpSessionRuntime.make(mockRuntimeOptions).pipe(
362 Effect.provideService(Scope.Scope, scope),
363 );
364 yield* runtime.start();
365 yield* runtime.getEvents().pipe(
366 Stream.runForEach((event) =>
367 event._tag === "EventStreamBarrier"
368 ? Deferred.succeed(barrierReceived, undefined).pipe(Effect.andThen(Effect.never))
369 : Effect.void,
370 ),
371 Effect.forkIn(scope),
372 );
373 const drain = yield* runtime.drainEvents.pipe(Effect.forkChild);
374 yield* Deferred.await(barrierReceived);
375 yield* Scope.close(scope, Exit.void);
376 yield* Fiber.join(drain);
377 yield* runtime.drainEvents;
378 expect(yield* runtime.initialize().pipe(Effect.flip)).toMatchObject({
379 _tag: "AcpTransportError",
380 detail: "The ACP session runtime is closed.",
381 });
382 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
383 );
384
385 it.effect("bounds native cancellation when its event consumer is absent", () =>
386 Effect.gen(function* () {
387 const toolStarted = yield* Deferred.make<void>();
388 const cancelReceived = yield* Deferred.make<void>();
389 const runtime = yield* AcpSessionRuntime.make({
390 ...mockRuntimeOptions,
391 spawn: {
392 ...mockRuntimeOptions.spawn,
393 env: { T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL: "1" },
394 },
395 cancelBehavior: "wait-for-prompt",
396 cancelTimeout: "1 second",
397 });
398 yield* runtime.handleSessionUpdate((notification) => {
399 if (notification.update.sessionUpdate === "tool_call") {
400 return Deferred.succeed(toolStarted, undefined).pipe(Effect.asVoid);
401 }
402 if (notification.update.sessionUpdate === "agent_thought_chunk") {
403 return Deferred.succeed(cancelReceived, undefined).pipe(Effect.asVoid);
404 }
405 return Effect.void;
406 });
407 yield* runtime.start();
408 const prompt = yield* runtime
409 .prompt({
410 prompt: [{ type: "text", text: "first" }],
411 })
412 .pipe(Effect.forkChild);
413 yield* Deferred.await(toolStarted);
414 const cancellation = yield* runtime.cancel.pipe(Effect.forkChild);
415 yield* Deferred.await(cancelReceived);
416 yield* runtime.request("_test/finish-cancel", {});
417 expect(yield* Fiber.join(prompt)).toMatchObject({ stopReason: "cancelled" });
418 yield* TestClock.adjust("2 seconds");
419 expect(yield* Fiber.join(cancellation).pipe(Effect.flip)).toMatchObject({
420 _tag: "AcpTransportError",
421 method: "session/cancel",
422 });
423 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
424 );
425
426 it.effect("does not restore ambient variables to a sanitized child environment", () =>
427 Effect.gen(function* () {
428 yield* Effect.acquireRelease(
429 Effect.sync(() => {
430 const previous = process.env.T3_ACP_RUNTIME_AMBIENT;
431 process.env.T3_ACP_RUNTIME_AMBIENT = "sentinel";
432 return previous;
433 }),
434 (previous) =>
435 Effect.sync(() => {
436 if (previous === undefined) delete process.env.T3_ACP_RUNTIME_AMBIENT;
437 else process.env.T3_ACP_RUNTIME_AMBIENT = previous;
438 }),
439 );
440 const runtime = yield* AcpSessionRuntime.make({
441 ...mockRuntimeOptions,
442 spawn: {
443 command: process.execPath,
444 args: mockAgentArgs,
445 extendEnv: false,
446 env: { T3_ACP_RUNTIME_EXPLICIT: "kept" },
447 },
448 });
449 yield* runtime.initialize();
450 expect(yield* runtime.request("_test/environment", {})).toEqual({
451 inherited: false,
452 explicit: true,
453 });
454 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
455 );
456
25457 it.effect("merges custom initialize client capabilities into the ACP handshake", () => {
26458 const requestEvents: Array<AcpSessionRuntime.AcpSessionRequestLogEvent> = [];
27459 return Effect.gen(function* () {
apps/server/src/provider/acp/AcpRuntimeModel.test.ts+39
......@@ -339,6 +339,45 @@ describe("AcpRuntimeModel", () => {
339339 ]);
340340 });
341341
342 it("keeps thought chunks separate from assistant text", () => {
343 const notification = {
344 sessionId: "session-1",
345 update: {
346 sessionUpdate: "agent_thought_chunk",
347 content: { type: "text", text: "Inspect the current implementation first." },
348 },
349 } satisfies EffectAcpSchema.SessionNotification;
350
351 expect(parseSessionUpdateEvent(notification).events).toEqual([
352 {
353 _tag: "ThoughtDelta",
354 text: "Inspect the current implementation first.",
355 rawPayload: notification,
356 },
357 ]);
358 });
359
360 it("preserves native command inputs and empty command lists", () => {
361 const availableCommands = [
362 { name: "plan", description: "Plan a task", input: { hint: "task" } },
363 { name: "logout", description: "Sign out" },
364 ] satisfies ReadonlyArray<EffectAcpSchema.AvailableCommand>;
365
366 for (const commands of [availableCommands, []]) {
367 const notification = {
368 sessionId: "session-1",
369 update: { sessionUpdate: "available_commands_update", availableCommands: commands },
370 } satisfies EffectAcpSchema.SessionNotification;
371 expect(parseSessionUpdateEvent(notification).events).toEqual([
372 {
373 _tag: "AvailableCommandsUpdated",
374 availableCommands: commands,
375 rawPayload: notification,
376 },
377 ]);
378 }
379 });
380
342381 it("keeps permission request parsing compatible with loose extension payloads", () => {
343382 const request = parsePermissionRequest({
344383 sessionId: "session-1",
apps/server/src/provider/acp/AcpRuntimeModel.ts+28
......@@ -85,6 +85,11 @@ export type AcpParsedSessionEvent =
8585 readonly _tag: "ModeChanged";
8686 readonly modeId: string;
8787 }
88 | {
89 readonly _tag: "AvailableCommandsUpdated";
90 readonly availableCommands: ReadonlyArray<EffectAcpSchema.AvailableCommand>;
91 readonly rawPayload: unknown;
92 }
8893 | {
8994 readonly _tag: "AssistantItemStarted";
9095 readonly itemId: string;
......@@ -108,6 +113,11 @@ export type AcpParsedSessionEvent =
108113 readonly itemId?: string;
109114 readonly text: string;
110115 readonly rawPayload: unknown;
116 }
117 | {
118 readonly _tag: "ThoughtDelta";
119 readonly text: string;
120 readonly rawPayload: unknown;
111121 };
112122
113123type AcpSessionSetupResponse =
......@@ -778,6 +788,14 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat
778788 let modeId: string | undefined;
779789
780790 switch (upd.sessionUpdate) {
791 case "available_commands_update": {
792 events.push({
793 _tag: "AvailableCommandsUpdated",
794 availableCommands: upd.availableCommands,
795 rawPayload: params,
796 });
797 break;
798 }
781799 case "current_mode_update": {
782800 modeId = upd.currentModeId.trim();
783801 if (modeId) {
......@@ -838,6 +856,16 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat
838856 }
839857 break;
840858 }
859 case "agent_thought_chunk": {
860 if (upd.content.type === "text" && upd.content.text.length > 0) {
861 events.push({
862 _tag: "ThoughtDelta",
863 text: upd.content.text,
864 rawPayload: params,
865 });
866 }
867 break;
868 }
841869 default:
842870 break;
843871 }
apps/server/src/provider/acp/AcpSessionRuntime.ts+355-114
......@@ -5,6 +5,7 @@ import * as Crypto from "effect/Crypto";
55import * as Deferred from "effect/Deferred";
66import * as Duration from "effect/Duration";
77import * as Effect from "effect/Effect";
8import * as Exit from "effect/Exit";
89import * as Fiber from "effect/Fiber";
910import * as Layer from "effect/Layer";
1011import * as Option from "effect/Option";
......@@ -53,24 +54,38 @@ export interface AcpSessionEventStreamBarrier {
5354 readonly acknowledge: Deferred.Deferred<void>;
5455}
5556
56export type AcpSessionRuntimeEvent = AcpParsedSessionEvent | AcpSessionEventStreamBarrier;
57export type AcpSessionRuntimeEvent =
58 | AcpParsedSessionEvent
59 | AcpSessionEventStreamBarrier
60 | {
61 readonly _tag: "ConnectionTerminated";
62 readonly error: EffectAcpErrors.AcpError;
63 };
5764
5865const defaultSessionLoadTimeout = Duration.seconds(90);
5966const defaultSessionLoadReplayIdleGap = Duration.seconds(2);
67const defaultCancelTimeout = Duration.seconds(15);
68const maxStartupMetadataUpdates = 32;
69const maxStderrChunkLength = 8_192;
6070
6171export interface AcpSpawnInput {
6272 readonly command: string;
6373 readonly args: ReadonlyArray<string>;
6474 readonly cwd?: string;
6575 readonly env?: NodeJS.ProcessEnv;
76 readonly extendEnv?: boolean;
6677}
6778
6879export interface AcpSessionRuntimeOptions {
6980 readonly spawn: AcpSpawnInput;
7081 readonly cwd: string;
7182 readonly resumeSessionId?: string;
83 readonly resumeMethod?: "load" | "resume";
7284 readonly sessionLoadTimeout?: Duration.Input;
7385 readonly sessionLoadReplayIdleGap?: Duration.Input;
86 /** Native cancellation waits for the prompt response and the getEvents consumer to drain. */
87 readonly cancelBehavior?: "interrupt" | "wait-for-prompt";
88 readonly cancelTimeout?: Duration.Input;
7489 readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"];
7590 readonly clientInfo: {
7691 readonly name: string;
......@@ -78,6 +93,16 @@ export interface AcpSessionRuntimeOptions {
7893 };
7994 readonly authMethodId: string;
8095 readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>;
96 /** Extra workspace roots the agent may read and write besides `cwd`. */
97 readonly additionalDirectories?: ReadonlyArray<string>;
98 /** Transforms provider stdout before protocol parsing and protocol logging. */
99 readonly transformStdout?: EffectAcpClient.AcpClientOptions["transformStdout"];
100 /** Normalizes provider-specific fields before notification queues or runtime state retain them. */
101 readonly transformSessionUpdate?: (
102 notification: EffectAcpSchema.SessionNotification,
103 ) => EffectAcpSchema.SessionNotification;
104 /** Receives bounded stderr chunks. The provider must redact any secrets before logging. */
105 readonly onStderr?: (text: string) => Effect.Effect<void, never>;
81106 readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>;
82107 readonly protocolLogging?: {
83108 readonly logIncoming?: boolean;
......@@ -197,9 +222,9 @@ export class AcpSessionRuntime extends Context.Service<
197222 * Concurrent calls share the same in-flight startup and a failed startup may be retried.
198223 */
199224 readonly start: () => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>;
200 /** Stream of parsed ACP session events emitted after startup. */
225 /** Stream of parsed root-session events and connection failures. */
201226 readonly getEvents: () => Stream.Stream<AcpSessionRuntimeEvent, never>;
202 /** Waits until the current event consumer has processed every queued event. */
227 /** Waits for queued events to be processed, or for the runtime scope to close. */
203228 readonly drainEvents: Effect.Effect<void>;
204229 /** Latest mode state observed from session setup and `session/update` notifications. */
205230 readonly getModeState: Effect.Effect<AcpSessionModeState | undefined>;
......@@ -288,6 +313,11 @@ interface EnsureActiveAssistantSegmentResult {
288313 readonly startedEvent?: Extract<AcpParsedSessionEvent, { readonly _tag: "AssistantItemStarted" }>;
289314}
290315
316interface AcpActivePrompt {
317 readonly fiber: Fiber.Fiber<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>;
318 readonly completed: Deferred.Deferred<void>;
319}
320
291321export const make = (
292322 options: AcpSessionRuntimeOptions,
293323): Effect.Effect<
......@@ -314,12 +344,51 @@ export const make = (
314344 const assistantSegmentRef = yield* Ref.make<AcpAssistantSegmentState>({ nextSegmentIndex: 0 });
315345 const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined));
316346 const startStateRef = yield* Ref.make<AcpStartState>({ _tag: "NotStarted" });
347 const startupMetadataRef = yield* Ref.make<ReadonlyArray<EffectAcpSchema.SessionNotification>>(
348 [],
349 );
350 const notificationSemaphore = yield* Semaphore.make(1);
351 const terminationErrorRef = yield* Ref.make<Option.Option<EffectAcpErrors.AcpError>>(
352 Option.none(),
353 );
354 const stoppingRef = yield* Ref.make(false);
355 const runtimeClosed = yield* Deferred.make<void>();
317356 const promptSerializationSemaphore = yield* Semaphore.make(1);
318 const activePromptFiberRef = yield* Ref.make<
319 Option.Option<Fiber.Fiber<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>>
320 >(Option.none());
357 const promptDispatchSemaphore = yield* Semaphore.make(1);
358 const activePromptRef = yield* Ref.make<Option.Option<AcpActivePrompt>>(Option.none());
321359 const sessionLoadGateRef = yield* Ref.make<Option.Option<SessionLoadGate>>(Option.none());
322360
361 const ensureConnected = Effect.gen(function* () {
362 const error = yield* Ref.get(terminationErrorRef);
363 if (Option.isSome(error)) {
364 return yield* error.value;
365 }
366 if (yield* Ref.get(stoppingRef)) {
367 return yield* new EffectAcpErrors.AcpTransportError({
368 detail: "The ACP session runtime is closed.",
369 cause: undefined,
370 });
371 }
372 });
373
374 const recordTermination = Effect.fn("AcpSessionRuntime.recordTermination")(function* (
375 error: EffectAcpErrors.AcpError,
376 ) {
377 if (yield* Ref.get(stoppingRef)) {
378 return;
379 }
380 const firstTermination = yield* Ref.modify(terminationErrorRef, (current) =>
381 Option.isSome(current)
382 ? ([false, current] as const)
383 : ([true, Option.some(error)] as const),
384 );
385 if (!firstTermination) {
386 return;
387 }
388 yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef });
389 yield* Queue.offer(eventQueue, { _tag: "ConnectionTerminated", error });
390 });
391
323392 const logRequest = (event: AcpSessionRequestLogEvent) =>
324393 options.requestLogger ? options.requestLogger(event) : Effect.void;
325394
......@@ -351,16 +420,16 @@ export const make = (
351420 ),
352421 );
353422
354 const spawnCommand = yield* resolveSpawnCommand(
355 options.spawn.command,
356 options.spawn.args,
357 options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {},
358 );
423 const spawnCommand = yield* resolveSpawnCommand(options.spawn.command, options.spawn.args, {
424 ...(options.spawn.env ? { env: options.spawn.env } : {}),
425 extendEnv: options.spawn.extendEnv ?? true,
426 });
359427 const child = yield* spawner
360428 .spawn(
361429 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
362430 ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}),
363 ...(options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}),
431 ...(options.spawn.env ? { env: options.spawn.env } : {}),
432 extendEnv: options.spawn.extendEnv ?? true,
364433 shell: spawnCommand.shell,
365434 }),
366435 )
......@@ -375,8 +444,22 @@ export const make = (
375444 ),
376445 );
377446
447 yield* child.stderr.pipe(
448 Stream.decodeText(),
449 Stream.runForEach((chunk) =>
450 options.onStderr ? options.onStderr(chunk.slice(-maxStderrChunkLength)) : Effect.void,
451 ),
452 Effect.ignore,
453 Effect.forkIn(runtimeScope),
454 );
455
378456 const acpContext = yield* Layer.build(
379457 EffectAcpClient.layerChildProcess(child, {
458 ...(options.transformStdout ? { transformStdout: options.transformStdout } : {}),
459 ...(options.transformSessionUpdate
460 ? { transformSessionUpdate: options.transformSessionUpdate }
461 : {}),
462 onTermination: recordTermination,
380463 ...(options.protocolLogging?.logIncoming !== undefined
381464 ? { logIncoming: options.protocolLogging.logIncoming }
382465 : {}),
......@@ -389,41 +472,72 @@ export const make = (
389472
390473 const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext));
391474
475 const processSessionUpdate = (notification: EffectAcpSchema.SessionNotification) =>
476 handleSessionUpdate({
477 queue: eventQueue,
478 modeStateRef,
479 configOptionsRef,
480 toolCallsRef,
481 assistantSegmentRef,
482 assistantItemRuntimeId,
483 params: notification,
484 });
485
392486 yield* acp.handleSessionUpdate((notification) =>
393 Effect.gen(function* () {
394 const gate = yield* Ref.get(sessionLoadGateRef);
395 if (Option.isSome(gate) && gate.value.active) {
396 const lastActivityAtMillis = yield* Clock.currentTimeMillis;
397 yield* Ref.set(
398 sessionLoadGateRef,
399 Option.some({
400 ...gate.value,
401 lastActivityAtMillis,
402 }),
403 );
404 return;
405 }
406 if (sessionUpdateIsReplay(notification)) {
407 return;
408 }
409 const startState = yield* Ref.get(startStateRef);
410 // One runtime projects one root ACP session. Child-session updates need
411 // explicit lineage routing and must never be flattened into this stream.
412 if (
413 startState._tag !== "Started" ||
414 notification.sessionId !== startState.result.sessionId
415 ) {
416 return;
417 }
418 yield* handleSessionUpdate({
419 queue: eventQueue,
420 modeStateRef,
421 toolCallsRef,
422 assistantSegmentRef,
423 assistantItemRuntimeId,
424 params: notification,
425 });
426 }),
487 notificationSemaphore.withPermit(
488 Effect.gen(function* () {
489 if (Option.isSome(yield* Ref.get(terminationErrorRef))) {
490 return;
491 }
492 const gate = yield* Ref.get(sessionLoadGateRef);
493 if (
494 Option.isSome(gate) &&
495 gate.value.active &&
496 notification.sessionId === options.resumeSessionId
497 ) {
498 const lastActivityAtMillis = yield* Clock.currentTimeMillis;
499 yield* Ref.set(
500 sessionLoadGateRef,
501 Option.some({
502 ...gate.value,
503 lastActivityAtMillis,
504 }),
505 );
506 }
507 if (sessionUpdateIsReplay(notification)) {
508 return;
509 }
510 const startState = yield* Ref.get(startStateRef);
511 if (startState._tag === "Starting") {
512 if (isStartupMetadataUpdate(notification)) {
513 yield* Ref.update(startupMetadataRef, (current) =>
514 [
515 ...current.filter(
516 (previous) =>
517 previous.sessionId !== notification.sessionId ||
518 previous.update.sessionUpdate !== notification.update.sessionUpdate,
519 ),
520 notification,
521 ].slice(-maxStartupMetadataUpdates),
522 );
523 }
524 return;
525 }
526 // One runtime projects one root ACP session. Child-session updates need
527 // explicit lineage routing and must never be flattened into this stream.
528 if (
529 startState._tag !== "Started" ||
530 notification.sessionId !== startState.result.sessionId
531 ) {
532 return;
533 }
534 yield* processSessionUpdate(notification);
535 }),
536 ),
537 );
538 yield* Scope.addFinalizer(
539 runtimeScope,
540 Ref.set(stoppingRef, true).pipe(Effect.andThen(Deferred.succeed(runtimeClosed, undefined))),
427541 );
428542 const initializeClientCapabilities = {
429543 fs: {
......@@ -440,6 +554,7 @@ export const make = (
440554 } satisfies NonNullable<EffectAcpSchema.InitializeRequest["clientCapabilities"]>;
441555
442556 const getStartedState = Effect.gen(function* () {
557 yield* ensureConnected;
443558 const state = yield* Ref.get(startStateRef);
444559 if (state._tag === "Started") {
445560 return state.result;
......@@ -579,7 +694,43 @@ export const make = (
579694 | EffectAcpSchema.LoadSessionResponse
580695 | EffectAcpSchema.NewSessionResponse
581696 | EffectAcpSchema.ResumeSessionResponse;
582 if (options.resumeSessionId) {
697 if (options.resumeSessionId && options.resumeMethod === "resume") {
698 if (!initializeResult.agentCapabilities?.sessionCapabilities?.resume) {
699 return yield* new EffectAcpErrors.AcpTransportError({
700 method: "session/resume",
701 detail: "The ACP agent does not support session/resume.",
702 cause: undefined,
703 });
704 }
705 const resumePayload = {
706 sessionId: options.resumeSessionId,
707 cwd: options.cwd,
708 mcpServers: options.mcpServers ?? [],
709 ...(options.additionalDirectories && options.additionalDirectories.length > 0
710 ? { additionalDirectories: options.additionalDirectories }
711 : {}),
712 } satisfies EffectAcpSchema.ResumeSessionRequest;
713 sessionId = options.resumeSessionId;
714 sessionSetupResult = yield* runLoggedRequest(
715 "session/resume",
716 resumePayload,
717 acp.agent.resumeSession(resumePayload).pipe(
718 Effect.timeoutOption(options.sessionLoadTimeout ?? defaultSessionLoadTimeout),
719 Effect.flatMap((result) =>
720 Option.isSome(result)
721 ? Effect.succeed(result.value)
722 : Effect.fail(
723 new EffectAcpErrors.AcpTransportError({
724 operation: "call-rpc",
725 method: "session/resume",
726 detail: "session/resume timed out waiting for the agent response.",
727 cause: undefined,
728 }),
729 ),
730 ),
731 ),
732 );
733 } else if (options.resumeSessionId) {
583734 const loadPayload = {
584735 sessionId: options.resumeSessionId,
585736 cwd: options.cwd,
......@@ -657,6 +808,9 @@ export const make = (
657808 const createPayload = {
658809 cwd: options.cwd,
659810 mcpServers: options.mcpServers ?? [],
811 ...(options.additionalDirectories && options.additionalDirectories.length > 0
812 ? { additionalDirectories: options.additionalDirectories }
813 : {}),
660814 } satisfies EffectAcpSchema.NewSessionRequest;
661815 const created = yield* runLoggedRequest(
662816 "session/new",
......@@ -680,6 +834,7 @@ export const make = (
680834 });
681835
682836 const start = Effect.gen(function* () {
837 yield* ensureConnected;
683838 const deferred = yield* Deferred.make<
684839 AcpSessionRuntimeStartResult,
685840 EffectAcpErrors.AcpError
......@@ -694,13 +849,27 @@ export const make = (
694849 return [
695850 startOnce.pipe(
696851 Effect.tap((result) =>
697 Ref.set(startStateRef, { _tag: "Started", result }).pipe(
698 Effect.andThen(Deferred.succeed(deferred, result)),
852 notificationSemaphore.withPermit(
853 Effect.gen(function* () {
854 const error = yield* Ref.get(terminationErrorRef);
855 if (Option.isSome(error)) {
856 return yield* error.value;
857 }
858 yield* Ref.set(startStateRef, { _tag: "Started", result });
859 const metadata = yield* Ref.getAndSet(startupMetadataRef, []);
860 for (const notification of metadata) {
861 if (notification.sessionId === result.sessionId) {
862 yield* processSessionUpdate(notification);
863 }
864 }
865 yield* Deferred.succeed(deferred, result);
866 }),
699867 ),
700868 ),
701869 Effect.onError((cause) =>
702870 Deferred.failCause(deferred, cause).pipe(
703871 Effect.andThen(Ref.set(startStateRef, { _tag: "NotStarted" })),
872 Effect.andThen(Ref.set(startupMetadataRef, [])),
704873 ),
705874 ),
706875 ),
......@@ -711,6 +880,61 @@ export const make = (
711880 return yield* effect;
712881 });
713882
883 const drainEvents = Effect.gen(function* () {
884 if (yield* Ref.get(stoppingRef)) {
885 return;
886 }
887 const acknowledge = yield* Deferred.make<void>();
888 yield* Queue.offer(eventQueue, { _tag: "EventStreamBarrier", acknowledge });
889 yield* Effect.raceFirst(Deferred.await(acknowledge), Deferred.await(runtimeClosed));
890 });
891
892 const retireRuntime = Effect.fn("AcpSessionRuntime.retireRuntime")(function* (
893 error: EffectAcpErrors.AcpError,
894 ) {
895 yield* recordTermination(error);
896 yield* child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore);
897 });
898
899 const cancel = Effect.gen(function* () {
900 const started = yield* getStartedState;
901 const activePrompt = yield* Ref.get(activePromptRef);
902 if (options.cancelBehavior !== "wait-for-prompt") {
903 if (Option.isSome(activePrompt)) {
904 yield* Fiber.interrupt(activePrompt.value.fiber).pipe(Effect.ignore);
905 }
906 // Write cancel before a replacement prompt can reach the agent.
907 yield* acp.agent.cancel({ sessionId: started.sessionId }).pipe(Effect.ignore);
908 return;
909 }
910
911 yield* acp.agent.cancel({ sessionId: started.sessionId });
912 if (Option.isNone(activePrompt)) {
913 return;
914 }
915 const completed = yield* Effect.gen(function* () {
916 const result = yield* Fiber.await(activePrompt.value.fiber);
917 yield* Deferred.await(activePrompt.value.completed);
918 if (Option.isNone(yield* Ref.get(terminationErrorRef))) {
919 yield* drainEvents;
920 }
921 return result;
922 }).pipe(Effect.timeoutOption(options.cancelTimeout ?? defaultCancelTimeout));
923 if (Option.isNone(completed)) {
924 const error = new EffectAcpErrors.AcpTransportError({
925 operation: "call-rpc",
926 method: "session/cancel",
927 detail: "The ACP agent did not finish cancellation. Its process was stopped.",
928 cause: undefined,
929 });
930 yield* retireRuntime(error);
931 return yield* error;
932 }
933 if (Exit.isFailure(completed.value)) {
934 return yield* Effect.failCause(completed.value.cause);
935 }
936 });
937
714938 return {
715939 handleRequestPermission: acp.handleRequestPermission,
716940 handleElicitation: acp.handleElicitation,
......@@ -727,77 +951,75 @@ export const make = (
727951 handleUnknownExtNotification: acp.handleUnknownExtNotification,
728952 handleExtRequest: acp.handleExtRequest,
729953 handleExtNotification: acp.handleExtNotification,
730 initialize: () => sendInitialize,
954 initialize: () => ensureConnected.pipe(Effect.andThen(sendInitialize)),
731955 start: () => start,
732956 getEvents: () => Stream.fromQueue(eventQueue),
733 drainEvents: Effect.gen(function* () {
734 const acknowledge = yield* Deferred.make<void>();
735 yield* Queue.offer(eventQueue, {
736 _tag: "EventStreamBarrier",
737 acknowledge,
738 });
739 yield* Deferred.await(acknowledge);
740 }),
957 drainEvents,
741958 getModeState: Ref.get(modeStateRef),
742959 getConfigOptions: Ref.get(configOptionsRef),
743960 prompt: (payload, promptOptions?) =>
744961 promptSerializationSemaphore.withPermit(
745 Effect.gen(function* () {
746 const started = yield* getStartedState;
747 yield* closeActiveAssistantSegment({
748 queue: eventQueue,
749 assistantSegmentRef,
750 });
751 const requestPayload = {
752 sessionId: started.sessionId,
753 ...payload,
754 } satisfies EffectAcpSchema.PromptRequest;
755 const cancelledResponse = {
756 stopReason: "cancelled",
757 } satisfies EffectAcpSchema.PromptResponse;
758 const promptRpcFiber = yield* runLoggedRequest(
759 "session/prompt",
760 requestPayload,
761 acp.agent.prompt(requestPayload),
762 ).pipe(Effect.forkIn(runtimeScope));
763 yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber));
764 if (promptOptions?.dispatched) {
765 yield* Deferred.succeed(promptOptions.dispatched, undefined);
766 }
767 return yield* Fiber.join(promptRpcFiber).pipe(
768 Effect.catchCause((cause) =>
769 Cause.hasInterruptsOnly(cause)
770 ? Effect.succeed(cancelledResponse)
771 : Effect.failCause(cause),
772 ),
773 Effect.ensuring(
774 Effect.gen(function* () {
775 yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore);
776 yield* Ref.set(activePromptFiberRef, Option.none());
777 }),
778 ),
779 Effect.tap(() =>
780 closeActiveAssistantSegment({
781 queue: eventQueue,
782 assistantSegmentRef,
783 }),
962 Effect.acquireUseRelease(
963 promptDispatchSemaphore.withPermit(
964 Effect.gen(function* () {
965 const started = yield* getStartedState;
966 yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef });
967 const requestPayload = {
968 sessionId: started.sessionId,
969 ...payload,
970 } satisfies EffectAcpSchema.PromptRequest;
971 const completed = yield* Deferred.make<void>();
972 const fiber = yield* runLoggedRequest(
973 "session/prompt",
974 requestPayload,
975 acp.agent.prompt(requestPayload),
976 ).pipe(Effect.forkIn(runtimeScope));
977 const active = { fiber, completed } satisfies AcpActivePrompt;
978 yield* Ref.set(activePromptRef, Option.some(active));
979 if (promptOptions?.dispatched) {
980 yield* Deferred.succeed(promptOptions.dispatched, undefined);
981 }
982 return active;
983 }),
984 ),
985 (activePrompt) =>
986 Fiber.join(activePrompt.fiber).pipe(
987 Effect.catchCause((cause) =>
988 options.cancelBehavior !== "wait-for-prompt" && Cause.hasInterruptsOnly(cause)
989 ? Effect.succeed({
990 stopReason: "cancelled",
991 } satisfies EffectAcpSchema.PromptResponse)
992 : Effect.failCause(cause),
993 ),
994 Effect.tap(() =>
995 closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }),
996 ),
784997 ),
785 );
786 }),
787 ),
788 cancel: getStartedState.pipe(
789 Effect.flatMap((started) =>
790 Effect.gen(function* () {
791 const activePromptFiber = yield* Ref.get(activePromptFiberRef);
792 if (Option.isSome(activePromptFiber)) {
793 yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore);
794 }
795 // Await the notification write so a replacement session/prompt
796 // cannot race ahead of session/cancel on the wire.
797 yield* acp.agent.cancel({ sessionId: started.sessionId }).pipe(Effect.ignore);
798 }),
998 (activePrompt, result) =>
999 Effect.gen(function* () {
1000 if (
1001 options.cancelBehavior === "wait-for-prompt" &&
1002 Exit.isFailure(result) &&
1003 Cause.hasInterrupts(result.cause)
1004 ) {
1005 yield* retireRuntime(
1006 new EffectAcpErrors.AcpTransportError({
1007 method: "session/prompt",
1008 detail: "The ACP prompt stopped before the agent confirmed completion.",
1009 cause: undefined,
1010 }),
1011 );
1012 }
1013 yield* Fiber.interrupt(activePrompt.fiber).pipe(Effect.ignore);
1014 yield* Ref.set(activePromptRef, Option.none());
1015 yield* Deferred.succeed(activePrompt.completed, undefined);
1016 }),
1017 ),
7991018 ),
800 ),
1019 cancel:
1020 options.cancelBehavior === "wait-for-prompt"
1021 ? promptDispatchSemaphore.withPermit(cancel)
1022 : cancel,
8011023 setMode: (modeId) =>
8021024 Ref.get(modeStateRef).pipe(
8031025 Effect.flatMap((modeState) => {
......@@ -832,8 +1054,11 @@ export const make = (
8321054 }),
8331055 ),
8341056 request: (method, payload) =>
835 runLoggedRequest(method, payload, acp.raw.request(method, payload)),
836 notify: acp.raw.notify,
1057 ensureConnected.pipe(
1058 Effect.andThen(runLoggedRequest(method, payload, acp.raw.request(method, payload))),
1059 ),
1060 notify: (method, payload) =>
1061 ensureConnected.pipe(Effect.andThen(acp.raw.notify(method, payload))),
8371062 } satisfies AcpSessionRuntime["Service"];
8381063 });
8391064
......@@ -869,9 +1094,21 @@ function configOptionCurrentValueMatches(
8691094 return currentValue.trim() === String(value).trim();
8701095}
8711096
1097function isStartupMetadataUpdate(notification: EffectAcpSchema.SessionNotification): boolean {
1098 switch (notification.update.sessionUpdate) {
1099 case "current_mode_update":
1100 case "config_option_update":
1101 case "available_commands_update":
1102 return true;
1103 default:
1104 return false;
1105 }
1106}
1107
8721108const handleSessionUpdate = ({
8731109 queue,
8741110 modeStateRef,
1111 configOptionsRef,
8751112 toolCallsRef,
8761113 assistantSegmentRef,
8771114 assistantItemRuntimeId,
......@@ -879,12 +1116,16 @@ const handleSessionUpdate = ({
8791116}: {
8801117 readonly queue: Queue.Queue<AcpSessionRuntimeEvent>;
8811118 readonly modeStateRef: Ref.Ref<AcpSessionModeState | undefined>;
1119 readonly configOptionsRef: Ref.Ref<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>;
8821120 readonly toolCallsRef: Ref.Ref<Map<string, AcpToolCallTrackedState>>;
8831121 readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>;
8841122 readonly assistantItemRuntimeId: string;
8851123 readonly params: EffectAcpSchema.SessionNotification;
8861124}): Effect.Effect<void> =>
8871125 Effect.gen(function* () {
1126 if (params.update.sessionUpdate === "config_option_update") {
1127 yield* Ref.set(configOptionsRef, params.update.configOptions);
1128 }
8881129 const parsed = parseSessionUpdateEvent(params);
8891130 if (parsed.modeId) {
8901131 yield* Ref.update(modeStateRef, (current) =>
apps/server/src/provider/acp/AntigravityAcpSupport.test.ts created+502
......@@ -0,0 +1,502 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import {
4 ANTIGRAVITY_DEFAULT_MODEL,
5 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
6 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
7 type ChatAttachment,
8 type RuntimeMode,
9} from "@t3tools/contracts";
10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
12import * as Path from "effect/Path";
13import * as EffectAcpErrors from "effect-acp/errors";
14import type * as EffectAcpSchema from "effect-acp/schema";
15
16import { resolveAttachmentPath } from "../../attachmentStore.ts";
17import {
18 ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES,
19 antigravityPermissionMode,
20 applyAntigravityAcpModelSelection,
21 buildAntigravityPrompt,
22} from "./AntigravityAcpSupport.ts";
23
24const modelConfig = {
25 id: "model",
26 name: "Model",
27 type: "select",
28 currentValue: "gemini-default",
29 options: [
30 { value: "gemini-default", name: "Gemini default" },
31 { value: "gemini-saved", name: "Gemini saved" },
32 ],
33} satisfies EffectAcpSchema.SessionConfigOption;
34
35function makeModelRuntime(
36 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption> = [modelConfig],
37 failure?: EffectAcpErrors.AcpError,
38) {
39 const selections: string[] = [];
40 const setModel = Effect.fn("AntigravityAcpSupportTest.setModel")(function* (model: string) {
41 if (failure) return yield* failure;
42 selections.push(model);
43 });
44 return {
45 runtime: { getConfigOptions: Effect.succeed(configOptions), setModel },
46 selections,
47 };
48}
49
50describe("applyAntigravityAcpModelSelection", () => {
51 it.effect("restores the saved model instead of the cold-resume default", () =>
52 Effect.gen(function* () {
53 const { runtime, selections } = makeModelRuntime();
54 const model = yield* applyAntigravityAcpModelSelection({
55 runtime,
56 model: "gemini-saved",
57 mapError: (cause) => cause,
58 });
59
60 expect(model).toBe("gemini-saved");
61 expect(selections).toEqual(["gemini-saved"]);
62 }),
63 );
64
65 it.effect("reapplies an explicit selection even when setup reports the same model", () =>
66 Effect.gen(function* () {
67 const { runtime, selections } = makeModelRuntime([
68 { ...modelConfig, currentValue: "gemini-saved" },
69 ]);
70 const model = yield* applyAntigravityAcpModelSelection({
71 runtime,
72 model: "gemini-saved",
73 mapError: (cause) => cause,
74 });
75
76 expect(model).toBe("gemini-saved");
77 expect(selections).toEqual(["gemini-saved"]);
78 }),
79 );
80
81 it.effect.each([undefined, null, ANTIGRAVITY_DEFAULT_MODEL])(
82 "uses the native default for %s without sending an internal model ID",
83 (requestedModel) =>
84 Effect.gen(function* () {
85 const { runtime, selections } = makeModelRuntime();
86 const model = yield* applyAntigravityAcpModelSelection({
87 runtime,
88 model: requestedModel,
89 mapError: (cause) => cause,
90 });
91
92 expect(model).toBe("gemini-default");
93 expect(selections).toEqual([]);
94 }),
95 );
96
97 it.effect("selects the manifest default for the alias when the account offers it", () =>
98 Effect.gen(function* () {
99 const { runtime, selections } = makeModelRuntime();
100 const model = yield* applyAntigravityAcpModelSelection({
101 runtime,
102 model: ANTIGRAVITY_DEFAULT_MODEL,
103 defaultModel: "gemini-saved",
104 mapError: (cause) => cause,
105 });
106 expect(model).toBe("gemini-saved");
107 expect(selections).toEqual(["gemini-saved"]);
108
109 const { runtime: other, selections: otherSelections } = makeModelRuntime();
110 const fallback = yield* applyAntigravityAcpModelSelection({
111 runtime: other,
112 model: ANTIGRAVITY_DEFAULT_MODEL,
113 defaultModel: "gemini-not-offered",
114 mapError: (cause) => cause,
115 });
116 expect(fallback).toBe("gemini-default");
117 expect(otherSelections).toEqual([]);
118 }),
119 );
120
121 it.effect.each(["gemini-removed", "Gemini saved", "gemini-saved[reasoning=high]"])(
122 "rejects unavailable or non-native model ID %s without selecting a fallback",
123 (model) =>
124 Effect.gen(function* () {
125 const { runtime, selections } = makeModelRuntime();
126 const error = yield* applyAntigravityAcpModelSelection({
127 runtime,
128 model,
129 mapError: (cause) => cause,
130 }).pipe(Effect.flip);
131
132 expect(error).toMatchObject({
133 _tag: "AcpRequestError",
134 code: -32602,
135 errorMessage: expect.stringContaining(`'${model}' is unavailable`),
136 });
137 expect(selections).toEqual([]);
138 }),
139 );
140
141 it.effect("accepts exact model IDs from grouped native options", () =>
142 Effect.gen(function* () {
143 const { runtime, selections } = makeModelRuntime([
144 {
145 ...modelConfig,
146 options: [{ group: "gemini", name: "Gemini", options: modelConfig.options }],
147 },
148 ]);
149 const model = yield* applyAntigravityAcpModelSelection({
150 runtime,
151 model: "gemini-saved",
152 mapError: (cause) => cause,
153 });
154
155 expect(model).toBe("gemini-saved");
156 expect(selections).toEqual(["gemini-saved"]);
157 }),
158 );
159
160 it.effect("reports a native model-selection failure through the adapter error mapper", () =>
161 Effect.gen(function* () {
162 const nativeError = EffectAcpErrors.AcpRequestError.invalidParams("Model access changed.");
163 const { runtime } = makeModelRuntime([modelConfig], nativeError);
164 const error = yield* applyAntigravityAcpModelSelection({
165 runtime,
166 model: "gemini-saved",
167 mapError: (cause) => ({ operation: "select-model", cause }),
168 }).pipe(Effect.flip);
169
170 expect(error).toEqual({ operation: "select-model", cause: nativeError });
171 }),
172 );
173});
174
175describe("antigravityPermissionMode", () => {
176 it.each([
177 { runtimeMode: "approval-required", nativeMode: "default" },
178 { runtimeMode: "auto", nativeMode: "default" },
179 { runtimeMode: "auto-accept-edits", nativeMode: "auto_edit" },
180 { runtimeMode: "full-access", nativeMode: "yolo" },
181 ] satisfies ReadonlyArray<{ runtimeMode: RuntimeMode; nativeMode: string }>)(
182 "maps $runtimeMode to $nativeMode",
183 ({ runtimeMode, nativeMode }) => {
184 expect(antigravityPermissionMode(runtimeMode)).toBe(nativeMode);
185 },
186 );
187});
188
189const imageAttachment = {
190 type: "image",
191 id: "thread-00000000-0000-4000-8000-000000000001",
192 name: "screen.png",
193 mimeType: "image/png",
194 sizeBytes: 1,
195} satisfies ChatAttachment;
196
197const textAttachment = {
198 type: "file",
199 id: "thread-00000000-0000-4000-8000-000000000002-tsx",
200 name: "example.tsx",
201 mimeType: "application/octet-stream",
202 sizeBytes: 1,
203} satisfies ChatAttachment;
204
205const pdfAttachment = {
206 type: "file",
207 id: "thread-00000000-0000-4000-8000-000000000003-pdf",
208 name: "report.pdf",
209 mimeType: "application/pdf",
210 sizeBytes: 1,
211} satisfies ChatAttachment;
212
213const makeAttachmentFixture = Effect.fn("AntigravityAcpSupportTest.makeAttachmentFixture")(
214 function* () {
215 const fs = yield* FileSystem.FileSystem;
216 const path = yield* Path.Path;
217 const attachmentsDir = yield* fs.makeTempDirectoryScoped({
218 prefix: "t3-antigravity-attachments-",
219 });
220 const write = Effect.fn("AntigravityAcpSupportTest.writeAttachment")(function* (
221 attachment: ChatAttachment,
222 content: string | Uint8Array,
223 ) {
224 const filePath = resolveAttachmentPath({ attachmentsDir, attachment });
225 if (filePath === null) throw new Error("Invalid test attachment path.");
226 if (typeof content === "string") yield* fs.writeFileString(filePath, content);
227 else yield* fs.writeFile(filePath, content);
228 return { filePath, uri: (yield* path.toFileUrl(filePath)).href };
229 });
230 return { fs, attachmentsDir, write };
231 },
232);
233
234it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => {
235 it.effect("sends image bytes as native image content alongside the user prompt", () =>
236 Effect.gen(function* () {
237 const fixture = yield* makeAttachmentFixture();
238 const bytes = Buffer.from(
239 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jY9kAAAAASUVORK5CYII=",
240 "base64",
241 );
242 yield* fixture.write(imageAttachment, bytes);
243 const prompt = yield* buildAntigravityPrompt({
244 input: " Explain this image. ",
245 attachments: [imageAttachment],
246 attachmentsDir: fixture.attachmentsDir,
247 });
248
249 expect(prompt).toEqual([
250 { type: "text", text: "Explain this image." },
251 { type: "image", data: bytes.toString("base64"), mimeType: "image/png" },
252 ]);
253 }),
254 );
255
256 it.effect("embeds UTF-8 code from the selected environment and keeps the upload in place", () =>
257 Effect.gen(function* () {
258 const selectedEnvironment = yield* makeAttachmentFixture();
259 const otherEnvironment = yield* makeAttachmentFixture();
260 const source = ' const name = "caf\u00e9";\n';
261 const upload = yield* selectedEnvironment.write(textAttachment, source);
262 yield* otherEnvironment.write(textAttachment, "Different environment.");
263 const prompt = yield* buildAntigravityPrompt({
264 input: undefined,
265 attachments: [textAttachment],
266 attachmentsDir: selectedEnvironment.attachmentsDir,
267 });
268
269 expect(prompt).toEqual([
270 {
271 type: "resource",
272 resource: { uri: upload.uri, mimeType: "application/octet-stream", text: source },
273 },
274 ]);
275 expect(yield* selectedEnvironment.fs.readFileString(upload.filePath)).toBe(source);
276 expect(
277 yield* selectedEnvironment.fs.readDirectory(selectedEnvironment.attachmentsDir),
278 ).toHaveLength(1);
279 }),
280 );
281
282 it.effect("sends supported audio files as native audio content", () =>
283 Effect.gen(function* () {
284 const fixture = yield* makeAttachmentFixture();
285 const audioAttachment = {
286 ...textAttachment,
287 id: "recording-1",
288 name: "recording.wav",
289 mimeType: "audio/wav",
290 } satisfies ChatAttachment;
291 const bytes = Buffer.from("RIFF....WAVEfmt ", "latin1");
292 yield* fixture.write(audioAttachment, bytes);
293 const prompt = yield* buildAntigravityPrompt({
294 input: "Transcribe this.",
295 attachments: [audioAttachment],
296 attachmentsDir: fixture.attachmentsDir,
297 });
298
299 expect(prompt).toEqual([
300 { type: "text", text: "Transcribe this." },
301 { type: "audio", data: bytes.toString("base64"), mimeType: "audio/wav" },
302 ]);
303 }),
304 );
305
306 it.effect("uses a PDF file resource link without copying or reading its bytes", () =>
307 Effect.gen(function* () {
308 const fixture = yield* makeAttachmentFixture();
309 const upload = yield* fixture.write(pdfAttachment, "%PDF-1.7\n");
310 const forbidFileAccess = () =>
311 Effect.die("PDF prompt construction must only inspect the file.");
312 const prompt = yield* buildAntigravityPrompt({
313 input: undefined,
314 attachments: [pdfAttachment],
315 attachmentsDir: fixture.attachmentsDir,
316 }).pipe(
317 Effect.provideService(FileSystem.FileSystem, {
318 ...fixture.fs,
319 readFile: forbidFileAccess,
320 writeFile: forbidFileAccess,
321 writeFileString: forbidFileAccess,
322 copyFile: forbidFileAccess,
323 copy: forbidFileAccess,
324 rename: forbidFileAccess,
325 }),
326 );
327
328 expect(prompt).toEqual([
329 { type: "resource_link", uri: upload.uri, name: "report.pdf", mimeType: "application/pdf" },
330 ]);
331 }),
332 );
333
334 it.effect.each([
335 { ...imageAttachment, name: "animation.gif", mimeType: "image/gif" },
336 { ...textAttachment, name: "archive.zip", mimeType: "application/zip" },
337 { ...textAttachment, name: "recording.aiff", mimeType: "audio/aiff" },
338 ] satisfies ReadonlyArray<ChatAttachment>)(
339 "rejects $name instead of silently dropping it from a valid prompt",
340 (attachment) =>
341 Effect.gen(function* () {
342 const fixture = yield* makeAttachmentFixture();
343 yield* fixture.write(imageAttachment, new Uint8Array([1, 2, 3]));
344 const error = yield* buildAntigravityPrompt({
345 input: "Analyze every attachment.",
346 attachments: [imageAttachment, attachment],
347 attachmentsDir: fixture.attachmentsDir,
348 }).pipe(Effect.flip);
349
350 expect(error).toMatchObject({
351 _tag: "AcpRequestError",
352 code: -32602,
353 errorMessage: expect.stringContaining(`does not support '${attachment.name}'`),
354 });
355 }),
356 );
357
358 it.effect.each([
359 { attachment: textAttachment, bytes: ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES + 1 },
360 { attachment: imageAttachment, bytes: PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + 1 },
361 { attachment: pdfAttachment, bytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES + 1 },
362 ])(
363 "rejects oversized $attachment.name using file size instead of upload metadata",
364 ({ attachment, bytes }) =>
365 Effect.gen(function* () {
366 const fixture = yield* makeAttachmentFixture();
367 const upload = yield* fixture.write(attachment, "");
368 yield* fixture.fs.truncate(upload.filePath, bytes);
369 const error = yield* buildAntigravityPrompt({
370 input: "Read this attachment.",
371 attachments: [attachment],
372 attachmentsDir: fixture.attachmentsDir,
373 }).pipe(Effect.flip);
374
375 expect(error).toMatchObject({
376 _tag: "AcpRequestError",
377 code: -32602,
378 errorMessage: expect.stringContaining(`'${attachment.name}' is too large`),
379 });
380 }),
381 );
382
383 it.effect("accepts 50 MiB in total but rejects one byte more across files", () =>
384 Effect.gen(function* () {
385 const fixture = yield* makeAttachmentFixture();
386 const secondAttachment = {
387 ...pdfAttachment,
388 id: `${pdfAttachment.id}-second`,
389 name: "second.pdf",
390 };
391 const first = yield* fixture.write(pdfAttachment, "");
392 const second = yield* fixture.write(secondAttachment, "");
393 yield* fixture.fs.truncate(first.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES / 2);
394 yield* fixture.fs.truncate(second.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES / 2);
395 const input = {
396 input: undefined,
397 attachments: [pdfAttachment, secondAttachment],
398 attachmentsDir: fixture.attachmentsDir,
399 };
400 const prompt = yield* buildAntigravityPrompt(input);
401 expect(prompt).toEqual([
402 { type: "resource_link", uri: first.uri, name: "report.pdf", mimeType: "application/pdf" },
403 { type: "resource_link", uri: second.uri, name: "second.pdf", mimeType: "application/pdf" },
404 ]);
405
406 yield* fixture.fs.truncate(second.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES / 2 + 1);
407 const error = yield* buildAntigravityPrompt(input).pipe(Effect.flip);
408 expect(error).toMatchObject({
409 _tag: "AcpRequestError",
410 code: -32602,
411 errorMessage: expect.stringContaining("'second.pdf' is too large"),
412 });
413 }),
414 );
415
416 it.effect("rejects aggregate overflow when a text upload grows after its size check", () =>
417 Effect.gen(function* () {
418 const fixture = yield* makeAttachmentFixture();
419 const pdf = yield* fixture.write(pdfAttachment, "");
420 const text = yield* fixture.write(textAttachment, "a");
421 yield* fixture.fs.truncate(pdf.filePath, PROVIDER_SEND_TURN_MAX_FILE_BYTES - 1);
422 const error = yield* buildAntigravityPrompt({
423 input: undefined,
424 attachments: [pdfAttachment, textAttachment],
425 attachmentsDir: fixture.attachmentsDir,
426 }).pipe(
427 Effect.provideService(FileSystem.FileSystem, {
428 ...fixture.fs,
429 stat: Effect.fn("AntigravityAcpSupportTest.growAfterStat")(function* (filePath: string) {
430 const info = yield* fixture.fs.stat(filePath);
431 if (filePath === text.filePath) {
432 yield* fixture.fs.writeFileString(filePath, "ab");
433 }
434 return info;
435 }),
436 }),
437 Effect.flip,
438 );
439
440 expect(error).toMatchObject({
441 _tag: "AcpRequestError",
442 code: -32602,
443 errorMessage: "Attachment 'example.tsx' changed while being read and is too large.",
444 });
445 }),
446 );
447
448 it.effect.each([
449 { bytes: new Uint8Array([0xff, 0xfe, 0x61]), message: "is not a UTF-8 text file" },
450 { bytes: new Uint8Array([0x61, 0, 0x62]), message: "contains binary data" },
451 ])("rejects binary data disguised as code: $message", ({ bytes, message }) =>
452 Effect.gen(function* () {
453 const fixture = yield* makeAttachmentFixture();
454 yield* fixture.write(textAttachment, bytes);
455 const error = yield* buildAntigravityPrompt({
456 input: undefined,
457 attachments: [textAttachment],
458 attachmentsDir: fixture.attachmentsDir,
459 }).pipe(Effect.flip);
460
461 expect(error).toMatchObject({
462 _tag: "AcpRequestError",
463 code: -32602,
464 errorMessage: expect.stringContaining(message),
465 });
466 }),
467 );
468
469 it.effect("reports a missing upload instead of sending only the remaining text", () =>
470 Effect.gen(function* () {
471 const fixture = yield* makeAttachmentFixture();
472 const error = yield* buildAntigravityPrompt({
473 input: "Read this image.",
474 attachments: [imageAttachment],
475 attachmentsDir: fixture.attachmentsDir,
476 }).pipe(Effect.flip);
477
478 expect(error).toMatchObject({
479 _tag: "AcpRequestError",
480 code: -32602,
481 errorMessage: "Could not read attachment 'screen.png'.",
482 });
483 }),
484 );
485
486 it.effect("rejects an empty turn", () =>
487 Effect.gen(function* () {
488 const fixture = yield* makeAttachmentFixture();
489 const error = yield* buildAntigravityPrompt({
490 input: " ",
491 attachments: [],
492 attachmentsDir: fixture.attachmentsDir,
493 }).pipe(Effect.flip);
494
495 expect(error).toMatchObject({
496 _tag: "AcpRequestError",
497 code: -32602,
498 errorMessage: "A turn requires text or supported attachments.",
499 });
500 }),
501 );
502});
apps/server/src/provider/acp/AntigravityAcpSupport.ts created+360
......@@ -0,0 +1,360 @@
1import {
2 ANTIGRAVITY_DEFAULT_MODEL,
3 type AntigravityAuthMethod,
4 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
5 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
6 type ProviderSendTurnInput,
7 type RuntimeMode,
8} from "@t3tools/contracts";
9import * as Crypto from "effect/Crypto";
10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";
13import * as Path from "effect/Path";
14import * as Scope from "effect/Scope";
15import * as Stream from "effect/Stream";
16import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
17import * as EffectAcpErrors from "effect-acp/errors";
18import type * as EffectAcpSchema from "effect-acp/schema";
19
20import { resolveAttachmentPath } from "../../attachmentStore.ts";
21import {
22 drainAntigravityStderr,
23 makeAntigravityStdoutTransform,
24} from "../antigravityAuthSupport.ts";
25import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
26import { normalizeAntigravitySessionUpdate } from "./AntigravityProtocol.ts";
27
28export interface AntigravityAcpRuntimeInput extends Omit<
29 AcpSessionRuntime.AcpSessionRuntimeOptions,
30 | "authMethodId"
31 | "cancelBehavior"
32 | "clientCapabilities"
33 | "onStderr"
34 | "resumeMethod"
35 | "transformSessionUpdate"
36 | "transformStdout"
37> {
38 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
39 readonly onAuthorizationUrl?: (url: string) => Effect.Effect<void, EffectAcpErrors.AcpError>;
40 /**
41 * Advertise `fs.readTextFile` and `fs.writeTextFile`. The agent then routes
42 * workspace reads and writes through T3, which turns each edit into a
43 * `session/request_permission` with the file content, instead of writing
44 * through its own tools. Chat sessions turn this on. Setup, probe, and text
45 * generation helpers leave it off so they never touch a workspace.
46 */
47 readonly clientFileSystem?: boolean;
48 /** ACP `authenticate` method id. Defaults to the personal Google account flow. */
49 readonly authMethod?: AntigravityAuthMethod;
50}
51
52/** Normal launches reject browser login; only the auth flow supplies `onAuthorizationUrl`. */
53export const makeAntigravityAcpRuntime = Effect.fn("makeAntigravityAcpRuntime")(function* (
54 input: AntigravityAcpRuntimeInput,
55): Effect.fn.Return<
56 AcpSessionRuntime.AcpSessionRuntime["Service"],
57 EffectAcpErrors.AcpError,
58 Crypto.Crypto | Scope.Scope
59> {
60 const context = yield* Layer.build(
61 AcpSessionRuntime.layer({
62 ...input,
63 authMethodId: input.authMethod ?? "oauth-personal",
64 resumeMethod: "resume",
65 cancelBehavior: "wait-for-prompt",
66 clientCapabilities: {
67 fs: {
68 readTextFile: input.clientFileSystem === true,
69 writeTextFile: input.clientFileSystem === true,
70 },
71 terminal: false,
72 },
73 transformStdout: makeAntigravityStdoutTransform(
74 input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {},
75 ),
76 onStderr: drainAntigravityStderr,
77 transformSessionUpdate: normalizeAntigravitySessionUpdate,
78 }).pipe(
79 Layer.provide(
80 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),
81 ),
82 ),
83 );
84 return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(Effect.provide(context));
85});
86
87export function antigravityPermissionMode(runtimeMode: RuntimeMode): string {
88 switch (runtimeMode) {
89 case "full-access":
90 return "yolo";
91 case "auto-accept-edits":
92 return "auto_edit";
93 case "auto":
94 case "approval-required":
95 return "default";
96 }
97}
98
99export function antigravityModelOptions(
100 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>,
101) {
102 const model = configOptions.find((option) => option.id === "model");
103 if (model?.type !== "select") return [];
104 return model.options.flatMap((entry) => ("value" in entry ? [entry] : entry.options));
105}
106
107/**
108 * Resolves the model a turn should run on. A saved selection is reapplied
109 * as-is. The provider default alias resolves to `defaultModel` when the
110 * account offers it, so T3 can pick a newer model than the one Google marks
111 * current. Otherwise the agent's current selection stands.
112 */
113export function resolveAntigravityModel(input: {
114 readonly configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>;
115 readonly model: string | null | undefined;
116 readonly defaultModel?: string | undefined;
117}): string | undefined {
118 const modelConfig = input.configOptions.find((option) => option.id === "model");
119 const current = modelConfig?.type === "select" ? modelConfig.currentValue : undefined;
120 if (input.model && input.model !== ANTIGRAVITY_DEFAULT_MODEL) return input.model;
121 const options = antigravityModelOptions(input.configOptions);
122 return input.defaultModel && options.some((option) => option.value === input.defaultModel)
123 ? input.defaultModel
124 : current;
125}
126
127/** Never replace a saved selection with the default returned by a cold resume. */
128export const applyAntigravityAcpModelSelection = Effect.fn("applyAntigravityAcpModelSelection")(
129 function* <E>(input: {
130 readonly runtime: Pick<
131 AcpSessionRuntime.AcpSessionRuntime["Service"],
132 "getConfigOptions" | "setModel"
133 >;
134 readonly model: string | null | undefined;
135 /** Model to select for the provider default alias. See `resolveAntigravityModel`. */
136 readonly defaultModel?: string | undefined;
137 readonly mapError: (cause: EffectAcpErrors.AcpError) => E;
138 }): Effect.fn.Return<string | undefined, E> {
139 const configOptions = yield* input.runtime.getConfigOptions;
140 const modelConfig = configOptions.find((option) => option.id === "model");
141 const current = modelConfig?.type === "select" ? modelConfig.currentValue : undefined;
142 const resolved = resolveAntigravityModel({
143 configOptions,
144 model: input.model,
145 defaultModel: input.defaultModel,
146 });
147 // The default alias never sends an internal ID. It selects the manifest
148 // default when that differs from the agent's current model, and otherwise
149 // leaves the agent's choice alone.
150 const explicit = Boolean(input.model) && input.model !== ANTIGRAVITY_DEFAULT_MODEL;
151 if (resolved === undefined || (!explicit && resolved === current)) return current;
152 const options = antigravityModelOptions(configOptions);
153 if (!options.some((option) => option.value === resolved)) {
154 return yield* Effect.fail(
155 input.mapError(
156 EffectAcpErrors.AcpRequestError.invalidParams(
157 `Antigravity model '${resolved}' is unavailable for this Google account. Select an available model.`,
158 ),
159 ),
160 );
161 }
162 yield* input.runtime.setModel(resolved).pipe(Effect.mapError(input.mapError));
163 return resolved;
164 },
165);
166
167const IMAGE_MIME_TYPES = new Set(["image/bmp", "image/jpeg", "image/png", "image/webp"]);
168// Formats the bundled SDK's Audio type accepts. Anything else is rejected up front.
169const AUDIO_MIME_TYPES = new Set([
170 "audio/aac",
171 "audio/flac",
172 "audio/mp3",
173 "audio/mpeg",
174 "audio/mp4",
175 "audio/m4a",
176 "audio/x-m4a",
177 "audio/ogg",
178 "audio/wav",
179 "audio/x-wav",
180 "audio/webm",
181]);
182export const ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES = 20 * 1024 * 1024;
183const TEXT_MIME_TYPES = new Set([
184 "application/json",
185 "application/ld+json",
186 "application/javascript",
187 "application/typescript",
188 "application/xml",
189 "application/yaml",
190 "application/x-yaml",
191 "application/x-sh",
192]);
193const TEXT_FILE_EXTENSIONS = new Set([
194 ".txt",
195 ".md",
196 ".mdx",
197 ".json",
198 ".jsonl",
199 ".yaml",
200 ".yml",
201 ".toml",
202 ".xml",
203 ".csv",
204 ".tsv",
205 ".js",
206 ".jsx",
207 ".mjs",
208 ".cjs",
209 ".ts",
210 ".tsx",
211 ".html",
212 ".css",
213 ".scss",
214 ".less",
215 ".py",
216 ".rs",
217 ".go",
218 ".java",
219 ".kt",
220 ".swift",
221 ".c",
222 ".h",
223 ".cc",
224 ".cpp",
225 ".hpp",
226 ".cs",
227 ".rb",
228 ".php",
229 ".sh",
230 ".bash",
231 ".zsh",
232 ".sql",
233 ".graphql",
234 ".svelte",
235 ".vue",
236 ".log",
237 ".diff",
238 ".patch",
239 ".ini",
240 ".conf",
241]);
242export const ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES = 1024 * 1024;
243const MAX_TOTAL_ATTACHMENT_BYTES = PROVIDER_SEND_TURN_MAX_FILE_BYTES;
244
245/** Sends uploads as native ACP content instead of workspace path hints. */
246export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(function* (input: {
247 readonly input: ProviderSendTurnInput["input"];
248 readonly attachments: ProviderSendTurnInput["attachments"];
249 readonly attachmentsDir: string;
250}): Effect.fn.Return<
251 ReadonlyArray<EffectAcpSchema.ContentBlock>,
252 EffectAcpErrors.AcpError,
253 FileSystem.FileSystem | Path.Path
254> {
255 const fileSystem = yield* FileSystem.FileSystem;
256 const path = yield* Path.Path;
257 const blocks: Array<EffectAcpSchema.ContentBlock> = [];
258 const text = input.input?.trim();
259 if (text) blocks.push({ type: "text", text });
260 let totalBytes = 0;
261
262 for (const attachment of input.attachments ?? []) {
263 const mimeType = attachment.mimeType.toLowerCase().split(";", 1)[0] ?? "";
264 const image = attachment.type === "image" && IMAGE_MIME_TYPES.has(mimeType);
265 const audio = attachment.type === "file" && AUDIO_MIME_TYPES.has(mimeType);
266 const pdf = attachment.type === "file" && mimeType === "application/pdf";
267 const textFile =
268 attachment.type === "file" &&
269 (mimeType.startsWith("text/") ||
270 TEXT_MIME_TYPES.has(mimeType) ||
271 TEXT_FILE_EXTENSIONS.has(path.extname(attachment.name).toLowerCase()));
272 if (!image && !audio && !pdf && !textFile) {
273 return yield* EffectAcpErrors.AcpRequestError.invalidParams(
274 `Antigravity does not support '${attachment.name}' (${attachment.mimeType}). Attach a BMP, JPEG, PNG, WebP, PDF, audio, or text file.`,
275 );
276 }
277 const attachmentPath = resolveAttachmentPath({
278 attachmentsDir: input.attachmentsDir,
279 attachment,
280 });
281 if (!attachmentPath) {
282 return yield* EffectAcpErrors.AcpRequestError.invalidParams(
283 `Invalid attachment '${attachment.name}'.`,
284 );
285 }
286 const info = yield* fileSystem
287 .stat(attachmentPath)
288 .pipe(
289 Effect.mapError(() =>
290 EffectAcpErrors.AcpRequestError.invalidParams(
291 `Could not read attachment '${attachment.name}'.`,
292 ),
293 ),
294 );
295 const size = Number(info.size);
296 const limit = image
297 ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES
298 : audio
299 ? ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES
300 : pdf
301 ? PROVIDER_SEND_TURN_MAX_FILE_BYTES
302 : ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES;
303 totalBytes += size;
304 if (info.type !== "File" || size > limit || totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) {
305 return yield* EffectAcpErrors.AcpRequestError.invalidParams(
306 `Attachment '${attachment.name}' is too large. Antigravity accepts text files up to 1 MiB, images up to 10 MiB, audio up to 20 MiB, and 50 MiB total attachments.`,
307 );
308 }
309 const uri = yield* path.toFileUrl(attachmentPath).pipe(
310 Effect.map((url) => url.href),
311 Effect.mapError(() =>
312 EffectAcpErrors.AcpRequestError.invalidParams(`Invalid attachment '${attachment.name}'.`),
313 ),
314 );
315 if (pdf) {
316 blocks.push({ type: "resource_link", uri, name: attachment.name, mimeType });
317 continue;
318 }
319 const bytes = yield* fileSystem.stream(attachmentPath, { bytesToRead: limit + 1 }).pipe(
320 Stream.runCollect,
321 Effect.map((chunks) => Buffer.concat(chunks)),
322 Effect.mapError(() =>
323 EffectAcpErrors.AcpRequestError.invalidParams(
324 `Could not read attachment '${attachment.name}'.`,
325 ),
326 ),
327 );
328 totalBytes += bytes.length - size;
329 if (bytes.length > limit || totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) {
330 return yield* EffectAcpErrors.AcpRequestError.invalidParams(
331 `Attachment '${attachment.name}' changed while being read and is too large.`,
332 );
333 }
334 if (image) {
335 blocks.push({ type: "image", data: Buffer.from(bytes).toString("base64"), mimeType });
336 } else if (audio) {
337 blocks.push({ type: "audio", data: Buffer.from(bytes).toString("base64"), mimeType });
338 } else {
339 const decoded = yield* Effect.try({
340 try: () => new TextDecoder("utf-8", { fatal: true }).decode(bytes),
341 catch: () =>
342 EffectAcpErrors.AcpRequestError.invalidParams(
343 `Attachment '${attachment.name}' is not a UTF-8 text file.`,
344 ),
345 });
346 if (decoded.includes("\0")) {
347 return yield* EffectAcpErrors.AcpRequestError.invalidParams(
348 `Attachment '${attachment.name}' contains binary data.`,
349 );
350 }
351 blocks.push({ type: "resource", resource: { uri, mimeType, text: decoded } });
352 }
353 }
354 if (blocks.length === 0) {
355 return yield* EffectAcpErrors.AcpRequestError.invalidParams(
356 "A turn requires text or supported attachments.",
357 );
358 }
359 return blocks;
360});
apps/server/src/provider/acp/AntigravityProtocol.test.ts created+420
......@@ -0,0 +1,420 @@
1import { describe, expect, it } from "vite-plus/test";
2import * as Schema from "effect/Schema";
3import * as EffectAcpSchema from "effect-acp/schema";
4
5import {
6 extractAntigravityUserInputQuestion,
7 isAntigravityOpenCommand,
8 antigravityApprovalOptions,
9 isAntigravityUserInputRequest,
10 makeAntigravityUserInputResponse,
11 normalizeAntigravitySessionUpdate,
12 normalizeAntigravityToolCall,
13 sanitizeAntigravityToolPayload,
14 selectAntigravityPermissionOptionId,
15} from "./AntigravityProtocol.ts";
16import { mergeToolCallState, parseSessionUpdateEvent } from "./AcpRuntimeModel.ts";
17
18const isSessionNotification = Schema.is(EffectAcpSchema.SessionNotification);
19
20const questionRequest = {
21 sessionId: "session-1",
22 toolCall: {
23 toolCallId: "interaction_9960062f",
24 status: "pending",
25 title: "Which result label should be used for the verification?",
26 rawInput: {},
27 },
28 options: [
29 { optionId: "1", name: "Verified", kind: "allow_once" },
30 { optionId: "2", name: "Needs review", kind: "allow_once" },
31 ],
32} satisfies EffectAcpSchema.RequestPermissionRequest;
33
34const commandStarted = {
35 sessionId: "session-1",
36 update: {
37 sessionUpdate: "tool_call",
38 toolCallId: "fc28d0af6ad14be8bbba20f4258d4d3e",
39 title: "run_command",
40 kind: "execute",
41 status: "in_progress",
42 rawInput: { CommandLine: "cat probe.txt", Cwd: "/workspace" },
43 },
44} satisfies EffectAcpSchema.SessionNotification;
45
46const commandCompleted = {
47 sessionId: "session-1",
48 update: {
49 toolCallId: commandStarted.update.toolCallId,
50 status: "completed",
51 rawOutput: {
52 commandLine: "cat probe.txt",
53 workingDir: "/workspace",
54 exitCode: 0,
55 exit_code: 0,
56 combinedOutput: "after\n",
57 formatted_output: "after\n",
58 },
59 sessionUpdate: "tool_call_update",
60 },
61} satisfies EffectAcpSchema.SessionNotification;
62
63function parseToolUpdate(notification: EffectAcpSchema.SessionNotification) {
64 const result = parseSessionUpdateEvent(normalizeAntigravitySessionUpdate(notification));
65 const event = result.events[0];
66 if (event?._tag !== "ToolCallUpdated") {
67 throw new Error("Expected a tool update.");
68 }
69 return event;
70}
71
72describe("Antigravity permissions and questions", () => {
73 const permissionRequest = {
74 sessionId: "session-1",
75 toolCall: { toolCallId: "command-1", kind: "execute", title: "Run command" },
76 options: [
77 { optionId: "remember-this-command", name: "Allow always", kind: "allow_always" },
78 { optionId: "run-this-time", name: "Allow", kind: "allow_once" },
79 { optionId: "stop-this-command", name: "Deny", kind: "reject_once" },
80 ],
81 } satisfies EffectAcpSchema.RequestPermissionRequest;
82
83 it("uses only the option IDs offered for each approval decision", () => {
84 expect(selectAntigravityPermissionOptionId(permissionRequest, "accept")).toBe("run-this-time");
85 expect(selectAntigravityPermissionOptionId(permissionRequest, "acceptForSession")).toBe(
86 "remember-this-command",
87 );
88 expect(selectAntigravityPermissionOptionId(permissionRequest, "acceptAlways")).toBe(
89 "remember-this-command",
90 );
91 expect(selectAntigravityPermissionOptionId(permissionRequest, "decline")).toBe(
92 "stop-this-command",
93 );
94 expect(selectAntigravityPermissionOptionId(permissionRequest, "cancel")).toBeUndefined();
95 });
96
97 it("surfaces the agent's prompt injection warning on the remembered approval", () => {
98 const risky = {
99 ...permissionRequest,
100 options: [
101 {
102 optionId: "remember-this-command",
103 name: "Allow Always (risky)",
104 kind: "allow_always",
105 _meta: {
106 "agy.security.warning": {
107 severity: "high",
108 risk: "prompt_injection",
109 title: "Allowing always can be risky",
110 message: "Untrusted files could re-run this action without asking.",
111 },
112 },
113 },
114 { optionId: "run-this-time", name: "Allow", kind: "allow_once" },
115 { optionId: "stop-this-command", name: "Deny", kind: "reject_once" },
116 ],
117 } satisfies EffectAcpSchema.RequestPermissionRequest;
118 expect(antigravityApprovalOptions(risky)).toEqual([
119 { decision: "accept", label: "Allow once" },
120 {
121 decision: "acceptForSession",
122 label: "Allow for this thread",
123 warning: "Untrusted files could re-run this action without asking.",
124 },
125 { decision: "decline", label: "Deny" },
126 { decision: "cancel", label: "Cancel" },
127 ]);
128 expect(antigravityApprovalOptions(permissionRequest)[1]).not.toHaveProperty("warning");
129 });
130
131 it("does not replace unsupported remembered approval with a single approval", () => {
132 const request = {
133 ...permissionRequest,
134 options: permissionRequest.options.filter((option) => option.kind !== "allow_always"),
135 };
136 expect(selectAntigravityPermissionOptionId(request, "acceptAlways")).toBeUndefined();
137 expect(selectAntigravityPermissionOptionId(request, "acceptForSession")).toBeUndefined();
138 expect(selectAntigravityPermissionOptionId(request, "accept")).toBe("run-this-time");
139 expect(
140 selectAntigravityPermissionOptionId({ ...request, options: [] }, "decline"),
141 ).toBeUndefined();
142 });
143
144 it("routes the captured native question to single-choice input, not approval", () => {
145 expect(isAntigravityUserInputRequest(questionRequest)).toBe(true);
146 expect(selectAntigravityPermissionOptionId(questionRequest, "accept")).toBeUndefined();
147 expect(extractAntigravityUserInputQuestion(questionRequest)).toEqual({
148 id: "interaction_9960062f",
149 header: "Question",
150 question: "Which result label should be used for the verification?",
151 multiSelect: false,
152 allowCustomAnswer: false,
153 options: [
154 { value: "1", label: "Verified", description: "Verified" },
155 { value: "2", label: "Needs review", description: "Needs review" },
156 ],
157 });
158 expect(extractAntigravityUserInputQuestion(permissionRequest)).toBeUndefined();
159 });
160
161 it("returns exact opaque IDs and accepts a unique label from an older client", () => {
162 for (const answer of ["1", ["1"], "Verified"]) {
163 expect(
164 makeAntigravityUserInputResponse(questionRequest, { interaction_9960062f: answer }),
165 ).toEqual({ outcome: { outcome: "selected", optionId: "1" } });
166 }
167 const request = {
168 ...questionRequest,
169 options: [{ optionId: " choice: opaque ", name: "Keep", kind: "allow_once" as const }],
170 };
171 expect(
172 makeAntigravityUserInputResponse(request, { interaction_9960062f: " choice: opaque " }),
173 ).toEqual({ outcome: { outcome: "selected", optionId: " choice: opaque " } });
174 });
175
176 it("does not treat a question's reject choice as cancellation", () => {
177 const request = {
178 ...questionRequest,
179 options: [{ optionId: "deny", name: "Do not trust", kind: "reject_once" as const }],
180 };
181 expect(makeAntigravityUserInputResponse(request, { interaction_9960062f: "deny" })).toEqual({
182 outcome: { outcome: "selected", optionId: "deny" },
183 });
184 });
185
186 it("preserves duplicate labels and rejects ambiguous label answers", () => {
187 const request = {
188 ...questionRequest,
189 options: questionRequest.options.map((option) => ({ ...option, name: "Same label" })),
190 };
191 expect(
192 extractAntigravityUserInputQuestion(request)?.options.map((option) => option.value),
193 ).toEqual(["1", "2"]);
194 expect(
195 makeAntigravityUserInputResponse(request, { interaction_9960062f: "Same label" }),
196 ).toBeUndefined();
197 expect(makeAntigravityUserInputResponse(request, { interaction_9960062f: "2" })).toEqual({
198 outcome: { outcome: "selected", optionId: "2" },
199 });
200 });
201
202 it.each([undefined, null, "", "arbitrary answer", [], ["1", "2"], { answer: "1" }, 1])(
203 "keeps the question open for an unsupported answer: %j",
204 (answer) => {
205 expect(
206 makeAntigravityUserInputResponse(questionRequest, { interaction_9960062f: answer }),
207 ).toBeUndefined();
208 },
209 );
210
211 it("rejects missing or duplicate native option IDs", () => {
212 for (const optionId of ["", "1"]) {
213 const request = {
214 ...questionRequest,
215 options: [questionRequest.options[0]!, { ...questionRequest.options[1]!, optionId }],
216 };
217 expect(extractAntigravityUserInputQuestion(request)).toBeUndefined();
218 expect(
219 makeAntigravityUserInputResponse(request, { interaction_9960062f: "1" }),
220 ).toBeUndefined();
221 }
222 });
223
224 it("bounds question text without changing choice IDs", () => {
225 const request = {
226 ...questionRequest,
227 toolCall: { ...questionRequest.toolCall, title: "Question ".repeat(2_000) },
228 options: [
229 { optionId: "stable-choice", name: "Choice ".repeat(2_000), kind: "allow_once" as const },
230 ],
231 };
232 const question = extractAntigravityUserInputQuestion(request);
233 expect(question?.question.length).toBeLessThanOrEqual(8_000);
234 expect(question?.options[0]?.label.length).toBeLessThanOrEqual(512);
235 expect(question?.options[0]?.value).toBe("stable-choice");
236 });
237});
238
239describe("Antigravity tool results", () => {
240 it("normalizes the captured command and completed output for the existing clients", () => {
241 const initial = parseToolUpdate(commandStarted).toolCall;
242 const completed = parseToolUpdate(commandCompleted).toolCall;
243 const toolCall = normalizeAntigravityToolCall(mergeToolCallState(initial, completed));
244
245 expect(toolCall).toMatchObject({
246 kind: "execute",
247 status: "completed",
248 command: "cat probe.txt",
249 detail: "cat probe.txt",
250 data: {
251 command: "cat probe.txt",
252 cwd: "/workspace",
253 item: {
254 command: "cat probe.txt",
255 cwd: "/workspace",
256 aggregatedOutput: "after\n",
257 exitCode: 0,
258 },
259 },
260 });
261 expect(toolCall.data.rawOutput).not.toHaveProperty("formatted_output");
262 expect(commandCompleted.update.rawOutput.formatted_output).toBe("after\n");
263 });
264
265 it.each([
266 { CommandLine: "pwd", Cwd: "/one" },
267 { CommandLine: "pwd", WorkingDirectory: "/one" },
268 { command_line: "pwd", working_dir: "/one" },
269 { commandLine: "pwd", workingDir: "/one" },
270 { command: "pwd", cwd: "/one" },
271 ])("handles native command input aliases: %j", (rawInput) => {
272 const event = parseToolUpdate({
273 ...commandStarted,
274 update: { ...commandStarted.update, rawInput },
275 });
276 expect(normalizeAntigravityToolCall(event.toolCall)).toMatchObject({
277 command: "pwd",
278 detail: "pwd",
279 data: { item: { command: "pwd", cwd: "/one" } },
280 });
281 });
282
283 it("recovers command fields from history and keeps nonzero exits separate from tool failure", () => {
284 const event = parseToolUpdate({
285 ...commandCompleted,
286 update: {
287 ...commandCompleted.update,
288 rawOutput: {
289 command_line: "test -f missing.txt",
290 working_dir: "/workspace",
291 combined_output: "",
292 exit_code: 1,
293 },
294 },
295 });
296 expect(normalizeAntigravityToolCall(event.toolCall)).toMatchObject({
297 kind: "execute",
298 status: "completed",
299 command: "test -f missing.txt",
300 data: { item: { cwd: "/workspace", aggregatedOutput: "", exitCode: 1 } },
301 });
302 });
303
304 it("bounds both canonical output and retained raw output", () => {
305 const output = `${"output line\n".repeat(20_000)}last line\n`;
306 const event = parseToolUpdate({
307 ...commandCompleted,
308 update: {
309 ...commandCompleted.update,
310 rawOutput: {
311 ...commandCompleted.update.rawOutput,
312 combinedOutput: output,
313 formatted_output: output,
314 },
315 },
316 });
317 const toolCall = normalizeAntigravityToolCall(event.toolCall);
318 expect(toolCall.data).toMatchObject({
319 item: { aggregatedOutput: expect.stringContaining("last line\n") },
320 rawOutput: { combinedOutput: expect.stringContaining("last line\n") },
321 });
322 expect(JSON.stringify(toolCall).length).toBeLessThan(20_000);
323 expect(JSON.stringify(event.rawPayload).length).toBeLessThan(10_000);
324 expect(JSON.stringify(event.rawPayload)).not.toContain("formatted_output");
325 });
326
327 it("removes inline images while preserving valid tool content and the local image path", () => {
328 const inlineImage = "inline-image-bytes".repeat(50_000);
329 const notification = {
330 sessionId: "session-1",
331 update: {
332 sessionUpdate: "tool_call_update",
333 toolCallId: "image-1",
334 status: "completed",
335 rawOutput: { imageName: "result", imagePath: "file:///workspace/brain/result%20image.png" },
336 content: [
337 { type: "content", content: { type: "image", mimeType: "image/png", data: inlineImage } },
338 { type: "content", content: { type: "text", text: "Saved the image." } },
339 { type: "diff", path: "/workspace/note.txt", oldText: "old", newText: "new" },
340 ],
341 },
342 } satisfies EffectAcpSchema.SessionNotification;
343 const normalized = normalizeAntigravitySessionUpdate(notification);
344 expect(isSessionNotification(normalized)).toBe(true);
345 expect(JSON.stringify(normalized)).not.toContain("inline-image-bytes");
346 expect(normalized.update).toMatchObject({
347 content: [
348 { type: "content", content: { type: "text", text: "Saved the image." } },
349 { type: "diff", path: "/workspace/note.txt", oldText: "old", newText: "new" },
350 ],
351 });
352 expect(normalizeAntigravityToolCall(parseToolUpdate(normalized).toolCall).data.imagePath).toBe(
353 "/workspace/brain/result image.png",
354 );
355 });
356
357 it.each([
358 ["/workspace/result.png", "/workspace/result.png"],
359 ["C:\\work\\result.png", "C:\\work\\result.png"],
360 ["file:///C:/work/result.png", "C:/work/result.png"],
361 ["https://example.com/result.png", undefined],
362 ["file://another-host/result.png", undefined],
363 ["data:image/png;base64,AAAA", undefined],
364 ])("only promotes local image references: %s", (imagePath, expected) => {
365 const toolCall = normalizeAntigravityToolCall({
366 toolCallId: "image-1",
367 data: { rawOutput: { imagePath } },
368 });
369 expect(toolCall.data.imagePath).toBe(expected);
370 });
371
372 it("bounds nested tool data and drops image blobs and data URLs", () => {
373 const payload = sanitizeAntigravityToolPayload({
374 rawOutput: {
375 text: "a".repeat(100_000),
376 result: { mimeType: "image/png", blob: "image-blob".repeat(100_000) },
377 image: { type: "image", data: "inline-image".repeat(100_000) },
378 uri: "data:image/png;base64,inline-data-url",
379 },
380 });
381 const serialized = JSON.stringify(payload);
382 expect(serialized.length).toBeLessThan(9_000);
383 expect(serialized).not.toContain("image-blob");
384 expect(serialized).not.toContain("inline-image");
385 expect(serialized).not.toContain("inline-data-url");
386 });
387
388 it("keeps large assistant replies and startup command metadata unchanged", () => {
389 const notifications = [
390 {
391 sessionId: "session-1",
392 update: {
393 sessionUpdate: "agent_message_chunk",
394 content: { type: "text", text: "a".repeat(20_000) },
395 },
396 },
397 {
398 sessionId: "session-1",
399 update: {
400 sessionUpdate: "available_commands_update",
401 availableCommands: [{ name: "plan", description: "Make a plan" }],
402 },
403 },
404 ] satisfies ReadonlyArray<EffectAcpSchema.SessionNotification>;
405 for (const notification of notifications) {
406 expect(normalizeAntigravitySessionUpdate(notification)).toBe(notification);
407 }
408 });
409
410 it("tracks only commands that have passed approval and are still running", () => {
411 const running = normalizeAntigravityToolCall(parseToolUpdate(commandStarted).toolCall);
412 expect(isAntigravityOpenCommand(running)).toBe(true);
413 expect(isAntigravityOpenCommand({ ...running, status: "pending" })).toBe(false);
414 expect(isAntigravityOpenCommand({ ...running, kind: "read" })).toBe(false);
415 const completed = normalizeAntigravityToolCall(
416 mergeToolCallState(running, parseToolUpdate(commandCompleted).toolCall),
417 );
418 expect(isAntigravityOpenCommand(completed)).toBe(false);
419 });
420});
apps/server/src/provider/acp/AntigravityProtocol.ts created+351
......@@ -0,0 +1,351 @@
1import type {
2 ProviderApprovalDecision,
3 ProviderApprovalOption,
4 ProviderUserInputAnswers,
5 UserInputQuestion,
6} from "@t3tools/contracts";
7import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
8import * as Option from "effect/Option";
9import * as Predicate from "effect/Predicate";
10import * as Schema from "effect/Schema";
11import * as EffectAcpSchema from "effect-acp/schema";
12
13import type { AcpToolCallState } from "./AcpRuntimeModel.ts";
14
15const TOOL_TEXT_LIMIT = 8_000;
16const TOOL_TEXT_TRUNCATED = "[Earlier output truncated]\n\n";
17const QUESTION_LABEL_LIMIT = 512;
18
19const NativeToolFields = Schema.Struct({
20 command: Schema.optional(Schema.String),
21 CommandLine: Schema.optional(Schema.String),
22 command_line: Schema.optional(Schema.String),
23 commandLine: Schema.optional(Schema.String),
24 cwd: Schema.optional(Schema.String),
25 Cwd: Schema.optional(Schema.String),
26 WorkingDirectory: Schema.optional(Schema.String),
27 working_dir: Schema.optional(Schema.String),
28 workingDir: Schema.optional(Schema.String),
29 combinedOutput: Schema.optional(Schema.String),
30 combined_output: Schema.optional(Schema.String),
31 exitCode: Schema.optional(Schema.Int),
32 exit_code: Schema.optional(Schema.Int),
33 imagePath: Schema.optional(Schema.String),
34});
35const decodeNativeToolFields = Schema.decodeUnknownOption(NativeToolFields);
36const decodeSingleAnswer = Schema.decodeUnknownOption(
37 Schema.Union([Schema.String, Schema.Tuple([Schema.String])]),
38);
39const decodeToolCallContent = Schema.decodeUnknownOption(EffectAcpSchema.ToolCallContent);
40
41/** Native questions share the permission method, but their choices are not approvals. */
42export function isAntigravityUserInputRequest(
43 request: EffectAcpSchema.RequestPermissionRequest,
44): boolean {
45 return request.toolCall.toolCallId.startsWith("interaction_");
46}
47
48export function selectAntigravityPermissionOptionId(
49 request: EffectAcpSchema.RequestPermissionRequest,
50 decision: ProviderApprovalDecision,
51): string | undefined {
52 if (decision === "cancel" || isAntigravityUserInputRequest(request)) {
53 return undefined;
54 }
55 const kind =
56 decision === "accept" ? "allow_once" : decision === "decline" ? "reject_once" : "allow_always";
57 const option = request.options.find((entry) => entry.kind === kind);
58 return option?.optionId.trim() ? option.optionId : undefined;
59}
60
61/** Copy truncated text so V8 cannot retain the original large string. */
62const SECURITY_WARNING_META_KEY = "agy.security.warning";
63const WARNING_TEXT_LIMIT = 512;
64const decodeSecurityWarning = Schema.decodeUnknownOption(
65 Schema.Struct({
66 title: Schema.optional(Schema.String),
67 message: Schema.optional(Schema.String),
68 }),
69);
70
71/**
72 * The agent marks "Allow Always" on shell and web tools with a prompt injection
73 * warning in `_meta`. Surface it as option text so both clients can show it.
74 */
75export function antigravitySecurityWarning(
76 option: EffectAcpSchema.PermissionOption,
77): string | undefined {
78 const meta = option._meta;
79 if (!Predicate.isObject(meta)) return undefined;
80 const warning = Option.getOrUndefined(decodeSecurityWarning(meta[SECURITY_WARNING_META_KEY]));
81 const text = warning?.message?.trim() || warning?.title?.trim();
82 if (!text) return undefined;
83 return text.length > WARNING_TEXT_LIMIT
84 ? copyBoundedText(`${text.slice(0, WARNING_TEXT_LIMIT - 3)}...`)
85 : text;
86}
87
88/** Only advertise decisions that the native request can honor. */
89export function antigravityApprovalOptions(
90 request: EffectAcpSchema.RequestPermissionRequest,
91): ReadonlyArray<ProviderApprovalOption> {
92 if (isAntigravityUserInputRequest(request)) return [];
93 const options: ProviderApprovalOption[] = [];
94 const optionWithKind = (kind: EffectAcpSchema.PermissionOption["kind"]) =>
95 request.options.find((entry) => entry.kind === kind && entry.optionId.trim());
96 const once = optionWithKind("allow_once");
97 if (once) {
98 options.push({ decision: "accept", label: "Allow once" });
99 }
100 const always = optionWithKind("allow_always");
101 if (always) {
102 const warning = antigravitySecurityWarning(always);
103 options.push({
104 decision: "acceptForSession",
105 label: "Allow for this thread",
106 ...(warning ? { warning } : {}),
107 });
108 }
109 if (optionWithKind("reject_once")) {
110 options.push({ decision: "decline", label: "Deny" });
111 }
112 options.push({ decision: "cancel", label: "Cancel" });
113 return options;
114}
115
116function copyBoundedText(text: string): string {
117 return Buffer.from(text, "utf16le").toString("utf16le");
118}
119
120function questionLabel(option: EffectAcpSchema.PermissionOption): string {
121 const label = option.name.trim() || option.optionId;
122 return label.length > QUESTION_LABEL_LIMIT
123 ? copyBoundedText(`${label.slice(0, QUESTION_LABEL_LIMIT - 3)}...`)
124 : label;
125}
126
127export function extractAntigravityUserInputQuestion(
128 request: EffectAcpSchema.RequestPermissionRequest,
129): UserInputQuestion | undefined {
130 if (!isAntigravityUserInputRequest(request) || request.options.length === 0) {
131 return undefined;
132 }
133 const ids = new Set<string>();
134 for (const option of request.options) {
135 if (!option.optionId.trim() || ids.has(option.optionId)) {
136 return undefined;
137 }
138 ids.add(option.optionId);
139 }
140 const question = request.toolCall.title?.trim() || "Choose an option.";
141 return {
142 id: request.toolCall.toolCallId,
143 header: "Question",
144 question:
145 question.length > TOOL_TEXT_LIMIT
146 ? copyBoundedText(`${question.slice(0, TOOL_TEXT_LIMIT - 3)}...`)
147 : question,
148 multiSelect: false,
149 allowCustomAnswer: false,
150 options: request.options.map((option) => ({
151 value: option.optionId,
152 label: questionLabel(option),
153 description: questionLabel(option),
154 })),
155 };
156}
157
158/** Return undefined for an invalid answer so the adapter keeps the question open. */
159export function makeAntigravityUserInputResponse(
160 request: EffectAcpSchema.RequestPermissionRequest,
161 answers: ProviderUserInputAnswers,
162): EffectAcpSchema.RequestPermissionResponse | undefined {
163 if (extractAntigravityUserInputQuestion(request) === undefined) {
164 return undefined;
165 }
166 const answer = Option.getOrUndefined(decodeSingleAnswer(answers[request.toolCall.toolCallId]));
167 const value = typeof answer === "string" ? answer : answer?.[0];
168 if (value === undefined) {
169 return undefined;
170 }
171 const exact = request.options.find((option) => option.optionId === value);
172 if (exact) {
173 return { outcome: { outcome: "selected", optionId: exact.optionId } };
174 }
175 const matchingLabels = request.options.filter((option) => questionLabel(option) === value);
176 const option = matchingLabels.length === 1 ? matchingLabels[0] : undefined;
177 return option ? { outcome: { outcome: "selected", optionId: option.optionId } } : undefined;
178}
179
180function boundText(text: string, limit = TOOL_TEXT_LIMIT): string {
181 return text.length <= limit
182 ? text
183 : copyBoundedText(`${TOOL_TEXT_TRUNCATED}${text.slice(-limit)}`);
184}
185
186interface ToolPayloadBudget {
187 nodes: number;
188 text: number;
189}
190
191function sanitizeToolValue(value: unknown, budget: ToolPayloadBudget, depth: number): unknown {
192 if (depth > 12 || budget.nodes-- <= 0) {
193 return undefined;
194 }
195 if (typeof value === "string") {
196 if (/^data:image\//i.test(value) || budget.text <= 0) {
197 return undefined;
198 }
199 const text = boundText(value, Math.min(TOOL_TEXT_LIMIT, budget.text));
200 budget.text -= text.length;
201 return text;
202 }
203 if (Array.isArray(value)) {
204 const result: unknown[] = [];
205 for (const entry of value) {
206 if (budget.nodes <= 0) break;
207 const sanitized = sanitizeToolValue(entry, budget, depth + 1);
208 if (sanitized !== undefined) result.push(sanitized);
209 }
210 return result;
211 }
212 if (!Predicate.isObject(value)) {
213 return value;
214 }
215 const entries: Array<readonly [string, unknown]> = [];
216 for (const [key, entry] of Object.entries(value)) {
217 if (budget.nodes <= 0) break;
218 if (
219 (value.type === "image" && (key === "data" || key === "blob")) ||
220 (key === "blob" &&
221 typeof value.mimeType === "string" &&
222 value.mimeType.startsWith("image/")) ||
223 ((key === "formatted_output" || key === "formattedOutput") &&
224 (entry === value.combinedOutput || entry === value.combined_output))
225 ) {
226 continue;
227 }
228 const sanitized = sanitizeToolValue(entry, budget, depth + 1);
229 if (sanitized !== undefined) entries.push([key, sanitized]);
230 }
231 return Object.fromEntries(entries);
232}
233
234/** Bound both retained raw events and display data before they enter the event stream. */
235export function sanitizeAntigravityToolPayload(payload: unknown): unknown {
236 return sanitizeToolValue(payload, { nodes: 512, text: 64_000 }, 0);
237}
238
239/** The runtime uses this before it retains tool state or dispatches raw callbacks. */
240export function normalizeAntigravitySessionUpdate(
241 notification: EffectAcpSchema.SessionNotification,
242): EffectAcpSchema.SessionNotification {
243 const update = notification.update;
244 if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
245 return notification;
246 }
247 const contentBudget = { nodes: 512, text: 32_000 };
248 const content = update.content?.flatMap((entry) => {
249 const decoded = Option.getOrUndefined(
250 decodeToolCallContent(sanitizeToolValue(entry, contentBudget, 0)),
251 );
252 return decoded === undefined ? [] : [decoded];
253 });
254 const meta = sanitizeAntigravityToolPayload(update._meta);
255 return {
256 ...notification,
257 update: {
258 ...update,
259 ...(typeof update.title === "string" ? { title: boundText(update.title) } : {}),
260 ...(update.rawInput !== undefined
261 ? { rawInput: sanitizeAntigravityToolPayload(update.rawInput) }
262 : {}),
263 ...(update.rawOutput !== undefined
264 ? { rawOutput: sanitizeAntigravityToolPayload(update.rawOutput) }
265 : {}),
266 ...(update.content !== undefined ? { content: content ?? [] } : {}),
267 ...(update._meta !== undefined ? { _meta: Predicate.isObject(meta) ? meta : null } : {}),
268 },
269 };
270}
271
272function localImagePath(imagePath: string | undefined): string | undefined {
273 if (!imagePath || imagePath.length > TOOL_TEXT_LIMIT) {
274 return undefined;
275 }
276 const path = imagePath.trim();
277 if (!isWorkspaceImagePreviewPath(path)) {
278 return undefined;
279 }
280 if (/^file:\/\//i.test(path)) {
281 try {
282 const url = new URL(path);
283 if (url.hostname && url.hostname !== "localhost") return undefined;
284 const pathname = decodeURIComponent(url.pathname);
285 return /^\/[a-z]:\//i.test(pathname) ? pathname.slice(1) : pathname;
286 } catch {
287 return undefined;
288 }
289 }
290 return /^[a-z][a-z\d+.-]*:/i.test(path) && !/^[a-z]:[\\/]/i.test(path) ? undefined : path;
291}
292
293export function normalizeAntigravityToolCall(toolCall: AcpToolCallState): AcpToolCallState {
294 const input = Option.getOrUndefined(decodeNativeToolFields(toolCall.data.rawInput));
295 const output = Option.getOrUndefined(decodeNativeToolFields(toolCall.data.rawOutput));
296 const nativeCommand =
297 input?.CommandLine ??
298 input?.command_line ??
299 input?.commandLine ??
300 input?.command ??
301 output?.commandLine ??
302 output?.command_line ??
303 toolCall.command;
304 const command = nativeCommand?.trim() ? boundText(nativeCommand.trim()) : undefined;
305 const nativeCwd =
306 input?.Cwd ??
307 input?.WorkingDirectory ??
308 input?.working_dir ??
309 input?.workingDir ??
310 input?.cwd ??
311 output?.workingDir ??
312 output?.working_dir;
313 const cwd = nativeCwd?.trim() ? boundText(nativeCwd.trim()) : undefined;
314 const nativeOutput = output?.combinedOutput ?? output?.combined_output;
315 const aggregatedOutput = nativeOutput === undefined ? undefined : boundText(nativeOutput);
316 const exitCode = output?.exitCode ?? output?.exit_code;
317 const imagePath = localImagePath(output?.imagePath);
318 const sanitizedData = sanitizeAntigravityToolPayload(toolCall.data);
319 const data: Record<string, unknown> = Predicate.isObject(sanitizedData) ? sanitizedData : {};
320 const kind = toolCall.kind ?? (command !== undefined ? "execute" : undefined);
321 if (kind !== undefined) data.kind = kind;
322 if (command !== undefined) data.command = command;
323 if (cwd !== undefined) data.cwd = cwd;
324 if (imagePath !== undefined) data.imagePath = imagePath;
325 if (kind === "execute") {
326 data.item = {
327 ...(Predicate.isObject(data.item) ? data.item : {}),
328 ...(command !== undefined ? { command } : {}),
329 ...(cwd !== undefined ? { cwd } : {}),
330 ...(aggregatedOutput !== undefined ? { aggregatedOutput } : {}),
331 ...(exitCode !== undefined ? { exitCode } : {}),
332 };
333 }
334 return {
335 ...toolCall,
336 ...(kind !== undefined ? { kind } : {}),
337 ...(command !== undefined ? { command } : {}),
338 ...(toolCall.title !== undefined ? { title: boundText(toolCall.title) } : {}),
339 ...(command !== undefined
340 ? { detail: command }
341 : toolCall.detail !== undefined
342 ? { detail: boundText(toolCall.detail) }
343 : {}),
344 data,
345 };
346}
347
348/** Only commands still running after end_turn become background tasks. */
349export function isAntigravityOpenCommand(toolCall: AcpToolCallState): boolean {
350 return toolCall.kind === "execute" && toolCall.status === "inProgress";
351}
apps/server/src/provider/acp/AntigravitySessionFiles.ts created+43
......@@ -0,0 +1,43 @@
1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Path from "effect/Path";
4import * as Schema from "effect/Schema";
5
6const isNativeSessionId = Schema.is(Schema.String.check(Schema.isUUID(4)));
7const decodeSessionMetadata = Schema.decodeEffect(
8 Schema.fromJsonString(Schema.Struct({ cwd: Schema.String })),
9);
10
11/** Call after the process closes. The unique temporary cwd proves which session we own. */
12export const removeAntigravitySessionFiles = Effect.fn("removeAntigravitySessionFiles")(
13 function* (input: {
14 readonly profileDirectory: string;
15 readonly sessionId: string | undefined;
16 readonly cwd: string;
17 }) {
18 if (input.sessionId === undefined || !isNativeSessionId(input.sessionId)) {
19 return;
20 }
21 const fs = yield* FileSystem.FileSystem;
22 const path = yield* Path.Path;
23 const acpDirectory = path.join(input.profileDirectory, "antigravity-acp");
24 const base = path.join(acpDirectory, "conversations", input.sessionId);
25 if (!(yield* fs.exists(`${base}.meta`))) {
26 return;
27 }
28 const metadata = yield* fs
29 .readFileString(`${base}.meta`)
30 .pipe(Effect.flatMap(decodeSessionMetadata));
31 if (metadata.cwd !== input.cwd) {
32 return;
33 }
34 for (const suffix of [".db", ".db-wal", ".db-shm", ".db-journal", ".meta"]) {
35 yield* fs.remove(`${base}${suffix}`, { force: true });
36 }
37 yield* fs.remove(path.join(acpDirectory, "brain", input.sessionId), {
38 recursive: true,
39 force: true,
40 });
41 },
42 Effect.catch(() => Effect.logWarning("Could not remove temporary Antigravity session files.")),
43);
apps/server/src/provider/antigravityAuthSupport.test.ts created+516
......@@ -0,0 +1,516 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off
2import * as NodeChildProcess from "node:child_process";
3
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { ProviderInstanceId } from "@t3tools/contracts";
6import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
7import { describe, expect, it } from "@effect/vitest";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Path from "effect/Path";
11import * as Result from "effect/Result";
12import * as Schema from "effect/Schema";
13import * as Stream from "effect/Stream";
14import * as Ndjson from "effect/unstable/encoding/Ndjson";
15import * as ChildProcess from "effect/unstable/process/ChildProcess";
16import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
17import * as AcpErrors from "effect-acp/errors";
18
19import {
20 ANTIGRAVITY_AUTH_STDOUT_PREFIX,
21 ANTIGRAVITY_PERSONAL_AUTH,
22 ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE,
23 type AntigravityAuthConfig,
24 antigravityAuthConfigIssue,
25 type AntigravityProfile,
26 antigravityProfileSettings,
27 buildAntigravityAcpSpawnInput,
28 isAntigravitySignInRequiredError,
29 makeAntigravityStdoutTransform,
30 parseAntigravityAuthorizationUrl,
31 prepareAntigravityProfile,
32 resolveAntigravityProfileDirectory,
33} from "./antigravityAuthSupport.ts";
34
35const authorizationUrl =
36 "https://accounts.google.com/o/oauth2/v2/auth?response_type=code" +
37 "&client_id=test-client&redirect_uri=http%3A%2F%2F127.0.0.1%3A46353%2F" +
38 "&state=test-opaque-state&code_challenge=test-challenge&code_challenge_method=S256";
39const authLine = `${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\n`;
40const encode = (text: string) => new TextEncoder().encode(text);
41const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
42
43const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
44
45describe("Antigravity process environment", () => {
46 const profile: AntigravityProfile = {
47 platform: "linux",
48 geminiHome: "/t3/userdata/providers/antigravity/profile",
49 acpDirectory: "/t3/userdata/providers/antigravity/profile/antigravity-acp",
50 tokenPath: "/t3/userdata/providers/antigravity/profile/antigravity-acp/acp_token.json",
51 browserCommand: "managed-browser-helper",
52 };
53
54 it("isolates the profile and harness after merging overrides without changing the base environment", () => {
55 const baseEnv = {
56 HOME: "/home/developer",
57 PATH: "/usr/bin",
58 GEMINI_API_KEY: "do-not-use-api-billing",
59 google_api_key: "case-insensitive-api-key",
60 GOOGLE_CLOUD_PROJECT: "do-not-use-project",
61 GOOGLE_CLOUD_LOCATION: "do-not-use-location",
62 GOOGLE_APPLICATION_CREDENTIALS: "/credentials.json",
63 GOOGLE_CLOUD_QUOTA_PROJECT: "do-not-use-quota-project",
64 GOOGLE_GENAI_USE_VERTEXAI: "true",
65 AGY_ACP_CCPA_PROJECT: "do-not-use-consumer-project",
66 AGY_ACP_ENABLE_OAUTH: "1",
67 GEMINI_HOME: "/shared-gemini-home",
68 gemini_home: "/alias-shared-home",
69 AGY_ACP_FORCE_FILE_STORAGE: "0",
70 ANTIGRAVITY_HARNESS_PATH: "/wrong-version/harness",
71 BROWSER: "open-real-browser",
72 browser: "another-real-browser",
73 PYTHONUNBUFFERED: "0",
74 ELECTRON_RUN_AS_NODE: "0",
75 CUSTOM_SETTING: "keep-this",
76 };
77 const original = { ...baseEnv };
78 const spawn = buildAntigravityAcpSpawnInput({
79 installation: { executablePath: "/release/acp", harnessPath: "/release/harness" },
80 profile,
81 cwd: "/project",
82 baseEnv,
83 });
84
85 expect(baseEnv).toEqual(original);
86 expect(spawn).toEqual({
87 command: "/release/acp",
88 args: ["--uid="],
89 cwd: "/project",
90 extendEnv: false,
91 env: {
92 HOME: "/home/developer",
93 PATH: "/usr/bin",
94 CUSTOM_SETTING: "keep-this",
95 GEMINI_HOME: profile.geminiHome,
96 AGY_ACP_FORCE_FILE_STORAGE: "1",
97 ANTIGRAVITY_HARNESS_PATH: "/release/harness",
98 BROWSER: profile.browserCommand,
99 PYTHONUNBUFFERED: "1",
100 ELECTRON_RUN_AS_NODE: "1",
101 },
102 });
103 });
104
105 it("passes only the configured method's credential and keeps the GCP pair out of the environment", () => {
106 const baseEnv = { PATH: "/usr/bin", GOOGLE_API_KEY: "ambient-key" };
107 const spawnFor = (auth: AntigravityAuthConfig) =>
108 buildAntigravityAcpSpawnInput({
109 installation: { executablePath: "/release/acp", harnessPath: "/release/harness" },
110 profile,
111 cwd: "/project",
112 baseEnv,
113 auth,
114 }).env ?? {};
115
116 const geminiKey = spawnFor({
117 authMethod: "gemini-api-key",
118 apiKey: "gemini-secret",
119 gcpProject: "proj",
120 gcpLocation: "us-central1",
121 });
122 expect(geminiKey.GEMINI_API_KEY).toBe("gemini-secret");
123 expect(geminiKey.GOOGLE_API_KEY).toBeUndefined();
124 expect(geminiKey.GOOGLE_CLOUD_PROJECT).toBeUndefined();
125
126 const vertexKey = spawnFor({
127 authMethod: "agent-platform",
128 apiKey: "vertex-secret",
129 gcpProject: "",
130 gcpLocation: "",
131 });
132 expect(vertexKey.GOOGLE_API_KEY).toBe("vertex-secret");
133 expect(vertexKey.GEMINI_API_KEY).toBeUndefined();
134
135 const business = spawnFor({
136 authMethod: "oauth-business",
137 apiKey: "ignored",
138 gcpProject: "proj",
139 gcpLocation: "us-central1",
140 });
141 expect(business.GEMINI_API_KEY).toBeUndefined();
142 expect(business.GOOGLE_API_KEY).toBeUndefined();
143 });
144
145 it("writes the auth method and GCP block into the agent's settings.json", () => {
146 expect(
147 decodeJson(
148 antigravityProfileSettings({
149 authMethod: "oauth-business",
150 apiKey: "never-written",
151 gcpProject: "proj",
152 gcpLocation: "us-central1",
153 }),
154 ),
155 ).toEqual({
156 auth: { type: "oauth-business" },
157 gcp: { project: "proj", location: "us-central1" },
158 });
159 // The agent's logout reads auth.type to clear only that method's token.
160 expect(decodeJson(antigravityProfileSettings(ANTIGRAVITY_PERSONAL_AUTH))).toEqual({
161 auth: { type: "oauth-personal" },
162 });
163 });
164
165 it("names the missing credential for each method", () => {
166 expect(antigravityAuthConfigIssue(ANTIGRAVITY_PERSONAL_AUTH)).toBeNull();
167 expect(
168 antigravityAuthConfigIssue({ ...ANTIGRAVITY_PERSONAL_AUTH, authMethod: "gemini-api-key" }),
169 ).toContain("API key");
170 expect(
171 antigravityAuthConfigIssue({
172 ...ANTIGRAVITY_PERSONAL_AUTH,
173 authMethod: "oauth-business",
174 gcpProject: "proj",
175 }),
176 ).toContain("location");
177 expect(
178 antigravityAuthConfigIssue({
179 ...ANTIGRAVITY_PERSONAL_AUTH,
180 authMethod: "agent-platform",
181 gcpProject: "proj",
182 gcpLocation: "us-central1",
183 }),
184 ).toBeNull();
185 });
186
187 it("uses the registry launch arguments for each supported host platform", () => {
188 for (const platform of ["linux", "darwin", "win32"] as const) {
189 const spawn = buildAntigravityAcpSpawnInput({
190 installation: { executablePath: "/release/acp", harnessPath: "/release/harness" },
191 profile: { ...profile, platform },
192 cwd: "/project",
193 baseEnv: {},
194 });
195 expect(spawn.args).toEqual(platform === "linux" ? ["--uid="] : []);
196 }
197 });
198
199 it("keeps accounts separate even when instance IDs differ only by case", () => {
200 const first = resolveAntigravityProfileDirectory(
201 "/userdata",
202 ProviderInstanceId.make("antigravity"),
203 );
204 const second = resolveAntigravityProfileDirectory(
205 "/userdata",
206 ProviderInstanceId.make("Antigravity"),
207 );
208 expect(first.toLowerCase()).not.toBe(second.toLowerCase());
209 expect(
210 resolveAntigravityProfileDirectory("/userdata", ProviderInstanceId.make("antigravity")),
211 ).toBe(first);
212 });
213});
214
215describe("Antigravity authorization URL", () => {
216 it.effect("returns the official Google URL and its owned loopback target", () =>
217 Effect.gen(function* () {
218 expect(yield* parseAntigravityAuthorizationUrl(authorizationUrl)).toEqual({
219 authorizationUrl,
220 redirectUri: "http://127.0.0.1:46353/",
221 state: "test-opaque-state",
222 });
223 }),
224 );
225
226 it.effect(
227 "rejects other origins, ambiguous state, and non-loopback redirects without retaining them",
228 () =>
229 Effect.gen(function* () {
230 const invalidUrls = [
231 authorizationUrl.replace("https:", "http:"),
232 authorizationUrl.replace("accounts.google.com", "accounts.google.com.example.invalid"),
233 authorizationUrl.replace("accounts.google.com", "secret@accounts.google.com"),
234 authorizationUrl.replace("/o/oauth2/v2/auth", "/another-path"),
235 `${authorizationUrl}#secret-fragment`,
236 `${authorizationUrl}&state=another-state`,
237 authorizationUrl.replace("test-opaque-state", ""),
238 authorizationUrl.replace("test-opaque-state", "opaque%0astate"),
239 authorizationUrl.replace("127.0.0.1", "localhost"),
240 authorizationUrl.replace("127.0.0.1", "169.254.169.254"),
241 authorizationUrl.replace("46353", "80"),
242 authorizationUrl.replace("46353", "70000"),
243 authorizationUrl.replace("46353%2F", "46353%2Fother"),
244 authorizationUrl.replace("response_type=code", "response_type=token"),
245 "not a URL containing secret-code",
246 ];
247 for (const invalidUrl of invalidUrls) {
248 const result = yield* parseAntigravityAuthorizationUrl(invalidUrl).pipe(Effect.result);
249 expect(Result.isFailure(result)).toBe(true);
250 if (Result.isSuccess(result)) continue;
251 expect(result.failure._tag).toBe("AcpTransportError");
252 expect(encodeUnknownJson(result.failure)).not.toContain("test-opaque-state");
253 expect(encodeUnknownJson(result.failure)).not.toContain("secret-code");
254 expect(encodeUnknownJson(result.failure)).not.toContain(invalidUrl);
255 }
256 }),
257 );
258});
259
260describe("Antigravity sign-in errors", () => {
261 it("recognizes native auth-required errors and the blocked-login transport error", () => {
262 expect(isAntigravitySignInRequiredError(AcpErrors.AcpRequestError.authRequired())).toBe(true);
263 expect(
264 isAntigravitySignInRequiredError(
265 new AcpErrors.AcpTransportError({
266 detail: ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE,
267 cause: undefined,
268 }),
269 ),
270 ).toBe(true);
271 });
272
273 it("does not treat other failures or arbitrary text as missing authentication", () => {
274 const otherErrors = [
275 new AcpErrors.AcpTransportError({ detail: "The process stopped.", cause: undefined }),
276 AcpErrors.AcpRequestError.internalError(ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE),
277 new Error(ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE),
278 { detail: ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE },
279 ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE,
280 undefined,
281 ];
282 for (const error of otherErrors) expect(isAntigravitySignInRequiredError(error)).toBe(false);
283 });
284});
285
286describe("Antigravity stdout compatibility", () => {
287 it.effect(
288 "handles fragmented login lines between JSON messages without changing protocol bytes",
289 () =>
290 Effect.gen(function* () {
291 const urls: string[] = [];
292 const jsonBefore = '{"jsonrpc":"2.0","id":1,"result":{}}\r\n';
293 const jsonAfter = '{"jsonrpc":"2.0","id":2,"result":{"text":"café"}}\n';
294 const chunks = [
295 encode(`${jsonBefore}${ANTIGRAVITY_AUTH_STDOUT_PREFIX.slice(0, 7)}`),
296 encode(ANTIGRAVITY_AUTH_STDOUT_PREFIX.slice(7)),
297 encode(authorizationUrl.slice(0, 40)),
298 encode(`${authorizationUrl.slice(40)}\r`),
299 encode(`\n${jsonAfter}`),
300 ];
301 const result = yield* makeAntigravityStdoutTransform({
302 onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)),
303 })(Stream.fromIterable(chunks)).pipe(Stream.decodeText(), Stream.mkString);
304 expect(result).toBe(`${jsonBefore}${jsonAfter}`);
305 expect(urls).toEqual([authorizationUrl]);
306 }),
307 );
308
309 it.effect("handles an auth line without a final newline", () =>
310 Effect.gen(function* () {
311 const urls: string[] = [];
312 const result = yield* makeAntigravityStdoutTransform({
313 onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)),
314 })(Stream.make(encode(authLine.slice(0, -1)))).pipe(Stream.runCollect);
315 expect(result).toEqual([]);
316 expect(urls).toEqual([authorizationUrl]);
317 }),
318 );
319
320 it.effect("ends normal work with a safe sign-in error instead of waiting for OAuth", () =>
321 Effect.gen(function* () {
322 const result = yield* makeAntigravityStdoutTransform()(Stream.make(encode(authLine))).pipe(
323 Stream.runDrain,
324 Effect.result,
325 );
326 expect(Result.isFailure(result)).toBe(true);
327 if (Result.isSuccess(result)) return;
328 expect(result.failure).toMatchObject({
329 _tag: "AcpTransportError",
330 detail: ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE,
331 });
332 expect(encodeUnknownJson(result.failure)).not.toContain(authorizationUrl);
333 expect(encodeUnknownJson(result.failure)).not.toContain("test-opaque-state");
334 }),
335 );
336
337 it.effect("preserves typed errors from the flow owner", () =>
338 Effect.gen(function* () {
339 const failure = new AcpErrors.AcpTransportError({
340 detail: "This sign-in flow has expired.",
341 cause: undefined,
342 });
343 const result = yield* makeAntigravityStdoutTransform({
344 onAuthorizationUrl: () => Effect.fail(failure),
345 })(Stream.make(encode(authLine))).pipe(Stream.runDrain, Effect.result);
346 expect(Result.isFailure(result)).toBe(true);
347 if (Result.isSuccess(result)) return;
348 expect(result.failure).toBe(failure);
349 }),
350 );
351
352 it.effect("does not suppress malformed protocol data or similar login messages", () =>
353 Effect.gen(function* () {
354 const unrelated = [
355 "this is not JSON\n",
356 `${ANTIGRAVITY_AUTH_STDOUT_PREFIX.toLowerCase()}${authorizationUrl}\n`,
357 ` ${authLine}`,
358 ];
359 for (const line of unrelated) {
360 const transform = makeAntigravityStdoutTransform();
361 const output = yield* transform(Stream.make(encode(line))).pipe(
362 Stream.decodeText(),
363 Stream.mkString,
364 );
365 expect(output).toBe(line);
366 const decoded = yield* transform(Stream.make(encode(line))).pipe(
367 Stream.pipeThroughChannel(Ndjson.decode()),
368 Stream.runDrain,
369 Effect.result,
370 );
371 expect(Result.isFailure(decoded)).toBe(true);
372 }
373 }),
374 );
375
376 it.effect("bounds unfinished protocol lines", () =>
377 Effect.gen(function* () {
378 const result = yield* makeAntigravityStdoutTransform()(
379 Stream.make(new Uint8Array(16 * 1024 * 1024), encode("x")),
380 ).pipe(Stream.runDrain, Effect.result);
381 expect(Result.isFailure(result)).toBe(true);
382 if (Result.isSuccess(result)) return;
383 expect(result.failure).toMatchObject({
384 _tag: "AcpTransportError",
385 detail: "Antigravity sent a protocol line that is too large.",
386 });
387 }),
388 );
389});
390
391it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => {
392 it.effect("preflights the no-browser helper and creates private directories only", () =>
393 Effect.gen(function* () {
394 const fs = yield* FileSystem.FileSystem;
395 const path = yield* Path.Path;
396 const temporaryDirectory = yield* fs.makeTempDirectoryScoped();
397 const profile = yield* prepareAntigravityProfile({
398 profileDirectory: path.join(temporaryDirectory, "profile"),
399 });
400
401 expect(profile.geminiHome).toBe(path.join(temporaryDirectory, "profile"));
402 expect(yield* fs.exists(profile.acpDirectory)).toBe(true);
403 expect(yield* fs.exists(profile.tokenPath)).toBe(false);
404 if ((yield* HostProcessPlatform) !== "win32") {
405 expect((yield* fs.stat(profile.geminiHome)).mode & 0o777).toBe(0o700);
406 expect((yield* fs.stat(profile.acpDirectory)).mode & 0o777).toBe(0o700);
407 }
408
409 yield* fs.writeFileString(profile.tokenPath, "synthetic-token-fixture");
410 yield* prepareAntigravityProfile({ profileDirectory: profile.geminiHome });
411 expect(yield* fs.readFileString(profile.tokenPath)).toBe("synthetic-token-fixture");
412 }),
413 );
414
415 it.effect("rewrites the GCP block on every launch and never stores the API key", () =>
416 Effect.gen(function* () {
417 const fs = yield* FileSystem.FileSystem;
418 const path = yield* Path.Path;
419 const temporaryDirectory = yield* fs.makeTempDirectoryScoped();
420 const profile = yield* prepareAntigravityProfile({
421 profileDirectory: temporaryDirectory,
422 auth: {
423 authMethod: "agent-platform",
424 apiKey: "vertex-secret",
425 gcpProject: "proj",
426 gcpLocation: "us-central1",
427 },
428 });
429 const settingsPath = path.join(profile.acpDirectory, "settings.json");
430 const first = yield* fs.readFileString(settingsPath);
431 expect(decodeJson(first)).toEqual({
432 auth: { type: "agent-platform" },
433 gcp: { project: "proj", location: "us-central1" },
434 });
435 expect(first).not.toContain("vertex-secret");
436
437 yield* prepareAntigravityProfile({ profileDirectory: temporaryDirectory });
438 expect(decodeJson(yield* fs.readFileString(settingsPath))).toEqual({
439 auth: { type: "oauth-personal" },
440 });
441 }),
442 );
443
444 it.effect("keeps the browser helper successful when cancellation closes stderr", () =>
445 Effect.gen(function* () {
446 const fs = yield* FileSystem.FileSystem;
447 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
448 const temporaryDirectory = yield* fs.makeTempDirectoryScoped();
449 let helperCommand: ChildProcess.StandardCommand | undefined;
450 yield* prepareAntigravityProfile({ profileDirectory: temporaryDirectory }).pipe(
451 Effect.provideService(
452 ChildProcessSpawner.ChildProcessSpawner,
453 ChildProcessSpawner.make((command) => {
454 if (ChildProcess.isStandardCommand(command)) helperCommand = command;
455 return spawner.spawn(command);
456 }),
457 ),
458 );
459 expect(helperCommand).toBeDefined();
460 if (!helperCommand) return;
461 const command = helperCommand;
462 const child = yield* Effect.acquireRelease(
463 Effect.sync(() =>
464 NodeChildProcess.spawn(command.command, command.args, {
465 env: { ...command.options.env },
466 stdio: ["ignore", "ignore", "pipe"],
467 }),
468 ),
469 (process) => Effect.sync(() => void process.kill()),
470 );
471 child.stderr?.destroy();
472 const exitCode = yield* Effect.promise(
473 () =>
474 new Promise<number | null>((resolve, reject) => {
475 child.once("error", reject);
476 child.once("exit", resolve);
477 }),
478 );
479 expect(exitCode).toBe(0);
480 }),
481 );
482
483 it.effect("fails before creating a profile when the helper cannot start", () =>
484 Effect.gen(function* () {
485 const fs = yield* FileSystem.FileSystem;
486 const path = yield* Path.Path;
487 const temporaryDirectory = yield* fs.makeTempDirectoryScoped();
488 const profileDirectory = path.join(temporaryDirectory, "unused-profile");
489 const result = yield* prepareAntigravityProfile({
490 profileDirectory,
491 runtimeExecutablePath: path.join(temporaryDirectory, "missing-runtime"),
492 }).pipe(Effect.result);
493
494 expect(Result.isFailure(result)).toBe(true);
495 expect(yield* fs.exists(profileDirectory)).toBe(false);
496 }),
497 );
498
499 it.effect("rejects Python BROWSER delimiter collisions before starting a helper", () =>
500 Effect.gen(function* () {
501 const fs = yield* FileSystem.FileSystem;
502 const path = yield* Path.Path;
503 const temporaryDirectory = yield* fs.makeTempDirectoryScoped();
504 for (const platform of ["linux", "win32"] as const) {
505 const profileDirectory = path.join(temporaryDirectory, platform);
506 const result = yield* prepareAntigravityProfile({
507 profileDirectory,
508 platform,
509 runtimeExecutablePath: platform === "win32" ? "C:/bad;path/node.exe" : "/bad:path/node",
510 }).pipe(Effect.result);
511 expect(Result.isFailure(result)).toBe(true);
512 expect(yield* fs.exists(profileDirectory)).toBe(false);
513 }
514 }),
515 );
516});
apps/server/src/provider/antigravityAuthSupport.ts created+459
......@@ -0,0 +1,459 @@
1import * as NodeCrypto from "node:crypto";
2// @effect-diagnostics-next-line nodeBuiltinImport:off - resolveAntigravityProfileDirectory is a pure sync helper, so it cannot use the Path service.
3import * as NodePath from "node:path";
4
5import type { AntigravityAuthMethod, ProviderInstanceId } from "@t3tools/contracts";
6import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import type * as PlatformError from "effect/PlatformError";
11import * as Schema from "effect/Schema";
12import * as Stream from "effect/Stream";
13import * as ChildProcess from "effect/unstable/process/ChildProcess";
14import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
15import * as AcpErrors from "effect-acp/errors";
16
17import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
18import type { AcpSpawnInput } from "./acp/AcpSessionRuntime.ts";
19
20export const ANTIGRAVITY_AUTH_STDOUT_PREFIX =
21 "Open the following link to authenticate the ACP server: ";
22export const ANTIGRAVITY_AUTH_BROWSER_MARKER = "__T3_ANTIGRAVITY_AUTH_URL__";
23export const ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE =
24 "Sign in to Antigravity in Settings before you continue.";
25
26const maxAuthorizationUrlLength = 16_384;
27const maxStdoutLineBytes = 16 * 1024 * 1024;
28const authPrefixBytes = new TextEncoder().encode(ANTIGRAVITY_AUTH_STDOUT_PREFIX);
29const decodeUrl = Schema.decodeUnknownEffect(Schema.URLFromString);
30const ProfileSettingsFile = Schema.Struct({
31 auth: Schema.Struct({ type: Schema.String }),
32 gcp: Schema.optional(
33 Schema.Struct({
34 project: Schema.optional(Schema.String),
35 location: Schema.optional(Schema.String),
36 }),
37 ),
38});
39const encodeProfileSettings = Schema.encodeSync(Schema.fromJsonString(ProfileSettingsFile));
40const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError);
41const isAcpTransportError = Schema.is(AcpErrors.AcpTransportError);
42
43// Python splits BROWSER on the platform path separator before it parses quotes.
44// Keep this source free of both colons and semicolons. EPIPE must still exit 0
45// so Python does not fall back to an OS browser after cancellation.
46const browserHelperSource =
47 `process.stderr.on("error",()=>process.exit(0)).write(` +
48 `"${ANTIGRAVITY_AUTH_BROWSER_MARKER}"+JSON.stringify(process.argv[1])+"\\n",` +
49 `()=>process.exit(0))`;
50const browserPreflightUrl = "https://example.invalid/t3-antigravity-browser-preflight";
51
52const removedEnvironmentKeys = new Set([
53 "GEMINI_API_KEY",
54 "GOOGLE_API_KEY",
55 "GOOGLE_APPLICATION_CREDENTIALS",
56 "GOOGLE_CLOUD_PROJECT",
57 "GOOGLE_CLOUD_LOCATION",
58 "GOOGLE_CLOUD_QUOTA_PROJECT",
59 "GOOGLE_GENAI_USE_VERTEXAI",
60 "GCLOUD_PROJECT",
61 "CLOUDSDK_CORE_PROJECT",
62 "AGY_ACP_CCPA_PROJECT",
63 "AGY_ACP_ENABLE_OAUTH",
64 "GEMINI_HOME",
65 "AGY_ACP_FORCE_FILE_STORAGE",
66 "ANTIGRAVITY_HARNESS_PATH",
67 "BROWSER",
68 "PYTHONUNBUFFERED",
69 "ELECTRON_RUN_AS_NODE",
70]);
71
72export interface AntigravityProfile {
73 readonly platform: NodeJS.Platform;
74 readonly geminiHome: string;
75 readonly acpDirectory: string;
76 readonly tokenPath: string;
77 readonly browserCommand: string;
78}
79
80/**
81 * Credentials for the non-personal ACP auth methods. The agent reads the API
82 * key from its environment and the GCP project and location from
83 * `settings.json` in the profile. Empty strings mean "not set".
84 */
85export interface AntigravityAuthConfig {
86 readonly authMethod: AntigravityAuthMethod;
87 readonly apiKey: string;
88 readonly gcpProject: string;
89 readonly gcpLocation: string;
90}
91
92export const ANTIGRAVITY_PERSONAL_AUTH: AntigravityAuthConfig = {
93 authMethod: "oauth-personal",
94 apiKey: "",
95 gcpProject: "",
96 gcpLocation: "",
97};
98
99/** True for the two methods that open a Google sign-in page. */
100export function antigravityAuthUsesBrowser(authMethod: AntigravityAuthMethod): boolean {
101 return authMethod === "oauth-personal" || authMethod === "oauth-business";
102}
103
104/** Label shown on the provider card once the method has authenticated. */
105export function antigravityAuthLabel(authMethod: AntigravityAuthMethod): string {
106 switch (authMethod) {
107 case "oauth-personal":
108 return "Google account";
109 case "oauth-business":
110 return "Gemini Enterprise";
111 case "gemini-api-key":
112 return "Gemini API key";
113 case "agent-platform":
114 return "Agent Platform";
115 }
116}
117
118/**
119 * Explains what is missing before a non-personal method can authenticate, or
120 * null when the config is complete. Personal sign-in never needs config.
121 */
122export function antigravityAuthConfigIssue(auth: AntigravityAuthConfig): string | null {
123 switch (auth.authMethod) {
124 case "oauth-personal":
125 return null;
126 case "oauth-business":
127 return auth.gcpProject && auth.gcpLocation
128 ? null
129 : "Gemini Enterprise needs a GCP project and location in the Antigravity provider settings.";
130 case "gemini-api-key":
131 return auth.apiKey ? null : "Enter a Gemini API key in the Antigravity provider settings.";
132 case "agent-platform":
133 return auth.apiKey || (auth.gcpProject && auth.gcpLocation)
134 ? null
135 : "Agent Platform needs an API key, or a GCP project and location, in the Antigravity provider settings.";
136 }
137}
138
139/**
140 * `settings.json` content for the agent's profile. `auth.type` names the
141 * selected method so a native logout clears only that method's credentials
142 * instead of every stored token. The GCP block feeds Enterprise and Agent
143 * Platform. Never holds a credential.
144 */
145export function antigravityProfileSettings(auth: AntigravityAuthConfig): string {
146 const gcp = {
147 ...(auth.gcpProject ? { project: auth.gcpProject } : {}),
148 ...(auth.gcpLocation ? { location: auth.gcpLocation } : {}),
149 };
150 return `${encodeProfileSettings({
151 auth: { type: auth.authMethod },
152 ...(Object.keys(gcp).length > 0 ? { gcp } : {}),
153 })}\n`;
154}
155
156export interface AntigravityAuthorizationUrl {
157 readonly authorizationUrl: string;
158 readonly redirectUri: string;
159 readonly state: string;
160}
161
162function authSupportError(detail: string) {
163 return new AcpErrors.AcpTransportError({ detail, cause: undefined });
164}
165
166/** Recognizes native auth failures and interactive login blocked by T3. */
167export function isAntigravitySignInRequiredError(error: unknown): boolean {
168 return (
169 (isAcpRequestError(error) && error.code === -32000) ||
170 (isAcpTransportError(error) && error.detail === ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE)
171 );
172}
173
174/** Keeps case-sensitive instance IDs separate on case-insensitive filesystems. */
175export function resolveAntigravityProfileDirectory(
176 stateDir: string,
177 instanceId: ProviderInstanceId,
178): string {
179 const directoryName = NodeCrypto.createHash("sha256").update(instanceId).digest("hex");
180 return NodePath.join(stateDir, "providers", "antigravity", directoryName);
181}
182
183function quoteBrowserArgument(value: string): string {
184 return `'${value.replaceAll("'", `'"'"'`)}'`;
185}
186
187function antigravityEnvironment(
188 profile: AntigravityProfile,
189 baseEnv: NodeJS.ProcessEnv,
190 auth: AntigravityAuthConfig,
191) {
192 const environment: NodeJS.ProcessEnv = {};
193 for (const [key, value] of Object.entries(baseEnv)) {
194 // Windows treats environment keys as case-insensitive. Remove aliases too.
195 if (!removedEnvironmentKeys.has(key.toUpperCase())) environment[key] = value;
196 }
197 // Only the configured method's credential reaches the agent. The agent
198 // prefers GOOGLE_API_KEY over the GCP pair for Agent Platform, so the pair
199 // goes through settings.json instead of the environment.
200 const credential =
201 auth.authMethod === "gemini-api-key" && auth.apiKey
202 ? { GEMINI_API_KEY: auth.apiKey }
203 : auth.authMethod === "agent-platform" && auth.apiKey
204 ? { GOOGLE_API_KEY: auth.apiKey }
205 : {};
206 return {
207 ...environment,
208 ...credential,
209 GEMINI_HOME: profile.geminiHome,
210 AGY_ACP_FORCE_FILE_STORAGE: "1",
211 BROWSER: profile.browserCommand,
212 PYTHONUNBUFFERED: "1",
213 ELECTRON_RUN_AS_NODE: "1",
214 };
215}
216
217/** Prepares a private profile without reading or copying Google credentials. */
218export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")(function* (input: {
219 readonly profileDirectory: string;
220 readonly baseEnv?: NodeJS.ProcessEnv;
221 readonly runtimeExecutablePath?: string;
222 readonly platform?: NodeJS.Platform;
223 readonly auth?: AntigravityAuthConfig;
224}) {
225 const auth = input.auth ?? ANTIGRAVITY_PERSONAL_AUTH;
226 const fs = yield* FileSystem.FileSystem;
227 const path = yield* Path.Path;
228 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
229 const platform = input.platform ?? (yield* HostProcessPlatform);
230 const runtimeExecutablePath = input.runtimeExecutablePath ?? (yield* HostProcessExecutablePath);
231 const helperExecutable =
232 platform === "win32" ? runtimeExecutablePath.replaceAll("\\", "/") : runtimeExecutablePath;
233 const browserArguments = [helperExecutable, "-e", browserHelperSource, "--", "%s"];
234 const browserCommand = browserArguments.map(quoteBrowserArgument).join(" ");
235 if (
236 browserCommand.includes(platform === "win32" ? ";" : ":") ||
237 helperExecutable.includes("\r") ||
238 helperExecutable.includes("\n") ||
239 helperExecutable.includes("\0") ||
240 helperExecutable.includes("%s")
241 ) {
242 return yield* authSupportError(
243 "The T3 runtime path cannot be used to suppress Antigravity browser launches.",
244 );
245 }
246
247 const geminiHome = path.resolve(input.profileDirectory);
248 const acpDirectory = path.join(geminiHome, "antigravity-acp");
249 const profile: AntigravityProfile = {
250 platform,
251 geminiHome,
252 acpDirectory,
253 tokenPath: path.join(acpDirectory, "acp_token.json"),
254 browserCommand,
255 };
256 const environment = antigravityEnvironment(profile, input.baseEnv ?? process.env, auth);
257 yield* Effect.gen(function* () {
258 const child = yield* spawner.spawn(
259 ChildProcess.make(helperExecutable, ["-e", browserHelperSource, "--", browserPreflightUrl], {
260 env: environment,
261 extendEnv: false,
262 shell: false,
263 }),
264 );
265 const [stdout, stderr, exitCode] = yield* Effect.all(
266 [
267 collectUint8StreamText({ stream: child.stdout, maxBytes: 4_096 }),
268 collectUint8StreamText({ stream: child.stderr, maxBytes: 4_096 }),
269 child.exitCode,
270 ],
271 { concurrency: "unbounded" },
272 );
273 if (
274 Number(exitCode) !== 0 ||
275 stdout.bytes !== 0 ||
276 stdout.truncated ||
277 stderr.truncated ||
278 stderr.text !== `${ANTIGRAVITY_AUTH_BROWSER_MARKER}"${browserPreflightUrl}"\n`
279 ) {
280 return yield* authSupportError("Antigravity browser suppression could not be verified.");
281 }
282 }).pipe(
283 Effect.scoped,
284 Effect.timeoutOrElse({
285 duration: "5 seconds",
286 orElse: () =>
287 Effect.fail(authSupportError("Antigravity browser suppression verification timed out.")),
288 }),
289 Effect.mapError((error) =>
290 error._tag === "AcpTransportError"
291 ? error
292 : authSupportError("Antigravity browser suppression could not be verified."),
293 ),
294 );
295
296 for (const directory of [geminiHome, acpDirectory]) {
297 yield* fs
298 .makeDirectory(directory, { recursive: true, mode: 0o700 })
299 .pipe(
300 Effect.mapError(() =>
301 authSupportError("The Antigravity profile directory could not be created."),
302 ),
303 );
304 if (platform !== "win32") {
305 yield* fs
306 .chmod(directory, 0o700)
307 .pipe(
308 Effect.mapError(() =>
309 authSupportError("The Antigravity profile directory permissions could not be set."),
310 ),
311 );
312 }
313 }
314 // Rewriting on every launch keeps a method, project, or location edit in
315 // Settings effective. The agent also records auth.type here after a
316 // sign-in, which matches the value written below.
317 yield* fs
318 .writeFileString(path.join(acpDirectory, "settings.json"), antigravityProfileSettings(auth))
319 .pipe(
320 Effect.mapError(() =>
321 authSupportError("The Antigravity profile settings could not be written."),
322 ),
323 );
324 return profile;
325});
326
327/** Applies the same subscription-only launch settings to every ACP process. */
328export function buildAntigravityAcpSpawnInput(input: {
329 readonly installation: {
330 readonly executablePath: string;
331 readonly harnessPath: string;
332 };
333 readonly profile: AntigravityProfile;
334 readonly cwd: string;
335 readonly baseEnv?: NodeJS.ProcessEnv;
336 readonly auth?: AntigravityAuthConfig;
337}): AcpSpawnInput {
338 return {
339 command: input.installation.executablePath,
340 args: input.profile.platform === "linux" ? ["--uid="] : [],
341 cwd: input.cwd,
342 env: {
343 ...antigravityEnvironment(
344 input.profile,
345 input.baseEnv ?? process.env,
346 input.auth ?? ANTIGRAVITY_PERSONAL_AUTH,
347 ),
348 ANTIGRAVITY_HARNESS_PATH: input.installation.harnessPath,
349 },
350 extendEnv: false,
351 };
352}
353
354/** Reads only the public authorization request, never an OAuth token file. */
355export const parseAntigravityAuthorizationUrl = Effect.fn("parseAntigravityAuthorizationUrl")(
356 function* (
357 authorizationUrl: string,
358 ): Effect.fn.Return<AntigravityAuthorizationUrl, AcpErrors.AcpError> {
359 const invalidUrl = () =>
360 authSupportError("Antigravity returned an invalid Google sign-in URL.");
361 if (authorizationUrl.length > maxAuthorizationUrlLength || /\s/.test(authorizationUrl)) {
362 return yield* invalidUrl();
363 }
364 const url = yield* decodeUrl(authorizationUrl).pipe(Effect.mapError(invalidUrl));
365 const state = url.searchParams.get("state");
366 const redirectUri = url.searchParams.get("redirect_uri");
367 if (
368 url.origin !== "https://accounts.google.com" ||
369 url.pathname !== "/o/oauth2/v2/auth" ||
370 url.username !== "" ||
371 url.password !== "" ||
372 url.hash !== "" ||
373 url.searchParams.getAll("state").length !== 1 ||
374 url.searchParams.getAll("redirect_uri").length !== 1 ||
375 url.searchParams.getAll("response_type").length !== 1 ||
376 url.searchParams.get("response_type") !== "code" ||
377 state === null ||
378 state.length === 0 ||
379 state.length > 512 ||
380 /\s/.test(state) ||
381 redirectUri === null ||
382 !/^http:\/\/127\.0\.0\.1:[1-9][0-9]{0,4}\/$/.test(redirectUri)
383 ) {
384 return yield* invalidUrl();
385 }
386 const redirect = yield* decodeUrl(redirectUri).pipe(Effect.mapError(invalidUrl));
387 if (Number(redirect.port) < 1_024) return yield* invalidUrl();
388 return { authorizationUrl, redirectUri, state };
389 },
390);
391
392export function makeAntigravityStdoutTransform(
393 input: {
394 readonly onAuthorizationUrl?: (
395 authorizationUrl: string,
396 ) => Effect.Effect<void, AcpErrors.AcpError>;
397 } = {},
398) {
399 const handleLine = Effect.fn("antigravityAuthSupport.handleStdoutLine")(function* (
400 line: Uint8Array,
401 ) {
402 if (!authPrefixBytes.every((byte, index) => line[index] === byte)) return [line];
403 const message = new TextDecoder().decode(line).replace(/\r?\n$/, "");
404 const request = yield* parseAntigravityAuthorizationUrl(
405 message.slice(ANTIGRAVITY_AUTH_STDOUT_PREFIX.length),
406 );
407 if (!input.onAuthorizationUrl) {
408 return yield* authSupportError(ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE);
409 }
410 yield* input.onAuthorizationUrl(request.authorizationUrl);
411 return [];
412 });
413
414 return (
415 stdout: ChildProcessSpawner.ChildProcessHandle["stdout"],
416 ): Stream.Stream<Uint8Array, PlatformError.PlatformError | AcpErrors.AcpError> =>
417 Stream.suspend(() => {
418 let pending: Uint8Array[] = [];
419 let pendingBytes = 0;
420 const finishLine = () => {
421 const line = Buffer.concat(pending, pendingBytes);
422 pending = [];
423 pendingBytes = 0;
424 return line;
425 };
426 return stdout.pipe(
427 Stream.mapEffect(
428 Effect.fn("antigravityAuthSupport.splitStdoutLines")(function* (chunk: Uint8Array) {
429 const lines: Uint8Array[] = [];
430 let offset = 0;
431 while (offset < chunk.byteLength) {
432 const newline = chunk.indexOf(10, offset);
433 const end = newline === -1 ? chunk.byteLength : newline + 1;
434 const part = chunk.subarray(offset, end);
435 if (pendingBytes + part.byteLength > maxStdoutLineBytes) {
436 return yield* authSupportError(
437 "Antigravity sent a protocol line that is too large.",
438 );
439 }
440 pending.push(part);
441 pendingBytes += part.byteLength;
442 if (newline !== -1) lines.push(finishLine());
443 offset = end;
444 }
445 return lines;
446 }),
447 ),
448 Stream.flatMap(Stream.fromIterable),
449 Stream.concat(
450 Stream.suspend(() => (pendingBytes > 0 ? Stream.succeed(finishLine()) : Stream.empty)),
451 ),
452 Stream.mapEffect(handleLine),
453 Stream.flatMap(Stream.fromIterable),
454 );
455 });
456}
457
458/** Upstream stderr can contain OAuth URLs, states, and redirect codes. */
459export const drainAntigravityStderr = (_text: string): Effect.Effect<void> => Effect.void;
apps/server/src/provider/antigravityCallback.test.ts created+48
......@@ -0,0 +1,48 @@
1import { assert, it } from "@effect/vitest";
2import { ProviderInstanceId } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5
6import { validateAntigravityCallbackUrl } from "./antigravityCallback.ts";
7
8const instanceId = ProviderInstanceId.make("antigravity-callback-test");
9const pending = { redirectUri: "http://127.0.0.1:51234/", state: "owned-state" };
10
11it.effect("accepts the exact owned Google callback and an explicit Google denial", () =>
12 Effect.gen(function* () {
13 for (const response of ["code=example-code", "error=access_denied"]) {
14 const callback = `http://127.0.0.1:51234/?state=owned-state&${response}&iss=https%3A%2F%2Faccounts.google.com`;
15 const parsed = yield* validateAntigravityCallbackUrl(instanceId, pending, callback);
16 assert.equal(parsed.toString(), callback);
17 }
18 }),
19);
20
21it.effect("rejects different targets, credentials, fragments, and duplicate OAuth fields", () =>
22 Effect.gen(function* () {
23 const callbacks = [
24 "https://127.0.0.1:51234/?state=owned-state&code=x",
25 "http://localhost:51234/?state=owned-state&code=x",
26 "http://127.0.0.2:51234/?state=owned-state&code=x",
27 "http://127.0.0.1:51235/?state=owned-state&code=x",
28 "http://127.0.0.1:51234/other?state=owned-state&code=x",
29 "http://user:password@127.0.0.1:51234/?state=owned-state&code=x",
30 "http://127.0.0.1:51234/?state=owned-state&code=x#fragment",
31 "http://127.0.0.1:51234/?state=wrong-state&code=x",
32 "http://127.0.0.1:51234/?state=owned-state&state=owned-state&code=x",
33 "http://127.0.0.1:51234/?state=owned-state&code=x&code=y",
34 "http://127.0.0.1:51234/?state=owned-state&code=x&error=access_denied",
35 "http://127.0.0.1:51234/?state=owned-state&error=access_denied&error=other",
36 "http://127.0.0.1:51234/?state=owned-state&code=",
37 "http://127.0.0.1:51234/?state=owned-state&iss=https%3A%2F%2Faccounts.google.com",
38 "http://127.0.0.1:51234/?state=owned-state&code=x&iss=https%3A%2F%2Fexample.com",
39 "http://127.0.0.1:51234/?state=owned-state&code=x&iss=https%3A%2F%2Faccounts.google.com&iss=https%3A%2F%2Faccounts.google.com",
40 ];
41 for (const callback of callbacks) {
42 const result = yield* validateAntigravityCallbackUrl(instanceId, pending, callback).pipe(
43 Effect.exit,
44 );
45 assert.isTrue(Exit.isFailure(result), callback);
46 }
47 }),
48);
apps/server/src/provider/antigravityCallback.ts created+115
......@@ -0,0 +1,115 @@
1// @effect-diagnostics nodeBuiltinImport:off - node:http sends the one-shot loopback callback with no proxy, redirect handling, or response logging.
2import * as NodeHttp from "node:http";
3
4import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts";
5import * as Effect from "effect/Effect";
6
7export interface AntigravityPendingCallback {
8 readonly redirectUri: string;
9 readonly state: string;
10}
11
12/** Only the callback advertised by this running ACP process may receive a request. */
13export const validateAntigravityCallbackUrl = Effect.fn("validateAntigravityCallbackUrl")(
14 function* (
15 instanceId: ProviderInstanceId,
16 pending: AntigravityPendingCallback,
17 callbackUrl: string,
18 ) {
19 const invalid = (detail: string) =>
20 new ProviderSetupError({ instanceId, operation: "complete", detail });
21 if (callbackUrl.length > 16_384) {
22 return yield* invalid("The sign-in response URL is too long.");
23 }
24 const callback = yield* Effect.try({
25 try: () => new URL(callbackUrl),
26 catch: () => invalid("Paste the complete redirect URL from the Google sign-in page."),
27 });
28 const expected = new URL(pending.redirectUri);
29 if (
30 callback.protocol !== "http:" ||
31 callback.hostname !== "127.0.0.1" ||
32 callback.origin !== expected.origin ||
33 callback.pathname !== expected.pathname ||
34 callback.username !== "" ||
35 callback.password !== "" ||
36 callback.hash !== ""
37 ) {
38 return yield* invalid("This redirect URL does not belong to the current sign-in.");
39 }
40 const states = callback.searchParams.getAll("state");
41 if (states.length !== 1 || states[0] !== pending.state) {
42 return yield* invalid("This redirect URL does not belong to the current sign-in.");
43 }
44 const codes = callback.searchParams.getAll("code");
45 const errors = callback.searchParams.getAll("error");
46 if (
47 !(
48 (codes.length === 1 && Boolean(codes[0]) && errors.length === 0) ||
49 (errors.length === 1 && Boolean(errors[0]) && codes.length === 0)
50 )
51 ) {
52 return yield* invalid("The redirect URL must contain one Google sign-in response.");
53 }
54 const issuers = callback.searchParams.getAll("iss");
55 if (
56 issuers.length > 1 ||
57 (issuers.length === 1 && issuers[0] !== "https://accounts.google.com")
58 ) {
59 return yield* invalid("The redirect URL is not a Google sign-in response.");
60 }
61 return callback;
62 },
63);
64
65/** Sends one callback, without proxies, redirects, readiness probes, or response logging. */
66export const forwardAntigravityCallback = (
67 instanceId: ProviderInstanceId,
68 callback: URL,
69): Effect.Effect<void, ProviderSetupError> =>
70 Effect.callback<void, ProviderSetupError>((resume) => {
71 const failed = () =>
72 new ProviderSetupError({
73 instanceId,
74 operation: "complete",
75 detail: "Could not deliver the sign-in response. Start sign-in again.",
76 });
77 let response: NodeHttp.IncomingMessage | undefined;
78 const request = NodeHttp.request(
79 {
80 protocol: "http:",
81 hostname: callback.hostname,
82 port: callback.port,
83 path: `${callback.pathname}${callback.search}`,
84 method: "GET",
85 agent: false,
86 },
87 (incoming) => {
88 response = incoming;
89 incoming.once("error", () => resume(Effect.fail(failed())));
90 incoming.once("end", () => {
91 const status = incoming.statusCode ?? 0;
92 resume(status >= 200 && status < 300 ? Effect.void : Effect.fail(failed()));
93 });
94 incoming.resume();
95 },
96 );
97 request.once("error", () => resume(Effect.fail(failed())));
98 request.end();
99 return Effect.sync(() => {
100 request.destroy();
101 response?.destroy();
102 });
103 }).pipe(
104 Effect.timeoutOrElse({
105 duration: "10 seconds",
106 orElse: () =>
107 Effect.fail(
108 new ProviderSetupError({
109 instanceId,
110 operation: "complete",
111 detail: "The sign-in response timed out. Start sign-in again.",
112 }),
113 ),
114 }),
115 );
apps/server/src/provider/antigravityRelease.ts created+83
......@@ -0,0 +1,83 @@
1export const ANTIGRAVITY_RELEASE_VERSION = "agy_acp_server_20260818_01_RC01";
2
3export interface AntigravityReleaseAsset {
4 readonly version: string;
5 readonly url: string;
6 readonly sha256: string;
7 readonly archiveBytes: number;
8 readonly executable: {
9 readonly name: string;
10 readonly bytes: number;
11 };
12 readonly harness: {
13 readonly name: string;
14 readonly bytes: number;
15 };
16}
17
18// URLs come from the official registry. Hashes and sizes were checked on 2026-09-02.
19// https://github.com/agentclientprotocol/registry/blob/536e378b70a7a6d5f078a9160180e3569a23253c/antigravity-acp/agent.json
20const releaseAssets = new Map<string, AntigravityReleaseAsset>([
21 [
22 "darwin-arm64",
23 {
24 version: ANTIGRAVITY_RELEASE_VERSION,
25 url: "https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-agy_acp_server_20260818_01_RC01-darwin-arm64.zip",
26 sha256: "f122ca7e7030a27f9649da4cf1a7d80e12c48c5f6118ff35affc34d56cbf83dd",
27 archiveBytes: 314_500_221,
28 executable: { name: "agy_acp_server.par", bytes: 792_105_680 },
29 harness: { name: "localharness_external", bytes: 101_551_680 },
30 },
31 ],
32 [
33 "linux-x64",
34 {
35 version: ANTIGRAVITY_RELEASE_VERSION,
36 url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-agy_acp_server_20260818_01_RC01-linux-x86_64.zip",
37 sha256: "ce3f09628575b25497cf5a3c19d073b49acb80f1dab1ff8592919e9c9b8799e1",
38 archiveBytes: 543_411_011,
39 executable: { name: "agy_acp_server.par", bytes: 1_529_513_909 },
40 harness: { name: "localharness_external", bytes: 117_532_520 },
41 },
42 ],
43 [
44 "linux-arm64",
45 {
46 version: ANTIGRAVITY_RELEASE_VERSION,
47 url: "https://dl.google.com/agy-extensions/releases/linux/agy-acp-server-agy_acp_server_20260818_01_RC01-linux-arm64.zip",
48 sha256: "70fcdac70684de60f7a0eb16ea497d6cc4498728420f060e0850cfc9a9329b40",
49 archiveBytes: 524_995_159,
50 executable: { name: "agy_acp_server.par", bytes: 1_519_373_648 },
51 harness: { name: "localharness_external", bytes: 110_601_552 },
52 },
53 ],
54 [
55 "win32-x64",
56 {
57 version: ANTIGRAVITY_RELEASE_VERSION,
58 url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-agy_acp_server_20260818_01_RC01-windows-x86_64.zip",
59 sha256: "35c7dd169c2794172ce02e9444a6db4a8ed4bb11398be07976cac2ee494f44e6",
60 archiveBytes: 331_985_114,
61 executable: { name: "agy_acp_server.exe", bytes: 297_200_088 },
62 harness: { name: "localharness_external.exe", bytes: 122_038_424 },
63 },
64 ],
65 [
66 "win32-arm64",
67 {
68 version: ANTIGRAVITY_RELEASE_VERSION,
69 url: "https://dl.google.com/agy-extensions/releases/windows/agy-acp-server-agy_acp_server_20260818_01_RC01-windows-arm64.zip",
70 sha256: "1522056748d45fbc34d0be72b41b99b0637be1b4caad0b34d37eb16d04ccb9c4",
71 archiveBytes: 332_484_576,
72 executable: { name: "agy_acp_server.exe", bytes: 301_449_928 },
73 harness: { name: "localharness_external.exe", bytes: 114_173_080 },
74 },
75 ],
76]);
77
78export function resolveAntigravityReleaseAsset(
79 platform: NodeJS.Platform,
80 arch: string,
81): AntigravityReleaseAsset | null {
82 return releaseAssets.get(`${platform}-${arch}`) ?? null;
83}
apps/server/src/provider/builtInDrivers.ts+4-1
......@@ -25,6 +25,7 @@ import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";
2525import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts";
2626import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";
2727import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts";
28import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts";
2829import type { AnyProviderDriver } from "./ProviderDriver.ts";
2930
3031/**
......@@ -37,7 +38,8 @@ export type BuiltInDriversEnv =
3738 | CodexDriverEnv
3839 | CursorDriverEnv
3940 | GrokDriverEnv
40 | OpenCodeDriverEnv;
41 | OpenCodeDriverEnv
42 | AntigravityDriverEnv;
4143
4244/**
4345 * Ordered list of built-in drivers. Order matters only for tie-breaking in
......@@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray<AnyProviderDriver<BuiltInDriversEnv
5052 CursorDriver,
5153 GrokDriver,
5254 OpenCodeDriver,
55 AntigravityDriver,
5356];
apps/server/src/provider/model-manifest.json+388-85
......@@ -8,7 +8,8 @@
88 "gpt-daybreak-blue-latest",
99 "gpt-daybreak-red-latest"
1010 ],
11 "claudeAgent": ["claude-fable-5-1", "claude-opus-5", "claude-sonnet-5"]
11 "claudeAgent": ["claude-fable-5-1", "claude-opus-5", "claude-sonnet-5"],
12 "antigravity": ["gemini-3.8-flash-high", "gemini-3.8-flash-medium", "gemini-3.8-flash-low"]
1213 },
1314 "providers": {
1415 "claudeAgent": {
......@@ -24,17 +25,36 @@
2425 "label": "Reasoning",
2526 "type": "select",
2627 "options": [
27 { "id": "low", "label": "Low" },
28 { "id": "medium", "label": "Medium" },
29 { "id": "high", "label": "High", "isDefault": true },
30 { "id": "xhigh", "label": "Extra High" },
31 { "id": "max", "label": "Max" },
28 {
29 "id": "low",
30 "label": "Low"
31 },
32 {
33 "id": "medium",
34 "label": "Medium"
35 },
36 {
37 "id": "high",
38 "label": "High",
39 "isDefault": true
40 },
41 {
42 "id": "xhigh",
43 "label": "Extra High"
44 },
45 {
46 "id": "max",
47 "label": "Max"
48 },
3249 {
3350 "id": "ultracode",
3451 "label": "Ultracode",
3552 "description": "xhigh effort plus multi-agent workflow orchestration"
3653 },
37 { "id": "ultrathink", "label": "Ultrathink" }
54 {
55 "id": "ultrathink",
56 "label": "Ultrathink"
57 }
3858 ],
3959 "promptInjectedValues": ["ultrathink"]
4060 },
......@@ -43,17 +63,34 @@
4363 "label": "Context Window",
4464 "type": "select",
4565 "options": [
46 { "id": "200k", "label": "200k" },
47 { "id": "1m", "label": "1M", "isDefault": true }
66 {
67 "id": "200k",
68 "label": "200k"
69 },
70 {
71 "id": "1m",
72 "label": "1M",
73 "isDefault": true
74 }
4875 ]
4976 }
5077 ]
5178 },
5279 "adapter": {
5380 "claudeCode": {
54 "effortMap": { "ultracode": "xhigh", "ultrathink": null },
55 "modelSuffixes": { "contextWindow": { "1m": "[1m]" } },
56 "contextWindowTokens": { "200k": 200000, "1m": 1000000 }
81 "effortMap": {
82 "ultracode": "xhigh",
83 "ultrathink": null
84 },
85 "modelSuffixes": {
86 "contextWindow": {
87 "1m": "[1m]"
88 }
89 },
90 "contextWindowTokens": {
91 "200k": 200000,
92 "1m": 1000000
93 }
5794 }
5895 }
5996 },
......@@ -65,37 +102,77 @@
65102 "label": "Reasoning",
66103 "type": "select",
67104 "options": [
68 { "id": "low", "label": "Low" },
69 { "id": "medium", "label": "Medium" },
70 { "id": "high", "label": "High", "isDefault": true },
71 { "id": "xhigh", "label": "Extra High" },
72 { "id": "max", "label": "Max" },
105 {
106 "id": "low",
107 "label": "Low"
108 },
109 {
110 "id": "medium",
111 "label": "Medium"
112 },
113 {
114 "id": "high",
115 "label": "High",
116 "isDefault": true
117 },
118 {
119 "id": "xhigh",
120 "label": "Extra High"
121 },
122 {
123 "id": "max",
124 "label": "Max"
125 },
73126 {
74127 "id": "ultracode",
75128 "label": "Ultracode",
76129 "description": "xhigh effort plus multi-agent workflow orchestration"
77130 },
78 { "id": "ultrathink", "label": "Ultrathink" }
131 {
132 "id": "ultrathink",
133 "label": "Ultrathink"
134 }
79135 ],
80136 "promptInjectedValues": ["ultrathink"]
81137 },
82 { "id": "fastMode", "label": "Fast Mode", "type": "boolean" },
138 {
139 "id": "fastMode",
140 "label": "Fast Mode",
141 "type": "boolean"
142 },
83143 {
84144 "id": "contextWindow",
85145 "label": "Context Window",
86146 "type": "select",
87147 "options": [
88 { "id": "200k", "label": "200k" },
89 { "id": "1m", "label": "1M", "isDefault": true }
148 {
149 "id": "200k",
150 "label": "200k"
151 },
152 {
153 "id": "1m",
154 "label": "1M",
155 "isDefault": true
156 }
90157 ]
91158 }
92159 ]
93160 },
94161 "adapter": {
95162 "claudeCode": {
96 "effortMap": { "ultracode": "xhigh", "ultrathink": null },
97 "modelSuffixes": { "contextWindow": { "1m": "[1m]" } },
98 "contextWindowTokens": { "200k": 200000, "1m": 1000000 }
163 "effortMap": {
164 "ultracode": "xhigh",
165 "ultrathink": null
166 },
167 "modelSuffixes": {
168 "contextWindow": {
169 "1m": "[1m]"
170 }
171 },
172 "contextWindowTokens": {
173 "200k": 200000,
174 "1m": 1000000
175 }
99176 }
100177 }
101178 },
......@@ -107,26 +184,52 @@
107184 "label": "Reasoning",
108185 "type": "select",
109186 "options": [
110 { "id": "low", "label": "Low" },
111 { "id": "medium", "label": "Medium" },
112 { "id": "high", "label": "High", "isDefault": true },
113 { "id": "xhigh", "label": "Extra High" },
114 { "id": "max", "label": "Max" },
187 {
188 "id": "low",
189 "label": "Low"
190 },
191 {
192 "id": "medium",
193 "label": "Medium"
194 },
195 {
196 "id": "high",
197 "label": "High",
198 "isDefault": true
199 },
200 {
201 "id": "xhigh",
202 "label": "Extra High"
203 },
204 {
205 "id": "max",
206 "label": "Max"
207 },
115208 {
116209 "id": "ultracode",
117210 "label": "Ultracode",
118211 "description": "xhigh effort plus multi-agent workflow orchestration"
119212 },
120 { "id": "ultrathink", "label": "Ultrathink" }
213 {
214 "id": "ultrathink",
215 "label": "Ultrathink"
216 }
121217 ],
122218 "promptInjectedValues": ["ultrathink"]
123219 },
124 { "id": "fastMode", "label": "Fast Mode", "type": "boolean" }
220 {
221 "id": "fastMode",
222 "label": "Fast Mode",
223 "type": "boolean"
224 }
125225 ]
126226 },
127227 "adapter": {
128228 "claudeCode": {
129 "effortMap": { "ultracode": "xhigh", "ultrathink": null },
229 "effortMap": {
230 "ultracode": "xhigh",
231 "ultrathink": null
232 },
130233 "fixedContextWindowTokens": 1000000
131234 }
132235 }
......@@ -139,21 +242,47 @@
139242 "label": "Reasoning",
140243 "type": "select",
141244 "options": [
142 { "id": "low", "label": "Low" },
143 { "id": "medium", "label": "Medium" },
144 { "id": "high", "label": "High" },
145 { "id": "xhigh", "label": "Extra High", "isDefault": true },
146 { "id": "max", "label": "Max" },
147 { "id": "ultrathink", "label": "Ultrathink" }
245 {
246 "id": "low",
247 "label": "Low"
248 },
249 {
250 "id": "medium",
251 "label": "Medium"
252 },
253 {
254 "id": "high",
255 "label": "High"
256 },
257 {
258 "id": "xhigh",
259 "label": "Extra High",
260 "isDefault": true
261 },
262 {
263 "id": "max",
264 "label": "Max"
265 },
266 {
267 "id": "ultrathink",
268 "label": "Ultrathink"
269 }
148270 ],
149271 "promptInjectedValues": ["ultrathink"]
150272 },
151 { "id": "fastMode", "label": "Fast Mode", "type": "boolean" }
273 {
274 "id": "fastMode",
275 "label": "Fast Mode",
276 "type": "boolean"
277 }
152278 ]
153279 },
154280 "adapter": {
155281 "claudeCode": {
156 "effortMap": { "xhigh": "max", "ultrathink": null },
282 "effortMap": {
283 "xhigh": "max",
284 "ultrathink": null
285 },
157286 "fixedContextWindowTokens": 1000000
158287 }
159288 }
......@@ -166,31 +295,67 @@
166295 "label": "Reasoning",
167296 "type": "select",
168297 "options": [
169 { "id": "low", "label": "Low" },
170 { "id": "medium", "label": "Medium" },
171 { "id": "high", "label": "High", "isDefault": true },
172 { "id": "max", "label": "Max" },
173 { "id": "ultrathink", "label": "Ultrathink" }
298 {
299 "id": "low",
300 "label": "Low"
301 },
302 {
303 "id": "medium",
304 "label": "Medium"
305 },
306 {
307 "id": "high",
308 "label": "High",
309 "isDefault": true
310 },
311 {
312 "id": "max",
313 "label": "Max"
314 },
315 {
316 "id": "ultrathink",
317 "label": "Ultrathink"
318 }
174319 ],
175320 "promptInjectedValues": ["ultrathink"]
176321 },
177 { "id": "fastMode", "label": "Fast Mode", "type": "boolean" },
322 {
323 "id": "fastMode",
324 "label": "Fast Mode",
325 "type": "boolean"
326 },
178327 {
179328 "id": "contextWindow",
180329 "label": "Context Window",
181330 "type": "select",
182331 "options": [
183 { "id": "200k", "label": "200k" },
184 { "id": "1m", "label": "1M", "isDefault": true }
332 {
333 "id": "200k",
334 "label": "200k"
335 },
336 {
337 "id": "1m",
338 "label": "1M",
339 "isDefault": true
340 }
185341 ]
186342 }
187343 ]
188344 },
189345 "adapter": {
190346 "claudeCode": {
191 "effortMap": { "ultrathink": null },
192 "modelSuffixes": { "contextWindow": { "1m": "[1m]" } },
193 "contextWindowTokens": { "200k": 200000, "1m": 1000000 }
347 "effortMap": {
348 "ultrathink": null
349 },
350 "modelSuffixes": {
351 "contextWindow": {
352 "1m": "[1m]"
353 }
354 },
355 "contextWindowTokens": {
356 "200k": 200000,
357 "1m": 1000000
358 }
194359 }
195360 }
196361 },
......@@ -202,16 +367,35 @@
202367 "label": "Reasoning",
203368 "type": "select",
204369 "options": [
205 { "id": "low", "label": "Low" },
206 { "id": "medium", "label": "Medium" },
207 { "id": "high", "label": "High", "isDefault": true },
208 { "id": "max", "label": "Max" }
370 {
371 "id": "low",
372 "label": "Low"
373 },
374 {
375 "id": "medium",
376 "label": "Medium"
377 },
378 {
379 "id": "high",
380 "label": "High",
381 "isDefault": true
382 },
383 {
384 "id": "max",
385 "label": "Max"
386 }
209387 ]
210388 },
211 { "id": "fastMode", "label": "Fast Mode", "type": "boolean" }
389 {
390 "id": "fastMode",
391 "label": "Fast Mode",
392 "type": "boolean"
393 }
212394 ]
213395 },
214 "adapter": { "claudeCode": {} }
396 "adapter": {
397 "claudeCode": {}
398 }
215399 },
216400 "sonnet-5": {
217401 "capabilities": {
......@@ -221,12 +405,31 @@
221405 "label": "Reasoning",
222406 "type": "select",
223407 "options": [
224 { "id": "low", "label": "Low" },
225 { "id": "medium", "label": "Medium" },
226 { "id": "high", "label": "High", "isDefault": true },
227 { "id": "xhigh", "label": "Extra High" },
228 { "id": "max", "label": "Max" },
229 { "id": "ultrathink", "label": "Ultrathink" }
408 {
409 "id": "low",
410 "label": "Low"
411 },
412 {
413 "id": "medium",
414 "label": "Medium"
415 },
416 {
417 "id": "high",
418 "label": "High",
419 "isDefault": true
420 },
421 {
422 "id": "xhigh",
423 "label": "Extra High"
424 },
425 {
426 "id": "max",
427 "label": "Max"
428 },
429 {
430 "id": "ultrathink",
431 "label": "Ultrathink"
432 }
230433 ],
231434 "promptInjectedValues": ["ultrathink"]
232435 },
......@@ -235,17 +438,33 @@
235438 "label": "Context Window",
236439 "type": "select",
237440 "options": [
238 { "id": "200k", "label": "200k", "isDefault": true },
239 { "id": "1m", "label": "1M" }
441 {
442 "id": "200k",
443 "label": "200k",
444 "isDefault": true
445 },
446 {
447 "id": "1m",
448 "label": "1M"
449 }
240450 ]
241451 }
242452 ]
243453 },
244454 "adapter": {
245455 "claudeCode": {
246 "effortMap": { "ultrathink": null },
247 "modelSuffixes": { "contextWindow": { "1m": "[1m]" } },
248 "contextWindowTokens": { "200k": 200000, "1m": 1000000 }
456 "effortMap": {
457 "ultrathink": null
458 },
459 "modelSuffixes": {
460 "contextWindow": {
461 "1m": "[1m]"
462 }
463 },
464 "contextWindowTokens": {
465 "200k": 200000,
466 "1m": 1000000
467 }
249468 }
250469 }
251470 },
......@@ -257,11 +476,27 @@
257476 "label": "Reasoning",
258477 "type": "select",
259478 "options": [
260 { "id": "low", "label": "Low" },
261 { "id": "medium", "label": "Medium" },
262 { "id": "high", "label": "High", "isDefault": true },
263 { "id": "max", "label": "Max" },
264 { "id": "ultrathink", "label": "Ultrathink" }
479 {
480 "id": "low",
481 "label": "Low"
482 },
483 {
484 "id": "medium",
485 "label": "Medium"
486 },
487 {
488 "id": "high",
489 "label": "High",
490 "isDefault": true
491 },
492 {
493 "id": "max",
494 "label": "Max"
495 },
496 {
497 "id": "ultrathink",
498 "label": "Ultrathink"
499 }
265500 ],
266501 "promptInjectedValues": ["ultrathink"]
267502 },
......@@ -270,25 +505,50 @@
270505 "label": "Context Window",
271506 "type": "select",
272507 "options": [
273 { "id": "200k", "label": "200k", "isDefault": true },
274 { "id": "1m", "label": "1M" }
508 {
509 "id": "200k",
510 "label": "200k",
511 "isDefault": true
512 },
513 {
514 "id": "1m",
515 "label": "1M"
516 }
275517 ]
276518 }
277519 ]
278520 },
279521 "adapter": {
280522 "claudeCode": {
281 "effortMap": { "max": "high", "ultrathink": null },
282 "modelSuffixes": { "contextWindow": { "1m": "[1m]" } },
283 "contextWindowTokens": { "200k": 200000, "1m": 1000000 }
523 "effortMap": {
524 "max": "high",
525 "ultrathink": null
526 },
527 "modelSuffixes": {
528 "contextWindow": {
529 "1m": "[1m]"
530 }
531 },
532 "contextWindowTokens": {
533 "200k": 200000,
534 "1m": 1000000
535 }
284536 }
285537 }
286538 },
287539 "haiku-4-5": {
288540 "capabilities": {
289 "optionDescriptors": [{ "id": "thinking", "label": "Thinking", "type": "boolean" }]
541 "optionDescriptors": [
542 {
543 "id": "thinking",
544 "label": "Thinking",
545 "type": "boolean"
546 }
547 ]
290548 },
291 "adapter": { "claudeCode": {} }
549 "adapter": {
550 "claudeCode": {}
551 }
292552 }
293553 },
294554 "models": [
......@@ -299,14 +559,22 @@
299559 "status": "current",
300560 "badge": "new",
301561 "profile": "fable-5",
302 "adapter": { "claudeCode": { "minVersion": "2.1.257" } }
562 "adapter": {
563 "claudeCode": {
564 "minVersion": "2.1.257"
565 }
566 }
303567 },
304568 {
305569 "slug": "claude-fable-5",
306570 "name": "Claude Fable 5",
307571 "status": "legacy",
308572 "profile": "fable-5",
309 "adapter": { "claudeCode": { "minVersion": "2.1.169" } }
573 "adapter": {
574 "claudeCode": {
575 "minVersion": "2.1.169"
576 }
577 }
310578 },
311579 {
312580 "slug": "claude-opus-5",
......@@ -314,7 +582,11 @@
314582 "aliases": ["opus", "opus-5", "claude-opus-5.0", "claude-opus-5-0"],
315583 "status": "current",
316584 "profile": "opus-5",
317 "adapter": { "claudeCode": { "minVersion": "2.1.219" } }
585 "adapter": {
586 "claudeCode": {
587 "minVersion": "2.1.219"
588 }
589 }
318590 },
319591 {
320592 "slug": "claude-opus-4-8",
......@@ -322,7 +594,11 @@
322594 "aliases": ["opus-4.8", "claude-opus-4.8"],
323595 "status": "legacy",
324596 "profile": "opus-4-8",
325 "adapter": { "claudeCode": { "minVersion": "2.1.154" } }
597 "adapter": {
598 "claudeCode": {
599 "minVersion": "2.1.154"
600 }
601 }
326602 },
327603 {
328604 "slug": "claude-opus-4-7",
......@@ -330,7 +606,11 @@
330606 "aliases": ["opus-4.7", "claude-opus-4.7"],
331607 "status": "legacy",
332608 "profile": "opus-4-7",
333 "adapter": { "claudeCode": { "minVersion": "2.1.111" } }
609 "adapter": {
610 "claudeCode": {
611 "minVersion": "2.1.111"
612 }
613 }
334614 },
335615 {
336616 "slug": "claude-opus-4-6",
......@@ -367,6 +647,29 @@
367647 "profile": "haiku-4-5"
368648 }
369649 ]
650 },
651 "antigravity": {
652 "defaults": {
653 "chat": "gemini-3.8-flash-high"
654 },
655 "profiles": {},
656 "models": [
657 {
658 "slug": "gemini-3.8-flash-high",
659 "name": "Gemini 3.8 Flash (High)",
660 "status": "current"
661 },
662 {
663 "slug": "gemini-3.8-flash-medium",
664 "name": "Gemini 3.8 Flash (Medium)",
665 "status": "current"
666 },
667 {
668 "slug": "gemini-3.8-flash-low",
669 "name": "Gemini 3.8 Flash (Low)",
670 "status": "current"
671 }
672 ]
370673 }
371674 }
372675}
apps/server/src/provider/providerInstallation.test.ts created+169
......@@ -0,0 +1,169 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import {
4 ProviderDriverKind,
5 ProviderInstanceId,
6 type ProviderInstallState,
7} from "@t3tools/contracts";
8import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
12import * as Path from "effect/Path";
13import * as Stream from "effect/Stream";
14
15import { layerTest as settingsLayerTest } from "../serverSettings.ts";
16import { AntigravityInstallation } from "./AntigravityInstallation.ts";
17import type { ProviderInstance } from "./ProviderDriver.ts";
18import { makeProviderInstallation } from "./providerInstallation.ts";
19import { ProviderInstanceRegistry } from "./Services/ProviderInstanceRegistry.ts";
20import { ProviderRegistry } from "./Services/ProviderRegistry.ts";
21
22const instanceId = ProviderInstanceId.make("antigravity");
23const driver = ProviderDriverKind.make("antigravity");
24const state: ProviderInstallState = {
25 driver,
26 operationId: null,
27 phase: "idle",
28 downloadedBytes: 0,
29 totalBytes: null,
30 version: null,
31 installedVersion: null,
32 canRemove: false,
33 message: null,
34};
35
36function instance(kind = driver): ProviderInstance {
37 return {
38 instanceId,
39 driverKind: kind,
40 enabled: false,
41 displayName: undefined,
42 continuationIdentity: { driverKind: kind, continuationKey: instanceId },
43 get adapter(): never {
44 throw new Error("Installation must not start a provider session.");
45 },
46 get snapshot(): never {
47 throw new Error("Installation routing must not probe the provider.");
48 },
49 get textGeneration(): never {
50 throw new Error("Installation must not generate text.");
51 },
52 };
53}
54
55const makeHarness = Effect.fn("providerInstallation.test.makeHarness")(function* (
56 input: {
57 instance?: ProviderInstance;
58 settings?: Parameters<typeof settingsLayerTest>[0];
59 } = {},
60) {
61 const calls: string[] = [];
62 let protectedPaths: ReadonlyArray<string> = [];
63 const configured = input.instance ?? instance();
64 const router = yield* makeProviderInstallation().pipe(
65 Effect.provide(
66 Layer.mergeAll(
67 settingsLayerTest(input.settings),
68 Layer.mock(ProviderInstanceRegistry)({
69 getInstance: (id) =>
70 Effect.succeed(id === configured.instanceId ? configured : undefined),
71 listInstances: Effect.succeed([configured]),
72 }),
73 Layer.mock(ProviderRegistry)({
74 refreshInstance: () =>
75 Effect.sync(() => {
76 calls.push("refresh");
77 return [];
78 }),
79 }),
80 Layer.mock(AntigravityInstallation)({
81 managedDirectory: "/unused-managed-runtime",
82 start: Effect.sync(() => {
83 calls.push("start");
84 return state;
85 }),
86 cancel: () =>
87 Effect.sync(() => {
88 calls.push("cancel");
89 return state;
90 }),
91 state: Effect.succeed(state),
92 changes: Stream.succeed(state),
93 remove: (paths) =>
94 Effect.sync(() => {
95 protectedPaths = paths ?? [];
96 calls.push("remove");
97 }),
98 }),
99 ),
100 ),
101 );
102 return { router, calls, protectedPaths: () => protectedPaths };
103});
104
105describe("provider installation routing", () => {
106 it.effect("allows explicit installation while the provider is disabled", () =>
107 Effect.gen(function* () {
108 const harness = yield* makeHarness();
109 yield* harness.router.start({ instanceId });
110 assert.deepEqual(harness.calls, ["start"]);
111 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
112 );
113
114 it.effect("rejects another driver and unknown instances before installation", () =>
115 Effect.gen(function* () {
116 const harness = yield* makeHarness({ instance: instance(ProviderDriverKind.make("codex")) });
117 const wrongDriver = yield* Effect.flip(harness.router.start({ instanceId }));
118 const missing = yield* Effect.flip(
119 harness.router.start({ instanceId: ProviderInstanceId.make("missing") }),
120 );
121 assert.equal(wrongDriver._tag, "ProviderSetupError");
122 assert.equal(missing._tag, "ProviderSetupError");
123 assert.deepEqual(harness.calls, []);
124 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
125 );
126
127 it.effect("keeps external installs manual without hiding shared install status", () =>
128 Effect.gen(function* () {
129 const harness = yield* makeHarness({
130 settings: { providers: { antigravity: { binaryPath: "/external/agy" } } },
131 });
132 const start = yield* Effect.flip(harness.router.start({ instanceId }));
133 const remove = yield* Effect.flip(harness.router.remove({ instanceId }));
134 assert.include(start.detail, "custom executable");
135 assert.include(remove.detail, "custom executable");
136 const observed = yield* Stream.runCollect(harness.router.subscribe({ instanceId }));
137 assert.deepEqual(Array.from(observed), [state]);
138 assert.deepEqual(harness.calls, []);
139 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
140 );
141
142 it.effect("protects another instance's binary found through its own PATH", () =>
143 Effect.gen(function* () {
144 const fs = yield* FileSystem.FileSystem;
145 const path = yield* Path.Path;
146 const platform = yield* HostProcessPlatform;
147 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-provider-install-route-" });
148 const binary = platform === "win32" ? "agy-test.exe" : "agy-test";
149 const executable = path.join(directory, binary);
150 yield* fs.writeFileString(executable, "test");
151 yield* fs.chmod(executable, 0o755);
152 const other = ProviderInstanceId.make("antigravity-work");
153 const harness = yield* makeHarness({
154 settings: {
155 providerInstances: {
156 [other]: {
157 driver,
158 config: { binaryPath: binary },
159 environment: [{ name: "PATH", value: directory, sensitive: false }],
160 },
161 },
162 },
163 });
164 yield* harness.router.remove({ instanceId });
165 assert.include(harness.protectedPaths(), executable);
166 assert.deepEqual(harness.calls, ["remove", "refresh"]);
167 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
168 );
169});
apps/server/src/provider/providerInstallation.ts created+137
......@@ -0,0 +1,137 @@
1import {
2 AntigravitySettings,
3 ProviderDriverKind,
4 type ProviderInstallCancelInput,
5 type ProviderInstanceId,
6 ProviderSetupError,
7 type ProviderSetupInput,
8} from "@t3tools/contracts";
9import { resolveCommandPath } from "@t3tools/shared/shell";
10import * as Effect from "effect/Effect";
11import * as Schema from "effect/Schema";
12import * as Stream from "effect/Stream";
13
14import { ServerSettingsService } from "../serverSettings.ts";
15import {
16 AntigravityInstallation,
17 type AntigravityInstallationError,
18} from "./AntigravityInstallation.ts";
19import { deriveProviderInstanceConfigMap } from "./Layers/ProviderInstanceRegistryHydration.ts";
20import { ProviderInstanceRegistry } from "./Services/ProviderInstanceRegistry.ts";
21import { ProviderRegistry } from "./Services/ProviderRegistry.ts";
22import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts";
23
24const ANTIGRAVITY = ProviderDriverKind.make("antigravity");
25const hasBinaryPath = Schema.is(Schema.Struct({ binaryPath: Schema.String }));
26const decodeAntigravitySettings = Schema.decodeUnknownEffect(AntigravitySettings);
27
28/** Route instance setup to the environment-owned installer without owning the download. */
29export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(function* () {
30 const installation = yield* AntigravityInstallation;
31 const instances = yield* ProviderInstanceRegistry;
32 const providers = yield* ProviderRegistry;
33 const settings = yield* ServerSettingsService;
34
35 const readEntries = Effect.fn("ProviderInstallation.readEntries")(function* (
36 instanceId: ProviderInstanceId,
37 operation: string,
38 ) {
39 const current = yield* settings.getSettings.pipe(
40 Effect.mapError(
41 () =>
42 new ProviderSetupError({
43 instanceId,
44 operation,
45 detail: "Could not read provider installation settings.",
46 }),
47 ),
48 );
49 return deriveProviderInstanceConfigMap(current);
50 });
51
52 const requireInstance = Effect.fn("ProviderInstallation.requireInstance")(function* (
53 instanceId: ProviderInstanceId,
54 operation: string,
55 managedOnly = false,
56 ) {
57 const instance = yield* instances.getInstance(instanceId);
58 if (instance?.driverKind !== ANTIGRAVITY) {
59 return yield* new ProviderSetupError({
60 instanceId,
61 operation,
62 detail: "Managed installation is not available for this provider instance.",
63 });
64 }
65 if (!managedOnly) return;
66 const entries = yield* readEntries(instanceId, operation);
67 const config = yield* decodeAntigravitySettings(entries[instanceId]?.config ?? {}).pipe(
68 Effect.mapError(
69 () =>
70 new ProviderSetupError({
71 instanceId,
72 operation,
73 detail: "The Antigravity instance configuration is invalid.",
74 }),
75 ),
76 );
77 if (config.binaryPath) {
78 return yield* new ProviderSetupError({
79 instanceId,
80 operation,
81 detail:
82 "This instance uses a custom executable. Clear its binary path to manage installation in T3 Code.",
83 });
84 }
85 });
86
87 const failure = (instanceId: ProviderInstanceId) => (error: AntigravityInstallationError) =>
88 new ProviderSetupError({ instanceId, operation: error.operation, detail: error.detail });
89
90 const start = Effect.fn("ProviderInstallation.start")(function* (input: ProviderSetupInput) {
91 yield* requireInstance(input.instanceId, "install", true);
92 return yield* installation.start.pipe(Effect.mapError(failure(input.instanceId)));
93 });
94
95 const cancel = Effect.fn("ProviderInstallation.cancel")(function* (
96 input: ProviderInstallCancelInput,
97 ) {
98 yield* requireInstance(input.instanceId, "cancel-install");
99 return yield* installation
100 .cancel(input.operationId)
101 .pipe(Effect.mapError(failure(input.instanceId)));
102 });
103
104 const subscribe = (input: ProviderSetupInput) =>
105 Stream.unwrap(
106 requireInstance(input.instanceId, "observe-install").pipe(Effect.as(installation.changes)),
107 );
108
109 const remove = Effect.fn("ProviderInstallation.remove")(function* (input: ProviderSetupInput) {
110 yield* requireInstance(input.instanceId, "remove-install", true);
111 const entries = yield* readEntries(input.instanceId, "remove-install");
112 const protectedPaths = yield* Effect.forEach(Object.values(entries), (entry) => {
113 if (!hasBinaryPath(entry.config) || !entry.config.binaryPath.trim()) {
114 return Effect.succeed([]);
115 }
116 const binaryPath = entry.config.binaryPath.trim();
117 return resolveCommandPath(binaryPath, {
118 env: mergeProviderInstanceEnvironment(entry.environment),
119 }).pipe(
120 Effect.map((resolved) => [binaryPath, resolved]),
121 Effect.catch(() => Effect.succeed([binaryPath])),
122 );
123 });
124 yield* installation
125 .remove(protectedPaths.flat())
126 .pipe(Effect.mapError(failure(input.instanceId)));
127 const allInstances = yield* instances.listInstances;
128 yield* Effect.forEach(
129 allInstances.filter((instance) => instance.driverKind === ANTIGRAVITY),
130 (instance) => providers.refreshInstance(instance.instanceId),
131 { discard: true },
132 );
133 return yield* installation.state;
134 });
135
136 return { start, cancel, subscribe, remove };
137});
apps/server/src/provider/providerStatusCache.ts+20-1
......@@ -29,13 +29,32 @@ const mergeProviderModels = (
2929 ];
3030};
3131
32/**
33 * Built-in drivers in presentation order. Codex and Claude lead, the opt-in
34 * providers follow, and unknown or fork drivers sort after every built-in.
35 */
36const BUILT_IN_DRIVER_ORDER: ReadonlyArray<string> = [
37 "codex",
38 "claudeAgent",
39 "cursor",
40 "grok",
41 "opencode",
42 "antigravity",
43];
44
45const driverRank = (driver: string): number => {
46 const index = BUILT_IN_DRIVER_ORDER.indexOf(driver);
47 return index === -1 ? BUILT_IN_DRIVER_ORDER.length : index;
48};
49
3250export const orderProviderSnapshots = (
3351 providers: ReadonlyArray<ServerProvider>,
3452): ReadonlyArray<ServerProvider> =>
3553 [...providers].toSorted(
3654 (left, right) =>
37 (left.displayName ?? "").localeCompare(right.displayName ?? "") ||
55 driverRank(left.driver) - driverRank(right.driver) ||
3856 left.driver.localeCompare(right.driver) ||
57 (left.displayName ?? "").localeCompare(right.displayName ?? "") ||
3958 left.instanceId.localeCompare(right.instanceId),
4059 );
4160
apps/server/src/server.test.ts+367
......@@ -29,8 +29,11 @@ import {
2929 ORCHESTRATION_WS_METHODS,
3030 type PreviewEvent,
3131 ProjectId,
32 type ProviderAuthState,
3233 ProviderDriverKind,
3334 ProviderInstanceId,
35 type ProviderInstallState,
36 ProviderSetupError,
3437 ResolvedKeybindingRule,
3538 ThreadId,
3639 TurnId,
......@@ -112,6 +115,13 @@ import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
112115import { PersistenceSqlError } from "./persistence/Errors.ts";
113116import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
114117import * as ProviderService from "./provider/Services/ProviderService.ts";
118import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts";
119import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts";
120import {
121 AntigravityInstallation,
122 AntigravityInstallationError,
123} from "./provider/AntigravityInstallation.ts";
124import type { ProviderInstance } from "./provider/ProviderDriver.ts";
115125import { ProviderAdapterRequestError } from "./provider/Errors.ts";
116126import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts";
117127import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
......@@ -192,6 +202,47 @@ const defaultModelSelection = {
192202 model: "gpt-5-codex",
193203} as const;
194204
205const providerSetupInstanceId = ProviderInstanceId.make("antigravity-custom-profile");
206const providerSetupDriver = ProviderDriverKind.make("antigravity");
207const providerSetupInstallState: ProviderInstallState = {
208 driver: providerSetupDriver,
209 operationId: "install-operation",
210 phase: "downloading",
211 downloadedBytes: 128,
212 totalBytes: 256,
213 version: "test-release",
214 installedVersion: null,
215 canRemove: false,
216 message: null,
217};
218const providerSetupAuthState: ProviderAuthState = {
219 instanceId: providerSetupInstanceId,
220 phase: "idle",
221 flowId: null,
222 authorizationUrl: null,
223 expiresAt: null,
224 message: null,
225};
226const providerSetupInstance: ProviderInstance = {
227 instanceId: providerSetupInstanceId,
228 driverKind: providerSetupDriver,
229 enabled: false,
230 displayName: "Google account",
231 continuationIdentity: {
232 driverKind: providerSetupDriver,
233 continuationKey: providerSetupInstanceId,
234 },
235 get adapter(): never {
236 throw new Error("Provider setup must not start a chat session.");
237 },
238 get snapshot(): never {
239 throw new Error("Installation routing must not probe the provider.");
240 },
241 get textGeneration(): never {
242 throw new Error("Provider setup must not generate text.");
243 },
244};
245
195246const makeLiveToolActivityEvent = (
196247 sequence: number,
197248 kind: "tool.updated" | "tool.completed" = "tool.updated",
......@@ -437,6 +488,9 @@ const buildAppUnderTest = (options?: {
437488 environmentTheme?: Partial<EnvironmentTheme.EnvironmentThemeService["Service"]>;
438489 providerRegistry?: Partial<ProviderRegistry.ProviderRegistry["Service"]>;
439490 providerService?: Partial<ProviderService.ProviderService["Service"]>;
491 providerAuth?: Partial<ProviderAuthService["Service"]>;
492 providerInstanceRegistry?: Partial<ProviderInstanceRegistry["Service"]>;
493 antigravityInstallation?: Partial<AntigravityInstallation["Service"]>;
440494 serverSettings?: Partial<ServerSettings.ServerSettingsService["Service"]>;
441495 externalLauncher?: Partial<ExternalLauncher.ExternalLauncher["Service"]>;
442496 vcsDriver?: Partial<VcsDriver.VcsDriver["Service"]>;
......@@ -706,6 +760,18 @@ const buildAppUnderTest = (options?: {
706760 uploadFeedback: () => Effect.die("Provider feedback is not stubbed in this test"),
707761 ...options?.layers?.providerService,
708762 }),
763 Layer.mock(ProviderAuthService)({
764 ...options?.layers?.providerAuth,
765 }),
766 Layer.mock(ProviderInstanceRegistry)({
767 getInstance: () => Effect.succeed(undefined),
768 listInstances: Effect.succeed([]),
769 ...options?.layers?.providerInstanceRegistry,
770 }),
771 Layer.mock(AntigravityInstallation)({
772 managedDirectory: "unused-test-antigravity-runtime",
773 ...options?.layers?.antigravityInstallation,
774 }),
709775 ),
710776 ),
711777 Layer.provide(
......@@ -4981,6 +5047,307 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
49815047 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
49825048 );
49835049
5050 it.effect("provider setup lets read-only clients observe installation but not change setup", () =>
5051 Effect.gen(function* () {
5052 let installStarts = 0;
5053 let authCalls = 0;
5054 yield* buildAppUnderTest({
5055 layers: {
5056 providerInstanceRegistry: {
5057 getInstance: (instanceId) =>
5058 Effect.succeed(
5059 instanceId === providerSetupInstanceId ? providerSetupInstance : undefined,
5060 ),
5061 },
5062 antigravityInstallation: {
5063 start: Effect.sync(() => {
5064 installStarts += 1;
5065 return providerSetupInstallState;
5066 }),
5067 changes: Stream.succeed(providerSetupInstallState),
5068 },
5069 providerAuth: {
5070 start: () =>
5071 Effect.sync(() => {
5072 authCalls += 1;
5073 return providerSetupAuthState;
5074 }),
5075 subscribe: () =>
5076 Stream.fromEffect(
5077 Effect.sync(() => {
5078 authCalls += 1;
5079 return providerSetupAuthState;
5080 }),
5081 ),
5082 },
5083 },
5084 });
5085 const token = yield* exchangeAccessToken(defaultDesktopBootstrapToken, {
5086 scope: "orchestration:read",
5087 });
5088 assert.equal(token.response.status, 200);
5089 const ticketResponse = yield* HttpClient.post("/api/auth/websocket-ticket", {
5090 headers: { authorization: `Bearer ${token.body.access_token ?? ""}` },
5091 });
5092 const { ticket } = yield* responseJsonEffect<{ readonly ticket: string }>(ticketResponse);
5093 const wsUrl = `${yield* getWsServerUrl("/ws", { authenticated: false })}?wsTicket=${encodeURIComponent(ticket)}`;
5094 yield* Effect.scoped(
5095 withWsRpcClient(wsUrl, (client) =>
5096 Effect.gen(function* () {
5097 const observed = yield* client[WS_METHODS.providerInstallSubscribe]({
5098 instanceId: providerSetupInstanceId,
5099 }).pipe(Stream.runHead, Effect.map(Option.getOrThrow));
5100 assert.deepEqual(observed, providerSetupInstallState);
5101 const errors = [
5102 yield* client[WS_METHODS.providerInstallStart]({
5103 instanceId: providerSetupInstanceId,
5104 }).pipe(Effect.flip),
5105 yield* client[WS_METHODS.providerAuthStart]({
5106 instanceId: providerSetupInstanceId,
5107 }).pipe(Effect.flip),
5108 yield* client[WS_METHODS.providerAuthSubscribe]({
5109 instanceId: providerSetupInstanceId,
5110 }).pipe(Stream.runHead, Effect.flip),
5111 ];
5112 for (const error of errors) {
5113 assert.equal(error._tag, "EnvironmentAuthorizationError");
5114 if (error._tag === "EnvironmentAuthorizationError") {
5115 assert.equal(error.requiredScope, "orchestration:operate");
5116 }
5117 }
5118 }),
5119 ),
5120 );
5121 assert.equal(installStarts, 0);
5122 assert.equal(authCalls, 0);
5123 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5124 );
5125
5126 it.effect("provider setup binds private sign-in to the authenticated websocket session", () =>
5127 Effect.gen(function* () {
5128 const flowId = "private-sign-in-flow";
5129 const callbackUrl = "http://127.0.0.1:51234/?state=test-state&code=test-code";
5130 const waiting: ProviderAuthState = {
5131 ...providerSetupAuthState,
5132 phase: "waiting",
5133 flowId,
5134 authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=test-state",
5135 expiresAt: "2026-09-02T00:05:00.000Z",
5136 };
5137 const calls: Array<{
5138 readonly operation: string;
5139 readonly instanceId: ProviderInstanceId;
5140 readonly ownerSessionId: string;
5141 }> = [];
5142 const forwardedCallbacks: string[] = [];
5143 const logoutInstances: ProviderInstanceId[] = [];
5144 let flowOwner = "";
5145 yield* buildAppUnderTest({
5146 layers: {
5147 providerAuth: {
5148 start: (input, ownerSessionId) =>
5149 Effect.sync(() => {
5150 flowOwner = ownerSessionId;
5151 calls.push({ operation: "start", instanceId: input.instanceId, ownerSessionId });
5152 return waiting;
5153 }),
5154 subscribe: (input, ownerSessionId) =>
5155 Stream.fromEffect(
5156 Effect.sync(() => {
5157 calls.push({
5158 operation: "subscribe",
5159 instanceId: input.instanceId,
5160 ownerSessionId,
5161 });
5162 return ownerSessionId === flowOwner
5163 ? waiting
5164 : { ...waiting, flowId: null, authorizationUrl: null, expiresAt: null };
5165 }),
5166 ),
5167 complete: (input, ownerSessionId) =>
5168 Effect.gen(function* () {
5169 calls.push({ operation: "complete", instanceId: input.instanceId, ownerSessionId });
5170 if (ownerSessionId !== flowOwner) {
5171 return yield* new ProviderSetupError({
5172 instanceId: input.instanceId,
5173 operation: "complete",
5174 detail: "This sign-in belongs to another client.",
5175 });
5176 }
5177 assert.equal(input.flowId, flowId);
5178 forwardedCallbacks.push(input.callbackUrl);
5179 return { ...waiting, phase: "verifying" as const, authorizationUrl: null };
5180 }),
5181 cancel: (input, ownerSessionId) =>
5182 Effect.sync(() => {
5183 assert.equal(input.flowId, flowId);
5184 calls.push({ operation: "cancel", instanceId: input.instanceId, ownerSessionId });
5185 return { ...providerSetupAuthState, phase: "cancelled" as const, flowId };
5186 }),
5187 logout: (input) =>
5188 Effect.sync(() => {
5189 logoutInstances.push(input.instanceId);
5190 return providerSetupAuthState;
5191 }),
5192 },
5193 },
5194 });
5195 const firstCookie = yield* getAuthenticatedSessionCookieHeader();
5196 const secondCookie = yield* getAuthenticatedSessionCookieHeader();
5197 const firstClients = yield* HttpClient.get("/api/auth/clients", {
5198 headers: { cookie: firstCookie },
5199 }).pipe(
5200 Effect.flatMap(
5201 responseJsonEffect<
5202 ReadonlyArray<{ readonly sessionId: string; readonly current: boolean }>
5203 >,
5204 ),
5205 );
5206 const secondClients = yield* HttpClient.get("/api/auth/clients", {
5207 headers: { cookie: secondCookie },
5208 }).pipe(
5209 Effect.flatMap(
5210 responseJsonEffect<
5211 ReadonlyArray<{ readonly sessionId: string; readonly current: boolean }>
5212 >,
5213 ),
5214 );
5215 const firstOwner = firstClients.find((session) => session.current)?.sessionId;
5216 const secondOwner = secondClients.find((session) => session.current)?.sessionId;
5217 assert.isString(firstOwner);
5218 assert.isString(secondOwner);
5219 assert.notEqual(firstOwner, secondOwner);
5220 const baseWsUrl = yield* getWsServerUrl("/ws", { authenticated: false });
5221 const target = {
5222 instanceId: providerSetupInstanceId,
5223 ownerSessionId: "client-supplied-owner",
5224 };
5225 yield* Effect.scoped(
5226 withWsRpcClient(appendSessionCookieToWsUrl(baseWsUrl, firstCookie), (client) =>
5227 Effect.gen(function* () {
5228 const started = yield* client[WS_METHODS.providerAuthStart](target);
5229 assert.equal(started.flowId, flowId);
5230 const ownState = yield* client[WS_METHODS.providerAuthSubscribe](target).pipe(
5231 Stream.runHead,
5232 Effect.map(Option.getOrThrow),
5233 );
5234 assert.equal(ownState.authorizationUrl, waiting.authorizationUrl);
5235 yield* Effect.scoped(
5236 withWsRpcClient(appendSessionCookieToWsUrl(baseWsUrl, secondCookie), (otherClient) =>
5237 Effect.gen(function* () {
5238 const otherState = yield* otherClient[WS_METHODS.providerAuthSubscribe](
5239 target,
5240 ).pipe(Stream.runHead, Effect.map(Option.getOrThrow));
5241 assert.isNull(otherState.authorizationUrl);
5242 assert.isNull(otherState.flowId);
5243 const forged = { ...target, ownerSessionId: firstOwner, flowId, callbackUrl };
5244 const denied = yield* otherClient[WS_METHODS.providerAuthComplete](forged).pipe(
5245 Effect.flip,
5246 );
5247 assert.equal(denied._tag, "ProviderSetupError");
5248 assert.deepEqual(forwardedCallbacks, []);
5249 }),
5250 ),
5251 );
5252 const completed = yield* client[WS_METHODS.providerAuthComplete]({
5253 ...target,
5254 flowId,
5255 callbackUrl,
5256 });
5257 assert.equal(completed.phase, "verifying");
5258 const cancelled = yield* client[WS_METHODS.providerAuthCancel]({ ...target, flowId });
5259 assert.equal(cancelled.phase, "cancelled");
5260 const signedOut = yield* client[WS_METHODS.providerAuthLogout](target);
5261 assert.equal(signedOut.phase, "idle");
5262 }),
5263 ),
5264 );
5265 assert.deepEqual(forwardedCallbacks, [callbackUrl]);
5266 assert.deepEqual(logoutInstances, [providerSetupInstanceId]);
5267 assert.isTrue(calls.every((call) => call.instanceId === providerSetupInstanceId));
5268 assert.deepEqual(
5269 calls.map((call) => call.ownerSessionId),
5270 [firstOwner, firstOwner, secondOwner, secondOwner, firstOwner, firstOwner],
5271 );
5272 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5273 );
5274
5275 it.effect(
5276 "provider setup routes installation operations and returns only safe typed errors",
5277 () =>
5278 Effect.gen(function* () {
5279 const calls: string[] = [];
5280 let state = providerSetupInstallState;
5281 yield* buildAppUnderTest({
5282 layers: {
5283 providerInstanceRegistry: {
5284 getInstance: (instanceId) =>
5285 Effect.succeed(
5286 instanceId === providerSetupInstanceId ? providerSetupInstance : undefined,
5287 ),
5288 },
5289 antigravityInstallation: {
5290 start: Effect.sync(() => {
5291 calls.push("start");
5292 return state;
5293 }),
5294 cancel: (operationId) =>
5295 Effect.gen(function* () {
5296 calls.push(`cancel:${operationId}`);
5297 if (operationId !== state.operationId) {
5298 return yield* new AntigravityInstallationError({
5299 operation: "cancel",
5300 detail: "This installation is no longer running.",
5301 cause: new Error("Private download diagnostics."),
5302 });
5303 }
5304 state = { ...state, phase: "cancelled" };
5305 return state;
5306 }),
5307 changes: Stream.fromEffect(Effect.sync(() => state)),
5308 },
5309 },
5310 });
5311 const wsUrl = yield* getWsServerUrl("/ws");
5312 yield* Effect.scoped(
5313 withWsRpcClient(wsUrl, (client) =>
5314 Effect.gen(function* () {
5315 const unknownInstance = yield* client[WS_METHODS.providerInstallStart]({
5316 instanceId: ProviderInstanceId.make("unknown-instance"),
5317 }).pipe(Effect.flip);
5318 assert.equal(unknownInstance._tag, "ProviderSetupError");
5319 assert.deepEqual(calls, []);
5320 const started = yield* client[WS_METHODS.providerInstallStart]({
5321 instanceId: providerSetupInstanceId,
5322 });
5323 assert.deepEqual(started, providerSetupInstallState);
5324 const stale = yield* client[WS_METHODS.providerInstallCancel]({
5325 instanceId: providerSetupInstanceId,
5326 operationId: "old-operation",
5327 }).pipe(Effect.flip);
5328 assert.equal(stale._tag, "ProviderSetupError");
5329 if (stale._tag === "ProviderSetupError") {
5330 assert.equal(stale.instanceId, providerSetupInstanceId);
5331 assert.equal(stale.operation, "cancel");
5332 assert.equal(stale.detail, "This installation is no longer running.");
5333 assert.notProperty(stale, "cause");
5334 }
5335 const cancelled = yield* client[WS_METHODS.providerInstallCancel]({
5336 instanceId: providerSetupInstanceId,
5337 operationId: "install-operation",
5338 });
5339 assert.equal(cancelled.phase, "cancelled");
5340 const observed = yield* client[WS_METHODS.providerInstallSubscribe]({
5341 instanceId: providerSetupInstanceId,
5342 }).pipe(Stream.runHead, Effect.map(Option.getOrThrow));
5343 assert.deepEqual(observed, cancelled);
5344 }),
5345 ),
5346 );
5347 assert.deepEqual(calls, ["start", "cancel:old-operation", "cancel:install-operation"]);
5348 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5349 );
5350
49845351 it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () =>
49855352 Effect.gen(function* () {
49865353 const path = yield* Path.Path;
apps/server/src/server.ts+37-1
......@@ -1,9 +1,10 @@
1import { EnvironmentHttpApi } from "@t3tools/contracts";
1import { EnvironmentHttpApi, ProviderDriverKind } from "@t3tools/contracts";
22import * as Duration from "effect/Duration";
33import * as Deferred from "effect/Deferred";
44import * as Effect from "effect/Effect";
55import * as Layer from "effect/Layer";
66import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
78import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http";
89import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
910
......@@ -35,6 +36,10 @@ import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRe
3536import * as ModelManifest from "./provider/ModelManifest.ts";
3637import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts";
3738import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts";
39import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts";
40import { AntigravityInstallation } from "./provider/AntigravityInstallation.ts";
41import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts";
42import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts";
3843import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts";
3944import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts";
4045import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
......@@ -406,7 +411,36 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(
406411 Layer.provideMerge(OrchestrationLayerLive),
407412);
408413
414const AntigravityInstallationRefreshLive = Layer.effectDiscard(
415 Effect.gen(function* () {
416 const installation = yield* AntigravityInstallation;
417 const instances = yield* ProviderInstanceRegistry;
418 const providers = yield* ProviderRegistry;
419 yield* installation.changes.pipe(
420 Stream.map((state) => state.installedVersion),
421 Stream.changes,
422 Stream.drop(1),
423 Stream.runForEach(() =>
424 instances.listInstances.pipe(
425 Effect.flatMap((entries) =>
426 Effect.forEach(
427 entries.filter(
428 (instance) => instance.driverKind === ProviderDriverKind.make("antigravity"),
429 ),
430 (instance) => providers.refreshInstance(instance.instanceId),
431 { discard: true },
432 ),
433 ),
434 ),
435 ),
436 Effect.forkScoped,
437 );
438 }),
439);
440
409441const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
442 Layer.provideMerge(AntigravityInstallationRefreshLive),
443 Layer.provideMerge(ProviderAuthServiceLive),
410444 // Core Services
411445 Layer.provideMerge(ServerSettingsLayerLive),
412446 Layer.provideMerge(CheckpointingLayerLive),
......@@ -428,6 +462,8 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
428462 // `providerInstances` hydration merges `settings.providers.<kind>`
429463 // with explicit `providerInstances` entries on boot.
430464 Layer.provideMerge(ProviderInstanceRegistryHydrationLive),
465).pipe(
466 Layer.provideMerge(AntigravityInstallation.layer),
431467 // Shared native/canonical NDJSON writers used by both the per-instance
432468 // drivers (native stream, written from inside each `<X>Adapter`) and
433469 // `ProviderService` (canonical stream, written after event normalization).
apps/server/src/serverRuntimeStartup.reconcile.test.ts+1
......@@ -57,6 +57,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray<ThreadId> = []) =>
5757 stopSession: () => Effect.die("unused"),
5858 listSessions: () => Effect.succeed(liveThreadIds.map((threadId) => ({ threadId }) as never)),
5959 getCapabilities: () => Effect.die("unused"),
60 assertConversationRollbackSupported: () => Effect.die("unused"),
6061 getInstanceInfo: () => Effect.die("unused"),
6162 rollbackConversation: () => Effect.die("unused"),
6263 uploadFeedback: () => Effect.die("unused"),
apps/server/src/textGeneration/AntigravityTextGeneration.test.ts created+633
......@@ -0,0 +1,633 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it } from "@effect/vitest";
3import {
4 ANTIGRAVITY_DEFAULT_MODEL,
5 ProviderInstanceId,
6 ProviderSetupError,
7} from "@t3tools/contracts";
8import * as Deferred from "effect/Deferred";
9import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";
11import * as Fiber from "effect/Fiber";
12import * as FileSystem from "effect/FileSystem";
13import * as Path from "effect/Path";
14import * as Queue from "effect/Queue";
15import * as Schema from "effect/Schema";
16import * as Scope from "effect/Scope";
17import * as Stream from "effect/Stream";
18import * as TestClock from "effect/testing/TestClock";
19import { type AcpError, AcpRequestError } from "effect-acp/errors";
20import type * as AcpSchema from "effect-acp/schema";
21import { expect } from "vite-plus/test";
22
23import type { AcpSessionRuntimeEvent } from "../provider/acp/AcpSessionRuntime.ts";
24import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts";
25
26import {
27 type AntigravityTextGenerationOptions,
28 isAntigravityTextGenerationAvailable,
29 makeAntigravityTextGeneration,
30} from "./AntigravityTextGeneration.ts";
31
32type TextRuntime = Effect.Success<ReturnType<AntigravityTextGenerationOptions["makeRuntime"]>>;
33
34const SESSION_ID = "047c62f6-607b-44db-bfbe-f83b67e9e8b1";
35const modelSelection = {
36 instanceId: ProviderInstanceId.make("antigravity-test"),
37 model: "gemini-test",
38};
39const encodeMetadata = Schema.encodeEffect(
40 Schema.fromJsonString(Schema.Struct({ cwd: Schema.String })),
41);
42
43interface PromptContext {
44 readonly emit: (
45 update: AcpSchema.SessionNotification["update"],
46 sessionId?: string,
47 ) => Effect.Effect<void, AcpError>;
48 permission: Parameters<TextRuntime["handleRequestPermission"]>[0];
49 question: Parameters<TextRuntime["handleElicitation"]>[0];
50 readFile: Parameters<TextRuntime["handleReadTextFile"]>[0];
51 createTerminal: Parameters<TextRuntime["handleCreateTerminal"]>[0];
52 extension: Parameters<TextRuntime["handleUnknownExtRequest"]>[0];
53 readonly cwd: string;
54}
55
56const makeFixture = Effect.fn("makeAntigravityTextGenerationFixture")(function* (
57 options: {
58 readonly outputs?: ReadonlyArray<string>;
59 readonly prompt?: (context: PromptContext) => Effect.Effect<AcpSchema.PromptResponse, AcpError>;
60 readonly startError?: AcpError;
61 readonly rejectAdmission?: boolean;
62 readonly sessionId?: string;
63 } = {},
64) {
65 const fs = yield* FileSystem.FileSystem;
66 const path = yield* Path.Path;
67 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-antigravity-text-test-" });
68 const profileDirectory = path.join(root, "profile");
69 const projectDirectory = path.join(root, "project");
70 const conversations = path.join(profileDirectory, "antigravity-acp", "conversations");
71 const nativeSessionId = options.sessionId ?? SESSION_ID;
72 const sessionBase = path.join(conversations, SESSION_ID);
73 const brainDirectory = path.join(profileDirectory, "antigravity-acp", "brain", SESSION_ID);
74 yield* fs.makeDirectory(projectDirectory, { recursive: true });
75 yield* fs.makeDirectory(conversations, { recursive: true });
76 yield* fs.writeFileString(path.join(projectDirectory, "keep.txt"), "untouched");
77 yield* fs.writeFileString(path.join(conversations, "user-session.db"), "keep native history");
78 const enteredPrompt = yield* Deferred.make<void>();
79 const state = {
80 workspaces: [] as Array<string>,
81 closed: [] as Array<string>,
82 prompts: [] as Array<Parameters<TextRuntime["prompt"]>[0]>,
83 selectedModels: [] as Array<string>,
84 selectedModes: [] as Array<string>,
85 nativeFilesAtClose: [] as Array<boolean>,
86 cancellations: 0,
87 stop: undefined as Effect.Effect<void> | undefined,
88 };
89 const outputs = [...(options.outputs ?? ['{"title":"Repair login"}'])];
90 const incoming: Omit<PromptContext, "cwd" | "emit"> = {
91 permission: () => Effect.die("No permission handler."),
92 question: () => Effect.die("No question handler."),
93 readFile: () => Effect.die("No file handler."),
94 createTerminal: () => Effect.die("No terminal handler."),
95 extension: () => Effect.die("No extension handler."),
96 };
97
98 const withProcess: AntigravityTextGenerationOptions["withProcess"] = (stop, task) =>
99 Effect.gen(function* () {
100 if (options.rejectAdmission) {
101 return yield* new ProviderSetupError({
102 instanceId: modelSelection.instanceId,
103 operation: "launch",
104 detail: "Sign in before starting Antigravity.",
105 });
106 }
107 const scope = yield* Scope.Scope;
108 const child = yield* Effect.forkIn(task, scope);
109 state.stop = Fiber.interrupt(child).pipe(Effect.andThen(stop));
110 yield* Effect.addFinalizer(() =>
111 Effect.sync(() => {
112 state.stop = undefined;
113 }),
114 );
115 return yield* Fiber.await(child).pipe(
116 Effect.flatMap((exit) => exit),
117 Effect.ensuring(Fiber.interrupt(child)),
118 );
119 });
120
121 const makeRuntime: AntigravityTextGenerationOptions["makeRuntime"] = (cwd) =>
122 Effect.gen(function* () {
123 const events = yield* Queue.unbounded<AcpSessionRuntimeEvent>();
124 state.workspaces.push(cwd);
125 expect(yield* fs.readDirectory(cwd).pipe(Effect.orDie)).toEqual([]);
126 yield* Effect.addFinalizer(() =>
127 Effect.gen(function* () {
128 state.nativeFilesAtClose.push(yield* fs.exists(`${sessionBase}.db`).pipe(Effect.orDie));
129 state.closed.push(cwd);
130 }),
131 );
132 let sessionUpdate: Parameters<TextRuntime["handleSessionUpdate"]>[0] = () => Effect.void;
133
134 return {
135 start: () =>
136 Effect.gen(function* () {
137 if (options.startError) return yield* options.startError;
138 yield* fs.makeDirectory(brainDirectory, { recursive: true }).pipe(Effect.orDie);
139 yield* fs.writeFileString(`${sessionBase}.db`, "helper session").pipe(Effect.orDie);
140 yield* fs.writeFileString(`${sessionBase}.db-wal`, "helper journal").pipe(Effect.orDie);
141 const metadata = yield* encodeMetadata({ cwd }).pipe(Effect.orDie);
142 yield* fs.writeFileString(`${sessionBase}.meta`, metadata).pipe(Effect.orDie);
143 yield* fs
144 .writeFileString(path.join(brainDirectory, "output.txt"), "helper artifact")
145 .pipe(Effect.orDie);
146 return {
147 sessionId: nativeSessionId,
148 initializeResult: { protocolVersion: 1 },
149 sessionSetupResult: { sessionId: nativeSessionId },
150 modelConfigId: "model",
151 };
152 }),
153 setMode: (mode) =>
154 Effect.sync(() => {
155 state.selectedModes.push(mode);
156 return {};
157 }),
158 getConfigOptions: Effect.succeed([
159 {
160 id: "model",
161 name: "Model",
162 category: "model",
163 type: "select",
164 currentValue: modelSelection.model,
165 options: [{ value: modelSelection.model, name: "Gemini test" }],
166 },
167 ]),
168 getEvents: () => Stream.fromQueue(events),
169 setModel: (model) =>
170 Effect.sync(() => {
171 state.selectedModels.push(model);
172 }),
173 prompt: (request) =>
174 Effect.gen(function* () {
175 state.prompts.push(request);
176 yield* Deferred.succeed(enteredPrompt, undefined);
177 const emit: PromptContext["emit"] = (update, sessionId = nativeSessionId) =>
178 sessionUpdate({ sessionId, update });
179 if (options.prompt) {
180 return yield* options.prompt({
181 emit,
182 ...incoming,
183 cwd,
184 });
185 }
186 const output = outputs.shift() ?? '{"title":"Repair login"}';
187 yield* emit({
188 sessionUpdate: "agent_thought_chunk",
189 content: { type: "text", text: "Choose concise text." },
190 });
191 yield* emit(
192 {
193 sessionUpdate: "agent_message_chunk",
194 content: { type: "text", text: "unrelated session" },
195 },
196 "another-session",
197 );
198 for (const text of [output.slice(0, 9), output.slice(9)]) {
199 yield* emit({
200 sessionUpdate: "agent_message_chunk",
201 content: { type: "text", text },
202 });
203 }
204 return { stopReason: "end_turn" };
205 }),
206 cancel: Effect.gen(function* () {
207 state.cancellations += 1;
208 const acknowledge = yield* Deferred.make<void>();
209 yield* Queue.offer(events, { _tag: "EventStreamBarrier", acknowledge });
210 yield* Deferred.await(acknowledge);
211 }),
212 handleSessionUpdate: (handler) =>
213 Effect.sync(() => {
214 sessionUpdate = handler;
215 }),
216 handleRequestPermission: (handler) =>
217 Effect.sync(() => {
218 incoming.permission = handler;
219 }),
220 handleElicitation: (handler) =>
221 Effect.sync(() => {
222 incoming.question = handler;
223 }),
224 handleReadTextFile: (handler) =>
225 Effect.sync(() => {
226 incoming.readFile = handler;
227 }),
228 handleWriteTextFile: () => Effect.void,
229 handleCreateTerminal: (handler) =>
230 Effect.sync(() => {
231 incoming.createTerminal = handler;
232 }),
233 handleTerminalOutput: () => Effect.void,
234 handleTerminalWaitForExit: () => Effect.void,
235 handleTerminalKill: () => Effect.void,
236 handleTerminalRelease: () => Effect.void,
237 handleUnknownExtRequest: (handler) =>
238 Effect.sync(() => {
239 incoming.extension = handler;
240 }),
241 } satisfies TextRuntime;
242 });
243
244 const textGeneration = yield* makeAntigravityTextGeneration({
245 profileDirectory,
246 makeRuntime,
247 withProcess,
248 });
249 const titleInput = { cwd: projectDirectory, message: "Repair Google login", modelSelection };
250 const assertCleaned = Effect.gen(function* () {
251 expect(state.closed).toEqual(state.workspaces);
252 for (const workspace of state.workspaces) {
253 expect(yield* fs.exists(workspace)).toBe(false);
254 }
255 expect(yield* fs.exists(`${sessionBase}.db`)).toBe(false);
256 expect(yield* fs.exists(`${sessionBase}.db-wal`)).toBe(false);
257 expect(yield* fs.exists(`${sessionBase}.meta`)).toBe(false);
258 expect(yield* fs.exists(brainDirectory)).toBe(false);
259 expect(yield* fs.readFileString(path.join(projectDirectory, "keep.txt"))).toBe("untouched");
260 expect(yield* fs.readFileString(path.join(conversations, "user-session.db"))).toBe(
261 "keep native history",
262 );
263 });
264 return {
265 fs,
266 path,
267 profileDirectory,
268 projectDirectory,
269 sessionBase,
270 brainDirectory,
271 state,
272 incoming,
273 enteredPrompt,
274 textGeneration,
275 titleInput,
276 assertCleaned,
277 };
278});
279
280it.layer(NodeServices.layer)("AntigravityTextGeneration", (it) => {
281 it.effect(
282 "generates all helper types in empty workspaces and removes only owned session files",
283 () =>
284 Effect.gen(function* () {
285 const fixture = yield* makeFixture({
286 outputs: [
287 '{"subject":" Repair Google login.\\nExtra line","body":" Keep the remote callback. ","branch":"Repair Login"}',
288 '```json\n{"title":" Repair Google login\\nExtra line","body":" ## Summary\\nSupport remote callbacks. "}\n```',
289 '{"branch":" Repair Google Login "}',
290 '{"title":" \\"Repair Google login\\" "}',
291 ],
292 });
293 const common = { cwd: fixture.projectDirectory, modelSelection };
294 expect(
295 yield* fixture.textGeneration.generateCommitMessage({
296 ...common,
297 branch: "feature/login",
298 stagedSummary: "M login.ts",
299 stagedPatch: "+handleRemoteCallback()",
300 includeBranch: true,
301 }),
302 ).toEqual({
303 subject: "Repair Google login",
304 body: "Keep the remote callback.",
305 branch: "feature/repair-login",
306 });
307 expect(
308 yield* fixture.textGeneration.generatePrContent({
309 ...common,
310 baseBranch: "main",
311 headBranch: "feature/login",
312 commitSummary: "Repair login",
313 diffSummary: "M login.ts",
314 diffPatch: "+handleRemoteCallback()",
315 }),
316 ).toEqual({ title: "Repair Google login", body: "## Summary\nSupport remote callbacks." });
317 expect(
318 yield* fixture.textGeneration.generateBranchName({
319 ...common,
320 message: "Repair Google login",
321 }),
322 ).toEqual({ branch: "repair-google-login" });
323 expect(yield* fixture.textGeneration.generateThreadTitle(fixture.titleInput)).toEqual({
324 title: "Repair Google login",
325 });
326 expect(new Set(fixture.state.workspaces).size).toBe(4);
327 expect(fixture.state.workspaces).not.toContain(fixture.projectDirectory);
328 expect(fixture.state.nativeFilesAtClose).toEqual([true, true, true, true]);
329 expect(fixture.state.selectedModes).toEqual(["default", "default", "default", "default"]);
330 expect(fixture.state.selectedModels).toEqual(Array(4).fill(modelSelection.model));
331 expect(fixture.state.prompts[0]?.prompt).toEqual([
332 { type: "text", text: expect.stringContaining("+handleRemoteCallback()") },
333 ]);
334 yield* fixture.assertCleaned;
335 }).pipe(Effect.scoped),
336 );
337
338 it.effect.each(["tool_call", "tool_call_update"] as const)(
339 "aborts on %s even without a permission request",
340 (sessionUpdate) =>
341 Effect.gen(function* () {
342 const fixture = yield* makeFixture({
343 prompt: ({ emit }) =>
344 emit({ sessionUpdate, toolCallId: "tool-1", title: "Read files" }).pipe(
345 Effect.andThen(Effect.never),
346 ),
347 });
348 const error = yield* fixture.textGeneration
349 .generateThreadTitle(fixture.titleInput)
350 .pipe(Effect.flip);
351 expect(error.detail).toContain("tool work");
352 expect(fixture.state.cancellations).toBe(1);
353 yield* fixture.assertCleaned;
354 }).pipe(Effect.scoped),
355 );
356
357 it.effect("rejects native permission and choice requests instead of answering them", () =>
358 Effect.gen(function* () {
359 const fixture = yield* makeFixture({ prompt: () => Effect.never });
360 const child = yield* fixture.textGeneration
361 .generateThreadTitle(fixture.titleInput)
362 .pipe(Effect.forkChild);
363 yield* Deferred.await(fixture.enteredPrompt);
364 const reply = yield* fixture.incoming.permission({
365 sessionId: SESSION_ID,
366 toolCall: { toolCallId: "question-1", title: "Choose a title", kind: "other" },
367 options: [
368 { optionId: "first-native-choice", name: "Use this title", kind: "allow_once" },
369 { optionId: "second-native-choice", name: "Use this title", kind: "allow_once" },
370 ],
371 });
372 const error = yield* Fiber.join(child).pipe(Effect.flip);
373 expect(reply).toEqual({ outcome: { outcome: "cancelled" } });
374 expect(error.detail).toContain("permission or user input");
375 yield* fixture.assertCleaned;
376 }).pipe(Effect.scoped),
377 );
378
379 it.effect("declines elicitation and stops the helper", () =>
380 Effect.gen(function* () {
381 const fixture = yield* makeFixture({ prompt: () => Effect.never });
382 const child = yield* fixture.textGeneration
383 .generateThreadTitle(fixture.titleInput)
384 .pipe(Effect.forkChild);
385 yield* Deferred.await(fixture.enteredPrompt);
386 const reply = yield* fixture.incoming.question({
387 sessionId: SESSION_ID,
388 mode: "form",
389 message: "Name this branch",
390 requestedSchema: { type: "object", properties: {} },
391 });
392 const error = yield* Fiber.join(child).pipe(Effect.flip);
393 expect(reply).toEqual({ action: { action: "decline" } });
394 expect(error.detail).toContain("user input");
395 yield* fixture.assertCleaned;
396 }).pipe(Effect.scoped),
397 );
398
399 it.effect.each(["file", "terminal", "extension"] as const)(
400 "denies unexpected %s requests",
401 (kind) =>
402 Effect.gen(function* () {
403 const fixture = yield* makeFixture({ prompt: () => Effect.never });
404 const child = yield* fixture.textGeneration
405 .generateThreadTitle(fixture.titleInput)
406 .pipe(Effect.forkChild);
407 yield* Deferred.await(fixture.enteredPrompt);
408 const request =
409 kind === "file"
410 ? fixture.incoming.readFile({ sessionId: SESSION_ID, path: "/not-allowed" })
411 : kind === "terminal"
412 ? fixture.incoming.createTerminal({ sessionId: SESSION_ID, command: "not-allowed" })
413 : fixture.incoming.extension("_ask_user", {});
414 const denied = yield* request.pipe(Effect.exit);
415 expect(Exit.isFailure(denied)).toBe(true);
416 const error = yield* Fiber.join(child).pipe(Effect.flip);
417 expect(error.detail).toContain("tool or user input");
418 yield* fixture.assertCleaned;
419 }).pipe(Effect.scoped),
420 );
421
422 it.effect.each([
423 { output: " ", detail: "empty" },
424 { output: "No JSON here", detail: "invalid structured output" },
425 { output: '{"title":42}', detail: "invalid structured output" },
426 { output: "x".repeat(128_001), detail: "output limit" },
427 ])("rejects $detail output and closes the runtime", ({ output, detail }) =>
428 Effect.gen(function* () {
429 const fixture = yield* makeFixture({ outputs: [output] });
430 const error = yield* fixture.textGeneration
431 .generateThreadTitle(fixture.titleInput)
432 .pipe(Effect.flip);
433 expect(error.detail).toContain(detail);
434 yield* fixture.assertCleaned;
435 }).pipe(Effect.scoped),
436 );
437
438 it.effect("does not return partial JSON after native cancellation", () =>
439 Effect.gen(function* () {
440 const fixture = yield* makeFixture({
441 prompt: ({ emit }) =>
442 emit({
443 sessionUpdate: "agent_message_chunk",
444 content: { type: "text", text: '{"title":"Partial title"}' },
445 }).pipe(Effect.as({ stopReason: "cancelled" })),
446 });
447 const error = yield* fixture.textGeneration
448 .generateThreadTitle(fixture.titleInput)
449 .pipe(Effect.flip);
450 expect(error.detail).toContain("cancelled");
451 yield* fixture.assertCleaned;
452 }).pipe(Effect.scoped),
453 );
454
455 it.effect("rejects a helper that writes files in its temporary workspace", () =>
456 Effect.gen(function* () {
457 const fs = yield* FileSystem.FileSystem;
458 const path = yield* Path.Path;
459 const fixture = yield* makeFixture({
460 prompt: ({ cwd, emit }) =>
461 Effect.gen(function* () {
462 yield* fs
463 .writeFileString(path.join(cwd, "unexpected.txt"), "unexpected")
464 .pipe(Effect.orDie);
465 yield* emit({
466 sessionUpdate: "agent_message_chunk",
467 content: { type: "text", text: '{"title":"Ignored output"}' },
468 });
469 return { stopReason: "end_turn" };
470 }),
471 });
472 const error = yield* fixture.textGeneration
473 .generateThreadTitle(fixture.titleInput)
474 .pipe(Effect.flip);
475 expect(error.detail).toContain("wrote files");
476 yield* fixture.assertCleaned;
477 }).pipe(Effect.scoped),
478 );
479
480 it.effect("fails an unavailable model before prompting", () =>
481 Effect.gen(function* () {
482 const fixture = yield* makeFixture();
483 const error = yield* fixture.textGeneration
484 .generateThreadTitle({
485 ...fixture.titleInput,
486 modelSelection: { ...modelSelection, model: "not-in-the-account" },
487 })
488 .pipe(Effect.flip);
489 expect(error.detail).toContain("select the Antigravity model");
490 expect(fixture.state.prompts).toEqual([]);
491 yield* fixture.assertCleaned;
492 }).pipe(Effect.scoped),
493 );
494
495 it.effect("uses the native default without sending T3's default selection as a model ID", () =>
496 Effect.gen(function* () {
497 const fixture = yield* makeFixture();
498 const result = yield* fixture.textGeneration.generateThreadTitle({
499 ...fixture.titleInput,
500 modelSelection: { ...modelSelection, model: ANTIGRAVITY_DEFAULT_MODEL },
501 });
502 expect(result).toEqual({ title: "Repair login" });
503 expect(fixture.state.selectedModels).toEqual([]);
504 yield* fixture.assertCleaned;
505 }).pipe(Effect.scoped),
506 );
507
508 it.effect.each([
509 {
510 name: "hooks.json",
511 value: '{"default":{"PreInvocation":[{"command":"do-not-run"}]}}',
512 available: false,
513 },
514 {
515 name: "mcp_config.json",
516 value: '{"mcpServers":{"server":{"command":"do-not-run"}}}',
517 available: false,
518 },
519 { name: "hooks.json", value: "invalid JSON", available: false },
520 { name: "mcp_config.json", value: "x".repeat(64_001), available: false },
521 { name: "hooks.json", value: "{}", available: true },
522 { name: "hooks.json", value: '{"hooks":{}}', available: true },
523 { name: "mcp_config.json", value: '{"mcpServers":{}}', available: true },
524 ])("checks $name before starting a helper, available=$available", ({ name, value, available }) =>
525 Effect.gen(function* () {
526 const fixture = yield* makeFixture();
527 const configDirectory = fixture.path.join(fixture.profileDirectory, "config");
528 yield* fixture.fs.makeDirectory(configDirectory, { recursive: true });
529 yield* fixture.fs.writeFileString(fixture.path.join(configDirectory, name), value);
530 expect(yield* isAntigravityTextGenerationAvailable(fixture.profileDirectory)).toBe(available);
531 const result = yield* fixture.textGeneration
532 .generateThreadTitle(fixture.titleInput)
533 .pipe(Effect.exit);
534 expect(Exit.isSuccess(result)).toBe(available);
535 expect(fixture.state.workspaces.length).toBe(available ? 1 : 0);
536 yield* fixture.assertCleaned;
537 }).pipe(Effect.scoped),
538 );
539
540 it.effect("does not start another runtime or prompt after authentication fails", () =>
541 Effect.gen(function* () {
542 const fixture = yield* makeFixture({
543 startError: new AcpRequestError({ code: -32000, errorMessage: "Authentication required" }),
544 });
545 const error = yield* fixture.textGeneration
546 .generateThreadTitle(fixture.titleInput)
547 .pipe(Effect.flip);
548 expect(error._tag).toBe("TextGenerationError");
549 expect(fixture.state.workspaces).toHaveLength(1);
550 expect(fixture.state.prompts).toEqual([]);
551 yield* fixture.assertCleaned;
552 }).pipe(Effect.scoped),
553 );
554
555 it.effect("does not launch during sign-out or before sign-in", () =>
556 Effect.gen(function* () {
557 const fixture = yield* makeFixture({ rejectAdmission: true });
558 const error = yield* fixture.textGeneration
559 .generateThreadTitle(fixture.titleInput)
560 .pipe(Effect.flip);
561 expect(error._tag).toBe("TextGenerationError");
562 expect(fixture.state.workspaces).toEqual([]);
563 yield* fixture.assertCleaned;
564 }).pipe(Effect.scoped),
565 );
566
567 it.effect("times out a stalled helper and waits for cleanup", () =>
568 Effect.gen(function* () {
569 const fixture = yield* makeFixture({ prompt: () => Effect.never });
570 const child = yield* fixture.textGeneration
571 .generateThreadTitle(fixture.titleInput)
572 .pipe(Effect.forkChild);
573 yield* Deferred.await(fixture.enteredPrompt);
574 yield* TestClock.adjust(180_000);
575 const error = yield* Fiber.join(child).pipe(Effect.flip);
576 expect(error.detail).toContain("timed out");
577 expect(fixture.state.cancellations).toBe(1);
578 yield* fixture.assertCleaned;
579 }).pipe(Effect.scoped),
580 );
581
582 it.effect.each(["caller", "sign-out"] as const)(
583 "closes all helper resources when stopped by %s",
584 (source) =>
585 Effect.gen(function* () {
586 const fixture = yield* makeFixture({ prompt: () => Effect.never });
587 const child = yield* fixture.textGeneration
588 .generateThreadTitle(fixture.titleInput)
589 .pipe(Effect.forkChild);
590 yield* Deferred.await(fixture.enteredPrompt);
591 if (source === "caller") {
592 yield* Fiber.interrupt(child);
593 } else {
594 if (!fixture.state.stop) return yield* Effect.die("No tracked helper to stop.");
595 yield* fixture.state.stop;
596 yield* Fiber.await(child);
597 }
598 expect(fixture.state.cancellations).toBe(1);
599 yield* fixture.assertCleaned;
600 }).pipe(Effect.scoped),
601 );
602
603 it.effect("refuses unsafe native session IDs without deleting other files", () =>
604 Effect.gen(function* () {
605 const fixture = yield* makeFixture({ sessionId: "../../user-session" });
606 const error = yield* fixture.textGeneration
607 .generateThreadTitle(fixture.titleInput)
608 .pipe(Effect.flip);
609 expect(error.detail).toContain("invalid text helper session ID");
610 expect(fixture.state.prompts).toEqual([]);
611 expect(yield* fixture.fs.readFileString(`${fixture.sessionBase}.db`)).toBe("helper session");
612 expect(fixture.state.closed).toEqual(fixture.state.workspaces);
613 }).pipe(Effect.scoped),
614 );
615
616 it.effect("keeps native sessions whose metadata belongs to a different workspace", () =>
617 Effect.gen(function* () {
618 const fixture = yield* makeFixture();
619 yield* fixture.fs.writeFileString(`${fixture.sessionBase}.db`, "keep this session");
620 const metadata = yield* encodeMetadata({ cwd: fixture.projectDirectory });
621 yield* fixture.fs.writeFileString(`${fixture.sessionBase}.meta`, metadata);
622 yield* removeAntigravitySessionFiles({
623 profileDirectory: fixture.profileDirectory,
624 sessionId: SESSION_ID,
625 cwd: "different-temporary-workspace",
626 });
627 expect(yield* fixture.fs.readFileString(`${fixture.sessionBase}.db`)).toBe(
628 "keep this session",
629 );
630 expect(yield* fixture.fs.readFileString(`${fixture.sessionBase}.meta`)).toBe(metadata);
631 }).pipe(Effect.scoped),
632 );
633});
apps/server/src/textGeneration/AntigravityTextGeneration.ts created+410
......@@ -0,0 +1,410 @@
1import {
2 type ModelSelection,
3 type ProviderSetupError,
4 TextGenerationError,
5} from "@t3tools/contracts";
6import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
7import { extractJsonObject } from "@t3tools/shared/schemaJson";
8import * as Deferred from "effect/Deferred";
9import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";
11import * as FileSystem from "effect/FileSystem";
12import * as Option from "effect/Option";
13import * as Path from "effect/Path";
14import * as Ref from "effect/Ref";
15import * as Schema from "effect/Schema";
16import * as Scope from "effect/Scope";
17import * as Stream from "effect/Stream";
18import { type AcpError, AcpRequestError } from "effect-acp/errors";
19
20import { applyAntigravityAcpModelSelection } from "../provider/acp/AntigravityAcpSupport.ts";
21import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts";
22import type { AcpSessionRuntime } from "../provider/acp/AcpSessionRuntime.ts";
23import type * as TextGeneration from "./TextGeneration.ts";
24import {
25 buildBranchNamePrompt,
26 buildCommitMessagePrompt,
27 buildPrContentPrompt,
28 buildThreadTitlePrompt,
29} from "./TextGenerationPrompts.ts";
30import {
31 sanitizeCommitSubject,
32 sanitizePrTitle,
33 sanitizeThreadTitle,
34} from "./TextGenerationUtils.ts";
35
36const ANTIGRAVITY_TIMEOUT_MS = 180_000;
37const MAX_OUTPUT_CHARS = 128_000;
38const isTextGenerationError = Schema.is(TextGenerationError);
39const isNativeSessionId = Schema.is(Schema.String.check(Schema.isUUID(4)));
40const Configuration = Schema.Record(Schema.String, Schema.Unknown);
41const decodeConfiguration = Schema.decodeEffect(Schema.fromJsonString(Configuration));
42const decodeConfigurationObject = Schema.decodeUnknownEffect(Configuration);
43
44type AntigravityTextRuntime = Pick<
45 AcpSessionRuntime["Service"],
46 | "start"
47 | "setMode"
48 | "getConfigOptions"
49 | "getEvents"
50 | "setModel"
51 | "prompt"
52 | "cancel"
53 | "handleSessionUpdate"
54 | "handleRequestPermission"
55 | "handleElicitation"
56 | "handleReadTextFile"
57 | "handleWriteTextFile"
58 | "handleCreateTerminal"
59 | "handleTerminalOutput"
60 | "handleTerminalWaitForExit"
61 | "handleTerminalKill"
62 | "handleTerminalRelease"
63 | "handleUnknownExtRequest"
64>;
65
66export interface AntigravityTextGenerationOptions {
67 readonly profileDirectory: string;
68 /** Model the provider default alias selects, when the account offers it. */
69 readonly defaultModel?: Effect.Effect<string | undefined>;
70 /** Uses the instance's personal Google login, with no injected MCP servers or client tools. */
71 readonly makeRuntime: (
72 cwd: string,
73 ) => Effect.Effect<AntigravityTextRuntime, AcpError | ProviderSetupError, Scope.Scope>;
74 /** Registers the whole helper so sign-out can stop it before clearing credentials. */
75 readonly withProcess: <A, E, R>(
76 stop: Effect.Effect<void>,
77 task: Effect.Effect<A, E, R>,
78 ) => Effect.Effect<A, E | ProviderSetupError, R | Scope.Scope>;
79}
80
81/** Global hooks and MCP servers can run before a helper can deny a tool request. */
82export const isAntigravityTextGenerationAvailable = Effect.fn(
83 "isAntigravityTextGenerationAvailable",
84)(function* (profileDirectory: string) {
85 const fs = yield* FileSystem.FileSystem;
86 const path = yield* Path.Path;
87 for (const name of ["hooks.json", "mcp_config.json"]) {
88 const configurationPath = path.join(profileDirectory, "config", name);
89 if (!(yield* fs.exists(configurationPath))) {
90 continue;
91 }
92 const info = yield* fs.stat(configurationPath);
93 if (info.type !== "File" || info.size > 64_000n) {
94 return false;
95 }
96 const empty = yield* fs.readFileString(configurationPath).pipe(
97 Effect.flatMap(decodeConfiguration),
98 Effect.flatMap((configuration) =>
99 decodeConfigurationObject(
100 configuration[name === "hooks.json" ? "hooks" : "mcpServers"] ?? configuration,
101 ),
102 ),
103 Effect.map((configuration) => Object.keys(configuration).length === 0),
104 Effect.orElseSucceed(() => false),
105 );
106 if (!empty) return false;
107 }
108 return true;
109});
110
111/** Runs short-lived subscription helpers without giving them the user's workspace. */
112export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGeneration")(function* (
113 options: AntigravityTextGenerationOptions,
114) {
115 const fs = yield* FileSystem.FileSystem;
116 const path = yield* Path.Path;
117 const available = isAntigravityTextGenerationAvailable(options.profileDirectory).pipe(
118 Effect.provideService(FileSystem.FileSystem, fs),
119 Effect.provideService(Path.Path, path),
120 );
121
122 const runAntigravityJson = Effect.fn("AntigravityTextGeneration.runJson")(
123 function* <S extends Schema.Top>(input: {
124 readonly operation: keyof TextGeneration.TextGeneration["Service"];
125 readonly prompt: string;
126 readonly outputSchema: S;
127 readonly modelSelection: ModelSelection;
128 }) {
129 const { operation } = input;
130 const scope = yield* Scope.make();
131 yield* Effect.addFinalizer((exit) => Scope.close(scope, exit));
132 const helper = Effect.gen(function* () {
133 if (!(yield* available)) {
134 return yield* new TextGenerationError({
135 operation,
136 detail:
137 "Antigravity text generation is unavailable for profiles with global hooks or MCP configuration. Select another system model.",
138 });
139 }
140
141 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-antigravity-text-" });
142 let sessionId: string | undefined;
143 yield* Effect.addFinalizer(() =>
144 removeAntigravitySessionFiles({
145 profileDirectory: options.profileDirectory,
146 sessionId,
147 cwd,
148 }).pipe(
149 Effect.provideService(FileSystem.FileSystem, fs),
150 Effect.provideService(Path.Path, path),
151 ),
152 );
153
154 const rawResult = yield* Effect.gen(function* () {
155 const runtime = yield* options.makeRuntime(cwd);
156 yield* runtime.getEvents().pipe(
157 Stream.runForEach((event) =>
158 event._tag === "EventStreamBarrier"
159 ? Deferred.succeed(event.acknowledge, undefined).pipe(Effect.asVoid)
160 : Effect.void,
161 ),
162 Effect.forkScoped,
163 );
164 const output = yield* Ref.make("");
165 const rejected = yield* Deferred.make<never, TextGenerationError>();
166 const reject = (detail: string) =>
167 Deferred.fail(rejected, new TextGenerationError({ operation, detail })).pipe(
168 Effect.asVoid,
169 );
170 const rejectToolRequest = () =>
171 reject("Antigravity text generation requested a tool or user input.").pipe(
172 Effect.andThen(
173 Effect.fail(
174 new AcpRequestError({
175 code: -32601,
176 errorMessage: "Tools and user input are disabled for text generation.",
177 }),
178 ),
179 ),
180 );
181
182 yield* runtime.handleRequestPermission(() =>
183 reject("Antigravity text generation requested a tool permission or user input.").pipe(
184 Effect.as({ outcome: { outcome: "cancelled" as const } }),
185 ),
186 );
187 yield* runtime.handleElicitation(() =>
188 reject("Antigravity text generation requested user input.").pipe(
189 Effect.as({ action: { action: "decline" as const } }),
190 ),
191 );
192 yield* runtime.handleReadTextFile(rejectToolRequest);
193 yield* runtime.handleWriteTextFile(rejectToolRequest);
194 yield* runtime.handleCreateTerminal(rejectToolRequest);
195 yield* runtime.handleTerminalOutput(rejectToolRequest);
196 yield* runtime.handleTerminalWaitForExit(rejectToolRequest);
197 yield* runtime.handleTerminalKill(rejectToolRequest);
198 yield* runtime.handleTerminalRelease(rejectToolRequest);
199 yield* runtime.handleUnknownExtRequest(rejectToolRequest);
200 yield* runtime.handleSessionUpdate((notification) =>
201 Effect.gen(function* () {
202 const update = notification.update;
203 if (
204 update.sessionUpdate === "tool_call" ||
205 update.sessionUpdate === "tool_call_update"
206 ) {
207 return yield* reject("Antigravity attempted tool work during text generation.");
208 }
209 if (
210 notification.sessionId !== sessionId ||
211 update.sessionUpdate !== "agent_message_chunk" ||
212 update.content.type !== "text"
213 ) {
214 return;
215 }
216 const text = update.content.text;
217 const exceeded = yield* Ref.modify(output, (current) =>
218 current.length + text.length > MAX_OUTPUT_CHARS
219 ? [true, current]
220 : [false, current + text],
221 );
222 if (exceeded) {
223 return yield* reject("Antigravity text generation exceeded the output limit.");
224 }
225 }),
226 );
227
228 return yield* Effect.gen(function* () {
229 const started = yield* runtime.start();
230 sessionId = started.sessionId;
231 if (!isNativeSessionId(sessionId)) {
232 return yield* new TextGenerationError({
233 operation,
234 detail: "Antigravity returned an invalid text helper session ID.",
235 });
236 }
237 yield* runtime.setMode("default");
238 yield* applyAntigravityAcpModelSelection({
239 runtime,
240 model: input.modelSelection.model,
241 defaultModel: yield* options.defaultModel ?? Effect.succeed(undefined),
242 mapError: (cause) =>
243 new TextGenerationError({
244 operation,
245 detail: "Could not select the Antigravity model for text generation.",
246 cause,
247 }),
248 });
249
250 const result = yield* runtime.prompt({
251 prompt: [
252 {
253 type: "text",
254 text: [
255 "Use only the input below. Do not use tools, read or write files, run commands, or ask questions.",
256 "Return only the requested JSON object.",
257 "",
258 input.prompt,
259 ].join("\n"),
260 },
261 ],
262 });
263 if (yield* Deferred.isDone(rejected)) {
264 return yield* Deferred.await(rejected);
265 }
266 if (result.stopReason === "cancelled") {
267 return yield* new TextGenerationError({
268 operation,
269 detail: "Antigravity text generation was cancelled.",
270 });
271 }
272 return (yield* Ref.get(output)).trim();
273 }).pipe(
274 Effect.onInterrupt(() =>
275 runtime.cancel.pipe(Effect.timeoutOption(2_000), Effect.ignore),
276 ),
277 Effect.raceFirst(Deferred.await(rejected)),
278 );
279 }).pipe(Effect.scoped);
280
281 if ((yield* fs.readDirectory(cwd)).length > 0) {
282 return yield* new TextGenerationError({
283 operation,
284 detail: "Antigravity wrote files during text generation.",
285 });
286 }
287 if (!rawResult) {
288 return yield* new TextGenerationError({
289 operation,
290 detail: "Antigravity returned empty text generation output.",
291 });
292 }
293 const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchema));
294 return yield* decodeOutput(extractJsonObject(rawResult)).pipe(
295 Effect.mapError(
296 (cause) =>
297 new TextGenerationError({
298 operation,
299 detail: "Antigravity returned invalid structured output.",
300 cause,
301 }),
302 ),
303 );
304 }).pipe(
305 Effect.scoped,
306 Effect.timeoutOption(ANTIGRAVITY_TIMEOUT_MS),
307 Effect.flatMap(
308 Option.match({
309 onNone: () =>
310 Effect.fail(
311 new TextGenerationError({
312 operation,
313 detail: "Antigravity text generation timed out.",
314 }),
315 ),
316 onSome: Effect.succeed,
317 }),
318 ),
319 );
320
321 return yield* options
322 .withProcess(Scope.close(scope, Exit.void), helper)
323 .pipe(Effect.provideService(Scope.Scope, scope));
324 },
325 (effect, input) =>
326 effect.pipe(
327 Effect.mapError((cause) =>
328 isTextGenerationError(cause)
329 ? cause
330 : new TextGenerationError({
331 operation: input.operation,
332 detail: "Antigravity text generation failed.",
333 cause,
334 }),
335 ),
336 Effect.scoped,
337 ),
338 );
339
340 const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] =
341 Effect.fn("AntigravityTextGeneration.generateCommitMessage")(function* (input) {
342 const generated = yield* runAntigravityJson({
343 operation: "generateCommitMessage",
344 ...buildCommitMessagePrompt({
345 branch: input.branch,
346 stagedSummary: input.stagedSummary,
347 stagedPatch: input.stagedPatch,
348 includeBranch: input.includeBranch === true,
349 policy: input.policy,
350 }),
351 modelSelection: input.modelSelection,
352 });
353 return {
354 subject: sanitizeCommitSubject(generated.subject),
355 body: generated.body.trim(),
356 ...("branch" in generated && typeof generated.branch === "string"
357 ? { branch: sanitizeFeatureBranchName(generated.branch) }
358 : {}),
359 };
360 });
361
362 const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] =
363 Effect.fn("AntigravityTextGeneration.generatePrContent")(function* (input) {
364 const generated = yield* runAntigravityJson({
365 operation: "generatePrContent",
366 ...buildPrContentPrompt({
367 baseBranch: input.baseBranch,
368 headBranch: input.headBranch,
369 commitSummary: input.commitSummary,
370 diffSummary: input.diffSummary,
371 diffPatch: input.diffPatch,
372 policy: input.policy,
373 changeRequestTemplate: input.changeRequestTemplate,
374 }),
375 modelSelection: input.modelSelection,
376 });
377 return { title: sanitizePrTitle(generated.title), body: generated.body.trim() };
378 });
379
380 const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] =
381 Effect.fn("AntigravityTextGeneration.generateBranchName")(function* (input) {
382 const generated = yield* runAntigravityJson({
383 operation: "generateBranchName",
384 ...buildBranchNamePrompt({ message: input.message, attachments: input.attachments }),
385 modelSelection: input.modelSelection,
386 });
387 return { branch: sanitizeBranchFragment(generated.branch) };
388 });
389
390 const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] =
391 Effect.fn("AntigravityTextGeneration.generateThreadTitle")(function* (input) {
392 const generated = yield* runAntigravityJson({
393 operation: "generateThreadTitle",
394 ...buildThreadTitlePrompt({
395 message: input.message,
396 previousTitle: input.previousTitle,
397 attachments: input.attachments,
398 }),
399 modelSelection: input.modelSelection,
400 });
401 return { title: sanitizeThreadTitle(generated.title) };
402 });
403
404 return {
405 generateCommitMessage,
406 generatePrContent,
407 generateBranchName,
408 generateThreadTitle,
409 } satisfies TextGeneration.TextGeneration["Service"];
410});
apps/server/src/ws.ts+91-9
......@@ -48,6 +48,7 @@ import {
4848 ProjectSearchEntriesError,
4949 ProjectWriteFileError,
5050 ProviderUploadFeedbackError,
51 ProviderSetupError,
5152 RelayClientInstallFailedError,
5253 type RelayClientInstallProgressEvent,
5354 ServerSelfUpdateError,
......@@ -95,6 +96,9 @@ import {
9596import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
9697import * as ProviderService from "./provider/Services/ProviderService.ts";
9798import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
99import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts";
100import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts";
101import { makeProviderInstallation } from "./provider/providerInstallation.ts";
98102import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";
99103import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
100104import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
......@@ -509,6 +513,9 @@ const makeWsRpcLayer = (
509513 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
510514 const providerService = yield* ProviderService.ProviderService;
511515 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
516 const providerAuth = yield* ProviderAuthService;
517 const providerInstances = yield* ProviderInstanceRegistry;
518 const providerInstallation = yield* makeProviderInstallation();
512519 const serverUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;
513520 const config = yield* ServerConfig.ServerConfig;
514521 const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents;
......@@ -1666,15 +1673,44 @@ const makeWsRpcLayer = (
16661673 [WS_METHODS.serverRefreshProviders]: (input) =>
16671674 observeRpcEffect(
16681675 WS_METHODS.serverRefreshProviders,
1669 (input.cwd !== undefined && input.instanceId !== undefined
1670 ? providerRegistry.refreshWorkspaceSnapshot({
1671 instanceId: input.instanceId,
1672 cwd: input.cwd,
1673 })
1674 : input.instanceId !== undefined
1675 ? providerRegistry.refreshInstance(input.instanceId)
1676 : providerRegistry.refresh()
1677 ).pipe(Effect.map((providers) => ({ providers }))),
1676 Effect.gen(function* () {
1677 let providers = yield* input.cwd !== undefined && input.instanceId !== undefined
1678 ? providerRegistry.refreshWorkspaceSnapshot({
1679 instanceId: input.instanceId,
1680 cwd: input.cwd,
1681 })
1682 : input.instanceId !== undefined
1683 ? providerRegistry.refreshInstance(input.instanceId)
1684 : providerRegistry.refresh();
1685 if (input.refreshModels) {
1686 const instances = yield* providerInstances.listInstances;
1687 for (const instance of instances) {
1688 if (
1689 !instance.refreshModels ||
1690 (input.instanceId !== undefined && input.instanceId !== instance.instanceId) ||
1691 !providers.some(
1692 (provider) =>
1693 provider.instanceId === instance.instanceId &&
1694 provider.enabled &&
1695 provider.installed,
1696 )
1697 )
1698 continue;
1699 yield* instance.refreshModels().pipe(
1700 Effect.mapError(
1701 (error) =>
1702 new ProviderSetupError({
1703 instanceId: instance.instanceId,
1704 operation: "refresh-models",
1705 detail: error.detail,
1706 }),
1707 ),
1708 );
1709 providers = yield* providerRegistry.refreshInstance(instance.instanceId);
1710 }
1711 }
1712 return { providers };
1713 }),
16781714 { "rpc.aggregate": "server" },
16791715 ),
16801716 [WS_METHODS.providerUploadFeedback]: (input) =>
......@@ -1699,6 +1735,52 @@ const makeWsRpcLayer = (
16991735 "rpc.aggregate": "server",
17001736 },
17011737 ),
1738 [WS_METHODS.providerAuthStart]: (input) =>
1739 observeRpcEffect(
1740 WS_METHODS.providerAuthStart,
1741 providerAuth.start(input, currentSessionId),
1742 { "rpc.aggregate": "provider" },
1743 ),
1744 [WS_METHODS.providerAuthComplete]: (input) =>
1745 observeRpcEffect(
1746 WS_METHODS.providerAuthComplete,
1747 providerAuth.complete(input, currentSessionId),
1748 { "rpc.aggregate": "provider" },
1749 ),
1750 [WS_METHODS.providerAuthCancel]: (input) =>
1751 observeRpcEffect(
1752 WS_METHODS.providerAuthCancel,
1753 providerAuth.cancel(input, currentSessionId),
1754 { "rpc.aggregate": "provider" },
1755 ),
1756 [WS_METHODS.providerAuthLogout]: (input) =>
1757 observeRpcEffect(WS_METHODS.providerAuthLogout, providerAuth.logout(input), {
1758 "rpc.aggregate": "provider",
1759 }),
1760 [WS_METHODS.providerAuthSubscribe]: (input) =>
1761 observeRpcStream(
1762 WS_METHODS.providerAuthSubscribe,
1763 providerAuth.subscribe(input, currentSessionId),
1764 { "rpc.aggregate": "provider" },
1765 ),
1766 [WS_METHODS.providerInstallStart]: (input) =>
1767 observeRpcEffect(WS_METHODS.providerInstallStart, providerInstallation.start(input), {
1768 "rpc.aggregate": "provider",
1769 }),
1770 [WS_METHODS.providerInstallCancel]: (input) =>
1771 observeRpcEffect(WS_METHODS.providerInstallCancel, providerInstallation.cancel(input), {
1772 "rpc.aggregate": "provider",
1773 }),
1774 [WS_METHODS.providerInstallSubscribe]: (input) =>
1775 observeRpcStream(
1776 WS_METHODS.providerInstallSubscribe,
1777 providerInstallation.subscribe(input),
1778 { "rpc.aggregate": "provider" },
1779 ),
1780 [WS_METHODS.providerInstallRemove]: (input) =>
1781 observeRpcEffect(WS_METHODS.providerInstallRemove, providerInstallation.remove(input), {
1782 "rpc.aggregate": "provider",
1783 }),
17021784 [WS_METHODS.serverUpdateServer]: (input) =>
17031785 observeRpcEffect(WS_METHODS.serverUpdateServer, serverUpdate.update(input), {
17041786 "rpc.aggregate": "server",
apps/web/src/components/ChatView.logic.test.ts+327-1
......@@ -1,14 +1,19 @@
11import {
2 CheckpointRef,
23 EnvironmentId,
34 MessageId,
45 ProjectId,
6 ProviderDriverKind,
57 ProviderInstanceId,
8 type ServerProvider,
69 ThreadId,
710 TurnId,
811} from "@t3tools/contracts";
912import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
1013
11import type { Thread, ThreadShell } from "../types";
14import type { Thread, ThreadShell, TurnDiffSummary } from "../types";
15import type { TimelineEntry } from "../session-logic";
16import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
1217import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
1318import type { RightPanelSurface } from "../rightPanelStore";
1419import {
......@@ -18,11 +23,13 @@ import {
1823 branchMismatchKey,
1924 buildExpiredTerminalContextToastCopy,
2025 buildLoadingThreadFromShell,
26 buildRevertTurnCountByUserMessageId,
2127 buildThreadTurnInterruptInput,
2228 createLocalDispatchSnapshot,
2329 deriveComposerSendState,
2430 dismissBranchMismatchForSession,
2531 ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,
32 getAntigravitySendBlockReason,
2633 getStartedThreadModelChangeBlockReason,
2734 hasEnvironmentReconnectWarningGraceElapsed,
2835 hasServerAcknowledgedLocalDispatch,
......@@ -30,6 +37,8 @@ import {
3037 reconcileMountedTerminalThreadIds,
3138 reconcileRetainedMountedThreadIds,
3239 resolveBackgroundDraftWorkspaceOptions,
40 resolveComposerInteractionMode,
41 resolveComposerProviderSelection,
3342 resolveDraftPromotionNavigationTarget,
3443 resolveThreadMetadataUpdateForNextTurn,
3544 resolveSendEnvMode,
......@@ -661,6 +670,323 @@ describe("buildThreadTurnInterruptInput", () => {
661670 });
662671});
663672
673describe("resolveComposerProviderSelection", () => {
674 const catalogModels: ServerProvider["models"] = [
675 { slug: "gemini-pro", name: "Gemini Pro", isCustom: false, capabilities: null },
676 ];
677
678 function entry(driver: string, instanceId = driver, overrides: Partial<ServerProvider> = {}) {
679 return deriveProviderInstanceEntries([
680 {
681 driver: ProviderDriverKind.make(driver),
682 instanceId: ProviderInstanceId.make(instanceId),
683 enabled: true,
684 installed: true,
685 status: "ready",
686 auth: { status: "authenticated" },
687 version: null,
688 checkedAt: now,
689 models: [],
690 slashCommands: [],
691 skills: [],
692 ...overrides,
693 },
694 ])[0]!;
695 }
696
697 it("uses the custom instance's capability instead of the default instance", () => {
698 const defaultEntry = entry("antigravity", "antigravity", {
699 showInteractionModeToggle: true,
700 });
701 const customEntry = entry("antigravity", "google_work", {
702 showInteractionModeToggle: false,
703 });
704 const selection = resolveComposerProviderSelection({
705 entries: [defaultEntry, customEntry],
706 candidateInstanceIds: [customEntry.instanceId],
707 lockedProvider: null,
708 lockedInstanceId: null,
709 });
710
711 expect(selection.selectedProviderEntry?.instanceId).toBe(customEntry.instanceId);
712 expect(
713 resolveComposerInteractionMode({
714 provider: selection.selectedProviderEntry?.snapshot,
715 planModeEnabled: true,
716 interactionMode: "plan",
717 }),
718 ).toEqual({ enabled: false, interactionMode: "default" });
719 });
720
721 it("uses the fallback provider's plan capability after the draft's instance is disabled", () => {
722 const disabledEntry = entry("antigravity", "antigravity", {
723 enabled: false,
724 showInteractionModeToggle: false,
725 });
726 const fallbackEntry = entry("codex");
727 const selection = resolveComposerProviderSelection({
728 entries: [disabledEntry, fallbackEntry],
729 candidateInstanceIds: [disabledEntry.instanceId],
730 lockedProvider: null,
731 lockedInstanceId: null,
732 });
733
734 expect(selection.selectedProviderEntry?.instanceId).toBe(fallbackEntry.instanceId);
735 expect(
736 resolveComposerInteractionMode({
737 provider: selection.selectedProviderEntry?.snapshot,
738 planModeEnabled: true,
739 interactionMode: "plan",
740 }),
741 ).toEqual({ enabled: true, interactionMode: "plan" });
742 });
743
744 it("keeps a signed-out selection instead of silently switching providers", () => {
745 const signedOutEntry = entry("antigravity", "google_work", {
746 status: "error",
747 auth: { status: "unauthenticated" },
748 models: catalogModels,
749 });
750 const selection = resolveComposerProviderSelection({
751 entries: [entry("codex"), signedOutEntry],
752 candidateInstanceIds: [signedOutEntry.instanceId],
753 lockedProvider: null,
754 lockedInstanceId: null,
755 });
756
757 expect(selection.selectedProviderEntry?.instanceId).toBe(signedOutEntry.instanceId);
758 expect(
759 getAntigravitySendBlockReason(selection.selectedProviderEntry?.snapshot, "gemini-pro"),
760 ).toBe("Sign in to Antigravity in provider settings before sending.");
761 });
762
763 it("blocks sends until the selected Antigravity profile is installed", () => {
764 const provider = entry("antigravity", "google_work", {
765 installed: false,
766 models: catalogModels,
767 }).snapshot;
768
769 expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBe(
770 "Install Antigravity in provider settings before sending.",
771 );
772 });
773
774 it("blocks sends until Antigravity confirms authentication", () => {
775 const provider = entry("antigravity", "google_work", {
776 auth: { status: "unknown" },
777 models: catalogModels,
778 }).snapshot;
779
780 expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBe(
781 "Sign in to Antigravity in provider settings before sending.",
782 );
783 });
784
785 it("blocks saved model sends until Antigravity loads its account catalog", () => {
786 expect(getAntigravitySendBlockReason(entry("antigravity").snapshot, "gemini-pro")).toBe(
787 "Refresh Antigravity models in provider settings before sending.",
788 );
789 });
790
791 it("blocks an empty Antigravity selection after the catalog has loaded", () => {
792 const provider = entry("antigravity", "google_work", { models: catalogModels }).snapshot;
793
794 expect(getAntigravitySendBlockReason(provider, "")).toBe(
795 "Choose an Antigravity model before sending.",
796 );
797 });
798
799 it("blocks a saved model that a ready catalog no longer lists", () => {
800 const provider = entry("antigravity", "google_work", {
801 status: "ready",
802 models: catalogModels,
803 }).snapshot;
804
805 expect(getAntigravitySendBlockReason(provider, "saved-model-not-in-current-catalog")).toBe(
806 "That Antigravity model is no longer available. Choose another model.",
807 );
808 expect(getAntigravitySendBlockReason(provider, "gemini-pro")).toBeNull();
809 });
810
811 it("allows a saved native model to retry after a provider error without changing it", () => {
812 const provider = entry("antigravity", "google_work", {
813 status: "error",
814 models: catalogModels,
815 }).snapshot;
816
817 expect(
818 getAntigravitySendBlockReason(provider, "saved-model-not-in-current-catalog"),
819 ).toBeNull();
820 });
821
822 it("keeps existing send behavior for other providers", () => {
823 const provider = entry("codex", "codex", {
824 installed: false,
825 auth: { status: "unknown" },
826 models: [],
827 }).snapshot;
828
829 expect(getAntigravitySendBlockReason(provider, "gpt-model")).toBeNull();
830 });
831
832 it("does not continue an existing Antigravity thread in another profile after deletion", () => {
833 const missingInstanceId = ProviderInstanceId.make("google_work");
834 const selection = resolveComposerProviderSelection({
835 entries: [entry("antigravity")],
836 candidateInstanceIds: [missingInstanceId],
837 lockedProvider: ProviderDriverKind.make("antigravity"),
838 lockedInstanceId: missingInstanceId,
839 });
840
841 expect(selection.selectedProviderEntry).toBeUndefined();
842 expect(selection.unavailableProviderInstanceId).toBe(missingInstanceId);
843 });
844
845 it("does not treat the empty draft placeholder as a provider setup target", () => {
846 const selection = resolveComposerProviderSelection({
847 entries: [entry("antigravity", "antigravity", { enabled: false })],
848 candidateInstanceIds: [NO_PROVIDER_MODEL_SELECTION.instanceId],
849 lockedProvider: null,
850 lockedInstanceId: null,
851 });
852
853 expect(selection.selectedProviderEntry).toBeUndefined();
854 expect(selection.unavailableProviderInstanceId).toBeUndefined();
855 });
856
857 it("keeps the session's continuation group when another instance was selected", () => {
858 const sessionEntry = entry("antigravity", "google_work", {
859 enabled: false,
860 continuation: { groupKey: "work-profile" },
861 });
862 const anotherEntry = entry("antigravity", "google_personal", {
863 continuation: { groupKey: "personal-profile" },
864 });
865 const selection = resolveComposerProviderSelection({
866 entries: [sessionEntry, anotherEntry],
867 candidateInstanceIds: [anotherEntry.instanceId, sessionEntry.instanceId],
868 lockedProvider: ProviderDriverKind.make("antigravity"),
869 lockedInstanceId: sessionEntry.instanceId,
870 });
871
872 expect(selection.selectedProviderEntry).toBeUndefined();
873 });
874});
875
876describe("resolveComposerInteractionMode", () => {
877 it("resets a restored plan draft when the selected instance does not support plan mode", () => {
878 expect(
879 resolveComposerInteractionMode({
880 planModeEnabled: true,
881 provider: { showInteractionModeToggle: false },
882 interactionMode: "plan",
883 }),
884 ).toEqual({ enabled: false, interactionMode: "default" });
885 });
886
887 it("keeps legacy plan behavior for providers that omit the capability", () => {
888 expect(
889 resolveComposerInteractionMode({
890 planModeEnabled: true,
891 provider: {},
892 interactionMode: "plan",
893 }),
894 ).toEqual({ enabled: true, interactionMode: "plan" });
895 });
896
897 it("resets a restored plan draft when the beta setting is off", () => {
898 expect(
899 resolveComposerInteractionMode({
900 planModeEnabled: false,
901 provider: { showInteractionModeToggle: true },
902 interactionMode: "plan",
903 }),
904 ).toEqual({ enabled: false, interactionMode: "default" });
905 });
906
907 it("disables plan mode until the selected provider is available", () => {
908 expect(
909 resolveComposerInteractionMode({
910 planModeEnabled: true,
911 provider: null,
912 interactionMode: "plan",
913 }),
914 ).toEqual({ enabled: false, interactionMode: "default" });
915 });
916});
917
918describe("buildRevertTurnCountByUserMessageId", () => {
919 const userMessageId = MessageId.make("rewind-user-message");
920 const assistantMessageId = MessageId.make("rewind-assistant-message");
921 const turnId = TurnId.make("rewind-turn");
922 const timelineEntries = [
923 {
924 id: userMessageId,
925 kind: "message",
926 createdAt: now,
927 message: {
928 id: userMessageId,
929 role: "user",
930 text: "Update the file",
931 turnId,
932 createdAt: now,
933 updatedAt: now,
934 streaming: false,
935 },
936 },
937 {
938 id: assistantMessageId,
939 kind: "message",
940 createdAt: now,
941 message: {
942 id: assistantMessageId,
943 role: "assistant",
944 text: "Updated the file",
945 turnId,
946 createdAt: now,
947 updatedAt: now,
948 streaming: false,
949 },
950 },
951 ] satisfies ReadonlyArray<TimelineEntry>;
952 const turnDiffSummaryByAssistantMessageId = new Map<MessageId, TurnDiffSummary>([
953 [
954 assistantMessageId,
955 {
956 turnId,
957 checkpointTurnCount: 1,
958 checkpointRef: CheckpointRef.make("refs/t3/checkpoints/rewind-turn"),
959 status: "ready",
960 files: [],
961 assistantMessageId,
962 completedAt: now,
963 },
964 ],
965 ]);
966
967 it("offers the checkpoint before the user message when conversation rollback is supported", () => {
968 expect(
969 buildRevertTurnCountByUserMessageId({
970 supportsConversationRollback: true,
971 timelineEntries,
972 turnDiffSummaryByAssistantMessageId,
973 inferredCheckpointTurnCountByTurnId: {},
974 }),
975 ).toEqual(new Map([[userMessageId, 0]]));
976 });
977
978 it("offers no rewind action when file checkpoints exist but conversation rollback is unsupported", () => {
979 expect(
980 buildRevertTurnCountByUserMessageId({
981 supportsConversationRollback: false,
982 timelineEntries,
983 turnDiffSummaryByAssistantMessageId,
984 inferredCheckpointTurnCountByTurnId: {},
985 }).size,
986 ).toBe(0);
987 });
988});
989
664990describe("deriveComposerSendState", () => {
665991 it("treats expired terminal pills as non-sendable content", () => {
666992 const state = deriveComposerSendState({
apps/web/src/components/ChatView.logic.ts+154-1
......@@ -1,4 +1,5 @@
11import {
2 ANTIGRAVITY_DEFAULT_MODEL,
23 type AssetCreateUrlInput,
34 type AssetCreateUrlResult,
45 type ChatFileAttachment,
......@@ -8,7 +9,8 @@ import {
89 type MessageId,
910 type ModelSelection,
1011 type ProviderInteractionMode,
11 type ProviderDriverKind,
12 ProviderDriverKind,
13 type ProviderInstanceId,
1214 type ServerProvider,
1315 type ScopedProjectRef,
1416 type ScopedThreadRef,
......@@ -32,6 +34,7 @@ import {
3234 type SessionPhase,
3335 type Thread,
3436 type ThreadShell,
37 type TurnDiffSummary,
3538} from "../types";
3639import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
3740import * as Schema from "effect/Schema";
......@@ -47,6 +50,11 @@ import type { ComposerSubmissionIntent } from "../composer-logic";
4750import type { TimelineEntry } from "../session-logic";
4851import type { DesktopPreviewOverlay } from "../previewStateStore";
4952import type { RightPanelSurface } from "../rightPanelStore";
53import {
54 NO_PROVIDER_MODEL_SELECTION,
55 resolveSelectableProviderInstanceEntry,
56 type ProviderInstanceEntry,
57} from "../providerInstances";
5058
5159export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
5260export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
......@@ -317,6 +325,151 @@ export function buildThreadTurnInterruptInput(thread: Pick<Thread, "id" | "sessi
317325 };
318326}
319327
328/** Use the same enabled instance for the composer, provider status, and chat actions. */
329export function resolveComposerProviderSelection(input: {
330 entries: ReadonlyArray<ProviderInstanceEntry>;
331 candidateInstanceIds: ReadonlyArray<ProviderInstanceId | null | undefined>;
332 lockedProvider: ProviderDriverKind | null;
333 lockedInstanceId: ProviderInstanceId | null | undefined;
334}) {
335 const requestedInstanceId = input.candidateInstanceIds.find(
336 (candidate) => candidate != null && candidate !== NO_PROVIDER_MODEL_SELECTION.instanceId,
337 );
338 const requestedDriverKind =
339 input.lockedProvider ??
340 input.entries.find((entry) => entry.instanceId === requestedInstanceId)?.driverKind ??
341 input.entries[0]?.driverKind ??
342 ProviderDriverKind.make("unconfigured");
343 const lockedContinuationGroupKey = input.lockedProvider
344 ? (input.entries.find((entry) => entry.instanceId === input.lockedInstanceId)
345 ?.continuationGroupKey ?? null)
346 : null;
347 // Missing metadata must not move Antigravity history into another Google profile.
348 const requiresExactInstance =
349 input.lockedProvider === "antigravity" &&
350 input.lockedInstanceId != null &&
351 lockedContinuationGroupKey === null;
352 const compatibleEntries = input.entries.filter(
353 (entry) =>
354 (!input.lockedProvider || entry.driverKind === input.lockedProvider) &&
355 (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey) &&
356 (!requiresExactInstance || entry.instanceId === input.lockedInstanceId),
357 );
358 const selectedProviderEntry =
359 input.candidateInstanceIds
360 .map((candidate) =>
361 compatibleEntries.find(
362 (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable,
363 ),
364 )
365 .find((entry) => entry !== undefined) ??
366 resolveSelectableProviderInstanceEntry(
367 compatibleEntries.filter((entry) => entry.driverKind === requestedDriverKind),
368 undefined,
369 ) ??
370 resolveSelectableProviderInstanceEntry(compatibleEntries, undefined);
371 const unavailableProviderInstanceId = selectedProviderEntry
372 ? undefined
373 : input.lockedProvider
374 ? (input.lockedInstanceId ?? requestedInstanceId)
375 : requestedInstanceId;
376 return {
377 selectedProviderEntry,
378 requestedDriverKind,
379 lockedContinuationGroupKey,
380 unavailableProviderInstanceId,
381 };
382}
383
384/** Keep restored drafts and every plan control on the selected instance's supported mode. */
385export function resolveComposerInteractionMode(input: {
386 planModeEnabled: boolean;
387 provider: Pick<ServerProvider, "showInteractionModeToggle"> | null | undefined;
388 interactionMode: ProviderInteractionMode;
389}): { enabled: boolean; interactionMode: ProviderInteractionMode } {
390 const enabled =
391 input.planModeEnabled &&
392 input.provider != null &&
393 input.provider.showInteractionModeToggle !== false;
394 return {
395 enabled,
396 interactionMode: enabled ? input.interactionMode : "default",
397 };
398}
399
400export function getAntigravitySendBlockReason(
401 provider:
402 | Pick<ServerProvider, "driver" | "installed" | "auth" | "models" | "status">
403 | null
404 | undefined,
405 model: string,
406): string | null {
407 if (provider?.driver !== "antigravity") return null;
408 if (!provider.installed) {
409 return "Install Antigravity in provider settings before sending.";
410 }
411 if (provider.auth.status !== "authenticated") {
412 return "Sign in to Antigravity in provider settings before sending.";
413 }
414 if (provider.models.length === 0) {
415 return "Refresh Antigravity models in provider settings before sending.";
416 }
417 const slug = model.trim();
418 if (slug.length === 0) return "Choose an Antigravity model before sending.";
419 // A saved model that left the catalog is kept in the picker as unavailable
420 // so the user sees what the thread used. The server rejects it at turn
421 // start, so block here unless the provider is in an error state, where a
422 // retry with the same model is the right move.
423 if (
424 provider.status === "ready" &&
425 slug !== ANTIGRAVITY_DEFAULT_MODEL &&
426 !provider.models.some((entry) => entry.slug === slug || entry.aliases?.includes(slug))
427 ) {
428 return "That Antigravity model is no longer available. Choose another model.";
429 }
430 return null;
431}
432
433export function buildRevertTurnCountByUserMessageId(input: {
434 supportsConversationRollback: boolean;
435 timelineEntries: ReadonlyArray<TimelineEntry>;
436 turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;
437 inferredCheckpointTurnCountByTurnId: Readonly<Record<string, number | undefined>>;
438}) {
439 const byUserMessageId = new Map<MessageId, number>();
440 if (!input.supportsConversationRollback) {
441 return byUserMessageId;
442 }
443 for (let index = 0; index < input.timelineEntries.length; index += 1) {
444 const entry = input.timelineEntries[index];
445 if (!entry || entry.kind !== "message" || entry.message.role !== "user") {
446 continue;
447 }
448
449 for (let nextIndex = index + 1; nextIndex < input.timelineEntries.length; nextIndex += 1) {
450 const nextEntry = input.timelineEntries[nextIndex];
451 if (!nextEntry || nextEntry.kind !== "message") {
452 continue;
453 }
454 if (nextEntry.message.role === "user") {
455 break;
456 }
457 const summary = input.turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id);
458 if (!summary) {
459 continue;
460 }
461 const turnCount =
462 summary.checkpointTurnCount ?? input.inferredCheckpointTurnCountByTurnId[summary.turnId];
463 if (typeof turnCount !== "number") {
464 break;
465 }
466 byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1));
467 break;
468 }
469 }
470 return byUserMessageId;
471}
472
320473export function reconcileMountedTerminalThreadIds(input: {
321474 currentThreadIds: ReadonlyArray<string>;
322475 openThreadIds: ReadonlyArray<string>;
apps/web/src/components/ChatView.tsx+106-84
......@@ -3,7 +3,6 @@ import {
33 type ApprovalRequestId,
44 type ChatFileAttachment,
55 DEFAULT_MODEL,
6 defaultInstanceIdForDriver,
76 type EnvironmentId,
87 type MessageId,
98 type ModelSelection,
......@@ -206,10 +205,11 @@ import {
206205import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
207206import { useBrowserHistoryStore } from "~/browserHistoryStore";
208207import { registerFaviconProjectForThread } from "~/browserFaviconStore";
209import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
208import { getProviderModelCapabilities } from "../providerModels";
210209import {
211210 applyProviderInstanceSettings,
212211 deriveProviderInstanceEntries,
212 sortProviderInstanceEntries,
213213 NO_PROVIDER_MODEL_SELECTION,
214214} from "../providerInstances";
215215import {
......@@ -350,6 +350,7 @@ import {
350350 buildExpiredTerminalContextToastCopy,
351351 buildLocalDraftThread,
352352 buildLoadingThreadFromShell,
353 buildRevertTurnCountByUserMessageId,
353354 buildThreadTurnInterruptInput,
354355 collectUserMessageBlobPreviewUrls,
355356 createLocalDispatchSnapshot,
......@@ -376,6 +377,8 @@ import {
376377 resolveFileAttachmentUrl,
377378 reconcileMountedTerminalThreadIds,
378379 resolveBackgroundDraftWorkspaceOptions,
380 resolveComposerInteractionMode,
381 resolveComposerProviderSelection,
379382 resolveDraftHeroState,
380383 resolveThreadMetadataUpdateForNextTurn,
381384 resolveSendEnvMode,
......@@ -1719,13 +1722,6 @@ function ChatViewContent(props: ChatViewProps) {
17191722 // the branch mismatch banner.
17201723 const [, setThreadErrorBannerDismissTick] = useState(0);
17211724 const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
1722 // Plan mode is legacy (Settings → Beta). With the flag off the effective
1723 // mode is forced to "default" — even for threads with a stored plan mode —
1724 // so nobody is trapped in plan mode while its toggle is hidden. The next
1725 // send persists "default" back to the thread.
1726 const interactionMode = settings.planModeEnabled
1727 ? (composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE)
1728 : DEFAULT_INTERACTION_MODE;
17291725 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
17301726 const canCheckoutPullRequestIntoThread = isLocalDraftThread;
17311727 const activeThreadId = activeThread?.id ?? null;
......@@ -2426,11 +2422,52 @@ function ChatViewContent(props: ChatViewProps) {
24262422 versionMismatchServerLabel,
24272423 ]);
24282424 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
2429 const unlockedSelectedProvider = resolveSelectableProvider(
2430 providerStatuses,
2431 selectedProviderByThreadId ?? threadProvider,
2425 const providerInstanceEntries = useMemo(
2426 () =>
2427 sortProviderInstanceEntries(
2428 applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings),
2429 ),
2430 [providerStatuses, settings],
2431 );
2432 const { selectedProviderEntry, requestedDriverKind } = useMemo(
2433 () =>
2434 resolveComposerProviderSelection({
2435 entries: providerInstanceEntries,
2436 candidateInstanceIds: [
2437 selectedProviderByThreadId,
2438 activeThread?.session?.providerInstanceId,
2439 activeThread?.modelSelection.instanceId,
2440 activeProject?.defaultModelSelection?.instanceId,
2441 ],
2442 lockedProvider,
2443 lockedInstanceId:
2444 activeThread?.session?.providerInstanceId ?? activeThread?.modelSelection.instanceId,
2445 }),
2446 [
2447 activeProject?.defaultModelSelection?.instanceId,
2448 activeThread?.modelSelection.instanceId,
2449 activeThread?.session?.providerInstanceId,
2450 lockedProvider,
2451 providerInstanceEntries,
2452 selectedProviderByThreadId,
2453 ],
24322454 );
2433 const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
2455 const selectedProvider = selectedProviderEntry?.driverKind ?? requestedDriverKind;
2456 const activeProviderInstanceId = selectedProviderEntry?.instanceId ?? null;
2457 const activeProviderStatus = selectedProviderEntry?.snapshot ?? null;
2458 const { enabled: interactionModeEnabled, interactionMode } = resolveComposerInteractionMode({
2459 planModeEnabled: settings.planModeEnabled,
2460 provider: activeProviderStatus,
2461 interactionMode:
2462 composerInteractionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE,
2463 });
2464 const conversationProviderStatus =
2465 providerStatuses.find(
2466 (status) => status.instanceId === activeThread?.session?.providerInstanceId,
2467 ) ?? activeProviderStatus;
2468 const supportsConversationRollback =
2469 conversationProviderStatus !== null &&
2470 conversationProviderStatus.supportsConversationRollback !== false;
24342471 const phase = derivePhase(activeThread?.session ?? null);
24352472 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
24362473 const latestCheckpointCompletedAt = activeThread?.checkpoints.at(-1)?.completedAt ?? null;
......@@ -2866,38 +2903,21 @@ function ChatViewContent(props: ChatViewProps) {
28662903 }
28672904 return byMessageId;
28682905 }, [turnDiffSummaries]);
2869 const revertTurnCountByUserMessageId = useMemo(() => {
2870 const byUserMessageId = new Map<MessageId, number>();
2871 for (let index = 0; index < timelineEntries.length; index += 1) {
2872 const entry = timelineEntries[index];
2873 if (!entry || entry.kind !== "message" || entry.message.role !== "user") {
2874 continue;
2875 }
2876
2877 for (let nextIndex = index + 1; nextIndex < timelineEntries.length; nextIndex += 1) {
2878 const nextEntry = timelineEntries[nextIndex];
2879 if (!nextEntry || nextEntry.kind !== "message") {
2880 continue;
2881 }
2882 if (nextEntry.message.role === "user") {
2883 break;
2884 }
2885 const summary = turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id);
2886 if (!summary) {
2887 continue;
2888 }
2889 const turnCount =
2890 summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId];
2891 if (typeof turnCount !== "number") {
2892 break;
2893 }
2894 byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1));
2895 break;
2896 }
2897 }
2898
2899 return byUserMessageId;
2900 }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]);
2906 const revertTurnCountByUserMessageId = useMemo(
2907 () =>
2908 buildRevertTurnCountByUserMessageId({
2909 supportsConversationRollback,
2910 timelineEntries,
2911 turnDiffSummaryByAssistantMessageId,
2912 inferredCheckpointTurnCountByTurnId,
2913 }),
2914 [
2915 supportsConversationRollback,
2916 inferredCheckpointTurnCountByTurnId,
2917 timelineEntries,
2918 turnDiffSummaryByAssistantMessageId,
2919 ],
2920 );
29012921
29022922 const gitCwd = activeProject
29032923 ? projectScriptCwd({
......@@ -2922,26 +2942,10 @@ function ChatViewContent(props: ChatViewProps) {
29222942 });
29232943 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
29242944 const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom);
2925 // Prefer an instance-id match so a custom Codex instance (e.g.
2926 // `codex_personal`) surfaces its own status/message in the banner rather
2927 // than the default Codex's. Falls back to first-match-by-kind when no
2928 // saved instance id is available or the instance no longer exists.
2929 const selectedProviderInstanceId =
2930 providerStatuses.find((status) => status.instanceId === selectedProviderByThreadId)
2931 ?.instanceId ?? null;
2932 const activeProviderInstanceId =
2933 selectedProviderInstanceId ??
2934 activeThread?.session?.providerInstanceId ??
2935 activeThread?.modelSelection.instanceId ??
2936 activeProject?.defaultModelSelection?.instanceId ??
2937 null;
29382945 const compactionProviderAvailable = useMemo(
29392946 () =>
29402947 hasAvailableClaudeCompactionProvider({
2941 providers: applyProviderInstanceSettings(
2942 deriveProviderInstanceEntries(providerStatuses),
2943 settings,
2944 ),
2948 providers: providerInstanceEntries,
29452949 instanceId: activeProviderInstanceId,
29462950 lockedInstanceId: lockedProvider
29472951 ? (activeThread?.session?.providerInstanceId ??
......@@ -2954,19 +2958,9 @@ function ChatViewContent(props: ChatViewProps) {
29542958 activeThread?.modelSelection.instanceId,
29552959 activeThread?.session?.providerInstanceId,
29562960 lockedProvider,
2957 providerStatuses,
2958 settings,
2961 providerInstanceEntries,
29592962 ],
29602963 );
2961 const activeProviderStatus = useMemo(() => {
2962 if (activeProviderInstanceId) {
2963 return (
2964 providerStatuses.find((status) => status.instanceId === activeProviderInstanceId) ?? null
2965 );
2966 }
2967 const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider);
2968 return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null;
2969 }, [activeProviderInstanceId, providerStatuses, selectedProvider]);
29702964 const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] =
29712965 useLocalStorage(
29722966 `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`,
......@@ -3612,6 +3606,7 @@ function ChatViewContent(props: ChatViewProps) {
36123606
36133607 const handleInteractionModeChange = useCallback(
36143608 (mode: ProviderInteractionMode) => {
3609 if (mode === "plan" && !interactionModeEnabled) return;
36153610 if (mode === interactionMode) return;
36163611 setComposerDraftInteractionMode(composerDraftTarget, mode);
36173612 if (isLocalDraftThread) {
......@@ -3621,6 +3616,7 @@ function ChatViewContent(props: ChatViewProps) {
36213616 },
36223617 [
36233618 interactionMode,
3619 interactionModeEnabled,
36243620 isLocalDraftThread,
36253621 scheduleComposerFocus,
36263622 composerDraftTarget,
......@@ -3629,8 +3625,18 @@ function ChatViewContent(props: ChatViewProps) {
36293625 ],
36303626 );
36313627 const toggleInteractionMode = useCallback(() => {
3628 if (!interactionModeEnabled) return;
36323629 handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");
3633 }, [handleInteractionModeChange, interactionMode]);
3630 }, [handleInteractionModeChange, interactionMode, interactionModeEnabled]);
3631 const openProviderSetup = useCallback(
3632 (instanceId: ProviderInstanceId) => {
3633 void navigate({
3634 to: "/settings/providers",
3635 search: { environmentId, instanceId },
3636 });
3637 },
3638 [environmentId, navigate],
3639 );
36343640 const createBrowserSurface = useCallback(
36353641 (profileId?: string) => {
36363642 if (!activeThreadRef) return;
......@@ -5863,6 +5869,13 @@ function ChatViewContent(props: ChatViewProps) {
58635869 const localApi = readLocalApi();
58645870 if (!localApi || !activeThread || isRevertingCheckpoint) return;
58655871
5872 if (!supportsConversationRollback) {
5873 setThreadError(
5874 activeThread.id,
5875 "This provider does not support reverting conversation history. Start a new thread instead.",
5876 );
5877 return;
5878 }
58665879 if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) {
58675880 setThreadError(
58685881 activeThread.id,
......@@ -5915,6 +5928,7 @@ function ChatViewContent(props: ChatViewProps) {
59155928 phase,
59165929 revertThreadCheckpoint,
59175930 setThreadError,
5931 supportsConversationRollback,
59185932 ],
59195933 );
59205934
......@@ -5983,6 +5997,8 @@ function ChatViewContent(props: ChatViewProps) {
59835997 selectedProviderModels: ctxSelectedProviderModels,
59845998 selectedPromptEffort: ctxSelectedPromptEffort,
59855999 selectedModelSelection: ctxSelectedModelSelection,
6000 interactionMode: sendInteractionMode,
6001 interactionModeEnabled: sendInteractionModeEnabled,
59866002 } = sendCtx;
59876003 const annotationImageAlreadyAttached =
59886004 directAnnotation?.image !== undefined &&
......@@ -6129,6 +6145,7 @@ function ChatViewContent(props: ChatViewProps) {
61296145 }
61306146 if (
61316147 !directAnnotation &&
6148 sendInteractionModeEnabled &&
61326149 showPlanFollowUpPrompt &&
61336150 activeProposedPlan &&
61346151 composerImages.length === 0 &&
......@@ -6157,10 +6174,9 @@ function ChatViewContent(props: ChatViewProps) {
61576174 });
61586175 return;
61596176 }
6160 // Legacy plan mode: /plan and /default only act when the beta flag is on;
6161 // otherwise they send as plain text like any other message.
6177 // Providers without the legacy toggle receive their native commands unchanged.
61626178 const standaloneSlashCommand =
6163 settings.planModeEnabled &&
6179 sendInteractionModeEnabled &&
61646180 composerImages.length === 0 &&
61656181 composerFiles.length === 0 &&
61666182 sendableComposerTerminalContexts.length === 0 &&
......@@ -6480,7 +6496,7 @@ function ChatViewContent(props: ChatViewProps) {
64806496 ? { branch: localCheckoutBranchMismatch.currentBranch }
64816497 : {}),
64826498 runtimeMode,
6483 interactionMode,
6499 interactionMode: sendInteractionMode,
64846500 });
64856501 if (settingsResult._tag === "Failure") {
64866502 failure = settingsResult;
......@@ -6511,7 +6527,7 @@ function ChatViewContent(props: ChatViewProps) {
65116527 title,
65126528 modelSelection: threadCreateModelSelection,
65136529 runtimeMode,
6514 interactionMode,
6530 interactionMode: sendInteractionMode,
65156531 branch: activeThreadBranch,
65166532 worktreePath: activeThread.worktreePath,
65176533 createdAt: activeThread.createdAt,
......@@ -6551,7 +6567,7 @@ function ChatViewContent(props: ChatViewProps) {
65516567 modelSelection: ctxSelectedModelSelection,
65526568 titleSeed: title,
65536569 runtimeMode,
6554 interactionMode,
6570 interactionMode: sendInteractionMode,
65556571 ...(bootstrap ? { bootstrap } : {}),
65566572 createdAt: messageCreatedAt,
65576573 },
......@@ -6772,7 +6788,7 @@ function ChatViewContent(props: ChatViewProps) {
67726788 );
67736789
67746790 const onSelectActivePendingUserInputOption = useCallback(
6775 (questionId: string, optionLabel: string) => {
6791 (questionId: string, optionValue: string) => {
67766792 if (!activePendingUserInput) {
67776793 return;
67786794 }
......@@ -6793,7 +6809,7 @@ function ChatViewContent(props: ChatViewProps) {
67936809 [questionId]: togglePendingUserInputOptionSelection(
67946810 question,
67956811 existing[activePendingUserInput.requestId]?.[questionId],
6796 optionLabel,
6812 optionValue,
67976813 ),
67986814 },
67996815 };
......@@ -6815,6 +6831,10 @@ function ChatViewContent(props: ChatViewProps) {
68156831 if (!activePendingUserInput) {
68166832 return;
68176833 }
6834 const question = activePendingUserInput.questions.find((entry) => entry.id === questionId);
6835 if (!question || question.allowCustomAnswer === false) {
6836 return;
6837 }
68186838 promptRef.current = value;
68196839 setPendingUserInputAnswersByRequestId((existing) => ({
68206840 ...existing,
......@@ -6888,7 +6908,7 @@ function ChatViewContent(props: ChatViewProps) {
68886908 }
68896909
68906910 const sendCtx = composerRef.current?.getSendContext();
6891 if (!sendCtx?.providerAvailable) {
6911 if (!sendCtx?.providerAvailable || !sendCtx.interactionModeEnabled) {
68926912 return;
68936913 }
68946914 const {
......@@ -7033,7 +7053,7 @@ function ChatViewContent(props: ChatViewProps) {
70337053 }
70347054
70357055 const sendCtx = composerRef.current?.getSendContext();
7036 if (!sendCtx?.providerAvailable) {
7056 if (!sendCtx?.providerAvailable || !sendCtx.interactionModeEnabled) {
70377057 return;
70387058 }
70397059 const {
......@@ -7625,6 +7645,7 @@ function ChatViewContent(props: ChatViewProps) {
76257645 <ProviderStatusBanner
76267646 status={visibleProviderStatus}
76277647 onDismiss={() => setDismissedProviderStatusBannerKey(providerStatusBannerKey)}
7648 onOpenProviderSetup={openProviderSetup}
76287649 />
76297650 </div>
76307651 {/* Messages Wrapper */}
......@@ -7836,6 +7857,7 @@ function ChatViewContent(props: ChatViewProps) {
78367857 onChangeActivePendingUserInputCustomAnswer
78377858 }
78387859 onProviderModelSelect={onProviderModelSelect}
7860 onOpenProviderSetup={openProviderSetup}
78397861 getModelDisabledReason={getModelDisabledReason}
78407862 toggleInteractionMode={toggleInteractionMode}
78417863 handleRuntimeModeChange={handleRuntimeModeChange}
apps/web/src/components/chat/ChatComposer.tsx+167-176
......@@ -47,7 +47,13 @@ import {
4747 replaceTextRange,
4848} from "../../composer-logic";
4949import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder";
50import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic";
50import {
51 deriveComposerSendState,
52 getAntigravitySendBlockReason,
53 readFileAsDataUrl,
54 resolveComposerInteractionMode,
55 resolveComposerProviderSelection,
56} from "../ChatView.logic";
5157import {
5258 dataTransferHasComposerMention,
5359 makeComposerMentionDragHandlers,
......@@ -767,13 +773,11 @@ import {
767773 XIcon,
768774} from "lucide-react";
769775import { proposedPlanTitle } from "../../proposedPlan";
770import { getProviderInteractionModeToggle } from "../../providerModels";
776import { hasProviderSetup } from "./ProviderStatusBanner";
771777import {
772778 applyProviderInstanceSettings,
773779 deriveProviderInstanceEntries,
774780 NO_PROVIDER_MODEL_SELECTION,
775 resolveProviderDriverKindForInstanceSelection,
776 resolveSelectableProviderInstanceEntry,
777781 sortProviderInstanceEntries,
778782 type ProviderInstanceEntry,
779783} from "../../providerInstances";
......@@ -1116,6 +1120,8 @@ export interface ChatComposerHandle {
11161120 selectedProvider: ProviderDriverKind;
11171121 selectedModel: string;
11181122 selectedProviderModels: ReadonlyArray<ServerProvider["models"][number]>;
1123 interactionMode: ProviderInteractionMode;
1124 interactionModeEnabled: boolean;
11191125 };
11201126 /** Validate the fully composed text immediately before a provider turn starts. */
11211127 validateProviderInput: (providerInput: string) => boolean;
......@@ -1165,7 +1171,11 @@ export interface ChatComposerProps {
11651171 isLastQuestion: boolean;
11661172 canAdvance: boolean;
11671173 customAnswer: string;
1168 activeQuestion: { id: string; multiSelect?: boolean | undefined } | null;
1174 activeQuestion: {
1175 id: string;
1176 multiSelect?: boolean | undefined;
1177 allowCustomAnswer?: boolean | undefined;
1178 } | null;
11691179 } | null;
11701180 activePendingResolvedAnswers: Record<string, unknown> | null;
11711181 activePendingIsResponding: boolean;
......@@ -1227,7 +1237,7 @@ export interface ChatComposerProps {
12271237 requestId: ApprovalRequestId,
12281238 decision: ProviderApprovalDecision,
12291239 ) => Promise<unknown>;
1230 onSelectActivePendingUserInputOption: (questionId: string, optionLabel: string) => void;
1240 onSelectActivePendingUserInputOption: (questionId: string, optionValue: string) => void;
12311241 onAdvanceActivePendingUserInput: () => void;
12321242 onPreviousActivePendingUserInputQuestion: () => void;
12331243 onChangeActivePendingUserInputCustomAnswer: (
......@@ -1239,6 +1249,7 @@ export interface ChatComposerProps {
12391249 ) => void;
12401250
12411251 onProviderModelSelect: (instanceId: ProviderInstanceId, model: string) => void;
1252 onOpenProviderSetup: (instanceId: ProviderInstanceId) => void;
12421253 getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null;
12431254 toggleInteractionMode: () => void;
12441255 handleRuntimeModeChange: (mode: RuntimeMode) => void;
......@@ -1290,7 +1301,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
12901301 showPlanFollowUpPrompt,
12911302 activeProposedPlan,
12921303 runtimeMode,
1293 interactionMode,
1304 interactionMode: requestedInteractionMode,
12941305 lockedProvider,
12951306 providerStatuses,
12961307 activeProjectDefaultModelSelection,
......@@ -1327,6 +1338,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
13271338 onPreviousActivePendingUserInputQuestion,
13281339 onChangeActivePendingUserInputCustomAnswer,
13291340 onProviderModelSelect,
1341 onOpenProviderSetup,
13301342 getModelDisabledReason,
13311343 toggleInteractionMode,
13321344 handleRuntimeModeChange,
......@@ -1393,10 +1405,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
13931405 environmentId,
13941406 })
13951407 : null);
1396 const sendDisabledReason =
1397 externalSendDisabledReason ?? (activePendingProgress ? null : attachmentBlockReason);
1398 const isSendDisabled = sendDisabledReason !== null;
1399
14001408 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
14011409 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);
14021410 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
......@@ -1523,105 +1531,43 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
15231531 [providerStatuses, settings],
15241532 );
15251533 const selectedProviderByThreadId = composerDraft.activeProvider ?? null;
1526 const threadProvider =
1527 activeThread?.session?.providerInstanceId ??
1528 activeThreadModelSelection?.instanceId ??
1529 activeProjectDefaultModelSelection?.instanceId ??
1530 null;
1531 const explicitSelectedInstanceId = selectedProviderByThreadId ?? threadProvider;
1532
1533 const unlockedSelectedProvider =
1534 resolveProviderDriverKindForInstanceSelection(
1535 providerInstanceEntries,
1536 providerStatuses,
1537 explicitSelectedInstanceId,
1538 ) ??
1539 providerInstanceEntries[0]?.driverKind ??
1540 ProviderDriverKind.make("unconfigured");
1541 const requestedDriverKind: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;
1542 const lockedContinuationGroupKey = useMemo((): string | null => {
1543 if (!lockedProvider || !activeThread) return null;
1544 const lockedInstanceId =
1545 activeThread.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId;
1546 if (!lockedInstanceId) return null;
1547 return (
1548 providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId)
1549 ?.continuationGroupKey ?? null
1550 );
1551 }, [
1552 activeThread,
1553 activeThreadModelSelection?.instanceId,
1554 lockedProvider,
1555 providerInstanceEntries,
1556 ]);
1557
1558 // Resolve which configured instance the composer is currently targeting.
1559 // Priority:
1560 // 1. The composer draft's `activeProvider` — the user's unsaved pick
1561 // from the model picker (must win, otherwise the UI appears to
1562 // ignore picker selections).
1563 // 2. Thread's persisted instance id (server-side saved selection).
1564 // 3. Project default's instance id.
1565 // 4. First enabled entry matching the current driver kind.
1566 // 5. First enabled entry overall / default instance for the kind.
1567 //
1568 const selectedInstanceId = useMemo<ProviderInstanceId>(() => {
1569 const candidates: Array<string | null | undefined> = [
1570 composerDraft.activeProvider,
1534 const {
1535 selectedProviderEntry,
1536 requestedDriverKind,
1537 lockedContinuationGroupKey,
1538 unavailableProviderInstanceId,
1539 } = useMemo(
1540 () =>
1541 resolveComposerProviderSelection({
1542 entries: providerInstanceEntries,
1543 candidateInstanceIds: [
1544 selectedProviderByThreadId,
1545 activeThread?.session?.providerInstanceId,
1546 activeThreadModelSelection?.instanceId,
1547 activeProjectDefaultModelSelection?.instanceId,
1548 ],
1549 lockedProvider,
1550 lockedInstanceId:
1551 activeThread?.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId,
1552 }),
1553 [
1554 activeProjectDefaultModelSelection?.instanceId,
15711555 activeThread?.session?.providerInstanceId,
15721556 activeThreadModelSelection?.instanceId,
1573 activeProjectDefaultModelSelection?.instanceId,
1574 ];
1575 for (const candidate of candidates) {
1576 if (!candidate) continue;
1577 const match = providerInstanceEntries.find(
1578 (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable,
1579 );
1580 if (match) {
1581 // When locked to a specific driver kind, ignore persisted instance
1582 // ids from a different kind or continuation group.
1583 if (lockedProvider && match.driverKind !== lockedProvider) continue;
1584 if (
1585 lockedContinuationGroupKey &&
1586 match.continuationGroupKey !== lockedContinuationGroupKey
1587 ) {
1588 continue;
1589 }
1590 return match.instanceId;
1591 }
1592 }
1593 const compatibleEntries = providerInstanceEntries.filter(
1594 (entry) =>
1595 (!lockedProvider || entry.driverKind === lockedProvider) &&
1596 (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey),
1597 );
1598 const requestedDriverEntries = compatibleEntries.filter(
1599 (entry) => entry.driverKind === requestedDriverKind,
1600 );
1601 return (
1602 resolveSelectableProviderInstanceEntry(requestedDriverEntries, undefined)?.instanceId ??
1603 resolveSelectableProviderInstanceEntry(compatibleEntries, undefined)?.instanceId ??
1604 NO_PROVIDER_MODEL_SELECTION.instanceId
1605 );
1606 }, [
1607 activeProjectDefaultModelSelection?.instanceId,
1608 activeThread?.session?.providerInstanceId,
1609 activeThreadModelSelection?.instanceId,
1610 composerDraft.activeProvider,
1611 lockedContinuationGroupKey,
1612 lockedProvider,
1613 providerInstanceEntries,
1614 requestedDriverKind,
1615 ]);
1616
1617 // Resolve the active instance's snapshot by `instanceId` so a custom
1618 // instance gets its own slash commands, skills, and model list — not
1619 // the first snapshot for the same driver kind.
1620 const selectedProviderEntry = useMemo(
1621 () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId),
1622 [providerInstanceEntries, selectedInstanceId],
1557 selectedProviderByThreadId,
1558 lockedProvider,
1559 providerInstanceEntries,
1560 ],
16231561 );
1562 const selectedInstanceId =
1563 selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId;
16241564 const noProviderAvailable = selectedProviderEntry === undefined;
1565 const providerSetupInstanceId = noProviderAvailable
1566 ? (unavailableProviderInstanceId ??
1567 (lockedProvider === null
1568 ? providerInstanceEntries.find((entry) => hasProviderSetup(entry.snapshot))?.instanceId
1569 : undefined))
1570 : undefined;
16251571 const resolvedCompactDisabledReason =
16261572 compactDisabledReason ?? (noProviderAvailable ? "Compacting is unavailable right now" : null);
16271573 // The driver kind follows the instance that will actually run the turn,
......@@ -1639,6 +1585,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
16391585 projectModelSelection: activeProjectDefaultModelSelection,
16401586 settings,
16411587 });
1588 const providerSendBlockReason = getAntigravitySendBlockReason(
1589 selectedProviderEntry?.snapshot,
1590 selectedModel,
1591 );
1592 const sendDisabledReason =
1593 externalSendDisabledReason ??
1594 (activePendingProgress ? null : (attachmentBlockReason ?? providerSendBlockReason));
1595 const isSendDisabled = sendDisabledReason !== null;
16421596 const selectedProviderStatus = useMemo(
16431597 () => selectedProviderEntry?.snapshot ?? null,
16441598 [selectedProviderEntry],
......@@ -1735,17 +1689,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
17351689
17361690 const selectedPromptEffort = composerProviderState.promptEffort;
17371691 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;
1738 // Plan mode is a legacy feature behind Settings → Beta. With the flag off,
1739 // ChatView forces the effective mode to "default", so hiding the toggle
1740 // can't trap anyone in plan mode.
1741 const planModeUiEnabled = settings.planModeEnabled;
1742 const composerProviderControls = useMemo(
1743 () => ({
1744 showInteractionModeToggle:
1745 planModeUiEnabled && getProviderInteractionModeToggle(providerStatuses, selectedProvider),
1746 }),
1747 [planModeUiEnabled, providerStatuses, selectedProvider],
1748 );
1692 const { enabled: planModeUiEnabled, interactionMode } = resolveComposerInteractionMode({
1693 planModeEnabled: settings.planModeEnabled,
1694 provider: selectedProviderStatus,
1695 interactionMode: requestedInteractionMode,
1696 });
17491697 const selectedModelSelection = useMemo<ModelSelection>(
17501698 () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch),
17511699 [selectedInstanceId, selectedModel, selectedModelOptionsForDispatch],
......@@ -2027,6 +1975,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
20271975
20281976 const isComposerApprovalState = activePendingApproval !== null;
20291977 const activePendingUserInput = pendingUserInputs[0] ?? null;
1978 const isChoiceOnlyPendingQuestion =
1979 activePendingProgress?.activeQuestion?.allowCustomAnswer === false;
20301980 const showComposerTopDrawer =
20311981 isComposerApprovalState ||
20321982 pendingUserInputs.length > 0 ||
......@@ -2519,6 +2469,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25192469 ) => {
25202470 expandComposerForEditorChange();
25212471 if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) {
2472 if (activePendingProgress.activeQuestion.allowCustomAnswer === false) return;
25222473 setComposerCursor(nextCursor);
25232474 setComposerTrigger(
25242475 cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),
......@@ -2572,6 +2523,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25722523 citationComment?: { start: number; sourceAnchor: AssistantCitationSourceAnchor };
25732524 },
25742525 ): boolean => {
2526 if (
2527 activePendingUserInput &&
2528 activePendingProgress?.activeQuestion?.allowCustomAnswer === false
2529 ) {
2530 return false;
2531 }
25752532 const currentText = promptRef.current;
25762533 const safeStart = Math.max(0, Math.min(currentText.length, rangeStart));
25772534 const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd));
......@@ -2696,6 +2653,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
26962653 }
26972654 return;
26982655 }
2656 if (!planModeUiEnabled) return;
26992657 void handleInteractionModeChange(item.command === "plan" ? "plan" : "default");
27002658 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
27012659 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
......@@ -2742,7 +2700,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
27422700 return;
27432701 }
27442702 },
2745 [applyPromptReplacement, handleInteractionModeChange, resolveActiveComposerTrigger],
2703 [
2704 applyPromptReplacement,
2705 handleInteractionModeChange,
2706 planModeUiEnabled,
2707 resolveActiveComposerTrigger,
2708 ],
27462709 );
27472710
27482711 const onComposerMenuItemHighlighted = useCallback(
......@@ -3766,7 +3729,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
37663729 id: "mode",
37673730 content: (
37683731 <ComposerFooterModeControls
3769 showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}
3732 showInteractionModeToggle={planModeUiEnabled}
37703733 interactionMode={interactionMode}
37713734 runtimeMode={runtimeMode}
37723735 size={composerControlsInStrip ? "xs" : "sm"}
......@@ -3784,12 +3747,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
37843747 type="button"
37853748 size="sm"
37863749 variant="ghost"
3787 disabled
3750 disabled={!providerSetupInstanceId}
3751 onClick={() => {
3752 if (providerSetupInstanceId) {
3753 onOpenProviderSetup(providerSetupInstanceId);
3754 }
3755 }}
37883756 data-chat-provider-unavailable="true"
37893757 className="shrink-0 gap-2 px-2 text-secondary-label sm:px-3"
37903758 >
37913759 <CircleAlertIcon className="size-4" />
3792 No provider available
3760 {providerSetupInstanceId ? "Open provider settings" : "No provider available"}
37933761 </Button>
37943762 ) : (
37953763 <>
......@@ -3835,13 +3803,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
38353803 onOpenChange={setIsComposerModelPickerOpen}
38363804 getModelDisabledReason={getModelDisabledReason}
38373805 onInstanceModelChange={onProviderModelSelect}
3806 onOpenProviderSetup={onOpenProviderSetup}
38383807 />
38393808
38403809 {composerControlsCompact ? (
38413810 <CompactComposerControlsMenu
38423811 interactionMode={interactionMode}
38433812 runtimeMode={runtimeMode}
3844 showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}
3813 showInteractionModeToggle={planModeUiEnabled}
38453814 traitsMenuContent={providerTraitsMenuContent}
38463815 onToggleInteractionMode={toggleInteractionMode}
38473816 onRuntimeModeChange={handleRuntimeModeChange}
......@@ -3884,8 +3853,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
38843853 size="xs"
38853854 hidden={hiddenRestingBlockIds.length === 0}
38863855 showInteractionModeToggle={
3887 composerProviderControls.showInteractionModeToggle &&
3888 hiddenRestingBlockIds.includes("mode")
3856 planModeUiEnabled && hiddenRestingBlockIds.includes("mode")
38893857 }
38903858 traitsMenuContent={
38913859 hiddenRestingBlockIds.includes("traits") ? providerTraitsMenuContent : undefined
......@@ -4454,7 +4422,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
44544422 );
44554423 },
44564424 addTerminalContext: (selection: TerminalContextSelection) => {
4457 if (!activeThread) return;
4425 if (!activeThread || isChoiceOnlyPendingQuestion) return;
44584426 const snapshot = composerEditorRef.current?.readSnapshot() ?? {
44594427 value: promptRef.current,
44604428 cursor: composerCursor,
......@@ -4499,10 +4467,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
44994467 selectedPromptEffort,
45004468 selectedModelOptionsForDispatch,
45014469 selectedModelSelection,
4502 providerAvailable: !noProviderAvailable,
4470 providerAvailable: !noProviderAvailable && providerSendBlockReason === null,
45034471 selectedProvider,
45044472 selectedModel,
45054473 selectedProviderModels,
4474 interactionMode,
4475 interactionModeEnabled: planModeUiEnabled,
45064476 }),
45074477 validateProviderInput: (providerInput: string) => {
45084478 const validationMessage = getComposerSubmissionValidationMessage({
......@@ -4534,6 +4504,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
45344504 focusComposer,
45354505 isConnecting,
45364506 isComposerApprovalState,
4507 isChoiceOnlyPendingQuestion,
45374508 pendingUserInputs.length,
45384509 projectSelectionRequired,
45394510 applyPromptReplacement,
......@@ -4543,9 +4514,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
45434514 selectedModelOptionsForDispatch,
45444515 selectedModelSelection,
45454516 noProviderAvailable,
4517 providerSendBlockReason,
45464518 selectedPromptEffort,
45474519 selectedProvider,
45484520 selectedProviderModels,
4521 interactionMode,
4522 planModeUiEnabled,
45494523 compactThreadContext,
45504524 ],
45514525 );
......@@ -4710,54 +4684,61 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
47104684 onToggleOption={onSelectActivePendingUserInputOption}
47114685 onAdvance={onAdvanceActivePendingUserInput}
47124686 />
4713 <ComposerBanner.Body>
4714 <div
4715 data-chat-composer-mobile-pending-compact="true"
4716 className={cn(
4717 "flex min-w-0 items-center gap-2 rounded-lg border border-border/55 bg-background/55 p-1.5 pl-3 transition-colors hover:bg-background/80",
4718 !activePendingProgress?.activeQuestion?.multiSelect && "p-0",
4719 )}
4720 >
4721 <button
4722 type="button"
4687 {!isChoiceOnlyPendingQuestion ||
4688 activePendingProgress?.activeQuestion?.multiSelect ? (
4689 <ComposerBanner.Body>
4690 <div
4691 data-chat-composer-mobile-pending-compact="true"
47234692 className={cn(
4724 "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm",
4725 activePendingProgress?.customAnswer
4726 ? "text-foreground"
4727 : "text-placeholder",
4728 !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2",
4693 "flex min-w-0 items-center gap-2 rounded-lg border border-border/55 bg-background/55 p-1.5 pl-3 transition-colors hover:bg-background/80",
4694 !activePendingProgress?.activeQuestion?.multiSelect && "p-0",
47294695 )}
4730 onPointerDown={(event) => event.preventDefault()}
4731 onClick={expandMobileComposer}
4732 aria-label="Write custom answer"
47334696 >
4734 {activePendingProgress?.customAnswer || "Write custom answer"}
4735 </button>
4736 {activePendingProgress?.activeQuestion?.multiSelect ? (
4737 <ComposerPrimaryActions
4738 compact
4739 pendingAction={pendingPrimaryAction}
4740 isRunning={false}
4741 showPlanFollowUpPrompt={false}
4742 promptHasText={false}
4743 isSendBusy={isSendBusy}
4744 sendDisabledReason={sendDisabledReason}
4745 isConnecting={isConnecting}
4746 isEnvironmentUnavailable={
4747 environmentUnavailable !== null ||
4748 noProviderAvailable ||
4749 projectSelectionRequired
4750 }
4751 isPreparingWorktree={false}
4752 hasSendableContent={false}
4753 preserveComposerFocusOnPointerDown
4754 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
4755 onInterrupt={handleInterruptPrimaryAction}
4756 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
4757 />
4758 ) : null}
4759 </div>
4760 </ComposerBanner.Body>
4697 {!isChoiceOnlyPendingQuestion ? (
4698 <button
4699 type="button"
4700 className={cn(
4701 "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm",
4702 activePendingProgress?.customAnswer
4703 ? "text-foreground"
4704 : "text-placeholder",
4705 !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2",
4706 )}
4707 onPointerDown={(event) => event.preventDefault()}
4708 onClick={expandMobileComposer}
4709 aria-label="Write custom answer"
4710 >
4711 {activePendingProgress?.customAnswer || "Write custom answer"}
4712 </button>
4713 ) : null}
4714 {activePendingProgress?.activeQuestion?.multiSelect ? (
4715 <ComposerPrimaryActions
4716 compact
4717 pendingAction={pendingPrimaryAction}
4718 isRunning={false}
4719 showPlanFollowUpPrompt={false}
4720 promptHasText={false}
4721 isSendBusy={isSendBusy}
4722 sendDisabledReason={sendDisabledReason}
4723 isConnecting={isConnecting}
4724 isEnvironmentUnavailable={
4725 environmentUnavailable !== null ||
4726 noProviderAvailable ||
4727 projectSelectionRequired
4728 }
4729 isPreparingWorktree={false}
4730 hasSendableContent={false}
4731 preserveComposerFocusOnPointerDown
4732 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
4733 onInterrupt={handleInterruptPrimaryAction}
4734 onImplementPlanInNewThread={
4735 handleImplementPlanInNewThreadPrimaryAction
4736 }
4737 />
4738 ) : null}
4739 </div>
4740 </ComposerBanner.Body>
4741 ) : null}
47614742 </div>
47624743 ) : null}
47634744 </ComposerBanner.Root>
......@@ -4823,12 +4804,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
48234804 : "text-placeholder",
48244805 )}
48254806 onPointerDown={(event) => event.preventDefault()}
4826 onClick={expandMobileComposer}
4807 onClick={isChoiceOnlyPendingQuestion ? undefined : expandMobileComposer}
4808 disabled={isChoiceOnlyPendingQuestion}
48274809 aria-label="Expand composer"
48284810 >
48294811 {activePendingProgress
4830 ? activePendingProgress.customAnswer ||
4831 "Type your own answer, or leave this blank to use the selected option"
4812 ? isChoiceOnlyPendingQuestion
4813 ? "Choose an option above"
4814 : activePendingProgress.customAnswer ||
4815 "Type your own answer, or leave this blank to use the selected option"
48324816 : prompt.trim() ||
48334817 (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
48344818 </button>
......@@ -5268,7 +5252,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
52685252 ? (activePendingApproval?.detail ??
52695253 "Resolve this approval request to continue")
52705254 : activePendingProgress
5271 ? "Type your own answer, or leave this blank to use the selected option"
5255 ? isChoiceOnlyPendingQuestion
5256 ? "Choose an option above"
5257 : "Type your own answer, or leave this blank to use the selected option"
52725258 : showPlanFollowUpPrompt && activeProposedPlan
52735259 ? "Add feedback to refine the plan, or leave this blank to implement it"
52745260 : projectSelectionRequired
......@@ -5279,7 +5265,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
52795265 ? DISCONNECTED_COMPOSER_PLACEHOLDER
52805266 : "Ask anything, @tag files/folders, $use skills, or / for commands"
52815267 }
5282 disabled={isConnecting || isComposerApprovalState || projectSelectionRequired}
5268 disabled={
5269 isConnecting ||
5270 isComposerApprovalState ||
5271 projectSelectionRequired ||
5272 isChoiceOnlyPendingQuestion
5273 }
52835274 />
52845275 {isComposerResting ? collapsedComposerImagePreviews : null}
52855276 {showMobilePendingAnswerActions ? (
apps/web/src/components/chat/ComposerPendingApprovalActions.test.tsx+25
......@@ -41,6 +41,31 @@ describe("ComposerPendingApprovalActions", () => {
4141 expect(markup).not.toContain("Always allow this session");
4242 });
4343
44 it("marks an option that carries a provider warning", () => {
45 const markup = renderToStaticMarkup(
46 <ComposerPendingApprovalActions
47 requestId={ApprovalRequestId.make("approval-1")}
48 isResponding={false}
49 options={[
50 { decision: "accept", label: "Allow once" },
51 {
52 decision: "acceptForSession",
53 label: "Allow for this thread",
54 warning: "Untrusted files could re-run this action without asking.",
55 },
56 { decision: "decline", label: "Deny" },
57 ]}
58 onRespondToApproval={async () => undefined}
59 />,
60 );
61
62 expect(markup).toContain(
63 'aria-description="Untrusted files could re-run this action without asking."',
64 );
65 expect(markup).toContain("text-warning");
66 expect(markup).toContain("Allow for this thread");
67 });
68
4469 it("limits provider-supplied approval labels so narrow rows can wrap", () => {
4570 const label = "Allow ".repeat(40).trim();
4671 const markup = renderToStaticMarkup(
apps/web/src/components/chat/ComposerPendingApprovalActions.tsx+38-18
......@@ -4,7 +4,9 @@ import {
44 type ProviderApprovalOption,
55} from "@t3tools/contracts";
66import { memo } from "react";
7import { TriangleAlertIcon } from "lucide-react";
78import { Button } from "../ui/button";
9import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
810
911interface ComposerPendingApprovalActionsProps {
1012 requestId: ApprovalRequestId;
......@@ -32,24 +34,42 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro
3234}: ComposerPendingApprovalActionsProps) {
3335 return (
3436 <>
35 {options.map((option) => (
36 <Button
37 key={option.decision}
38 size="micro"
39 variant="ghost-muted"
40 className={`${APPROVAL_ACTION_CLASS_NAME}${
41 option.decision === "decline"
42 ? " text-destructive-foreground [:hover,[data-pressed]]:text-destructive-foreground"
43 : option.decision === "accept"
44 ? " text-foreground"
45 : ""
46 }`}
47 disabled={isResponding}
48 onClick={() => void onRespondToApproval(requestId, option.decision)}
49 >
50 <span className="max-w-40 truncate">{option.label}</span>
51 </Button>
52 ))}
37 {options.map((option) => {
38 const button = (
39 <Button
40 key={option.decision}
41 size="micro"
42 variant="ghost-muted"
43 className={`${APPROVAL_ACTION_CLASS_NAME}${
44 option.decision === "decline"
45 ? " text-destructive-foreground [:hover,[data-pressed]]:text-destructive-foreground"
46 : option.decision === "accept"
47 ? " text-foreground"
48 : option.warning
49 ? " text-warning"
50 : ""
51 }`}
52 disabled={isResponding}
53 aria-description={option.warning}
54 onClick={() => void onRespondToApproval(requestId, option.decision)}
55 >
56 {option.warning ? <TriangleAlertIcon className="size-3 shrink-0" /> : null}
57 <span className="max-w-40 truncate">{option.label}</span>
58 </Button>
59 );
60 // A provider caution, such as a prompt injection warning on "allow
61 // always", rides along as a tooltip so the row stays one line.
62 return option.warning ? (
63 <Tooltip key={option.decision}>
64 <TooltipTrigger render={button} />
65 <TooltipPopup side="top" className="max-w-72 text-xs leading-snug">
66 {option.warning}
67 </TooltipPopup>
68 </Tooltip>
69 ) : (
70 button
71 );
72 })}
5373 </>
5474 );
5575});
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+15-14
......@@ -15,7 +15,7 @@ interface PendingUserInputPanelProps {
1515 respondingRequestIds: ApprovalRequestId[];
1616 answers: Record<string, PendingUserInputDraftAnswer>;
1717 questionIndex: number;
18 onToggleOption: (questionId: string, optionLabel: string) => void;
18 onToggleOption: (questionId: string, optionValue: string) => void;
1919 onAdvance: () => void;
2020}
2121
......@@ -56,7 +56,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
5656 isResponding: boolean;
5757 answers: Record<string, PendingUserInputDraftAnswer>;
5858 questionIndex: number;
59 onToggleOption: (questionId: string, optionLabel: string) => void;
59 onToggleOption: (questionId: string, optionValue: string) => void;
6060 onAdvance: () => void;
6161}) {
6262 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
......@@ -65,7 +65,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
6565 const onAdvanceRef = useRef(onAdvance);
6666 const [optimisticSingleSelect, setOptimisticSingleSelect] = useState<{
6767 questionId: string;
68 optionLabel: string;
68 optionValue: string;
6969 } | null>(null);
7070 // Collapsing hides everything but the header so a tall prompt stops covering
7171 // the thread the user is trying to read. Scoped to a single question: the card
......@@ -90,7 +90,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
9090 }
9191 if (
9292 progress.customAnswer.trim().length === 0 &&
93 progress.selectedOptionLabels.includes(optimisticSingleSelect.optionLabel)
93 progress.selectedOptionValues.includes(optimisticSingleSelect.optionValue)
9494 ) {
9595 setOptimisticSingleSelect(null);
9696 }
......@@ -98,7 +98,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
9898 activeQuestion,
9999 optimisticSingleSelect,
100100 progress.customAnswer,
101 progress.selectedOptionLabels,
101 progress.selectedOptionValues,
102102 ]);
103103
104104 // Clear auto-advance timer on unmount
......@@ -111,13 +111,13 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
111111 }, []);
112112
113113 const handleOptionSelection = useCallback(
114 (questionId: string, optionLabel: string) => {
114 (questionId: string, optionValue: string) => {
115115 if (activeQuestion?.multiSelect) {
116 onToggleOption(questionId, optionLabel);
116 onToggleOption(questionId, optionValue);
117117 return;
118118 }
119 setOptimisticSingleSelect({ questionId, optionLabel });
120 onToggleOption(questionId, optionLabel);
119 setOptimisticSingleSelect({ questionId, optionValue });
120 onToggleOption(questionId, optionValue);
121121 if (autoAdvanceTimerRef.current !== null) {
122122 window.clearTimeout(autoAdvanceTimerRef.current);
123123 }
......@@ -154,7 +154,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
154154 const option = activeQuestion.options[optionIndex];
155155 if (!option) return;
156156 event.preventDefault();
157 handleOptionSelection(activeQuestion.id, option.label);
157 handleOptionSelection(activeQuestion.id, option.value ?? option.label);
158158 };
159159 document.addEventListener("keydown", handler);
160160 return () => document.removeEventListener("keydown", handler);
......@@ -208,12 +208,13 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
208208 ) : null}
209209 <div className="mt-2 space-y-0.5">
210210 {activeQuestion.options.map((option, index) => {
211 const optionValue = option.value ?? option.label;
211212 const isOptimisticallySelected =
212213 optimisticSingleSelect?.questionId === activeQuestion.id &&
213 optimisticSingleSelect.optionLabel === option.label;
214 optimisticSingleSelect.optionValue === optionValue;
214215 const isSelected =
215216 isOptimisticallySelected ||
216 (!customAnswerActive && progress.selectedOptionLabels.includes(option.label));
217 (!customAnswerActive && progress.selectedOptionValues.includes(optionValue));
217218 const shortcutKey = index < 9 ? index + 1 : null;
218219 const className = cn(
219220 "group flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-primary/25",
......@@ -246,11 +247,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
246247 );
247248 return (
248249 <button
249 key={`${activeQuestion.id}:${option.label}`}
250 key={`${activeQuestion.id}:${optionValue}`}
250251 type="button"
251252 disabled={isResponding}
252253 onClick={() => {
253 handleOptionSelection(activeQuestion.id, option.label);
254 handleOptionSelection(activeQuestion.id, optionValue);
254255 }}
255256 className={className}
256257 >
apps/web/src/components/chat/ModelPickerContent.test.ts+158-10
......@@ -1,14 +1,23 @@
1import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
1import {
2 ANTIGRAVITY_DEFAULT_MODEL,
3 ProviderDriverKind,
4 ProviderInstanceId,
5 type ServerProvider,
6} from "@t3tools/contracts";
27import { describe, expect, it } from "vite-plus/test";
38
49import { deriveProviderInstanceEntries } from "../../providerInstances";
5import { shouldIncludeModelPickerOption } from "./ModelPickerContent";
10import {
11 resolveModelPickerSelectedModel,
12 shouldIncludeModelPickerOption,
13 shouldOfferModelPickerSetup,
14} from "./ModelPickerContent";
615
7function entry(status: ServerProvider["status"]) {
16function entry(status: ServerProvider["status"], driver = "opencode") {
817 return deriveProviderInstanceEntries([
918 {
10 instanceId: ProviderInstanceId.make("opencode_work"),
11 driver: ProviderDriverKind.make("opencode"),
19 instanceId: ProviderInstanceId.make(`${driver}_work`),
20 driver: ProviderDriverKind.make(driver),
1221 enabled: true,
1322 installed: true,
1423 version: null,
......@@ -23,12 +32,36 @@ function entry(status: ServerProvider["status"]) {
2332}
2433
2534describe("shouldIncludeModelPickerOption", () => {
26 it.each(["error", "warning"] as const)(
27 "keeps only the active synthetic OpenCode row when the provider status is %s",
35 it.each(["ready", "error"] as const)(
36 "never offers the internal Antigravity default marker as a model when %s",
2837 (status) => {
29 const providerEntry = entry(status);
30 const activeInstanceId = ProviderInstanceId.make("opencode_work");
31 const activeModel = "openrouter/kimi-k3";
38 const providerEntry = entry(status, "antigravity");
39 expect(
40 shouldIncludeModelPickerOption({
41 entry: providerEntry,
42 option: {
43 slug: ANTIGRAVITY_DEFAULT_MODEL,
44 name: ANTIGRAVITY_DEFAULT_MODEL,
45 isUnavailable: true,
46 },
47 activeInstanceId: providerEntry.instanceId,
48 activeModel: ANTIGRAVITY_DEFAULT_MODEL,
49 }),
50 ).toBe(false);
51 },
52 );
53
54 it.each([
55 ["opencode", "error"],
56 ["opencode", "warning"],
57 ["antigravity", "error"],
58 ["antigravity", "warning"],
59 ] as const)(
60 "keeps only the active synthetic %s row when the provider status is %s",
61 (driver, status) => {
62 const providerEntry = entry(status, driver);
63 const activeInstanceId = providerEntry.instanceId;
64 const activeModel = "missing-model";
3265
3366 expect(
3467 shouldIncludeModelPickerOption({
......@@ -62,6 +95,121 @@ describe("shouldIncludeModelPickerOption", () => {
6295 activeModel,
6396 }),
6497 ).toBe(false);
98 expect(
99 shouldIncludeModelPickerOption({
100 entry: providerEntry,
101 option: { slug: activeModel, name: activeModel, isUnavailable: true },
102 activeInstanceId: ProviderInstanceId.make(`${driver}_personal`),
103 activeModel,
104 }),
105 ).toBe(false);
65106 },
66107 );
67108});
109
110describe("resolveModelPickerSelectedModel", () => {
111 it("follows the catalog default for the marker but keeps an explicit native model", () => {
112 const driverKind = ProviderDriverKind.make("antigravity");
113 const previousOptions = [
114 { slug: "gemini-fast", name: "Gemini Fast", aliases: [ANTIGRAVITY_DEFAULT_MODEL] },
115 { slug: "gemini-pro", name: "Gemini Pro" },
116 ];
117 const nextOptions = [
118 { slug: "gemini-fast", name: "Gemini Fast" },
119 { slug: "gemini-pro", name: "Gemini Pro", aliases: [ANTIGRAVITY_DEFAULT_MODEL] },
120 ];
121
122 expect(
123 resolveModelPickerSelectedModel({
124 driverKind,
125 model: ANTIGRAVITY_DEFAULT_MODEL,
126 options: previousOptions,
127 })?.slug,
128 ).toBe("gemini-fast");
129 expect(
130 resolveModelPickerSelectedModel({
131 driverKind,
132 model: ANTIGRAVITY_DEFAULT_MODEL,
133 options: nextOptions,
134 })?.slug,
135 ).toBe("gemini-pro");
136 expect(
137 resolveModelPickerSelectedModel({
138 driverKind,
139 model: "gemini-fast",
140 options: nextOptions,
141 })?.slug,
142 ).toBe("gemini-fast");
143 });
144
145 it("does not guess the default from the first model in a catalog", () => {
146 expect(
147 resolveModelPickerSelectedModel({
148 driverKind: ProviderDriverKind.make("antigravity"),
149 model: ANTIGRAVITY_DEFAULT_MODEL,
150 options: [{ slug: "gemini-fast", name: "Gemini Fast" }],
151 }),
152 ).toBeUndefined();
153 });
154});
155
156describe("shouldOfferModelPickerSetup", () => {
157 const availableModel = { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro" };
158
159 it("offers setup before an Antigravity account has models", () => {
160 expect(shouldOfferModelPickerSetup(entry("error", "antigravity"), [])).toBe(true);
161 });
162
163 it("offers setup after sign-out even if a model remains cached", () => {
164 const providerEntry = entry("ready", "antigravity");
165 expect(
166 shouldOfferModelPickerSetup(
167 {
168 ...providerEntry,
169 snapshot: { ...providerEntry.snapshot, auth: { status: "unauthenticated" } },
170 },
171 [availableModel],
172 ),
173 ).toBe(true);
174 });
175
176 it("offers setup when the only model is an unavailable saved selection", () => {
177 expect(
178 shouldOfferModelPickerSetup(entry("ready", "antigravity"), [
179 { ...availableModel, isUnavailable: true },
180 ]),
181 ).toBe(true);
182 });
183
184 it("does not offer setup for a ready account with available models", () => {
185 expect(shouldOfferModelPickerSetup(entry("ready", "antigravity"), [availableModel])).toBe(
186 false,
187 );
188 });
189
190 it("does not restore a disabled provider while its status snapshot is stale", () => {
191 expect(
192 shouldOfferModelPickerSetup({ ...entry("error", "antigravity"), enabled: false }, []),
193 ).toBe(false);
194 });
195
196 it("keeps providers without integrated setup on their existing path", () => {
197 expect(shouldOfferModelPickerSetup(entry("error", "codex"), [])).toBe(false);
198 });
199
200 it("uses the environment's setup capability for other drivers", () => {
201 const providerEntry = entry("error", "custom_driver");
202 expect(
203 shouldOfferModelPickerSetup(
204 {
205 ...providerEntry,
206 snapshot: {
207 ...providerEntry.snapshot,
208 setup: { canAuthenticate: true, canInstall: false },
209 },
210 },
211 [],
212 ),
213 ).toBe(true);
214 });
215});
apps/web/src/components/chat/ModelPickerContent.tsx+134-19
......@@ -1,4 +1,5 @@
11import {
2 ANTIGRAVITY_DEFAULT_MODEL,
23 type ProviderInstanceId,
34 type ProviderDriverKind,
45 type ResolvedKeybindingsConfig,
......@@ -9,6 +10,7 @@ import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRe
910import { ChevronRightIcon, SearchIcon } from "lucide-react";
1011import { ModelListRow } from "./ModelListRow";
1112import { ModelPickerSidebar } from "./ModelPickerSidebar";
13import { getProviderStatusMessage, hasProviderSetup } from "./ProviderStatusBanner";
1214import {
1315 modelPickerLegacySectionKey,
1416 modelPickerModelKey,
......@@ -34,6 +36,7 @@ import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"
3436import { cn } from "~/lib/utils";
3537import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";
3638import { TooltipProvider } from "../ui/tooltip";
39import { Button } from "../ui/button";
3740import {
3841 isProviderInstancePickerReady,
3942 isProviderInstancePickerVisible,
......@@ -56,22 +59,57 @@ type ModelPickerItem = {
5659 isUnavailable?: boolean | undefined;
5760};
5861
62export function resolveModelPickerSelectedModel(input: {
63 driverKind: ProviderDriverKind | undefined;
64 model: string;
65 options: ReadonlyArray<ModelEsque>;
66}) {
67 if (input.driverKind === "antigravity" && input.model === ANTIGRAVITY_DEFAULT_MODEL) {
68 const availableModels = input.options.filter(
69 (option) => option.slug !== ANTIGRAVITY_DEFAULT_MODEL && !option.isUnavailable,
70 );
71 return (
72 availableModels.find((option) => option.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL)) ??
73 availableModels.find((option) => option.isDefault)
74 );
75 }
76 return input.options.find((option) => option.slug === input.model);
77}
78
5979export function shouldIncludeModelPickerOption(input: {
6080 readonly entry: ProviderInstanceEntry;
6181 readonly option: ModelEsque;
6282 readonly activeInstanceId: ProviderInstanceId;
6383 readonly activeModel: string;
6484}): boolean {
85 if (input.entry.driverKind === "antigravity" && input.option.slug === ANTIGRAVITY_DEFAULT_MODEL) {
86 return false;
87 }
6588 if (isProviderInstancePickerReady(input.entry)) return true;
6689 return (
6790 input.entry.enabled &&
68 input.entry.driverKind === "opencode" &&
91 (input.entry.driverKind === "opencode" || input.entry.driverKind === "antigravity") &&
6992 input.entry.instanceId === input.activeInstanceId &&
7093 input.option.slug === input.activeModel &&
7194 input.option.isUnavailable === true
7295 );
7396}
7497
98export function shouldOfferModelPickerSetup(
99 entry: ProviderInstanceEntry,
100 options: ReadonlyArray<ModelEsque>,
101): boolean {
102 return (
103 entry.enabled &&
104 entry.status !== "disabled" &&
105 hasProviderSetup(entry.snapshot) &&
106 (!isProviderInstancePickerReady(entry) ||
107 !entry.installed ||
108 entry.snapshot.auth.status === "unauthenticated" ||
109 !options.some((option) => !option.isUnavailable))
110 );
111}
112
75113const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>();
76114
77115function ModelListSeparator() {
......@@ -107,6 +145,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
107145 modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>;
108146 terminalOpen: boolean;
109147 onRequestClose?: () => void;
148 onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void;
110149 getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null;
111150 onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void;
112151}) {
......@@ -127,6 +166,16 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
127166 const activeEntry = props.instanceEntries.find(
128167 (entry) => entry.instanceId === props.activeInstanceId,
129168 );
169 const activeModel = resolveModelPickerSelectedModel({
170 driverKind: activeEntry?.driverKind,
171 model: props.model,
172 options: modelOptionsByInstance.get(props.activeInstanceId) ?? [],
173 });
174 const activeModelSlug =
175 activeModel?.slug ?? (props.model === ANTIGRAVITY_DEFAULT_MODEL ? "" : props.model);
176 const activeModelKey = activeModelSlug
177 ? modelPickerModelKey(props.activeInstanceId, activeModelSlug)
178 : null;
130179 const activeInstanceHasSelectableUnavailableModel =
131180 activeEntry !== undefined &&
132181 (modelOptionsByInstance.get(props.activeInstanceId) ?? []).some((option) =>
......@@ -134,15 +183,25 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
134183 entry: activeEntry,
135184 option,
136185 activeInstanceId: props.activeInstanceId,
137 activeModel: props.model,
186 activeModel: activeModelSlug,
138187 }),
139188 ) &&
140189 !isProviderInstancePickerReady(activeEntry);
190 const activeInstanceNeedsSetup =
191 props.onOpenProviderSetup !== undefined &&
192 activeEntry !== undefined &&
193 shouldOfferModelPickerSetup(
194 activeEntry,
195 modelOptionsByInstance.get(props.activeInstanceId) ?? [],
196 );
141197 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | "favorites">(
142198 () => {
143 if (props.lockedProvider !== null || activeInstanceHasSelectableUnavailableModel) {
144 // When locked, prime the sidebar to the currently-active instance
145 // so jumping into the picker keeps the focused instance visible.
199 if (
200 props.lockedProvider !== null ||
201 activeInstanceHasSelectableUnavailableModel ||
202 activeInstanceNeedsSetup
203 ) {
204 // Keep the active instance visible when it is locked or needs setup.
146205 return props.activeInstanceId;
147206 }
148207 return favorites.length > 0 ? "favorites" : props.activeInstanceId;
......@@ -153,7 +212,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
153212 new Set<ProviderInstanceId>(
154213 modelOptionsByInstance
155214 .get(props.activeInstanceId)
156 ?.some((model) => model.slug === props.model && model.isLegacy)
215 ?.some((model) => model.slug === activeModelSlug && model.isLegacy)
157216 ? [props.activeInstanceId]
158217 : [],
159218 ),
......@@ -221,11 +280,27 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
221280 );
222281
223282 const selectableUnavailableInstanceIds = useMemo(() => {
224 if (!activeInstanceHasSelectableUnavailableModel) {
225 return undefined;
283 const instanceIds = new Set<ProviderInstanceId>();
284 if (activeInstanceHasSelectableUnavailableModel) {
285 instanceIds.add(props.activeInstanceId);
226286 }
227 return new Set([props.activeInstanceId]);
228 }, [activeInstanceHasSelectableUnavailableModel, props.activeInstanceId]);
287 if (props.onOpenProviderSetup) {
288 for (const entry of instanceEntries) {
289 if (
290 shouldOfferModelPickerSetup(entry, modelOptionsByInstance.get(entry.instanceId) ?? [])
291 ) {
292 instanceIds.add(entry.instanceId);
293 }
294 }
295 }
296 return instanceIds.size > 0 ? instanceIds : undefined;
297 }, [
298 activeInstanceHasSelectableUnavailableModel,
299 instanceEntries,
300 modelOptionsByInstance,
301 props.activeInstanceId,
302 props.onOpenProviderSetup,
303 ]);
229304
230305 // Flatten models into a searchable array. One pass over the
231306 // instance-keyed map; each model carries its instance id + driver kind
......@@ -246,7 +321,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
246321 entry,
247322 option: model,
248323 activeInstanceId: props.activeInstanceId,
249 activeModel: props.model,
324 activeModel: activeModelSlug,
250325 })
251326 ) {
252327 continue;
......@@ -270,7 +345,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
270345 }
271346 }
272347 return out;
273 }, [modelOptionsByInstance, entryByInstanceId, props.activeInstanceId, props.model]);
348 }, [modelOptionsByInstance, entryByInstanceId, props.activeInstanceId, activeModelSlug]);
274349
275350 const isLocked = props.lockedProvider !== null;
276351 const isSearching = searchQuery.trim().length > 0;
......@@ -441,6 +516,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
441516 ];
442517 }, [filteredModels, legacySection]);
443518
519 const selectedEntry =
520 selectedInstanceId === "favorites" ? undefined : entryByInstanceId.get(selectedInstanceId);
521 const providerSetupEntries =
522 !isSearching && props.onOpenProviderSetup
523 ? instanceEntries.filter(
524 (entry) =>
525 matchesLockedProvider(entry) &&
526 shouldOfferModelPickerSetup(
527 entry,
528 modelOptionsByInstance.get(entry.instanceId) ?? [],
529 ) &&
530 (selectedEntry
531 ? entry.instanceId === selectedEntry.instanceId
532 : filteredModels.length === 0),
533 )
534 : [];
535
444536 const toggleLegacySection = useCallback((instanceId: ProviderInstanceId) => {
445537 setExpandedLegacyInstances((expanded) => {
446538 const next = new Set(expanded);
......@@ -666,7 +758,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
666758 autoHighlight
667759 open
668760 virtualized
669 value={modelPickerModelKey(props.activeInstanceId, props.model)}
761 value={activeModelKey}
670762 onItemHighlighted={(modelKey, eventDetails) => {
671763 highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null;
672764 if (eventDetails.reason === "keyboard" && eventDetails.index >= 0) {
......@@ -799,9 +891,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
799891 isFavorite={favoritesSet.has(
800892 providerModelKey(model.instanceId, model.slug),
801893 )}
802 isSelected={
803 modelKey === modelPickerModelKey(props.activeInstanceId, props.model)
804 }
894 isSelected={modelKey === activeModelKey}
805895 showProvider
806896 preferShortName={!isLocked}
807897 useTriggerLabel={false}
......@@ -830,9 +920,34 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
830920 />
831921 </ComboboxListVirtualized>
832922 </div>
833 <ComboboxEmpty className="not-empty:py-6 empty:h-0 text-xs font-normal leading-snug">
834 No models found
835 </ComboboxEmpty>
923 {providerSetupEntries.length > 0 ? (
924 <div className="max-h-44 shrink-0 overflow-y-auto border-t border-border/70 p-2">
925 {providerSetupEntries.map((entry) => (
926 <div key={entry.instanceId} className="px-1 py-1.5 text-xs leading-snug">
927 <p className="line-clamp-3 text-muted-foreground">
928 {getProviderStatusMessage(entry.snapshot)}
929 </p>
930 <Button
931 className="mt-1 px-0 text-foreground"
932 onClick={() => {
933 props.onRequestClose?.();
934 props.onOpenProviderSetup?.(entry.instanceId);
935 }}
936 size="xs"
937 variant="link"
938 >
939 {providerSetupEntries.length > 1
940 ? `Set up ${entry.displayName}`
941 : "Open provider setup"}
942 </Button>
943 </div>
944 ))}
945 </div>
946 ) : (
947 <ComboboxEmpty className="not-empty:py-6 empty:h-0 text-xs font-normal leading-snug">
948 No models found
949 </ComboboxEmpty>
950 )}
836951 </div>
837952 </Combobox>
838953 </div>
apps/web/src/components/chat/ProviderModelPicker.test.tsx+53-11
......@@ -1,4 +1,9 @@
1import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
1import {
2 ANTIGRAVITY_DEFAULT_MODEL,
3 ProviderDriverKind,
4 ProviderInstanceId,
5 type ServerProvider,
6} from "@t3tools/contracts";
27import { renderToStaticMarkup } from "react-dom/server";
38import { describe, expect, it } from "vite-plus/test";
49
......@@ -45,17 +50,54 @@ function renderPicker(input: {
4550}
4651
4752describe("ProviderModelPicker", () => {
48 it("shows a missing model slug for a custom OpenCode instance", () => {
49 const markup = renderPicker({
50 instanceId: "team_runtime",
51 driver: "opencode",
52 model: "openrouter/missing-model",
53 options: [{ slug: "openrouter/fallback", name: "Fallback model" }],
54 });
53 it.each(["", ANTIGRAVITY_DEFAULT_MODEL])(
54 "shows a choice prompt before Antigravity has an account catalog for %s",
55 (model) => {
56 const markup = renderPicker({
57 instanceId: "antigravity",
58 driver: "antigravity",
59 model,
60 options: [],
61 });
5562
56 expect(markup).toContain("openrouter/missing-model");
57 expect(markup).not.toContain("Fallback model");
58 });
63 expect(markup).toContain("Choose model");
64 expect(markup).not.toContain(ANTIGRAVITY_DEFAULT_MODEL);
65 },
66 );
67
68 it.each([{ aliases: [ANTIGRAVITY_DEFAULT_MODEL] }, { isDefault: true }])(
69 "shows the actual default model for an Antigravity marker with %j",
70 (defaultMetadata) => {
71 const markup = renderPicker({
72 instanceId: "google_work",
73 driver: "antigravity",
74 model: ANTIGRAVITY_DEFAULT_MODEL,
75 options: [
76 { slug: "gemini-fast", name: "Gemini Fast" },
77 { slug: "gemini-pro", name: "Gemini Pro", ...defaultMetadata },
78 ],
79 });
80
81 expect(markup).toContain("Gemini Pro");
82 expect(markup).not.toContain("Gemini Fast");
83 expect(markup).not.toContain(ANTIGRAVITY_DEFAULT_MODEL);
84 },
85 );
86
87 it.each(["opencode", "antigravity"])(
88 "keeps the selected model label when the %s account catalog does not contain it",
89 (driver) => {
90 const markup = renderPicker({
91 instanceId: "team_runtime",
92 driver,
93 model: "missing-model",
94 options: [{ slug: "fallback", name: "Fallback model" }],
95 });
96
97 expect(markup).toContain("missing-model");
98 expect(markup).not.toContain("Fallback model");
99 },
100 );
59101
60102 it.each(["codex", "claudeAgent", "cursor", "grok"])(
61103 "uses the first option label for a missing %s model",
apps/web/src/components/chat/ProviderModelPicker.tsx+19-7
......@@ -1,4 +1,5 @@
11import {
2 ANTIGRAVITY_DEFAULT_MODEL,
23 type ProviderInstanceId,
34 type ProviderDriverKind,
45 type ResolvedKeybindingsConfig,
......@@ -10,7 +11,7 @@ import { buttonVariants } from "../ui/button";
1011import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
1112import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1213import { cn } from "~/lib/utils";
13import { ModelPickerContent } from "./ModelPickerContent";
14import { ModelPickerContent, resolveModelPickerSelectedModel } from "./ModelPickerContent";
1415import { ProviderInstanceIcon } from "./ProviderInstanceIcon";
1516import {
1617 ModelEsque,
......@@ -50,6 +51,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
5051 triggerClassName?: string;
5152 triggerAriaLabel?: string;
5253 onOpenChange?: (open: boolean) => void;
54 onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void;
5355 getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null;
5456 onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void;
5557}) {
......@@ -68,15 +70,24 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
6870
6971 const activeInstanceId = props.activeInstanceId;
7072 const selectedInstanceOptions = props.modelOptionsByInstance.get(activeInstanceId) ?? [];
71 // OpenCode can keep a model through a transient catalog refresh. Other
72 // providers keep the active instance's first option as their normal fallback.
73 // Account-specific catalogs must keep the selected model label while unavailable.
7374 const selectedModel =
74 selectedInstanceOptions.find((option) => option.slug === props.model) ??
75 (activeEntry?.driverKind === "opencode" ? undefined : selectedInstanceOptions[0]);
76 const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model;
75 resolveModelPickerSelectedModel({
76 driverKind: activeEntry?.driverKind,
77 model: props.model,
78 options: selectedInstanceOptions,
79 }) ??
80 (activeEntry?.driverKind === "opencode" || activeEntry?.driverKind === "antigravity"
81 ? undefined
82 : selectedInstanceOptions[0]);
83 const triggerTitle = selectedModel
84 ? getTriggerDisplayModelName(selectedModel)
85 : props.model === ANTIGRAVITY_DEFAULT_MODEL
86 ? "Choose model"
87 : props.model || "Choose model";
7788 const triggerLabel = selectedModel
7889 ? `${getTriggerDisplayModelLabel(selectedModel)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}`
79 : props.model;
90 : triggerTitle;
8091 const showInstanceBadge =
8192 activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries);
8293
......@@ -225,6 +236,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
225236 modelOptionsByInstance={props.modelOptionsByInstance}
226237 terminalOpen={props.terminalOpen ?? false}
227238 onRequestClose={() => setIsMenuOpen(false)}
239 {...(props.onOpenProviderSetup ? { onOpenProviderSetup: props.onOpenProviderSetup } : {})}
228240 {...(props.getModelDisabledReason
229241 ? { getModelDisabledReason: props.getModelDisabledReason }
230242 : {})}
apps/web/src/components/chat/ProviderStatusBanner.test.tsx+53
......@@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";
44
55import {
66 getProviderStatusBannerKey,
7 getProviderStatusMessage,
78 ProviderStatusBanner,
89 shouldShowProviderStatusBanner,
910} from "./ProviderStatusBanner";
......@@ -64,3 +65,55 @@ describe("ProviderStatusBanner", () => {
6465 expect(markup).toContain('aria-label="Dismiss Codex provider error"');
6566 });
6667});
68
69describe("getProviderStatusMessage", () => {
70 it("preserves the environment's authentication error", () => {
71 const message = "SUBSCRIPTION_REQUIRED: This Google account cannot use Antigravity.";
72 expect(
73 getProviderStatusMessage({
74 ...warningProvider(),
75 driver: ProviderDriverKind.make("antigravity"),
76 status: "error",
77 auth: { status: "unauthenticated" },
78 message,
79 }),
80 ).toBe(message);
81 });
82
83 it("points a signed-out Antigravity account to Google sign-in without a CLI command", () => {
84 expect(
85 getProviderStatusMessage({
86 ...warningProvider(),
87 driver: ProviderDriverKind.make("antigravity"),
88 status: "error",
89 auth: { status: "unauthenticated" },
90 message: "",
91 }),
92 ).toBe("Open provider setup to sign in with Google.");
93 });
94
95 it("requires installation on the environment before sign-in", () => {
96 expect(
97 getProviderStatusMessage({
98 ...warningProvider(),
99 driver: ProviderDriverKind.make("antigravity"),
100 displayName: "Google work account",
101 installed: false,
102 status: "error",
103 auth: { status: "unauthenticated" },
104 message: "",
105 }),
106 ).toBe("Open provider setup to install Antigravity on this environment.");
107 });
108
109 it("keeps CLI sign-in advice for a provider without integrated setup", () => {
110 expect(
111 getProviderStatusMessage({
112 ...warningProvider(),
113 status: "error",
114 auth: { status: "unauthenticated" },
115 message: "",
116 }),
117 ).toBe("Sign in via the CLI to authenticate again.");
118 });
119});
apps/web/src/components/chat/ProviderStatusBanner.tsx+44-7
......@@ -1,4 +1,4 @@
1import { type ServerProvider } from "@t3tools/contracts";
1import { type ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
22import { memo } from "react";
33import { InfoIcon, XIcon } from "lucide-react";
44import { cn } from "~/lib/utils";
......@@ -20,11 +20,43 @@ export function shouldShowProviderStatusBanner(
2020 return bannerKey !== null && bannerKey !== dismissedBannerKey;
2121}
2222
23export function hasProviderSetup(status: ServerProvider): boolean {
24 return (
25 status.driver === "antigravity" ||
26 status.setup?.canAuthenticate === true ||
27 status.setup?.canInstall === true
28 );
29}
30
31/** Keep the environment's error intact in both the banner and model picker. */
32export function getProviderStatusMessage(status: ServerProvider): string {
33 if (status.message) return status.message;
34 const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver);
35 if (!status.installed && hasProviderSetup(status)) {
36 return `Open provider setup to install ${formatProviderDriverKindLabel(status.driver)} on this environment.`;
37 }
38 if (status.auth.status === "unauthenticated") {
39 if (hasProviderSetup(status)) {
40 return status.driver === "antigravity"
41 ? "Open provider setup to sign in with Google."
42 : "Open provider setup to sign in.";
43 }
44 return "Sign in via the CLI to authenticate again.";
45 }
46 return status.status === "ready"
47 ? "No models are available for this provider."
48 : status.status === "error"
49 ? `${providerName} provider is unavailable.`
50 : `${providerName} provider has limited availability.`;
51}
52
2353export const ProviderStatusBanner = memo(function ProviderStatusBanner({
2454 onDismiss,
55 onOpenProviderSetup,
2556 status,
2657}: {
2758 onDismiss: () => void;
59 onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void;
2860 status: ServerProvider | null;
2961}) {
3062 if (!status || status.status === "ready" || status.status === "disabled") {
......@@ -36,12 +68,7 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
3668 const title = isUnauthenticated
3769 ? `${providerName} is unauthenticated`
3870 : `${providerName} provider status`;
39 const message = isUnauthenticated
40 ? "Sign in via the CLI to authenticate again."
41 : (status.message ??
42 (status.status === "error"
43 ? `${providerName} provider is unavailable.`
44 : `${providerName} provider has limited availability.`));
71 const message = getProviderStatusMessage(status);
4572
4673 return (
4774 <div className="pointer-events-auto mx-auto w-fit max-w-[calc(100%-2rem)] pt-3">
......@@ -66,6 +93,16 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
6693 {message}
6794 </TooltipPopup>
6895 </Tooltip>
96 {onOpenProviderSetup && hasProviderSetup(status) ? (
97 <Button
98 className="self-start px-0 text-foreground"
99 onClick={() => onOpenProviderSetup(status.instanceId)}
100 size="xs"
101 variant="link"
102 >
103 Open provider setup
104 </Button>
105 ) : null}
69106 </div>
70107 <Button
71108 aria-label={`Dismiss ${providerName} provider ${status.status}`}
apps/web/src/components/chat/providerIconUtils.ts+12-1
......@@ -1,5 +1,13 @@
11import { ProviderDriverKind } from "@t3tools/contracts";
2import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons";
2import {
3 AntigravityIcon,
4 ClaudeAI,
5 CursorIcon,
6 GrokIcon,
7 Icon,
8 OpenAI,
9 OpenCodeIcon,
10} from "../Icons";
311
412export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = {
513 [ProviderDriverKind.make("codex")]: OpenAI,
......@@ -7,6 +15,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>
715 [ProviderDriverKind.make("opencode")]: OpenCodeIcon,
816 [ProviderDriverKind.make("cursor")]: CursorIcon,
917 [ProviderDriverKind.make("grok")]: GrokIcon,
18 [ProviderDriverKind.make("antigravity")]: AntigravityIcon,
1019};
1120
1221export type ModelEsque = {
......@@ -14,6 +23,8 @@ export type ModelEsque = {
1423 name: string;
1524 shortName?: string | undefined;
1625 subProvider?: string | undefined;
26 aliases?: ReadonlyArray<string> | undefined;
27 isDefault?: boolean | undefined;
1728 badge?: "new" | undefined;
1829 isLegacy?: boolean | undefined;
1930 isUnavailable?: boolean | undefined;
apps/web/src/components/settings/ProjectSettingsPanel.tsx+6
......@@ -907,6 +907,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
907907 modelOptionsByInstance={modelOptionsByInstance}
908908 triggerVariant="outline"
909909 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
910 onOpenProviderSetup={(instanceId) => {
911 void navigate({
912 to: "/settings/providers",
913 search: { environmentId: representative.environmentId, instanceId },
914 });
915 }}
910916 onInstanceModelChange={(instanceId, model) => {
911917 setDefaultModel(createModelSelection(instanceId, model));
912918 }}
apps/web/src/components/settings/ProviderInstanceCard.tsx+5-1
......@@ -369,6 +369,7 @@ interface ProviderInstanceCardProps {
369369 * omit it.
370370 */
371371 readonly headerAction?: ReactNode | undefined;
372 readonly setup?: ReactNode;
372373 readonly hiddenModels: ReadonlyArray<string>;
373374 readonly favoriteModels: ReadonlyArray<string>;
374375 readonly modelOrder: ReadonlyArray<string>;
......@@ -410,6 +411,7 @@ export function ProviderInstanceCard({
410411 onUpdate,
411412 onDelete,
412413 headerAction,
414 setup,
413415 hiddenModels,
414416 favoriteModels,
415417 modelOrder,
......@@ -474,7 +476,8 @@ export function ProviderInstanceCard({
474476 : null;
475477 const visibleTab = driverOption === undefined ? "configuration" : activeTab;
476478
477 const customModels = readConfigStringArray(instance.config, "customModels");
479 const customModels =
480 instance.driver === "antigravity" ? [] : readConfigStringArray(instance.config, "customModels");
478481 // Server-returned models may lag behind settings writes. Treat probe
479482 // models as the source for built-ins only; custom rows come directly
480483 // from the current instance config so add/remove reflects immediately.
......@@ -843,6 +846,7 @@ export function ProviderInstanceCard({
843846 className="lg:h-full"
844847 hidden={visibleTab !== "configuration"}
845848 >
849 {setup ? <div className="border-b border-border/60 px-4 py-3">{setup}</div> : null}
846850 <div
847851 inert={readOnly}
848852 aria-disabled={readOnly || undefined}
apps/web/src/components/settings/ProviderModelsSection.tsx+5-2
......@@ -202,6 +202,7 @@ export function ProviderModelsSection({
202202 }, [displayModels]);
203203
204204 const handleAdd = () => {
205 if (driverKind === "antigravity") return;
205206 const normalized = normalizeCustomModelSlug(input);
206207 if (!normalized) {
207208 setError("Enter a model slug.");
......@@ -505,7 +506,7 @@ export function ProviderModelsSection({
505506 })}
506507 </div>
507508
508 {isAdding ? (
509 {driverKind === "antigravity" ? null : isAdding ? (
509510 <div className="mt-3 flex flex-col gap-2 sm:flex-row">
510511 <Input
511512 id={`provider-instance-${instanceId}-custom-model`}
......@@ -551,7 +552,9 @@ export function ProviderModelsSection({
551552 </Button>
552553 )}
553554
554 {error ? <p className="mt-2 text-xs text-destructive">{error}</p> : null}
555 {driverKind !== "antigravity" && error ? (
556 <p className="mt-2 text-xs text-destructive">{error}</p>
557 ) : null}
555558 </div>
556559 );
557560}
apps/web/src/components/settings/ProviderSettingsForm.test.ts+23
......@@ -37,6 +37,29 @@ describe("ProviderSettingsForm helpers", () => {
3737 });
3838 });
3939
40 it("derives a select control with its choices for the Antigravity sign-in method", () => {
41 const antigravity = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("antigravity")];
42 expect(antigravity).toBeDefined();
43
44 const fields = deriveProviderSettingsFields(antigravity!);
45 expect(fields.map((field) => field.key)).toEqual([
46 "authMethod",
47 "apiKey",
48 "gcpProject",
49 "gcpLocation",
50 "binaryPath",
51 ]);
52 const authMethod = fields.find((field) => field.key === "authMethod");
53 expect(authMethod).toMatchObject({ control: "select", clearWhenEmpty: "omit" });
54 expect(authMethod?.options?.map((option) => option.value)).toEqual([
55 "oauth-personal",
56 "oauth-business",
57 "gemini-api-key",
58 "agent-platform",
59 ]);
60 expect(fields.find((field) => field.key === "apiKey")?.control).toBe("password");
61 });
62
4063 it("shows the auto-compaction threshold for Claude providers", () => {
4164 const claude = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("claudeAgent")];
4265 expect(claude).toBeDefined();
apps/web/src/components/settings/ProviderSettingsForm.tsx+77
......@@ -6,12 +6,14 @@ import * as Schema from "effect/Schema";
66import type {
77 ProviderSettingsFormAnnotation,
88 ProviderSettingsFormControl,
9 ProviderSettingsFormOption,
910 ProviderSettingsFormSchemaAnnotation,
1011} from "@t3tools/contracts";
1112
1213import { cn } from "../../lib/utils";
1314import { DraftInput } from "../ui/draft-input";
1415import { Input } from "../ui/input";
16import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
1517import { Switch } from "../ui/switch";
1618import { Textarea } from "../ui/textarea";
1719import type { ProviderClientDefinition } from "./providerDriverMeta";
......@@ -24,6 +26,8 @@ export interface ProviderSettingsFieldModel {
2426 readonly placeholder?: string | undefined;
2527 readonly clearWhenEmpty: "omit" | "persist";
2628 readonly defaultBooleanValue?: boolean | undefined;
29 /** Choices for a `select` control. The first entry is the default. */
30 readonly options?: ReadonlyArray<ProviderSettingsFormOption> | undefined;
2731}
2832
2933function titleizeFieldKey(key: string): string {
......@@ -106,6 +110,9 @@ export function deriveProviderSettingsFields(
106110 ...(formAnnotation.control === "switch"
107111 ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) }
108112 : {}),
113 ...(formAnnotation.control === "select" && formAnnotation.options
114 ? { options: formAnnotation.options }
115 : {}),
109116 } satisfies ProviderSettingsFieldModel,
110117 ];
111118 });
......@@ -168,6 +175,48 @@ interface ProviderSettingsFormProps {
168175 readonly onChange: (nextConfig: Record<string, unknown> | undefined) => void;
169176}
170177
178/** Stores the default choice as an omitted key so unchanged configs stay small. */
179function ProviderSettingsSelect({
180 field,
181 value,
182 inputId,
183 size,
184 className,
185 onChange,
186}: {
187 readonly field: ProviderSettingsFieldModel;
188 readonly value: unknown;
189 readonly inputId: string;
190 readonly size: "sm" | "xs";
191 readonly className?: string | undefined;
192 readonly onChange: ProviderSettingsFormProps["onChange"];
193}) {
194 const options = field.options ?? [];
195 const fallback = options[0]?.value ?? "";
196 const current = readProviderConfigString(value, field.key) || fallback;
197 const label = options.find((option) => option.value === current)?.label ?? current;
198 return (
199 <Select
200 value={current}
201 onValueChange={(next) => {
202 if (typeof next !== "string") return;
203 onChange(nextProviderConfigWithFieldValue(value, field, next === fallback ? "" : next));
204 }}
205 >
206 <SelectTrigger id={inputId} size={size} className={className} aria-label={field.label}>
207 <SelectValue>{label}</SelectValue>
208 </SelectTrigger>
209 <SelectPopup align="start" alignItemWithTrigger={false}>
210 {options.map((option) => (
211 <SelectItem key={option.value} value={option.value}>
212 {option.label}
213 </SelectItem>
214 ))}
215 </SelectPopup>
216 </Select>
217 );
218}
219
171220function FieldFrame(props: {
172221 readonly variant: ProviderSettingsFormProps["variant"];
173222 readonly children: ReactNode;
......@@ -229,6 +278,15 @@ function ProviderSettingsFieldRow({
229278 aria-describedby={descriptionId}
230279 />
231280 </span>
281 ) : field.control === "select" ? (
282 <ProviderSettingsSelect
283 field={field}
284 value={value}
285 inputId={inputId}
286 size="xs"
287 className="w-56"
288 onChange={onChange}
289 />
232290 ) : field.control === "textarea" ? (
233291 <Textarea
234292 id={inputId}
......@@ -285,6 +343,25 @@ function ProviderSettingsFieldRow({
285343 );
286344 }
287345
346 if (field.control === "select") {
347 return (
348 <FieldFrame variant={variant}>
349 <label htmlFor={inputId} className={cn(variant === "card" && "block")}>
350 {label}
351 <ProviderSettingsSelect
352 field={field}
353 value={value}
354 inputId={inputId}
355 size="sm"
356 className={cn("w-full", variant === "card" && "mt-1.5")}
357 onChange={onChange}
358 />
359 {description}
360 </label>
361 </FieldFrame>
362 );
363 }
364
288365 if (field.control === "textarea") {
289366 return (
290367 <FieldFrame variant={variant}>
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+28-1
......@@ -132,12 +132,16 @@ function provider(): ServerProvider {
132132
133133function renderPanel(options?: {
134134 readonly readOnly?: boolean;
135 readonly targetInstanceId?: ProviderInstanceId;
135136}): ReactElement<Record<string, unknown>> {
136137 hooks.beginRender();
137138 return EnvironmentProviderSettings({
138139 environmentId,
139140 environmentLabel: "Remote device",
140141 ...(options?.readOnly === undefined ? {} : { readOnly: options.readOnly }),
142 ...(options?.targetInstanceId === undefined
143 ? {}
144 : { targetInstanceId: options.targetInstanceId }),
141145 }) as ReactElement<Record<string, unknown>>;
142146}
143147
......@@ -204,7 +208,10 @@ describe("EnvironmentProviderSettings routing", () => {
204208 (refreshButton?.props.onClick as (() => void) | undefined)?.();
205209 await flushPromises();
206210
207 expect(commands.refresh).toHaveBeenCalledWith({ environmentId, input: {} });
211 expect(commands.refresh).toHaveBeenCalledWith({
212 environmentId,
213 input: { refreshModels: true },
214 });
208215
209216 const providerCard = visitElements(
210217 panel,
......@@ -221,6 +228,26 @@ describe("EnvironmentProviderSettings routing", () => {
221228 });
222229 });
223230
231 it("opens the requested provider instance instead of the first provider", () => {
232 settingsState.value = {
233 ...DEFAULT_UNIFIED_SETTINGS,
234 providerInstances: {
235 [customId]: { driver: ProviderDriverKind.make("codex"), enabled: true },
236 },
237 };
238 atoms.providers = [provider()];
239 const panel = renderPanel({ targetInstanceId: customId });
240 const editor = visitElements(panel, (element) => element.props.mode === "editor");
241 expect(editor?.props.instanceId).toBe(customId);
242 });
243
244 it("does not substitute another account when the requested instance was removed", () => {
245 atoms.providers = [provider()];
246 const panel = renderPanel({ targetInstanceId: customId });
247 expect(visitElements(panel, (element) => element.props.mode === "editor")).toBeNull();
248 expect(settingsState.updateSettings).not.toHaveBeenCalled();
249 });
250
224251 it("keeps provider selection available while write controls are read only", () => {
225252 settingsState.value = {
226253 ...DEFAULT_UNIFIED_SETTINGS,
apps/web/src/components/settings/ProviderSettingsPanel.tsx+105-22
......@@ -69,6 +69,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
6969import { stackedThreadToast, toastManager } from "../ui/toast";
7070import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
7171import { ProviderInstanceCard } from "./ProviderInstanceCard";
72import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection";
7273import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";
7374import { providerSettingsTabClassName } from "./providerSettingsTabs";
7475import { searchableSetting } from "./settingsSearch";
......@@ -119,6 +120,11 @@ const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({
119120 provider: definition.value,
120121}));
121122
123function configuredBinaryPath(config: unknown): string {
124 if (config === null || typeof config !== "object" || !("binaryPath" in config)) return "";
125 return typeof config.binaryPath === "string" ? config.binaryPath.trim() : "";
126}
127
122128function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {
123129 useRelativeTimeTick();
124130 const lastCheckedRelative = getRelativeTimeState(lastCheckedAt);
......@@ -183,15 +189,23 @@ function EnvironmentUnavailableRow({
183189 );
184190}
185191
186export function ProviderSettingsPanel() {
192interface ProviderSettingsTarget {
193 readonly environmentId?: EnvironmentId;
194 readonly instanceId?: ProviderInstanceId;
195}
196
197export function ProviderSettingsPanel(target: ProviderSettingsTarget) {
187198 return (
188199 <SettingsPageContainer className="gap-8">
189 <ProviderSettingsPanelContent />
200 <ProviderSettingsPanelContent
201 key={`${target.environmentId ?? ""}:${target.instanceId ?? ""}`}
202 {...target}
203 />
190204 </SettingsPageContainer>
191205 );
192206}
193207
194function ProviderSettingsPanelContent() {
208function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
195209 const { environments, isReady } = useEnvironments();
196210 const primaryEnvironmentId = usePrimaryEnvironmentId();
197211 const searchTargetId = useSettingsSearchTargetId();
......@@ -203,13 +217,15 @@ function ProviderSettingsPanelContent() {
203217 // device that drops out of the catalog falls back without erasing the pick —
204218 // if it reappears (e.g. after a reconnect) the selection is restored.
205219 const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(
206 primaryEnvironmentId,
207 );
208 const effectiveEnvironmentId = resolveSelectedProviderEnvironmentId(
209 options,
210 selectedEnvironmentId,
211 primaryEnvironmentId,
220 target.environmentId ?? primaryEnvironmentId,
212221 );
222 const targetEnvironmentMissing =
223 target.environmentId !== undefined &&
224 selectedEnvironmentId === target.environmentId &&
225 !options.some((environment) => environment.environmentId === target.environmentId);
226 const effectiveEnvironmentId = targetEnvironmentMissing
227 ? target.environmentId
228 : resolveSelectedProviderEnvironmentId(options, selectedEnvironmentId, primaryEnvironmentId);
213229 const selectedEnvironment =
214230 options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null;
215231 const selectedEnvironmentCanRenderSettings =
......@@ -288,7 +304,16 @@ function ProviderSettingsPanelContent() {
288304
289305 return (
290306 <>
291 {options.length === 0 ? (
307 {targetEnvironmentMissing ? (
308 <SettingsSection {...searchableSetting("providers")}>
309 {deviceTabs}
310 <SettingsRow
311 title="Device unavailable"
312 description="Reconnect this device to set up its provider, or select another device."
313 />
314 </SettingsSection>
315 ) : null}
316 {options.length === 0 && !targetEnvironmentMissing ? (
292317 <SettingsSection {...searchableSetting("providers")}>
293318 <SettingsRow
294319 title={isReady ? "No connected devices" : "Loading devices"}
......@@ -306,6 +331,12 @@ function ProviderSettingsPanelContent() {
306331 key={selectedEnvironment.environmentId}
307332 environment={selectedEnvironment}
308333 deviceTabs={deviceTabs}
334 targetInstanceId={
335 target.environmentId === undefined ||
336 selectedEnvironment.environmentId === target.environmentId
337 ? target.instanceId
338 : undefined
339 }
309340 />
310341 ) : null}
311342 </>
......@@ -315,9 +346,11 @@ function ProviderSettingsPanelContent() {
315346function SelectedEnvironmentProviderSettings({
316347 environment,
317348 deviceTabs,
349 targetInstanceId,
318350}: {
319351 readonly environment: EnvironmentPresentation;
320352 readonly deviceTabs?: ReactNode;
353 readonly targetInstanceId?: ProviderInstanceId | undefined;
321354}) {
322355 const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget";
323356 if (isPrimary) {
......@@ -329,22 +362,35 @@ function SelectedEnvironmentProviderSettings({
329362 environment={environment}
330363 operateAccess="granted"
331364 deviceTabs={deviceTabs}
365 targetInstanceId={targetInstanceId}
332366 />
333367 );
334368 }
335369 return (
336 <PrimarySessionGatedProviderSettings environment={environment} deviceTabs={deviceTabs} />
370 <PrimarySessionGatedProviderSettings
371 environment={environment}
372 deviceTabs={deviceTabs}
373 targetInstanceId={targetInstanceId}
374 />
337375 );
338376 }
339 return <RemoteSessionGatedProviderSettings environment={environment} deviceTabs={deviceTabs} />;
377 return (
378 <RemoteSessionGatedProviderSettings
379 environment={environment}
380 deviceTabs={deviceTabs}
381 targetInstanceId={targetInstanceId}
382 />
383 );
340384}
341385
342386function PrimarySessionGatedProviderSettings({
343387 environment,
344388 deviceTabs,
389 targetInstanceId,
345390}: {
346391 readonly environment: EnvironmentPresentation;
347392 readonly deviceTabs?: ReactNode;
393 readonly targetInstanceId?: ProviderInstanceId | undefined;
348394}) {
349395 const primarySessionState = usePrimarySessionState();
350396 const operateAccess = resolvePrimaryOperateAccess({
......@@ -359,6 +405,7 @@ function PrimarySessionGatedProviderSettings({
359405 environment={environment}
360406 operateAccess={operateAccess}
361407 deviceTabs={deviceTabs}
408 targetInstanceId={targetInstanceId}
362409 />
363410 );
364411}
......@@ -366,9 +413,11 @@ function PrimarySessionGatedProviderSettings({
366413function RemoteSessionGatedProviderSettings({
367414 environment,
368415 deviceTabs,
416 targetInstanceId,
369417}: {
370418 readonly environment: EnvironmentPresentation;
371419 readonly deviceTabs?: ReactNode;
420 readonly targetInstanceId?: ProviderInstanceId | undefined;
372421}) {
373422 const sessionState = useEnvironmentSessionState(environment.environmentId);
374423 const operateAccess = resolveRemoteOperateAccess({
......@@ -381,6 +430,7 @@ function RemoteSessionGatedProviderSettings({
381430 environment={environment}
382431 operateAccess={operateAccess}
383432 deviceTabs={deviceTabs}
433 targetInstanceId={targetInstanceId}
384434 />
385435 );
386436}
......@@ -389,10 +439,12 @@ function AccessGatedProviderSettings({
389439 environment,
390440 operateAccess,
391441 deviceTabs,
442 targetInstanceId,
392443}: {
393444 readonly environment: EnvironmentPresentation;
394445 readonly operateAccess: ProviderOperateAccess;
395446 readonly deviceTabs?: ReactNode;
447 readonly targetInstanceId?: ProviderInstanceId | undefined;
396448}) {
397449 const access = classifyProviderEnvironmentAccess({
398450 connectionPhase: environment.connection.phase,
......@@ -414,6 +466,7 @@ function AccessGatedProviderSettings({
414466 environmentLabel={environment.label}
415467 readOnly={access.kind === "read-only"}
416468 deviceTabs={deviceTabs}
469 targetInstanceId={targetInstanceId}
417470 />
418471 );
419472}
......@@ -423,10 +476,12 @@ export function EnvironmentProviderSettings({
423476 environmentLabel,
424477 readOnly = false,
425478 deviceTabs,
479 targetInstanceId,
426480}: {
427481 readonly environmentId: EnvironmentId;
428482 readonly environmentLabel: string;
429483 readonly deviceTabs?: ReactNode;
484 readonly targetInstanceId?: ProviderInstanceId | undefined;
430485 /**
431486 * Grey out and freeze every write control when this session's credential
432487 * lacks `orchestration:operate` on the environment. Selecting providers and
......@@ -448,7 +503,9 @@ export function EnvironmentProviderSettings({
448503 });
449504 const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
450505 const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false);
451 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | null>(null);
506 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | null>(
507 targetInstanceId ?? null,
508 );
452509 const [advancedOpen, setAdvancedOpen] = useState(false);
453510 const searchTargetId = useSettingsSearchTargetId();
454511 const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState<
......@@ -504,7 +561,7 @@ export function EnvironmentProviderSettings({
504561 void (async () => {
505562 const result = await refreshServerProviders({
506563 environmentId,
507 input: {},
564 input: { refreshModels: true },
508565 });
509566 refreshingRef.current = false;
510567 setIsRefreshingProviders(false);
......@@ -653,7 +710,13 @@ export function EnvironmentProviderSettings({
653710 }
654711 }
655712
656 const selectedRow = rows.find((row) => row.instanceId === selectedInstanceId) ?? rows[0] ?? null;
713 const targetInstanceMissing =
714 targetInstanceId !== undefined &&
715 selectedInstanceId === targetInstanceId &&
716 !rows.some((row) => row.instanceId === targetInstanceId);
717 const selectedRow =
718 rows.find((row) => row.instanceId === selectedInstanceId) ??
719 (targetInstanceMissing ? null : (rows[0] ?? null));
657720
658721 const updateProviderInstance = (
659722 row: InstanceRow,
......@@ -786,6 +849,21 @@ export function EnvironmentProviderSettings({
786849 selected={mode === "list" && selectedRow?.instanceId === row.instanceId}
787850 onSelect={mode === "list" ? () => setSelectedInstanceId(row.instanceId) : undefined}
788851 readOnly={readOnly}
852 setup={
853 mode === "editor" && row.driver === "antigravity" ? (
854 <ProviderSetupSection
855 environmentId={environmentId}
856 environmentLabel={environmentLabel}
857 instanceId={row.instanceId}
858 provider={liveProvider}
859 binaryPath={configuredBinaryPath(row.instance.config)}
860 authMethod={readAntigravityAuthMethod(row.instance.config)}
861 enabled={resolveProviderInstanceEnabled(row.instance)}
862 readOnly={readOnly}
863 onEnable={() => updateProviderInstance(row, { ...row.instance, enabled: true })}
864 />
865 ) : null
866 }
789867 onUpdate={(next) => {
790868 const wasEnabled = resolveProviderInstanceEnabled(row.instance);
791869 const isDisabling = next.enabled === false && wasEnabled;
......@@ -861,16 +939,17 @@ export function EnvironmentProviderSettings({
861939 size="xs"
862940 variant="ghost-muted"
863941 disabled={isRefreshingProviders}
942 aria-busy={isRefreshingProviders}
864943 onClick={() => void refreshProviders()}
865944 >
866 {isRefreshingProviders ? (
867 <LoaderIcon className="animate-spin" />
868 ) : (
869 <RefreshCwIcon />
870 )}
945 <RefreshCwIcon />
871946 <span className="sr-only">Refresh provider status</span>
872947 <span className="hidden min-w-0 truncate sm:inline">
873 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
948 {isRefreshingProviders ? (
949 "Refreshing providers"
950 ) : (
951 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
952 )}
874953 </span>
875954 </Button>
876955 }
......@@ -922,7 +1001,11 @@ export function EnvironmentProviderSettings({
9221001 {selectedRow ? (
9231002 renderProviderInstance(selectedRow, "editor")
9241003 ) : (
925 <div className="p-6 text-sm text-muted-foreground">No providers configured.</div>
1004 <div className="p-6 text-sm text-muted-foreground">
1005 {targetInstanceMissing
1006 ? "This provider instance is no longer available on this device."
1007 : "No providers configured."}
1008 </div>
9261009 )}
9271010 </div>
9281011 </div>
apps/web/src/components/settings/ProviderSetupSection.test.tsx created+353
......@@ -0,0 +1,353 @@
1import type { FunctionComponent, ReactElement } from "react";
2import {
3 EnvironmentId,
4 ProviderDriverKind,
5 ProviderInstanceId,
6 type ProviderAuthState,
7 type ProviderInstallState,
8 type ServerProvider,
9} from "@t3tools/contracts";
10import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
11
12import { visitElements } from "../../test/reactElementTree";
13import { reactHookHarness as hooks } from "../../test/reactHookHarness";
14
15const setup = vi.hoisted(() => ({
16 auth: null as ProviderAuthState | null,
17 installation: null as ProviderInstallState | null,
18 authState: vi.fn(() => "auth"),
19 installState: vi.fn(() => "installation"),
20 startAuth: vi.fn(),
21 completeAuth: vi.fn(),
22 cancelAuth: vi.fn(),
23 logoutAuth: vi.fn(),
24 startInstall: vi.fn(),
25 cancelInstall: vi.fn(),
26 removeInstall: vi.fn(),
27 confirm: vi.fn(),
28}));
29
30vi.mock("react", async (importOriginal) => {
31 const actual = await importOriginal<typeof import("react")>();
32 const { reactHookHarness } = await import("../../test/reactHookHarness");
33 return {
34 ...actual,
35 useRef: reactHookHarness.useRef,
36 useState: reactHookHarness.useState,
37 };
38});
39
40vi.mock("react/compiler-runtime", async () => {
41 const { reactHookHarness } = await import("../../test/reactHookHarness");
42 return { c: reactHookHarness.useMemoCache };
43});
44
45vi.mock("../../state/server", () => ({
46 serverEnvironment: {
47 providerAuthState: setup.authState,
48 providerInstallState: setup.installState,
49 startProviderAuth: setup.startAuth,
50 completeProviderAuth: setup.completeAuth,
51 cancelProviderAuth: setup.cancelAuth,
52 logoutProviderAuth: setup.logoutAuth,
53 startProviderInstall: setup.startInstall,
54 cancelProviderInstall: setup.cancelInstall,
55 removeProviderInstallation: setup.removeInstall,
56 },
57}));
58
59vi.mock("../../state/use-atom-command", () => ({
60 useAtomCommand: (command: unknown) => command,
61}));
62
63vi.mock("../../state/query", () => ({
64 useEnvironmentQuery: (atom: string) => ({
65 data: atom === "auth" ? setup.auth : setup.installation,
66 error: null,
67 isPending: false,
68 refresh: vi.fn(),
69 }),
70}));
71
72vi.mock("../../localApi", () => ({
73 ensureLocalApi: () => ({ dialogs: { confirm: setup.confirm } }),
74}));
75
76import { ProviderSetupSection } from "./ProviderSetupSection";
77
78const environmentId = EnvironmentId.make("remote-google");
79const instanceId = ProviderInstanceId.make("antigravity_work");
80const provider: ServerProvider = {
81 instanceId,
82 driver: ProviderDriverKind.make("antigravity"),
83 installed: true,
84 enabled: true,
85 version: "test-version",
86 status: "error",
87 auth: { status: "unauthenticated" },
88 checkedAt: "2026-09-02T00:00:00.000Z",
89 models: [],
90 skills: [],
91 slashCommands: [],
92 setup: { canAuthenticate: true, canInstall: true },
93};
94
95function authState(patch: Partial<ProviderAuthState> = {}): ProviderAuthState {
96 return {
97 instanceId,
98 phase: "waiting",
99 flowId: "flow-1",
100 authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=test-only",
101 expiresAt: "2026-09-02T00:05:00.000Z",
102 message: null,
103 ...patch,
104 };
105}
106
107function renderSetup(
108 options: {
109 readOnly?: boolean;
110 provider?: ServerProvider;
111 enabled?: boolean;
112 binaryPath?: string;
113 } = {},
114) {
115 hooks.beginRender();
116 const view = ProviderSetupSection({
117 environmentId,
118 environmentLabel: "Remote Google device",
119 instanceId,
120 provider: options.provider ?? provider,
121 binaryPath: options.binaryPath,
122 enabled: options.enabled ?? true,
123 readOnly: options.readOnly ?? false,
124 onEnable: vi.fn(),
125 });
126 const actions = visitElements(
127 view,
128 (element) =>
129 typeof element.type === "function" &&
130 element.props.environmentId === environmentId &&
131 element.props.instanceId === instanceId,
132 );
133 if (!actions) return view;
134 const Actions = actions.type as FunctionComponent<Record<string, unknown>>;
135 return Actions(actions.props) as ReactElement<Record<string, unknown>>;
136}
137
138function button(view: unknown, label: string) {
139 return visitElements(
140 view,
141 (element) => element.props.children === label && typeof element.props.onClick === "function",
142 );
143}
144
145function click(view: unknown, label: string) {
146 const target = button(view, label);
147 if (!target) throw new Error(`Missing button: ${label}`);
148 if (target.props.disabled) throw new Error(`Button is disabled: ${label}`);
149 (target.props.onClick as () => void)();
150}
151
152function setCallback(view: unknown, value: string) {
153 const input = visitElements(
154 view,
155 (element) => element.props.id === `provider-callback-${instanceId}`,
156 );
157 if (!input) throw new Error("Missing callback input.");
158 (input.props.onChange as (event: { target: { value: string } }) => void)({ target: { value } });
159}
160
161function submitCallback(view: unknown) {
162 const form = visitElements(view, (element) => element.type === "form");
163 if (!form) throw new Error("Missing callback form.");
164 (form.props.onSubmit as (event: { preventDefault: () => void }) => void)({
165 preventDefault: vi.fn(),
166 });
167}
168
169async function flushPromises() {
170 await Promise.resolve();
171 await Promise.resolve();
172 await Promise.resolve();
173}
174
175describe("Antigravity setup", () => {
176 beforeEach(() => {
177 hooks.reset();
178 vi.clearAllMocks();
179 setup.auth = authState();
180 setup.installation = {
181 driver: ProviderDriverKind.make("antigravity"),
182 operationId: null,
183 phase: "idle",
184 downloadedBytes: 0,
185 totalBytes: null,
186 version: null,
187 installedVersion: null,
188 canRemove: false,
189 message: null,
190 };
191 for (const command of [
192 setup.startAuth,
193 setup.completeAuth,
194 setup.cancelAuth,
195 setup.logoutAuth,
196 setup.startInstall,
197 setup.cancelInstall,
198 setup.removeInstall,
199 ]) {
200 command.mockReset().mockResolvedValue({ _tag: "Success", value: undefined });
201 }
202 setup.confirm.mockReset().mockResolvedValue(false);
203 });
204
205 it("waits for verified auth after submitting a callback to the selected environment", async () => {
206 const callbackUrl = "http://127.0.0.1:5555/?state=test-only&code=test-only";
207 setCallback(renderSetup(), callbackUrl);
208 submitCallback(renderSetup());
209 await flushPromises();
210
211 expect(setup.completeAuth).toHaveBeenCalledWith({
212 environmentId,
213 input: { instanceId, flowId: "flow-1", callbackUrl },
214 });
215 let view = renderSetup();
216 expect(
217 visitElements(view, (element) => element.props.children === "Signed in with Google."),
218 ).toBeNull();
219 expect(
220 visitElements(view, (element) => element.props.id === `provider-callback-${instanceId}`)
221 ?.props.value,
222 ).toBe("");
223
224 setup.auth = authState({ phase: "verifying", authorizationUrl: null });
225 expect(
226 visitElements(
227 renderSetup(),
228 (element) => element.props.children === "Signed in with Google.",
229 ),
230 ).toBeNull();
231 setup.auth = authState({ phase: "succeeded", authorizationUrl: null });
232 view = renderSetup({
233 provider: { ...provider, status: "ready", auth: { status: "authenticated" } },
234 });
235 expect(
236 visitElements(view, (element) => element.props.children === "Signed in with Google."),
237 ).not.toBeNull();
238 });
239
240 it("offers sign-in again when credentials expire after a completed auth flow", () => {
241 setup.auth = authState({
242 phase: "succeeded",
243 authorizationUrl: null,
244 message: "Google sign-in complete.",
245 });
246 renderSetup({
247 provider: { ...provider, status: "ready", auth: { status: "authenticated" } },
248 });
249 const expired = renderSetup();
250 expect(button(expired, "Sign in with Google")).not.toBeNull();
251 expect(
252 visitElements(expired, (element) => element.props.children === "Signed in with Google."),
253 ).toBeNull();
254 expect(
255 visitElements(expired, (element) => element.props.children === "Google sign-in complete."),
256 ).toBeNull();
257 });
258
259 it("does not send a callback left over from a replaced sign-in flow", async () => {
260 setCallback(renderSetup(), "http://127.0.0.1:5555/?state=old-flow&code=test-only");
261 setup.auth = authState({ flowId: "flow-2" });
262 submitCallback(renderSetup());
263 await flushPromises();
264
265 expect(setup.completeAuth).not.toHaveBeenCalled();
266 expect(setup.authState).toHaveBeenLastCalledWith({ environmentId, input: { instanceId } });
267 });
268
269 it("coalesces repeated sign-in clicks while start is pending", async () => {
270 setup.auth = authState({ phase: "idle", flowId: null, authorizationUrl: null });
271 let completeStart: (value: { _tag: "Success"; value: undefined }) => void = () => {
272 throw new Error("Missing start resolver.");
273 };
274 const pending = new Promise<{ _tag: "Success"; value: undefined }>((resolve) => {
275 completeStart = resolve;
276 });
277 setup.startAuth.mockReturnValueOnce(pending);
278 const view = renderSetup();
279 click(view, "Sign in with Google");
280 click(view, "Sign in with Google");
281
282 expect(setup.startAuth).toHaveBeenCalledTimes(1);
283 expect(setup.startAuth).toHaveBeenCalledWith({ environmentId, input: { instanceId } });
284 completeStart({ _tag: "Success", value: undefined });
285 await flushPromises();
286 });
287
288 it("removes an owned damaged runtime only after confirmation", async () => {
289 setup.auth = authState({ phase: "idle", flowId: null, authorizationUrl: null });
290 setup.installation = {
291 ...setup.installation!,
292 phase: "failed",
293 canRemove: true,
294 installedVersion: null,
295 };
296 const view = renderSetup();
297 click(view, "Remove downloaded runtime");
298 await flushPromises();
299 expect(setup.removeInstall).not.toHaveBeenCalled();
300
301 setup.confirm.mockResolvedValue(true);
302 click(view, "Remove downloaded runtime");
303 await flushPromises();
304 expect(setup.removeInstall).toHaveBeenCalledWith({ environmentId, input: { instanceId } });
305 });
306
307 it.each([true, false])(
308 "can sign out an unchecked account when its instance is enabled=%s",
309 async (enabled) => {
310 setup.auth = authState({ phase: "idle", flowId: null, authorizationUrl: null });
311 setup.confirm.mockResolvedValue(true);
312 const view = renderSetup({
313 enabled,
314 provider: {
315 ...provider,
316 enabled,
317 installed: false,
318 status: enabled ? "warning" : "disabled",
319 auth: { status: "unknown" },
320 },
321 });
322 click(view, "Sign out of Google");
323 await flushPromises();
324 expect(setup.logoutAuth).toHaveBeenCalledWith({ environmentId, input: { instanceId } });
325 },
326 );
327
328 it("does not let a shared managed install hide an invalid custom binary path", () => {
329 setup.auth = authState({ phase: "idle", flowId: null, authorizationUrl: null });
330 setup.installation = {
331 ...setup.installation!,
332 installedVersion: "test-version",
333 canRemove: true,
334 };
335 const view = renderSetup({
336 provider: { ...provider, installed: false },
337 binaryPath: "/missing/antigravity",
338 });
339 expect(button(view, "Sign in with Google")?.props.disabled).toBe(true);
340 expect(setup.startAuth).not.toHaveBeenCalled();
341 });
342
343 it.each(["read-only", "older-server"] as const)(
344 "does not open private setup subscriptions for a %s view",
345 (mode) => {
346 const { setup: _setup, ...olderProvider } = provider;
347 renderSetup(mode === "read-only" ? { readOnly: true } : { provider: olderProvider });
348 expect(setup.authState).not.toHaveBeenCalled();
349 expect(setup.installState).not.toHaveBeenCalled();
350 expect(setup.startAuth).not.toHaveBeenCalled();
351 },
352 );
353});
apps/web/src/components/settings/ProviderSetupSection.tsx created+481
......@@ -0,0 +1,481 @@
1import {
2 isAtomCommandInterrupted,
3 squashAtomCommandFailure,
4 type AtomCommandResult,
5} from "@t3tools/client-runtime/state/runtime";
6import {
7 ANTIGRAVITY_AUTH_METHODS,
8 type AntigravityAuthMethod,
9 type EnvironmentId,
10 type ProviderAuthState,
11 type ProviderInstanceId,
12 type ServerProvider,
13} from "@t3tools/contracts";
14import { useRef, useState } from "react";
15
16import { writeTextToClipboard } from "../../hooks/useCopyToClipboard";
17import { ensureLocalApi } from "../../localApi";
18import { useEnvironmentQuery } from "../../state/query";
19import { serverEnvironment } from "../../state/server";
20import { useAtomCommand } from "../../state/use-atom-command";
21import { Button } from "../ui/button";
22import { Input } from "../ui/input";
23
24interface ProviderSetupSectionProps {
25 readonly environmentId: EnvironmentId;
26 readonly environmentLabel: string;
27 readonly instanceId: ProviderInstanceId;
28 readonly provider: ServerProvider | undefined;
29 readonly binaryPath?: string | undefined;
30 readonly authMethod?: AntigravityAuthMethod | undefined;
31 readonly enabled: boolean;
32 readonly readOnly: boolean;
33 readonly onEnable: () => void;
34}
35
36const AUTH_PHASE_LABELS: Record<ProviderAuthState["phase"], string> = {
37 idle: "Sign in with your Google account.",
38 starting: "Starting Google sign-in.",
39 waiting: "Waiting for Google sign-in.",
40 verifying: "Checking Google sign-in and available models.",
41 succeeded: "Google sign-in complete.",
42 failed: "Google sign-in failed.",
43 cancelled: "Google sign-in cancelled.",
44};
45
46/** API key methods skip the browser, so the phases read as a credential check. */
47const CREDENTIAL_PHASE_LABELS: Record<ProviderAuthState["phase"], string> = {
48 idle: "Connect with the credentials in the provider settings.",
49 starting: "Checking credentials.",
50 waiting: "Checking credentials.",
51 verifying: "Checking credentials and available models.",
52 succeeded: "Connected.",
53 failed: "Could not connect with the configured credentials.",
54 cancelled: "Connection cancelled.",
55};
56
57/** Read the configured method from the instance config. Unknown values fall back to personal. */
58export function readAntigravityAuthMethod(config: unknown): AntigravityAuthMethod {
59 const value =
60 config !== null && typeof config === "object" && "authMethod" in config
61 ? config.authMethod
62 : undefined;
63 return (
64 ANTIGRAVITY_AUTH_METHODS.find((method) => method.value === value)?.value ?? "oauth-personal"
65 );
66}
67
68/** Setup state belongs to the selected environment and is never saved in client settings. */
69export function ProviderSetupSection(props: ProviderSetupSectionProps) {
70 return (
71 <section aria-label="Antigravity setup" className="grid gap-3 text-xs">
72 <p>Antigravity runs on {props.environmentLabel}.</p>
73 {!props.enabled ? (
74 <div className="flex flex-wrap items-center gap-2">
75 <span className="text-muted-foreground">Enable it to use it in threads.</span>
76 {!props.readOnly ? (
77 <Button size="xs" variant="outline" onClick={props.onEnable}>
78 Enable Antigravity
79 </Button>
80 ) : null}
81 </div>
82 ) : null}
83 {props.readOnly ? (
84 <p className="text-muted-foreground">This connection cannot change provider setup.</p>
85 ) : props.provider?.setup === undefined ? (
86 <p className="text-muted-foreground">
87 Update this environment to install Antigravity and sign in with Google here.
88 </p>
89 ) : (
90 <ProviderSetupActions
91 key={`${props.environmentId}:${props.instanceId}`}
92 environmentId={props.environmentId}
93 environmentLabel={props.environmentLabel}
94 instanceId={props.instanceId}
95 provider={props.provider}
96 binaryPath={props.binaryPath}
97 authMethod={props.authMethod ?? "oauth-personal"}
98 enabled={props.enabled}
99 />
100 )}
101 </section>
102 );
103}
104
105function ProviderSetupActions({
106 environmentId,
107 environmentLabel,
108 instanceId,
109 provider,
110 enabled,
111 binaryPath,
112 authMethod,
113}: Pick<
114 ProviderSetupSectionProps,
115 "environmentId" | "environmentLabel" | "instanceId" | "enabled" | "binaryPath"
116> & {
117 readonly provider: ServerProvider;
118 readonly authMethod: AntigravityAuthMethod;
119}) {
120 const target = { environmentId, input: { instanceId } };
121 const usesBrowser = authMethod === "oauth-personal" || authMethod === "oauth-business";
122 const phaseLabels = usesBrowser ? AUTH_PHASE_LABELS : CREDENTIAL_PHASE_LABELS;
123 const methodLabel =
124 ANTIGRAVITY_AUTH_METHODS.find((method) => method.value === authMethod)?.label ??
125 "Google account";
126 const authQuery = useEnvironmentQuery(serverEnvironment.providerAuthState(target));
127 const installQuery = useEnvironmentQuery(serverEnvironment.providerInstallState(target));
128 const auth = authQuery.data;
129 const installation = installQuery.data;
130 const commandOptions = { reportFailure: false, reportDefect: false };
131 const startAuth = useAtomCommand(serverEnvironment.startProviderAuth, commandOptions);
132 const completeAuth = useAtomCommand(serverEnvironment.completeProviderAuth, commandOptions);
133 const cancelAuth = useAtomCommand(serverEnvironment.cancelProviderAuth, commandOptions);
134 const logoutAuth = useAtomCommand(serverEnvironment.logoutProviderAuth, commandOptions);
135 const startInstall = useAtomCommand(serverEnvironment.startProviderInstall, commandOptions);
136 const cancelInstall = useAtomCommand(serverEnvironment.cancelProviderInstall, commandOptions);
137 const removeInstall = useAtomCommand(
138 serverEnvironment.removeProviderInstallation,
139 commandOptions,
140 );
141 const [pendingLabel, setPendingLabel] = useState<string | null>(null);
142 const pendingRef = useRef(false);
143 const [error, setError] = useState<string | null>(null);
144 const [callbackDraft, setCallbackDraft] = useState({ flowId: null as string | null, value: "" });
145 const [copiedFlowId, setCopiedFlowId] = useState<string | null>(null);
146 const callbackUrl = callbackDraft.flowId === auth?.flowId ? callbackDraft.value : "";
147 const authActive =
148 auth?.phase === "starting" || auth?.phase === "waiting" || auth?.phase === "verifying";
149 const installActive =
150 installation?.phase === "downloading" ||
151 installation?.phase === "extracting" ||
152 installation?.phase === "verifying";
153 const usesCustomBinary = Boolean(binaryPath?.trim());
154 const installed =
155 provider.installed || (!usesCustomBinary && installation?.installedVersion != null);
156 const authenticated = provider.auth.status === "authenticated";
157 const authStatusMessage =
158 auth === null
159 ? "Reading sign-in status."
160 : authActive || auth.phase === "failed" || auth.phase === "cancelled"
161 ? (auth.message ?? phaseLabels[auth.phase])
162 : authenticated
163 ? usesBrowser
164 ? "Signed in with Google."
165 : "Connected."
166 : auth.phase === "idle" && auth.message
167 ? auth.message
168 : phaseLabels.idle;
169 const authorizationUrl = auth?.phase === "waiting" ? auth.authorizationUrl : null;
170 const queryError = authQuery.error ?? installQuery.error;
171 const actionsDisabled = pendingLabel !== null || queryError !== null;
172
173 async function runCommand<A, E>(
174 label: string,
175 request: () => Promise<AtomCommandResult<A, E>>,
176 ): Promise<boolean> {
177 if (pendingRef.current) return false;
178 pendingRef.current = true;
179 setPendingLabel(label);
180 setError(null);
181 try {
182 const result = await request();
183 if (result._tag === "Failure") {
184 if (!isAtomCommandInterrupted(result)) {
185 const failure = squashAtomCommandFailure(result);
186 setError(failure instanceof Error ? failure.message : "Provider setup failed.");
187 }
188 return false;
189 }
190 return true;
191 } catch {
192 setError("Provider setup failed. Try again.");
193 return false;
194 } finally {
195 pendingRef.current = false;
196 setPendingLabel(null);
197 }
198 }
199
200 async function openSignInPage() {
201 if (!authorizationUrl) return;
202 try {
203 await ensureLocalApi().shell.openExternal(authorizationUrl);
204 setError(null);
205 } catch {
206 setError("Could not open the sign-in page. Copy the link and open it in your browser.");
207 }
208 }
209
210 async function copySignInLink() {
211 if (!authorizationUrl) return;
212 try {
213 await writeTextToClipboard(authorizationUrl, "Google sign-in link");
214 setCopiedFlowId(auth?.flowId ?? null);
215 setError(null);
216 } catch {
217 setError("Could not copy the sign-in link. Use Open sign-in page.");
218 }
219 }
220
221 async function submitCallback() {
222 const flowId = auth?.flowId;
223 if (!flowId || !callbackUrl.trim() || auth.phase !== "waiting") return;
224 const accepted = await runCommand("Checking redirect", () =>
225 completeAuth({ environmentId, input: { instanceId, flowId, callbackUrl } }),
226 );
227 if (accepted) {
228 setCallbackDraft({ flowId: null, value: "" });
229 }
230 }
231
232 async function signOut() {
233 const confirmed = await ensureLocalApi().dialogs.confirm(
234 `${usesBrowser ? "Sign out of Google" : "Disconnect"} for ${provider.displayName ?? "Antigravity"} on ${environmentLabel}? This stops its running threads. Thread history is kept.`,
235 );
236 if (confirmed) {
237 await runCommand("Signing out", () => logoutAuth(target));
238 }
239 }
240
241 async function removeRuntime() {
242 const confirmed = await ensureLocalApi().dialogs.confirm(
243 `Remove the downloaded Antigravity runtime from ${environmentLabel}? Google sign-in and thread history are kept.`,
244 );
245 if (confirmed) {
246 await runCommand("Removing runtime", () => removeInstall(target));
247 }
248 }
249
250 return (
251 <div className="grid gap-3">
252 <div className="grid gap-2">
253 <p className="font-medium">Runtime</p>
254 <p role="status" className="text-muted-foreground">
255 {installation?.phase === "downloading"
256 ? `Downloading ${(installation.downloadedBytes / 1_000_000).toFixed(1)} MB${installation.totalBytes === null ? "" : ` of ${(installation.totalBytes / 1_000_000).toFixed(1)} MB`}.`
257 : installation?.phase === "extracting"
258 ? "Extracting Antigravity."
259 : installation?.phase === "verifying"
260 ? "Checking the downloaded runtime."
261 : installed
262 ? "Antigravity is installed."
263 : usesCustomBinary
264 ? enabled
265 ? "The configured Antigravity runtime is unavailable."
266 : "The configured Antigravity runtime has not been checked."
267 : "Install the official Antigravity runtime before signing in."}
268 </p>
269 {installation?.phase === "downloading" &&
270 installation.totalBytes !== null &&
271 installation.totalBytes > 0 ? (
272 <progress
273 aria-label="Antigravity download"
274 className="h-1 w-full accent-foreground"
275 value={installation.downloadedBytes}
276 max={installation.totalBytes}
277 />
278 ) : null}
279 {installation?.message ? (
280 <p className="text-muted-foreground [overflow-wrap:anywhere]">{installation.message}</p>
281 ) : null}
282 {usesCustomBinary ? (
283 <p className="text-muted-foreground">
284 This instance uses the binary path below. Installing a managed runtime does not change
285 that path.
286 </p>
287 ) : null}
288 {!installed && !usesCustomBinary && !installActive && installation?.totalBytes ? (
289 <p className="text-muted-foreground">
290 Downloads {Math.ceil(installation.totalBytes / 1_000_000)} MB from Google.
291 </p>
292 ) : null}
293 {!installed && !provider.setup?.canInstall ? (
294 <p className="text-muted-foreground">
295 Automatic installation is unavailable here. Set an existing binary path below or use a
296 supported remote environment.
297 </p>
298 ) : null}
299 <div className="flex flex-wrap gap-2">
300 {installActive && installation.operationId ? (
301 <Button
302 size="xs"
303 variant="outline"
304 disabled={actionsDisabled}
305 onClick={() => {
306 const operationId = installation.operationId;
307 if (!operationId) return;
308 void runCommand("Cancelling installation", () =>
309 cancelInstall({ environmentId, input: { instanceId, operationId } }),
310 );
311 }}
312 >
313 Cancel installation
314 </Button>
315 ) : !installActive && provider.setup?.canInstall ? (
316 <Button
317 size="xs"
318 variant="outline"
319 disabled={actionsDisabled || installation === null || authActive}
320 onClick={() => void runCommand("Starting installation", () => startInstall(target))}
321 >
322 {installation?.installedVersion
323 ? installation.version && installation.version !== installation.installedVersion
324 ? "Update Antigravity"
325 : "Reinstall Antigravity"
326 : installation?.phase === "failed" || installation?.phase === "cancelled"
327 ? "Retry installation"
328 : installed
329 ? "Install managed runtime"
330 : "Install Antigravity"}
331 </Button>
332 ) : null}
333 {installation?.canRemove && !installActive ? (
334 <Button
335 size="xs"
336 variant="ghost"
337 disabled={actionsDisabled || authActive}
338 onClick={() => void removeRuntime()}
339 >
340 Remove downloaded runtime
341 </Button>
342 ) : null}
343 </div>
344 </div>
345
346 <div className="grid gap-2 border-t border-border/60 pt-3">
347 <p className="font-medium">{methodLabel}</p>
348 <p role="status" className="text-muted-foreground [overflow-wrap:anywhere]">
349 {authStatusMessage}
350 </p>
351 {authorizationUrl ? (
352 <>
353 <div className="flex flex-wrap gap-2">
354 <Button size="xs" variant="outline" onClick={() => void openSignInPage()}>
355 Open sign-in page
356 </Button>
357 <Button size="xs" variant="ghost" onClick={() => void copySignInLink()}>
358 {copiedFlowId === auth?.flowId ? "Link copied" : "Copy sign-in link"}
359 </Button>
360 </div>
361 {auth?.expiresAt ? (
362 <p className="text-muted-foreground">
363 Link expires at{" "}
364 <time dateTime={auth.expiresAt}>
365 {new Date(auth.expiresAt).toLocaleTimeString([], {
366 hour: "numeric",
367 minute: "2-digit",
368 })}
369 </time>
370 .
371 </p>
372 ) : null}
373 <form
374 className="grid gap-2"
375 onSubmit={(event) => {
376 event.preventDefault();
377 void submitCallback();
378 }}
379 >
380 <label htmlFor={`provider-callback-${instanceId}`}>
381 If the final localhost page does not load, paste its full URL here.
382 </label>
383 <Input
384 id={`provider-callback-${instanceId}`}
385 size="sm"
386 type="url"
387 autoComplete="off"
388 spellCheck={false}
389 placeholder="http://127.0.0.1:..."
390 value={callbackUrl}
391 maxLength={16_384}
392 disabled={actionsDisabled}
393 onChange={(event) =>
394 setCallbackDraft({ flowId: auth?.flowId ?? null, value: event.target.value })
395 }
396 />
397 <Button
398 size="xs"
399 variant="outline"
400 type="submit"
401 className="w-fit"
402 disabled={actionsDisabled || !callbackUrl.trim()}
403 >
404 Continue
405 </Button>
406 </form>
407 </>
408 ) : auth?.phase === "waiting" ? (
409 <p className="text-muted-foreground">
410 Sign-in is open in another client. Complete or cancel it there.
411 </p>
412 ) : null}
413 <div className="flex flex-wrap gap-2">
414 {authActive && auth?.flowId ? (
415 <Button
416 size="xs"
417 variant="ghost"
418 disabled={actionsDisabled}
419 onClick={() => {
420 const flowId = auth.flowId;
421 if (!flowId) return;
422 void runCommand("Cancelling sign-in", () =>
423 cancelAuth({ environmentId, input: { instanceId, flowId } }),
424 );
425 }}
426 >
427 Cancel sign-in
428 </Button>
429 ) : !authActive && !authenticated && provider.setup?.canAuthenticate ? (
430 <Button
431 size="xs"
432 variant="outline"
433 disabled={actionsDisabled || !installed || auth === null || installActive}
434 onClick={() => void runCommand("Starting sign-in", () => startAuth(target))}
435 >
436 {usesBrowser
437 ? auth?.phase === "failed" || auth?.phase === "cancelled"
438 ? "Retry Google sign-in"
439 : "Sign in with Google"
440 : auth?.phase === "failed" || auth?.phase === "cancelled"
441 ? "Retry connection"
442 : "Connect"}
443 </Button>
444 ) : null}
445 {!authActive && provider.setup?.canAuthenticate ? (
446 <Button
447 size="xs"
448 variant={authenticated ? "outline" : "ghost"}
449 disabled={actionsDisabled || auth === null}
450 onClick={() => void signOut()}
451 >
452 {usesBrowser ? "Sign out of Google" : "Disconnect"}
453 </Button>
454 ) : null}
455 </div>
456 </div>
457
458 {pendingLabel ? <p role="status">{pendingLabel}.</p> : null}
459 {error || queryError ? (
460 <div className="grid gap-2">
461 <p role="alert" className="text-destructive [overflow-wrap:anywhere]">
462 {error ?? queryError}
463 </p>
464 {queryError ? (
465 <Button
466 size="xs"
467 variant="outline"
468 className="w-fit"
469 onClick={() => {
470 authQuery.refresh();
471 installQuery.refresh();
472 }}
473 >
474 Retry setup status
475 </Button>
476 ) : null}
477 </div>
478 ) : null}
479 </div>
480 );
481}
apps/web/src/components/settings/SettingsPanels.tsx+89-58
......@@ -1,5 +1,5 @@
11import { ArchiveIcon, ArchiveX, ChevronRightIcon, LoaderIcon, SettingsIcon } from "lucide-react";
2import { Link } from "@tanstack/react-router";
2import { Link, useNavigate } from "@tanstack/react-router";
33import type { CSSProperties, ReactNode } from "react";
44import { useCallback, useEffect, useMemo, useRef, useState } from "react";
55import { useAtomValue } from "@effect/atom-react";
......@@ -7,6 +7,7 @@ import {
77 type BackgroundActivityProfile,
88 type DesktopUpdateChannel,
99 ProviderDriverKind,
10 type ProviderInstanceId,
1011 type ScopedThreadRef,
1112 type SidebarProjectGroupingMode,
1213} from "@t3tools/contracts";
......@@ -89,6 +90,7 @@ import {
8990 primaryServerProvidersAtom,
9091} from "../../state/server";
9192import { useProjects } from "../../state/entities";
93import { usePrimaryEnvironmentId } from "../../state/environments";
9294import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
9395import { formatRelativeTimeLabel } from "../../timestampFormat";
9496import { Button } from "../ui/button";
......@@ -1998,6 +2000,8 @@ function LegacyFeaturesSection() {
19982000export function GeneralSettingsPanel() {
19992001 const settings = usePrimarySettings();
20002002 const updateSettings = useUpdatePrimarySettings();
2003 const navigate = useNavigate();
2004 const environmentId = usePrimaryEnvironmentId();
20012005 const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false);
20022006 const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>(
20032007 readLastEnabledProjectGroupingMode(),
......@@ -2014,12 +2018,21 @@ export function GeneralSettingsPanel() {
20142018 otlpMetricsUrl: observability?.otlpMetricsUrl,
20152019 });
20162020
2017 const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);
2021 const textGenerationProviders = serverProviders.filter(
2022 (provider) => provider.supportsTextGeneration !== false,
2023 );
2024 const textGenerationModelSelection = resolveAppModelSelectionState(
2025 settings,
2026 textGenerationProviders,
2027 );
20182028 const textGenInstanceId = textGenerationModelSelection.instanceId;
20192029 const textGenModel = textGenerationModelSelection.model;
20202030 const textGenModelOptions = textGenerationModelSelection.options;
20212031 const textGenerationModelInstanceEntries = sortProviderInstanceEntries(
2022 applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings),
2032 applyProviderInstanceSettings(deriveProviderInstanceEntries(textGenerationProviders), settings),
2033 );
2034 const hasTextGenerationProvider = textGenerationModelInstanceEntries.some(
2035 (entry) => entry.enabled && entry.isAvailable,
20232036 );
20242037 const textGenInstanceEntry = textGenerationModelInstanceEntries.find(
20252038 (entry) => entry.instanceId === textGenInstanceId,
......@@ -2028,7 +2041,7 @@ export function GeneralSettingsPanel() {
20282041 textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND;
20292042 const textGenerationModelOptionsByInstance = getCustomModelOptionsByInstance(
20302043 settings,
2031 serverProviders,
2044 textGenerationProviders,
20322045 textGenInstanceId,
20332046 textGenModel,
20342047 );
......@@ -2711,61 +2724,79 @@ export function GeneralSettingsPanel() {
27112724 ) : null
27122725 }
27132726 control={
2714 <div className="flex flex-wrap items-center justify-end gap-1.5">
2715 <ProviderModelPicker
2716 activeInstanceId={textGenInstanceId}
2717 model={textGenModel}
2718 lockedProvider={null}
2719 instanceEntries={textGenerationModelInstanceEntries}
2720 modelOptionsByInstance={textGenerationModelOptionsByInstance}
2721 triggerVariant="outline"
2722 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
2723 onInstanceModelChange={(instanceId, model) => {
2724 updateSettings({
2725 textGenerationModelSelection: resolveAppModelSelectionState(
2726 {
2727 ...settings,
2728 textGenerationModelSelection: createModelSelection(instanceId, model),
2729 },
2730 serverProviders,
2731 ),
2732 });
2733 }}
2734 />
2735 <TraitsPicker
2736 provider={textGenProvider}
2737 models={
2738 // Use the exact instance's models (rather than the
2739 // first-kind-match) so a custom text-gen instance like
2740 // `codex_personal` gets its own model list, not the
2741 // default Codex one.
2742 textGenInstanceEntry?.models ?? []
2743 }
2744 model={textGenModel}
2745 prompt=""
2746 onPromptChange={() => {}}
2747 modelOptions={textGenModelOptions}
2748 allowPromptInjectedEffort={false}
2749 planModeEnabled={settings.planModeEnabled}
2750 triggerVariant="outline"
2751 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
2752 onModelOptionsChange={(nextOptions) => {
2753 updateSettings({
2754 textGenerationModelSelection: resolveAppModelSelectionState(
2755 {
2756 ...settings,
2757 textGenerationModelSelection: createModelSelection(
2758 textGenInstanceId,
2759 textGenModel,
2760 nextOptions,
2727 !hasTextGenerationProvider ? (
2728 <span className="text-sm text-muted-foreground">
2729 No text generation providers available.
2730 </span>
2731 ) : (
2732 <div className="flex flex-wrap items-center justify-end gap-1.5">
2733 <ProviderModelPicker
2734 activeInstanceId={textGenInstanceId}
2735 model={textGenModel}
2736 lockedProvider={null}
2737 instanceEntries={textGenerationModelInstanceEntries}
2738 modelOptionsByInstance={textGenerationModelOptionsByInstance}
2739 triggerVariant="outline"
2740 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
2741 {...(environmentId
2742 ? {
2743 onOpenProviderSetup: (instanceId: ProviderInstanceId) => {
2744 void navigate({
2745 to: "/settings/providers",
2746 search: { environmentId, instanceId },
2747 });
2748 },
2749 }
2750 : {})}
2751 onInstanceModelChange={(instanceId, model) => {
2752 updateSettings({
2753 textGenerationModelSelection: resolveAppModelSelectionState(
2754 {
2755 ...settings,
2756 textGenerationModelSelection: createModelSelection(instanceId, model),
2757 },
2758 textGenerationProviders,
2759 ),
2760 });
2761 }}
2762 />
2763 {textGenInstanceEntry ? (
2764 <TraitsPicker
2765 provider={textGenProvider}
2766 models={
2767 // Use the exact instance's models (rather than the
2768 // first-kind-match) so a custom text-gen instance like
2769 // `codex_personal` gets its own model list, not the
2770 // default Codex one.
2771 textGenInstanceEntry?.models ?? []
2772 }
2773 model={textGenModel}
2774 prompt=""
2775 onPromptChange={() => {}}
2776 modelOptions={textGenModelOptions}
2777 allowPromptInjectedEffort={false}
2778 planModeEnabled={settings.planModeEnabled}
2779 triggerVariant="outline"
2780 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
2781 onModelOptionsChange={(nextOptions) => {
2782 updateSettings({
2783 textGenerationModelSelection: resolveAppModelSelectionState(
2784 {
2785 ...settings,
2786 textGenerationModelSelection: createModelSelection(
2787 textGenInstanceId,
2788 textGenModel,
2789 nextOptions,
2790 ),
2791 },
2792 textGenerationProviders,
27612793 ),
2762 },
2763 serverProviders,
2764 ),
2765 });
2766 }}
2767 />
2768 </div>
2794 });
2795 }}
2796 />
2797 ) : null}
2798 </div>
2799 )
27692800 }
27702801 />
27712802 </SettingsSection>
apps/web/src/components/settings/SourceControlWritingSettings.tsx+41-12
......@@ -1,6 +1,7 @@
11import { useAtomValue } from "@effect/atom-react";
2import { useNavigate } from "@tanstack/react-router";
23import { useRef } from "react";
3import type { SourceControlWritingStyleMode } from "@t3tools/contracts";
4import type { ProviderInstanceId, SourceControlWritingStyleMode } from "@t3tools/contracts";
45import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
56import { createModelSelection } from "@t3tools/shared/model";
67import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings";
......@@ -16,6 +17,7 @@ import {
1617 resolveAppModelSelectionState,
1718} from "../../modelSelection";
1819import { primaryServerProvidersAtom } from "../../state/server";
20import { usePrimaryEnvironmentId } from "../../state/environments";
1921import { ProviderModelPicker } from "../chat/ProviderModelPicker";
2022import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
2123import { Switch } from "../ui/switch";
......@@ -49,6 +51,8 @@ const MODE_OPTIONS: Record<SourceControlWritingStyleMode, { label: string; descr
4951export function SourceControlWritingSettingsSection() {
5052 const settings = usePrimarySettings();
5153 const updateSettings = useUpdatePrimarySettings();
54 const navigate = useNavigate();
55 const environmentId = usePrimaryEnvironmentId();
5256 const serverProviders = useAtomValue(primaryServerProvidersAtom);
5357 const customInstructionsRef = useRef<HTMLTextAreaElement>(null);
5458 const style = settings.sourceControlWritingStyle;
......@@ -56,22 +60,31 @@ export function SourceControlWritingSettingsSection() {
5660 const isSourceControlWritingStyleDirty =
5761 style.mode !== defaults.mode || style.customInstructions !== defaults.customInstructions;
5862
59 const defaultModelSelection = resolveAppModelSelectionState(settings, serverProviders);
63 const textGenerationProviders = serverProviders.filter(
64 (provider) => provider.supportsTextGeneration !== false,
65 );
66 const defaultModelSelection = resolveAppModelSelectionState(settings, textGenerationProviders);
6067 const usesDedicatedModel = settings.sourceControlWriterModelSelection !== null;
61 const resolvedSourceControlWriterSelection = resolveSourceControlWriterModelSelection(
62 settings,
63 serverProviders,
68 const activeSelection = resolveAppModelSelectionState(
69 {
70 ...settings,
71 textGenerationModelSelection: resolveSourceControlWriterModelSelection(
72 settings,
73 textGenerationProviders,
74 ),
75 },
76 textGenerationProviders,
6477 );
65 const activeSelection =
66 resolvedSourceControlWriterSelection === settings.textGenerationModelSelection
67 ? defaultModelSelection
68 : resolvedSourceControlWriterSelection;
6978 const instanceEntries = sortProviderInstanceEntries(
70 applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings),
79 applyProviderInstanceSettings(deriveProviderInstanceEntries(textGenerationProviders), settings),
80 );
81 const canEnableDedicatedModel = instanceEntries.some(
82 (entry) =>
83 entry.instanceId === defaultModelSelection.instanceId && entry.enabled && entry.isAvailable,
7184 );
7285 const modelOptionsByInstance = getCustomModelOptionsByInstance(
7386 settings,
74 serverProviders,
87 textGenerationProviders,
7588 activeSelection.instanceId,
7689 activeSelection.model,
7790 );
......@@ -186,7 +199,12 @@ export function SourceControlWritingSettingsSection() {
186199 description="Optional model override for change descriptions, change request titles and descriptions, and branch or bookmark names. Off uses the global text generation model."
187200 control={
188201 <div className="flex flex-wrap items-center justify-end gap-2">
189 {usesDedicatedModel ? (
202 {usesDedicatedModel && !canEnableDedicatedModel ? (
203 <span className="text-sm text-muted-foreground">
204 No text generation providers available.
205 </span>
206 ) : null}
207 {usesDedicatedModel && canEnableDedicatedModel ? (
190208 <ProviderModelPicker
191209 activeInstanceId={activeSelection.instanceId}
192210 model={activeSelection.model}
......@@ -196,6 +214,16 @@ export function SourceControlWritingSettingsSection() {
196214 triggerVariant="outline"
197215 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
198216 triggerAriaLabel="Source control writer model"
217 {...(environmentId
218 ? {
219 onOpenProviderSetup: (instanceId: ProviderInstanceId) => {
220 void navigate({
221 to: "/settings/providers",
222 search: { environmentId, instanceId },
223 });
224 },
225 }
226 : {})}
199227 onInstanceModelChange={(instanceId, model) => {
200228 updateSettings({
201229 sourceControlWriterModelSelection: createModelSelection(instanceId, model),
......@@ -205,6 +233,7 @@ export function SourceControlWritingSettingsSection() {
205233 ) : null}
206234 <Switch
207235 checked={usesDedicatedModel}
236 disabled={!usesDedicatedModel && !canEnableDedicatedModel}
208237 onCheckedChange={(checked) =>
209238 updateSettings({
210239 sourceControlWriterModelSelection: checked
apps/web/src/components/settings/providerDriverMeta.ts+16-1
......@@ -1,4 +1,5 @@
11import {
2 AntigravitySettings,
23 ClaudeSettings,
34 CodexSettings,
45 CursorSettings,
......@@ -7,7 +8,15 @@ import {
78 ProviderDriverKind,
89} from "@t3tools/contracts";
910import type * as Schema from "effect/Schema";
10import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons";
11import {
12 AntigravityIcon,
13 ClaudeAI,
14 CursorIcon,
15 GrokIcon,
16 type Icon,
17 OpenAI,
18 OpenCodeIcon,
19} from "../Icons";
1120
1221type ProviderSettingsSchema = {
1322 readonly fields: Readonly<Record<string, Schema.Top>>;
......@@ -67,6 +76,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] =
6776 icon: OpenCodeIcon,
6877 settingsSchema: OpenCodeSettings,
6978 },
79 {
80 value: ProviderDriverKind.make("antigravity"),
81 label: "Antigravity",
82 icon: AntigravityIcon,
83 settingsSchema: AntigravitySettings,
84 },
7085];
7186
7287export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial<
apps/web/src/components/settings/providerStatus.ts+9-1
......@@ -87,7 +87,15 @@ export function getProviderSummary(provider: ServerProvider | undefined) {
8787 */
8888export function getProviderVersionLabel(version: string | null | undefined) {
8989 if (!version) return null;
90 return version.startsWith("v") ? version : `v${version}`;
90 // Antigravity reports a release tag such as `agy_acp_server_20260818_01_RC01`.
91 // Show the date and candidate so the row title keeps room for the name.
92 const antigravity = /^agy_acp_server_(\d{4})(\d{2})(\d{2})_\d+(?:_(\w+))?$/.exec(version);
93 if (antigravity) {
94 const [, year, month, day, candidate] = antigravity;
95 return `${year}-${month}-${day}${candidate ? ` ${candidate}` : ""}`;
96 }
97 // Only bare semver-like versions get a `v` prefix. Other tags are shown as-is.
98 return /^\d/.test(version) ? `v${version}` : version;
9199}
92100
93101export function getProviderVersionAdvisoryPresentation(
apps/web/src/components/settings/settingsSearch.test.ts+2
......@@ -87,6 +87,8 @@ describe("searchSettings", () => {
8787 expect(searchSettings("push notifications")[0]?.id).toBe("publish-agent-activity");
8888 expect(searchSettings("battery saver")[0]?.id).toBe("background-activity");
8989 expect(searchSettings("binary path")[0]?.id).toBe("providers");
90 expect(searchSettings("Antigravity")[0]?.id).toBe("providers");
91 expect(searchSettings("Google sign in")[0]?.id).toBe("providers");
9092 expect(searchSettings("authorized clients")[0]?.id).toBe("connections-environment");
9193 expect(searchSettings("administrative access")[0]?.id).toBe("connections-environment");
9294 });
apps/web/src/components/settings/settingsSearch.ts+1-1
......@@ -313,7 +313,7 @@ export const SETTINGS_SEARCH_ITEMS = [
313313 title: "Providers",
314314 to: "/settings/providers",
315315 searchTerms: [
316 "agents cli codex claude cursor grok opencode instances authentication api key models configuration binary path config directory endpoint arguments environment variables display name accent color custom favorite hidden auto compact",
316 "agents cli codex claude cursor grok opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory endpoint arguments environment variables display name accent color custom favorite hidden auto compact",
317317 ],
318318 },
319319 {
apps/web/src/composerDraftStore.ts+8-1
......@@ -1147,6 +1147,8 @@ export function deriveEffectiveComposerModelState(input: {
11471147 { preserveUnavailableSelection: preserveThreadModel },
11481148 )
11491149 : null) ??
1150 // Antigravity has no static model or cross-account catalog fallback.
1151 (input.selectedProvider === "antigravity" && input.selectedInstanceId ? "" : null) ??
11501152 resolveAppModelSelection(
11511153 input.selectedProvider,
11521154 input.settings,
......@@ -1163,7 +1165,11 @@ export function deriveEffectiveComposerModelState(input: {
11631165 ? input.draft?.modelSelectionByProvider?.[input.selectedInstanceId]
11641166 : undefined;
11651167 const legacySelection =
1166 input.draft?.modelSelectionByProvider?.[ProviderInstanceId.make(input.selectedProvider)];
1168 input.selectedProvider === "antigravity" &&
1169 input.selectedInstanceId &&
1170 input.selectedInstanceId !== defaultInstanceIdForDriver(input.selectedProvider)
1171 ? undefined
1172 : input.draft?.modelSelectionByProvider?.[ProviderInstanceId.make(input.selectedProvider)];
11671173 const activeSelection = instanceSelection ?? legacySelection;
11681174 const activeSelectionInstanceId = instanceSelection
11691175 ? (input.selectedInstanceId ?? ProviderInstanceId.make(input.selectedProvider))
......@@ -1176,6 +1182,7 @@ export function deriveEffectiveComposerModelState(input: {
11761182 activeSelection.model,
11771183 { preserveUnavailableSelection: true },
11781184 ) ??
1185 (input.selectedProvider === "antigravity" ? "" : null) ??
11791186 resolveAppModelSelection(
11801187 input.selectedProvider,
11811188 input.settings,
apps/web/src/modelSelection.test.ts+319-124
......@@ -1,10 +1,15 @@
1import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
1import {
2 ANTIGRAVITY_DEFAULT_MODEL,
3 ProviderDriverKind,
4 ProviderInstanceId,
5 type ServerProvider,
6} from "@t3tools/contracts";
27import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings";
38import { describe, expect, it } from "vite-plus/test";
49import { createModelSelection } from "@t3tools/shared/model";
510import { deriveEffectiveComposerModelState } from "./composerDraftStore";
611import { getComposerProviderState } from "./components/chat/composerProviderState";
7import { deriveProviderInstanceEntries } from "./providerInstances";
12import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "./providerInstances";
813import {
914 getCustomModelOptionsByInstance,
1015 getAppModelOptionsForInstance,
......@@ -334,134 +339,147 @@ describe("instance-scoped model selection", () => {
334339 ).toBe("claude-sonnet-4-6");
335340 });
336341
337 it("preserves an existing OpenCode model when a catalog refresh no longer contains it", () => {
338 const providers = [
339 provider({
340 provider: ProviderDriverKind.make("opencode"),
341 instanceId: "opencode",
342 models: ["opencode/big-pickle"],
343 }),
344 ];
342 describe.each([
343 {
344 driverName: "opencode",
345 availableModel: "opencode/big-pickle",
346 missingModel: "opencode/kimi-k3",
347 },
348 {
349 driverName: "antigravity",
350 availableModel: "gemini-3.1-pro",
351 missingModel: "gemini-3.1-pro-high",
352 },
353 ])("$driverName catalog gaps", ({ driverName, availableModel, missingModel }) => {
354 it("preserves a selected model when a catalog refresh no longer contains it", () => {
355 const providers = [
356 provider({
357 provider: ProviderDriverKind.make(driverName),
358 instanceId: driverName,
359 models: [availableModel],
360 }),
361 ];
362
363 expect(
364 resolveAppModelSelectionForInstance(
365 ProviderInstanceId.make(driverName),
366 settingsWithProviderInstances(),
367 providers,
368 missingModel,
369 { preserveUnavailableSelection: true },
370 ),
371 ).toBe(missingModel);
372 expect(
373 resolveAppModelSelectionForInstance(
374 ProviderInstanceId.make(driverName),
375 settingsWithProviderInstances(),
376 providers,
377 missingModel,
378 ),
379 ).toBe(availableModel);
380 });
345381
346 expect(
347 resolveAppModelSelectionForInstance(
348 ProviderInstanceId.make("opencode"),
349 settingsWithProviderInstances(),
350 providers,
351 "opencode/kimi-k3",
352 { preserveUnavailableSelection: true },
353 ),
354 ).toBe("opencode/kimi-k3");
355 expect(
356 resolveAppModelSelectionForInstance(
357 ProviderInstanceId.make("opencode"),
382 it("adds the selected missing model as an unavailable option", () => {
383 const providers = [
384 provider({
385 provider: ProviderDriverKind.make(driverName),
386 instanceId: driverName,
387 models: [availableModel],
388 }),
389 ];
390 const entry = deriveProviderInstanceEntries(providers)[0]!;
391
392 expect(
393 getAppModelOptionsForInstance(settingsWithProviderInstances(), entry, missingModel),
394 ).toEqual([
395 expect.objectContaining({ slug: availableModel }),
396 expect.objectContaining({
397 slug: missingModel,
398 name: missingModel,
399 isUnavailable: true,
400 }),
401 ]);
402 });
403
404 it("keeps a missing option scoped to the selected instance", () => {
405 const selectedInstanceId = ProviderInstanceId.make(`${driverName}_work`);
406 const otherInstanceId = ProviderInstanceId.make(`${driverName}_personal`);
407 const driver = ProviderDriverKind.make(driverName);
408 const providers = [
409 provider({ provider: driver, instanceId: selectedInstanceId, models: [] }),
410 provider({ provider: driver, instanceId: otherInstanceId, models: [] }),
411 ];
412 const options = getCustomModelOptionsByInstance(
358413 settingsWithProviderInstances(),
359414 providers,
360 "opencode/kimi-k3",
361 ),
362 ).toBe("opencode/big-pickle");
363 });
364
365 it("adds the selected missing OpenCode model as an unavailable option", () => {
366 const providers = [
367 provider({
368 provider: ProviderDriverKind.make("opencode"),
369 instanceId: "opencode",
370 models: ["opencode/big-pickle"],
371 }),
372 ];
373 const entry = deriveProviderInstanceEntries(providers)[0]!;
374
375 expect(
376 getAppModelOptionsForInstance(settingsWithProviderInstances(), entry, "opencode/kimi-k3"),
377 ).toEqual([
378 expect.objectContaining({ slug: "opencode/big-pickle" }),
379 expect.objectContaining({
380 slug: "opencode/kimi-k3",
381 name: "opencode/kimi-k3",
382 isUnavailable: true,
383 }),
384 ]);
385 });
386
387 it("keeps a missing OpenCode option scoped to the selected instance", () => {
388 const selectedInstanceId = ProviderInstanceId.make("opencode_work");
389 const otherInstanceId = ProviderInstanceId.make("opencode_personal");
390 const driver = ProviderDriverKind.make("opencode");
391 const providers = [
392 provider({ provider: driver, instanceId: selectedInstanceId, models: [] }),
393 provider({ provider: driver, instanceId: otherInstanceId, models: [] }),
394 ];
395 const options = getCustomModelOptionsByInstance(
396 settingsWithProviderInstances(),
397 providers,
398 selectedInstanceId,
399 "openrouter/kimi-k3",
400 );
401
402 expect(options.get(selectedInstanceId)).toEqual([
403 expect.objectContaining({ slug: "openrouter/kimi-k3", isUnavailable: true }),
404 ]);
405 expect(options.get(otherInstanceId)).toEqual([]);
406 });
407
408 it("replaces the unavailable marker with catalog metadata after recovery", () => {
409 const instanceId = ProviderInstanceId.make("opencode");
410 const driver = ProviderDriverKind.make("opencode");
411 const selectedModel = "opencode/kimi-k3";
412 const pendingProviders = [
413 provider({ provider: driver, instanceId, models: ["opencode/big-pickle"] }),
414 ];
415 const recoveredProviders = [
416 provider({ provider: driver, instanceId, models: ["opencode/big-pickle", selectedModel] }),
417 ];
415 selectedInstanceId,
416 missingModel,
417 );
418
419 expect(options.get(selectedInstanceId)).toEqual([
420 expect.objectContaining({ slug: missingModel, isUnavailable: true }),
421 ]);
422 expect(options.get(otherInstanceId)).toEqual([]);
423 });
418424
419 expect(
420 getAppModelOptionsForInstance(
421 settingsWithProviderInstances(),
422 deriveProviderInstanceEntries(pendingProviders)[0]!,
423 selectedModel,
424 ).find((option) => option.slug === selectedModel)?.isUnavailable,
425 ).toBe(true);
426 expect(
427 getAppModelOptionsForInstance(
428 settingsWithProviderInstances(),
429 deriveProviderInstanceEntries(recoveredProviders)[0]!,
430 selectedModel,
431 ).find((option) => option.slug === selectedModel)?.isUnavailable,
432 ).toBeUndefined();
433 expect(
434 resolveAppModelSelectionForInstance(
435 instanceId,
436 settingsWithProviderInstances(),
437 recoveredProviders,
438 selectedModel,
439 { preserveUnavailableSelection: true },
440 ),
441 ).toBe(selectedModel);
442 });
425 it("replaces the unavailable marker with catalog metadata after recovery", () => {
426 const instanceId = ProviderInstanceId.make(driverName);
427 const driver = ProviderDriverKind.make(driverName);
428 const selectedModel = missingModel;
429 const pendingProviders = [
430 provider({ provider: driver, instanceId, models: [availableModel] }),
431 ];
432 const recoveredProviders = [
433 provider({ provider: driver, instanceId, models: [availableModel, selectedModel] }),
434 ];
435
436 expect(
437 getAppModelOptionsForInstance(
438 settingsWithProviderInstances(),
439 deriveProviderInstanceEntries(pendingProviders)[0]!,
440 selectedModel,
441 ).find((option) => option.slug === selectedModel)?.isUnavailable,
442 ).toBe(true);
443 expect(
444 getAppModelOptionsForInstance(
445 settingsWithProviderInstances(),
446 deriveProviderInstanceEntries(recoveredProviders)[0]!,
447 selectedModel,
448 ).find((option) => option.slug === selectedModel)?.isUnavailable,
449 ).toBeUndefined();
450 expect(
451 resolveAppModelSelectionForInstance(
452 instanceId,
453 settingsWithProviderInstances(),
454 recoveredProviders,
455 selectedModel,
456 { preserveUnavailableSelection: true },
457 ),
458 ).toBe(selectedModel);
459 });
443460
444 it("does not resurrect a hidden OpenCode model when the raw catalog omits it", () => {
445 const instanceId = ProviderInstanceId.make("opencode");
446 const driver = ProviderDriverKind.make("opencode");
447 const settings: UnifiedSettings = {
448 ...settingsWithProviderInstances(),
449 providerModelPreferences: {
450 [instanceId]: {
451 hiddenModels: ["opencode/kimi-k3"],
452 modelOrder: [],
461 it("does not resurrect a hidden model when the raw catalog omits it", () => {
462 const instanceId = ProviderInstanceId.make(driverName);
463 const driver = ProviderDriverKind.make(driverName);
464 const settings: UnifiedSettings = {
465 ...settingsWithProviderInstances(),
466 providerModelPreferences: {
467 [instanceId]: {
468 hiddenModels: [missingModel],
469 modelOrder: [],
470 },
453471 },
454 },
455 };
456 const providers = [provider({ provider: driver, instanceId, models: [] })];
457 const entry = deriveProviderInstanceEntries(providers)[0]!;
458
459 expect(getAppModelOptionsForInstance(settings, entry, "opencode/kimi-k3")).toEqual([]);
460 expect(
461 resolveAppModelSelectionForInstance(instanceId, settings, providers, "opencode/kimi-k3", {
462 preserveUnavailableSelection: true,
463 }),
464 ).toBeNull();
472 };
473 const providers = [provider({ provider: driver, instanceId, models: [] })];
474 const entry = deriveProviderInstanceEntries(providers)[0]!;
475
476 expect(getAppModelOptionsForInstance(settings, entry, missingModel)).toEqual([]);
477 expect(
478 resolveAppModelSelectionForInstance(instanceId, settings, providers, missingModel, {
479 preserveUnavailableSelection: true,
480 }),
481 ).toBeNull();
482 });
465483 });
466484
467485 it("does not add unavailable options for other providers", () => {
......@@ -547,6 +565,159 @@ describe("instance-scoped model selection", () => {
547565 expect(state.modelOptions?.[instanceId]).toEqual(draftSelection.options);
548566 });
549567
568 it("preserves the Antigravity model in drafts and existing threads after sign-out", () => {
569 const instanceId = ProviderInstanceId.make("antigravity_work");
570 const driver = ProviderDriverKind.make("antigravity");
571 const saved = createModelSelection(instanceId, "gemini-3.1-pro-high");
572 const providers = [
573 {
574 ...provider({ provider: driver, instanceId, models: [] }),
575 status: "error" as const,
576 auth: { status: "unauthenticated" as const },
577 },
578 ];
579 for (const draft of [
580 null,
581 { activeProvider: instanceId, modelSelectionByProvider: { [instanceId]: saved } },
582 ]) {
583 const state = deriveEffectiveComposerModelState({
584 draft,
585 providers,
586 selectedProvider: driver,
587 selectedInstanceId: instanceId,
588 threadModelSelection: saved,
589 projectModelSelection: null,
590 settings: settingsWithProviderInstances(),
591 });
592 expect(state.selectedModel).toBe(saved.model);
593 }
594 });
595
596 it("does not borrow a default model while a new Antigravity account has no catalog", () => {
597 const driver = ProviderDriverKind.make("antigravity");
598 const instanceId = ProviderInstanceId.make("antigravity_work");
599 const providers = [
600 provider({ instanceId: "codex", models: ["gpt-5.6-sol"] }),
601 provider({ provider: driver, instanceId: "antigravity", models: ["gemini-other-account"] }),
602 provider({ provider: driver, instanceId, models: [] }),
603 ];
604
605 const otherAccountId = ProviderInstanceId.make("antigravity");
606 for (const draft of [
607 null,
608 {
609 activeProvider: instanceId,
610 modelSelectionByProvider: {
611 [otherAccountId]: createModelSelection(otherAccountId, "gemini-other-account"),
612 },
613 },
614 ]) {
615 const state = deriveEffectiveComposerModelState({
616 draft,
617 providers,
618 selectedProvider: driver,
619 selectedInstanceId: instanceId,
620 threadModelSelection: null,
621 projectModelSelection: createModelSelection(
622 ProviderInstanceId.make("codex"),
623 "gpt-5.6-sol",
624 ),
625 settings: settingsWithProviderInstances(),
626 });
627 expect(state.selectedModel).toBe("");
628 }
629 });
630
631 it("offers only account catalog models for Antigravity despite custom model settings", () => {
632 const driver = ProviderDriverKind.make("antigravity");
633 const customId = ProviderInstanceId.make("antigravity_work");
634 const nativeModel = "gemini-3.1-pro";
635 const settings: UnifiedSettings = {
636 ...DEFAULT_UNIFIED_SETTINGS,
637 providers: {
638 ...DEFAULT_UNIFIED_SETTINGS.providers,
639 antigravity: {
640 ...DEFAULT_UNIFIED_SETTINGS.providers.antigravity,
641 customModels: ["api-only-model"],
642 },
643 },
644 providerInstances: {
645 [customId]: { driver, config: { customModels: ["unknown-model"] } },
646 },
647 };
648 const entries = deriveProviderInstanceEntries([
649 provider({ provider: driver, instanceId: "antigravity", models: [nativeModel] }),
650 provider({ provider: driver, instanceId: customId, models: [nativeModel] }),
651 ]);
652
653 for (const entry of entries) {
654 expect(getAppModelOptionsForInstance(settings, entry).map((model) => model.slug)).toEqual([
655 nativeModel,
656 ]);
657 }
658 });
659
660 it("resolves the Antigravity default marker without creating an unavailable model", () => {
661 const instanceId = ProviderInstanceId.make("antigravity_work");
662 const nativeModel = "gemini-3.1-pro";
663 const base = provider({
664 provider: ProviderDriverKind.make("antigravity"),
665 instanceId,
666 models: [nativeModel],
667 });
668 const liveProvider = {
669 ...base,
670 models: base.models.map((model) => ({
671 ...model,
672 isDefault: true,
673 aliases: [ANTIGRAVITY_DEFAULT_MODEL],
674 })),
675 };
676 const settings = settingsWithProviderInstances();
677
678 expect(
679 getAppModelOptionsForInstance(
680 settings,
681 deriveProviderInstanceEntries([liveProvider])[0]!,
682 ANTIGRAVITY_DEFAULT_MODEL,
683 ).map((model) => model.slug),
684 ).toEqual([nativeModel]);
685 expect(
686 resolveAppModelSelectionForInstance(
687 instanceId,
688 settings,
689 [liveProvider],
690 ANTIGRAVITY_DEFAULT_MODEL,
691 {
692 preserveUnavailableSelection: true,
693 },
694 ),
695 ).toBe(nativeModel);
696
697 const hiddenSettings: UnifiedSettings = {
698 ...settings,
699 providerModelPreferences: {
700 [instanceId]: { hiddenModels: [nativeModel], modelOrder: [] },
701 },
702 };
703 expect(
704 resolveAppModelSelectionForInstance(
705 instanceId,
706 hiddenSettings,
707 [liveProvider],
708 ANTIGRAVITY_DEFAULT_MODEL,
709 { preserveUnavailableSelection: true },
710 ),
711 ).toBeNull();
712 expect(
713 getAppModelOptionsForInstance(
714 settings,
715 deriveProviderInstanceEntries([{ ...base, models: [] }])[0]!,
716 ANTIGRAVITY_DEFAULT_MODEL,
717 ),
718 ).toEqual([]);
719 });
720
550721 it("preserves saved options through dispatch when the model is absent from the catalog", () => {
551722 const instanceId = ProviderInstanceId.make("opencode");
552723 const driver = ProviderDriverKind.make("opencode");
......@@ -639,6 +810,30 @@ describe("instance-scoped model selection", () => {
639810 model: "openai/gpt-5.5",
640811 });
641812 });
813
814 it("does not select a provider that cannot generate system text", () => {
815 const instanceId = ProviderInstanceId.make("antigravity");
816 const unsupported = {
817 ...provider({
818 provider: ProviderDriverKind.make("antigravity"),
819 instanceId,
820 models: ["gemini-3.1-pro"],
821 }),
822 supportsTextGeneration: false,
823 };
824 const supported = provider({ instanceId: "codex", models: ["gpt-5.6-sol"] });
825 const settings = {
826 ...settingsWithProviderInstances(),
827 textGenerationModelSelection: createModelSelection(instanceId, "gemini-3.1-pro"),
828 };
829
830 expect(resolveAppModelSelectionState(settings, [unsupported, supported])).toEqual(
831 createModelSelection(supported.instanceId, "gpt-5.6-sol"),
832 );
833 expect(resolveAppModelSelectionState(settings, [unsupported])).toEqual(
834 NO_PROVIDER_MODEL_SELECTION,
835 );
836 });
642837});
643838
644839describe("withoutPlanAgentSelection", () => {
apps/web/src/modelSelection.ts+35-33
......@@ -1,4 +1,5 @@
11import {
2 ANTIGRAVITY_DEFAULT_MODEL,
23 DEFAULT_TEXT_GENERATION_MODEL,
34 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER,
45 defaultInstanceIdForDriver,
......@@ -23,7 +24,11 @@ import {
2324 resolveSelectableProvider,
2425} from "./providerModels";
2526import { ModelEsque } from "./components/chat/providerIconUtils";
26import { type ProviderInstanceEntry, deriveProviderInstanceEntries } from "./providerInstances";
27import {
28 type ProviderInstanceEntry,
29 deriveProviderInstanceEntries,
30 NO_PROVIDER_MODEL_SELECTION,
31} from "./providerInstances";
2732import { sortModelsForProviderInstance } from "./modelOrdering";
2833
2934const MAX_CUSTOM_MODEL_COUNT = 32;
......@@ -51,6 +56,7 @@ function readInstanceCustomModels(
5156 instanceId: ProviderInstanceId,
5257 driverKind: ProviderDriverKind,
5358): ReadonlyArray<string> {
59 if (driverKind === "antigravity") return [];
5460 const instance = settings.providerInstances?.[instanceId];
5561 const config = instance?.config;
5662 if (config !== null && typeof config === "object") {
......@@ -83,20 +89,21 @@ export interface AppModelOption {
8389 isUnavailable?: boolean;
8490}
8591
86function appendUnavailableOpenCodeSelection(
92function appendUnavailableDynamicModelSelection(
8793 options: AppModelOption[],
8894 rawModels: ReadonlyArray<ServerProvider["models"][number]>,
8995 provider: ProviderDriverKind,
9096 selectedModel: string | null | undefined,
9197 hiddenModels: ReadonlyArray<string>,
9298): AppModelOption[] {
93 if (provider !== "opencode") return options;
99 if (provider !== "opencode" && provider !== "antigravity") return options;
94100 const slug = normalizeCustomModelSlug(selectedModel);
95101 if (!slug) return options;
102 if (provider === "antigravity" && slug === ANTIGRAVITY_DEFAULT_MODEL) return options;
96103
97104 // A model that exists in the raw catalog can be absent from `options`
98105 // because the user hid it. Keep that preference authoritative.
99 if (rawModels.some((model) => model.slug === slug)) return options;
106 if (resolveSelectableModel(provider, slug, rawModels) !== null) return options;
100107 if (hiddenModels.includes(slug)) return options;
101108 if (options.some((option) => option.slug === slug)) return options;
102109
......@@ -212,7 +219,7 @@ export function getAppModelOptions(
212219 }
213220
214221 const preferences = readInstanceModelPreferences(settings, defaultInstanceId);
215 return appendUnavailableOpenCodeSelection(
222 return appendUnavailableDynamicModelSelection(
216223 applyInstanceModelPreferences(options, preferences),
217224 rawModels,
218225 provider,
......@@ -260,7 +267,7 @@ export function getAppModelOptionsForInstance(
260267 }
261268
262269 const preferences = readInstanceModelPreferences(settings, entry.instanceId);
263 return appendUnavailableOpenCodeSelection(
270 return appendUnavailableDynamicModelSelection(
264271 applyInstanceModelPreferences(options, preferences),
265272 entry.models,
266273 entry.driverKind,
......@@ -303,20 +310,22 @@ export function resolveAppModelSelectionForInstance(
303310 if (resolvedSelection) {
304311 return resolvedSelection;
305312 }
306 if (resolutionOptions?.preserveUnavailableSelection && entry.driverKind === "opencode") {
313 if (
314 resolutionOptions?.preserveUnavailableSelection &&
315 (entry.driverKind === "opencode" || entry.driverKind === "antigravity")
316 ) {
307317 const unavailableSelection = normalizeCustomModelSlug(selectedModel);
308318 const hiddenModels = readInstanceModelPreferences(settings, entry.instanceId).hiddenModels;
309 if (unavailableSelection && !hiddenModels.includes(unavailableSelection)) {
319 if (
320 unavailableSelection &&
321 !hiddenModels.includes(unavailableSelection) &&
322 resolveSelectableModel(entry.driverKind, selectedModel, entry.models) === null &&
323 (entry.driverKind !== "antigravity" || unavailableSelection !== ANTIGRAVITY_DEFAULT_MODEL)
324 ) {
310325 return unavailableSelection;
311326 }
312327 }
313 return (
314 options.find((option) => option.isDefault)?.slug ??
315 options[0]?.slug ??
316 entry.models.find((model) => model.isDefault)?.slug ??
317 entry.models[0]?.slug ??
318 null
319 );
328 return options.find((option) => option.isDefault)?.slug ?? options[0]?.slug ?? null;
320329}
321330
322331/**
......@@ -397,7 +406,10 @@ export function resolveAppModelSelectionState(
397406 instanceId: DEFAULT_TEXT_GENERATION_INSTANCE_ID,
398407 model: DEFAULT_TEXT_GENERATION_MODEL,
399408 };
400 const entries = deriveProviderInstanceEntries(providers);
409 const supportedProviders = providers.filter(
410 (provider) => provider.supportsTextGeneration !== false,
411 );
412 const entries = deriveProviderInstanceEntries(supportedProviders);
401413 const selectedEntry = entries.find(
402414 (entry) => entry.instanceId === selection.instanceId && entry.enabled && entry.isAvailable,
403415 );
......@@ -408,7 +420,12 @@ export function resolveAppModelSelectionState(
408420 // don't carry over the old instance's model — use the fallback instance's default.
409421 const selectedModel = selectedEntry ? selection.model : null;
410422 const model =
411 resolveAppModelSelectionForInstance(entry.instanceId, settings, providers, selectedModel) ??
423 resolveAppModelSelectionForInstance(
424 entry.instanceId,
425 settings,
426 supportedProviders,
427 selectedModel,
428 ) ??
412429 entry.models[0]?.slug ??
413430 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind];
414431 if (!model) {
......@@ -426,20 +443,5 @@ export function resolveAppModelSelectionState(
426443 return createModelSelection(entry.instanceId, model, modelOptionsForDispatch);
427444 }
428445
429 const provider = resolveSelectableProvider(providers, null);
430 const keptSelectedProvider = false;
431
432 // When the provider changed due to fallback (e.g. selected provider was disabled),
433 // don't carry over the old provider's model — use the fallback provider's default.
434 const selectedModel = keptSelectedProvider ? selection.model : null;
435 const model = resolveAppModelSelection(provider, settings, providers, selectedModel);
436 const { modelOptionsForDispatch } = getComposerProviderState({
437 provider,
438 model,
439 models: getProviderModels(providers, provider),
440 modelOptions: keptSelectedProvider ? selection.options : undefined,
441 planModeEnabled: settings.planModeEnabled,
442 });
443
444 return createModelSelection(defaultInstanceIdForDriver(provider), model, modelOptionsForDispatch);
446 return NO_PROVIDER_MODEL_SELECTION;
445447}
apps/web/src/pendingUserInput.test.ts+92-14
......@@ -39,11 +39,23 @@ const multiSelectQuestion = {
3939 multiSelect: true,
4040} as const;
4141
42const nativeChoiceQuestion = {
43 id: "result",
44 header: "Result",
45 question: "Which result should be used?",
46 options: [
47 { value: " first\t", label: "Result", description: "First result" },
48 { value: "second", label: "Result", description: "Second result" },
49 ],
50 allowCustomAnswer: false,
51 multiSelect: false,
52} as const;
53
4254describe("resolvePendingUserInputAnswer", () => {
4355 it("prefers a custom answer over selected options", () => {
4456 expect(
4557 resolvePendingUserInputAnswer(singleSelectQuestion, {
46 selectedOptionLabels: ["Orchestration-first"],
58 selectedOptionValues: ["Orchestration-first"],
4759 customAnswer: "Keep the existing envelope for one release",
4860 }),
4961 ).toBe("Keep the existing envelope for one release");
......@@ -52,7 +64,7 @@ describe("resolvePendingUserInputAnswer", () => {
5264 it("falls back to the selected option for single-select questions", () => {
5365 expect(
5466 resolvePendingUserInputAnswer(singleSelectQuestion, {
55 selectedOptionLabels: ["Orchestration-first"],
67 selectedOptionValues: ["Orchestration-first"],
5668 }),
5769 ).toBe("Orchestration-first");
5870 });
......@@ -60,7 +72,7 @@ describe("resolvePendingUserInputAnswer", () => {
6072 it("returns all selected labels for multi-select questions", () => {
6173 expect(
6274 resolvePendingUserInputAnswer(multiSelectQuestion, {
63 selectedOptionLabels: ["Server", "Web"],
75 selectedOptionValues: ["Server", "Web"],
6476 }),
6577 ).toEqual(["Server", "Web"]);
6678 });
......@@ -69,7 +81,7 @@ describe("resolvePendingUserInputAnswer", () => {
6981 expect(
7082 setPendingUserInputCustomAnswer(
7183 {
72 selectedOptionLabels: ["Server", "Web"],
84 selectedOptionValues: ["Server", "Web"],
7385 },
7486 "doesn't matter",
7587 ),
......@@ -77,6 +89,23 @@ describe("resolvePendingUserInputAnswer", () => {
7789 customAnswer: "doesn't matter",
7890 });
7991 });
92
93 it("does not replace a required choice with a custom answer", () => {
94 expect(
95 resolvePendingUserInputAnswer(nativeChoiceQuestion, {
96 selectedOptionValues: ["second"],
97 customAnswer: "Use another result",
98 }),
99 ).toBe("second");
100 });
101
102 it("does not submit labels or unknown values when an option has a value", () => {
103 expect(
104 resolvePendingUserInputAnswer(nativeChoiceQuestion, {
105 selectedOptionValues: ["Result", "unknown"],
106 }),
107 ).toBeNull();
108 });
80109});
81110
82111describe("togglePendingUserInputOptionSelection", () => {
......@@ -84,7 +113,7 @@ describe("togglePendingUserInputOptionSelection", () => {
84113 expect(togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Server")).toEqual(
85114 {
86115 customAnswer: "",
87 selectedOptionLabels: ["Server"],
116 selectedOptionValues: ["Server"],
88117 },
89118 );
90119
......@@ -92,13 +121,32 @@ describe("togglePendingUserInputOptionSelection", () => {
92121 togglePendingUserInputOptionSelection(
93122 multiSelectQuestion,
94123 {
95 selectedOptionLabels: ["Server", "Web"],
124 selectedOptionValues: ["Server", "Web"],
96125 },
97126 "Server",
98127 ),
99128 ).toEqual({
100129 customAnswer: "",
101 selectedOptionLabels: ["Web"],
130 selectedOptionValues: ["Web"],
131 });
132 });
133
134 it("selects and removes options with the same label independently", () => {
135 const question = { ...nativeChoiceQuestion, multiSelect: true };
136 const firstSelected = togglePendingUserInputOptionSelection(question, undefined, " first\t");
137 const bothSelected = togglePendingUserInputOptionSelection(question, firstSelected, "second");
138
139 expect(buildPendingUserInputAnswers([question], { result: bothSelected })).toEqual({
140 result: [" first\t", "second"],
141 });
142
143 const secondSelected = togglePendingUserInputOptionSelection(
144 question,
145 bothSelected,
146 " first\t",
147 );
148 expect(buildPendingUserInputAnswers([question], { result: secondSelected })).toEqual({
149 result: ["second"],
102150 });
103151 });
104152});
......@@ -124,7 +172,7 @@ describe("buildPendingUserInputAnswers", () => {
124172 ],
125173 {
126174 scope: {
127 selectedOptionLabels: ["Orchestration-first"],
175 selectedOptionValues: ["Orchestration-first"],
128176 },
129177 compat: {
130178 customAnswer: "Keep the current envelope for one release window",
......@@ -141,7 +189,7 @@ describe("buildPendingUserInputAnswers", () => {
141189 expect(
142190 buildPendingUserInputAnswers([multiSelectQuestion], {
143191 areas: {
144 selectedOptionLabels: ["Server", "Web"],
192 selectedOptionValues: ["Server", "Web"],
145193 },
146194 }),
147195 ).toEqual({
......@@ -152,6 +200,22 @@ describe("buildPendingUserInputAnswers", () => {
152200 it("returns null when any question is unanswered", () => {
153201 expect(buildPendingUserInputAnswers([singleSelectQuestion], {})).toBeNull();
154202 });
203
204 it.each([" first\t", ""])("preserves the exact selected option value %j", (value) => {
205 const question = {
206 ...nativeChoiceQuestion,
207 options: [{ ...nativeChoiceQuestion.options[0], value }, nativeChoiceQuestion.options[1]],
208 };
209 const draft = togglePendingUserInputOptionSelection(question, undefined, value);
210
211 expect(buildPendingUserInputAnswers([question], { result: draft })).toEqual({ result: value });
212 expect(derivePendingUserInputProgress([question], { result: draft }, 0)).toMatchObject({
213 selectedOptionValues: [value],
214 answeredQuestionCount: 1,
215 canAdvance: true,
216 isComplete: true,
217 });
218 });
155219});
156220
157221describe("pending user input question progress", () => {
......@@ -175,7 +239,7 @@ describe("pending user input question progress", () => {
175239 expect(
176240 countAnsweredPendingUserInputQuestions(questions, {
177241 scope: {
178 selectedOptionLabels: ["Orchestration-first"],
242 selectedOptionValues: ["Orchestration-first"],
179243 },
180244 }),
181245 ).toBe(1);
......@@ -187,7 +251,7 @@ describe("pending user input question progress", () => {
187251 questions,
188252 {
189253 scope: {
190 selectedOptionLabels: ["Orchestration-first"],
254 selectedOptionValues: ["Orchestration-first"],
191255 },
192256 },
193257 0,
......@@ -195,7 +259,7 @@ describe("pending user input question progress", () => {
195259 ).toMatchObject({
196260 questionIndex: 0,
197261 activeQuestion: questions[0],
198 selectedOptionLabels: ["Orchestration-first"],
262 selectedOptionValues: ["Orchestration-first"],
199263 customAnswer: "",
200264 resolvedAnswer: "Orchestration-first",
201265 answeredQuestionCount: 1,
......@@ -211,16 +275,30 @@ describe("pending user input question progress", () => {
211275 [multiSelectQuestion],
212276 {
213277 areas: {
214 selectedOptionLabels: ["Server", "Web"],
278 selectedOptionValues: ["Server", "Web"],
215279 },
216280 },
217281 0,
218282 ),
219283 ).toMatchObject({
220 selectedOptionLabels: ["Server", "Web"],
284 selectedOptionValues: ["Server", "Web"],
221285 resolvedAnswer: ["Server", "Web"],
222286 canAdvance: true,
223287 isComplete: true,
224288 });
225289 });
290
291 it("requires an option when custom answers are disabled", () => {
292 const drafts = { result: { customAnswer: "Use another result" } };
293
294 expect(buildPendingUserInputAnswers([nativeChoiceQuestion], drafts)).toBeNull();
295 expect(derivePendingUserInputProgress([nativeChoiceQuestion], drafts, 0)).toMatchObject({
296 customAnswer: "",
297 usingCustomAnswer: false,
298 resolvedAnswer: null,
299 answeredQuestionCount: 0,
300 canAdvance: false,
301 isComplete: false,
302 });
303 });
226304});
apps/web/src/pendingUserInput.ts+31-33
......@@ -1,7 +1,7 @@
11import type { UserInputQuestion } from "@t3tools/contracts";
22
33export interface PendingUserInputDraftAnswer {
4 selectedOptionLabels?: string[];
4 selectedOptionValues?: string[];
55 customAnswer?: string;
66}
77
......@@ -9,7 +9,7 @@ export interface PendingUserInputProgress {
99 questionIndex: number;
1010 activeQuestion: UserInputQuestion | null;
1111 activeDraft: PendingUserInputDraftAnswer | undefined;
12 selectedOptionLabels: string[];
12 selectedOptionValues: string[];
1313 customAnswer: string;
1414 resolvedAnswer: string | string[] | null;
1515 usingCustomAnswer: boolean;
......@@ -28,77 +28,72 @@ function normalizeDraftAnswer(value: string | undefined): string | null {
2828 return trimmed.length > 0 ? trimmed : null;
2929}
3030
31function normalizeSelectedOptionLabels(value: string[] | undefined): string[] {
31function normalizeSelectedOptionValues(value: string[] | undefined): string[] {
3232 if (!Array.isArray(value)) {
3333 return [];
3434 }
3535
36 const normalized: string[] = [];
37 for (const entry of value) {
38 if (typeof entry !== "string") continue;
39 const trimmed = entry.trim();
40 if (trimmed.length > 0) {
41 normalized.push(trimmed);
42 }
43 }
44
45 return Array.from(new Set(normalized));
36 // Provider option IDs must stay unchanged, including whitespace.
37 return Array.from(new Set(value.filter((entry) => typeof entry === "string")));
4638}
4739
4840export function resolvePendingUserInputAnswer(
4941 question: UserInputQuestion,
5042 draft: PendingUserInputDraftAnswer | undefined,
5143): string | string[] | null {
52 const customAnswer = normalizeDraftAnswer(draft?.customAnswer);
44 const customAnswer =
45 question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer);
5346 if (customAnswer) {
5447 return customAnswer;
5548 }
5649
57 const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels);
50 const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues).filter(
51 (value) => question.options.some((option) => (option.value ?? option.label) === value),
52 );
5853 if (question.multiSelect) {
59 return selectedOptionLabels.length > 0 ? selectedOptionLabels : null;
54 return selectedOptionValues.length > 0 ? selectedOptionValues : null;
6055 }
6156
62 return selectedOptionLabels[0] ?? null;
57 return selectedOptionValues[0] ?? null;
6358}
6459
6560export function setPendingUserInputCustomAnswer(
6661 draft: PendingUserInputDraftAnswer | undefined,
6762 customAnswer: string,
6863): PendingUserInputDraftAnswer {
69 const selectedOptionLabels =
64 const selectedOptionValues =
7065 customAnswer.trim().length > 0
7166 ? undefined
72 : normalizeSelectedOptionLabels(draft?.selectedOptionLabels);
67 : normalizeSelectedOptionValues(draft?.selectedOptionValues);
7368
7469 return {
7570 customAnswer,
76 ...(selectedOptionLabels && selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}),
71 ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}),
7772 };
7873}
7974
8075export function togglePendingUserInputOptionSelection(
8176 question: UserInputQuestion,
8277 draft: PendingUserInputDraftAnswer | undefined,
83 optionLabel: string,
78 optionValue: string,
8479): PendingUserInputDraftAnswer {
8580 if (question.multiSelect) {
86 const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels);
87 const nextSelectedOptionLabels = selectedOptionLabels.includes(optionLabel)
88 ? selectedOptionLabels.filter((label) => label !== optionLabel)
89 : [...selectedOptionLabels, optionLabel];
81 const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues);
82 const nextSelectedOptionValues = selectedOptionValues.includes(optionValue)
83 ? selectedOptionValues.filter((value) => value !== optionValue)
84 : [...selectedOptionValues, optionValue];
9085
9186 return {
9287 customAnswer: "",
93 ...(nextSelectedOptionLabels.length > 0
94 ? { selectedOptionLabels: nextSelectedOptionLabels }
88 ...(nextSelectedOptionValues.length > 0
89 ? { selectedOptionValues: nextSelectedOptionValues }
9590 : {}),
9691 };
9792 }
9893
9994 return {
10095 customAnswer: "",
101 selectedOptionLabels: [optionLabel],
96 selectedOptionValues: [optionValue],
10297 };
10398}
10499
......@@ -110,7 +105,7 @@ export function buildPendingUserInputAnswers(
110105
111106 for (const question of questions) {
112107 const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]);
113 if (!answer) {
108 if (answer === null) {
114109 return null;
115110 }
116111 answers[question.id] = answer;
......@@ -124,7 +119,9 @@ export function countAnsweredPendingUserInputQuestions(
124119 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
125120): number {
126121 return questions.reduce((count, question) => {
127 return resolvePendingUserInputAnswer(question, draftAnswers[question.id]) ? count + 1 : count;
122 return resolvePendingUserInputAnswer(question, draftAnswers[question.id]) !== null
123 ? count + 1
124 : count;
128125 }, 0);
129126}
130127
......@@ -140,7 +137,8 @@ export function derivePendingUserInputProgress(
140137 const resolvedAnswer = activeQuestion
141138 ? resolvePendingUserInputAnswer(activeQuestion, activeDraft)
142139 : null;
143 const customAnswer = activeDraft?.customAnswer ?? "";
140 const customAnswer =
141 activeQuestion?.allowCustomAnswer === false ? "" : (activeDraft?.customAnswer ?? "");
144142 const answeredQuestionCount = countAnsweredPendingUserInputQuestions(questions, draftAnswers);
145143 const isLastQuestion =
146144 questions.length === 0 ? true : normalizedQuestionIndex >= questions.length - 1;
......@@ -149,13 +147,13 @@ export function derivePendingUserInputProgress(
149147 questionIndex: normalizedQuestionIndex,
150148 activeQuestion,
151149 activeDraft,
152 selectedOptionLabels: normalizeSelectedOptionLabels(activeDraft?.selectedOptionLabels),
150 selectedOptionValues: normalizeSelectedOptionValues(activeDraft?.selectedOptionValues),
153151 customAnswer,
154152 resolvedAnswer,
155153 usingCustomAnswer: customAnswer.trim().length > 0,
156154 answeredQuestionCount,
157155 isLastQuestion,
158156 isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null,
159 canAdvance: Boolean(resolvedAnswer),
157 canAdvance: resolvedAnswer !== null,
160158 };
161159}
apps/web/src/routes/settings.providers.tsx+11-1
......@@ -1,11 +1,21 @@
11import { createFileRoute } from "@tanstack/react-router";
2import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
23
34import { ProviderSettingsPanel } from "../components/settings/ProviderSettingsPanel";
45
56function SettingsProvidersRoute() {
6 return <ProviderSettingsPanel />;
7 const target = Route.useSearch();
8 return <ProviderSettingsPanel {...target} />;
79}
810
911export const Route = createFileRoute("/settings/providers")({
12 validateSearch: (raw: Record<string, unknown>) => ({
13 ...(typeof raw.environmentId === "string" && raw.environmentId.trim()
14 ? { environmentId: EnvironmentId.make(raw.environmentId) }
15 : {}),
16 ...(typeof raw.instanceId === "string" && raw.instanceId.trim()
17 ? { instanceId: ProviderInstanceId.make(raw.instanceId) }
18 : {}),
19 }),
1020 component: SettingsProvidersRoute,
1121});
apps/web/src/session-logic.test.ts+24
......@@ -250,6 +250,30 @@ describe("derivePendingApprovals", () => {
250250});
251251
252252describe("derivePendingUserInputs", () => {
253 it("preserves native choice values and the custom-answer restriction", () => {
254 const question = {
255 id: "interaction-result",
256 header: "Result",
257 question: "Which result should be used?",
258 options: [
259 { value: " first\t", label: "Result", description: "First result" },
260 { value: "second", label: "Result", description: "Second result" },
261 ],
262 allowCustomAnswer: false,
263 multiSelect: false,
264 };
265 const activities = [
266 makeActivity({
267 id: "native-user-input",
268 kind: "user-input.requested",
269 summary: "User input requested",
270 payload: { requestId: "req-native-choice", questions: [question] },
271 }),
272 ];
273
274 expect(derivePendingUserInputs(activities)[0]?.questions).toEqual([question]);
275 });
276
253277 it("tracks open structured prompts and removes resolved ones", () => {
254278 const activities: OrchestrationThreadActivity[] = [
255279 makeActivity({
apps/web/src/session-logic.ts+10
......@@ -61,6 +61,12 @@ export const PROVIDER_OPTIONS: Array<{
6161 available: true,
6262 pickerSidebarBadge: "new",
6363 },
64 {
65 value: ProviderDriverKind.make("antigravity"),
66 label: "Antigravity",
67 available: true,
68 pickerSidebarBadge: "new",
69 },
6470];
6571
6672export type WorkLogToolLifecycleStatus =
......@@ -522,6 +528,7 @@ function parseUserInputQuestions(
522528 return {
523529 label: optionRecord.label,
524530 description: optionRecord.description,
531 ...(typeof optionRecord.value === "string" ? { value: optionRecord.value } : {}),
525532 };
526533 })
527534 .filter((option): option is UserInputQuestion["options"][number] => option !== null);
......@@ -534,6 +541,9 @@ function parseUserInputQuestions(
534541 question: question.question,
535542 options,
536543 multiSelect: question.multiSelect === true,
544 ...(typeof question.allowCustomAnswer === "boolean"
545 ? { allowCustomAnswer: question.allowCustomAnswer }
546 : {}),
537547 };
538548 })
539549 .filter((question): question is UserInputQuestion => question !== null);
docs/internals/glossary.md+1-1
......@@ -95,7 +95,7 @@ The live backend agent implementation and its event stream. The main service is
9595
9696#### Provider
9797
98The backend agent runtime that actually performs work. Five drivers ship built in: Codex, Claude, Cursor, Grok, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter.
98The backend agent runtime that actually performs work. Six drivers ship built in: Codex, Claude, Cursor, Grok, OpenCode, and Antigravity. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter.
9999
100100#### Session
101101
docs/internals/overview.md+4-4
......@@ -18,13 +18,13 @@ there, never in the client.
1818┌──────────────────â–¼─────────────────────────────┐
1919│ apps/server │
2020│ orchestration engine (event-sourced) │
21│ provider driver registry (5 built-in drivers) │
21│ provider driver registry (6 built-in drivers) │
2222│ checkpointing, VCS, terminals, filesystem │
2323└──────────────────┬─────────────────────────────┘
2424 │ per-driver transport
2525┌──────────────────â–¼─────────────────────────────┐
2626│ Agent CLIs: Codex, Claude, Cursor, Grok, │
27│ OpenCode │
27│ OpenCode, Antigravity │
2828└────────────────────────────────────────────────┘
2929```
3030
......@@ -122,8 +122,8 @@ build production behavior on receipts.
122122
123123## Provider drivers
124124
125Five drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`:
126Codex, Claude, Cursor, Grok, and OpenCode. A driver declares its kind and config schema and creates a
125Six drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`:
126Codex, Claude, Cursor, Grok, OpenCode, and Antigravity. A driver declares its kind and config schema and creates a
127127scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves
128128an instance to its adapter, so `ProviderService` routes session and turn operations without knowing
129129which agent is behind them. See [providers.md](./providers.md).
docs/internals/providers.md+164-8
......@@ -7,15 +7,16 @@ orchestration layer does not know which one is behind a thread.
77
88## Built-in drivers
99
10[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries:
10[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries:
1111
12| Driver kind | Driver source |
13| ------------- | --------------------------------------- |
14| `codex` | [`Drivers/CodexDriver.ts`][codex] |
15| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] |
16| `cursor` | [`Drivers/CursorDriver.ts`][cursor] |
17| `grok` | [`Drivers/GrokDriver.ts`][grok] |
18| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] |
12| Driver kind | Driver source |
13| ------------- | --------------------------------------------- |
14| `codex` | [`Drivers/CodexDriver.ts`][codex] |
15| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] |
16| `cursor` | [`Drivers/CursorDriver.ts`][cursor] |
17| `grok` | [`Drivers/GrokDriver.ts`][grok] |
18| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] |
19| `antigravity` | [`Drivers/AntigravityDriver.ts`][antigravity] |
1920
2021Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an
2122adapter in a child scope. Adapter implementations live beside them in
......@@ -54,6 +55,142 @@ list instead of persisting `error` over a working install. The built-in `grok-bu
5455CLI's product name, not an ACP model id. `applyGrokAcpModelSelection` treats it as "keep the
5556session's current model" and never sends it in `session/set_model`.
5657
58## Antigravity ownership and protocol
59
60[`AntigravityDriver`][antigravity] uses Google's official ACP executable. The instance config
61selects the ACP auth method: `oauth-personal` (default), `oauth-business`, `gemini-api-key`, or
62`agent-platform`. The two OAuth methods share the loopback sign-in flow below. The API key
63methods pass the configured key to the agent as `GEMINI_API_KEY` or `GOOGLE_API_KEY` and never
64open a browser. A GCP project and location are written to the profile's `settings.json` on each
65launch. The driver never reuses CLI credentials or ambient `GOOGLE_*` variables and never falls
66back to another method. Antigravity is disabled by default and supports multiple provider
67instances. The open driver and instance identifiers require no database migration.
68
69### Runtime installation
70
71[`AntigravityInstallation`][antigravity-installation] belongs to the environment, outside
72WebSocket and provider-instance scopes. Instances share an explicit download operation and the
73completed runtime. Client disconnects and instance rebuilds do not cancel installation.
74
75The fixed [release table][antigravity-release] contains official Google URLs, SHA-256 hashes,
76archive sizes, and the exact executable pair for each published host. Downloads stream to disk.
77Lazy `yauzl` entry streams extract only that pair, with member names, types, duplicates, and
78sizes checked. Validation runs ACP `initialize` in a temporary profile without authentication
79or a session. Progress updates are bounded, not sent for every network chunk.
80
81Complete releases live in immutable version directories under the T3 home
82`tools/antigravity-acp/<platform>-<arch>/versions`. An atomic `active.json` change selects the
83release for new processes. Each process holds a version lease until it exits. Updates do not
84replace running executables. Removal refuses active leases or explicit binary paths that still
85reference the managed files. Failure or cancellation removes owned partial files, not the
86previous release or account data.
87
88Resolution order is explicit `binaryPath`, active managed release, then the instance's `PATH`.
89An invalid explicit path fails without fallback. Manual installations are never changed by the
90installer. Every launch pins `ANTIGRAVITY_HARNESS_PATH` to the selected executable's sibling.
91
92### Google profiles and sign-in
93
94Each instance owns a stable profile at
95`<stateDir>/providers/antigravity/<sha256(instanceId)>`.
96[`antigravityAuthSupport.ts`][antigravity-auth-support] sets `GEMINI_HOME` to this directory and
97`AGY_ACP_FORCE_FILE_STORAGE=1` after merging instance environment variables. File storage avoids
98the official macOS keychain entry being shared across instances. Profile directories use mode
99`0700` on POSIX. This is file storage, not an encrypted keychain. Windows uses the host profile's
100filesystem permissions.
101
102The launch environment removes API-key and cloud-billing variables, disables inherited
103environment extension, sets `PYTHONUNBUFFERED=1`, and controls `BROWSER`. A tested Node or
104Electron-as-Node helper prevents the official agent from opening a browser on the environment.
105The same launch factory serves setup, health checks, chat, and text generation.
106
107The official agent prints one non-JSON OAuth line on stdout. Only the exact known prefix is
108filtered before ACP decoding. Fragmented lines are joined and bounded. Other malformed
109protocol output remains fatal. Authorization URLs are validated before use. Native stderr is
110drained without logging because it can contain OAuth data. Normal work rejects an interactive
111login request with a sign-in-required error instead of waiting for consent.
112
113[`AntigravityAuth`][antigravity-auth] owns each sign-in process and deadline in the instance
114scope. Only the initiating T3 auth session receives its URL and flow ID or can complete or
115cancel it. Other clients receive busy state without those values. Subscriptions follow
116controller replacement when settings rebuild an instance.
117
118For remote completion, the client sends the full return URL through the typed setup RPC.
119The server validates the pending loopback origin, port, root path, and single matching state
120before forwarding once to the owned listener. It does not probe the listener or follow
121redirects. Google's process owns PKCE, token exchange, refresh, and storage. Callback HTTP
122success is not authentication success. The controller waits for authenticated session setup
123and catalog discovery. Cancellation closes the process instead of sending a synthetic denial.
124
125Auth RPCs `provider.auth.start`, `complete`, `cancel`, `logout`, and `subscribe` require
126`orchestration:operate`. Install `start`, `cancel`, and `remove` use that scope too.
127`provider.install.subscribe` and public provider snapshots require `orchestration:read`.
128[`providerSetup.ts`][provider-setup] defines the operation IDs, states, and safe errors.
129
130Sign-out closes process admission for the instance, stops provider bindings through
131[`ProviderAuthService`][provider-auth-service], then stops owned startup and helper processes.
132A fresh official process calls `initialize` and native `logout` without authenticating.
133Only then does the provider clear auth, models, commands, skills, and workspace metadata.
134Thread history and native session files remain. Settings sign-out and a text-only `/logout`
135use this same path. The command is handled before model prompting or title generation.
136Disabling an instance closes its processes but keeps credentials. Account replacement is
137explicit sign-out followed by sign-in.
138
139### Sessions, models, and client capabilities
140
141[`AntigravityAdapter`][antigravity-adapter] owns one ACP process per active thread. It uses
142native `session/resume` without transcript replay and reapplies the persisted model and
143permission mode after new or resumed setup. An unavailable explicit model fails instead of
144accepting the native default. Steering cancels the previous prompt, waits for its result and
145event drain, then sends the replacement. Native background commands use T3's existing
146background-task state.
147
148The permission mapping is `approval-required` and `auto` to `default`, `auto-accept-edits` to
149`auto_edit`, and `full-access` to `yolo`. Native requests still need replies in `yolo`.
150`interaction_` requests are user questions, not approvals. T3 keeps opaque option IDs in
151`UserInputQuestion.options[].value` and sets `allowCustomAnswer=false`. Both clients preserve
152these values. Ordinary approval replies use only offered option IDs, including `allow_always`
153only when present. Existing providers keep their prior behavior when the optional fields are
154absent.
155
156`showInteractionModeToggle=false` keeps native `/plan` separate from T3 Plan mode.
157`supportsConversationRollback=false` hides unsupported client actions and makes checkpoint
158revert fail before filesystem changes. Checkpoint capture and diffs remain supported.
159
160Automatic status refreshes, reconnects, and workspace checks do not open catalog sessions.
161Health probes use `initialize` only. Disabled instances do not run background probes.
162An explicit `serverRefreshProviders` request with `refreshModels: true` calls the driver's
163optional `refreshModels` operation. Antigravity opens a short-lived catalog session under the
164instance's process admission guard, uses saved credentials, publishes models and commands,
165then closes the process. An interactive login request fails with sign-in required. Web's
166**Refresh provider status** and mobile's **Refresh models** actions request this operation.
167Account access starts unknown and becomes authenticated after successful session setup,
168including an explicit model refresh.
169The [provider snapshot][antigravity-provider] takes models and commands from setup and native
170updates. It preserves returned Gemini model IDs, labels, order, and thinking-level choices.
171The registry treats a successful empty catalog as authoritative and clears cached metadata
172after sign-out. It must not retain a previous account's models. Cached models do not prove
173current access. The auth response does not supply an email, plan tier, or reliable quota.
174
175Some upstream failures arrive as assistant text followed by `end_turn`. Preserve that message
176without treating model-written text as a structured error or successful task completion.
177
178### Text generation
179
180[`AntigravityTextGeneration`][antigravity-text] implements titles, branch names, commit text,
181and PR text through the same instance and Google sign-in. Each helper uses a temporary empty
182workspace, no injected MCP servers, native `default` mode, and explicit denial of tools and
183questions. Output is bounded, parsed against the existing schemas, and sanitized. Cancellation,
184timeout, and sign-out close the process. Cleanup removes only that helper's verified temporary
185native session files.
186
187The official agent has no verified hard no-tools setting. Global hooks and MCP configuration
188can run before a prompt. Helpers check the profile's `config/hooks.json` and
189`config/mcp_config.json` before launch and reject nonempty, malformed, or oversized
190configuration. `supportsTextGeneration=false` keeps such an instance out of system-model
191pickers. Empty managed profiles are supported. Do not describe prompt-time denial as a native
192sandbox.
193
57194## OpenCode server ownership and catalog
58195
59196Each OpenCode provider instance owns one lazy local server for catalog discovery and
......@@ -118,9 +255,18 @@ attachment to the provider adapter. Each adapter decides what its provider inges
118255
119256- Codex, Claude, Cursor, and Grok send images as native image inputs and skip generic files. For
120257 these providers, generic files reach the agent only as file paths in the turn text.
258- Antigravity sends BMP, JPEG, PNG, and WebP images and common audio formats as native blocks,
259 text files as embedded resources, and PDFs as resource links. Other files are rejected with an
260 error instead of being dropped. The session advertises the ACP client file system capability,
261 so workspace reads and writes come back through `fs/read_text_file` and `fs/write_text_file`
262 and are confined to the workspace and the attachments directory.
121263- OpenCode sends PNG/JPEG/GIF/WebP images, text files, and PDFs up to 20 MB as native file parts
122264 with their real mime type. Everything else (ZIP and other binaries, image formats model APIs
123265 reject, oversized files) falls back to the file path in the turn text, like the other providers.
266- Antigravity sends BMP/JPEG/PNG/WebP images as native image blocks, UTF-8 text as embedded
267 resources, and PDFs as local resource links. Text is limited to 1 MiB per file, images to
268 10 MiB each, and all attachments to 50 MiB per turn. Unsupported formats or oversized inputs
269 fail explicitly. Native path permissions still apply to PDFs.
124270
125271Claude receives the attachment directory as an allowed additional directory. Codex keeps its
126272configured sandbox policy, so access depends on that policy and the selected runtime mode. OpenCode
......@@ -180,6 +326,16 @@ when a request opens (approval) or user input is requested, via
180326[cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts
181327[grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts
182328[opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts
329[antigravity]: ../../apps/server/src/provider/Drivers/AntigravityDriver.ts
330[antigravity-adapter]: ../../apps/server/src/provider/Layers/AntigravityAdapter.ts
331[antigravity-provider]: ../../apps/server/src/provider/Layers/AntigravityProvider.ts
332[antigravity-installation]: ../../apps/server/src/provider/AntigravityInstallation.ts
333[antigravity-release]: ../../apps/server/src/provider/antigravityRelease.ts
334[antigravity-auth]: ../../apps/server/src/provider/AntigravityAuth.ts
335[antigravity-auth-support]: ../../apps/server/src/provider/antigravityAuthSupport.ts
336[antigravity-text]: ../../apps/server/src/textGeneration/AntigravityTextGeneration.ts
337[provider-auth-service]: ../../apps/server/src/provider/Layers/ProviderAuthService.ts
338[provider-setup]: ../../packages/contracts/src/providerSetup.ts
183339[opencode-server-owner]: ../../apps/server/src/provider/OpenCodeServerOwner.ts
184340[adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts
185341[instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts
docs/user/install.md+27-17
......@@ -6,7 +6,8 @@ T3 Code is a web and desktop GUI for running coding agents on your machine.
66
77Node.js `^22.16 || ^23.11 || >=24.10` on the machine that runs the T3 Code server.
88
9At least one provider CLI, installed and authenticated. See [Providers](#providers) below.
9At least one provider runtime, installed and authenticated. You can install Antigravity from
10T3 Code settings. See [Providers](#providers) below.
1011
1112## Run Without Installing
1213
......@@ -81,19 +82,25 @@ application files under `/mnt/c` instead and reinstalls the runtime on the next
8182
8283## Providers
8384
84T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want
85to use, then authenticate it.
85T3 Code uses provider runtimes but does not bundle them. Install and authenticate each
86provider's CLI, or use T3 Code's managed setup for Antigravity.
8687
87| Provider | CLI | Default binary | Log in with |
88| ---------- | ----------------------------------------------------- | -------------- | --------------------- |
89| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` |
90| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` |
91| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` |
92| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` |
93| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` |
88| Provider | CLI | Default binary | Log in with |
89| ----------- | ---------------------------------------------------------------------------------------------------------- | ------------------ | ---------------------------------- |
90| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` |
91| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` |
92| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` |
93| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` |
94| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` |
95| Antigravity | [Official ACP agent](https://github.com/agentclientprotocol/registry/blob/main/antigravity-acp/agent.json) | Managed by T3 Code | **Sign in with Google** in T3 Code |
9496
95Codex and Claude are on by default. Cursor, Grok Build, and OpenCode are off by default; turn
96them on in **Settings** → the provider's card when you want to use them.
97Codex and Claude are on by default. Cursor, Grok Build, OpenCode, and Antigravity are off by
98default. Turn them on in **Settings** > **Providers** when you want to use them.
99
100For Antigravity, select the environment in provider settings, then install and sign in there.
101The runtime and credentials stay on that environment, even when you use a phone or remote
102browser. See [Antigravity setup](./providers-antigravity.md) for Google sign-in, remote callback
103steps, and supported hosts.
97104
98105Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that
99106T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`.
......@@ -102,8 +109,8 @@ Grok models that support adjustable reasoning show a **Reasoning** control besid
102109The available levels and default come from the installed Grok Build CLI, so they can vary by model
103110and CLI version.
104111
105Run the login command on the machine running the T3 Code server, not on the device you browse
106from.
112Run CLI login commands on the machine running the T3 Code server, not on the device you browse
113from. Antigravity uses its sign-in controls in T3 Code instead of a CLI login command.
107114
108115### Binary Discovery
109116
......@@ -112,14 +119,17 @@ Each provider CLI must be on the server's `PATH`, or have an explicit binary pat
112119manager or a non-standard install location keeps the CLI off the `PATH` of the shell that
113120started T3 Code.
114121
122Antigravity can use its managed runtime without a `PATH` entry. Its optional **Binary path**
123overrides the managed runtime and must point to the official ACP executable.
124
115125### When Auth Is Needed
116126
117127Provider auth is required before you start a session with that provider, not before you start
118128T3 Code. You can install T3 Code, open it, and add providers afterwards. A provider that is not
119authenticated shows its status in **Settings** and fails at session start with the login command
120to run.
129authenticated shows its status and setup instructions in **Settings**.
121130
122For multi-account setups, see [Codex](./providers-codex.md) and [Claude](./providers-claude.md).
131For multi-account setups, see [Codex](./providers-codex.md), [Claude](./providers-claude.md), and
132[Antigravity](./providers-antigravity.md#accounts-and-removal).
123133
124134## Next Steps
125135
docs/user/permission-modes.md+10-1
......@@ -18,7 +18,7 @@ without prompting; commands and anything else still stop for approval.
1818**Auto**: routine actions proceed without you; risky ones still ask. How this is enforced depends
1919on the provider: Codex delegates routine approvals to an AI reviewer, Claude uses its own auto
2020permission mode, Cursor uses Smart Auto review, and providers without an equivalent (such as
21OpenCode) fall back to asking, like Supervised.
21OpenCode and Antigravity) fall back to asking, like Supervised.
2222
2323**Full access**: allow commands and edits without prompts. The default. The agent runs
2424unattended until it finishes or asks a question of its own.
......@@ -29,6 +29,11 @@ there.
2929For Grok, **Always allow this session** remembers the matching command or tool input. Other
3030actions still ask for approval. It does not change the thread to **Full access**.
3131
32Antigravity uses its own permission policy for each mode. T3 Code still shows any approval or
33question the official agent sends in **Full access**. A remembered approval is available only
34when the agent offers it for that action. Fixed-choice questions require one of the offered
35answers and do not accept custom text.
36
3237## Choosing a Mode
3338
3439Use **Full access** for work in a worktree or a sandbox you can throw away.
......@@ -49,3 +54,7 @@ set to always-approve, and **Full access** starts Grok with always-approve. The
4954describe what you get; the exact per-provider translation is internal and may change.
5055
5156Mobile offers the same four modes with the same labels and descriptions.
57
58Antigravity's native `/plan` command requests a plan. It does not change the permission mode.
59T3 Code's separate Plan mode control is not available for Antigravity. See
60[Antigravity](./providers-antigravity.md) for setup and thread limits.
docs/user/providers-antigravity.md created+174
......@@ -0,0 +1,174 @@
1# Antigravity
2
3T3 Code runs Google's official Antigravity ACP agent. By default it signs in with your personal
4Google account and uses that account's Antigravity access, including access provided by a
5Google AI subscription. It never falls back to a different sign-in method than the one you
6select.
7
8The agent, files, conversation state, and Google credentials stay on the selected environment.
9Your browser or phone controls that environment. Signing in to the Antigravity IDE or CLI does
10not sign in this T3 Code provider.
11
12## Set up Antigravity
13
14On web or desktop, open **Settings** > **Providers**, select the device that runs your project,
15then select **Antigravity**. On mobile, open **Settings** > **Environments**, expand the
16environment, then choose **Set up Antigravity**. An existing setup shows **Manage Antigravity**.
17The model picker can open setup for the selected environment too.
18
191. Choose **Enable Antigravity**. Antigravity is off by default.
202. Choose **Install Antigravity**. T3 Code downloads the official runtime from Google to that
21 environment. Installation continues if you leave the page or reconnect.
223. Choose **Sign in with Google**.
234. Choose **Open sign-in page** on web or desktop, or **Open Google sign-in** on mobile. You can
24 use **Copy sign-in link** to open it in another browser.
255. Complete Google sign-in. Use the account you use for Antigravity.
266. Wait for T3 Code to confirm sign-in and load the model choices. Select an Antigravity model
27 in the thread's model picker.
28
29Setup requires a connection with permission to operate the environment. If setup is unavailable
30on an older server, update that environment first.
31
32### Sign in from a remote device
33
34Google returns to a `127.0.0.1` address on the device running your browser. If that is the same
35machine as the T3 Code environment, sign-in can finish directly.
36
37On a phone or another computer, the final page will usually fail to load. This is expected.
38Copy the full address from the browser, including everything after `?`, and paste it into the
39return URL field in T3 Code. Choose **Continue** on web or desktop, or **Complete sign-in** on
40mobile. Do not change the address to your server's hostname.
41
42Return to the same T3 Code client and environment where you started sign-in. Another client
43can see that sign-in is in progress, but cannot complete that attempt. The setup screen shows
44the expiry time. If it expires, choose **Retry Google sign-in** and use the new link.
45
46The return URL contains a temporary sign-in code. Paste it only into the setup field, not into
47a thread or bug report. T3 Code waits for Google's agent to confirm sign-in. A successful
48callback page alone does not prove account access.
49
50### Other sign-in methods
51
52**Sign-in method** in the Antigravity provider settings on web or desktop selects how the
53agent authenticates. Mobile shows the selected method and its connect controls.
54
55| Method | What you enter | How it signs in |
56| -------------------------- | ---------------------------------------- | ------------------------------- |
57| Google account | Nothing | Google sign-in page |
58| Gemini Enterprise | GCP project and GCP location | Google sign-in page |
59| Gemini API key | API key | Choose **Connect**. No browser. |
60| Agent Platform (Vertex AI) | API key, or GCP project and GCP location | Choose **Connect**. No browser. |
61
62Gemini Enterprise resolves your license for the project and location you enter. Agent Platform
63with a project and location uses Application Default Credentials on the environment. API keys
64are stored in plain text in T3 Code settings on that environment and are passed only to the
65Antigravity agent process. Ambient `GEMINI_API_KEY` or `GOOGLE_*` variables on the environment
66are ignored.
67
68Changing the method stops the instance's sessions. Sign out or disconnect before you switch
69accounts.
70
71## Runtime installation
72
73Managed downloads are available for these environment hosts:
74
75| Host | Architecture |
76| ------- | ------------- |
77| macOS | Apple Silicon |
78| Linux | x64 or ARM64 |
79| Windows | x64 or ARM64 |
80
81Google does not publish a local Intel Mac runtime. Use an Intel Mac as a client connected to
82a supported remote environment.
83
84The current Linux x64 runtime downloads about 543 MB and uses about 1.65 GB after extraction.
85Allow at least 2.5 GB free for installation. An update keeps the previous runtime too.
86T3 Code does not download it until you choose to install it.
87
88On web or desktop, **Update Antigravity** appears when T3 Code has a newer managed release.
89Running sessions keep their current runtime. New sessions use the installed update.
90
91### Use a manual installation
92
93Download the correct archive from the [official ACP Registry][registry] and extract both the
94ACP executable and its `localharness_external` helper into the same directory. Keep both files
95at the same version and make them executable on macOS or Linux. Windows uses `.exe` files.
96
97On web or desktop, set **Binary path** in the Antigravity provider settings to the ACP
98executable on the selected environment. Do not point it at the helper or the Antigravity IDE.
99
100A nonempty **Binary path** takes priority. With the field empty, T3 Code uses its managed
101runtime, then an installation on the environment's `PATH`. An invalid explicit path reports
102an error instead of selecting another installation. T3 Code does not update or remove manual
103installations. Clear **Binary path** to use managed installation controls.
104
105## Models and threads
106
107The current official ACP exposes Gemini models only. T3 Code uses the model IDs and names
108returned for your account, including any model choices with different thinking levels. Models
109available in other Antigravity apps might not be available through this agent.
110
111New threads use Gemini 3.8 Flash (High) when your account offers it. Older Gemini generations
112stay available under **Legacy models** in the picker.
113
114Threads keep their selected model when you resume them. If that model is no longer available,
115T3 Code asks you to select an available model instead of silently changing it.
116
117Use Antigravity's native `/plan` command to request a plan. T3 Code's separate Plan mode control
118is not available for this provider.
119
120Antigravity reads and edits workspace files through T3 Code. Each write shows up as a file
121change approval with the content, so **Supervised** and **Auto-accept edits** behave the same way
122they do for other providers. Attach images, PDFs, text files, or audio clips to a message and
123the agent receives them directly.
124
125When the agent offers **Allow for this thread** on a shell or web tool, T3 Code shows Google's
126prompt injection warning next to that choice. Untrusted content could re-run the same action
127without asking again.
128
129Antigravity can ask you to choose from a fixed set of answers. Select one of the offered
130choices. These questions do not accept custom text and still appear in **Full access** mode.
131See [Permission modes](./permission-modes.md) for tool approvals.
132
133T3 Code keeps thread history and file diffs. Antigravity does not support conversation rewind,
134so reverting a thread or editing and resubmitting an earlier turn is unavailable. Send a
135follow-up message or start a new thread instead.
136
137## Accounts and removal
138
139Each Antigravity provider instance has its own Google sign-in on its environment. Use
140**Add provider** in web or desktop provider settings to create a separate instance for another
141account. To replace an instance's account, sign out first, then sign in again.
142
143| Action | Result |
144| -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
145| Disable Antigravity | Stops the instance's sessions. Keeps Google sign-in, thread history, and files. |
146| Sign out of Google | Stops the instance's sessions and removes its saved Google credentials. Keeps thread history and files. |
147| Remove downloaded runtime on web or desktop, or Remove managed install on mobile | Removes the runtime shared by Antigravity instances on that environment. Keeps Google credentials, thread history, and files. |
148
149Send `/logout` by itself in an Antigravity thread to sign out that provider instance. This has
150the same effect as **Sign out of Google**, including stopping its other sessions.
151
152Before removing a managed runtime, disable the instances that use it and cancel any active
153installation. T3 Code refuses removal while the runtime is in use. An explicit **Binary path**
154that points into the managed runtime must be cleared first.
155
156## Account access and errors
157
158Google controls account eligibility, models, and usage limits. T3 Code does not report your
159paid-plan tier or remaining subscription quota. See Google's [Antigravity plans][plans] and
160[personal Google sign-in guide][google-setup].
161
162After an environment restarts, Google sign-in can show as not checked until an authenticated
163session succeeds. To check account access and reload models, use **Refresh provider status**
164in web or desktop provider settings, or **Refresh models** in the mobile model picker. Refresh
165uses saved Google sign-in and does not open a login page. If sign-in is required, use the
166provider's setup controls. Automatic status checks verify the installation only.
167
168If Google reports `SUBSCRIPTION_REQUIRED`, an account restriction, or a usage limit, read the
169provider's message. A finished turn can contain an upstream error instead of completed work.
170Use any retry time Google supplies. T3 Code does not switch to an API key to get past the limit.
171
172[registry]: https://github.com/agentclientprotocol/registry/blob/main/antigravity-acp/agent.json
173[plans]: https://antigravity.google/docs/plans
174[google-setup]: https://antigravity.google/docs/ide/extensions/zed
docs/user/remote-access.md+15
......@@ -167,6 +167,21 @@ With mise, asdf, fnm, or nodenv, make sure the tool's shim directory is installe
167167
168168If reconnecting after an app update fails, retry the SSH launch once. The launcher now compares its generated runner script, stops stale launcher-managed remote servers, clears the SSH launch PID/port state, and starts a fresh remote server. You should not normally need to delete `~/.t3/ssh-launch` or kill `t3` processes manually.
169169
170## Antigravity Google sign-in
171
172Antigravity runs and saves its Google credentials on the selected environment. You can install
173it and sign in from a remote web, desktop, or mobile client without an SSH login.
174
175Start in **Settings** > **Providers** on web or desktop. On mobile, open **Settings** >
176**Environments**, expand the environment, then choose **Set up Antigravity**.
177
178After Google sign-in, a remote browser usually reaches a `127.0.0.1` page that cannot load.
179Copy that full address into the return URL field in the same T3 Code client. Choose
180**Continue** on web or desktop, or **Complete sign-in** on mobile. Keep the address unchanged.
181Do not paste the return URL into a thread or bug report.
182
183See [Antigravity setup](./providers-antigravity.md) for installation, expiry, and account changes.
184
170185## Updating a Remote Server
171186
172187When the T3 Code web or desktop app and a remote server use different versions, a warning appears in
packages/client-runtime/src/rpc/client.ts+2
......@@ -40,6 +40,8 @@ export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string;
4040type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag];
4141
4242export type EnvironmentSubscriptionRpcTag =
43 | typeof WS_METHODS.providerAuthSubscribe
44 | typeof WS_METHODS.providerInstallSubscribe
4345 | typeof ORCHESTRATION_WS_METHODS.subscribeShell
4446 | typeof ORCHESTRATION_WS_METHODS.subscribeThread
4547 | typeof WS_METHODS.subscribeAuthAccess
packages/client-runtime/src/state/server.ts+52-1
......@@ -806,6 +806,52 @@ export function createServerEnvironmentAtoms<R, E>(
806806 updateStateAtom,
807807 settingsValueAtom,
808808 providersValueAtom,
809 providerAuthState: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
810 label: "environment-data:provider:auth-state",
811 tag: WS_METHODS.providerAuthSubscribe,
812 idleTtlMs: 0,
813 }),
814 startProviderAuth: createEnvironmentRpcCommand(runtime, {
815 label: "environment-data:provider:auth-start",
816 tag: WS_METHODS.providerAuthStart,
817 concurrency: {
818 mode: "singleFlight",
819 key: ({ environmentId, input }) => JSON.stringify([environmentId, input.instanceId]),
820 },
821 }),
822 completeProviderAuth: createEnvironmentRpcCommand(runtime, {
823 label: "environment-data:provider:auth-complete",
824 tag: WS_METHODS.providerAuthComplete,
825 }),
826 cancelProviderAuth: createEnvironmentRpcCommand(runtime, {
827 label: "environment-data:provider:auth-cancel",
828 tag: WS_METHODS.providerAuthCancel,
829 }),
830 logoutProviderAuth: createEnvironmentRpcCommand(runtime, {
831 label: "environment-data:provider:auth-logout",
832 tag: WS_METHODS.providerAuthLogout,
833 }),
834 providerInstallState: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
835 label: "environment-data:provider:install-state",
836 tag: WS_METHODS.providerInstallSubscribe,
837 idleTtlMs: 0,
838 }),
839 startProviderInstall: createEnvironmentRpcCommand(runtime, {
840 label: "environment-data:provider:install-start",
841 tag: WS_METHODS.providerInstallStart,
842 concurrency: {
843 mode: "singleFlight",
844 key: ({ environmentId }) => environmentId,
845 },
846 }),
847 cancelProviderInstall: createEnvironmentRpcCommand(runtime, {
848 label: "environment-data:provider:install-cancel",
849 tag: WS_METHODS.providerInstallCancel,
850 }),
851 removeProviderInstallation: createEnvironmentRpcCommand(runtime, {
852 label: "environment-data:provider:install-remove",
853 tag: WS_METHODS.providerInstallRemove,
854 }),
809855 traceDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, {
810856 label: "environment-data:server:trace-diagnostics",
811857 tag: WS_METHODS.serverGetTraceDiagnostics,
......@@ -850,7 +896,12 @@ export function createServerEnvironmentAtoms<R, E>(
850896 concurrency: {
851897 mode: "singleFlight",
852898 key: ({ environmentId, input }) =>
853 JSON.stringify([environmentId, input.instanceId ?? null, input.cwd ?? null]),
899 JSON.stringify([
900 environmentId,
901 input.instanceId ?? null,
902 input.cwd ?? null,
903 input.refreshModels ?? false,
904 ]),
854905 },
855906 }),
856907 updateProvider: createEnvironmentRpcCommand(runtime, {
packages/contracts/src/index.ts+1
......@@ -12,6 +12,7 @@ export * from "./ipc.ts";
1212export * from "./terminal.ts";
1313export * from "./provider.ts";
1414export * from "./providerInstance.ts";
15export * from "./providerSetup.ts";
1516export * from "./providerRuntime.ts";
1617export * from "./model.ts";
1718export * from "./keybindings.ts";
packages/contracts/src/model.ts+5
......@@ -145,6 +145,8 @@ export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray<string> = [
145145 "gpt-5.6-terra",
146146];
147147export const DEFAULT_TEXT_GENERATION_MODEL = "gpt-5.6-luna";
148/** Keep the official Antigravity session's current model. Never send this ID to ACP. */
149export const ANTIGRAVITY_DEFAULT_MODEL = "antigravity-default";
148150export const DEFAULT_TEXT_GENERATION_REASONING_EFFORT = "low";
149151
150152export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, string>> = {
......@@ -154,6 +156,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, strin
154156 // Product slug, not an ACP model id. The Grok adapter treats it as "the session's current model".
155157 [GROK_DRIVER_KIND]: "grok-build",
156158 [OPENCODE_DRIVER_KIND]: "openai/gpt-5",
159 [ProviderDriverKind.make("antigravity")]: ANTIGRAVITY_DEFAULT_MODEL,
157160};
158161
159162/** Per-provider text generation model defaults. */
......@@ -161,6 +164,7 @@ export const DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER: Partial<
161164 Record<ProviderDriverKind, string>
162165> = {
163166 [CODEX_DRIVER_KIND]: DEFAULT_TEXT_GENERATION_MODEL,
167 [ProviderDriverKind.make("antigravity")]: ANTIGRAVITY_DEFAULT_MODEL,
164168 [CLAUDE_DRIVER_KIND]: "claude-haiku-4-5",
165169 [CURSOR_DRIVER_KIND]: "composer-2",
166170 [OPENCODE_DRIVER_KIND]: "openai/gpt-5",
......@@ -195,6 +199,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial<
195199// ── Provider display names ────────────────────────────────────────────
196200
197201export const PROVIDER_DISPLAY_NAMES: Partial<Record<ProviderDriverKind, string>> = {
202 [ProviderDriverKind.make("antigravity")]: "Antigravity",
198203 [CODEX_DRIVER_KIND]: "Codex",
199204 [CLAUDE_DRIVER_KIND]: "Claude",
200205 [CURSOR_DRIVER_KIND]: "Cursor",
packages/contracts/src/orchestration.ts+2
......@@ -148,6 +148,8 @@ export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type;
148148export const ProviderApprovalOption = Schema.Struct({
149149 decision: ProviderApprovalDecision,
150150 label: TrimmedNonEmptyString,
151 /** Provider-supplied caution shown next to the option, such as a prompt injection warning. */
152 warning: Schema.optional(TrimmedNonEmptyString),
151153});
152154export type ProviderApprovalOption = typeof ProviderApprovalOption.Type;
153155export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unknown);
packages/contracts/src/providerRuntime.ts+2
......@@ -492,6 +492,7 @@ export type RequestResolvedPayload = typeof RequestResolvedPayload.Type;
492492const UserInputQuestionOption = Schema.Struct({
493493 label: TrimmedNonEmptyStringSchema,
494494 description: TrimmedNonEmptyStringSchema,
495 value: Schema.optional(Schema.String),
495496});
496497export type UserInputQuestionOption = typeof UserInputQuestionOption.Type;
497498
......@@ -500,6 +501,7 @@ export const UserInputQuestion = Schema.Struct({
500501 header: TrimmedNonEmptyStringSchema,
501502 question: TrimmedNonEmptyStringSchema,
502503 options: Schema.Array(UserInputQuestionOption),
504 allowCustomAnswer: Schema.optional(Schema.Boolean),
503505 multiSelect: Schema.optional(Schema.Boolean).pipe(
504506 Schema.withConstructorDefault(Effect.succeed(false)),
505507 ),
packages/contracts/src/providerSetup.ts created+86
......@@ -0,0 +1,86 @@
1import * as Schema from "effect/Schema";
2
3import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas.ts";
4import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
5
6export const ProviderSetupInput = Schema.Struct({
7 instanceId: ProviderInstanceId,
8});
9export type ProviderSetupInput = typeof ProviderSetupInput.Type;
10
11const SetupOperationId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));
12
13export const ProviderAuthState = Schema.Struct({
14 instanceId: ProviderInstanceId,
15 phase: Schema.Literals([
16 "idle",
17 "starting",
18 "waiting",
19 "verifying",
20 "succeeded",
21 "failed",
22 "cancelled",
23 ]),
24 flowId: Schema.NullOr(SetupOperationId),
25 authorizationUrl: Schema.NullOr(Schema.String),
26 expiresAt: Schema.NullOr(IsoDateTime),
27 message: Schema.NullOr(Schema.String),
28});
29export type ProviderAuthState = typeof ProviderAuthState.Type;
30
31export const ProviderAuthCompleteInput = Schema.Struct({
32 instanceId: ProviderInstanceId,
33 flowId: SetupOperationId,
34 callbackUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(16_384)),
35});
36export type ProviderAuthCompleteInput = typeof ProviderAuthCompleteInput.Type;
37
38export const ProviderAuthCancelInput = Schema.Struct({
39 instanceId: ProviderInstanceId,
40 flowId: SetupOperationId,
41});
42export type ProviderAuthCancelInput = typeof ProviderAuthCancelInput.Type;
43
44const ByteCount = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
45
46export const ProviderInstallState = Schema.Struct({
47 driver: ProviderDriverKind,
48 operationId: Schema.NullOr(SetupOperationId),
49 phase: Schema.Literals([
50 "idle",
51 "downloading",
52 "extracting",
53 "verifying",
54 "succeeded",
55 "failed",
56 "cancelled",
57 ]),
58 downloadedBytes: ByteCount,
59 totalBytes: Schema.NullOr(ByteCount),
60 version: Schema.NullOr(TrimmedNonEmptyString),
61 installedVersion: Schema.NullOr(TrimmedNonEmptyString),
62 canRemove: Schema.Boolean,
63 message: Schema.NullOr(Schema.String),
64});
65export type ProviderInstallState = typeof ProviderInstallState.Type;
66
67export const ProviderInstallCancelInput = Schema.Struct({
68 instanceId: ProviderInstanceId,
69 operationId: SetupOperationId,
70});
71export type ProviderInstallCancelInput = typeof ProviderInstallCancelInput.Type;
72
73/** Safe setup failure text. Never include OAuth codes, URLs, or native token data. */
74export class ProviderSetupError extends Schema.TaggedErrorClass<ProviderSetupError>()(
75 "ProviderSetupError",
76 {
77 instanceId: ProviderInstanceId,
78 operation: Schema.String,
79 detail: Schema.String,
80 cause: Schema.optional(Schema.Defect()),
81 },
82) {
83 override get message(): string {
84 return this.detail;
85 }
86}
packages/contracts/src/rpc.ts+88-1
......@@ -2,6 +2,15 @@ import * as Schema from "effect/Schema";
22import * as Rpc from "effect/unstable/rpc/Rpc";
33import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
44import { TrimmedNonEmptyString } from "./baseSchemas.ts";
5import {
6 ProviderAuthCancelInput,
7 ProviderAuthCompleteInput,
8 ProviderAuthState,
9 ProviderInstallCancelInput,
10 ProviderInstallState,
11 ProviderSetupError,
12 ProviderSetupInput,
13} from "./providerSetup.ts";
514
615import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts";
716import {
......@@ -233,6 +242,15 @@ export const WS_METHODS = {
233242
234243 // Provider methods
235244 providerUploadFeedback: "provider.uploadFeedback",
245 providerAuthStart: "provider.auth.start",
246 providerAuthComplete: "provider.auth.complete",
247 providerAuthCancel: "provider.auth.cancel",
248 providerAuthLogout: "provider.auth.logout",
249 providerAuthSubscribe: "provider.auth.subscribe",
250 providerInstallStart: "provider.install.start",
251 providerInstallCancel: "provider.install.cancel",
252 providerInstallSubscribe: "provider.install.subscribe",
253 providerInstallRemove: "provider.install.remove",
236254
237255 // VCS methods
238256 vcsPull: "vcs.pull",
......@@ -376,9 +394,11 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv
376394 */
377395 instanceId: Schema.optional(ProviderInstanceId),
378396 cwd: Schema.optional(TrimmedNonEmptyString),
397 /** Explicit user request. Background status refreshes must not open agent sessions. */
398 refreshModels: Schema.optional(Schema.Boolean),
379399 }),
380400 success: ServerProviderUpdatedPayload,
381 error: EnvironmentAuthorizationError,
401 error: Schema.Union([EnvironmentAuthorizationError, ProviderSetupError]),
382402});
383403
384404export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, {
......@@ -387,6 +407,64 @@ export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvide
387407 error: Schema.Union([ServerProviderUpdateError, EnvironmentAuthorizationError]),
388408});
389409
410const ProviderSetupRpcError = Schema.Union([ProviderSetupError, EnvironmentAuthorizationError]);
411
412export const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, {
413 payload: ProviderSetupInput,
414 success: ProviderAuthState,
415 error: ProviderSetupRpcError,
416});
417
418export const WsProviderAuthCompleteRpc = Rpc.make(WS_METHODS.providerAuthComplete, {
419 payload: ProviderAuthCompleteInput,
420 success: ProviderAuthState,
421 error: ProviderSetupRpcError,
422});
423
424export const WsProviderAuthCancelRpc = Rpc.make(WS_METHODS.providerAuthCancel, {
425 payload: ProviderAuthCancelInput,
426 success: ProviderAuthState,
427 error: ProviderSetupRpcError,
428});
429
430export const WsProviderAuthLogoutRpc = Rpc.make(WS_METHODS.providerAuthLogout, {
431 payload: ProviderSetupInput,
432 success: ProviderAuthState,
433 error: ProviderSetupRpcError,
434});
435
436export const WsProviderAuthSubscribeRpc = Rpc.make(WS_METHODS.providerAuthSubscribe, {
437 payload: ProviderSetupInput,
438 success: ProviderAuthState,
439 error: ProviderSetupRpcError,
440 stream: true,
441});
442
443export const WsProviderInstallStartRpc = Rpc.make(WS_METHODS.providerInstallStart, {
444 payload: ProviderSetupInput,
445 success: ProviderInstallState,
446 error: ProviderSetupRpcError,
447});
448
449export const WsProviderInstallCancelRpc = Rpc.make(WS_METHODS.providerInstallCancel, {
450 payload: ProviderInstallCancelInput,
451 success: ProviderInstallState,
452 error: ProviderSetupRpcError,
453});
454
455export const WsProviderInstallSubscribeRpc = Rpc.make(WS_METHODS.providerInstallSubscribe, {
456 payload: ProviderSetupInput,
457 success: ProviderInstallState,
458 error: ProviderSetupRpcError,
459 stream: true,
460});
461
462export const WsProviderInstallRemoveRpc = Rpc.make(WS_METHODS.providerInstallRemove, {
463 payload: ProviderSetupInput,
464 success: ProviderInstallState,
465 error: ProviderSetupRpcError,
466});
467
390468export const WsServerUpdateServerRpc = Rpc.make(WS_METHODS.serverUpdateServer, {
391469 payload: ServerSelfUpdateInput,
392470 success: ServerSelfUpdateResult,
......@@ -1066,6 +1144,15 @@ export const WsRpcGroup = RpcGroup.make(
10661144 WsServerGetConfigRpc,
10671145 WsServerRefreshProvidersRpc,
10681146 WsServerUpdateProviderRpc,
1147 WsProviderAuthStartRpc,
1148 WsProviderAuthCompleteRpc,
1149 WsProviderAuthCancelRpc,
1150 WsProviderAuthLogoutRpc,
1151 WsProviderAuthSubscribeRpc,
1152 WsProviderInstallStartRpc,
1153 WsProviderInstallCancelRpc,
1154 WsProviderInstallSubscribeRpc,
1155 WsProviderInstallRemoveRpc,
10691156 WsServerUpdateServerRpc,
10701157 WsServerUpdateServerWithProgressRpc,
10711158 WsServerCommitDesktopUpdateRpc,
packages/contracts/src/server.ts+8
......@@ -197,6 +197,14 @@ export const ServerProvider = Schema.Struct({
197197 continuation: Schema.optional(ServerProviderContinuation),
198198 showInteractionModeToggle: Schema.optional(Schema.Boolean),
199199 requiresNewThreadForModelChange: Schema.optional(Schema.Boolean),
200 supportsConversationRollback: Schema.optional(Schema.Boolean),
201 supportsTextGeneration: Schema.optional(Schema.Boolean),
202 setup: Schema.optional(
203 Schema.Struct({
204 canAuthenticate: Schema.Boolean,
205 canInstall: Schema.Boolean,
206 }),
207 ),
200208 enabled: Schema.Boolean,
201209 installed: Schema.Boolean,
202210 version: Schema.NullOr(TrimmedNonEmptyString),
packages/contracts/src/settings.ts+103-1
......@@ -365,13 +365,20 @@ const makeBinaryPathSetting = (fallback: string) =>
365365 Schema.withDecodingDefault(Effect.succeed(fallback)),
366366 );
367367
368export type ProviderSettingsFormControl = "text" | "password" | "textarea" | "switch";
368export type ProviderSettingsFormControl = "text" | "password" | "textarea" | "switch" | "select";
369
370export interface ProviderSettingsFormOption {
371 readonly value: string;
372 readonly label: string;
373}
369374
370375export interface ProviderSettingsFormAnnotation {
371376 readonly control?: ProviderSettingsFormControl | undefined;
372377 readonly placeholder?: string | undefined;
373378 readonly hidden?: boolean | undefined;
374379 readonly clearWhenEmpty?: "omit" | "persist" | undefined;
380 /** Choices for a `select` control. The first entry is the default. */
381 readonly options?: ReadonlyArray<ProviderSettingsFormOption> | undefined;
375382}
376383
377384export interface ProviderSettingsFormSchemaAnnotation {
......@@ -585,6 +592,89 @@ export const GrokSettings = makeProviderSettingsSchema(
585592);
586593export type GrokSettings = typeof GrokSettings.Type;
587594
595/**
596 * Antigravity ACP auth methods. Personal and Enterprise open a Google sign-in
597 * in the browser. The API key and Agent Platform methods take credentials from
598 * the instance config and never open a browser.
599 */
600export const ANTIGRAVITY_AUTH_METHODS = [
601 { value: "oauth-personal", label: "Google account" },
602 { value: "oauth-business", label: "Gemini Enterprise" },
603 { value: "gemini-api-key", label: "Gemini API key" },
604 { value: "agent-platform", label: "Agent Platform (Vertex AI)" },
605] as const satisfies ReadonlyArray<ProviderSettingsFormOption>;
606export const AntigravityAuthMethod = Schema.Literals(
607 ANTIGRAVITY_AUTH_METHODS.map((method) => method.value),
608);
609export type AntigravityAuthMethod = typeof AntigravityAuthMethod.Type;
610
611export const AntigravitySettings = makeProviderSettingsSchema(
612 {
613 enabled: Schema.Boolean.pipe(
614 Schema.withDecodingDefault(Effect.succeed(false)),
615 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
616 ),
617 authMethod: AntigravityAuthMethod.pipe(
618 Schema.withDecodingDefault(Effect.succeed("oauth-personal" as const)),
619 Schema.annotateKey({
620 title: "Sign-in method",
621 description:
622 "Google account uses your Antigravity subscription. Gemini Enterprise needs a GCP project and location. API key and Agent Platform bill the credential you enter.",
623 providerSettingsForm: {
624 control: "select",
625 options: ANTIGRAVITY_AUTH_METHODS,
626 clearWhenEmpty: "omit",
627 },
628 }),
629 ),
630 apiKey: TrimmedString.pipe(
631 Schema.withDecodingDefault(Effect.succeed("")),
632 Schema.annotateKey({
633 title: "API key",
634 description:
635 "Gemini API key, or a Vertex AI express key for Agent Platform. Stored in plain text on this environment.",
636 providerSettingsForm: {
637 control: "password",
638 placeholder: "Optional",
639 clearWhenEmpty: "omit",
640 },
641 }),
642 ),
643 gcpProject: TrimmedString.pipe(
644 Schema.withDecodingDefault(Effect.succeed("")),
645 Schema.annotateKey({
646 title: "GCP project",
647 description:
648 "Required for Gemini Enterprise. Agent Platform uses it when no API key is set.",
649 providerSettingsForm: { placeholder: "my-project-id", clearWhenEmpty: "omit" },
650 }),
651 ),
652 gcpLocation: TrimmedString.pipe(
653 Schema.withDecodingDefault(Effect.succeed("")),
654 Schema.annotateKey({
655 title: "GCP location",
656 description: "Region for Gemini Enterprise or Agent Platform, such as us-central1.",
657 providerSettingsForm: { placeholder: "us-central1", clearWhenEmpty: "omit" },
658 }),
659 ),
660 binaryPath: TrimmedString.pipe(
661 Schema.withDecodingDefault(Effect.succeed("")),
662 Schema.annotateKey({
663 title: "Binary path",
664 description:
665 "Optional path to the official Antigravity ACP executable. Leave empty for automatic selection.",
666 providerSettingsForm: { placeholder: "Automatic", clearWhenEmpty: "persist" },
667 }),
668 ),
669 customModels: Schema.Array(Schema.String).pipe(
670 Schema.withDecodingDefault(Effect.succeed([])),
671 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
672 ),
673 },
674 { order: ["authMethod", "apiKey", "gcpProject", "gcpLocation", "binaryPath"] },
675);
676export type AntigravitySettings = typeof AntigravitySettings.Type;
677
588678export const OpenCodeSettings = makeProviderSettingsSchema(
589679 {
590680 // Off by default (like Cursor and Grok): the binding is not yet stable
......@@ -804,6 +894,7 @@ export const ServerSettings = Schema.Struct({
804894 cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
805895 grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
806896 opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
897 antigravity: AntigravitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
807898 }).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
808899 // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values
809900 // are `ProviderInstanceConfig` envelopes. The driver-specific config blob
......@@ -949,6 +1040,16 @@ const GrokSettingsPatch = Schema.Struct({
9491040 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
9501041});
9511042
1043const AntigravitySettingsPatch = Schema.Struct({
1044 enabled: Schema.optionalKey(Schema.Boolean),
1045 authMethod: Schema.optionalKey(AntigravityAuthMethod),
1046 apiKey: Schema.optionalKey(TrimmedString),
1047 gcpProject: Schema.optionalKey(TrimmedString),
1048 gcpLocation: Schema.optionalKey(TrimmedString),
1049 binaryPath: Schema.optionalKey(TrimmedString),
1050 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
1051});
1052
9521053const OpenCodeSettingsPatch = Schema.Struct({
9531054 enabled: Schema.optionalKey(Schema.Boolean),
9541055 binaryPath: Schema.optionalKey(TrimmedString),
......@@ -1001,6 +1102,7 @@ export const ServerSettingsPatch = Schema.Struct({
10011102 cursor: Schema.optionalKey(CursorSettingsPatch),
10021103 grok: Schema.optionalKey(GrokSettingsPatch),
10031104 opencode: Schema.optionalKey(OpenCodeSettingsPatch),
1105 antigravity: Schema.optionalKey(AntigravitySettingsPatch),
10041106 }),
10051107 ),
10061108 // Whole-map replacement for the new instance config. Patching individual
packages/effect-acp/src/_internal/shared.ts+8-5
......@@ -5,6 +5,7 @@ import { RpcClientError } from "effect/unstable/rpc";
55import * as AcpSchema from "../_generated/schema.gen.ts";
66import * as AcpError from "../errors.ts";
77const isError = Schema.is(AcpSchema.Error);
8const isAcpError = Schema.is(AcpError.AcpError);
89
910export const callRpc = <A>(
1011 method: string,
......@@ -17,11 +18,13 @@ export const callRpc = <A>(
1718 Effect.catchTags({
1819 RpcClientError: (cause) =>
1920 Effect.fail(
20 new AcpError.AcpTransportError({
21 operation: "call-rpc",
22 method,
23 cause,
24 }),
21 cause.reason._tag === "RpcClientDefect" && isAcpError(cause.reason.cause)
22 ? cause.reason.cause
23 : new AcpError.AcpTransportError({
24 operation: "call-rpc",
25 method,
26 cause,
27 }),
2528 ),
2629 }),
2730 );
packages/effect-acp/src/client.test.ts+204
......@@ -1,5 +1,6 @@
11import * as Path from "effect/Path";
22import * as Cause from "effect/Cause";
3import * as Deferred from "effect/Deferred";
34import * as Effect from "effect/Effect";
45import * as Exit from "effect/Exit";
56import * as Fiber from "effect/Fiber";
......@@ -31,6 +32,10 @@ const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String
3132const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean }));
3233const PromptRequest = jsonRpcRequest("session/prompt", AcpSchema.PromptRequest);
3334const PromptResponse = jsonRpcResponse(AcpSchema.PromptResponse);
35const SessionUpdateNotification = jsonRpcNotification(
36 "session/update",
37 AcpSchema.SessionNotification,
38);
3439const decodePromptRequestLine = Schema.decodeEffect(Schema.fromJsonString(PromptRequest));
3540const XAiPromptCompleteNotification = jsonRpcNotification(
3641 "_x.ai/session/prompt_complete",
......@@ -59,6 +64,15 @@ const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) =>
5964 path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"),
6065);
6166const mockPeerArgs = (path: string) => [path];
67const mockStartupNotice = "Mock ACP startup notice";
68const stripMockStartupNotice = (stdout: ChildProcessSpawner.ChildProcessHandle["stdout"]) =>
69 stdout.pipe(
70 Stream.decodeText,
71 Stream.splitLines,
72 Stream.filter((line) => line !== mockStartupNotice),
73 Stream.map((line) => `${line}\n`),
74 Stream.encodeText,
75 );
6276
6377function concatBytes(chunks: ReadonlyArray<Uint8Array>): Uint8Array {
6478 const batch = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0));
......@@ -82,6 +96,196 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => {
8296 return yield* spawner.spawn(command);
8397 });
8498
99 it.effect("transforms fragmented stdout before parsing and preserves UTF-8", () =>
100 Effect.gen(function* () {
101 const handle = yield* makeHandle({ ACP_MOCK_STDOUT_PREFIX: `${mockStartupNotice}\n` });
102 yield* Effect.gen(function* () {
103 const acp = yield* AcpClient.AcpClient;
104 const initialized = yield* acp.agent.initialize({ protocolVersion: 1 });
105 assert.equal(initialized.protocolVersion, 1);
106
107 const echoed = yield* acp.raw.request("x/echo", { message: "café" });
108 assert.deepEqual(echoed, {
109 echoedMethod: "x/echo",
110 echoedParams: { message: "café" },
111 });
112 }).pipe(
113 Effect.provide(
114 AcpClient.layerChildProcess(handle, {
115 transformStdout: (stdout) =>
116 stdout.pipe(
117 Stream.flatMap((chunk) =>
118 Stream.fromIterable(Array.from(chunk, (byte) => Uint8Array.of(byte))),
119 ),
120 stripMockStartupNotice,
121 ),
122 }),
123 ),
124 );
125 }),
126 );
127
128 it.effect("reports malformed JSON that remains after a stdout transform", () =>
129 Effect.gen(function* () {
130 const handle = yield* makeHandle({
131 ACP_MOCK_STDOUT_PREFIX: `${mockStartupNotice}\n`,
132 ACP_MOCK_MALFORMED_OUTPUT: "1",
133 });
134 const termination = yield* Deferred.make<AcpError.AcpError>();
135 const error = yield* Deferred.await(termination).pipe(
136 Effect.provide(
137 AcpClient.layerChildProcess(handle, {
138 transformStdout: stripMockStartupNotice,
139 onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid),
140 }),
141 ),
142 );
143
144 if (error._tag !== "AcpProtocolParseError") {
145 return assert.fail(`Expected a parse error, got ${error._tag}`);
146 }
147 assert.equal(error.operation, "decode-wire-message");
148 }),
149 );
150
151 it.effect("preserves stdout transform errors without logging rejected input", () =>
152 Effect.gen(function* () {
153 const privateNotice = "Mock sign-in URL: https://example.test/login?token=mock-private-token";
154 const handle = yield* makeHandle({ ACP_MOCK_STDOUT_PREFIX: `${privateNotice}\n` });
155 const termination = yield* Deferred.make<AcpError.AcpError>();
156 const logs = yield* Ref.make<Array<unknown>>([]);
157 const expectedError = new AcpError.AcpTransportError({
158 operation: "read-input-stream",
159 detail: "Sign in to the mock agent.",
160 cause: undefined,
161 });
162 const error = yield* Effect.gen(function* () {
163 const acp = yield* AcpClient.AcpClient;
164 const failure = yield* acp.agent.initialize({ protocolVersion: 1 }).pipe(Effect.flip);
165 assert.strictEqual(yield* Deferred.await(termination), expectedError);
166 return failure;
167 }).pipe(
168 Effect.provide(
169 AcpClient.layerChildProcess(handle, {
170 logIncoming: true,
171 logger: (event) => Ref.update(logs, (current) => [...current, event]),
172 transformStdout: (stdout) =>
173 stdout.pipe(
174 Stream.decodeText,
175 Stream.splitLines,
176 Stream.mapEffect((line) =>
177 line === privateNotice ? Effect.fail(expectedError) : Effect.succeed(`${line}\n`),
178 ),
179 Stream.encodeText,
180 ),
181 onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid),
182 }),
183 ),
184 );
185
186 assert.strictEqual(error, expectedError);
187 assert.isEmpty(yield* Ref.get(logs));
188 }),
189 );
190
191 it.effect("retains only transformed session updates in raw notifications and handlers", () =>
192 Effect.gen(function* () {
193 const { stdio, input } = yield* makeInMemoryStdio();
194 const delivered = yield* Deferred.make<AcpSchema.SessionNotification>();
195 const imageData = "A".repeat(1_048_576);
196 const normalized = {
197 sessionId: "session-1",
198 update: {
199 sessionUpdate: "agent_message_chunk",
200 content: { type: "text", text: "Image data omitted." },
201 },
202 } satisfies AcpSchema.SessionNotification;
203 let transformCalls = 0;
204 const acp = yield* AcpClient.make(stdio, {
205 transformSessionUpdate: (notification) => {
206 if (
207 notification.update.sessionUpdate !== "agent_message_chunk" ||
208 notification.update.content.type !== "image"
209 ) {
210 return notification;
211 }
212 assert.equal(notification.update.content.data, imageData);
213 transformCalls += 1;
214 return normalized;
215 },
216 });
217 yield* acp.handleSessionUpdate((notification) =>
218 Deferred.succeed(delivered, notification).pipe(Effect.asVoid),
219 );
220
221 yield* Queue.offer(
222 input,
223 yield* encodeJsonl(SessionUpdateNotification, {
224 jsonrpc: "2.0",
225 method: "session/update",
226 params: {
227 sessionId: "session-1",
228 update: {
229 sessionUpdate: "agent_message_chunk",
230 content: { type: "image", data: imageData, mimeType: "image/png" },
231 },
232 },
233 }),
234 );
235
236 assert.strictEqual(yield* Deferred.await(delivered), normalized);
237 const [retained] = yield* acp.raw.notifications.pipe(Stream.take(1), Stream.runCollect);
238 assert.deepEqual(retained, {
239 _tag: "SessionUpdate",
240 method: "session/update",
241 params: normalized,
242 });
243 assert.equal(transformCalls, 1);
244 }),
245 );
246
247 it.effect("reports idle child termination and rejects later requests and notifications", () =>
248 Effect.gen(function* () {
249 const handle = yield* makeHandle();
250 const termination = yield* Deferred.make<AcpError.AcpError>();
251 yield* Effect.gen(function* () {
252 const acp = yield* AcpClient.AcpClient;
253 yield* acp.agent.initialize({ protocolVersion: 1 });
254 yield* handle.kill();
255
256 const error = yield* Deferred.await(termination);
257 if (error._tag !== "AcpProcessExitedError") {
258 return assert.fail(`Expected a process exit, got ${error._tag}`);
259 }
260 assert.equal(error.pid, handle.pid);
261 assert.equal(error.code, yield* handle.exitCode);
262 const rawRequestError = yield* acp.raw.request("x/echo", {}).pipe(
263 Effect.match({
264 onFailure: (failure) => failure,
265 onSuccess: () => assert.fail("Expected the request to fail after process exit"),
266 }),
267 );
268 assert.strictEqual(rawRequestError, error);
269 assert.strictEqual(yield* acp.raw.notify("x/notify", {}).pipe(Effect.flip), error);
270 assert.strictEqual(
271 yield* acp.agent.cancel({ sessionId: "mock-session-1" }).pipe(Effect.flip),
272 error,
273 );
274
275 const requestError = yield* acp.agent
276 .createSession({ cwd: process.cwd(), mcpServers: [] })
277 .pipe(Effect.flip);
278 assert.strictEqual(requestError, error);
279 }).pipe(
280 Effect.provide(
281 AcpClient.layerChildProcess(handle, {
282 onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid),
283 }),
284 ),
285 );
286 }),
287 );
288
85289 it.effect("initializes, prompts, receives updates, and handles permission requests", () =>
86290 Effect.gen(function* () {
87291 const updates = yield* Ref.make<Array<unknown>>([]);
packages/effect-acp/src/client.ts+23-5
......@@ -1,6 +1,5 @@
11import * as Context from "effect/Context";
22import * as Effect from "effect/Effect";
3import * as Stdio from "effect/Stdio";
43import * as Layer from "effect/Layer";
54import * as Schema from "effect/Schema";
65import * as Scope from "effect/Scope";
......@@ -27,6 +26,16 @@ export interface AcpClientOptions {
2726 readonly logIncoming?: boolean;
2827 readonly logOutgoing?: boolean;
2928 readonly logger?: (event: AcpProtocol.AcpProtocolLogEvent) => Effect.Effect<void, never>;
29 /** Transforms child output before protocol logging and parsing. */
30 readonly transformStdout?: (
31 stdout: ChildProcessSpawner.ChildProcessHandle["stdout"],
32 ) => AcpProtocol.AcpStdio["stdin"];
33 /** Transforms decoded session updates before buffering or delivery. */
34 readonly transformSessionUpdate?: (
35 notification: AcpSchema.SessionNotification,
36 ) => AcpSchema.SessionNotification;
37 /** Reports input failures and process exits, even between requests. */
38 readonly onTermination?: (error: AcpError.AcpError) => Effect.Effect<void, never, never>;
3039}
3140
3241type AcpClientRaw = {
......@@ -307,7 +316,7 @@ interface BufferedNotificationHandler<A> {
307316}
308317
309318export const make = Effect.fn("effect-acp/AcpClient.make")(function* (
310 stdio: Stdio.Stdio,
319 stdio: AcpProtocol.AcpStdio,
311320 options: AcpClientOptions = {},
312321 terminationError?: Effect.Effect<AcpError.AcpError>,
313322): Effect.fn.Return<AcpClient["Service"], never, Scope.Scope> {
......@@ -404,6 +413,10 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* (
404413 ...(options.logIncoming !== undefined ? { logIncoming: options.logIncoming } : {}),
405414 ...(options.logOutgoing !== undefined ? { logOutgoing: options.logOutgoing } : {}),
406415 ...(options.logger ? { logger: options.logger } : {}),
416 ...(options.transformSessionUpdate
417 ? { transformSessionUpdate: options.transformSessionUpdate }
418 : {}),
419 ...(options.onTermination ? { onTermination: options.onTermination } : {}),
407420 onNotification: dispatchNotification,
408421 onExtRequest: dispatchExtRequest,
409422 });
......@@ -572,14 +585,19 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* (
572585 });
573586});
574587
575export const layer = (stdio: Stdio.Stdio, options: AcpClientOptions = {}): Layer.Layer<AcpClient> =>
576 Layer.effect(AcpClient, make(stdio, options));
588export const layer = (
589 stdio: AcpProtocol.AcpStdio,
590 options: AcpClientOptions = {},
591): Layer.Layer<AcpClient> => Layer.effect(AcpClient, make(stdio, options));
577592
578593export const layerChildProcess = (
579594 handle: ChildProcessSpawner.ChildProcessHandle,
580595 options: AcpClientOptions = {},
581596): Layer.Layer<AcpClient> => {
582 const stdio = makeChildStdio(handle);
597 const stdio = {
598 ...makeChildStdio(handle),
599 stdin: options.transformStdout?.(handle.stdout) ?? handle.stdout,
600 };
583601 const terminationError = makeTerminationError(handle);
584602 return Layer.effect(AcpClient, make(stdio, options, terminationError));
585603};
packages/effect-acp/src/errors.test.ts+18
......@@ -36,6 +36,24 @@ describe("effect-acp errors", () => {
3636 });
3737 });
3838
39 it.effect("preserves typed ACP failures carried by the RPC protocol", () => {
40 const original = new AcpError.AcpTransportError({
41 detail: "Sign in to the agent.",
42 cause: undefined,
43 });
44 const failure = new RpcClientError.RpcClientError({
45 reason: new RpcClientError.RpcClientDefect({
46 message: "ACP protocol terminated.",
47 cause: original,
48 }),
49 });
50
51 return Effect.gen(function* () {
52 const error = yield* callRpc("initialize", Effect.fail(failure)).pipe(Effect.flip);
53 expect(error).toBe(original);
54 });
55 });
56
3957 it.effect("preserves protocol request errors as request errors", () => {
4058 const failure = AcpSchema.Error.make({
4159 code: -32602,
packages/effect-acp/src/protocol.test.ts+48
......@@ -175,9 +175,14 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
175175 const secret = "acp-core-notification-secret-sentinel";
176176 const { stdio, input } = yield* makeInMemoryStdio();
177177 const termination = yield* Deferred.make<AcpError.AcpError>();
178 let transformCalls = 0;
178179 yield* AcpProtocol.makeAcpPatchedProtocol({
179180 stdio,
180181 serverRequestMethods: new Set(),
182 transformSessionUpdate: (notification) => {
183 transformCalls += 1;
184 return notification;
185 },
181186 onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid),
182187 });
183188
......@@ -199,6 +204,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
199204 );
200205
201206 const error = yield* Deferred.await(termination);
207 assert.equal(transformCalls, 0);
202208 assert.instanceOf(error, AcpError.AcpProtocolParseError);
203209 const parseError = error as AcpError.AcpProtocolParseError;
204210 const { cause, ...directDiagnostics } = parseError;
......@@ -719,4 +725,46 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
719725 assert.equal(error.code, 0);
720726 }),
721727 );
728
729 for (const operation of ["request", "notification"] as const) {
730 it.effect(`rejects a ${operation} if the connection ends while its logger is running`, () =>
731 Effect.gen(function* () {
732 const { stdio, input, output } = yield* makeInMemoryStdio();
733 const writeStarted = yield* Deferred.make<void>();
734 const releaseWrite = yield* Deferred.make<void>();
735 const terminated = yield* Deferred.make<AcpError.AcpError>();
736 const transport = yield* AcpProtocol.makeAcpPatchedProtocol({
737 stdio,
738 serverRequestMethods: new Set(),
739 logOutgoing: true,
740 logger: (event) =>
741 event.stage === "raw"
742 ? Deferred.succeed(writeStarted, undefined).pipe(
743 Effect.andThen(Deferred.await(releaseWrite)),
744 )
745 : Effect.void,
746 onTermination: (error) => Deferred.succeed(terminated, error).pipe(Effect.asVoid),
747 });
748 const send = yield* (
749 operation === "request"
750 ? transport.request("x/test", { hello: "world" })
751 : transport.notify("session/cancel", { sessionId: "session-1" })
752 ).pipe(Effect.forkScoped);
753
754 yield* Deferred.await(writeStarted);
755 yield* Queue.end(input);
756 const error = yield* Deferred.await(terminated);
757 yield* Deferred.succeed(releaseWrite, undefined);
758
759 const failure = yield* Fiber.join(send).pipe(
760 Effect.match({
761 onFailure: (failure) => failure,
762 onSuccess: () => assert.fail("Expected the send to fail after termination"),
763 }),
764 );
765 assert.strictEqual(failure, error);
766 assert.equal(yield* Queue.size(output), 0);
767 }),
768 );
769 }
722770});
packages/effect-acp/src/protocol.ts+23-6
......@@ -4,6 +4,7 @@ import * as Deferred from "effect/Deferred";
44import * as Exit from "effect/Exit";
55import * as Queue from "effect/Queue";
66import * as Ref from "effect/Ref";
7import type * as PlatformError from "effect/PlatformError";
78import * as Schema from "effect/Schema";
89import * as Scope from "effect/Scope";
910import * as Stream from "effect/Stream";
......@@ -42,13 +43,21 @@ export type AcpIncomingNotification =
4243 readonly params: unknown;
4344 };
4445
46/** Standard I/O whose input can report provider-specific ACP failures. */
47export interface AcpStdio extends Omit<Stdio.Stdio, "stdin"> {
48 readonly stdin: Stream.Stream<Uint8Array, PlatformError.PlatformError | AcpError.AcpError>;
49}
50
4551export interface AcpPatchedProtocolOptions {
46 readonly stdio: Stdio.Stdio;
52 readonly stdio: AcpStdio;
4753 readonly terminationError?: Effect.Effect<AcpError.AcpError>;
4854 readonly serverRequestMethods: ReadonlySet<string>;
4955 readonly logIncoming?: boolean;
5056 readonly logOutgoing?: boolean;
5157 readonly logger?: (event: AcpProtocolLogEvent) => Effect.Effect<void, never>;
58 readonly transformSessionUpdate?: (
59 notification: AcpSchema.SessionNotification,
60 ) => AcpSchema.SessionNotification;
5261 readonly onNotification?: (
5362 notification: AcpIncomingNotification,
5463 ) => Effect.Effect<void, AcpError.AcpError, never>;
......@@ -102,8 +111,13 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
102111 const outgoing = yield* Queue.unbounded<string | Uint8Array, Cause.Done<void>>();
103112 const nextRequestId = yield* Ref.make(1);
104113 const terminationHandled = yield* Ref.make(false);
114 const terminationFailure = yield* Deferred.make<never, AcpError.AcpError>();
105115 const extPending = yield* Ref.make(new Map<string, AcpPendingRequest>());
106116
117 const ensureActive = Ref.get(terminationHandled).pipe(
118 Effect.flatMap((terminated) => (terminated ? Deferred.await(terminationFailure) : Effect.void)),
119 );
120
107121 const logProtocol = (event: AcpProtocolLogEvent) => {
108122 if (event.direction === "incoming" && !options.logIncoming) {
109123 return Effect.void;
......@@ -125,6 +139,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
125139 if (message._tag === "Interrupt") {
126140 return;
127141 }
142 yield* ensureActive;
128143 yield* logProtocol({
129144 direction: "outgoing",
130145 stage: "decoded",
......@@ -151,6 +166,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
151166 payload: typeof encoded === "string" ? encoded : new TextDecoder().decode(encoded),
152167 });
153168
169 yield* ensureActive;
154170 yield* Queue.offer(outgoing, encoded).pipe(Effect.asVoid);
155171 }
156172 });
......@@ -217,7 +233,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
217233 }),
218234 }).pipe(Effect.asVoid);
219235
220 const handleTermination = (classify: () => Effect.Effect<AcpError.AcpError | undefined>) =>
236 const handleTermination = (classify: () => Effect.Effect<AcpError.AcpError>) =>
221237 Ref.modify(terminationHandled, (handled) => {
222238 if (handled) {
223239 return [Effect.void, true] as const;
......@@ -226,9 +242,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
226242 Effect.gen(function* () {
227243 yield* Queue.offer(disconnects, 0);
228244 const error = yield* classify();
229 if (!error) {
230 return;
231 }
245 yield* Deferred.fail(terminationFailure, error);
232246 yield* failAllExtPending(error);
233247 yield* emitClientProtocolError(error);
234248 if (options.onTermination) {
......@@ -289,7 +303,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
289303 ({
290304 _tag: "SessionUpdate",
291305 method: CLIENT_METHODS.session_update,
292 params,
306 params: options.transformSessionUpdate?.(params) ?? params,
293307 }) satisfies AcpIncomingNotification,
294308 ),
295309 Effect.mapError((cause) =>
......@@ -544,6 +558,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
544558 method: string,
545559 payload: unknown,
546560 ) {
561 yield* ensureActive;
547562 yield* logProtocol({
548563 direction: "outgoing",
549564 stage: "decoded",
......@@ -559,10 +574,12 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
559574 }
560575 const encoded = `${exit.value}\n`;
561576 yield* logProtocol({ direction: "outgoing", stage: "raw", payload: encoded });
577 yield* ensureActive;
562578 yield* Queue.offer(outgoing, encoded);
563579 });
564580
565581 const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) {
582 yield* ensureActive;
566583 const requestId = yield* Ref.modify(
567584 nextRequestId,
568585 (current) => [current, current + 1] as const,
packages/effect-acp/test/fixtures/acp-mock-peer.ts+4
......@@ -6,6 +6,10 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
66
77import * as AcpAgent from "../../src/agent.ts";
88
9if (process.env.ACP_MOCK_STDOUT_PREFIX !== undefined) {
10 process.stdout.write(process.env.ACP_MOCK_STDOUT_PREFIX);
11}
12
913if (process.env.ACP_MOCK_MALFORMED_OUTPUT === "1") {
1014 process.stdout.write("{not-json}\n");
1115 process.exit(Number(process.env.ACP_MOCK_MALFORMED_OUTPUT_EXIT_CODE ?? "0"));
pnpm-lock.yaml+26
......@@ -505,6 +505,9 @@ importers:
505505 yaml:
506506 specifier: ^2.9.0
507507 version: 2.9.0
508 yauzl:
509 specifier: ^3.4.0
510 version: 3.4.0
508511 devDependencies:
509512 '@effect/vitest':
510513 specifier: 4.0.0-beta.103
......@@ -527,6 +530,9 @@ importers:
527530 '@types/node':
528531 specifier: 24.12.4
529532 version: 24.12.4
533 '@types/yauzl':
534 specifier: ^3.4.0
535 version: 3.4.0
530536 effect-acp:
531537 specifier: workspace:*
532538 version: link:../../packages/effect-acp
......@@ -4793,6 +4799,9 @@ packages:
47934799 '@types/yargs@17.0.35':
47944800 resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==}
47954801
4802 '@types/yauzl@3.4.0':
4803 resolution: {integrity: sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==}
4804
47964805 '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260604.1':
47974806 resolution: {integrity: sha512-zs616um9UuaODLsNlCu5Aw95rFcTV4u3hVt090r6k0lVvTxfaJOv8HKA6BpIotcEYlZlMQowrMSYCCdedo7iyA==}
47984807 engines: {node: '>=16.20.0'}
......@@ -8527,6 +8536,9 @@ packages:
85278536 resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==}
85288537 engines: {node: '>=12', npm: '>=6'}
85298538
8539 pend@1.2.0:
8540 resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
8541
85308542 pg-cloudflare@1.4.0:
85318543 resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
85328544
......@@ -10339,6 +10351,10 @@ packages:
1033910351 resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
1034010352 engines: {node: '>=12'}
1034110353
10354 yauzl@3.4.0:
10355 resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==}
10356 engines: {node: '>=12'}
10357
1034210358 yjs@13.6.31:
1034310359 resolution: {integrity: sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==}
1034410360 engines: {node: '>=16.0.0', npm: '>=8.0.0'}
......@@ -14619,6 +14635,10 @@ snapshots:
1461914635 dependencies:
1462014636 '@types/yargs-parser': 21.0.3
1462114637
14638 '@types/yauzl@3.4.0':
14639 dependencies:
14640 '@types/node': 24.12.4
14641
1462214642 '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260604.1':
1462314643 optional: true
1462414644
......@@ -19064,6 +19084,8 @@ snapshots:
1906419084
1906519085 pe-library@0.4.1: {}
1906619086
19087 pend@1.2.0: {}
19088
1906719089 pg-cloudflare@1.4.0:
1906819090 optional: true
1906919091
......@@ -21124,6 +21146,10 @@ snapshots:
2112421146 y18n: 5.0.8
2112521147 yargs-parser: 21.1.1
2112621148
21149 yauzl@3.4.0:
21150 dependencies:
21151 pend: 1.2.0
21152
2112721153 yjs@13.6.31:
2112821154 dependencies:
2112921155 lib0: 0.2.117