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 @@...@@ -1,6 +1,6 @@
1# T3 Code1# 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
5You 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.5You 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...@@ -68,7 +68,7 @@ The most common defect in this repo is a change that works on the path you teste
6868
69- **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.69- **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.
70- **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime`70- **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".
72- **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow.72- **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow.
73- **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.73- **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.
74- **Connection modes.** Local, remote/relay, and tunnel behave differently. Multi-device and multi-environment cases are real.74- **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 @@...@@ -2,7 +2,7 @@
22
3T3 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).3T3 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
7## "Wait, what are you selling me?"7## "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...@@ -13,13 +13,14 @@ We wanted something performant, remote-ready, and truly open. If we ever go the
13## Installation13## Installation
1414
15> [!WARNING]15> [!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:
17>17>
18> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`18> - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login`
19> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login`19> - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login`
20> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login`20> - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login`
21> - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login`21> - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login`
22> - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login`22> - 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
24### Try it out (install-free)25### 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...@@ -56,6 +56,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc
56import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";56import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
57import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";57import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen";
58import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen";58import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen";
59import { SettingsProviderSetupRouteScreen } from "./features/settings/SettingsProviderSetupRouteScreen";
59import { UsageRouteScreen } from "./features/usage/UsageRouteScreen";60import { UsageRouteScreen } from "./features/usage/UsageRouteScreen";
60import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";61import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
61import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator";62import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator";
...@@ -164,6 +165,13 @@ const SettingsContentStack = createNativeStackNavigator({...@@ -164,6 +165,13 @@ const SettingsContentStack = createNativeStackNavigator({
164 title: "Add Environment",165 title: "Add Environment",
165 },166 },
166 }),167 }),
168 SettingsProviderSetup: createNativeStackScreen({
169 screen: SettingsProviderSetupRouteScreen,
170 linking: "providers/:environmentId/:instanceId",
171 options: {
172 title: "Antigravity",
173 },
174 }),
167 SettingsArchive: createNativeStackScreen({175 SettingsArchive: createNativeStackScreen({
168 screen: ArchivedThreadsRouteScreen,176 screen: ArchivedThreadsRouteScreen,
169 linking: "archive",177 linking: "archive",
apps/mobile/src/components/ProviderIcon.tsx+11
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { Image } from "expo-image";
1import { Path, Svg } from "react-native-svg";2import { Path, Svg } from "react-native-svg";
2import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";3import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
34
...@@ -12,6 +13,16 @@ export function ProviderIcon(props: ProviderIconProps) {...@@ -12,6 +13,16 @@ export function ProviderIcon(props: ProviderIconProps) {
12 const size = props.size ?? 16;13 const size = props.size ?? 16;
13 const mono = isDarkMode ? "#e5e5e5" : "#171717";14 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
15 if (props.provider === "claudeAgent") {26 if (props.provider === "claudeAgent") {
16 return (27 return (
17 <Svg width={size} height={size} viewBox="0 0 256 257" fill="none">28 <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() {...@@ -111,6 +111,12 @@ function ConfiguredConnectOnboardingRouteScreen() {
111 <CloudEnvironmentRows111 <CloudEnvironmentRows
112 connectedCloudEnvironments={connectedCloudEnvironments}112 connectedCloudEnvironments={connectedCloudEnvironments}
113 onReconnectEnvironment={onReconnectEnvironment}113 onReconnectEnvironment={onReconnectEnvironment}
114 onSetupProvider={(params) =>
115 navigation.navigate("SettingsSheet", {
116 screen: "SettingsContent",
117 params: { screen: "SettingsProviderSetup", params },
118 })
119 }
114 showHeader={false}120 showHeader={false}
115 />121 />
116 ) : (122 ) : (
apps/mobile/src/features/connection/CloudEnvironmentRows.tsx+42-18
...@@ -26,6 +26,8 @@ import { cn } from "../../lib/cn";...@@ -26,6 +26,8 @@ import { cn } from "../../lib/cn";
26import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";26import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
27import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";27import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
28import { serverEnvironment } from "../../state/server";28import { serverEnvironment } from "../../state/server";
29import { ProviderSetupLink } from "../settings/ProviderSetupLink";
30import type { ProviderSetupRouteParams } from "../settings/SettingsProviderSetupRouteScreen";
29import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation";31import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation";
30import { hasCloudPublicConfig } from "../cloud/publicConfig";32import { hasCloudPublicConfig } from "../cloud/publicConfig";
31import { ConnectionStatusDot } from "./ConnectionStatusDot";33import { ConnectionStatusDot } from "./ConnectionStatusDot";
...@@ -34,6 +36,7 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect...@@ -34,6 +36,7 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect
34interface CloudEnvironmentRowsProps {36interface CloudEnvironmentRowsProps {
35 readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>;37 readonly connectedCloudEnvironments: ReadonlyArray<ConnectedEnvironmentSummary>;
36 readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void;38 readonly onReconnectEnvironment: (environmentId: EnvironmentId) => void;
39 readonly onSetupProvider?: (target: ProviderSetupRouteParams) => void;
37 readonly showcaseAvailableEnvironments?: ReadonlyArray<RelayEnvironmentView>;40 readonly showcaseAvailableEnvironments?: ReadonlyArray<RelayEnvironmentView>;
38 readonly showcaseSignedIn?: boolean;41 readonly showcaseSignedIn?: boolean;
39 /**42 /**
...@@ -148,6 +151,7 @@ function CloudEnvironmentRowsContent(...@@ -148,6 +151,7 @@ function CloudEnvironmentRowsContent(
148 onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)}151 onDisconnect={() => handleDisconnectCloudEnvironment(environment.environmentId)}
149 errorExpanded={expandedErrorId === environment.environmentId}152 errorExpanded={expandedErrorId === environment.environmentId}
150 onToggleError={() => handleToggleCloudError(environment.environmentId)}153 onToggleError={() => handleToggleCloudError(environment.environmentId)}
154 onSetupProvider={props.onSetupProvider}
151 />155 />
152 ))}156 ))}
153 {availableCloudEnvironments.map((environment, index) => (157 {availableCloudEnvironments.map((environment, index) => (
...@@ -211,29 +215,49 @@ function ConnectedCloudEnvironmentRow(props: {...@@ -211,29 +215,49 @@ function ConnectedCloudEnvironmentRow(props: {
211 readonly onConnect: () => void;215 readonly onConnect: () => void;
212 readonly onDisconnect: () => void;216 readonly onDisconnect: () => void;
213 readonly onToggleError: () => void;217 readonly onToggleError: () => void;
218 readonly onSetupProvider?: (target: ProviderSetupRouteParams) => void;
214}) {219}) {
215 const serverConfig = useAtomValue(220 const serverConfig = useAtomValue(
216 serverEnvironment.configValueAtom(props.environment.environmentId),221 serverEnvironment.configValueAtom(props.environment.environmentId),
217 );222 );
218 return (223 return (
219 <CloudEnvironmentRowShell224 <View>
220 borderTop={props.borderTop}225 <CloudEnvironmentRowShell
221 connectionError={props.environment.connectionError}226 borderTop={props.borderTop}
222 connectionErrorTraceId={props.environment.connectionErrorTraceId}227 connectionError={props.environment.connectionError}
223 connectionState={props.environment.connectionState}228 connectionErrorTraceId={props.environment.connectionErrorTraceId}
224 errorExpanded={props.errorExpanded}229 connectionState={props.environment.connectionState}
225 label={props.environment.environmentLabel}230 errorExpanded={props.errorExpanded}
226 machine={resolveEnvironmentMachineKind(serverConfig)}231 label={props.environment.environmentLabel}
227 onValueChange={(enabled) => {232 machine={resolveEnvironmentMachineKind(serverConfig)}
228 if (enabled) {233 onValueChange={(enabled) => {
229 props.onConnect();234 if (enabled) {
230 return;235 props.onConnect();
231 }236 return;
232 props.onDisconnect();237 }
233 }}238 props.onDisconnect();
234 onToggleError={props.onToggleError}239 }}
235 value={props.environment.connectionState !== "available"}240 onToggleError={props.onToggleError}
236 />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>
237 );261 );
238}262}
239263
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+19
...@@ -15,6 +15,8 @@ import { cn } from "../../lib/cn";...@@ -15,6 +15,8 @@ import { cn } from "../../lib/cn";
15import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";15import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
16import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";16import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
17import { serverEnvironment } from "../../state/server";17import { serverEnvironment } from "../../state/server";
18import { ProviderSetupLink } from "../settings/ProviderSetupLink";
19import type { ProviderSetupRouteParams } from "../settings/SettingsProviderSetupRouteScreen";
18import { ConnectionStatusDot } from "./ConnectionStatusDot";20import { ConnectionStatusDot } from "./ConnectionStatusDot";
1921
20function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {22function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null {
...@@ -31,6 +33,7 @@ export function ConnectionEnvironmentRow(props: {...@@ -31,6 +33,7 @@ export function ConnectionEnvironmentRow(props: {
31 readonly onToggle: () => void;33 readonly onToggle: () => void;
32 readonly onReconnect: (environmentId: EnvironmentId) => void;34 readonly onReconnect: (environmentId: EnvironmentId) => void;
33 readonly onRemove: (environmentId: EnvironmentId) => void;35 readonly onRemove: (environmentId: EnvironmentId) => void;
36 readonly onSetupProvider: (target: ProviderSetupRouteParams) => void;
34 readonly onUpdate: (37 readonly onUpdate: (
35 environmentId: EnvironmentId,38 environmentId: EnvironmentId,
36 updates: { readonly label: string; readonly displayUrl: string },39 updates: { readonly label: string; readonly displayUrl: string },
...@@ -179,6 +182,22 @@ export function ConnectionEnvironmentRow(props: {...@@ -179,6 +182,22 @@ export function ConnectionEnvironmentRow(props: {
179 </>182 </>
180 )}183 )}
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
182 <View className="flex-row justify-end gap-2">201 <View className="flex-row justify-end gap-2">
183 {props.environment.isRelayManaged ? null : (202 {props.environment.isRelayManaged ? null : (
184 <Pressable203 <Pressable
apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx+6
...@@ -75,6 +75,12 @@ export function ConnectionsRouteScreen() {...@@ -75,6 +75,12 @@ export function ConnectionsRouteScreen() {
75 onReconnect={onReconnectEnvironment}75 onReconnect={onReconnectEnvironment}
76 onRemove={onRemoveEnvironmentPress}76 onRemove={onRemoveEnvironmentPress}
77 onUpdate={onUpdateEnvironment}77 onUpdate={onUpdateEnvironment}
78 onSetupProvider={(params) =>
79 navigation.navigate("SettingsSheet", {
80 screen: "SettingsContent",
81 params: { screen: "SettingsProviderSetup", params },
82 })
83 }
78 />84 />
79 </View>85 </View>
80 ))}86 ))}
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() {...@@ -137,6 +137,12 @@ export function SettingsEnvironmentsRouteScreen() {
137 onReconnect={onReconnectEnvironment}137 onReconnect={onReconnectEnvironment}
138 onRemove={onRemoveEnvironmentPress}138 onRemove={onRemoveEnvironmentPress}
139 onUpdate={handleUpdateEnvironment}139 onUpdate={handleUpdateEnvironment}
140 onSetupProvider={(params) =>
141 navigation.navigate("SettingsSheet", {
142 screen: "SettingsContent",
143 params: { screen: "SettingsProviderSetup", params },
144 })
145 }
140 />146 />
141 </View>147 </View>
142 ))}148 ))}
...@@ -164,6 +170,12 @@ export function SettingsEnvironmentsRouteScreen() {...@@ -164,6 +170,12 @@ export function SettingsEnvironmentsRouteScreen() {
164 <CloudEnvironmentRows170 <CloudEnvironmentRows
165 connectedCloudEnvironments={connectedCloudEnvironments}171 connectedCloudEnvironments={connectedCloudEnvironments}
166 onReconnectEnvironment={onReconnectEnvironment}172 onReconnectEnvironment={onReconnectEnvironment}
173 onSetupProvider={(params) =>
174 navigation.navigate("SettingsSheet", {
175 screen: "SettingsContent",
176 params: { screen: "SettingsProviderSetup", params },
177 })
178 }
167 {...(SHOWCASE_ENABLED179 {...(SHOWCASE_ENABLED
168 ? {180 ? {
169 showcaseAvailableEnvironments: SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS,181 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 {...@@ -84,7 +84,11 @@ import {
84 type ComposerDraft,84 type ComposerDraft,
85} from "../../state/use-composer-drafts";85} from "../../state/use-composer-drafts";
86import { useEnvironmentServerConfig, useProjects } from "../../state/entities";86import { 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";
88import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";92import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
89import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";93import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
90import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";94import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
...@@ -173,6 +177,7 @@ export function NewTaskDraftScreen(props: {...@@ -173,6 +177,7 @@ export function NewTaskDraftScreen(props: {
173 connectedEnvironments.find(177 connectedEnvironments.find(
174 (environment) => environment.environmentId === selectedProject.environmentId,178 (environment) => environment.environmentId === selectedProject.environmentId,
175 )?.connectionState === "connected";179 )?.connectionState === "connected";
180 const modelUnavailable = environmentConnected && flow.selectedModelOption?.isUnavailable === true;
176 const uploadStates = useAtomValue(composerAttachmentUploadsAtom);181 const uploadStates = useAtomValue(composerAttachmentUploadsAtom);
177 const attachmentBlockReason = selectedProject182 const attachmentBlockReason = selectedProject
178 ? composerAttachmentUploadBlockReason({183 ? composerAttachmentUploadBlockReason({
...@@ -862,9 +867,8 @@ export function NewTaskDraftScreen(props: {...@@ -862,9 +867,8 @@ export function NewTaskDraftScreen(props: {
862 return;867 return;
863 }868 }
864 const draft = getComposerDraftSnapshot(draftKey);869 const draft = getComposerDraftSnapshot(draftKey);
865 // Snapshot read keeps just-typed selector state; the availability gate870 // Read the latest explicit pick. Antigravity selections stay unchanged
866 // still applies so a stored selection on a disabled provider falls back871 // when setup or a catalog change makes them unavailable.
867 // to the flow's resolved model.
868 const modelSelection =872 const modelSelection =
869 resolveSelectableModelSelection(873 resolveSelectableModelSelection(
870 selectedEnvironmentServerConfig,874 selectedEnvironmentServerConfig,
...@@ -876,9 +880,12 @@ export function NewTaskDraftScreen(props: {...@@ -876,9 +880,12 @@ export function NewTaskDraftScreen(props: {
876 draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath;880 draft.workspaceSelection?.worktreePath ?? flow.selectedWorktreePath;
877 const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin;881 const startFromOrigin = draft.workspaceSelection?.startFromOrigin ?? flow.startFromOrigin;
878 const runtimeMode = draft.runtimeMode ?? flow.runtimeMode;882 const runtimeMode = draft.runtimeMode ?? flow.runtimeMode;
879 const interactionMode = flow.planModeEnabled883 const interactionMode = resolveProviderInteractionMode(
880 ? (draft.interactionMode ?? flow.interactionMode)884 selectedEnvironmentServerConfig?.providers.find(
881 : "default";885 (provider) => provider.instanceId === modelSelection?.instanceId,
886 ),
887 flow.planModeEnabled ? (draft.interactionMode ?? flow.interactionMode) : "default",
888 );
882 const initialMessageText = draft.text.trim();889 const initialMessageText = draft.text.trim();
883890
884 if (891 if (
...@@ -890,6 +897,16 @@ export function NewTaskDraftScreen(props: {...@@ -890,6 +897,16 @@ export function NewTaskDraftScreen(props: {
890 ) {897 ) {
891 return;898 return;
892 }899 }
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 }
893 // A failed-send restore can leave the draft over the cap on purpose (it910 // A failed-send restore can leave the draft over the cap on purpose (it
894 // never drops the user's files); starting anyway would upload everything911 // never drops the user's files); starting anyway would upload everything
895 // and have the server reject the turn.912 // and have the server reject the turn.
...@@ -1038,6 +1055,7 @@ export function NewTaskDraftScreen(props: {...@@ -1038,6 +1055,7 @@ export function NewTaskDraftScreen(props: {
1038 const isAndroid = Platform.OS === "android";1055 const isAndroid = Platform.OS === "android";
1039 const canStart =1056 const canStart =
1040 attachmentBlockReason === null &&1057 attachmentBlockReason === null &&
1058 !modelUnavailable &&
1041 Boolean(flow.selectedProject) &&1059 Boolean(flow.selectedProject) &&
1042 Boolean(flow.selectedModel) &&1060 Boolean(flow.selectedModel) &&
1043 flow.prompt.trim().length > 0 &&1061 flow.prompt.trim().length > 0 &&
...@@ -1231,6 +1249,17 @@ export function NewTaskDraftScreen(props: {...@@ -1231,6 +1249,17 @@ export function NewTaskDraftScreen(props: {
1231 ) : null}1249 ) : null}
1232 <View className="pb-1">{workspaceControls}</View>1250 <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
1234 <ComposerSurface1263 <ComposerSurface
1235 style={{1264 style={{
1236 borderRadius: 26,1265 borderRadius: 26,
apps/mobile/src/features/threads/PendingApprovalCard.tsx+10-3
...@@ -17,14 +17,16 @@ export interface PendingApprovalCardProps {...@@ -17,14 +17,16 @@ export interface PendingApprovalCardProps {
17 ) => Promise<unknown>;17 ) => Promise<unknown>;
18}18}
1919
20const DEFAULT_APPROVAL_OPTIONS = [20const DEFAULT_APPROVAL_OPTIONS: ReadonlyArray<ProviderApprovalOption> = [
21 { decision: "accept", label: "Allow once" },21 { decision: "accept", label: "Allow once" },
22 { decision: "acceptForSession", label: "Allow session" },22 { decision: "acceptForSession", label: "Allow session" },
23 { decision: "decline", label: "Decline" },23 { decision: "decline", label: "Decline" },
24] satisfies ReadonlyArray<ProviderApprovalOption>;24];
2525
26export function PendingApprovalCard(props: PendingApprovalCardProps) {26export 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;
28 // Opaque for the same reason as PendingUserInputCard: nothing blurs the feed30 // Opaque for the same reason as PendingUserInputCard: nothing blurs the feed
29 // behind this card, so a translucent surface bleeds messages through it.31 // behind this card, so a translucent surface bleeds messages through it.
30 return (32 return (
...@@ -40,6 +42,11 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {...@@ -40,6 +42,11 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) {
40 {props.approval.detail}42 {props.approval.detail}
41 </Text>43 </Text>
42 ) : null}44 ) : null}
45 {warning ? (
46 <Text className="font-sans text-xs leading-normal text-adaptive-amber-700-300">
47 {warning}
48 </Text>
49 ) : null}
43 <View className="flex-row flex-wrap gap-2.5">50 <View className="flex-row flex-wrap gap-2.5">
44 {options.map((option) => (51 {options.map((option) => (
45 <Pressable52 <Pressable
apps/mobile/src/features/threads/PendingUserInputCard.tsx+17-14
...@@ -58,7 +58,7 @@ export interface PendingUserInputCardProps {...@@ -58,7 +58,7 @@ export interface PendingUserInputCardProps {
58 readonly onSelectOption: (58 readonly onSelectOption: (
59 requestId: ApprovalRequestId,59 requestId: ApprovalRequestId,
60 question: UserInputQuestion,60 question: UserInputQuestion,
61 label: string,61 value: string,
62 ) => void;62 ) => void;
63 readonly onChangeCustomAnswer: (63 readonly onChangeCustomAnswer: (
64 requestId: ApprovalRequestId,64 requestId: ApprovalRequestId,
...@@ -267,12 +267,13 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {...@@ -267,12 +267,13 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
267 </Text>267 </Text>
268 <View className="gap-2">268 <View className="gap-2">
269 {question.options.map((option) => {269 {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);
271 const description =272 const description =
272 option.description !== option.label ? option.description : undefined;273 option.description !== option.label ? option.description : undefined;
273 return (274 return (
274 <Pressable275 <Pressable
275 key={option.label}276 key={optionValue}
276 className={cn(277 className={cn(
277 "min-h-12 w-full rounded-2xl border px-3.5 py-3",278 "min-h-12 w-full rounded-2xl border px-3.5 py-3",
278 selected279 selected
...@@ -283,7 +284,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {...@@ -283,7 +284,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
283 props.onSelectOption(284 props.onSelectOption(
284 props.pendingUserInput.requestId,285 props.pendingUserInput.requestId,
285 question,286 question,
286 option.label,287 optionValue,
287 )288 )
288 }289 }
289 >290 >
...@@ -308,16 +309,18 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {...@@ -308,16 +309,18 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
308 );309 );
309 })}310 })}
310 </View>311 </View>
311 <TextInput312 {question.allowCustomAnswer !== false ? (
312 value={draft?.customAnswer ?? ""}313 <TextInput
313 onChangeText={(value) =>314 value={draft?.customAnswer ?? ""}
314 props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value)315 onChangeText={(value) =>
315 }316 props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value)
316 onFocus={() => props.onInputFocusChange?.(true)}317 }
317 onBlur={() => props.onInputFocusChange?.(false)}318 onFocus={() => props.onInputFocusChange?.(true)}
318 placeholder="Or type a custom answer"319 onBlur={() => props.onInputFocusChange?.(false)}
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 placeholder="Or type a custom answer"
320 />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}
321 </View>324 </View>
322 );325 );
323 })}326 })}
apps/mobile/src/features/threads/ThreadComposer.tsx+24-3
...@@ -60,7 +60,11 @@ import type {...@@ -60,7 +60,11 @@ import type {
60 DraftComposerAttachment,60 DraftComposerAttachment,
61 DraftComposerFileAttachment,61 DraftComposerFileAttachment,
62} from "../../lib/composerImages";62} from "../../lib/composerImages";
63import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";63import {
64 buildModelOptions,
65 groupByProvider,
66 isModelSelectionUnavailable,
67} from "../../lib/modelOptions";
64import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";68import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
65import type { RemoteClientConnectionState } from "../../lib/connection";69import type { RemoteClientConnectionState } from "../../lib/connection";
66import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";70import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
...@@ -333,6 +337,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -333,6 +337,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
333 props.connectionState !== "connected" || props.queueCount > 0 ? "Queue" : "Send";337 props.connectionState !== "connected" || props.queueCount > 0 ? "Queue" : "Send";
334 const currentModelSelection = props.selectedThread.modelSelection;338 const currentModelSelection = props.selectedThread.modelSelection;
335 const currentRuntimeMode = props.selectedThread.runtimeMode;339 const currentRuntimeMode = props.selectedThread.runtimeMode;
340 const modelUnavailable =
341 props.connectionState === "connected" &&
342 isModelSelectionUnavailable(props.serverConfig, currentModelSelection);
336 const connectionStatus = composerConnectionStatus({343 const connectionStatus = composerConnectionStatus({
337 connectionError: props.connectionError,344 connectionError: props.connectionError,
338 connectionState: props.connectionState,345 connectionState: props.connectionState,
...@@ -357,7 +364,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -357,7 +364,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
357 selectedProviderStatus,364 selectedProviderStatus,
358 hasThread: true,365 hasThread: true,
359 onChangeDraftMessage: props.onChangeDraftMessage,366 onChangeDraftMessage: props.onChangeDraftMessage,
360 onUpdateInteractionMode: props.onUpdateInteractionMode,367 onUpdateInteractionMode:
368 selectedProviderStatus?.showInteractionModeToggle === false
369 ? undefined
370 : props.onUpdateInteractionMode,
361 });371 });
362 const voiceInput = useVoiceInputController({372 const voiceInput = useVoiceInputController({
363 ownerKey: composerOwnerKey,373 ownerKey: composerOwnerKey,
...@@ -383,7 +393,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -383,7 +393,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
383 serverConfig: props.serverConfig,393 serverConfig: props.serverConfig,
384 states: uploadStates,394 states: uploadStates,
385 });395 });
386 const canSend = hasContent && !voiceInput.blocksSubmission && attachmentBlockReason === null;396 const canSend =
397 hasContent &&
398 !voiceInput.blocksSubmission &&
399 attachmentBlockReason === null &&
400 !modelUnavailable;
387401
388 // Keep the feed inset aligned with the card or compact dictation strip.402 // Keep the feed inset aligned with the card or compact dictation strip.
389 useEffect(() => {403 useEffect(() => {
...@@ -496,6 +510,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -496,6 +510,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
496 () => ({510 () => ({
497 ownerId: settingsOwnerId,511 ownerId: settingsOwnerId,
498 environmentId: props.environmentId,512 environmentId: props.environmentId,
513 providerInstanceId: currentModelSelection.instanceId,
499 providerGroups: threadProviderGroups,514 providerGroups: threadProviderGroups,
500 selectedModel: currentModelSelection,515 selectedModel: currentModelSelection,
501 onSelectModel: (option) => props.onUpdateModelSelection(option.selection),516 onSelectModel: (option) => props.onUpdateModelSelection(option.selection),
...@@ -595,6 +610,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -595,6 +610,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
595 />610 />
596 ) : null}611 ) : 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
598 <ComposerSurface619 <ComposerSurface
599 style={620 style={
600 isExpanded621 isExpanded
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+1-1
...@@ -139,7 +139,7 @@ export interface ThreadDetailScreenProps {...@@ -139,7 +139,7 @@ export interface ThreadDetailScreenProps {
139 readonly onSelectUserInputOption: (139 readonly onSelectUserInputOption: (
140 requestId: ApprovalRequestId,140 requestId: ApprovalRequestId,
141 question: UserInputQuestion,141 question: UserInputQuestion,
142 label: string,142 value: string,
143 ) => void;143 ) => void;
144 readonly onChangeUserInputCustomAnswer: (144 readonly onChangeUserInputCustomAnswer: (
145 requestId: ApprovalRequestId,145 requestId: ApprovalRequestId,
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+83-11
...@@ -1,10 +1,13 @@...@@ -1,10 +1,13 @@
1import type {1import type {
2 EnvironmentId,2 EnvironmentId,
3 ModelSelection,3 ModelSelection,
4 ProviderInstanceId,
4 ProviderOptionDescriptor,5 ProviderOptionDescriptor,
5 ProviderOptionSelection,6 ProviderOptionSelection,
6 RuntimeMode,7 RuntimeMode,
8 ServerProvider,
7} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import { useAtomValue } from "@effect/atom-react";
8import type { LegendListRenderItemProps } from "@legendapp/list/react-native";11import type { LegendListRenderItemProps } from "@legendapp/list/react-native";
9import { AnimatedLegendList } from "@legendapp/list/reanimated";12import { AnimatedLegendList } from "@legendapp/list/reanimated";
10import { HeaderHeightContext } from "@react-navigation/elements";13import { HeaderHeightContext } from "@react-navigation/elements";
...@@ -49,6 +52,11 @@ import {...@@ -49,6 +52,11 @@ import {
49} from "../../native/StackHeader";52} from "../../native/StackHeader";
50import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";53import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
51import { serverEnvironment } from "../../state/server";54import { serverEnvironment } from "../../state/server";
55import { ProviderSetupLink } from "../settings/ProviderSetupLink";
56import {
57 SettingsProviderSetupRouteScreen,
58 type ProviderSetupRouteParams,
59} from "../settings/SettingsProviderSetupRouteScreen";
52import { useAtomCommand } from "../../state/use-atom-command";60import { useAtomCommand } from "../../state/use-atom-command";
53import { useNewTaskFlow } from "./new-task-flow-provider";61import { useNewTaskFlow } from "./new-task-flow-provider";
54import {62import {
...@@ -62,8 +70,10 @@ import {...@@ -62,8 +70,10 @@ import {
62} from "../layout/native-mail-search-toolbar";70} from "../layout/native-mail-search-toolbar";
63import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-options";71import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-options";
64import {72import {
73 canCommitPendingModel,
65 modelMatchesCatalogQuery,74 modelMatchesCatalogQuery,
66 pendingModelAfterPress,75 pendingModelAfterPress,
76 providerSetupCandidates,
67 providerSectionIsCollapsed,77 providerSectionIsCollapsed,
68} from "./thread-settings-sheet-state";78} from "./thread-settings-sheet-state";
6979
...@@ -72,7 +82,11 @@ import {...@@ -72,7 +82,11 @@ import {
72 * and friends) starts folded so a 300-model catalog cannot bury the list. All82 * and friends) starts folded so a 300-model catalog cannot bury the list. All
73 * provider headers remain user-collapsible.83 * provider headers remain user-collapsible.
74 */84 */
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]);
76/**90/**
77 * Keep measured row changes stable, but let catalog mutations use the list's91 * Keep measured row changes stable, but let catalog mutations use the list's
78 * native bounds so a filtered catalog that underflows returns to the top.92 * native bounds so a filtered catalog that underflows returns to the top.
...@@ -102,7 +116,11 @@ function ModelRow(props: {...@@ -102,7 +116,11 @@ function ModelRow(props: {
102 <Pressable116 <Pressable
103 accessibilityLabel={[props.option.label, props.option.subtitle].filter(Boolean).join(", ")}117 accessibilityLabel={[props.option.label, props.option.subtitle].filter(Boolean).join(", ")}
104 accessibilityRole="radio"118 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}
106 onPress={props.onPress}124 onPress={props.onPress}
107 className={cn(125 className={cn(
108 "mx-4 min-h-11 flex-row items-center gap-2 bg-card px-4 py-2 active:bg-subtle",126 "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: {...@@ -128,6 +146,9 @@ function ModelRow(props: {
128 <Text className="text-3xs font-t3-bold text-foreground-muted">Legacy</Text>146 <Text className="text-3xs font-t3-bold text-foreground-muted">Legacy</Text>
129 </View>147 </View>
130 ) : null}148 ) : null}
149 {props.option.isUnavailable ? (
150 <Text className="text-xs text-foreground">Unavailable</Text>
151 ) : null}
131 </View>152 </View>
132 {props.option.subtitle ? (153 {props.option.subtitle ? (
133 <Text className="text-xs text-foreground-muted" numberOfLines={1}>154 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
...@@ -301,6 +322,7 @@ type ThreadSettingsSubmenuPage =...@@ -301,6 +322,7 @@ type ThreadSettingsSubmenuPage =
301322
302type ThreadSettingsSessionProps = {323type ThreadSettingsSessionProps = {
303 readonly environmentId: EnvironmentId | null;324 readonly environmentId: EnvironmentId | null;
325 readonly providerInstanceId?: ProviderInstanceId;
304 readonly providerGroups: ReadonlyArray<ProviderGroup>;326 readonly providerGroups: ReadonlyArray<ProviderGroup>;
305 readonly selectedModel: ModelSelection | null;327 readonly selectedModel: ModelSelection | null;
306 readonly onSelectModel: (option: ModelOption) => void;328 readonly onSelectModel: (option: ModelOption) => void;
...@@ -353,6 +375,7 @@ export function useExistingThreadSettingsRoutePresentation() {...@@ -353,6 +375,7 @@ export function useExistingThreadSettingsRoutePresentation() {
353375
354type ThreadSettingsSessionValue = {376type ThreadSettingsSessionValue = {
355 readonly environmentId: EnvironmentId | null;377 readonly environmentId: EnvironmentId | null;
378 readonly providerInstanceId?: ProviderInstanceId;
356 readonly providerGroups: ReadonlyArray<ProviderGroup>;379 readonly providerGroups: ReadonlyArray<ProviderGroup>;
357 readonly runtimeMode: RuntimeMode;380 readonly runtimeMode: RuntimeMode;
358 readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;381 readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void;
...@@ -364,7 +387,7 @@ type ThreadSettingsSessionValue = {...@@ -364,7 +387,7 @@ type ThreadSettingsSessionValue = {
364 readonly searchQuery: string;387 readonly searchQuery: string;
365 readonly showLegacy: boolean;388 readonly showLegacy: boolean;
366 readonly applyOptionChange: (id: string, value: string | boolean) => void;389 readonly applyOptionChange: (id: string, value: string | boolean) => void;
367 readonly commitPendingModel: () => void;390 readonly commitPendingModel: () => boolean;
368 readonly isApplied: (option: ModelOption) => boolean;391 readonly isApplied: (option: ModelOption) => boolean;
369 readonly isDisplayed: (option: ModelOption) => boolean;392 readonly isDisplayed: (option: ModelOption) => boolean;
370 readonly pressModel: (option: ModelOption) => void;393 readonly pressModel: (option: ModelOption) => void;
...@@ -422,10 +445,15 @@ function ThreadSettingsSessionProvider(...@@ -422,10 +445,15 @@ function ThreadSettingsSessionProvider(
422 );445 );
423 const commitPendingModel = useCallback(() => {446 const commitPendingModel = useCallback(() => {
424 if (pendingModel) {447 if (pendingModel) {
448 if (!canCommitPendingModel(pendingModel, props.providerGroups)) {
449 Alert.alert("Model unavailable", "Complete provider setup or select another model.");
450 return false;
451 }
425 void Haptics.selectionAsync();452 void Haptics.selectionAsync();
426 props.onSelectModel(pendingModel);453 props.onSelectModel(pendingModel);
427 }454 }
428 }, [pendingModel, props.onSelectModel]);455 return true;
456 }, [pendingModel, props.onSelectModel, props.providerGroups]);
429457
430 const applyOptionChange = useCallback(458 const applyOptionChange = useCallback(
431 (id: string, value: string | boolean) => {459 (id: string, value: string | boolean) => {
...@@ -472,6 +500,7 @@ function ThreadSettingsSessionProvider(...@@ -472,6 +500,7 @@ function ThreadSettingsSessionProvider(
472 const value = useMemo<ThreadSettingsSessionValue>(500 const value = useMemo<ThreadSettingsSessionValue>(
473 () => ({501 () => ({
474 environmentId: props.environmentId,502 environmentId: props.environmentId,
503 providerInstanceId: props.providerInstanceId,
475 providerGroups: props.providerGroups,504 providerGroups: props.providerGroups,
476 runtimeMode: props.runtimeMode,505 runtimeMode: props.runtimeMode,
477 onUpdateRuntimeMode: props.onUpdateRuntimeMode,506 onUpdateRuntimeMode: props.onUpdateRuntimeMode,
...@@ -501,6 +530,7 @@ function ThreadSettingsSessionProvider(...@@ -501,6 +530,7 @@ function ThreadSettingsSessionProvider(
501 isApplied,530 isApplied,
502 isDisplayed,531 isDisplayed,
503 props.environmentId,532 props.environmentId,
533 props.providerInstanceId,
504 pendingModel,534 pendingModel,
505 pressModel,535 pressModel,
506 providerFilter,536 providerFilter,
...@@ -551,6 +581,11 @@ type ThreadSettingsCatalogItem =...@@ -551,6 +581,11 @@ type ThreadSettingsCatalogItem =
551 readonly isFirst: boolean;581 readonly isFirst: boolean;
552 readonly isLast: boolean;582 readonly isLast: boolean;
553 }583 }
584 | {
585 readonly kind: "setup";
586 readonly key: string;
587 readonly provider: ServerProvider;
588 }
554 | {589 | {
555 readonly kind: "empty";590 readonly kind: "empty";
556 readonly key: "empty";591 readonly key: "empty";
...@@ -760,22 +795,37 @@ function ThreadSettingsOptionsItem(props: {...@@ -760,22 +795,37 @@ function ThreadSettingsOptionsItem(props: {
760/** One native scroll owner for the model catalog and its related settings. */795/** One native scroll owner for the model catalog and its related settings. */
761function ThreadSettingsMainContent(props: {796function ThreadSettingsMainContent(props: {
762 readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void;797 readonly onOpenSubmenu: (submenu: ThreadSettingsSubmenuPage) => void;
798 readonly onOpenProviderSetup: (instanceId: ProviderInstanceId) => void;
763}) {799}) {
764 const session = useThreadSettingsSession();800 const session = useThreadSettingsSession();
801 const config = useAtomValue(serverEnvironment.configValueAtom(session.environmentId));
765 const catalogItems = useThreadSettingsCatalogItems(session);802 const catalogItems = useThreadSettingsCatalogItems(session);
766 const [animationsReady, setAnimationsReady] = useState(false);803 const [animationsReady, setAnimationsReady] = useState(false);
767 const nativeHeaderHeight = use(HeaderHeightContext) ?? 0;804 const nativeHeaderHeight = use(HeaderHeightContext) ?? 0;
768 const hasActiveCatalogFilter =805 const hasActiveCatalogFilter =
769 session.providerFilter !== null || session.searchQuery.trim().length > 0;806 session.providerFilter !== null || session.searchQuery.trim().length > 0;
770 const usesTransparentNativeHeader = Platform.OS === "ios" && NATIVE_LIQUID_GLASS_SUPPORTED;807 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 );
771 const listItems = useMemo<ReadonlyArray<ThreadSettingsCatalogItem>>(818 const listItems = useMemo<ReadonlyArray<ThreadSettingsCatalogItem>>(
772 () => [819 () => [
773 ...(catalogItems.length === 0 && hasActiveCatalogFilter820 ...(catalogItems.length === 0 ? ([{ kind: "empty", key: "empty" }] as const) : catalogItems),
774 ? ([{ kind: "empty", key: "empty" }] as const)821 ...setupProviders.map((provider) => ({
775 : catalogItems),822 kind: "setup" as const,
823 key: `setup:${provider.instanceId}`,
824 provider,
825 })),
776 { kind: "options", key: "options" },826 { kind: "options", key: "options" },
777 ],827 ],
778 [catalogItems, hasActiveCatalogFilter],828 [catalogItems, setupProviders],
779 );829 );
780 const renderCatalogItem = useCallback(830 const renderCatalogItem = useCallback(
781 (itemProps: LegendListRenderItemProps<ThreadSettingsCatalogItem>) => {831 (itemProps: LegendListRenderItemProps<ThreadSettingsCatalogItem>) => {
...@@ -792,10 +842,19 @@ function ThreadSettingsMainContent(props: {...@@ -792,10 +842,19 @@ function ThreadSettingsMainContent(props: {
792 option={item.option}842 option={item.option}
793 />843 />
794 );844 );
845 } else if (item.kind === "setup") {
846 content = (
847 <ProviderSetupLink
848 provider={item.provider}
849 onPress={() => props.onOpenProviderSetup(item.provider.instanceId)}
850 />
851 );
795 } else if (item.kind === "empty") {852 } else if (item.kind === "empty") {
796 content = (853 content = (
797 <View className="items-center px-8 py-14">854 <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>
799 </View>858 </View>
800 );859 );
801 } else {860 } else {
...@@ -817,7 +876,7 @@ function ThreadSettingsMainContent(props: {...@@ -817,7 +876,7 @@ function ThreadSettingsMainContent(props: {
817 </Animated.View>876 </Animated.View>
818 );877 );
819 },878 },
820 [animationsReady, props.onOpenSubmenu],879 [animationsReady, hasActiveCatalogFilter, props.onOpenProviderSetup, props.onOpenSubmenu],
821 );880 );
822881
823 return (882 return (
...@@ -943,6 +1002,7 @@ function ThreadSettingsChoiceContent(props: {...@@ -943,6 +1002,7 @@ function ThreadSettingsChoiceContent(props: {
943type ThreadSettingsPickerStackParams = {1002type ThreadSettingsPickerStackParams = {
944 ThreadSettingsModels: undefined;1003 ThreadSettingsModels: undefined;
945 ThreadSettingsChoice: ThreadSettingsSubmenuPage & { readonly title: string };1004 ThreadSettingsChoice: ThreadSettingsSubmenuPage & { readonly title: string };
1005 ThreadSettingsProviderSetup: ProviderSetupRouteParams;
946};1006};
9471007
948type ThreadSettingsPickerPresentation = {1008type ThreadSettingsPickerPresentation = {
...@@ -987,7 +1047,7 @@ function ThreadSettingsModelsScreen() {...@@ -987,7 +1047,7 @@ function ThreadSettingsModelsScreen() {
987 });1047 });
988 }, [isRefreshingProviders, refreshProviderCatalog, session.environmentId]);1048 }, [isRefreshingProviders, refreshProviderCatalog, session.environmentId]);
989 const commitAndClose = useCallback(() => {1049 const commitAndClose = useCallback(() => {
990 session.commitPendingModel();1050 if (!session.commitPendingModel()) return;
991 presentation.onClose();1051 presentation.onClose();
992 }, [presentation, session]);1052 }, [presentation, session]);
993 const filterMenu = useMemo(1053 const filterMenu = useMemo(
...@@ -1086,6 +1146,13 @@ function ThreadSettingsModelsScreen() {...@@ -1086,6 +1146,13 @@ function ThreadSettingsModelsScreen() {
1086 }}1146 }}
1087 />1147 />
1088 <ThreadSettingsMainContent1148 <ThreadSettingsMainContent
1149 onOpenProviderSetup={(instanceId) => {
1150 if (!session.environmentId) return;
1151 navigation.navigate("ThreadSettingsProviderSetup", {
1152 environmentId: session.environmentId,
1153 instanceId,
1154 });
1155 }}
1089 onOpenSubmenu={(submenu) => {1156 onOpenSubmenu={(submenu) => {
1090 const title =1157 const title =
1091 submenu.kind === "runtime"1158 submenu.kind === "runtime"
...@@ -1220,6 +1287,11 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation)...@@ -1220,6 +1287,11 @@ function ThreadSettingsPickerNavigator(props: ThreadSettingsPickerPresentation)
1220 component={ThreadSettingsChoiceScreen}1287 component={ThreadSettingsChoiceScreen}
1221 options={({ route }) => ({ title: route.params.title })}1288 options={({ route }) => ({ title: route.params.title })}
1222 />1289 />
1290 <ThreadSettingsPickerStack.Screen
1291 name="ThreadSettingsProviderSetup"
1292 component={SettingsProviderSetupRouteScreen}
1293 options={{ title: "Antigravity" }}
1294 />
1223 </ThreadSettingsPickerStack.Navigator>1295 </ThreadSettingsPickerStack.Navigator>
1224 </ThreadSettingsPickerPresentationContext.Provider>1296 </ThreadSettingsPickerPresentationContext.Provider>
1225 );1297 );
apps/mobile/src/features/threads/legacy-plan-mode.test.ts+36-1
...@@ -1,8 +1,43 @@...@@ -1,8 +1,43 @@
1import { describe, expect, it } from "@effect/vitest";1import { 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
5describe("resolvePendingTaskInteractionMode", () => {25describe("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
6 it("preserves a queued plan task while the preference is still loading", () => {41 it("preserves a queued plan task while the preference is still loading", () => {
7 expect(42 expect(
8 resolvePendingTaskInteractionMode({43 resolvePendingTaskInteractionMode({
apps/mobile/src/features/threads/legacy-plan-mode.ts+17
...@@ -1,8 +1,21 @@...@@ -1,8 +1,21 @@
1import {1import {
2 DEFAULT_PROVIDER_INTERACTION_MODE,2 DEFAULT_PROVIDER_INTERACTION_MODE,
3 type ProviderInteractionMode,3 type ProviderInteractionMode,
4 type ServerProvider,
4} from "@t3tools/contracts";5} 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
6export function resolveLegacyPlanModeEnabled(input: {19export function resolveLegacyPlanModeEnabled(input: {
7 readonly loaded: boolean;20 readonly loaded: boolean;
8 readonly preference: boolean | undefined;21 readonly preference: boolean | undefined;
...@@ -15,7 +28,11 @@ export function resolvePendingTaskInteractionMode(input: {...@@ -15,7 +28,11 @@ export function resolvePendingTaskInteractionMode(input: {
15 readonly planModeEnabled: boolean;28 readonly planModeEnabled: boolean;
16 readonly draftInteractionMode: ProviderInteractionMode | undefined;29 readonly draftInteractionMode: ProviderInteractionMode | undefined;
17 readonly queuedInteractionMode: ProviderInteractionMode | undefined;30 readonly queuedInteractionMode: ProviderInteractionMode | undefined;
31 readonly provider?: InteractionModeProvider | null;
18}): ProviderInteractionMode {32}): ProviderInteractionMode {
33 if (input.provider?.showInteractionModeToggle === false) {
34 return DEFAULT_PROVIDER_INTERACTION_MODE;
35 }
19 if (input.planModeEnabled) {36 if (input.planModeEnabled) {
20 return input.draftInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE;37 return input.draftInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE;
21 }38 }
apps/mobile/src/features/threads/new-task-flow-provider.tsx+35-18
...@@ -83,7 +83,10 @@ import {...@@ -83,7 +83,10 @@ import {
83 type HomeProjectScope,83 type HomeProjectScope,
84} from "../home/homeThreadList";84} from "../home/homeThreadList";
85import { useMobileProjectGroupingSettings } from "../../state/project-grouping";85import { useMobileProjectGroupingSettings } from "../../state/project-grouping";
86import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode";86import {
87 resolvePendingTaskInteractionMode,
88 resolveProviderInteractionMode,
89} from "./legacy-plan-mode";
87import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled";90import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled";
88import {91import {
89 resolveNewTaskBranchWorktreePath,92 resolveNewTaskBranchWorktreePath,
...@@ -202,7 +205,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -202,7 +205,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
202 const threads = useThreadShells();205 const threads = useThreadShells();
203 const { savedConnectionsById } = useSavedRemoteConnections();206 const { savedConnectionsById } = useSavedRemoteConnections();
204 const groupingSettings = useMobileProjectGroupingSettings();207 const groupingSettings = useMobileProjectGroupingSettings();
205 const { enabled: planModeEnabled, loaded: planModePreferenceLoaded } = useLegacyPlanModeState();208 const { enabled: legacyPlanModeEnabled, loaded: planModePreferenceLoaded } =
209 useLegacyPlanModeState();
206 const projectScopes = useMemo(210 const projectScopes = useMemo(
207 () =>211 () =>
208 sortHomeProjectScopes({212 sortHomeProjectScopes({
...@@ -412,15 +416,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -412,15 +416,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
412 selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ??416 selectedEnvironmentServerConfig?.settings.newWorktreesStartFromOrigin ??
413 true;417 true;
414 const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE;418 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 the420 // Antigravity keeps unavailable selections so sign-out or a catalog change
420 // server; otherwise the server's default model wins instead of silently421 // cannot switch the user's model. Other providers retain their fallback
421 // targeting a disabled provider. The draft selection is an explicit pick422 // rules. Implicit defaults also exclude legacy models for those providers.
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.
424 const draftModelSelection = resolveSelectableModelSelection(423 const draftModelSelection = resolveSelectableModelSelection(
425 selectedEnvironmentServerConfig,424 selectedEnvironmentServerConfig,
426 selectedProjectDraft.modelSelection ?? null,425 selectedProjectDraft.modelSelection ?? null,
...@@ -474,6 +473,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -474,6 +473,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
474 ) ?? null,473 ) ?? null,
475 [selectedEnvironmentServerConfig, selectedModel?.instanceId],474 [selectedEnvironmentServerConfig, selectedModel?.instanceId],
476 );475 );
476 const planModeEnabled =
477 legacyPlanModeEnabled && selectedProviderStatus?.showInteractionModeToggle !== false;
478 const interactionMode = planModeEnabled
479 ? (selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE)
480 : DEFAULT_PROVIDER_INTERACTION_MODE;
477 const setSelectedModelKey = useCallback(481 const setSelectedModelKey = useCallback(
478 // Options ride along in the same write: a follow-up setSelectedModelOptions482 // Options ride along in the same write: a follow-up setSelectedModelOptions
479 // call would rebuild the selection from the stale pre-switch model.483 // call would rebuild the selection from the stale pre-switch model.
...@@ -486,10 +490,18 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -486,10 +490,18 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
486 return;490 return;
487 }491 }
488 const selection = options ? { ...option.selection, options } : option.selection;492 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 });
490 setStickyComposerModelSelection(selection);502 setStickyComposerModelSelection(selection);
491 },503 },
492 [modelOptions, selectedProjectDraftKey],504 [modelOptions, selectedEnvironmentServerConfig, selectedProjectDraftKey],
493 );505 );
494 const setSelectedModelOptions = useCallback(506 const setSelectedModelOptions = useCallback(
495 (options: ReadonlyArray<ProviderOptionSelection> | undefined) => {507 (options: ReadonlyArray<ProviderOptionSelection> | undefined) => {
...@@ -813,10 +825,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -813,10 +825,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
813 const setInteractionMode = useCallback(825 const setInteractionMode = useCallback(
814 (value: ProviderInteractionMode) => {826 (value: ProviderInteractionMode) => {
815 if (selectedProjectDraftKey) {827 if (selectedProjectDraftKey) {
816 updateComposerDraftSettings(selectedProjectDraftKey, { interactionMode: value });828 updateComposerDraftSettings(selectedProjectDraftKey, {
829 interactionMode: resolveProviderInteractionMode(selectedProviderStatus, value),
830 });
817 }831 }
818 },832 },
819 [selectedProjectDraftKey],833 [selectedProjectDraftKey, selectedProviderStatus],
820 );834 );
821835
822 const beginEditingPendingTask = useCallback((messageId: string): boolean => {836 const beginEditingPendingTask = useCallback((messageId: string): boolean => {
...@@ -859,8 +873,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -859,8 +873,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
859 }873 }
860 const draft = getComposerDraftSnapshot(selectedProjectDraftKey);874 const draft = getComposerDraftSnapshot(selectedProjectDraftKey);
861 const text = draft.text.trim();875 const text = draft.text.trim();
862 // Same availability gate the composer display applies: a stored876 // Use the displayed selection rules without substituting an unavailable
863 // selection targeting a disabled provider must not ride into the queue.877 // Antigravity model while the task is queued.
864 const draftModelSelection =878 const draftModelSelection =
865 resolveSelectableModelSelection(879 resolveSelectableModelSelection(
866 selectedEnvironmentServerConfig,880 selectedEnvironmentServerConfig,
...@@ -895,9 +909,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -895,9 +909,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
895 runtimeMode: draft.runtimeMode ?? DEFAULT_RUNTIME_MODE,909 runtimeMode: draft.runtimeMode ?? DEFAULT_RUNTIME_MODE,
896 interactionMode: resolvePendingTaskInteractionMode({910 interactionMode: resolvePendingTaskInteractionMode({
897 preferenceLoaded: planModePreferenceLoaded,911 preferenceLoaded: planModePreferenceLoaded,
898 planModeEnabled,912 planModeEnabled: legacyPlanModeEnabled,
899 draftInteractionMode: draft.interactionMode,913 draftInteractionMode: draft.interactionMode,
900 queuedInteractionMode: editingPendingTask?.interactionMode,914 queuedInteractionMode: editingPendingTask?.interactionMode,
915 provider: selectedEnvironmentServerConfig?.providers.find(
916 (candidate) => candidate.instanceId === draftModelSelection.instanceId,
917 ),
901 }),918 }),
902 creation: {919 creation: {
903 projectId: selectedProject.id,920 projectId: selectedProject.id,
...@@ -927,7 +944,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -927,7 +944,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
927 selectedModel,944 selectedModel,
928 selectedProject,945 selectedProject,
929 selectedProjectDraftKey,946 selectedProjectDraftKey,
930 planModeEnabled,947 legacyPlanModeEnabled,
931 planModePreferenceLoaded,948 planModePreferenceLoaded,
932 startFromOrigin,949 startFromOrigin,
933 workspaceMode,950 workspaceMode,
apps/mobile/src/features/threads/provider-catalog-refresh.test.ts+5-2
...@@ -9,7 +9,7 @@ import {...@@ -9,7 +9,7 @@ import {
9} from "./provider-catalog-refresh";9} from "./provider-catalog-refresh";
1010
11describe("mobile provider catalog refresh", () => {11describe("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 () => {
13 let resolveRefresh: ((value: "refreshed") => void) | undefined;13 let resolveRefresh: ((value: "refreshed") => void) | undefined;
14 const refreshProviders = vi.fn(14 const refreshProviders = vi.fn(
15 () =>15 () =>
...@@ -25,7 +25,10 @@ describe("mobile provider catalog refresh", () => {...@@ -25,7 +25,10 @@ describe("mobile provider catalog refresh", () => {
2525
26 expect(second).toBe(first);26 expect(second).toBe(first);
27 expect(refreshProviders).toHaveBeenCalledOnce();27 expect(refreshProviders).toHaveBeenCalledOnce();
28 expect(refreshProviders).toHaveBeenCalledWith({ environmentId, input: {} });28 expect(refreshProviders).toHaveBeenCalledWith({
29 environmentId,
30 input: { refreshModels: true },
31 });
2932
30 resolveRefresh?.("refreshed");33 resolveRefresh?.("refreshed");
31 await expect(first).resolves.toBe("refreshed");34 await expect(first).resolves.toBe("refreshed");
apps/mobile/src/features/threads/provider-catalog-refresh.ts+3-3
...@@ -7,10 +7,10 @@ import {...@@ -7,10 +7,10 @@ import {
77
8type RefreshProvidersTarget = {8type RefreshProvidersTarget = {
9 readonly environmentId: EnvironmentId;9 readonly environmentId: EnvironmentId;
10 readonly input: Record<never, never>;10 readonly input: { readonly refreshModels: true };
11};11};
1212
13/** Deduplicates taps while the server refresh command is still running. */13/** Explicit model-picker refresh. Repeated taps share the pending discovery. */
14export function createProviderCatalogRefreshRunner<Result>(14export function createProviderCatalogRefreshRunner<Result>(
15 refreshProviders: (target: RefreshProvidersTarget) => Promise<Result>,15 refreshProviders: (target: RefreshProvidersTarget) => Promise<Result>,
16) {16) {
...@@ -18,7 +18,7 @@ export function createProviderCatalogRefreshRunner<Result>(...@@ -18,7 +18,7 @@ export function createProviderCatalogRefreshRunner<Result>(
1818
19 return (environmentId: EnvironmentId): Promise<Result> => {19 return (environmentId: EnvironmentId): Promise<Result> => {
20 if (pending) return pending;20 if (pending) return pending;
21 pending = refreshProviders({ environmentId, input: {} }).finally(() => {21 pending = refreshProviders({ environmentId, input: { refreshModels: true } }).finally(() => {
22 pending = null;22 pending = null;
23 });23 });
24 return pending;24 return pending;
apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts+102-2
...@@ -1,9 +1,19 @@...@@ -1,9 +1,19 @@
1import { describe, expect, it } from "vite-plus/test";1import { 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
5import type { ModelOption } from "../../lib/modelOptions";10import 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
8function modelOption(18function modelOption(
9 model: string,19 model: string,
...@@ -96,4 +106,94 @@ describe("thread settings sheet state", () => {...@@ -96,4 +106,94 @@ describe("thread settings sheet state", () => {
96 }),106 }),
97 ).toBe(pressed);107 ).toBe(pressed);
98 });108 });
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 });
99});199});
apps/mobile/src/features/threads/thread-settings-sheet-state.ts+33-1
...@@ -1,4 +1,26 @@...@@ -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
3/** Match the terms a user can actually see or recognize in the model picker. */25/** Match the terms a user can actually see or recognize in the model picker. */
4export function modelMatchesCatalogQuery(input: {26export function modelMatchesCatalogQuery(input: {
...@@ -31,6 +53,16 @@ export function pendingModelAfterPress(input: {...@@ -31,6 +53,16 @@ export function pendingModelAfterPress(input: {
31 return input.current?.key === input.pressed.key ? input.current : input.pressed;53 return input.current?.key === input.pressed.key ? input.current : input.pressed;
32}54}
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
34/**66/**
35 * Primary and selected providers start open; all other catalogs start closed.67 * Primary and selected providers start open; all other catalogs start closed.
36 * A user's disclosure tap inverts that default until the picker is dismissed.68 * 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 @@...@@ -1,4 +1,5 @@
1import { describe, expect, it, vi } from "vite-plus/test";1import { describe, expect, it, vi } from "vite-plus/test";
2import { ProviderDriverKind } from "@t3tools/contracts";
23
3vi.mock("../../state/queries", () => ({4vi.mock("../../state/queries", () => ({
4 useComposerPathSearch: () => ({ entries: [], isPending: false }),5 useComposerPathSearch: () => ({ entries: [], isPending: false }),
...@@ -10,10 +11,93 @@ vi.mock("../../state/use-atom-command", () => ({...@@ -10,10 +11,93 @@ vi.mock("../../state/use-atom-command", () => ({
10 useAtomCommand: () => vi.fn(),11 useAtomCommand: () => vi.fn(),
11}));12}));
1213
13import { composerSelectionAtEnd } from "./use-composer-command-menu";14import {
15 buildComposerSlashCommandItems,
16 composerSelectionAtEnd,
17 resolveComposerCommandSelection,
18} from "./use-composer-command-menu";
1419
15describe("composerSelectionAtEnd", () => {20describe("composerSelectionAtEnd", () => {
16 it("resets a changed draft owner to the new draft end", () => {21 it("resets a changed draft owner to the new draft end", () => {
17 expect(composerSelectionAtEnd("queued task 🧪")).toEqual({ start: 14, end: 14 });22 expect(composerSelectionAtEnd("queued task 🧪")).toEqual({ start: 14, end: 14 });
18 });23 });
19});24});
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 {...@@ -3,6 +3,7 @@ import {
3 detectComposerTrigger,3 detectComposerTrigger,
4 replaceTextRange,4 replaceTextRange,
5 serializeComposerFileLink,5 serializeComposerFileLink,
6 type ComposerTrigger,
6} from "@t3tools/shared/composerTrigger";7} from "@t3tools/shared/composerTrigger";
7import {8import {
8 insertRankedSearchResult,9 insertRankedSearchResult,
...@@ -30,6 +31,107 @@ export function composerSelectionAtEnd(draftMessage: string): ComposerEditorSele...@@ -30,6 +31,107 @@ export function composerSelectionAtEnd(draftMessage: string): ComposerEditorSele
30 return { start: draftMessage.length, end: draftMessage.length };31 return { start: draftMessage.length, end: draftMessage.length };
31}32}
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
33/** Shared autocomplete for thread composers and unsent new-task drafts. */135/** Shared autocomplete for thread composers and unsent new-task drafts. */
34export function useComposerCommandMenu({136export function useComposerCommandMenu({
35 draftMessage,137 draftMessage,
...@@ -79,7 +181,6 @@ export function useComposerCommandMenu({...@@ -79,7 +181,6 @@ export function useComposerCommandMenu({
79 selectedProviderStatus ? resolveProviderSkillsForCwd(selectedProviderStatus, projectCwd) : [],181 selectedProviderStatus ? resolveProviderSkillsForCwd(selectedProviderStatus, projectCwd) : [],
80 [projectCwd, selectedProviderStatus],182 [projectCwd, selectedProviderStatus],
81 );183 );
82 const slashCommands = selectedProviderStatus?.slashCommands ?? [];
83 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {184 const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
84 reportFailure: false,185 reportFailure: false,
85 });186 });
...@@ -157,59 +258,13 @@ export function useComposerCommandMenu({...@@ -157,59 +258,13 @@ export function useComposerCommandMenu({
157258
158 if (trigger.kind === "slash-command") {259 if (trigger.kind === "slash-command") {
159 const q = trigger.query.toLowerCase();260 const q = trigger.query.toLowerCase();
160 const allBuiltIn = [261 const commandItems = buildComposerSlashCommandItems({
161 {262 query: q,
162 id: "cmd:model",263 atMessageStart: trigger.rangeStart === 0,
163 type: "slash-command" as const,264 hasThread,
164 command: "model",265 allowInteractionMode: onUpdateInteractionMode !== undefined,
165 label: "/model",266 selectedProviderStatus,
166 description: "Switch model",267 });
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 }
213268
214 const skillItems = getProviderSkillsForSlashMenu(skills, true)269 const skillItems = getProviderSkillsForSlashMenu(skills, true)
215 .filter((skill) => matchesSlashSkillQuery(skill, q))270 .filter((skill) => matchesSlashSkillQuery(skill, q))
...@@ -221,7 +276,7 @@ export function useComposerCommandMenu({...@@ -221,7 +276,7 @@ export function useComposerCommandMenu({
221 description: skill.shortDescription ?? skill.description ?? "",276 description: skill.shortDescription ?? skill.description ?? "",
222 }));277 }));
223278
224 return [...builtIn, ...providerCommands, ...skillItems];279 return [...commandItems, ...skillItems];
225 }280 }
226281
227 if (trigger.kind === "skill") {282 if (trigger.kind === "skill") {
...@@ -328,7 +383,6 @@ export function useComposerCommandMenu({...@@ -328,7 +383,6 @@ export function useComposerCommandMenu({
328 pathSearch.entries,383 pathSearch.entries,
329 selectedProviderStatus,384 selectedProviderStatus,
330 skills,385 skills,
331 slashCommands,
332 trigger,386 trigger,
333 ]);387 ]);
334388
...@@ -336,38 +390,27 @@ export function useComposerCommandMenu({...@@ -336,38 +390,27 @@ export function useComposerCommandMenu({
336 (item: ComposerCommandItem) => {390 (item: ComposerCommandItem) => {
337 if (!trigger) return;391 if (!trigger) return;
338392
339 if (393 const result = resolveComposerCommandSelection({
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(
362 draftMessage,394 draftMessage,
363 trigger.rangeStart,395 trigger,
364 trigger.rangeEnd,396 item,
365 replacement,397 allowInteractionMode:
366 );398 onUpdateInteractionMode !== undefined &&
399 selectedProviderStatus?.showInteractionModeToggle !== false,
400 });
367 setSelection({ start: result.cursor, end: result.cursor });401 setSelection({ start: result.cursor, end: result.cursor });
368 onChangeDraftMessage(result.text);402 onChangeDraftMessage(result.text);
403 if (result.interactionMode !== null) {
404 onUpdateInteractionMode?.(result.interactionMode);
405 }
369 },406 },
370 [draftMessage, onChangeDraftMessage, onUpdateInteractionMode, trigger],407 [
408 draftMessage,
409 onChangeDraftMessage,
410 onUpdateInteractionMode,
411 selectedProviderStatus?.showInteractionModeToggle,
412 trigger,
413 ],
371 );414 );
372415
373 return {416 return {
apps/mobile/src/features/threads/use-project-actions.ts+19-1
...@@ -19,12 +19,14 @@ import { prepareTurnAttachments, validateDraftFileAttachments } from "../../lib/...@@ -19,12 +19,14 @@ import { prepareTurnAttachments, validateDraftFileAttachments } from "../../lib/
19import { makeTurnCommandMetadata, type TurnCommandMetadata } from "../../lib/commandMetadata";19import { makeTurnCommandMetadata, type TurnCommandMetadata } from "../../lib/commandMetadata";
20import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn";20import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn";
21import { randomHex } from "../../lib/uuid";21import { randomHex } from "../../lib/uuid";
22import { isModelSelectionUnavailable } from "../../lib/modelOptions";
22import { useAtomCommand } from "../../state/use-atom-command";23import { useAtomCommand } from "../../state/use-atom-command";
23import { scheduleUnusedComposerAttachmentCleanup } from "../../state/use-composer-drafts";24import { scheduleUnusedComposerAttachmentCleanup } from "../../state/use-composer-drafts";
24import { setPendingConnectionError } from "../../state/use-remote-environment-registry";25import { setPendingConnectionError } from "../../state/use-remote-environment-registry";
25import { validateProjectThreadCreation } from "./projectThreadCreationValidation";26import { validateProjectThreadCreation } from "./projectThreadCreationValidation";
26import { appAtomRegistry } from "../../state/atom-registry";27import { appAtomRegistry } from "../../state/atom-registry";
27import { serverEnvironment } from "../../state/server";28import { serverEnvironment } from "../../state/server";
29import { resolveProviderInteractionMode } from "./legacy-plan-mode";
2830
29export function useCreateProjectThread() {31export function useCreateProjectThread() {
30 const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });32 const startTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false });
...@@ -110,6 +112,22 @@ export function useCreateProjectThread() {...@@ -110,6 +112,22 @@ export function useCreateProjectThread() {
110 return AsyncResult.failure(Cause.fail(new Error(preparedAttachmentError)));112 return AsyncResult.failure(Cause.fail(new Error(preparedAttachmentError)));
111 }113 }
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
113 const result = await startTurn({131 const result = await startTurn({
114 environmentId: input.project.environmentId,132 environmentId: input.project.environmentId,
115 input: buildProjectThreadStartTurnInput({133 input: buildProjectThreadStartTurnInput({
...@@ -124,7 +142,7 @@ export function useCreateProjectThread() {...@@ -124,7 +142,7 @@ export function useCreateProjectThread() {
124 uploadedAttachments: prepared.attachments,142 uploadedAttachments: prepared.attachments,
125 modelSelection: input.modelSelection,143 modelSelection: input.modelSelection,
126 runtimeMode: input.runtimeMode,144 runtimeMode: input.runtimeMode,
127 interactionMode: input.interactionMode,145 interactionMode: resolveProviderInteractionMode(provider, input.interactionMode),
128 workspaceMode: input.envMode,146 workspaceMode: input.envMode,
129 branch: input.branch,147 branch: input.branch,
130 worktreePath: input.worktreePath,148 worktreePath: input.worktreePath,
apps/mobile/src/lib/copyTextWithHaptic.test.ts+19-31
...@@ -18,12 +18,7 @@ vi.mock("expo-haptics", () => ({...@@ -18,12 +18,7 @@ vi.mock("expo-haptics", () => ({
18 selectionAsync: mocks.selectionAsync,18 selectionAsync: mocks.selectionAsync,
19}));19}));
2020
21import {21import { copyTextWithHaptic, tryCopyTextWithHaptic } from "./copyTextWithHaptic";
22 CopyTextClipboardWriteError,
23 CopyTextHapticFeedbackError,
24 copyTextWithHaptic,
25 tryCopyTextWithHaptic,
26} from "./copyTextWithHaptic";
2722
28describe("copyTextWithHaptic", () => {23describe("copyTextWithHaptic", () => {
29 beforeEach(() => {24 beforeEach(() => {
...@@ -69,36 +64,29 @@ describe("copyTextWithHaptic", () => {...@@ -69,36 +64,29 @@ describe("copyTextWithHaptic", () => {
69 });64 });
7065
71 it("reports structured failures without including clipboard contents", async () => {66 it("reports structured failures without including clipboard contents", async () => {
72 const clipboardCause = new Error("native clipboard failure");67 const content = "https://accounts.google.com/auth?state=private-state&code=private-code";
73 const hapticCause = new Error("native haptic failure");68 const clipboardCause = new Error(`Cannot copy ${content}`);
69 const hapticCause = new Error(`Native failure for ${content}`);
74 const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);70 const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
75 mocks.setStringAsync.mockRejectedValueOnce(clipboardCause);71 mocks.setStringAsync.mockRejectedValueOnce(clipboardCause);
76 mocks.impactAsync.mockRejectedValueOnce(hapticCause);72 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(() => {76 expect(consoleError).toHaveBeenCalledWith(
81 expect(consoleError).toHaveBeenCalledTimes(2);77 "Failed to copy provider-sign-in-link to the clipboard.",
82 });78 expect.objectContaining({
8379 _tag: "CopyTextClipboardWriteError",
84 const failures = consoleError.mock.calls.map(([failure]) => failure);80 target: "provider-sign-in-link",
85 const clipboardError = failures.find(81 }),
86 (failure) => failure instanceof CopyTextClipboardWriteError,
87 );82 );
88 expect(clipboardError).toBeInstanceOf(CopyTextClipboardWriteError);83 expect(consoleError).toHaveBeenCalledWith(
89 expect(clipboardError).toMatchObject({84 "Failed to trigger light-impact haptic feedback after copying provider-sign-in-link.",
90 target: "connection-trace-id",85 expect.objectContaining({ _tag: "CopyTextHapticFeedbackError", feedback: "light-impact" }),
91 cause: clipboardCause,86 );
92 });87 const diagnostics = JSON.stringify(consoleError.mock.calls);
93 expect((clipboardError as Error).message).not.toContain("secret clipboard contents");88 expect(diagnostics).not.toContain("private-state");
9489 expect(diagnostics).not.toContain("private-code");
95 const hapticError = failures.find((failure) => failure instanceof CopyTextHapticFeedbackError);90 expect(diagnostics).not.toContain("cause");
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");
103 });91 });
104});92});
apps/mobile/src/lib/copyTextWithHaptic.ts+4-13
...@@ -44,12 +44,8 @@ export async function tryCopyTextWithHaptic(...@@ -44,12 +44,8 @@ export async function tryCopyTextWithHaptic(
44 await Clipboard.setStringAsync(value);44 await Clipboard.setStringAsync(value);
45 return true;45 return true;
46 } catch (cause) {46 } catch (cause) {
47 console.error(47 const error = new CopyTextClipboardWriteError({ target, cause });
48 new CopyTextClipboardWriteError({48 console.error(error.message, { _tag: error._tag, target, stack: error.stack });
49 target,
50 cause,
51 }),
52 );
53 return false;49 return false;
54 }50 }
55 })();51 })();
...@@ -62,13 +58,8 @@ export async function tryCopyTextWithHaptic(...@@ -62,13 +58,8 @@ export async function tryCopyTextWithHaptic(
62 await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);58 await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
63 }59 }
64 } catch (cause) {60 } catch (cause) {
65 console.error(61 const error = new CopyTextHapticFeedbackError({ target, feedback, cause });
66 new CopyTextHapticFeedbackError({62 console.error(error.message, { _tag: error._tag, target, feedback, stack: error.stack });
67 target,
68 feedback,
69 cause,
70 }),
71 );
72 }63 }
73 })();64 })();
7465
apps/mobile/src/lib/modelOptions.test.ts+155-1
...@@ -5,6 +5,7 @@ import { ProviderInstanceId, type ModelSelection, type ServerConfig } from "@t3t...@@ -5,6 +5,7 @@ import { ProviderInstanceId, type ModelSelection, type ServerConfig } from "@t3t
5import {5import {
6 buildModelOptions,6 buildModelOptions,
7 groupByProvider,7 groupByProvider,
8 isModelSelectionUnavailable,
8 resolveDefaultableModelSelection,9 resolveDefaultableModelSelection,
9 resolveNewTaskModelSelection,10 resolveNewTaskModelSelection,
10 resolveSelectableModelSelection,11 resolveSelectableModelSelection,
...@@ -191,10 +192,153 @@ describe("mobile model options", () => {...@@ -191,10 +192,153 @@ describe("mobile model options", () => {
191 expect(resolveSelectableModelSelection(config, usable)).toBe(usable);192 expect(resolveSelectableModelSelection(config, usable)).toBe(usable);
192 expect(resolveSelectableModelSelection(config, disabled)).toBeNull();193 expect(resolveSelectableModelSelection(config, disabled)).toBeNull();
193 expect(resolveSelectableModelSelection(config, removed)).toBeNull();194 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.
195 expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled);197 expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled);
196 });198 });
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
198 it("keeps legacy models out of implicit defaults", () => {342 it("keeps legacy models out of implicit defaults", () => {
199 const config = {343 const config = {
200 providers: [344 providers: [
...@@ -253,5 +397,15 @@ describe("mobile model options", () => {...@@ -253,5 +397,15 @@ describe("mobile model options", () => {
253 expect(resolve(null, project, sticky)).toBe(project);397 expect(resolve(null, project, sticky)).toBe(project);
254 expect(resolve(null, null, sticky)).toBe(sticky);398 expect(resolve(null, null, sticky)).toBe(sticky);
255 expect(resolve(null, null, null)).toBe(providerDefault.selection);399 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();
256 });410 });
257});411});
apps/mobile/src/lib/modelOptions.ts+71-21
...@@ -17,6 +17,7 @@ export type ModelOption = {...@@ -17,6 +17,7 @@ export type ModelOption = {
17 readonly providerDriver: string;17 readonly providerDriver: string;
18 readonly isDefault: boolean;18 readonly isDefault: boolean;
19 readonly isLegacy: boolean;19 readonly isLegacy: boolean;
20 readonly isUnavailable?: boolean;
20 readonly capabilities: ModelCapabilities | null;21 readonly capabilities: ModelCapabilities | null;
21 readonly selection: ModelSelection;22 readonly selection: ModelSelection;
22};23};
...@@ -60,12 +61,34 @@ function normalizeSelectionOptions(...@@ -60,12 +61,34 @@ function normalizeSelectionOptions(
60 };61 };
61}62}
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
63/**88/**
64 * A stored model selection is only usable when its provider instance is89 * Keep Antigravity selections when setup or catalog changes make them
65 * currently enabled, installed, and authenticated on the server. Returns the90 * unavailable. Other providers fall through to the server default when they
66 * selection unchanged when usable, otherwise `null` so callers fall through to91 * are disabled, missing, or signed out. Without config, keep stored selections.
67 * the server's default model. A missing config (environment offline) cannot be
68 * validated, so stored selections pass through untouched.
69 */92 */
70export function resolveSelectableModelSelection(93export function resolveSelectableModelSelection(
71 config: T3ServerConfig | null | undefined,94 config: T3ServerConfig | null | undefined,
...@@ -77,6 +100,11 @@ export function resolveSelectableModelSelection(...@@ -77,6 +100,11 @@ export function resolveSelectableModelSelection(
77 const provider = config.providers.find(100 const provider = config.providers.find(
78 (candidate) => candidate.instanceId === selection.instanceId,101 (candidate) => candidate.instanceId === selection.instanceId,
79 );102 );
103 const driver =
104 provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver;
105 if (driver === "antigravity") {
106 return selection;
107 }
80 return provider &&108 return provider &&
81 provider.enabled &&109 provider.enabled &&
82 provider.installed &&110 provider.installed &&
...@@ -86,11 +114,9 @@ export function resolveSelectableModelSelection(...@@ -86,11 +114,9 @@ export function resolveSelectableModelSelection(
86}114}
87115
88/**116/**
89 * Like resolveSelectableModelSelection, but additionally rejects legacy117 * Reject legacy models for implicit defaults, except Antigravity selections,
90 * models. Used for implicit defaults (stored draft, project last-used): a118 * which must not silently change after a catalog update. Explicit picks in
91 * new thread should never quietly start on a legacy model, so those fall119 * the settings sheet are unaffected.
92 * through to the provider's default instead. Explicit picks in the settings
93 * sheet are unaffected.
94 */120 */
95export function resolveDefaultableModelSelection(121export function resolveDefaultableModelSelection(
96 config: T3ServerConfig | null | undefined,122 config: T3ServerConfig | null | undefined,
...@@ -102,7 +128,7 @@ export function resolveDefaultableModelSelection(...@@ -102,7 +128,7 @@ export function resolveDefaultableModelSelection(
102 }128 }
103 const provider = config.providers.find((candidate) => candidate.instanceId === usable.instanceId);129 const provider = config.providers.find((candidate) => candidate.instanceId === usable.instanceId);
104 const model = provider?.models.find((candidate) => candidate.slug === usable.model);130 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;
106}132}
107133
108export function resolveNewTaskModelSelection(input: {134export function resolveNewTaskModelSelection(input: {
...@@ -115,8 +141,8 @@ export function resolveNewTaskModelSelection(input: {...@@ -115,8 +141,8 @@ export function resolveNewTaskModelSelection(input: {
115 input.draftSelection ??141 input.draftSelection ??
116 input.projectDefaultSelection ??142 input.projectDefaultSelection ??
117 input.stickySelection ??143 input.stickySelection ??
118 input.modelOptions.find((option) => option.isDefault)?.selection ??144 input.modelOptions.find((option) => option.isDefault && !option.isUnavailable)?.selection ??
119 input.modelOptions[0]?.selection ??145 input.modelOptions.find((option) => !option.isUnavailable)?.selection ??
120 null146 null
121 );147 );
122}148}
...@@ -128,7 +154,12 @@ export function buildModelOptions(...@@ -128,7 +154,12 @@ export function buildModelOptions(
128 const options = new Map<string, ModelOption>();154 const options = new Map<string, ModelOption>();
129155
130 for (const provider of config?.providers ?? []) {156 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 ) {
132 continue;163 continue;
133 }164 }
134165
...@@ -162,20 +193,39 @@ export function buildModelOptions(...@@ -162,20 +193,39 @@ export function buildModelOptions(
162 if (existing) {193 if (existing) {
163 options.set(key, {194 options.set(key, {
164 ...existing,195 ...existing,
165 selection: normalizeSelectionOptions(fallbackModelSelection, existing.capabilities),196 selection:
197 existing.providerDriver === "antigravity"
198 ? fallbackModelSelection
199 : normalizeSelectionOptions(fallbackModelSelection, existing.capabilities),
166 });200 });
167 } else {201 } 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 });
169 options.set(key, {216 options.set(key, {
170 key,217 key,
171 label: fallbackModelSelection.model,218 label: model?.name ?? fallbackModelSelection.model,
172 subtitle: "",219 subtitle: model?.subProvider ?? "",
173 providerKey: fallbackModelSelection.instanceId,220 providerKey: fallbackModelSelection.instanceId,
174 providerLabel,221 providerLabel,
175 providerDriver: fallbackModelSelection.instanceId,222 providerDriver,
176 isDefault: false,223 isDefault: false,
177 isLegacy: false,224 isLegacy: model?.isLegacy === true,
178 capabilities: null,225 ...(isModelSelectionUnavailable(config, fallbackModelSelection)
226 ? { isUnavailable: true }
227 : {}),
228 capabilities: model?.capabilities ?? null,
179 selection: fallbackModelSelection,229 selection: fallbackModelSelection,
180 });230 });
181 }231 }
apps/mobile/src/lib/openExternalUrl.test.ts+16
...@@ -55,4 +55,20 @@ describe("tryOpenExternalUrl", () => {...@@ -55,4 +55,20 @@ describe("tryOpenExternalUrl", () => {
55 expect(diagnosticText).not.toContain("token=secret");55 expect(diagnosticText).not.toContain("token=secret");
56 expect(diagnosticText).not.toContain("browser-unavailable-secret-sentinel");56 expect(diagnosticText).not.toContain("browser-unavailable-secret-sentinel");
57 });57 });
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 });
58});74});
apps/mobile/src/lib/openExternalUrl.ts+6-1
...@@ -1,7 +1,12 @@...@@ -1,7 +1,12 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import { Linking } from "react-native";2import { 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
6export type ExternalUrlTarget = typeof ExternalUrlTarget.Type;11export type ExternalUrlTarget = typeof ExternalUrlTarget.Type;
712
apps/mobile/src/lib/threadActivity.test.ts+107-11
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 buildPendingUserInputAnswers,16 buildPendingUserInputAnswers,
17 buildThreadFeed,17 buildThreadFeed,
18 derivePendingApprovals,18 derivePendingApprovals,
19 derivePendingUserInputs,
19 deriveThreadFeedPresentation,20 deriveThreadFeedPresentation,
20 isPendingUserInputOptionSelected,21 isPendingUserInputOptionSelected,
21 setPendingUserInputCustomAnswer,22 setPendingUserInputCustomAnswer,
...@@ -74,15 +75,48 @@ const multiSelectQuestion = {...@@ -74,15 +75,48 @@ const multiSelectQuestion = {
74 multiSelect: true,75 multiSelect: true,
75} as const;76} 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
77describe("pending user input answers", () => {90describe("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
78 it("replaces single-select options and toggles multi-select options", () => {112 it("replaces single-select options and toggles multi-select options", () => {
79 expect(113 expect(
80 togglePendingUserInputOptionSelection(114 togglePendingUserInputOptionSelection(
81 singleSelectQuestion,115 singleSelectQuestion,
82 { selectedOptionLabels: ["Go"] },116 { selectedOptionValues: ["Go"] },
83 "Node.js",117 "Node.js",
84 ),118 ),
85 ).toEqual({ customAnswer: "", selectedOptionLabels: ["Node.js"] });119 ).toEqual({ customAnswer: "", selectedOptionValues: ["Node.js"] });
86120
87 const orders = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Orders");121 const orders = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Orders");
88 const ordersAndListings = togglePendingUserInputOptionSelection(122 const ordersAndListings = togglePendingUserInputOptionSelection(
...@@ -92,18 +126,18 @@ describe("pending user input answers", () => {...@@ -92,18 +126,18 @@ describe("pending user input answers", () => {
92 );126 );
93 expect(ordersAndListings).toEqual({127 expect(ordersAndListings).toEqual({
94 customAnswer: "",128 customAnswer: "",
95 selectedOptionLabels: ["Orders", "Listings"],129 selectedOptionValues: ["Orders", "Listings"],
96 });130 });
97 expect(131 expect(
98 togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"),132 togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"),
99 ).toEqual({ customAnswer: "", selectedOptionLabels: ["Listings"] });133 ).toEqual({ customAnswer: "", selectedOptionValues: ["Listings"] });
100134
101 const paddedOrders = togglePendingUserInputOptionSelection(135 const paddedOrders = togglePendingUserInputOptionSelection(
102 multiSelectQuestion,136 multiSelectQuestion,
103 undefined,137 undefined,
104 " Orders ",138 " Orders ",
105 );139 );
106 expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionLabels: ["Orders"] });140 expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionValues: ["Orders"] });
107 expect(141 expect(
108 togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "),142 togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "),
109 ).toEqual({ customAnswer: "" });143 ).toEqual({ customAnswer: "" });
...@@ -112,8 +146,8 @@ describe("pending user input answers", () => {...@@ -112,8 +146,8 @@ describe("pending user input answers", () => {
112 it("builds array answers for multi-select questions", () => {146 it("builds array answers for multi-select questions", () => {
113 expect(147 expect(
114 buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], {148 buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], {
115 runtime: { selectedOptionLabels: ["Go"] },149 runtime: { selectedOptionValues: ["Go"] },
116 scope: { selectedOptionLabels: ["Orders", "Listings"] },150 scope: { selectedOptionValues: ["Orders", "Listings"] },
117 }),151 }),
118 ).toEqual({152 ).toEqual({
119 runtime: "Go",153 runtime: "Go",
...@@ -124,23 +158,85 @@ describe("pending user input answers", () => {...@@ -124,23 +158,85 @@ describe("pending user input answers", () => {
124 it("clears selected options while a custom answer is active", () => {158 it("clears selected options while a custom answer is active", () => {
125 expect(159 expect(
126 setPendingUserInputCustomAnswer(160 setPendingUserInputCustomAnswer(
127 { selectedOptionLabels: ["Orders", "Listings"] },161 multiSelectQuestion,
162 { selectedOptionValues: ["Orders", "Listings"] },
128 "Orders first",163 "Orders first",
129 ),164 ),
130 ).toEqual({ customAnswer: "Orders first" });165 ).toEqual({ customAnswer: "Orders first" });
131 });166 });
132167
133 it("matches selected chips against normalized option labels", () => {168 it("matches selected options against normalized legacy labels", () => {
134 expect(169 expect(
135 isPendingUserInputOptionSelected({ selectedOptionLabels: ["Orders"] }, " Orders "),170 isPendingUserInputOptionSelected(
171 multiSelectQuestion,
172 { selectedOptionValues: ["Orders"] },
173 " Orders ",
174 ),
136 ).toBe(true);175 ).toBe(true);
137 expect(176 expect(
138 isPendingUserInputOptionSelected(177 isPendingUserInputOptionSelected(
139 { selectedOptionLabels: ["Orders"], customAnswer: "Orders first" },178 multiSelectQuestion,
179 { selectedOptionValues: ["Orders"], customAnswer: "Orders first" },
140 " Orders ",180 " Orders ",
141 ),181 ),
142 ).toBe(false);182 ).toBe(false);
143 });183 });
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 });
144});240});
145241
146describe("pending approvals", () => {242describe("pending approvals", () => {
apps/mobile/src/lib/threadActivity.ts+66-23
...@@ -52,7 +52,7 @@ export interface PendingUserInput {...@@ -52,7 +52,7 @@ export interface PendingUserInput {
52}52}
5353
54export interface PendingUserInputDraftAnswer {54export interface PendingUserInputDraftAnswer {
55 readonly selectedOptionLabels?: ReadonlyArray<string>;55 readonly selectedOptionValues?: ReadonlyArray<string>;
56 readonly customAnswer?: string;56 readonly customAnswer?: string;
57}57}
5858
...@@ -241,6 +241,7 @@ function parseUserInputQuestions(...@@ -241,6 +241,7 @@ function parseUserInputQuestions(
241 return {241 return {
242 label: record.label,242 label: record.label,
243 description: record.description,243 description: record.description,
244 ...(typeof record.value === "string" ? { value: record.value } : {}),
244 };245 };
245 })246 })
246 .filter((option): option is UserInputQuestion["options"][number] => option !== null);247 .filter((option): option is UserInputQuestion["options"][number] => option !== null);
...@@ -253,6 +254,9 @@ function parseUserInputQuestions(...@@ -253,6 +254,9 @@ function parseUserInputQuestions(
253 question: question.question,254 question: question.question,
254 options,255 options,
255 multiSelect: question.multiSelect === true,256 multiSelect: question.multiSelect === true,
257 ...(typeof question.allowCustomAnswer === "boolean"
258 ? { allowCustomAnswer: question.allowCustomAnswer }
259 : {}),
256 };260 };
257 })261 })
258 .filter((question): question is UserInputQuestion => question !== null);262 .filter((question): question is UserInputQuestion => question !== null);
...@@ -268,7 +272,23 @@ function normalizeDraftAnswer(value: string | undefined): string | null {...@@ -268,7 +272,23 @@ function normalizeDraftAnswer(value: string | undefined): string | null {
268 return trimmed.length > 0 ? trimmed : null;272 return trimmed.length > 0 ? trimmed : null;
269}273}
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,
272 value: ReadonlyArray<string> | undefined,292 value: ReadonlyArray<string> | undefined,
273): ReadonlyArray<string> {293): ReadonlyArray<string> {
274 if (!Array.isArray(value)) {294 if (!Array.isArray(value)) {
...@@ -276,7 +296,11 @@ function normalizeSelectedOptionLabels(...@@ -276,7 +296,11 @@ function normalizeSelectedOptionLabels(
276 }296 }
277297
278 return Array.from(298 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 ),
280 );304 );
281}305}
282306
...@@ -284,16 +308,17 @@ function resolvePendingUserInputAnswer(...@@ -284,16 +308,17 @@ function resolvePendingUserInputAnswer(
284 question: UserInputQuestion,308 question: UserInputQuestion,
285 draft: PendingUserInputDraftAnswer | undefined,309 draft: PendingUserInputDraftAnswer | undefined,
286): string | ReadonlyArray<string> | null {310): string | ReadonlyArray<string> | null {
287 const customAnswer = normalizeDraftAnswer(draft?.customAnswer);311 const customAnswer =
312 question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer);
288 if (customAnswer) {313 if (customAnswer) {
289 return customAnswer;314 return customAnswer;
290 }315 }
291316
292 const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels);317 const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues);
293 if (question.multiSelect) {318 if (question.multiSelect) {
294 return selectedOptionLabels.length > 0 ? selectedOptionLabels : null;319 return selectedOptionValues.length > 0 ? selectedOptionValues : null;
295 }320 }
296 return selectedOptionLabels[0] ?? null;321 return selectedOptionValues[0] ?? null;
297}322}
298323
299/** Codex children settle via task.updated (idle/failed/interrupted), never324/** Codex children settle via task.updated (idle/failed/interrupted), never
...@@ -1759,54 +1784,72 @@ export function derivePendingUserInputs(...@@ -1759,54 +1784,72 @@ export function derivePendingUserInputs(
1759}1784}
17601785
1761export function setPendingUserInputCustomAnswer(1786export function setPendingUserInputCustomAnswer(
1787 question: UserInputQuestion,
1762 draft: PendingUserInputDraftAnswer | undefined,1788 draft: PendingUserInputDraftAnswer | undefined,
1763 customAnswer: string,1789 customAnswer: string,
1764): PendingUserInputDraftAnswer {1790): PendingUserInputDraftAnswer {
1765 const selectedOptionLabels =1791 if (question.allowCustomAnswer === false) {
1792 return draft ?? {};
1793 }
1794
1795 const selectedOptionValues =
1766 customAnswer.trim().length > 01796 customAnswer.trim().length > 0
1767 ? undefined1797 ? undefined
1768 : normalizeSelectedOptionLabels(draft?.selectedOptionLabels);1798 : normalizeSelectedOptionValues(question, draft?.selectedOptionValues);
1769 return {1799 return {
1770 customAnswer,1800 customAnswer,
1771 ...(selectedOptionLabels && selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}),1801 ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}),
1772 };1802 };
1773}1803}
17741804
1775export function isPendingUserInputOptionSelected(1805export function isPendingUserInputOptionSelected(
1806 question: UserInputQuestion,
1776 draft: PendingUserInputDraftAnswer | undefined,1807 draft: PendingUserInputDraftAnswer | undefined,
1777 optionLabel: string,1808 optionValue: string,
1778): boolean {1809): boolean {
1779 if (normalizeDraftAnswer(draft?.customAnswer)) {1810 if (question.allowCustomAnswer !== false && normalizeDraftAnswer(draft?.customAnswer)) {
1780 return false;1811 return false;
1781 }1812 }
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 );
1784}1821}
17851822
1786export function togglePendingUserInputOptionSelection(1823export function togglePendingUserInputOptionSelection(
1787 question: UserInputQuestion,1824 question: UserInputQuestion,
1788 draft: PendingUserInputDraftAnswer | undefined,1825 draft: PendingUserInputDraftAnswer | undefined,
1789 optionLabel: string,1826 optionValue: string,
1790): PendingUserInputDraftAnswer {1827): PendingUserInputDraftAnswer {
1791 const normalizedOptionLabel = optionLabel.trim();1828 const resolvedOptionValue = resolvePendingUserInputOptionValue(question, optionValue);
1829 if (resolvedOptionValue === null) {
1830 return draft ?? {};
1831 }
17921832
1793 if (question.multiSelect) {1833 if (question.multiSelect) {
1794 const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels);1834 const selectedOptionValues = normalizeSelectedOptionValues(
1795 const nextSelectedOptionLabels = selectedOptionLabels.includes(normalizedOptionLabel)1835 question,
1796 ? selectedOptionLabels.filter((label) => label !== normalizedOptionLabel)1836 draft?.selectedOptionValues,
1797 : [...selectedOptionLabels, normalizedOptionLabel];1837 );
1838 const nextSelectedOptionValues = selectedOptionValues.includes(resolvedOptionValue)
1839 ? selectedOptionValues.filter((value) => value !== resolvedOptionValue)
1840 : [...selectedOptionValues, resolvedOptionValue];
17981841
1799 return {1842 return {
1800 customAnswer: "",1843 customAnswer: "",
1801 ...(nextSelectedOptionLabels.length > 01844 ...(nextSelectedOptionValues.length > 0
1802 ? { selectedOptionLabels: nextSelectedOptionLabels }1845 ? { selectedOptionValues: nextSelectedOptionValues }
1803 : {}),1846 : {}),
1804 };1847 };
1805 }1848 }
18061849
1807 return {1850 return {
1808 customAnswer: "",1851 customAnswer: "",
1809 selectedOptionLabels: [normalizedOptionLabel],1852 selectedOptionValues: [resolvedOptionValue],
1810 };1853 };
1811}1854}
18121855
...@@ -1818,7 +1861,7 @@ export function buildPendingUserInputAnswers(...@@ -1818,7 +1861,7 @@ export function buildPendingUserInputAnswers(
18181861
1819 for (const question of questions) {1862 for (const question of questions) {
1820 const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]);1863 const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]);
1821 if (!answer) {1864 if (answer === null) {
1822 return null;1865 return null;
1823 }1866 }
1824 answers[question.id] = answer;1867 answers[question.id] = answer;
apps/mobile/src/state/thread-outbox-model.ts+18-10
...@@ -18,12 +18,14 @@ import {...@@ -18,12 +18,14 @@ import {
18 type ProjectId as ProjectIdType,18 type ProjectId as ProjectIdType,
19 type ProviderInteractionMode as ProviderInteractionModeType,19 type ProviderInteractionMode as ProviderInteractionModeType,
20 type RuntimeMode as RuntimeModeType,20 type RuntimeMode as RuntimeModeType,
21 type ServerProvider,
21} from "@t3tools/contracts";22} from "@t3tools/contracts";
22import * as Schema from "effect/Schema";23import * as Schema from "effect/Schema";
2324
24import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema";25import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema";
25import type { DraftComposerAttachment } from "../lib/composerImages";26import type { DraftComposerAttachment } from "../lib/composerImages";
26import { scopedThreadKey } from "../lib/scopedEntities";27import { scopedThreadKey } from "../lib/scopedEntities";
28import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode";
2729
28const THREAD_OUTBOX_SCHEMA_VERSION = 3;30const THREAD_OUTBOX_SCHEMA_VERSION = 3;
29const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000;31const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000;
...@@ -93,11 +95,19 @@ export interface ThreadSettingsSnapshot {...@@ -93,11 +95,19 @@ export interface ThreadSettingsSnapshot {
93export function resolveQueuedThreadSettings(95export function resolveQueuedThreadSettings(
94 message: QueuedThreadMessage,96 message: QueuedThreadMessage,
95 thread: ThreadSettingsSnapshot,97 thread: ThreadSettingsSnapshot,
98 providers: ReadonlyArray<Pick<ServerProvider, "instanceId" | "showInteractionModeToggle">> = [],
96): ThreadSettingsSnapshot {99): ThreadSettingsSnapshot {
100 const modelSelection = message.modelSelection ?? thread.modelSelection;
101 const provider = providers.find(
102 (candidate) => candidate.instanceId === modelSelection.instanceId,
103 );
97 return {104 return {
98 modelSelection: message.modelSelection ?? thread.modelSelection,105 modelSelection,
99 runtimeMode: message.runtimeMode ?? thread.runtimeMode,106 runtimeMode: message.runtimeMode ?? thread.runtimeMode,
100 interactionMode: message.interactionMode ?? thread.interactionMode,107 interactionMode: resolveProviderInteractionMode(
108 provider,
109 message.interactionMode ?? thread.interactionMode,
110 ),
101 };111 };
102}112}
103113
...@@ -184,11 +194,9 @@ export type ThreadOutboxDispatchStep =...@@ -184,11 +194,9 @@ export type ThreadOutboxDispatchStep =
184 | { readonly step: "send" };194 | { readonly step: "send" };
185195
186/**196/**
187 * Orders the resolved delivery action against the file-capability gate. The197 * Wait for provider and file capabilities before sending. Cleanup does not
188 * gate applies only to a message that will send: a message whose thread198 * need config: a creation whose thread exists, or a message whose thread is
189 * already exists (or is gone) must be removed even while the server config is199 * gone, can still be removed while config loads.
190 * still loading, and a missing config defers with a retry instead of parking
191 * the message forever.
192 */200 */
193export function resolveThreadOutboxDispatchStep(input: {201export function resolveThreadOutboxDispatchStep(input: {
194 readonly deliveryAction: ThreadOutboxDeliveryAction;202 readonly deliveryAction: ThreadOutboxDeliveryAction;
...@@ -199,12 +207,12 @@ export function resolveThreadOutboxDispatchStep(input: {...@@ -199,12 +207,12 @@ export function resolveThreadOutboxDispatchStep(input: {
199 if (input.deliveryAction !== "send") {207 if (input.deliveryAction !== "send") {
200 return { step: input.deliveryAction };208 return { step: input.deliveryAction };
201 }209 }
202 if (input.fileAttachments.length === 0) {
203 return { step: "send" };
204 }
205 if (input.serverConfig === null) {210 if (input.serverConfig === null) {
206 return { step: "retry" };211 return { step: "retry" };
207 }212 }
213 if (input.fileAttachments.length === 0) {
214 return { step: "send" };
215 }
208 const maxBytes = input.serverConfig.maxFileUploadBytes;216 const maxBytes = input.serverConfig.maxFileUploadBytes;
209 if (maxBytes === undefined) {217 if (maxBytes === undefined) {
210 return { step: "restore", reason: "This server does not support file attachments." };218 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", () => {...@@ -150,6 +150,62 @@ describe("thread outbox", () => {
150 ).toBe(false);150 ).toBe(false);
151 });151 });
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
153 it("backs off queued delivery retries and caps them at sixteen seconds", () => {209 it("backs off queued delivery retries and caps them at sixteen seconds", () => {
154 expect([1, 2, 3, 4, 5, 6].map(threadOutboxRetryDelayMs)).toEqual([210 expect([1, 2, 3, 4, 5, 6].map(threadOutboxRetryDelayMs)).toEqual([
155 1_000, 2_000, 4_000, 8_000, 16_000, 16_000,211 1_000, 2_000, 4_000, 8_000, 16_000, 16_000,
...@@ -938,13 +994,20 @@ describe("thread outbox", () => {...@@ -938,13 +994,20 @@ describe("thread outbox", () => {
938 ).toEqual({ step: "send" });994 ).toEqual({ step: "send" });
939 });995 });
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", () => {
942 expect(998 expect(
943 resolveThreadOutboxDispatchStep({999 resolveThreadOutboxDispatchStep({
944 deliveryAction: "send",1000 deliveryAction: "send",
945 fileAttachments: [],1001 fileAttachments: [],
946 serverConfig: null,1002 serverConfig: null,
947 }),1003 }),
1004 ).toEqual({ step: "retry" });
1005 expect(
1006 resolveThreadOutboxDispatchStep({
1007 deliveryAction: "send",
1008 fileAttachments: [],
1009 serverConfig: { maxFileUploadBytes: undefined },
1010 }),
948 ).toEqual({ step: "send" });1011 ).toEqual({ step: "send" });
949 });1012 });
9501013
apps/mobile/src/state/use-selected-thread-requests.ts+14-10
...@@ -31,7 +31,7 @@ const userInputDraftsByRequestKeyAtom = Atom.make<...@@ -31,7 +31,7 @@ const userInputDraftsByRequestKeyAtom = Atom.make<
31function setUserInputDraftOption(31function setUserInputDraftOption(
32 requestKey: string,32 requestKey: string,
33 question: UserInputQuestion,33 question: UserInputQuestion,
34 label: string,34 value: string,
35): void {35): void {
36 const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom);36 const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom);
37 appAtomRegistry.set(userInputDraftsByRequestKeyAtom, {37 appAtomRegistry.set(userInputDraftsByRequestKeyAtom, {
...@@ -41,7 +41,7 @@ function setUserInputDraftOption(...@@ -41,7 +41,7 @@ function setUserInputDraftOption(
41 [question.id]: togglePendingUserInputOptionSelection(41 [question.id]: togglePendingUserInputOptionSelection(
42 question,42 question,
43 current[requestKey]?.[question.id],43 current[requestKey]?.[question.id],
44 label,44 value,
45 ),45 ),
46 },46 },
47 });47 });
...@@ -49,7 +49,7 @@ function setUserInputDraftOption(...@@ -49,7 +49,7 @@ function setUserInputDraftOption(
4949
50function setUserInputDraftCustomAnswer(50function setUserInputDraftCustomAnswer(
51 requestKey: string,51 requestKey: string,
52 questionId: string,52 question: UserInputQuestion,
53 customAnswer: string,53 customAnswer: string,
54): void {54): void {
55 const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom);55 const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom);
...@@ -57,8 +57,9 @@ function setUserInputDraftCustomAnswer(...@@ -57,8 +57,9 @@ function setUserInputDraftCustomAnswer(
57 ...current,57 ...current,
58 [requestKey]: {58 [requestKey]: {
59 ...current[requestKey],59 ...current[requestKey],
60 [questionId]: setPendingUserInputCustomAnswer(60 [question.id]: setPendingUserInputCustomAnswer(
61 current[requestKey]?.[questionId],61 question,
62 current[requestKey]?.[question.id],
62 customAnswer,63 customAnswer,
63 ),64 ),
64 },65 },
...@@ -108,27 +109,30 @@ export function useSelectedThreadRequests() {...@@ -108,27 +109,30 @@ export function useSelectedThreadRequests() {
108 : null;109 : null;
109110
110 const onSelectUserInputOption = useCallback(111 const onSelectUserInputOption = useCallback(
111 (requestId: ApprovalRequestId, question: UserInputQuestion, label: string) => {112 (requestId: ApprovalRequestId, question: UserInputQuestion, value: string) => {
112 if (!selectedThreadShell) {113 if (!selectedThreadShell) {
113 return;114 return;
114 }115 }
115116
116 const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId);117 const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId);
117 setUserInputDraftOption(requestKey, question, label);118 setUserInputDraftOption(requestKey, question, value);
118 },119 },
119 [selectedThreadShell],120 [selectedThreadShell],
120 );121 );
121122
122 const onChangeUserInputCustomAnswer = useCallback(123 const onChangeUserInputCustomAnswer = useCallback(
123 (requestId: ApprovalRequestId, questionId: string, customAnswer: string) => {124 (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) {
125 return;129 return;
126 }130 }
127131
128 const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId);132 const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId);
129 setUserInputDraftCustomAnswer(requestKey, questionId, customAnswer);133 setUserInputDraftCustomAnswer(requestKey, question, customAnswer);
130 },134 },
131 [selectedThreadShell],135 [activePendingUserInputs, selectedThreadShell],
132 );136 );
133137
134 const onRespondToApproval = useCallback(138 const onRespondToApproval = useCallback(
apps/mobile/src/state/use-thread-composer-state.ts+51-9
...@@ -5,6 +5,7 @@ import * as Cause from "effect/Cause";...@@ -5,6 +5,7 @@ import * as Cause from "effect/Cause";
55
6import {6import {
7 CommandId,7 CommandId,
8 DEFAULT_PROVIDER_INTERACTION_MODE,
8 MessageId,9 MessageId,
9 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,10 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
10 type EnvironmentId,11 type EnvironmentId,
...@@ -24,6 +25,8 @@ import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"...@@ -24,6 +25,8 @@ import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"
24import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";25import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";
2526
26import { makeQueuedMessageMetadata } from "../lib/commandMetadata";27import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
28import { isModelSelectionUnavailable } from "../lib/modelOptions";
29import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode";
27import {30import {
28 convertPastedImagesToAttachments,31 convertPastedImagesToAttachments,
29 pasteComposerClipboard,32 pasteComposerClipboard,
...@@ -145,7 +148,15 @@ export function useThreadComposerState() {...@@ -145,7 +148,15 @@ export function useThreadComposerState() {
145 const selectedThread = selectedThreadDetail ?? selectedThreadShell;148 const selectedThread = selectedThreadDetail ?? selectedThreadShell;
146 const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null;149 const modelSelection = selectedDraft?.modelSelection ?? selectedThread?.modelSelection ?? null;
147 const runtimeMode = selectedDraft?.runtimeMode ?? selectedThread?.runtimeMode ?? null;150 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
150 const selectedThreadSessionActivity = useMemo(() => {161 const selectedThreadSessionActivity = useMemo(() => {
151 const selectedThread = selectedThreadDetail ?? selectedThreadShell;162 const selectedThread = selectedThreadDetail ?? selectedThreadShell;
...@@ -207,8 +218,20 @@ export function useThreadComposerState() {...@@ -207,8 +218,20 @@ export function useThreadComposerState() {
207 return null;218 return null;
208 }219 }
209220
210 const provider = selectedEnvironmentRuntime?.serverConfig?.providers.find(221 const modelSelection = draft.modelSelection ?? thread.modelSelection;
211 (entry) => entry.instanceId === thread.modelSelection.instanceId,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,
212 );235 );
213 const feedbackCommand =236 const feedbackCommand =
214 attachments.length === 0 &&237 attachments.length === 0 &&
...@@ -285,9 +308,12 @@ export function useThreadComposerState() {...@@ -285,9 +308,12 @@ export function useThreadComposerState() {
285 commandId: CommandId.make(metadata.commandId),308 commandId: CommandId.make(metadata.commandId),
286 text,309 text,
287 attachments,310 attachments,
288 modelSelection: draft.modelSelection ?? thread.modelSelection,311 modelSelection,
289 runtimeMode: draft.runtimeMode ?? thread.runtimeMode,312 runtimeMode: draft.runtimeMode ?? thread.runtimeMode,
290 interactionMode: draft.interactionMode ?? thread.interactionMode,313 interactionMode: resolveProviderInteractionMode(
314 provider,
315 draft.interactionMode ?? thread.interactionMode,
316 ),
291 createdAt: metadata.createdAt,317 createdAt: metadata.createdAt,
292 });318 });
293 clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true });319 clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true });
...@@ -455,9 +481,17 @@ export function useThreadComposerState() {...@@ -455,9 +481,17 @@ export function useThreadComposerState() {
455 if (!selectedThreadKey) {481 if (!selectedThreadKey) {
456 return;482 return;
457 }483 }
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 });
459 },493 },
460 [selectedThreadKey],494 [selectedEnvironmentRuntime?.serverConfig, selectedThreadKey],
461 );495 );
462496
463 const onUpdateRuntimeMode = useCallback(497 const onUpdateRuntimeMode = useCallback(
...@@ -475,9 +509,17 @@ export function useThreadComposerState() {...@@ -475,9 +509,17 @@ export function useThreadComposerState() {
475 if (!selectedThreadKey) {509 if (!selectedThreadKey) {
476 return;510 return;
477 }511 }
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 });
479 },521 },
480 [selectedThreadKey],522 [selectedEnvironmentRuntime?.serverConfig, selectedThread?.modelSelection, selectedThreadKey],
481 );523 );
482524
483 return {525 return {
apps/mobile/src/state/use-thread-outbox-drain.test.ts+5
...@@ -80,6 +80,11 @@ vi.mock("./entities", () => ({...@@ -80,6 +80,11 @@ vi.mock("./entities", () => ({
80 useThreadShells: () => [],80 useThreadShells: () => [],
81}));81}));
8282
83vi.mock("./server", async () => {
84 const { Atom } = await import("effect/unstable/reactivity");
85 return { serverEnvironment: { configValueAtom: Atom.family(() => Atom.make(null)) } };
86});
87
83vi.mock("./threads", () => ({88vi.mock("./threads", () => ({
84 threadEnvironment: {},89 threadEnvironment: {},
85}));90}));
apps/mobile/src/state/use-thread-outbox-drain.ts+73-15
...@@ -20,8 +20,10 @@ import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities";...@@ -20,8 +20,10 @@ import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities";
20import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn";20import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn";
21import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload";21import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload";
22import { randomHex } from "../lib/uuid";22import { randomHex } from "../lib/uuid";
23import { isModelSelectionUnavailable } from "../lib/modelOptions";
23import { appAtomRegistry } from "./atom-registry";24import { appAtomRegistry } from "./atom-registry";
24import { useProjects, useServerConfigs, useThreadShells } from "./entities";25import { useProjects, useServerConfigs, useThreadShells } from "./entities";
26import { serverEnvironment } from "./server";
25import {27import {
26 confirmThreadOutboxMessageQueued,28 confirmThreadOutboxMessageQueued,
27 ensureThreadOutboxLoaded,29 ensureThreadOutboxLoaded,
...@@ -630,7 +632,17 @@ export function useThreadOutboxDrain(): void {...@@ -630,7 +632,17 @@ export function useThreadOutboxDrain(): void {
630632
631 const sendQueuedMessage = useCallback(633 const sendQueuedMessage = useCallback(
632 async (queuedMessage: QueuedThreadMessage, thread: EnvironmentThreadShell) => {634 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 }
634 const { reportFailure } = makeDeliveryHelpers(queuedMessage);646 const { reportFailure } = makeDeliveryHelpers(queuedMessage);
635647
636 if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) {648 if (!modelSelectionsEqual(settings.modelSelection, thread.modelSelection)) {
...@@ -686,8 +698,7 @@ export function useThreadOutboxDrain(): void {...@@ -686,8 +698,7 @@ export function useThreadOutboxDrain(): void {
686 try {698 try {
687 const preparedResult = await prepareQueuedMessageAttachments(699 const preparedResult = await prepareQueuedMessageAttachments(
688 queuedMessage,700 queuedMessage,
689 serverConfigs.get(queuedMessage.environmentId)?.environment.capabilities701 serverConfig.environment.capabilities.attachmentUploads === true,
690 .attachmentUploads === true,
691 );702 );
692 if (preparedResult.status === "abandoned") {703 if (preparedResult.status === "abandoned") {
693 return true;704 return true;
...@@ -715,6 +726,21 @@ export function useThreadOutboxDrain(): void {...@@ -715,6 +726,21 @@ export function useThreadOutboxDrain(): void {
715 if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) {726 if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) {
716 return true;727 return true;
717 }728 }
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 );
718 const deliveryResult = await startTurn({744 const deliveryResult = await startTurn({
719 environmentId: queuedMessage.environmentId,745 environmentId: queuedMessage.environmentId,
720 input: {746 input: {
...@@ -726,9 +752,9 @@ export function useThreadOutboxDrain(): void {...@@ -726,9 +752,9 @@ export function useThreadOutboxDrain(): void {
726 text: queuedMessage.text,752 text: queuedMessage.text,
727 attachments: prepared.attachments,753 attachments: prepared.attachments,
728 },754 },
729 modelSelection: settings.modelSelection,755 modelSelection: sendSettings.modelSelection,
730 runtimeMode: settings.runtimeMode,756 runtimeMode: sendSettings.runtimeMode,
731 interactionMode: settings.interactionMode,757 interactionMode: sendSettings.interactionMode,
732 createdAt: queuedMessage.createdAt,758 createdAt: queuedMessage.createdAt,
733 },759 },
734 });760 });
...@@ -760,7 +786,6 @@ export function useThreadOutboxDrain(): void {...@@ -760,7 +786,6 @@ export function useThreadOutboxDrain(): void {
760 startTurn,786 startTurn,
761 updateThreadMetadata,787 updateThreadMetadata,
762 restoreQueuedMessage,788 restoreQueuedMessage,
763 serverConfigs,
764 ],789 ],
765 );790 );
766791
...@@ -774,14 +799,32 @@ export function useThreadOutboxDrain(): void {...@@ -774,14 +799,32 @@ export function useThreadOutboxDrain(): void {
774 if (modelSelection === undefined) {799 if (modelSelection === undefined) {
775 return false;800 return false;
776 }801 }
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 }
777 let prepared: PreparedTurnAttachments;821 let prepared: PreparedTurnAttachments;
778 let persistedMessage: QueuedThreadMessage;822 let persistedMessage: QueuedThreadMessage;
779 let deliveryRevision: number;823 let deliveryRevision: number;
780 try {824 try {
781 const preparedResult = await prepareQueuedMessageAttachments(825 const preparedResult = await prepareQueuedMessageAttachments(
782 queuedMessage,826 queuedMessage,
783 serverConfigs.get(queuedMessage.environmentId)?.environment.capabilities827 serverConfig.environment.capabilities.attachmentUploads === true,
784 .attachmentUploads === true,
785 );828 );
786 if (preparedResult.status === "abandoned") {829 if (preparedResult.status === "abandoned") {
787 return true;830 return true;
...@@ -809,6 +852,21 @@ export function useThreadOutboxDrain(): void {...@@ -809,6 +852,21 @@ export function useThreadOutboxDrain(): void {
809 if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) {852 if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) {
810 return true;853 return true;
811 }854 }
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 );
812 const deliveryResult = await startTurn({870 const deliveryResult = await startTurn({
813 environmentId: queuedMessage.environmentId,871 environmentId: queuedMessage.environmentId,
814 input: buildProjectThreadStartTurnInput({872 input: buildProjectThreadStartTurnInput({
...@@ -821,9 +879,9 @@ export function useThreadOutboxDrain(): void {...@@ -821,9 +879,9 @@ export function useThreadOutboxDrain(): void {
821 text: queuedMessage.text.trim(),879 text: queuedMessage.text.trim(),
822 attachments: queuedMessage.attachments,880 attachments: queuedMessage.attachments,
823 uploadedAttachments: prepared.attachments,881 uploadedAttachments: prepared.attachments,
824 modelSelection,882 modelSelection: sendSettings.modelSelection,
825 runtimeMode: queuedMessage.runtimeMode ?? DEFAULT_RUNTIME_MODE,883 runtimeMode: sendSettings.runtimeMode,
826 interactionMode: queuedMessage.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE,884 interactionMode: sendSettings.interactionMode,
827 workspaceMode: creation.workspaceMode,885 workspaceMode: creation.workspaceMode,
828 branch: creation.branch,886 branch: creation.branch,
829 worktreePath: creation.worktreePath,887 worktreePath: creation.worktreePath,
...@@ -859,7 +917,7 @@ export function useThreadOutboxDrain(): void {...@@ -859,7 +917,7 @@ export function useThreadOutboxDrain(): void {
859 }917 }
860 return false;918 return false;
861 },919 },
862 [makeDeliveryHelpers, restoreQueuedMessage, serverConfigs, startTurn],920 [makeDeliveryHelpers, restoreQueuedMessage, startTurn],
863 );921 );
864922
865 useEffect(() => {923 useEffect(() => {
...@@ -945,8 +1003,8 @@ export function useThreadOutboxDrain(): void {...@@ -945,8 +1003,8 @@ export function useThreadOutboxDrain(): void {
945 environmentConnected: environment?.connectionState === "connected",1003 environmentConnected: environment?.connectionState === "connected",
946 threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting",1004 threadBusy: thread?.session?.status === "running" || thread?.session?.status === "starting",
947 });1005 });
948 // The delivery action resolves first; the file-capability gate applies1006 // The delivery action resolves first; capability checks apply only to
949 // only to a message that will send. Gating earlier would restore a1007 // a message that will send. Checking earlier would restore a
950 // creation whose startTurn already made the thread as a duplicate draft1008 // creation whose startTurn already made the thread as a duplicate draft
951 // instead of removing it.1009 // instead of removing it.
952 const serverConfig = serverConfigs.get(nextQueuedMessage.environmentId);1010 const serverConfig = serverConfigs.get(nextQueuedMessage.environmentId);
apps/server/integration/OrchestrationEngineHarness.integration.ts+6
...@@ -46,6 +46,7 @@ import {...@@ -46,6 +46,7 @@ import {
46 ProviderEventLoggers,46 ProviderEventLoggers,
47} from "../src/provider/Layers/ProviderEventLoggers.ts";47} from "../src/provider/Layers/ProviderEventLoggers.ts";
48import { ProviderService } from "../src/provider/Services/ProviderService.ts";48import { ProviderService } from "../src/provider/Services/ProviderService.ts";
49import { ProviderAuthService } from "../src/provider/Services/ProviderAuthService.ts";
49import { AnalyticsService } from "../src/telemetry/AnalyticsService.ts";50import { AnalyticsService } from "../src/telemetry/AnalyticsService.ts";
50import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";51import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";
51import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityResolver.ts";52import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityResolver.ts";
...@@ -337,6 +338,11 @@ export const makeOrchestrationIntegrationHarness = (...@@ -337,6 +338,11 @@ export const makeOrchestrationIntegrationHarness = (
337 generateThreadTitle: () => Effect.succeed({ title: "New thread" }),338 generateThreadTitle: () => Effect.succeed({ title: "New thread" }),
338 } as unknown as TextGeneration["Service"]);339 } as unknown as TextGeneration["Service"]);
339 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(340 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
341 Layer.provide(
342 Layer.mock(ProviderAuthService)({
343 tryHandlePromptCommand: () => Effect.succeed(false),
344 }),
345 ),
340 Layer.provideMerge(runtimeServicesLayer),346 Layer.provideMerge(runtimeServicesLayer),
341 Layer.provideMerge(gitWorkflowLayer),347 Layer.provideMerge(gitWorkflowLayer),
342 Layer.provideMerge(textGenerationLayer),348 Layer.provideMerge(textGenerationLayer),
apps/server/integration/orphanedProviderSessionStartup.integration.test.ts+1
...@@ -116,6 +116,7 @@ const startupDependencies = Layer.mergeAll(...@@ -116,6 +116,7 @@ const startupDependencies = Layer.mergeAll(
116 stopSession: () => Effect.die("unused"),116 stopSession: () => Effect.die("unused"),
117 listSessions: () => Effect.succeed([]),117 listSessions: () => Effect.succeed([]),
118 getCapabilities: () => Effect.die("unused"),118 getCapabilities: () => Effect.die("unused"),
119 assertConversationRollbackSupported: () => Effect.die("unused"),
119 getInstanceInfo: () => Effect.die("unused"),120 getInstanceInfo: () => Effect.die("unused"),
120 rollbackConversation: () => Effect.die("unused"),121 rollbackConversation: () => Effect.die("unused"),
121 uploadFeedback: () => Effect.die("unused"),122 uploadFeedback: () => Effect.die("unused"),
apps/server/package.json+3-1
...@@ -32,7 +32,8 @@...@@ -32,7 +32,8 @@
32 "effect": "catalog:",32 "effect": "catalog:",
33 "msgpackr-extract": "3.0.4",33 "msgpackr-extract": "3.0.4",
34 "node-pty": "^1.1.0",34 "node-pty": "^1.1.0",
35 "yaml": "catalog:"35 "yaml": "catalog:",
36 "yauzl": "^3.4.0"
36 },37 },
37 "devDependencies": {38 "devDependencies": {
38 "@effect/vitest": "catalog:",39 "@effect/vitest": "catalog:",
...@@ -42,6 +43,7 @@...@@ -42,6 +43,7 @@
42 "@t3tools/web": "workspace:*",43 "@t3tools/web": "workspace:*",
43 "@types/bun": "1.3.14",44 "@types/bun": "1.3.14",
44 "@types/node": "catalog:",45 "@types/node": "catalog:",
46 "@types/yauzl": "^3.4.0",
45 "effect-acp": "workspace:*",47 "effect-acp": "workspace:*",
46 "effect-codex-app-server": "workspace:*",48 "effect-codex-app-server": "workspace:*",
47 "vite-plus": "catalog:"49 "vite-plus": "catalog:"
apps/server/scripts/acp-mock-agent.ts+246-28
...@@ -3,6 +3,7 @@...@@ -3,6 +3,7 @@
3import * as NodeFS from "node:fs";3import * as NodeFS from "node:fs";
44
5import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
6import * as Deferred from "effect/Deferred";
67
7import * as NodeServices from "@effect/platform-node/NodeServices";8import * as NodeServices from "@effect/platform-node/NodeServices";
8import * as NodeRuntime from "@effect/platform-node/NodeRuntime";9import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
...@@ -13,6 +14,7 @@ import type * as AcpSchema from "effect-acp/schema";...@@ -13,6 +14,7 @@ import type * as AcpSchema from "effect-acp/schema";
1314
14const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH;15const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH;
15const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH;16const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH;
17const antigravityProfile = process.env.T3_ACP_ANTIGRAVITY === "1";
16const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1";18const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1";
17const emitInterleavedAssistantToolCalls =19const emitInterleavedAssistantToolCalls =
18 process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1";20 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";...@@ -29,6 +31,9 @@ const emitContentThenHang = process.env.T3_ACP_EMIT_CONTENT_THEN_HANG === "1";
29const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1";31const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1";
30const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1";32const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1";
31const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1";33const 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";
32const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1";37const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1";
33const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1";38const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1";
34const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL === "1";39const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL === "1";
...@@ -62,8 +67,8 @@ const permissionRequestCount = Math.max(...@@ -62,8 +67,8 @@ const permissionRequestCount = Math.max(
62);67);
63const sessionId = "mock-session-1";68const sessionId = "mock-session-1";
6469
65let currentModeId = "ask";70let currentModeId = antigravityProfile ? "default" : "ask";
66let currentModelId = "default";71let currentModelId = antigravityProfile ? "gemini-test-low" : "default";
67let parameterizedModelPicker = false;72let parameterizedModelPicker = false;
68let currentReasoning = "medium";73let currentReasoning = "medium";
69let currentContext = "272k";74let currentContext = "272k";
...@@ -109,6 +114,26 @@ process.once("exit", (code) => {...@@ -109,6 +114,26 @@ process.once("exit", (code) => {
109});114});
110115
111function configOptions(): ReadonlyArray<AcpSchema.SessionConfigOption> {116function 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 }
112 if (parameterizedModelPicker) {137 if (parameterizedModelPicker) {
113 const baseOptions: Array<AcpSchema.SessionConfigOption> = [138 const baseOptions: Array<AcpSchema.SessionConfigOption> = [
114 {139 {
...@@ -268,23 +293,34 @@ function availableModels(): ReadonlyArray<{...@@ -268,23 +293,34 @@ function availableModels(): ReadonlyArray<{
268 }));293 }));
269}294}
270295
271const availableModes: ReadonlyArray<AcpSchema.SessionMode> = [296const antigravityModels = [
272 {297 { modelId: "gemini-test-low", name: "Gemini Test Low" },
273 id: "ask",298 { modelId: "gemini-test-high", name: "Gemini Test High" },
274 name: "Ask",299] satisfies ReadonlyArray<AcpSchema.ModelInfo>;
275 description: "Request permission before making any changes",300
276 },301const availableModes: ReadonlyArray<AcpSchema.SessionMode> = antigravityProfile
277 {302 ? [
278 id: "architect",303 { id: "default", name: "Default" },
279 name: "Architect",304 { id: "auto_edit", name: "Auto edit" },
280 description: "Design and plan software systems without implementation",305 { id: "yolo", name: "YOLO" },
281 },306 ]
282 {307 : [
283 id: "code",308 {
284 name: "Code",309 id: "ask",
285 description: "Write and modify code with full tool access",310 name: "Ask",
286 },311 description: "Request permission before making any changes",
287];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
289function modeState(): AcpSchema.SessionModeState {325function modeState(): AcpSchema.SessionModeState {
290 return {326 return {
...@@ -314,6 +350,9 @@ const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [...@@ -314,6 +350,9 @@ const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [
314];350];
315351
316function modelState(): AcpSchema.SessionModelState {352function modelState(): AcpSchema.SessionModelState {
353 if (antigravityProfile) {
354 return { currentModelId, availableModels: antigravityModels };
355 }
317 const modelId = grokAcpModels.some((model) => model.modelId === currentModelId)356 const modelId = grokAcpModels.some((model) => model.modelId === currentModelId)
318 ? currentModelId357 ? currentModelId
319 : "grok-4.6";358 : "grok-4.6";
...@@ -325,14 +364,49 @@ function modelState(): AcpSchema.SessionModelState {...@@ -325,14 +364,49 @@ function modelState(): AcpSchema.SessionModelState {
325364
326const program = Effect.gen(function* () {365const program = Effect.gen(function* () {
327 const agent = yield* EffectAcpAgent.AcpAgent;366 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
329 yield* agent.handleInitialize((request) =>382 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 }
331 parameterizedModelPicker =392 parameterizedModelPicker =
332 request.clientCapabilities?._meta?.parameterizedModelPicker === true;393 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 }
333 return {407 return {
334 protocolVersion: 1,408 protocolVersion: 1,
335 agentCapabilities: { loadSession: true },409 agentCapabilities: { loadSession: true, sessionCapabilities: { resume: {} } },
336 // Grok advertises model state before any session exists; the provider410 // Grok advertises model state before any session exists; the provider
337 // health check reads it from here without authenticating.411 // health check reads it from here without authenticating.
338 _meta: { modelState: modelState() },412 _meta: { modelState: modelState() },
...@@ -340,14 +414,58 @@ const program = Effect.gen(function* () {...@@ -340,14 +414,58 @@ const program = Effect.gen(function* () {
340 }),414 }),
341 );415 );
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
345 yield* agent.handleCreateSession(() =>434 yield* agent.handleCreateSession(() =>
346 Effect.succeed({435 Effect.gen(function* () {
347 sessionId,436 if (antigravityProfile) {
348 modes: modeState(),437 yield* publishAntigravityCommands(sessionId);
349 models: modelState(),438 }
350 configOptions: configOptions(),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 };
351 }),469 }),
352 );470 );
353471
...@@ -415,7 +533,7 @@ const program = Effect.gen(function* () {...@@ -415,7 +533,7 @@ const program = Effect.gen(function* () {
415533
416 yield* agent.handleSetSessionModel((request) =>534 yield* agent.handleSetSessionModel((request) =>
417 Effect.gen(function* () {535 Effect.gen(function* () {
418 if (!grokAcpModels.some((model) => model.modelId === request.modelId)) {536 if (!modelState().availableModels.some((model) => model.modelId === request.modelId)) {
419 return yield* AcpError.AcpRequestError.invalidParams(537 return yield* AcpError.AcpRequestError.invalidParams(
420 `Unknown mock model id: ${request.modelId}`,538 `Unknown mock model id: ${request.modelId}`,
421 {539 {
...@@ -470,6 +588,16 @@ const program = Effect.gen(function* () {...@@ -470,6 +588,16 @@ const program = Effect.gen(function* () {
470 Effect.gen(function* () {588 Effect.gen(function* () {
471 const cancelledSessionId = String(sessionId ?? "mock-session-1");589 const cancelledSessionId = String(sessionId ?? "mock-session-1");
472 cancelledSessions.add(cancelledSessionId);590 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 }
473 if (emitLateUpdateAfterCancel) {601 if (emitLateUpdateAfterCancel) {
474 yield* Effect.sleep("50 millis");602 yield* Effect.sleep("50 millis");
475 yield* Effect.sync(() => {603 yield* Effect.sync(() => {
...@@ -490,6 +618,38 @@ const program = Effect.gen(function* () {...@@ -490,6 +618,38 @@ const program = Effect.gen(function* () {
490 const requestedSessionId = String(request.sessionId ?? sessionId);618 const requestedSessionId = String(request.sessionId ?? sessionId);
491 promptCount += 1;619 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
493 if (Number.isFinite(promptDelayMs) && promptDelayMs > 0) {653 if (Number.isFinite(promptDelayMs) && promptDelayMs > 0) {
494 yield* Effect.sleep(`${promptDelayMs} millis`);654 yield* Effect.sleep(`${promptDelayMs} millis`);
495 }655 }
...@@ -1072,6 +1232,60 @@ const program = Effect.gen(function* () {...@@ -1072,6 +1232,60 @@ const program = Effect.gen(function* () {
1072 );1232 );
10731233
1074 yield* agent.handleUnknownExtRequest((method, params) => {1234 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 }
1075 if (method === "cursor/list_available_models") {1289 if (method === "cursor/list_available_models") {
1076 return Effect.succeed({1290 return Effect.succeed({
1077 models: availableModels(),1291 models: availableModels(),
...@@ -1118,6 +1332,10 @@ const program = Effect.gen(function* () {...@@ -1118,6 +1332,10 @@ const program = Effect.gen(function* () {
1118 return Effect.succeed({});1332 return Effect.succeed({});
1119 });1333 });
11201334
1335 yield* agent.handleUnknownExtNotification((method) =>
1336 method === "_test/exit" ? Effect.sync(() => process.exit(19)) : Effect.void,
1337 );
1338
1121 return yield* Effect.never;1339 return yield* Effect.never;
1122}).pipe(1340}).pipe(
1123 Effect.provide(1341 Effect.provide(
apps/server/src/auth/RpcAuthorization.ts+9
...@@ -33,6 +33,15 @@ export const RPC_REQUIRED_SCOPES = {...@@ -33,6 +33,15 @@ export const RPC_REQUIRED_SCOPES = {
33 [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope,33 [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope,
34 [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope,34 [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope,
35 [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope,35 [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,
36 [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope,45 [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope,
37 [WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope,46 [WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope,
38 [WS_METHODS.serverCommitDesktopUpdate]: AuthOrchestrationOperateScope,47 [WS_METHODS.serverCommitDesktopUpdate]: AuthOrchestrationOperateScope,
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+91
...@@ -21,6 +21,7 @@ import {...@@ -21,6 +21,7 @@ import {
21} from "@t3tools/contracts";21} from "@t3tools/contracts";
22import * as NodeServices from "@effect/platform-node/NodeServices";22import * as NodeServices from "@effect/platform-node/NodeServices";
23import * as Clock from "effect/Clock";23import * as Clock from "effect/Clock";
24import * as Deferred from "effect/Deferred";
24import * as Effect from "effect/Effect";25import * as Effect from "effect/Effect";
25import * as Exit from "effect/Exit";26import * as Exit from "effect/Exit";
26import * as Layer from "effect/Layer";27import * as Layer from "effect/Layer";
...@@ -28,6 +29,7 @@ import * as ManagedRuntime from "effect/ManagedRuntime";...@@ -28,6 +29,7 @@ import * as ManagedRuntime from "effect/ManagedRuntime";
28import * as PubSub from "effect/PubSub";29import * as PubSub from "effect/PubSub";
29import * as Scope from "effect/Scope";30import * as Scope from "effect/Scope";
30import * as Stream from "effect/Stream";31import * as Stream from "effect/Stream";
32import { it as effectIt } from "@effect/vitest";
31import { afterEach, describe, expect, it, vi } from "vite-plus/test";33import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3234
33import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts";35import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts";
...@@ -56,6 +58,7 @@ import {...@@ -56,6 +58,7 @@ import {
56 type ProviderServiceShape,58 type ProviderServiceShape,
57} from "../../provider/Services/ProviderService.ts";59} from "../../provider/Services/ProviderService.ts";
58import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts";60import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts";
61import { ProviderValidationError } from "../../provider/Errors.ts";
59import { ServerConfig } from "../../config.ts";62import { ServerConfig } from "../../config.ts";
60import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts";63import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts";
61import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts";64import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts";
...@@ -87,6 +90,9 @@ function createProviderServiceHarness(...@@ -87,6 +90,9 @@ function createProviderServiceHarness(
87 const rollbackConversation = vi.fn(90 const rollbackConversation = vi.fn(
88 (_input: { readonly threadId: ThreadId; readonly numTurns: number }) => Effect.void,91 (_input: { readonly threadId: ThreadId; readonly numTurns: number }) => Effect.void,
89 );92 );
93 const assertConversationRollbackSupported = vi.fn<
94 ProviderServiceShape["assertConversationRollbackSupported"]
95 >(() => Effect.void);
9096
91 const unsupported = <A>() =>97 const unsupported = <A>() =>
92 Effect.die(new Error("Unsupported provider call in test")) as Effect.Effect<A, never>;98 Effect.die(new Error("Unsupported provider call in test")) as Effect.Effect<A, never>;
...@@ -113,6 +119,7 @@ function createProviderServiceHarness(...@@ -113,6 +119,7 @@ function createProviderServiceHarness(
113 stopSession: () => unsupported(),119 stopSession: () => unsupported(),
114 listSessions,120 listSessions,
115 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),121 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
122 assertConversationRollbackSupported,
116 getInstanceInfo: (instanceId) =>123 getInstanceInfo: (instanceId) =>
117 Effect.succeed({124 Effect.succeed({
118 instanceId,125 instanceId,
...@@ -137,6 +144,7 @@ function createProviderServiceHarness(...@@ -137,6 +144,7 @@ function createProviderServiceHarness(
137144
138 return {145 return {
139 service,146 service,
147 assertConversationRollbackSupported,
140 rollbackConversation,148 rollbackConversation,
141 emit,149 emit,
142 };150 };
...@@ -1002,6 +1010,89 @@ describe("CheckpointReactor", () => {...@@ -1002,6 +1010,89 @@ describe("CheckpointReactor", () => {
1002 ).toBe(true);1010 ).toBe(true);
1003 });1011 });
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
1005 it("executes provider revert and emits thread.reverted for checkpoint revert requests", async () => {1096 it("executes provider revert and emits thread.reverted for checkpoint revert requests", async () => {
1006 const harness = await createHarness();1097 const harness = await createHarness();
1007 const createdAt = "2026-01-01T00:00:00.000Z";1098 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* () {...@@ -755,6 +755,8 @@ const make = Effect.gen(function* () {
755 return;755 return;
756 }756 }
757757
758 yield* providerService.assertConversationRollbackSupported(event.payload.threadId);
759
758 const restored = yield* checkpointStore.restoreCheckpoint({760 const restored = yield* checkpointStore.restoreCheckpoint({
759 cwd: sessionRuntime.value.cwd,761 cwd: sessionRuntime.value.cwd,
760 checkpointRef: targetCheckpointRef,762 checkpointRef: targetCheckpointRef,
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+247-2
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9 ProviderSession,9 ProviderSession,
10 ProviderDriverKind,10 ProviderDriverKind,
11 ProviderInstanceId,11 ProviderInstanceId,
12 ProviderSetupError,
12} from "@t3tools/contracts";13} from "@t3tools/contracts";
13import { createModelSelection } from "@t3tools/shared/model";14import { createModelSelection } from "@t3tools/shared/model";
14import {15import {
...@@ -31,6 +32,7 @@ import * as ManagedRuntime from "effect/ManagedRuntime";...@@ -31,6 +32,7 @@ import * as ManagedRuntime from "effect/ManagedRuntime";
31import * as PubSub from "effect/PubSub";32import * as PubSub from "effect/PubSub";
32import * as Scope from "effect/Scope";33import * as Scope from "effect/Scope";
33import * as Stream from "effect/Stream";34import * as Stream from "effect/Stream";
35import * as SqlClient from "effect/unstable/sql/SqlClient";
34import { it as effectIt } from "@effect/vitest";36import { it as effectIt } from "@effect/vitest";
35import { afterEach, describe, expect, it, vi } from "vite-plus/test";37import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3638
...@@ -44,6 +46,7 @@ import {...@@ -44,6 +46,7 @@ import {
44 ProviderService,46 ProviderService,
45 type ProviderServiceShape,47 type ProviderServiceShape,
46} from "../../provider/Services/ProviderService.ts";48} from "../../provider/Services/ProviderService.ts";
49import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts";
47import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts";50import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts";
48import { TextGeneration } from "../../textGeneration/TextGeneration.ts";51import { TextGeneration } from "../../textGeneration/TextGeneration.ts";
49import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";52import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts";
...@@ -109,7 +112,10 @@ async function waitFor(...@@ -109,7 +112,10 @@ async function waitFor(
109112
110describe("ProviderCommandReactor", () => {113describe("ProviderCommandReactor", () => {
111 let runtime: ManagedRuntime.ManagedRuntime<114 let runtime: ManagedRuntime.ManagedRuntime<
112 OrchestrationEngineService | ProviderCommandReactor | ProjectionSnapshotQuery,115 | OrchestrationEngineService
116 | ProviderCommandReactor
117 | ProjectionSnapshotQuery
118 | SqlClient.SqlClient,
113 unknown119 unknown
114 > | null = null;120 > | null = null;
115 let scope: Scope.Closeable | null = null;121 let scope: Scope.Closeable | null = null;
...@@ -172,6 +178,7 @@ describe("ProviderCommandReactor", () => {...@@ -172,6 +178,7 @@ describe("ProviderCommandReactor", () => {
172 readonly startSessionEffect?: (178 readonly startSessionEffect?: (
173 session: ProviderSession,179 session: ProviderSession,
174 ) => Effect.Effect<ProviderSession, ProviderAdapterRequestError>;180 ) => Effect.Effect<ProviderSession, ProviderAdapterRequestError>;
181 readonly tryHandlePromptCommandEffect?: ProviderAuthService["Service"]["tryHandlePromptCommand"];
175 }) {182 }) {
176 const now = "2026-01-01T00:00:00.000Z";183 const now = "2026-01-01T00:00:00.000Z";
177 const baseDir =184 const baseDir =
...@@ -180,6 +187,9 @@ describe("ProviderCommandReactor", () => {...@@ -180,6 +187,9 @@ describe("ProviderCommandReactor", () => {
180 const { stateDir } = deriveServerPathsSync(baseDir, undefined);187 const { stateDir } = deriveServerPathsSync(baseDir, undefined);
181 createdStateDirs.add(stateDir);188 createdStateDirs.add(stateDir);
182 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());189 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
190 const tryHandlePromptCommand = vi.fn<ProviderAuthService["Service"]["tryHandlePromptCommand"]>(
191 input?.tryHandlePromptCommandEffect ?? (() => Effect.succeed(false)),
192 );
183 let nextSessionIndex = 1;193 let nextSessionIndex = 1;
184 const runtimeSessions: Array<ProviderSession> = [];194 const runtimeSessions: Array<ProviderSession> = [];
185 const modelSelection = input?.threadModelSelection ?? {195 const modelSelection = input?.threadModelSelection ?? {
...@@ -348,10 +358,17 @@ describe("ProviderCommandReactor", () => {...@@ -348,10 +358,17 @@ describe("ProviderCommandReactor", () => {
348 Effect.succeed({358 Effect.succeed({
349 sessionModelSwitch: input?.sessionModelSwitch ?? "in-session",359 sessionModelSwitch: input?.sessionModelSwitch ?? "in-session",
350 }),360 }),
361 assertConversationRollbackSupported: () => unsupported(),
351 getInstanceInfo: (instanceId) => {362 getInstanceInfo: (instanceId) => {
352 const raw = String(instanceId);363 const raw = String(instanceId);
353 const driverKind = ProviderDriverKind.make(364 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,
355 );372 );
356 return Effect.succeed({373 return Effect.succeed({
357 instanceId,374 instanceId,
...@@ -421,6 +438,7 @@ describe("ProviderCommandReactor", () => {...@@ -421,6 +438,7 @@ describe("ProviderCommandReactor", () => {
421 Layer.provideMerge(reactorOrchestrationLayer),438 Layer.provideMerge(reactorOrchestrationLayer),
422 Layer.provideMerge(projectionSnapshotLayer),439 Layer.provideMerge(projectionSnapshotLayer),
423 Layer.provideMerge(Layer.succeed(ProviderService, service)),440 Layer.provideMerge(Layer.succeed(ProviderService, service)),
441 Layer.provide(Layer.mock(ProviderAuthService, { tryHandlePromptCommand })),
424 Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)),442 Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)),
425 Layer.provideMerge(443 Layer.provideMerge(
426 Layer.mock(GitWorkflowService.GitWorkflowService)({444 Layer.mock(GitWorkflowService.GitWorkflowService)({
...@@ -445,6 +463,7 @@ describe("ProviderCommandReactor", () => {...@@ -445,6 +463,7 @@ describe("ProviderCommandReactor", () => {
445 }),463 }),
446 ),464 ),
447 Layer.provideMerge(ServerSettingsService.layerTest()),465 Layer.provideMerge(ServerSettingsService.layerTest()),
466 Layer.provideMerge(SqlitePersistenceMemory),
448 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),467 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
449 Layer.provideMerge(NodeServices.layer),468 Layer.provideMerge(NodeServices.layer),
450 );469 );
...@@ -531,6 +550,18 @@ describe("ProviderCommandReactor", () => {...@@ -531,6 +550,18 @@ describe("ProviderCommandReactor", () => {
531 return {550 return {
532 engine,551 engine,
533 readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()),552 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,
534 startSession,565 startSession,
535 sendTurn,566 sendTurn,
536 interruptTurn,567 interruptTurn,
...@@ -553,6 +584,220 @@ describe("ProviderCommandReactor", () => {...@@ -553,6 +584,220 @@ describe("ProviderCommandReactor", () => {
553 };584 };
554 }585 }
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
556 it("reacts to thread.turn.start by ensuring session and sending provider turn", async () => {801 it("reacts to thread.turn.start by ensuring session and sending provider turn", async () => {
557 const harness = await createHarness();802 const harness = await createHarness();
558 const now = "2026-01-01T00:00:00.000Z";803 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...@@ -33,6 +33,7 @@ import { increment, orchestrationEventsProcessedTotal } from "../../observabilit
33import { ProviderAdapterRequestError } from "../../provider/Errors.ts";33import { ProviderAdapterRequestError } from "../../provider/Errors.ts";
34import type { ProviderServiceError } from "../../provider/Errors.ts";34import type { ProviderServiceError } from "../../provider/Errors.ts";
35import { TextGeneration } from "../../textGeneration/TextGeneration.ts";35import { TextGeneration } from "../../textGeneration/TextGeneration.ts";
36import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts";
36import { ProviderService } from "../../provider/Services/ProviderService.ts";37import { ProviderService } from "../../provider/Services/ProviderService.ts";
37import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts";38import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts";
38import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";39import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
...@@ -308,6 +309,7 @@ const make = Effect.gen(function* () {...@@ -308,6 +309,7 @@ const make = Effect.gen(function* () {
308 const crypto = yield* Crypto.Crypto;309 const crypto = yield* Crypto.Crypto;
309 const orchestrationEngine = yield* OrchestrationEngineService;310 const orchestrationEngine = yield* OrchestrationEngineService;
310 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;311 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
312 const providerAuthService = yield* ProviderAuthService;
311 const providerService = yield* ProviderService;313 const providerService = yield* ProviderService;
312 const providerRegistry = yield* ProviderRegistry;314 const providerRegistry = yield* ProviderRegistry;
313 const gitWorkflow = yield* GitWorkflowService;315 const gitWorkflow = yield* GitWorkflowService;
...@@ -1151,39 +1153,6 @@ const make = Effect.gen(function* () {...@@ -1151,39 +1153,6 @@ const make = Effect.gen(function* () {
1151 return;1153 return;
1152 }1154 }
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
1187 const handleTurnStartFailure = (cause: Cause.Cause<unknown>) => {1156 const handleTurnStartFailure = (cause: Cause.Cause<unknown>) => {
1188 if (Cause.hasInterruptsOnly(cause)) {1157 if (Cause.hasInterruptsOnly(cause)) {
1189 return Effect.void;1158 return Effect.void;
...@@ -1220,6 +1189,90 @@ const make = Effect.gen(function* () {...@@ -1220,6 +1189,90 @@ const make = Effect.gen(function* () {
1220 ),1189 ),
1221 );1190 );
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
1223 const sendTurnRequest = yield* buildSendTurnRequestForThread({1276 const sendTurnRequest = yield* buildSendTurnRequestForThread({
1224 threadId: event.payload.threadId,1277 threadId: event.payload.threadId,
1225 messageText: message.text,1278 messageText: message.text,
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+1
...@@ -111,6 +111,7 @@ function createProviderServiceHarness() {...@@ -111,6 +111,7 @@ function createProviderServiceHarness() {
111 stopSession: () => unsupported(),111 stopSession: () => unsupported(),
112 listSessions: () => Effect.succeed([...runtimeSessions]),112 listSessions: () => Effect.succeed([...runtimeSessions]),
113 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),113 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
114 assertConversationRollbackSupported: () => unsupported(),
114 getInstanceInfo: (instanceId) => {115 getInstanceInfo: (instanceId) => {
115 const driverKind = ProviderDriverKind.make(String(instanceId));116 const driverKind = ProviderDriverKind.make(String(instanceId));
116 return Effect.succeed({117 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";...@@ -42,6 +42,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";
4242
43import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";43import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
44import type { BuiltInDriversEnv } from "../builtInDrivers.ts";44import type { BuiltInDriversEnv } from "../builtInDrivers.ts";
45import { AntigravityInstallation } from "../AntigravityInstallation.ts";
45import { ServerConfig } from "../../config.ts";46import { ServerConfig } from "../../config.ts";
46import { ServerSettingsService } from "../../serverSettings.ts";47import { ServerSettingsService } from "../../serverSettings.ts";
47import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts";48import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts";
...@@ -308,9 +309,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {...@@ -308,9 +309,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => {
308 // surfaced; that merged layer then provides `ServerConfig.layerTest`'s309 // surfaced; that merged layer then provides `ServerConfig.layerTest`'s
309 // `FileSystem` dep while keeping everything else surfaced to the test.310 // `FileSystem` dep while keeping everything else surfaced to the test.
310 const infraLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer));311 const infraLayer = OpenCodeRuntimeLive.pipe(Layer.provideMerge(NodeServices.layer));
311 const testLayer = ServerConfig.layerTest(process.cwd(), {312 const testLayer = AntigravityInstallation.layer.pipe(
312 prefix: "provider-instance-registry-all-drivers-test",313 Layer.provideMerge(
313 }).pipe(314 ServerConfig.layerTest(process.cwd(), {
315 prefix: "provider-instance-registry-all-drivers-test",
316 }),
317 ),
314 Layer.provideMerge(infraLayer),318 Layer.provideMerge(infraLayer),
315 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),319 Layer.provideMerge(BackgroundPolicyAlwaysRunLayer),
316 Layer.provideMerge(ServerSettingsService.layerTest()),320 Layer.provideMerge(ServerSettingsService.layerTest()),
apps/server/src/provider/Layers/ProviderRegistry.test.ts+157-54
...@@ -35,6 +35,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";...@@ -35,6 +35,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
35import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts";35import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts";
36import { checkClaudeProviderStatus } from "./ClaudeProvider.ts";36import { checkClaudeProviderStatus } from "./ClaudeProvider.ts";
37import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";37import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
38import { AntigravityInstallation } from "../AntigravityInstallation.ts";
38import * as ModelManifest from "../ModelManifest.ts";39import * as ModelManifest from "../ModelManifest.ts";
39import * as OpenCodeRuntime from "../opencodeRuntime.ts";40import * as OpenCodeRuntime from "../opencodeRuntime.ts";
40import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";41import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
...@@ -897,6 +898,119 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -897,6 +898,119 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
897 assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]);898 assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]);
898 });899 });
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
900 it("fills missing capabilities from the previous provider snapshot", () => {1014 it("fills missing capabilities from the previous provider snapshot", () => {
901 const previousProvider = {1015 const previousProvider = {
902 instanceId: ProviderInstanceId.make("cursor"),1016 instanceId: ProviderInstanceId.make("cursor"),
...@@ -1867,6 +1981,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1867,6 +1981,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1867 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));1981 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
1868 const providerRegistryLayer = ProviderRegistryLive.pipe(1982 const providerRegistryLayer = ProviderRegistryLive.pipe(
1869 Layer.provideMerge(ProviderInstanceRegistryHydrationLive),1983 Layer.provideMerge(ProviderInstanceRegistryHydrationLive),
1984 Layer.provideMerge(AntigravityInstallation.layer),
1870 Layer.provideMerge(1985 Layer.provideMerge(
1871 Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings),1986 Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings),
1872 ),1987 ),
...@@ -1932,18 +2047,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1932,18 +2047,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1932 }),2047 }),
1933 );2048 );
19342049
1935 // Guards the second half of the reported bug: changing2050 // A binary path change must rebuild Codex and publish its new probe result.
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 //
1942 it.effect("re-probes when settings change the codex binaryPath", () =>2051 it.effect("re-probes when settings change the codex binaryPath", () =>
1943 Effect.gen(function* () {2052 Effect.gen(function* () {
1944 const firstMissing = `t3code_codex_first_`;2053 const firstMissing = `t3code_codex_first_`;
1945 const secondMissing = `t3code_codex_second_`;2054 const secondMissing = `t3code_codex_second_`;
1946 const spawnedCommands: Array<string> = [];2055 const spawnedCommands: Array<string> = [];
2056 const secondProbeStarted = yield* Deferred.make<void>();
2057 const releaseSecondProbe = yield* Deferred.make<void>();
1947 const allowLazySettingsStream = yield* Deferred.make<void>();2058 const allowLazySettingsStream = yield* Deferred.make<void>();
1948 const mutableServerSettings = yield* makeMutableServerSettingsService(2059 const mutableServerSettings = yield* makeMutableServerSettingsService(
1949 decodeServerSettings(2060 decodeServerSettings(
...@@ -1970,6 +2081,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1970,6 +2081,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1970 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));2081 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
1971 const providerRegistryLayer = ProviderRegistryLive.pipe(2082 const providerRegistryLayer = ProviderRegistryLive.pipe(
1972 Layer.provideMerge(ProviderInstanceRegistryHydrationLive),2083 Layer.provideMerge(ProviderInstanceRegistryHydrationLive),
2084 Layer.provideMerge(AntigravityInstallation.layer),
1973 Layer.provideMerge(2085 Layer.provideMerge(
1974 Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings),2086 Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings),
1975 ),2087 ),
...@@ -1989,8 +2101,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -1989,8 +2101,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
1989 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),2101 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
1990 Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) =>2102 Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) =>
1991 ChildProcessSpawner.make((command) => {2103 ChildProcessSpawner.make((command) => {
1992 spawnedCommands.push((command as { readonly command: string }).command);2104 if (command._tag !== "StandardCommand") return spawner.spawn(command);
1993 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)));
1994 }),2113 }),
1995 ),2114 ),
1996 Layer.provideMerge(NodeServices.layer),2115 Layer.provideMerge(NodeServices.layer),
...@@ -2002,36 +2121,29 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -2002,36 +2121,29 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
20022121
2003 yield* Effect.gen(function* () {2122 yield* Effect.gen(function* () {
2004 const registry = yield* ProviderRegistry.ProviderRegistry;2123 const registry = yield* ProviderRegistry.ProviderRegistry;
2005 // Boot-time probe: the default codex instance is enabled with2124 const codexSnapshots = registry.streamChanges.pipe(
2006 // `firstMissing`, so the real spawner yields ENOENT and the2125 Stream.map((providers) =>
2007 // snapshot should be `status: "error"`.2126 providers.find((provider) => provider.instanceId === "codex"),
2008 let initialProviders = yield* registry.getProviders;2127 ),
2009 for (2128 Stream.filter((provider): provider is ServerProvider => provider !== undefined),
2010 let attempts = 0;2129 );
2011 attempts < 50 &&2130 const firstError = yield* Stream.toPull(
2012 initialProviders.find((provider) => provider.instanceId === "codex")?.status !==2131 codexSnapshots.pipe(Stream.filter((provider) => provider.status === "error")),
2013 "error";2132 );
2014 attempts += 12133 const currentCodex = (yield* registry.getProviders).find(
2015 ) {
2016 yield* TestClock.adjust("10 millis");
2017 yield* Effect.yieldNow;
2018 initialProviders = yield* registry.getProviders;
2019 }
2020 const initialCodex = initialProviders.find(
2021 (provider) => provider.instanceId === "codex",2134 (provider) => provider.instanceId === "codex",
2022 );2135 );
2136 const initialCodex =
2137 currentCodex?.status === "error" ? currentCodex : (yield* firstError)[0];
2023 assert.strictEqual(initialCodex?.status, "error");2138 assert.strictEqual(initialCodex?.status, "error");
2024 assert.strictEqual(initialCodex?.installed, false);2139 assert.strictEqual(initialCodex?.installed, false);
2025 assert.deepStrictEqual(spawnedCommands, [firstMissing]);2140 assert.deepStrictEqual(spawnedCommands, [firstMissing]);
20262141
2027 // Drive a settings change. The Hydration layer's2142 const pendingRebuild = yield* Stream.toPull(
2028 // `SettingsWatcherLive` consumes this via `subscribeChanges`,2143 codexSnapshots.pipe(
2029 // calls `reconcile`, which rebuilds the codex instance (the2144 Stream.filter((provider) => provider.status === "warning" && !provider.installed),
2030 // envelope changed because `binaryPath` differs → `entryEqual`2145 ),
2031 // is false). The registry's `Stream.runForEach(2146 );
2032 // instanceRegistry.streamChanges, () => syncLiveSources)`
2033 // fires `syncLiveSources`, which subscribes and launches a fresh
2034 // background refresh on the rebuilt instance.
2035 yield* serverSettings.updateSettings({2147 yield* serverSettings.updateSettings({
2036 providers: {2148 providers: {
2037 codex: { enabled: true, binaryPath: secondMissing },2149 codex: { enabled: true, binaryPath: secondMissing },
...@@ -2041,27 +2153,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -2041,27 +2153,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
2041 // not subscribe before forking has already lost this update.2153 // not subscribe before forking has already lost this update.
2042 yield* Deferred.succeed(allowLazySettingsStream, undefined);2154 yield* Deferred.succeed(allowLazySettingsStream, undefined);
20432155
2044 // Poll until the injected process boundary observes the new2156 // Hold the second probe until the aggregator sees the rebuilt
2045 // executable. This verifies the public settings-to-probe behavior2157 // instance. Its next error must come from the new executable.
2046 // without depending on timestamps assigned by TestClock.2158 yield* Deferred.await(secondProbeStarted);
2047 const refreshed = yield* Effect.gen(function* () {2159 yield* pendingRebuild;
2048 for (let attempts = 0; attempts < 60; attempts += 1) {2160 const rebuiltError = yield* Stream.toPull(
2049 const providers = yield* registry.getProviders;2161 codexSnapshots.pipe(Stream.filter((provider) => provider.status === "error")),
2050 const codex = providers.find((provider) => provider.instanceId === "codex");2162 );
2051 if (2163 yield* Deferred.succeed(releaseSecondProbe, undefined);
2052 codex !== undefined &&2164 const [reprobedCodex] = yield* rebuiltError;
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");
2065 assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]);2165 assert.deepStrictEqual(spawnedCommands, [firstMissing, secondMissing]);
2066 assert.strictEqual(reprobedCodex?.status, "error");2166 assert.strictEqual(reprobedCodex?.status, "error");
2067 assert.strictEqual(reprobedCodex?.installed, false);2167 assert.strictEqual(reprobedCodex?.installed, false);
...@@ -2096,6 +2196,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -2096,6 +2196,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
2096 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));2196 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
2097 const providerRegistryLayer = ProviderRegistryLive.pipe(2197 const providerRegistryLayer = ProviderRegistryLive.pipe(
2098 Layer.provideMerge(ProviderInstanceRegistryHydrationLive),2198 Layer.provideMerge(ProviderInstanceRegistryHydrationLive),
2199 Layer.provideMerge(AntigravityInstallation.layer),
2099 Layer.provideMerge(2200 Layer.provideMerge(
2100 Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings),2201 Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings),
2101 ),2202 ),
...@@ -2156,6 +2257,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -2156,6 +2257,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
2156 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));2257 yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void));
2157 const providerRegistryLayer = ProviderRegistryLive.pipe(2258 const providerRegistryLayer = ProviderRegistryLive.pipe(
2158 Layer.provideMerge(ProviderInstanceRegistryHydrationLive),2259 Layer.provideMerge(ProviderInstanceRegistryHydrationLive),
2260 Layer.provideMerge(AntigravityInstallation.layer),
2159 Layer.provideMerge(2261 Layer.provideMerge(
2160 Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings),2262 Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings),
2161 ),2263 ),
...@@ -2213,6 +2315,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -2213,6 +2315,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
2213 );2315 );
22142316
2215 assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [2317 assert.deepStrictEqual(providers.map((provider) => provider.instanceId).toSorted(), [
2318 "antigravity",
2216 "claudeAgent",2319 "claudeAgent",
2217 "codex",2320 "codex",
2218 "cursor",2321 "cursor",
apps/server/src/provider/Layers/ProviderRegistry.ts+13-8
...@@ -101,20 +101,25 @@ export function upsertProviderWorkspaceSnapshot(...@@ -101,20 +101,25 @@ export function upsertProviderWorkspaceSnapshot(
101}101}
102102
103const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => {103const 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")) {
105 return true;106 return true;
106 }107 }
107108
108 // OpenCode's initial snapshot is deliberately non-authoritative while its109 if (isAntigravity && (!provider.enabled || provider.auth.status === "unauthenticated")) {
109 // first probe is still running. A probe error from an installed CLI/server110 return false;
110 // is likewise partial: it could not establish the current inventory.111 }
111 // Conversely, disabled and missing-CLI snapshots are authoritative removals,112
112 // as are successful ready/warning inventories (including an empty one after113 // Both drivers replace their inventories after successful catalog discovery.
113 // logout or plugin removal).114 // Antigravity's local health check does not authenticate or discover models.
115 const isPendingAntigravityAuthentication =
116 isAntigravity && provider.status === "warning" && provider.auth.status === "unknown";
114 const isPendingInitialProbe =117 const isPendingInitialProbe =
115 provider.enabled && !provider.installed && provider.status === "warning";118 provider.enabled && !provider.installed && provider.status === "warning";
116 const didInstalledProviderProbeFail = provider.installed && provider.status === "error";119 const didInstalledProviderProbeFail = provider.installed && provider.status === "error";
117 return isPendingInitialProbe || didInstalledProviderProbeFail;120 return (
121 isPendingAntigravityAuthentication || isPendingInitialProbe || didInstalledProviderProbeFail
122 );
118};123};
119124
120const shouldRetainMissingOpenCodeMetadata = (provider: ServerProvider): boolean =>125const shouldRetainMissingOpenCodeMetadata = (provider: ServerProvider): boolean =>
apps/server/src/provider/Layers/ProviderService.test.ts+131-7
...@@ -120,7 +120,10 @@ type LegacyProviderRuntimeEvent = {...@@ -120,7 +120,10 @@ type LegacyProviderRuntimeEvent = {
120 readonly [key: string]: unknown;120 readonly [key: string]: unknown;
121};121};
122122
123function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {123function makeFakeCodexAdapter(
124 provider: ProviderDriverKind = CODEX_DRIVER,
125 supportsConversationRollback?: boolean,
126) {
124 const sessions = new Map<ThreadId, ProviderSession>();127 const sessions = new Map<ThreadId, ProviderSession>();
125 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());128 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
126129
...@@ -243,6 +246,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {...@@ -243,6 +246,7 @@ function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) {
243 provider,246 provider,
244 capabilities: {247 capabilities: {
245 sessionModelSwitch: "in-session",248 sessionModelSwitch: "in-session",
249 ...(supportsConversationRollback !== undefined ? { supportsConversationRollback } : {}),
246 ...(provider === CODEX_DRIVER ? { promptlessTurnContinuation: true } : {}),250 ...(provider === CODEX_DRIVER ? { promptlessTurnContinuation: true } : {}),
247 },251 },
248 startSession,252 startSession,
...@@ -313,16 +317,20 @@ const hasMetricSnapshot = (...@@ -313,16 +317,20 @@ const hasMetricSnapshot = (
313function makeProviderServiceLayer(317function makeProviderServiceLayer(
314 input: {318 input: {
315 readonly directory?: ProviderSessionDirectory.ProviderSessionDirectory["Service"];319 readonly directory?: ProviderSessionDirectory.ProviderSessionDirectory["Service"];
320 readonly supportsConversationRollback?: boolean;
321 readonly registry?: ProviderAdapterRegistry.ProviderAdapterRegistry["Service"];
316 } = {},322 } = {},
317) {323) {
318 const codex = makeFakeCodexAdapter();324 const codex = makeFakeCodexAdapter(CODEX_DRIVER, input.supportsConversationRollback);
319 const claude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER);325 const claude = makeFakeCodexAdapter(CLAUDE_AGENT_DRIVER);
320 const cursor = makeFakeCodexAdapter(CURSOR_DRIVER);326 const cursor = makeFakeCodexAdapter(CURSOR_DRIVER);
321 const registry = makeAdapterRegistryMock({327 const registry =
322 [ProviderDriverKind.make("codex")]: codex.adapter,328 input.registry ??
323 [ProviderDriverKind.make("claudeAgent")]: claude.adapter,329 makeAdapterRegistryMock({
324 [ProviderDriverKind.make("cursor")]: cursor.adapter,330 [ProviderDriverKind.make("codex")]: codex.adapter,
325 });331 [ProviderDriverKind.make("claudeAgent")]: claude.adapter,
332 [ProviderDriverKind.make("cursor")]: cursor.adapter,
333 });
326334
327 const providerAdapterLayer = Layer.succeed(335 const providerAdapterLayer = Layer.succeed(
328 ProviderAdapterRegistry.ProviderAdapterRegistry,336 ProviderAdapterRegistry.ProviderAdapterRegistry,
...@@ -636,6 +644,119 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance...@@ -636,6 +644,119 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance
636644
637const routing = makeProviderServiceLayer();645const 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
639it.effect(760it.effect(
640 "ProviderServiceLive uploads feedback through the adapter that recovered the session",761 "ProviderServiceLive uploads feedback through the adapter that recovered the session",
641 () =>762 () =>
...@@ -1271,6 +1392,9 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -1271,6 +1392,9 @@ routing.layer("ProviderServiceLive routing", (it) => {
1271 routing.codex.startSession.mockClear();1392 routing.codex.startSession.mockClear();
1272 routing.codex.rollbackThread.mockClear();1393 routing.codex.rollbackThread.mockClear();
12731394
1395 yield* provider.assertConversationRollbackSupported(initial.threadId);
1396 assert.equal(routing.codex.startSession.mock.calls.length, 0);
1397
1274 yield* provider.rollbackConversation({1398 yield* provider.rollbackConversation({
1275 threadId: initial.threadId,1399 threadId: initial.threadId,
1276 numTurns: 1,1400 numTurns: 1,
apps/server/src/provider/Layers/ProviderService.ts+37
...@@ -620,6 +620,26 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -620,6 +620,26 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
620 );620 );
621 }621 }
622 const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId));622 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 }
623 const effectiveResumeCursor =643 const effectiveResumeCursor =
624 input.resumeCursor ??644 input.resumeCursor ??
625 (persistedBinding?.providerInstanceId === resolvedInstanceId645 (persistedBinding?.providerInstanceId === resolvedInstanceId
...@@ -1104,6 +1124,21 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -1104,6 +1124,21 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
1104 const getInstanceInfo: ProviderServiceMethod<"getInstanceInfo"> = (instanceId) =>1124 const getInstanceInfo: ProviderServiceMethod<"getInstanceInfo"> = (instanceId) =>
1105 registry.getInstanceInfo(instanceId);1125 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
1107 const rollbackConversation: ProviderServiceMethod<"rollbackConversation"> = Effect.fn(1142 const rollbackConversation: ProviderServiceMethod<"rollbackConversation"> = Effect.fn(
1108 "rollbackConversation",1143 "rollbackConversation",
1109 )(function* (rawInput) {1144 )(function* (rawInput) {
...@@ -1117,6 +1152,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -1117,6 +1152,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
1117 }1152 }
1118 let metricProvider = "unknown";1153 let metricProvider = "unknown";
1119 return yield* Effect.gen(function* () {1154 return yield* Effect.gen(function* () {
1155 yield* assertConversationRollbackSupported(input.threadId);
1120 const routed = yield* resolveRoutableSession({1156 const routed = yield* resolveRoutableSession({
1121 threadId: input.threadId,1157 threadId: input.threadId,
1122 operation: "ProviderService.rollbackConversation",1158 operation: "ProviderService.rollbackConversation",
...@@ -1256,6 +1292,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (...@@ -1256,6 +1292,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
1256 listSessions,1292 listSessions,
1257 getCapabilities,1293 getCapabilities,
1258 getInstanceInfo,1294 getInstanceInfo,
1295 assertConversationRollbackSupported,
1259 rollbackConversation,1296 rollbackConversation,
1260 uploadFeedback,1297 uploadFeedback,
1261 // Each access creates a fresh PubSub subscription so that multiple1298 // 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", () => {...@@ -170,6 +170,7 @@ describe("ProviderSessionReaper", () => {
170 stopSession,170 stopSession,
171 listSessions: () => Effect.succeed([]),171 listSessions: () => Effect.succeed([]),
172 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),172 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
173 assertConversationRollbackSupported: () => unsupported(),
173 getInstanceInfo: (instanceId) => {174 getInstanceInfo: (instanceId) => {
174 const driverKind = ProviderDriverKind.make(String(instanceId));175 const driverKind = ProviderDriverKind.make(String(instanceId));
175 return Effect.succeed({176 return Effect.succeed({
apps/server/src/provider/ModelManifest.test.ts+31
...@@ -9,6 +9,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";...@@ -9,6 +9,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";
9import * as ServerConfig from "../config.ts";9import * as ServerConfig from "../config.ts";
10import * as ServerSettings from "../serverSettings.ts";10import * as ServerSettings from "../serverSettings.ts";
11import {11import {
12 applyManifestDefault,
12 BUNDLED_MODEL_MANIFEST,13 BUNDLED_MODEL_MANIFEST,
13 classifyModels,14 classifyModels,
14 make,15 make,
...@@ -59,6 +60,36 @@ describe("classifyModels", () => {...@@ -59,6 +60,36 @@ describe("classifyModels", () => {
59 });60 });
60});61});
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
62describe("resolveProviderCatalog", () => {93describe("resolveProviderCatalog", () => {
63 it("resolves generic model presentation through a reusable profile", () => {94 it("resolves generic model presentation through a reusable profile", () => {
64 const manifest: ModelManifestData = {95 const manifest: ModelManifestData = {
apps/server/src/provider/ModelManifest.ts+46-1
...@@ -216,7 +216,52 @@ export function applyModelManifest(...@@ -216,7 +216,52 @@ export function applyModelManifest(
216 manifest: ModelManifestData,216 manifest: ModelManifestData,
217 driverKind: ProviderDriverKind,217 driverKind: ProviderDriverKind,
218): ServerProviderDraft {218): 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 });
220}265}
221266
222/** Model-level half of `applyModelManifest`, exported for focused tests. */267/** 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";...@@ -35,6 +35,7 @@ import type * as TextGeneration from "../textGeneration/TextGeneration.ts";
35import type { ProviderAdapterError, ProviderDriverError } from "./Errors.ts";35import type { ProviderAdapterError, ProviderDriverError } from "./Errors.ts";
36import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts";36import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts";
37import type { ServerProviderShape } from "./Services/ServerProvider.ts";37import type { ServerProviderShape } from "./Services/ServerProvider.ts";
38import type { ProviderAuthController } from "./Services/ProviderAuthService.ts";
3839
39/**40/**
40 * Static metadata advertised by a driver. Used for default presentation41 * Static metadata advertised by a driver. Used for default presentation
...@@ -71,8 +72,10 @@ export interface ProviderInstance {...@@ -71,8 +72,10 @@ export interface ProviderInstance {
71 readonly enabled: boolean;72 readonly enabled: boolean;
72 readonly snapshot: ServerProviderShape;73 readonly snapshot: ServerProviderShape;
73 readonly snapshotForCwd?: (cwd: string) => Effect.Effect<ServerProvider, ProviderDriverError>;74 readonly snapshotForCwd?: (cwd: string) => Effect.Effect<ServerProvider, ProviderDriverError>;
75 readonly refreshModels?: () => Effect.Effect<void, ProviderDriverError>;
74 readonly adapter: ProviderAdapterShape<ProviderAdapterError>;76 readonly adapter: ProviderAdapterShape<ProviderAdapterError>;
75 readonly textGeneration: TextGeneration.TextGeneration["Service"];77 readonly textGeneration: TextGeneration.TextGeneration["Service"];
78 readonly auth?: ProviderAuthController;
76}79}
7780
78export interface ProviderContinuationIdentity {81export interface ProviderContinuationIdentity {
apps/server/src/provider/Services/ProviderAdapter.ts+2
...@@ -35,6 +35,8 @@ export interface ProviderAdapterCapabilities {...@@ -35,6 +35,8 @@ export interface ProviderAdapterCapabilities {
35 /** Starts a resumed turn with no synthetic user prompt. Omitted means the35 /** Starts a resumed turn with no synthetic user prompt. Omitted means the
36 adapter needs an explicit continuation instruction. */36 adapter needs an explicit continuation instruction. */
37 readonly promptlessTurnContinuation?: boolean;37 readonly promptlessTurnContinuation?: boolean;
38 /** False when native conversation history cannot be rewound. */
39 readonly supportsConversationRollback?: boolean;
38}40}
3941
40export interface ProviderThreadTurnSnapshot {42export 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 {...@@ -99,6 +99,13 @@ export interface ProviderServiceShape {
99 instanceId: ProviderInstanceId,99 instanceId: ProviderInstanceId,
100 ) => Effect.Effect<ProviderInstanceRoutingInfo, ProviderServiceError>;100 ) => 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
102 /**109 /**
103 * Roll back provider conversation state by a number of turns.110 * Roll back provider conversation state by a number of turns.
104 */111 */
apps/server/src/provider/acp/AcpCoreRuntimeEvents.test.ts+65-15
...@@ -27,27 +27,27 @@ describe("AcpCoreRuntimeEvents", () => {...@@ -27,27 +27,27 @@ describe("AcpCoreRuntimeEvents", () => {
27 },27 },
28 };28 };
2929
30 expect(30 const openedEvent = makeAcpRequestOpenedEvent({
31 makeAcpRequestOpenedEvent({31 stamp,
32 stamp,32 provider: ProviderDriverKind.make("cursor"),
33 provider: ProviderDriverKind.make("cursor"),33 threadId: "thread-1" as never,
34 threadId: "thread-1" as never,34 turnId,
35 turnId,35 requestId: RuntimeRequestId.make("request-1"),
36 requestId: RuntimeRequestId.make("request-1"),36 permissionRequest,
37 permissionRequest,37 detail: "cat package.json",
38 detail: "cat package.json",38 args: { command: ["cat", "package.json"] },
39 args: { command: ["cat", "package.json"] },39 source: "acp.jsonrpc",
40 source: "acp.jsonrpc",40 method: "session/request_permission",
41 method: "session/request_permission",41 rawPayload: { sessionId: "session-1" },
42 rawPayload: { sessionId: "session-1" },42 });
43 }),43 expect(openedEvent).toMatchObject({
44 ).toMatchObject({
45 type: "request.opened",44 type: "request.opened",
46 payload: {45 payload: {
47 requestType: "exec_command_approval",46 requestType: "exec_command_approval",
48 detail: "cat package.json",47 detail: "cat package.json",
49 },48 },
50 });49 });
50 expect(openedEvent).not.toHaveProperty("payload.options");
5151
52 expect(52 expect(
53 makeAcpRequestResolvedEvent({53 makeAcpRequestResolvedEvent({
...@@ -68,6 +68,36 @@ describe("AcpCoreRuntimeEvents", () => {...@@ -68,6 +68,36 @@ describe("AcpCoreRuntimeEvents", () => {
68 });68 });
69 });69 });
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
71 it("maps generic ACP permission kinds to dynamic tool approvals", () => {101 it("maps generic ACP permission kinds to dynamic tool approvals", () => {
72 const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" };102 const stamp = { eventId: "event-1" as never, createdAt: "2026-03-27T00:00:00.000Z" };
73103
...@@ -192,4 +222,24 @@ describe("AcpCoreRuntimeEvents", () => {...@@ -192,4 +222,24 @@ describe("AcpCoreRuntimeEvents", () => {
192 },222 },
193 });223 });
194 });224 });
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 });
195});245});
apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts+5-1
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 type CanonicalRequestType,4 type CanonicalRequestType,
5 type EventId,5 type EventId,
6 type ProviderApprovalDecision,6 type ProviderApprovalDecision,
7 type ProviderApprovalOption,
7 type ProviderDriverKind,8 type ProviderDriverKind,
8 type ProviderRuntimeEvent,9 type ProviderRuntimeEvent,
9 type RuntimeRequestId,10 type RuntimeRequestId,
...@@ -71,6 +72,7 @@ export function makeAcpRequestOpenedEvent(input: {...@@ -71,6 +72,7 @@ export function makeAcpRequestOpenedEvent(input: {
71 readonly turnId: TurnId | undefined;72 readonly turnId: TurnId | undefined;
72 readonly requestId: RuntimeRequestId;73 readonly requestId: RuntimeRequestId;
73 readonly permissionRequest: AcpPermissionRequest;74 readonly permissionRequest: AcpPermissionRequest;
75 readonly approvalOptions?: ReadonlyArray<ProviderApprovalOption>;
74 readonly detail: string;76 readonly detail: string;
75 readonly args: unknown;77 readonly args: unknown;
76 readonly source: AcpAdapterRawSource;78 readonly source: AcpAdapterRawSource;
...@@ -88,6 +90,7 @@ export function makeAcpRequestOpenedEvent(input: {...@@ -88,6 +90,7 @@ export function makeAcpRequestOpenedEvent(input: {
88 requestType: canonicalRequestTypeFromAcpKind(input.permissionRequest.kind),90 requestType: canonicalRequestTypeFromAcpKind(input.permissionRequest.kind),
89 detail: input.detail,91 detail: input.detail,
90 args: input.args,92 args: input.args,
93 ...(input.approvalOptions !== undefined ? { options: input.approvalOptions } : {}),
91 },94 },
92 raw: {95 raw: {
93 source: input.source,96 source: input.source,
...@@ -207,6 +210,7 @@ export function makeAcpContentDeltaEvent(input: {...@@ -207,6 +210,7 @@ export function makeAcpContentDeltaEvent(input: {
207 readonly threadId: ThreadId;210 readonly threadId: ThreadId;
208 readonly turnId: TurnId | undefined;211 readonly turnId: TurnId | undefined;
209 readonly itemId?: string;212 readonly itemId?: string;
213 readonly streamKind?: "assistant_text" | "reasoning_text";
210 readonly text: string;214 readonly text: string;
211 readonly rawPayload: unknown;215 readonly rawPayload: unknown;
212}): ProviderRuntimeEvent {216}): ProviderRuntimeEvent {
...@@ -218,7 +222,7 @@ export function makeAcpContentDeltaEvent(input: {...@@ -218,7 +222,7 @@ export function makeAcpContentDeltaEvent(input: {
218 turnId: input.turnId,222 turnId: input.turnId,
219 ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}),223 ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}),
220 payload: {224 payload: {
221 streamKind: "assistant_text",225 streamKind: input.streamKind ?? "assistant_text",
222 delta: input.text,226 delta: input.text,
223 },227 },
224 raw: {228 raw: {
apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts+432
...@@ -6,9 +6,12 @@ import * as NodeFS from "node:fs";...@@ -6,9 +6,12 @@ import * as NodeFS from "node:fs";
66
7import * as NodeServices from "@effect/platform-node/NodeServices";7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { it } from "@effect/vitest";8import { it } from "@effect/vitest";
9import * as Deferred from "effect/Deferred";
9import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";
10import * as Fiber from "effect/Fiber";12import * as Fiber from "effect/Fiber";
11import * as Option from "effect/Option";13import * as Option from "effect/Option";
14import * as Scope from "effect/Scope";
12import * as TestClock from "effect/testing/TestClock";15import * as TestClock from "effect/testing/TestClock";
13import * as Stream from "effect/Stream";16import * as Stream from "effect/Stream";
14import { describe, expect } from "vite-plus/test";17import { describe, expect } from "vite-plus/test";
...@@ -20,8 +23,437 @@ const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));...@@ -20,8 +23,437 @@ const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
20const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");23const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");
21const mockAgentCommand = "node";24const mockAgentCommand = "node";
22const mockAgentArgs = [mockAgentPath];25const 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
24describe("AcpSessionRuntime", () => {33describe("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
25 it.effect("merges custom initialize client capabilities into the ACP handshake", () => {457 it.effect("merges custom initialize client capabilities into the ACP handshake", () => {
26 const requestEvents: Array<AcpSessionRuntime.AcpSessionRequestLogEvent> = [];458 const requestEvents: Array<AcpSessionRuntime.AcpSessionRequestLogEvent> = [];
27 return Effect.gen(function* () {459 return Effect.gen(function* () {
apps/server/src/provider/acp/AcpRuntimeModel.test.ts+39
...@@ -339,6 +339,45 @@ describe("AcpRuntimeModel", () => {...@@ -339,6 +339,45 @@ describe("AcpRuntimeModel", () => {
339 ]);339 ]);
340 });340 });
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
342 it("keeps permission request parsing compatible with loose extension payloads", () => {381 it("keeps permission request parsing compatible with loose extension payloads", () => {
343 const request = parsePermissionRequest({382 const request = parsePermissionRequest({
344 sessionId: "session-1",383 sessionId: "session-1",
apps/server/src/provider/acp/AcpRuntimeModel.ts+28
...@@ -85,6 +85,11 @@ export type AcpParsedSessionEvent =...@@ -85,6 +85,11 @@ export type AcpParsedSessionEvent =
85 readonly _tag: "ModeChanged";85 readonly _tag: "ModeChanged";
86 readonly modeId: string;86 readonly modeId: string;
87 }87 }
88 | {
89 readonly _tag: "AvailableCommandsUpdated";
90 readonly availableCommands: ReadonlyArray<EffectAcpSchema.AvailableCommand>;
91 readonly rawPayload: unknown;
92 }
88 | {93 | {
89 readonly _tag: "AssistantItemStarted";94 readonly _tag: "AssistantItemStarted";
90 readonly itemId: string;95 readonly itemId: string;
...@@ -108,6 +113,11 @@ export type AcpParsedSessionEvent =...@@ -108,6 +113,11 @@ export type AcpParsedSessionEvent =
108 readonly itemId?: string;113 readonly itemId?: string;
109 readonly text: string;114 readonly text: string;
110 readonly rawPayload: unknown;115 readonly rawPayload: unknown;
116 }
117 | {
118 readonly _tag: "ThoughtDelta";
119 readonly text: string;
120 readonly rawPayload: unknown;
111 };121 };
112122
113type AcpSessionSetupResponse =123type AcpSessionSetupResponse =
...@@ -778,6 +788,14 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat...@@ -778,6 +788,14 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat
778 let modeId: string | undefined;788 let modeId: string | undefined;
779789
780 switch (upd.sessionUpdate) {790 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 }
781 case "current_mode_update": {799 case "current_mode_update": {
782 modeId = upd.currentModeId.trim();800 modeId = upd.currentModeId.trim();
783 if (modeId) {801 if (modeId) {
...@@ -838,6 +856,16 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat...@@ -838,6 +856,16 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat
838 }856 }
839 break;857 break;
840 }858 }
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 }
841 default:869 default:
842 break;870 break;
843 }871 }
apps/server/src/provider/acp/AcpSessionRuntime.ts+355-114
...@@ -5,6 +5,7 @@ import * as Crypto from "effect/Crypto";...@@ -5,6 +5,7 @@ import * as Crypto from "effect/Crypto";
5import * as Deferred from "effect/Deferred";5import * as Deferred from "effect/Deferred";
6import * as Duration from "effect/Duration";6import * as Duration from "effect/Duration";
7import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
8import * as Exit from "effect/Exit";
8import * as Fiber from "effect/Fiber";9import * as Fiber from "effect/Fiber";
9import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";11import * as Option from "effect/Option";
...@@ -53,24 +54,38 @@ export interface AcpSessionEventStreamBarrier {...@@ -53,24 +54,38 @@ export interface AcpSessionEventStreamBarrier {
53 readonly acknowledge: Deferred.Deferred<void>;54 readonly acknowledge: Deferred.Deferred<void>;
54}55}
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
58const defaultSessionLoadTimeout = Duration.seconds(90);65const defaultSessionLoadTimeout = Duration.seconds(90);
59const defaultSessionLoadReplayIdleGap = Duration.seconds(2);66const defaultSessionLoadReplayIdleGap = Duration.seconds(2);
67const defaultCancelTimeout = Duration.seconds(15);
68const maxStartupMetadataUpdates = 32;
69const maxStderrChunkLength = 8_192;
6070
61export interface AcpSpawnInput {71export interface AcpSpawnInput {
62 readonly command: string;72 readonly command: string;
63 readonly args: ReadonlyArray<string>;73 readonly args: ReadonlyArray<string>;
64 readonly cwd?: string;74 readonly cwd?: string;
65 readonly env?: NodeJS.ProcessEnv;75 readonly env?: NodeJS.ProcessEnv;
76 readonly extendEnv?: boolean;
66}77}
6778
68export interface AcpSessionRuntimeOptions {79export interface AcpSessionRuntimeOptions {
69 readonly spawn: AcpSpawnInput;80 readonly spawn: AcpSpawnInput;
70 readonly cwd: string;81 readonly cwd: string;
71 readonly resumeSessionId?: string;82 readonly resumeSessionId?: string;
83 readonly resumeMethod?: "load" | "resume";
72 readonly sessionLoadTimeout?: Duration.Input;84 readonly sessionLoadTimeout?: Duration.Input;
73 readonly sessionLoadReplayIdleGap?: Duration.Input;85 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;
74 readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"];89 readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"];
75 readonly clientInfo: {90 readonly clientInfo: {
76 readonly name: string;91 readonly name: string;
...@@ -78,6 +93,16 @@ export interface AcpSessionRuntimeOptions {...@@ -78,6 +93,16 @@ export interface AcpSessionRuntimeOptions {
78 };93 };
79 readonly authMethodId: string;94 readonly authMethodId: string;
80 readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>;95 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>;
81 readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>;106 readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>;
82 readonly protocolLogging?: {107 readonly protocolLogging?: {
83 readonly logIncoming?: boolean;108 readonly logIncoming?: boolean;
...@@ -197,9 +222,9 @@ export class AcpSessionRuntime extends Context.Service<...@@ -197,9 +222,9 @@ export class AcpSessionRuntime extends Context.Service<
197 * Concurrent calls share the same in-flight startup and a failed startup may be retried.222 * Concurrent calls share the same in-flight startup and a failed startup may be retried.
198 */223 */
199 readonly start: () => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>;224 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. */
201 readonly getEvents: () => Stream.Stream<AcpSessionRuntimeEvent, never>;226 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. */
203 readonly drainEvents: Effect.Effect<void>;228 readonly drainEvents: Effect.Effect<void>;
204 /** Latest mode state observed from session setup and `session/update` notifications. */229 /** Latest mode state observed from session setup and `session/update` notifications. */
205 readonly getModeState: Effect.Effect<AcpSessionModeState | undefined>;230 readonly getModeState: Effect.Effect<AcpSessionModeState | undefined>;
...@@ -288,6 +313,11 @@ interface EnsureActiveAssistantSegmentResult {...@@ -288,6 +313,11 @@ interface EnsureActiveAssistantSegmentResult {
288 readonly startedEvent?: Extract<AcpParsedSessionEvent, { readonly _tag: "AssistantItemStarted" }>;313 readonly startedEvent?: Extract<AcpParsedSessionEvent, { readonly _tag: "AssistantItemStarted" }>;
289}314}
290315
316interface AcpActivePrompt {
317 readonly fiber: Fiber.Fiber<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>;
318 readonly completed: Deferred.Deferred<void>;
319}
320
291export const make = (321export const make = (
292 options: AcpSessionRuntimeOptions,322 options: AcpSessionRuntimeOptions,
293): Effect.Effect<323): Effect.Effect<
...@@ -314,12 +344,51 @@ export const make = (...@@ -314,12 +344,51 @@ export const make = (
314 const assistantSegmentRef = yield* Ref.make<AcpAssistantSegmentState>({ nextSegmentIndex: 0 });344 const assistantSegmentRef = yield* Ref.make<AcpAssistantSegmentState>({ nextSegmentIndex: 0 });
315 const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined));345 const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined));
316 const startStateRef = yield* Ref.make<AcpStartState>({ _tag: "NotStarted" });346 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>();
317 const promptSerializationSemaphore = yield* Semaphore.make(1);356 const promptSerializationSemaphore = yield* Semaphore.make(1);
318 const activePromptFiberRef = yield* Ref.make<357 const promptDispatchSemaphore = yield* Semaphore.make(1);
319 Option.Option<Fiber.Fiber<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>>358 const activePromptRef = yield* Ref.make<Option.Option<AcpActivePrompt>>(Option.none());
320 >(Option.none());
321 const sessionLoadGateRef = yield* Ref.make<Option.Option<SessionLoadGate>>(Option.none());359 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
323 const logRequest = (event: AcpSessionRequestLogEvent) =>392 const logRequest = (event: AcpSessionRequestLogEvent) =>
324 options.requestLogger ? options.requestLogger(event) : Effect.void;393 options.requestLogger ? options.requestLogger(event) : Effect.void;
325394
...@@ -351,16 +420,16 @@ export const make = (...@@ -351,16 +420,16 @@ export const make = (
351 ),420 ),
352 );421 );
353422
354 const spawnCommand = yield* resolveSpawnCommand(423 const spawnCommand = yield* resolveSpawnCommand(options.spawn.command, options.spawn.args, {
355 options.spawn.command,424 ...(options.spawn.env ? { env: options.spawn.env } : {}),
356 options.spawn.args,425 extendEnv: options.spawn.extendEnv ?? true,
357 options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {},426 });
358 );
359 const child = yield* spawner427 const child = yield* spawner
360 .spawn(428 .spawn(
361 ChildProcess.make(spawnCommand.command, spawnCommand.args, {429 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
362 ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}),430 ...(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,
364 shell: spawnCommand.shell,433 shell: spawnCommand.shell,
365 }),434 }),
366 )435 )
...@@ -375,8 +444,22 @@ export const make = (...@@ -375,8 +444,22 @@ export const make = (
375 ),444 ),
376 );445 );
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
378 const acpContext = yield* Layer.build(456 const acpContext = yield* Layer.build(
379 EffectAcpClient.layerChildProcess(child, {457 EffectAcpClient.layerChildProcess(child, {
458 ...(options.transformStdout ? { transformStdout: options.transformStdout } : {}),
459 ...(options.transformSessionUpdate
460 ? { transformSessionUpdate: options.transformSessionUpdate }
461 : {}),
462 onTermination: recordTermination,
380 ...(options.protocolLogging?.logIncoming !== undefined463 ...(options.protocolLogging?.logIncoming !== undefined
381 ? { logIncoming: options.protocolLogging.logIncoming }464 ? { logIncoming: options.protocolLogging.logIncoming }
382 : {}),465 : {}),
...@@ -389,41 +472,72 @@ export const make = (...@@ -389,41 +472,72 @@ export const make = (
389472
390 const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext));473 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
392 yield* acp.handleSessionUpdate((notification) =>486 yield* acp.handleSessionUpdate((notification) =>
393 Effect.gen(function* () {487 notificationSemaphore.withPermit(
394 const gate = yield* Ref.get(sessionLoadGateRef);488 Effect.gen(function* () {
395 if (Option.isSome(gate) && gate.value.active) {489 if (Option.isSome(yield* Ref.get(terminationErrorRef))) {
396 const lastActivityAtMillis = yield* Clock.currentTimeMillis;490 return;
397 yield* Ref.set(491 }
398 sessionLoadGateRef,492 const gate = yield* Ref.get(sessionLoadGateRef);
399 Option.some({493 if (
400 ...gate.value,494 Option.isSome(gate) &&
401 lastActivityAtMillis,495 gate.value.active &&
402 }),496 notification.sessionId === options.resumeSessionId
403 );497 ) {
404 return;498 const lastActivityAtMillis = yield* Clock.currentTimeMillis;
405 }499 yield* Ref.set(
406 if (sessionUpdateIsReplay(notification)) {500 sessionLoadGateRef,
407 return;501 Option.some({
408 }502 ...gate.value,
409 const startState = yield* Ref.get(startStateRef);503 lastActivityAtMillis,
410 // One runtime projects one root ACP session. Child-session updates need504 }),
411 // explicit lineage routing and must never be flattened into this stream.505 );
412 if (506 }
413 startState._tag !== "Started" ||507 if (sessionUpdateIsReplay(notification)) {
414 notification.sessionId !== startState.result.sessionId508 return;
415 ) {509 }
416 return;510 const startState = yield* Ref.get(startStateRef);
417 }511 if (startState._tag === "Starting") {
418 yield* handleSessionUpdate({512 if (isStartupMetadataUpdate(notification)) {
419 queue: eventQueue,513 yield* Ref.update(startupMetadataRef, (current) =>
420 modeStateRef,514 [
421 toolCallsRef,515 ...current.filter(
422 assistantSegmentRef,516 (previous) =>
423 assistantItemRuntimeId,517 previous.sessionId !== notification.sessionId ||
424 params: notification,518 previous.update.sessionUpdate !== notification.update.sessionUpdate,
425 });519 ),
426 }),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))),
427 );541 );
428 const initializeClientCapabilities = {542 const initializeClientCapabilities = {
429 fs: {543 fs: {
...@@ -440,6 +554,7 @@ export const make = (...@@ -440,6 +554,7 @@ export const make = (
440 } satisfies NonNullable<EffectAcpSchema.InitializeRequest["clientCapabilities"]>;554 } satisfies NonNullable<EffectAcpSchema.InitializeRequest["clientCapabilities"]>;
441555
442 const getStartedState = Effect.gen(function* () {556 const getStartedState = Effect.gen(function* () {
557 yield* ensureConnected;
443 const state = yield* Ref.get(startStateRef);558 const state = yield* Ref.get(startStateRef);
444 if (state._tag === "Started") {559 if (state._tag === "Started") {
445 return state.result;560 return state.result;
...@@ -579,7 +694,43 @@ export const make = (...@@ -579,7 +694,43 @@ export const make = (
579 | EffectAcpSchema.LoadSessionResponse694 | EffectAcpSchema.LoadSessionResponse
580 | EffectAcpSchema.NewSessionResponse695 | EffectAcpSchema.NewSessionResponse
581 | EffectAcpSchema.ResumeSessionResponse;696 | 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) {
583 const loadPayload = {734 const loadPayload = {
584 sessionId: options.resumeSessionId,735 sessionId: options.resumeSessionId,
585 cwd: options.cwd,736 cwd: options.cwd,
...@@ -657,6 +808,9 @@ export const make = (...@@ -657,6 +808,9 @@ export const make = (
657 const createPayload = {808 const createPayload = {
658 cwd: options.cwd,809 cwd: options.cwd,
659 mcpServers: options.mcpServers ?? [],810 mcpServers: options.mcpServers ?? [],
811 ...(options.additionalDirectories && options.additionalDirectories.length > 0
812 ? { additionalDirectories: options.additionalDirectories }
813 : {}),
660 } satisfies EffectAcpSchema.NewSessionRequest;814 } satisfies EffectAcpSchema.NewSessionRequest;
661 const created = yield* runLoggedRequest(815 const created = yield* runLoggedRequest(
662 "session/new",816 "session/new",
...@@ -680,6 +834,7 @@ export const make = (...@@ -680,6 +834,7 @@ export const make = (
680 });834 });
681835
682 const start = Effect.gen(function* () {836 const start = Effect.gen(function* () {
837 yield* ensureConnected;
683 const deferred = yield* Deferred.make<838 const deferred = yield* Deferred.make<
684 AcpSessionRuntimeStartResult,839 AcpSessionRuntimeStartResult,
685 EffectAcpErrors.AcpError840 EffectAcpErrors.AcpError
...@@ -694,13 +849,27 @@ export const make = (...@@ -694,13 +849,27 @@ export const make = (
694 return [849 return [
695 startOnce.pipe(850 startOnce.pipe(
696 Effect.tap((result) =>851 Effect.tap((result) =>
697 Ref.set(startStateRef, { _tag: "Started", result }).pipe(852 notificationSemaphore.withPermit(
698 Effect.andThen(Deferred.succeed(deferred, result)),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 }),
699 ),867 ),
700 ),868 ),
701 Effect.onError((cause) =>869 Effect.onError((cause) =>
702 Deferred.failCause(deferred, cause).pipe(870 Deferred.failCause(deferred, cause).pipe(
703 Effect.andThen(Ref.set(startStateRef, { _tag: "NotStarted" })),871 Effect.andThen(Ref.set(startStateRef, { _tag: "NotStarted" })),
872 Effect.andThen(Ref.set(startupMetadataRef, [])),
704 ),873 ),
705 ),874 ),
706 ),875 ),
...@@ -711,6 +880,61 @@ export const make = (...@@ -711,6 +880,61 @@ export const make = (
711 return yield* effect;880 return yield* effect;
712 });881 });
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
714 return {938 return {
715 handleRequestPermission: acp.handleRequestPermission,939 handleRequestPermission: acp.handleRequestPermission,
716 handleElicitation: acp.handleElicitation,940 handleElicitation: acp.handleElicitation,
...@@ -727,77 +951,75 @@ export const make = (...@@ -727,77 +951,75 @@ export const make = (
727 handleUnknownExtNotification: acp.handleUnknownExtNotification,951 handleUnknownExtNotification: acp.handleUnknownExtNotification,
728 handleExtRequest: acp.handleExtRequest,952 handleExtRequest: acp.handleExtRequest,
729 handleExtNotification: acp.handleExtNotification,953 handleExtNotification: acp.handleExtNotification,
730 initialize: () => sendInitialize,954 initialize: () => ensureConnected.pipe(Effect.andThen(sendInitialize)),
731 start: () => start,955 start: () => start,
732 getEvents: () => Stream.fromQueue(eventQueue),956 getEvents: () => Stream.fromQueue(eventQueue),
733 drainEvents: Effect.gen(function* () {957 drainEvents,
734 const acknowledge = yield* Deferred.make<void>();
735 yield* Queue.offer(eventQueue, {
736 _tag: "EventStreamBarrier",
737 acknowledge,
738 });
739 yield* Deferred.await(acknowledge);
740 }),
741 getModeState: Ref.get(modeStateRef),958 getModeState: Ref.get(modeStateRef),
742 getConfigOptions: Ref.get(configOptionsRef),959 getConfigOptions: Ref.get(configOptionsRef),
743 prompt: (payload, promptOptions?) =>960 prompt: (payload, promptOptions?) =>
744 promptSerializationSemaphore.withPermit(961 promptSerializationSemaphore.withPermit(
745 Effect.gen(function* () {962 Effect.acquireUseRelease(
746 const started = yield* getStartedState;963 promptDispatchSemaphore.withPermit(
747 yield* closeActiveAssistantSegment({964 Effect.gen(function* () {
748 queue: eventQueue,965 const started = yield* getStartedState;
749 assistantSegmentRef,966 yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef });
750 });967 const requestPayload = {
751 const requestPayload = {968 sessionId: started.sessionId,
752 sessionId: started.sessionId,969 ...payload,
753 ...payload,970 } satisfies EffectAcpSchema.PromptRequest;
754 } satisfies EffectAcpSchema.PromptRequest;971 const completed = yield* Deferred.make<void>();
755 const cancelledResponse = {972 const fiber = yield* runLoggedRequest(
756 stopReason: "cancelled",973 "session/prompt",
757 } satisfies EffectAcpSchema.PromptResponse;974 requestPayload,
758 const promptRpcFiber = yield* runLoggedRequest(975 acp.agent.prompt(requestPayload),
759 "session/prompt",976 ).pipe(Effect.forkIn(runtimeScope));
760 requestPayload,977 const active = { fiber, completed } satisfies AcpActivePrompt;
761 acp.agent.prompt(requestPayload),978 yield* Ref.set(activePromptRef, Option.some(active));
762 ).pipe(Effect.forkIn(runtimeScope));979 if (promptOptions?.dispatched) {
763 yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber));980 yield* Deferred.succeed(promptOptions.dispatched, undefined);
764 if (promptOptions?.dispatched) {981 }
765 yield* Deferred.succeed(promptOptions.dispatched, undefined);982 return active;
766 }983 }),
767 return yield* Fiber.join(promptRpcFiber).pipe(984 ),
768 Effect.catchCause((cause) =>985 (activePrompt) =>
769 Cause.hasInterruptsOnly(cause)986 Fiber.join(activePrompt.fiber).pipe(
770 ? Effect.succeed(cancelledResponse)987 Effect.catchCause((cause) =>
771 : Effect.failCause(cause),988 options.cancelBehavior !== "wait-for-prompt" && Cause.hasInterruptsOnly(cause)
772 ),989 ? Effect.succeed({
773 Effect.ensuring(990 stopReason: "cancelled",
774 Effect.gen(function* () {991 } satisfies EffectAcpSchema.PromptResponse)
775 yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore);992 : Effect.failCause(cause),
776 yield* Ref.set(activePromptFiberRef, Option.none());993 ),
777 }),994 Effect.tap(() =>
778 ),995 closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }),
779 Effect.tap(() =>996 ),
780 closeActiveAssistantSegment({
781 queue: eventQueue,
782 assistantSegmentRef,
783 }),
784 ),997 ),
785 );998 (activePrompt, result) =>
786 }),999 Effect.gen(function* () {
787 ),1000 if (
788 cancel: getStartedState.pipe(1001 options.cancelBehavior === "wait-for-prompt" &&
789 Effect.flatMap((started) =>1002 Exit.isFailure(result) &&
790 Effect.gen(function* () {1003 Cause.hasInterrupts(result.cause)
791 const activePromptFiber = yield* Ref.get(activePromptFiberRef);1004 ) {
792 if (Option.isSome(activePromptFiber)) {1005 yield* retireRuntime(
793 yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore);1006 new EffectAcpErrors.AcpTransportError({
794 }1007 method: "session/prompt",
795 // Await the notification write so a replacement session/prompt1008 detail: "The ACP prompt stopped before the agent confirmed completion.",
796 // cannot race ahead of session/cancel on the wire.1009 cause: undefined,
797 yield* acp.agent.cancel({ sessionId: started.sessionId }).pipe(Effect.ignore);1010 }),
798 }),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 ),
799 ),1018 ),
800 ),1019 cancel:
1020 options.cancelBehavior === "wait-for-prompt"
1021 ? promptDispatchSemaphore.withPermit(cancel)
1022 : cancel,
801 setMode: (modeId) =>1023 setMode: (modeId) =>
802 Ref.get(modeStateRef).pipe(1024 Ref.get(modeStateRef).pipe(
803 Effect.flatMap((modeState) => {1025 Effect.flatMap((modeState) => {
...@@ -832,8 +1054,11 @@ export const make = (...@@ -832,8 +1054,11 @@ export const make = (
832 }),1054 }),
833 ),1055 ),
834 request: (method, payload) =>1056 request: (method, payload) =>
835 runLoggedRequest(method, payload, acp.raw.request(method, payload)),1057 ensureConnected.pipe(
836 notify: acp.raw.notify,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))),
837 } satisfies AcpSessionRuntime["Service"];1062 } satisfies AcpSessionRuntime["Service"];
838 });1063 });
8391064
...@@ -869,9 +1094,21 @@ function configOptionCurrentValueMatches(...@@ -869,9 +1094,21 @@ function configOptionCurrentValueMatches(
869 return currentValue.trim() === String(value).trim();1094 return currentValue.trim() === String(value).trim();
870}1095}
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
872const handleSessionUpdate = ({1108const handleSessionUpdate = ({
873 queue,1109 queue,
874 modeStateRef,1110 modeStateRef,
1111 configOptionsRef,
875 toolCallsRef,1112 toolCallsRef,
876 assistantSegmentRef,1113 assistantSegmentRef,
877 assistantItemRuntimeId,1114 assistantItemRuntimeId,
...@@ -879,12 +1116,16 @@ const handleSessionUpdate = ({...@@ -879,12 +1116,16 @@ const handleSessionUpdate = ({
879}: {1116}: {
880 readonly queue: Queue.Queue<AcpSessionRuntimeEvent>;1117 readonly queue: Queue.Queue<AcpSessionRuntimeEvent>;
881 readonly modeStateRef: Ref.Ref<AcpSessionModeState | undefined>;1118 readonly modeStateRef: Ref.Ref<AcpSessionModeState | undefined>;
1119 readonly configOptionsRef: Ref.Ref<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>;
882 readonly toolCallsRef: Ref.Ref<Map<string, AcpToolCallTrackedState>>;1120 readonly toolCallsRef: Ref.Ref<Map<string, AcpToolCallTrackedState>>;
883 readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>;1121 readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>;
884 readonly assistantItemRuntimeId: string;1122 readonly assistantItemRuntimeId: string;
885 readonly params: EffectAcpSchema.SessionNotification;1123 readonly params: EffectAcpSchema.SessionNotification;
886}): Effect.Effect<void> =>1124}): Effect.Effect<void> =>
887 Effect.gen(function* () {1125 Effect.gen(function* () {
1126 if (params.update.sessionUpdate === "config_option_update") {
1127 yield* Ref.set(configOptionsRef, params.update.configOptions);
1128 }
888 const parsed = parseSessionUpdateEvent(params);1129 const parsed = parseSessionUpdateEvent(params);
889 if (parsed.modeId) {1130 if (parsed.modeId) {
890 yield* Ref.update(modeStateRef, (current) =>1131 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";...@@ -25,6 +25,7 @@ import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";
25import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts";25import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts";
26import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";26import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";
27import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts";27import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts";
28import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts";
28import type { AnyProviderDriver } from "./ProviderDriver.ts";29import type { AnyProviderDriver } from "./ProviderDriver.ts";
2930
30/**31/**
...@@ -37,7 +38,8 @@ export type BuiltInDriversEnv =...@@ -37,7 +38,8 @@ export type BuiltInDriversEnv =
37 | CodexDriverEnv38 | CodexDriverEnv
38 | CursorDriverEnv39 | CursorDriverEnv
39 | GrokDriverEnv40 | GrokDriverEnv
40 | OpenCodeDriverEnv;41 | OpenCodeDriverEnv
42 | AntigravityDriverEnv;
4143
42/**44/**
43 * Ordered list of built-in drivers. Order matters only for tie-breaking in45 * 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...@@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray<AnyProviderDriver<BuiltInDriversEnv
50 CursorDriver,52 CursorDriver,
51 GrokDriver,53 GrokDriver,
52 OpenCodeDriver,54 OpenCodeDriver,
55 AntigravityDriver,
53];56];
apps/server/src/provider/model-manifest.json+388-85
...@@ -8,7 +8,8 @@...@@ -8,7 +8,8 @@
8 "gpt-daybreak-blue-latest",8 "gpt-daybreak-blue-latest",
9 "gpt-daybreak-red-latest"9 "gpt-daybreak-red-latest"
10 ],10 ],
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"]
12 },13 },
13 "providers": {14 "providers": {
14 "claudeAgent": {15 "claudeAgent": {
...@@ -24,17 +25,36 @@...@@ -24,17 +25,36 @@
24 "label": "Reasoning",25 "label": "Reasoning",
25 "type": "select",26 "type": "select",
26 "options": [27 "options": [
27 { "id": "low", "label": "Low" },28 {
28 { "id": "medium", "label": "Medium" },29 "id": "low",
29 { "id": "high", "label": "High", "isDefault": true },30 "label": "Low"
30 { "id": "xhigh", "label": "Extra High" },31 },
31 { "id": "max", "label": "Max" },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 },
32 {49 {
33 "id": "ultracode",50 "id": "ultracode",
34 "label": "Ultracode",51 "label": "Ultracode",
35 "description": "xhigh effort plus multi-agent workflow orchestration"52 "description": "xhigh effort plus multi-agent workflow orchestration"
36 },53 },
37 { "id": "ultrathink", "label": "Ultrathink" }54 {
55 "id": "ultrathink",
56 "label": "Ultrathink"
57 }
38 ],58 ],
39 "promptInjectedValues": ["ultrathink"]59 "promptInjectedValues": ["ultrathink"]
40 },60 },
...@@ -43,17 +63,34 @@...@@ -43,17 +63,34 @@
43 "label": "Context Window",63 "label": "Context Window",
44 "type": "select",64 "type": "select",
45 "options": [65 "options": [
46 { "id": "200k", "label": "200k" },66 {
47 { "id": "1m", "label": "1M", "isDefault": true }67 "id": "200k",
68 "label": "200k"
69 },
70 {
71 "id": "1m",
72 "label": "1M",
73 "isDefault": true
74 }
48 ]75 ]
49 }76 }
50 ]77 ]
51 },78 },
52 "adapter": {79 "adapter": {
53 "claudeCode": {80 "claudeCode": {
54 "effortMap": { "ultracode": "xhigh", "ultrathink": null },81 "effortMap": {
55 "modelSuffixes": { "contextWindow": { "1m": "[1m]" } },82 "ultracode": "xhigh",
56 "contextWindowTokens": { "200k": 200000, "1m": 1000000 }83 "ultrathink": null
84 },
85 "modelSuffixes": {
86 "contextWindow": {
87 "1m": "[1m]"
88 }
89 },
90 "contextWindowTokens": {
91 "200k": 200000,
92 "1m": 1000000
93 }
57 }94 }
58 }95 }
59 },96 },
...@@ -65,37 +102,77 @@...@@ -65,37 +102,77 @@
65 "label": "Reasoning",102 "label": "Reasoning",
66 "type": "select",103 "type": "select",
67 "options": [104 "options": [
68 { "id": "low", "label": "Low" },105 {
69 { "id": "medium", "label": "Medium" },106 "id": "low",
70 { "id": "high", "label": "High", "isDefault": true },107 "label": "Low"
71 { "id": "xhigh", "label": "Extra High" },108 },
72 { "id": "max", "label": "Max" },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 },
73 {126 {
74 "id": "ultracode",127 "id": "ultracode",
75 "label": "Ultracode",128 "label": "Ultracode",
76 "description": "xhigh effort plus multi-agent workflow orchestration"129 "description": "xhigh effort plus multi-agent workflow orchestration"
77 },130 },
78 { "id": "ultrathink", "label": "Ultrathink" }131 {
132 "id": "ultrathink",
133 "label": "Ultrathink"
134 }
79 ],135 ],
80 "promptInjectedValues": ["ultrathink"]136 "promptInjectedValues": ["ultrathink"]
81 },137 },
82 { "id": "fastMode", "label": "Fast Mode", "type": "boolean" },138 {
139 "id": "fastMode",
140 "label": "Fast Mode",
141 "type": "boolean"
142 },
83 {143 {
84 "id": "contextWindow",144 "id": "contextWindow",
85 "label": "Context Window",145 "label": "Context Window",
86 "type": "select",146 "type": "select",
87 "options": [147 "options": [
88 { "id": "200k", "label": "200k" },148 {
89 { "id": "1m", "label": "1M", "isDefault": true }149 "id": "200k",
150 "label": "200k"
151 },
152 {
153 "id": "1m",
154 "label": "1M",
155 "isDefault": true
156 }
90 ]157 ]
91 }158 }
92 ]159 ]
93 },160 },
94 "adapter": {161 "adapter": {
95 "claudeCode": {162 "claudeCode": {
96 "effortMap": { "ultracode": "xhigh", "ultrathink": null },163 "effortMap": {
97 "modelSuffixes": { "contextWindow": { "1m": "[1m]" } },164 "ultracode": "xhigh",
98 "contextWindowTokens": { "200k": 200000, "1m": 1000000 }165 "ultrathink": null
166 },
167 "modelSuffixes": {
168 "contextWindow": {
169 "1m": "[1m]"
170 }
171 },
172 "contextWindowTokens": {
173 "200k": 200000,
174 "1m": 1000000
175 }
99 }176 }
100 }177 }
101 },178 },
...@@ -107,26 +184,52 @@...@@ -107,26 +184,52 @@
107 "label": "Reasoning",184 "label": "Reasoning",
108 "type": "select",185 "type": "select",
109 "options": [186 "options": [
110 { "id": "low", "label": "Low" },187 {
111 { "id": "medium", "label": "Medium" },188 "id": "low",
112 { "id": "high", "label": "High", "isDefault": true },189 "label": "Low"
113 { "id": "xhigh", "label": "Extra High" },190 },
114 { "id": "max", "label": "Max" },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 },
115 {208 {
116 "id": "ultracode",209 "id": "ultracode",
117 "label": "Ultracode",210 "label": "Ultracode",
118 "description": "xhigh effort plus multi-agent workflow orchestration"211 "description": "xhigh effort plus multi-agent workflow orchestration"
119 },212 },
120 { "id": "ultrathink", "label": "Ultrathink" }213 {
214 "id": "ultrathink",
215 "label": "Ultrathink"
216 }
121 ],217 ],
122 "promptInjectedValues": ["ultrathink"]218 "promptInjectedValues": ["ultrathink"]
123 },219 },
124 { "id": "fastMode", "label": "Fast Mode", "type": "boolean" }220 {
221 "id": "fastMode",
222 "label": "Fast Mode",
223 "type": "boolean"
224 }
125 ]225 ]
126 },226 },
127 "adapter": {227 "adapter": {
128 "claudeCode": {228 "claudeCode": {
129 "effortMap": { "ultracode": "xhigh", "ultrathink": null },229 "effortMap": {
230 "ultracode": "xhigh",
231 "ultrathink": null
232 },
130 "fixedContextWindowTokens": 1000000233 "fixedContextWindowTokens": 1000000
131 }234 }
132 }235 }
...@@ -139,21 +242,47 @@...@@ -139,21 +242,47 @@
139 "label": "Reasoning",242 "label": "Reasoning",
140 "type": "select",243 "type": "select",
141 "options": [244 "options": [
142 { "id": "low", "label": "Low" },245 {
143 { "id": "medium", "label": "Medium" },246 "id": "low",
144 { "id": "high", "label": "High" },247 "label": "Low"
145 { "id": "xhigh", "label": "Extra High", "isDefault": true },248 },
146 { "id": "max", "label": "Max" },249 {
147 { "id": "ultrathink", "label": "Ultrathink" }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 }
148 ],270 ],
149 "promptInjectedValues": ["ultrathink"]271 "promptInjectedValues": ["ultrathink"]
150 },272 },
151 { "id": "fastMode", "label": "Fast Mode", "type": "boolean" }273 {
274 "id": "fastMode",
275 "label": "Fast Mode",
276 "type": "boolean"
277 }
152 ]278 ]
153 },279 },
154 "adapter": {280 "adapter": {
155 "claudeCode": {281 "claudeCode": {
156 "effortMap": { "xhigh": "max", "ultrathink": null },282 "effortMap": {
283 "xhigh": "max",
284 "ultrathink": null
285 },
157 "fixedContextWindowTokens": 1000000286 "fixedContextWindowTokens": 1000000
158 }287 }
159 }288 }
...@@ -166,31 +295,67 @@...@@ -166,31 +295,67 @@
166 "label": "Reasoning",295 "label": "Reasoning",
167 "type": "select",296 "type": "select",
168 "options": [297 "options": [
169 { "id": "low", "label": "Low" },298 {
170 { "id": "medium", "label": "Medium" },299 "id": "low",
171 { "id": "high", "label": "High", "isDefault": true },300 "label": "Low"
172 { "id": "max", "label": "Max" },301 },
173 { "id": "ultrathink", "label": "Ultrathink" }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 }
174 ],319 ],
175 "promptInjectedValues": ["ultrathink"]320 "promptInjectedValues": ["ultrathink"]
176 },321 },
177 { "id": "fastMode", "label": "Fast Mode", "type": "boolean" },322 {
323 "id": "fastMode",
324 "label": "Fast Mode",
325 "type": "boolean"
326 },
178 {327 {
179 "id": "contextWindow",328 "id": "contextWindow",
180 "label": "Context Window",329 "label": "Context Window",
181 "type": "select",330 "type": "select",
182 "options": [331 "options": [
183 { "id": "200k", "label": "200k" },332 {
184 { "id": "1m", "label": "1M", "isDefault": true }333 "id": "200k",
334 "label": "200k"
335 },
336 {
337 "id": "1m",
338 "label": "1M",
339 "isDefault": true
340 }
185 ]341 ]
186 }342 }
187 ]343 ]
188 },344 },
189 "adapter": {345 "adapter": {
190 "claudeCode": {346 "claudeCode": {
191 "effortMap": { "ultrathink": null },347 "effortMap": {
192 "modelSuffixes": { "contextWindow": { "1m": "[1m]" } },348 "ultrathink": null
193 "contextWindowTokens": { "200k": 200000, "1m": 1000000 }349 },
350 "modelSuffixes": {
351 "contextWindow": {
352 "1m": "[1m]"
353 }
354 },
355 "contextWindowTokens": {
356 "200k": 200000,
357 "1m": 1000000
358 }
194 }359 }
195 }360 }
196 },361 },
...@@ -202,16 +367,35 @@...@@ -202,16 +367,35 @@
202 "label": "Reasoning",367 "label": "Reasoning",
203 "type": "select",368 "type": "select",
204 "options": [369 "options": [
205 { "id": "low", "label": "Low" },370 {
206 { "id": "medium", "label": "Medium" },371 "id": "low",
207 { "id": "high", "label": "High", "isDefault": true },372 "label": "Low"
208 { "id": "max", "label": "Max" }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 }
209 ]387 ]
210 },388 },
211 { "id": "fastMode", "label": "Fast Mode", "type": "boolean" }389 {
390 "id": "fastMode",
391 "label": "Fast Mode",
392 "type": "boolean"
393 }
212 ]394 ]
213 },395 },
214 "adapter": { "claudeCode": {} }396 "adapter": {
397 "claudeCode": {}
398 }
215 },399 },
216 "sonnet-5": {400 "sonnet-5": {
217 "capabilities": {401 "capabilities": {
...@@ -221,12 +405,31 @@...@@ -221,12 +405,31 @@
221 "label": "Reasoning",405 "label": "Reasoning",
222 "type": "select",406 "type": "select",
223 "options": [407 "options": [
224 { "id": "low", "label": "Low" },408 {
225 { "id": "medium", "label": "Medium" },409 "id": "low",
226 { "id": "high", "label": "High", "isDefault": true },410 "label": "Low"
227 { "id": "xhigh", "label": "Extra High" },411 },
228 { "id": "max", "label": "Max" },412 {
229 { "id": "ultrathink", "label": "Ultrathink" }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 }
230 ],433 ],
231 "promptInjectedValues": ["ultrathink"]434 "promptInjectedValues": ["ultrathink"]
232 },435 },
...@@ -235,17 +438,33 @@...@@ -235,17 +438,33 @@
235 "label": "Context Window",438 "label": "Context Window",
236 "type": "select",439 "type": "select",
237 "options": [440 "options": [
238 { "id": "200k", "label": "200k", "isDefault": true },441 {
239 { "id": "1m", "label": "1M" }442 "id": "200k",
443 "label": "200k",
444 "isDefault": true
445 },
446 {
447 "id": "1m",
448 "label": "1M"
449 }
240 ]450 ]
241 }451 }
242 ]452 ]
243 },453 },
244 "adapter": {454 "adapter": {
245 "claudeCode": {455 "claudeCode": {
246 "effortMap": { "ultrathink": null },456 "effortMap": {
247 "modelSuffixes": { "contextWindow": { "1m": "[1m]" } },457 "ultrathink": null
248 "contextWindowTokens": { "200k": 200000, "1m": 1000000 }458 },
459 "modelSuffixes": {
460 "contextWindow": {
461 "1m": "[1m]"
462 }
463 },
464 "contextWindowTokens": {
465 "200k": 200000,
466 "1m": 1000000
467 }
249 }468 }
250 }469 }
251 },470 },
...@@ -257,11 +476,27 @@...@@ -257,11 +476,27 @@
257 "label": "Reasoning",476 "label": "Reasoning",
258 "type": "select",477 "type": "select",
259 "options": [478 "options": [
260 { "id": "low", "label": "Low" },479 {
261 { "id": "medium", "label": "Medium" },480 "id": "low",
262 { "id": "high", "label": "High", "isDefault": true },481 "label": "Low"
263 { "id": "max", "label": "Max" },482 },
264 { "id": "ultrathink", "label": "Ultrathink" }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 }
265 ],500 ],
266 "promptInjectedValues": ["ultrathink"]501 "promptInjectedValues": ["ultrathink"]
267 },502 },
...@@ -270,25 +505,50 @@...@@ -270,25 +505,50 @@
270 "label": "Context Window",505 "label": "Context Window",
271 "type": "select",506 "type": "select",
272 "options": [507 "options": [
273 { "id": "200k", "label": "200k", "isDefault": true },508 {
274 { "id": "1m", "label": "1M" }509 "id": "200k",
510 "label": "200k",
511 "isDefault": true
512 },
513 {
514 "id": "1m",
515 "label": "1M"
516 }
275 ]517 ]
276 }518 }
277 ]519 ]
278 },520 },
279 "adapter": {521 "adapter": {
280 "claudeCode": {522 "claudeCode": {
281 "effortMap": { "max": "high", "ultrathink": null },523 "effortMap": {
282 "modelSuffixes": { "contextWindow": { "1m": "[1m]" } },524 "max": "high",
283 "contextWindowTokens": { "200k": 200000, "1m": 1000000 }525 "ultrathink": null
526 },
527 "modelSuffixes": {
528 "contextWindow": {
529 "1m": "[1m]"
530 }
531 },
532 "contextWindowTokens": {
533 "200k": 200000,
534 "1m": 1000000
535 }
284 }536 }
285 }537 }
286 },538 },
287 "haiku-4-5": {539 "haiku-4-5": {
288 "capabilities": {540 "capabilities": {
289 "optionDescriptors": [{ "id": "thinking", "label": "Thinking", "type": "boolean" }]541 "optionDescriptors": [
542 {
543 "id": "thinking",
544 "label": "Thinking",
545 "type": "boolean"
546 }
547 ]
290 },548 },
291 "adapter": { "claudeCode": {} }549 "adapter": {
550 "claudeCode": {}
551 }
292 }552 }
293 },553 },
294 "models": [554 "models": [
...@@ -299,14 +559,22 @@...@@ -299,14 +559,22 @@
299 "status": "current",559 "status": "current",
300 "badge": "new",560 "badge": "new",
301 "profile": "fable-5",561 "profile": "fable-5",
302 "adapter": { "claudeCode": { "minVersion": "2.1.257" } }562 "adapter": {
563 "claudeCode": {
564 "minVersion": "2.1.257"
565 }
566 }
303 },567 },
304 {568 {
305 "slug": "claude-fable-5",569 "slug": "claude-fable-5",
306 "name": "Claude Fable 5",570 "name": "Claude Fable 5",
307 "status": "legacy",571 "status": "legacy",
308 "profile": "fable-5",572 "profile": "fable-5",
309 "adapter": { "claudeCode": { "minVersion": "2.1.169" } }573 "adapter": {
574 "claudeCode": {
575 "minVersion": "2.1.169"
576 }
577 }
310 },578 },
311 {579 {
312 "slug": "claude-opus-5",580 "slug": "claude-opus-5",
...@@ -314,7 +582,11 @@...@@ -314,7 +582,11 @@
314 "aliases": ["opus", "opus-5", "claude-opus-5.0", "claude-opus-5-0"],582 "aliases": ["opus", "opus-5", "claude-opus-5.0", "claude-opus-5-0"],
315 "status": "current",583 "status": "current",
316 "profile": "opus-5",584 "profile": "opus-5",
317 "adapter": { "claudeCode": { "minVersion": "2.1.219" } }585 "adapter": {
586 "claudeCode": {
587 "minVersion": "2.1.219"
588 }
589 }
318 },590 },
319 {591 {
320 "slug": "claude-opus-4-8",592 "slug": "claude-opus-4-8",
...@@ -322,7 +594,11 @@...@@ -322,7 +594,11 @@
322 "aliases": ["opus-4.8", "claude-opus-4.8"],594 "aliases": ["opus-4.8", "claude-opus-4.8"],
323 "status": "legacy",595 "status": "legacy",
324 "profile": "opus-4-8",596 "profile": "opus-4-8",
325 "adapter": { "claudeCode": { "minVersion": "2.1.154" } }597 "adapter": {
598 "claudeCode": {
599 "minVersion": "2.1.154"
600 }
601 }
326 },602 },
327 {603 {
328 "slug": "claude-opus-4-7",604 "slug": "claude-opus-4-7",
...@@ -330,7 +606,11 @@...@@ -330,7 +606,11 @@
330 "aliases": ["opus-4.7", "claude-opus-4.7"],606 "aliases": ["opus-4.7", "claude-opus-4.7"],
331 "status": "legacy",607 "status": "legacy",
332 "profile": "opus-4-7",608 "profile": "opus-4-7",
333 "adapter": { "claudeCode": { "minVersion": "2.1.111" } }609 "adapter": {
610 "claudeCode": {
611 "minVersion": "2.1.111"
612 }
613 }
334 },614 },
335 {615 {
336 "slug": "claude-opus-4-6",616 "slug": "claude-opus-4-6",
...@@ -367,6 +647,29 @@...@@ -367,6 +647,29 @@
367 "profile": "haiku-4-5"647 "profile": "haiku-4-5"
368 }648 }
369 ]649 ]
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 ]
370 }673 }
371 }674 }
372}675}
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 = (...@@ -29,13 +29,32 @@ const mergeProviderModels = (
29 ];29 ];
30};30};
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
32export const orderProviderSnapshots = (50export const orderProviderSnapshots = (
33 providers: ReadonlyArray<ServerProvider>,51 providers: ReadonlyArray<ServerProvider>,
34): ReadonlyArray<ServerProvider> =>52): ReadonlyArray<ServerProvider> =>
35 [...providers].toSorted(53 [...providers].toSorted(
36 (left, right) =>54 (left, right) =>
37 (left.displayName ?? "").localeCompare(right.displayName ?? "") ||55 driverRank(left.driver) - driverRank(right.driver) ||
38 left.driver.localeCompare(right.driver) ||56 left.driver.localeCompare(right.driver) ||
57 (left.displayName ?? "").localeCompare(right.displayName ?? "") ||
39 left.instanceId.localeCompare(right.instanceId),58 left.instanceId.localeCompare(right.instanceId),
40 );59 );
4160
apps/server/src/server.test.ts+367
...@@ -29,8 +29,11 @@ import {...@@ -29,8 +29,11 @@ import {
29 ORCHESTRATION_WS_METHODS,29 ORCHESTRATION_WS_METHODS,
30 type PreviewEvent,30 type PreviewEvent,
31 ProjectId,31 ProjectId,
32 type ProviderAuthState,
32 ProviderDriverKind,33 ProviderDriverKind,
33 ProviderInstanceId,34 ProviderInstanceId,
35 type ProviderInstallState,
36 ProviderSetupError,
34 ResolvedKeybindingRule,37 ResolvedKeybindingRule,
35 ThreadId,38 ThreadId,
36 TurnId,39 TurnId,
...@@ -112,6 +115,13 @@ import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";...@@ -112,6 +115,13 @@ import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
112import { PersistenceSqlError } from "./persistence/Errors.ts";115import { PersistenceSqlError } from "./persistence/Errors.ts";
113import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";116import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
114import * as ProviderService from "./provider/Services/ProviderService.ts";117import * 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";
115import { ProviderAdapterRequestError } from "./provider/Errors.ts";125import { ProviderAdapterRequestError } from "./provider/Errors.ts";
116import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts";126import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts";
117import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";127import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
...@@ -192,6 +202,47 @@ const defaultModelSelection = {...@@ -192,6 +202,47 @@ const defaultModelSelection = {
192 model: "gpt-5-codex",202 model: "gpt-5-codex",
193} as const;203} 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
195const makeLiveToolActivityEvent = (246const makeLiveToolActivityEvent = (
196 sequence: number,247 sequence: number,
197 kind: "tool.updated" | "tool.completed" = "tool.updated",248 kind: "tool.updated" | "tool.completed" = "tool.updated",
...@@ -437,6 +488,9 @@ const buildAppUnderTest = (options?: {...@@ -437,6 +488,9 @@ const buildAppUnderTest = (options?: {
437 environmentTheme?: Partial<EnvironmentTheme.EnvironmentThemeService["Service"]>;488 environmentTheme?: Partial<EnvironmentTheme.EnvironmentThemeService["Service"]>;
438 providerRegistry?: Partial<ProviderRegistry.ProviderRegistry["Service"]>;489 providerRegistry?: Partial<ProviderRegistry.ProviderRegistry["Service"]>;
439 providerService?: Partial<ProviderService.ProviderService["Service"]>;490 providerService?: Partial<ProviderService.ProviderService["Service"]>;
491 providerAuth?: Partial<ProviderAuthService["Service"]>;
492 providerInstanceRegistry?: Partial<ProviderInstanceRegistry["Service"]>;
493 antigravityInstallation?: Partial<AntigravityInstallation["Service"]>;
440 serverSettings?: Partial<ServerSettings.ServerSettingsService["Service"]>;494 serverSettings?: Partial<ServerSettings.ServerSettingsService["Service"]>;
441 externalLauncher?: Partial<ExternalLauncher.ExternalLauncher["Service"]>;495 externalLauncher?: Partial<ExternalLauncher.ExternalLauncher["Service"]>;
442 vcsDriver?: Partial<VcsDriver.VcsDriver["Service"]>;496 vcsDriver?: Partial<VcsDriver.VcsDriver["Service"]>;
...@@ -706,6 +760,18 @@ const buildAppUnderTest = (options?: {...@@ -706,6 +760,18 @@ const buildAppUnderTest = (options?: {
706 uploadFeedback: () => Effect.die("Provider feedback is not stubbed in this test"),760 uploadFeedback: () => Effect.die("Provider feedback is not stubbed in this test"),
707 ...options?.layers?.providerService,761 ...options?.layers?.providerService,
708 }),762 }),
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 }),
709 ),775 ),
710 ),776 ),
711 Layer.provide(777 Layer.provide(
...@@ -4981,6 +5047,307 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -4981,6 +5047,307 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
4981 }).pipe(Effect.provide(NodeHttpServer.layerTest)),5047 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
4982 );5048 );
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
4984 it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () =>5351 it.effect("routes websocket rpc subscribeServerConfig streams snapshot then update", () =>
4985 Effect.gen(function* () {5352 Effect.gen(function* () {
4986 const path = yield* Path.Path;5353 const path = yield* Path.Path;
apps/server/src/server.ts+37-1
...@@ -1,9 +1,10 @@...@@ -1,9 +1,10 @@
1import { EnvironmentHttpApi } from "@t3tools/contracts";1import { EnvironmentHttpApi, ProviderDriverKind } from "@t3tools/contracts";
2import * as Duration from "effect/Duration";2import * as Duration from "effect/Duration";
3import * as Deferred from "effect/Deferred";3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Schedule from "effect/Schedule";6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
7import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http";8import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http";
8import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";9import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
910
...@@ -35,6 +36,10 @@ import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRe...@@ -35,6 +36,10 @@ import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRe
35import * as ModelManifest from "./provider/ModelManifest.ts";36import * as ModelManifest from "./provider/ModelManifest.ts";
36import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts";37import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts";
37import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts";38import { 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";
38import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts";43import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts";
39import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts";44import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts";
40import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";45import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
...@@ -406,7 +411,36 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(...@@ -406,7 +411,36 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(
406 Layer.provideMerge(OrchestrationLayerLive),411 Layer.provideMerge(OrchestrationLayerLive),
407);412);
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
409const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(441const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
442 Layer.provideMerge(AntigravityInstallationRefreshLive),
443 Layer.provideMerge(ProviderAuthServiceLive),
410 // Core Services444 // Core Services
411 Layer.provideMerge(ServerSettingsLayerLive),445 Layer.provideMerge(ServerSettingsLayerLive),
412 Layer.provideMerge(CheckpointingLayerLive),446 Layer.provideMerge(CheckpointingLayerLive),
...@@ -428,6 +462,8 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(...@@ -428,6 +462,8 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
428 // `providerInstances` hydration merges `settings.providers.<kind>`462 // `providerInstances` hydration merges `settings.providers.<kind>`
429 // with explicit `providerInstances` entries on boot.463 // with explicit `providerInstances` entries on boot.
430 Layer.provideMerge(ProviderInstanceRegistryHydrationLive),464 Layer.provideMerge(ProviderInstanceRegistryHydrationLive),
465).pipe(
466 Layer.provideMerge(AntigravityInstallation.layer),
431 // Shared native/canonical NDJSON writers used by both the per-instance467 // Shared native/canonical NDJSON writers used by both the per-instance
432 // drivers (native stream, written from inside each `<X>Adapter`) and468 // drivers (native stream, written from inside each `<X>Adapter`) and
433 // `ProviderService` (canonical stream, written after event normalization).469 // `ProviderService` (canonical stream, written after event normalization).
apps/server/src/serverRuntimeStartup.reconcile.test.ts+1
...@@ -57,6 +57,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray<ThreadId> = []) =>...@@ -57,6 +57,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray<ThreadId> = []) =>
57 stopSession: () => Effect.die("unused"),57 stopSession: () => Effect.die("unused"),
58 listSessions: () => Effect.succeed(liveThreadIds.map((threadId) => ({ threadId }) as never)),58 listSessions: () => Effect.succeed(liveThreadIds.map((threadId) => ({ threadId }) as never)),
59 getCapabilities: () => Effect.die("unused"),59 getCapabilities: () => Effect.die("unused"),
60 assertConversationRollbackSupported: () => Effect.die("unused"),
60 getInstanceInfo: () => Effect.die("unused"),61 getInstanceInfo: () => Effect.die("unused"),
61 rollbackConversation: () => Effect.die("unused"),62 rollbackConversation: () => Effect.die("unused"),
62 uploadFeedback: () => Effect.die("unused"),63 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 {...@@ -48,6 +48,7 @@ import {
48 ProjectSearchEntriesError,48 ProjectSearchEntriesError,
49 ProjectWriteFileError,49 ProjectWriteFileError,
50 ProviderUploadFeedbackError,50 ProviderUploadFeedbackError,
51 ProviderSetupError,
51 RelayClientInstallFailedError,52 RelayClientInstallFailedError,
52 type RelayClientInstallProgressEvent,53 type RelayClientInstallProgressEvent,
53 ServerSelfUpdateError,54 ServerSelfUpdateError,
...@@ -95,6 +96,9 @@ import {...@@ -95,6 +96,9 @@ import {
95import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";96import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
96import * as ProviderService from "./provider/Services/ProviderService.ts";97import * as ProviderService from "./provider/Services/ProviderService.ts";
97import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";98import * 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";
98import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";102import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";
99import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";103import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
100import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";104import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
...@@ -509,6 +513,9 @@ const makeWsRpcLayer = (...@@ -509,6 +513,9 @@ const makeWsRpcLayer = (
509 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;513 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
510 const providerService = yield* ProviderService.ProviderService;514 const providerService = yield* ProviderService.ProviderService;
511 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;515 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
516 const providerAuth = yield* ProviderAuthService;
517 const providerInstances = yield* ProviderInstanceRegistry;
518 const providerInstallation = yield* makeProviderInstallation();
512 const serverUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;519 const serverUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;
513 const config = yield* ServerConfig.ServerConfig;520 const config = yield* ServerConfig.ServerConfig;
514 const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents;521 const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents;
...@@ -1666,15 +1673,44 @@ const makeWsRpcLayer = (...@@ -1666,15 +1673,44 @@ const makeWsRpcLayer = (
1666 [WS_METHODS.serverRefreshProviders]: (input) =>1673 [WS_METHODS.serverRefreshProviders]: (input) =>
1667 observeRpcEffect(1674 observeRpcEffect(
1668 WS_METHODS.serverRefreshProviders,1675 WS_METHODS.serverRefreshProviders,
1669 (input.cwd !== undefined && input.instanceId !== undefined1676 Effect.gen(function* () {
1670 ? providerRegistry.refreshWorkspaceSnapshot({1677 let providers = yield* input.cwd !== undefined && input.instanceId !== undefined
1671 instanceId: input.instanceId,1678 ? providerRegistry.refreshWorkspaceSnapshot({
1672 cwd: input.cwd,1679 instanceId: input.instanceId,
1673 })1680 cwd: input.cwd,
1674 : input.instanceId !== undefined1681 })
1675 ? providerRegistry.refreshInstance(input.instanceId)1682 : input.instanceId !== undefined
1676 : providerRegistry.refresh()1683 ? providerRegistry.refreshInstance(input.instanceId)
1677 ).pipe(Effect.map((providers) => ({ providers }))),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 }),
1678 { "rpc.aggregate": "server" },1714 { "rpc.aggregate": "server" },
1679 ),1715 ),
1680 [WS_METHODS.providerUploadFeedback]: (input) =>1716 [WS_METHODS.providerUploadFeedback]: (input) =>
...@@ -1699,6 +1735,52 @@ const makeWsRpcLayer = (...@@ -1699,6 +1735,52 @@ const makeWsRpcLayer = (
1699 "rpc.aggregate": "server",1735 "rpc.aggregate": "server",
1700 },1736 },
1701 ),1737 ),
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 }),
1702 [WS_METHODS.serverUpdateServer]: (input) =>1784 [WS_METHODS.serverUpdateServer]: (input) =>
1703 observeRpcEffect(WS_METHODS.serverUpdateServer, serverUpdate.update(input), {1785 observeRpcEffect(WS_METHODS.serverUpdateServer, serverUpdate.update(input), {
1704 "rpc.aggregate": "server",1786 "rpc.aggregate": "server",
apps/web/src/components/ChatView.logic.test.ts+327-1
...@@ -1,14 +1,19 @@...@@ -1,14 +1,19 @@
1import {1import {
2 CheckpointRef,
2 EnvironmentId,3 EnvironmentId,
3 MessageId,4 MessageId,
4 ProjectId,5 ProjectId,
6 ProviderDriverKind,
5 ProviderInstanceId,7 ProviderInstanceId,
8 type ServerProvider,
6 ThreadId,9 ThreadId,
7 TurnId,10 TurnId,
8} from "@t3tools/contracts";11} from "@t3tools/contracts";
9import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";12import { 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";
12import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";17import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates";
13import type { RightPanelSurface } from "../rightPanelStore";18import type { RightPanelSurface } from "../rightPanelStore";
14import {19import {
...@@ -18,11 +23,13 @@ import {...@@ -18,11 +23,13 @@ import {
18 branchMismatchKey,23 branchMismatchKey,
19 buildExpiredTerminalContextToastCopy,24 buildExpiredTerminalContextToastCopy,
20 buildLoadingThreadFromShell,25 buildLoadingThreadFromShell,
26 buildRevertTurnCountByUserMessageId,
21 buildThreadTurnInterruptInput,27 buildThreadTurnInterruptInput,
22 createLocalDispatchSnapshot,28 createLocalDispatchSnapshot,
23 deriveComposerSendState,29 deriveComposerSendState,
24 dismissBranchMismatchForSession,30 dismissBranchMismatchForSession,
25 ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,31 ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,
32 getAntigravitySendBlockReason,
26 getStartedThreadModelChangeBlockReason,33 getStartedThreadModelChangeBlockReason,
27 hasEnvironmentReconnectWarningGraceElapsed,34 hasEnvironmentReconnectWarningGraceElapsed,
28 hasServerAcknowledgedLocalDispatch,35 hasServerAcknowledgedLocalDispatch,
...@@ -30,6 +37,8 @@ import {...@@ -30,6 +37,8 @@ import {
30 reconcileMountedTerminalThreadIds,37 reconcileMountedTerminalThreadIds,
31 reconcileRetainedMountedThreadIds,38 reconcileRetainedMountedThreadIds,
32 resolveBackgroundDraftWorkspaceOptions,39 resolveBackgroundDraftWorkspaceOptions,
40 resolveComposerInteractionMode,
41 resolveComposerProviderSelection,
33 resolveDraftPromotionNavigationTarget,42 resolveDraftPromotionNavigationTarget,
34 resolveThreadMetadataUpdateForNextTurn,43 resolveThreadMetadataUpdateForNextTurn,
35 resolveSendEnvMode,44 resolveSendEnvMode,
...@@ -661,6 +670,323 @@ describe("buildThreadTurnInterruptInput", () => {...@@ -661,6 +670,323 @@ describe("buildThreadTurnInterruptInput", () => {
661 });670 });
662});671});
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
664describe("deriveComposerSendState", () => {990describe("deriveComposerSendState", () => {
665 it("treats expired terminal pills as non-sendable content", () => {991 it("treats expired terminal pills as non-sendable content", () => {
666 const state = deriveComposerSendState({992 const state = deriveComposerSendState({
apps/web/src/components/ChatView.logic.ts+154-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 ANTIGRAVITY_DEFAULT_MODEL,
2 type AssetCreateUrlInput,3 type AssetCreateUrlInput,
3 type AssetCreateUrlResult,4 type AssetCreateUrlResult,
4 type ChatFileAttachment,5 type ChatFileAttachment,
...@@ -8,7 +9,8 @@ import {...@@ -8,7 +9,8 @@ import {
8 type MessageId,9 type MessageId,
9 type ModelSelection,10 type ModelSelection,
10 type ProviderInteractionMode,11 type ProviderInteractionMode,
11 type ProviderDriverKind,12 ProviderDriverKind,
13 type ProviderInstanceId,
12 type ServerProvider,14 type ServerProvider,
13 type ScopedProjectRef,15 type ScopedProjectRef,
14 type ScopedThreadRef,16 type ScopedThreadRef,
...@@ -32,6 +34,7 @@ import {...@@ -32,6 +34,7 @@ import {
32 type SessionPhase,34 type SessionPhase,
33 type Thread,35 type Thread,
34 type ThreadShell,36 type ThreadShell,
37 type TurnDiffSummary,
35} from "../types";38} from "../types";
36import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";39import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
37import * as Schema from "effect/Schema";40import * as Schema from "effect/Schema";
...@@ -47,6 +50,11 @@ import type { ComposerSubmissionIntent } from "../composer-logic";...@@ -47,6 +50,11 @@ import type { ComposerSubmissionIntent } from "../composer-logic";
47import type { TimelineEntry } from "../session-logic";50import type { TimelineEntry } from "../session-logic";
48import type { DesktopPreviewOverlay } from "../previewStateStore";51import type { DesktopPreviewOverlay } from "../previewStateStore";
49import type { RightPanelSurface } from "../rightPanelStore";52import type { RightPanelSurface } from "../rightPanelStore";
53import {
54 NO_PROVIDER_MODEL_SELECTION,
55 resolveSelectableProviderInstanceEntry,
56 type ProviderInstanceEntry,
57} from "../providerInstances";
5058
51export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";59export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
52export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;60export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
...@@ -317,6 +325,151 @@ export function buildThreadTurnInterruptInput(thread: Pick<Thread, "id" | "sessi...@@ -317,6 +325,151 @@ export function buildThreadTurnInterruptInput(thread: Pick<Thread, "id" | "sessi
317 };325 };
318}326}
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
320export function reconcileMountedTerminalThreadIds(input: {473export function reconcileMountedTerminalThreadIds(input: {
321 currentThreadIds: ReadonlyArray<string>;474 currentThreadIds: ReadonlyArray<string>;
322 openThreadIds: ReadonlyArray<string>;475 openThreadIds: ReadonlyArray<string>;
apps/web/src/components/ChatView.tsx+106-84
...@@ -3,7 +3,6 @@ import {...@@ -3,7 +3,6 @@ import {
3 type ApprovalRequestId,3 type ApprovalRequestId,
4 type ChatFileAttachment,4 type ChatFileAttachment,
5 DEFAULT_MODEL,5 DEFAULT_MODEL,
6 defaultInstanceIdForDriver,
7 type EnvironmentId,6 type EnvironmentId,
8 type MessageId,7 type MessageId,
9 type ModelSelection,8 type ModelSelection,
...@@ -206,10 +205,11 @@ import {...@@ -206,10 +205,11 @@ import {
206import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";205import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
207import { useBrowserHistoryStore } from "~/browserHistoryStore";206import { useBrowserHistoryStore } from "~/browserHistoryStore";
208import { registerFaviconProjectForThread } from "~/browserFaviconStore";207import { registerFaviconProjectForThread } from "~/browserFaviconStore";
209import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";208import { getProviderModelCapabilities } from "../providerModels";
210import {209import {
211 applyProviderInstanceSettings,210 applyProviderInstanceSettings,
212 deriveProviderInstanceEntries,211 deriveProviderInstanceEntries,
212 sortProviderInstanceEntries,
213 NO_PROVIDER_MODEL_SELECTION,213 NO_PROVIDER_MODEL_SELECTION,
214} from "../providerInstances";214} from "../providerInstances";
215import {215import {
...@@ -350,6 +350,7 @@ import {...@@ -350,6 +350,7 @@ import {
350 buildExpiredTerminalContextToastCopy,350 buildExpiredTerminalContextToastCopy,
351 buildLocalDraftThread,351 buildLocalDraftThread,
352 buildLoadingThreadFromShell,352 buildLoadingThreadFromShell,
353 buildRevertTurnCountByUserMessageId,
353 buildThreadTurnInterruptInput,354 buildThreadTurnInterruptInput,
354 collectUserMessageBlobPreviewUrls,355 collectUserMessageBlobPreviewUrls,
355 createLocalDispatchSnapshot,356 createLocalDispatchSnapshot,
...@@ -376,6 +377,8 @@ import {...@@ -376,6 +377,8 @@ import {
376 resolveFileAttachmentUrl,377 resolveFileAttachmentUrl,
377 reconcileMountedTerminalThreadIds,378 reconcileMountedTerminalThreadIds,
378 resolveBackgroundDraftWorkspaceOptions,379 resolveBackgroundDraftWorkspaceOptions,
380 resolveComposerInteractionMode,
381 resolveComposerProviderSelection,
379 resolveDraftHeroState,382 resolveDraftHeroState,
380 resolveThreadMetadataUpdateForNextTurn,383 resolveThreadMetadataUpdateForNextTurn,
381 resolveSendEnvMode,384 resolveSendEnvMode,
...@@ -1719,13 +1722,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -1719,13 +1722,6 @@ function ChatViewContent(props: ChatViewProps) {
1719 // the branch mismatch banner.1722 // the branch mismatch banner.
1720 const [, setThreadErrorBannerDismissTick] = useState(0);1723 const [, setThreadErrorBannerDismissTick] = useState(0);
1721 const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;1724 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;
1729 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;1725 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
1730 const canCheckoutPullRequestIntoThread = isLocalDraftThread;1726 const canCheckoutPullRequestIntoThread = isLocalDraftThread;
1731 const activeThreadId = activeThread?.id ?? null;1727 const activeThreadId = activeThread?.id ?? null;
...@@ -2426,11 +2422,52 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2426,11 +2422,52 @@ function ChatViewContent(props: ChatViewProps) {
2426 versionMismatchServerLabel,2422 versionMismatchServerLabel,
2427 ]);2423 ]);
2428 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;2424 const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS;
2429 const unlockedSelectedProvider = resolveSelectableProvider(2425 const providerInstanceEntries = useMemo(
2430 providerStatuses,2426 () =>
2431 selectedProviderByThreadId ?? threadProvider,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 ],
2432 );2454 );
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;
2434 const phase = derivePhase(activeThread?.session ?? null);2471 const phase = derivePhase(activeThread?.session ?? null);
2435 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;2472 const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES;
2436 const latestCheckpointCompletedAt = activeThread?.checkpoints.at(-1)?.completedAt ?? null;2473 const latestCheckpointCompletedAt = activeThread?.checkpoints.at(-1)?.completedAt ?? null;
...@@ -2866,38 +2903,21 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2866,38 +2903,21 @@ function ChatViewContent(props: ChatViewProps) {
2866 }2903 }
2867 return byMessageId;2904 return byMessageId;
2868 }, [turnDiffSummaries]);2905 }, [turnDiffSummaries]);
2869 const revertTurnCountByUserMessageId = useMemo(() => {2906 const revertTurnCountByUserMessageId = useMemo(
2870 const byUserMessageId = new Map<MessageId, number>();2907 () =>
2871 for (let index = 0; index < timelineEntries.length; index += 1) {2908 buildRevertTurnCountByUserMessageId({
2872 const entry = timelineEntries[index];2909 supportsConversationRollback,
2873 if (!entry || entry.kind !== "message" || entry.message.role !== "user") {2910 timelineEntries,
2874 continue;2911 turnDiffSummaryByAssistantMessageId,
2875 }2912 inferredCheckpointTurnCountByTurnId,
28762913 }),
2877 for (let nextIndex = index + 1; nextIndex < timelineEntries.length; nextIndex += 1) {2914 [
2878 const nextEntry = timelineEntries[nextIndex];2915 supportsConversationRollback,
2879 if (!nextEntry || nextEntry.kind !== "message") {2916 inferredCheckpointTurnCountByTurnId,
2880 continue;2917 timelineEntries,
2881 }2918 turnDiffSummaryByAssistantMessageId,
2882 if (nextEntry.message.role === "user") {2919 ],
2883 break;2920 );
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]);
29012921
2902 const gitCwd = activeProject2922 const gitCwd = activeProject
2903 ? projectScriptCwd({2923 ? projectScriptCwd({
...@@ -2922,26 +2942,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2922,26 +2942,10 @@ function ChatViewContent(props: ChatViewProps) {
2922 });2942 });
2923 const keybindings = useAtomValue(primaryServerKeybindingsAtom);2943 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
2924 const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom);2944 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;
2938 const compactionProviderAvailable = useMemo(2945 const compactionProviderAvailable = useMemo(
2939 () =>2946 () =>
2940 hasAvailableClaudeCompactionProvider({2947 hasAvailableClaudeCompactionProvider({
2941 providers: applyProviderInstanceSettings(2948 providers: providerInstanceEntries,
2942 deriveProviderInstanceEntries(providerStatuses),
2943 settings,
2944 ),
2945 instanceId: activeProviderInstanceId,2949 instanceId: activeProviderInstanceId,
2946 lockedInstanceId: lockedProvider2950 lockedInstanceId: lockedProvider
2947 ? (activeThread?.session?.providerInstanceId ??2951 ? (activeThread?.session?.providerInstanceId ??
...@@ -2954,19 +2958,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2954,19 +2958,9 @@ function ChatViewContent(props: ChatViewProps) {
2954 activeThread?.modelSelection.instanceId,2958 activeThread?.modelSelection.instanceId,
2955 activeThread?.session?.providerInstanceId,2959 activeThread?.session?.providerInstanceId,
2956 lockedProvider,2960 lockedProvider,
2957 providerStatuses,2961 providerInstanceEntries,
2958 settings,
2959 ],2962 ],
2960 );2963 );
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]);
2970 const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] =2964 const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] =
2971 useLocalStorage(2965 useLocalStorage(
2972 `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`,2966 `t3code:resume-compaction-dismissed:${environmentId}:${activeProviderInstanceId ?? "claudeAgent"}`,
...@@ -3612,6 +3606,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3612,6 +3606,7 @@ function ChatViewContent(props: ChatViewProps) {
36123606
3613 const handleInteractionModeChange = useCallback(3607 const handleInteractionModeChange = useCallback(
3614 (mode: ProviderInteractionMode) => {3608 (mode: ProviderInteractionMode) => {
3609 if (mode === "plan" && !interactionModeEnabled) return;
3615 if (mode === interactionMode) return;3610 if (mode === interactionMode) return;
3616 setComposerDraftInteractionMode(composerDraftTarget, mode);3611 setComposerDraftInteractionMode(composerDraftTarget, mode);
3617 if (isLocalDraftThread) {3612 if (isLocalDraftThread) {
...@@ -3621,6 +3616,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3621,6 +3616,7 @@ function ChatViewContent(props: ChatViewProps) {
3621 },3616 },
3622 [3617 [
3623 interactionMode,3618 interactionMode,
3619 interactionModeEnabled,
3624 isLocalDraftThread,3620 isLocalDraftThread,
3625 scheduleComposerFocus,3621 scheduleComposerFocus,
3626 composerDraftTarget,3622 composerDraftTarget,
...@@ -3629,8 +3625,18 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3629,8 +3625,18 @@ function ChatViewContent(props: ChatViewProps) {
3629 ],3625 ],
3630 );3626 );
3631 const toggleInteractionMode = useCallback(() => {3627 const toggleInteractionMode = useCallback(() => {
3628 if (!interactionModeEnabled) return;
3632 handleInteractionModeChange(interactionMode === "plan" ? "default" : "plan");3629 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 );
3634 const createBrowserSurface = useCallback(3640 const createBrowserSurface = useCallback(
3635 (profileId?: string) => {3641 (profileId?: string) => {
3636 if (!activeThreadRef) return;3642 if (!activeThreadRef) return;
...@@ -5863,6 +5869,13 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5863,6 +5869,13 @@ function ChatViewContent(props: ChatViewProps) {
5863 const localApi = readLocalApi();5869 const localApi = readLocalApi();
5864 if (!localApi || !activeThread || isRevertingCheckpoint) return;5870 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 }
5866 if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) {5879 if (activeEnvironmentUnavailable && activeEnvironmentUnavailableLabel) {
5867 setThreadError(5880 setThreadError(
5868 activeThread.id,5881 activeThread.id,
...@@ -5915,6 +5928,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5915,6 +5928,7 @@ function ChatViewContent(props: ChatViewProps) {
5915 phase,5928 phase,
5916 revertThreadCheckpoint,5929 revertThreadCheckpoint,
5917 setThreadError,5930 setThreadError,
5931 supportsConversationRollback,
5918 ],5932 ],
5919 );5933 );
59205934
...@@ -5983,6 +5997,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5983,6 +5997,8 @@ function ChatViewContent(props: ChatViewProps) {
5983 selectedProviderModels: ctxSelectedProviderModels,5997 selectedProviderModels: ctxSelectedProviderModels,
5984 selectedPromptEffort: ctxSelectedPromptEffort,5998 selectedPromptEffort: ctxSelectedPromptEffort,
5985 selectedModelSelection: ctxSelectedModelSelection,5999 selectedModelSelection: ctxSelectedModelSelection,
6000 interactionMode: sendInteractionMode,
6001 interactionModeEnabled: sendInteractionModeEnabled,
5986 } = sendCtx;6002 } = sendCtx;
5987 const annotationImageAlreadyAttached =6003 const annotationImageAlreadyAttached =
5988 directAnnotation?.image !== undefined &&6004 directAnnotation?.image !== undefined &&
...@@ -6129,6 +6145,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6129,6 +6145,7 @@ function ChatViewContent(props: ChatViewProps) {
6129 }6145 }
6130 if (6146 if (
6131 !directAnnotation &&6147 !directAnnotation &&
6148 sendInteractionModeEnabled &&
6132 showPlanFollowUpPrompt &&6149 showPlanFollowUpPrompt &&
6133 activeProposedPlan &&6150 activeProposedPlan &&
6134 composerImages.length === 0 &&6151 composerImages.length === 0 &&
...@@ -6157,10 +6174,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6157,10 +6174,9 @@ function ChatViewContent(props: ChatViewProps) {
6157 });6174 });
6158 return;6175 return;
6159 }6176 }
6160 // Legacy plan mode: /plan and /default only act when the beta flag is on;6177 // Providers without the legacy toggle receive their native commands unchanged.
6161 // otherwise they send as plain text like any other message.
6162 const standaloneSlashCommand =6178 const standaloneSlashCommand =
6163 settings.planModeEnabled &&6179 sendInteractionModeEnabled &&
6164 composerImages.length === 0 &&6180 composerImages.length === 0 &&
6165 composerFiles.length === 0 &&6181 composerFiles.length === 0 &&
6166 sendableComposerTerminalContexts.length === 0 &&6182 sendableComposerTerminalContexts.length === 0 &&
...@@ -6480,7 +6496,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6480,7 +6496,7 @@ function ChatViewContent(props: ChatViewProps) {
6480 ? { branch: localCheckoutBranchMismatch.currentBranch }6496 ? { branch: localCheckoutBranchMismatch.currentBranch }
6481 : {}),6497 : {}),
6482 runtimeMode,6498 runtimeMode,
6483 interactionMode,6499 interactionMode: sendInteractionMode,
6484 });6500 });
6485 if (settingsResult._tag === "Failure") {6501 if (settingsResult._tag === "Failure") {
6486 failure = settingsResult;6502 failure = settingsResult;
...@@ -6511,7 +6527,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6511,7 +6527,7 @@ function ChatViewContent(props: ChatViewProps) {
6511 title,6527 title,
6512 modelSelection: threadCreateModelSelection,6528 modelSelection: threadCreateModelSelection,
6513 runtimeMode,6529 runtimeMode,
6514 interactionMode,6530 interactionMode: sendInteractionMode,
6515 branch: activeThreadBranch,6531 branch: activeThreadBranch,
6516 worktreePath: activeThread.worktreePath,6532 worktreePath: activeThread.worktreePath,
6517 createdAt: activeThread.createdAt,6533 createdAt: activeThread.createdAt,
...@@ -6551,7 +6567,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6551,7 +6567,7 @@ function ChatViewContent(props: ChatViewProps) {
6551 modelSelection: ctxSelectedModelSelection,6567 modelSelection: ctxSelectedModelSelection,
6552 titleSeed: title,6568 titleSeed: title,
6553 runtimeMode,6569 runtimeMode,
6554 interactionMode,6570 interactionMode: sendInteractionMode,
6555 ...(bootstrap ? { bootstrap } : {}),6571 ...(bootstrap ? { bootstrap } : {}),
6556 createdAt: messageCreatedAt,6572 createdAt: messageCreatedAt,
6557 },6573 },
...@@ -6772,7 +6788,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6772,7 +6788,7 @@ function ChatViewContent(props: ChatViewProps) {
6772 );6788 );
67736789
6774 const onSelectActivePendingUserInputOption = useCallback(6790 const onSelectActivePendingUserInputOption = useCallback(
6775 (questionId: string, optionLabel: string) => {6791 (questionId: string, optionValue: string) => {
6776 if (!activePendingUserInput) {6792 if (!activePendingUserInput) {
6777 return;6793 return;
6778 }6794 }
...@@ -6793,7 +6809,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6793,7 +6809,7 @@ function ChatViewContent(props: ChatViewProps) {
6793 [questionId]: togglePendingUserInputOptionSelection(6809 [questionId]: togglePendingUserInputOptionSelection(
6794 question,6810 question,
6795 existing[activePendingUserInput.requestId]?.[questionId],6811 existing[activePendingUserInput.requestId]?.[questionId],
6796 optionLabel,6812 optionValue,
6797 ),6813 ),
6798 },6814 },
6799 };6815 };
...@@ -6815,6 +6831,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6815,6 +6831,10 @@ function ChatViewContent(props: ChatViewProps) {
6815 if (!activePendingUserInput) {6831 if (!activePendingUserInput) {
6816 return;6832 return;
6817 }6833 }
6834 const question = activePendingUserInput.questions.find((entry) => entry.id === questionId);
6835 if (!question || question.allowCustomAnswer === false) {
6836 return;
6837 }
6818 promptRef.current = value;6838 promptRef.current = value;
6819 setPendingUserInputAnswersByRequestId((existing) => ({6839 setPendingUserInputAnswersByRequestId((existing) => ({
6820 ...existing,6840 ...existing,
...@@ -6888,7 +6908,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6888,7 +6908,7 @@ function ChatViewContent(props: ChatViewProps) {
6888 }6908 }
68896909
6890 const sendCtx = composerRef.current?.getSendContext();6910 const sendCtx = composerRef.current?.getSendContext();
6891 if (!sendCtx?.providerAvailable) {6911 if (!sendCtx?.providerAvailable || !sendCtx.interactionModeEnabled) {
6892 return;6912 return;
6893 }6913 }
6894 const {6914 const {
...@@ -7033,7 +7053,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7033,7 +7053,7 @@ function ChatViewContent(props: ChatViewProps) {
7033 }7053 }
70347054
7035 const sendCtx = composerRef.current?.getSendContext();7055 const sendCtx = composerRef.current?.getSendContext();
7036 if (!sendCtx?.providerAvailable) {7056 if (!sendCtx?.providerAvailable || !sendCtx.interactionModeEnabled) {
7037 return;7057 return;
7038 }7058 }
7039 const {7059 const {
...@@ -7625,6 +7645,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7625,6 +7645,7 @@ function ChatViewContent(props: ChatViewProps) {
7625 <ProviderStatusBanner7645 <ProviderStatusBanner
7626 status={visibleProviderStatus}7646 status={visibleProviderStatus}
7627 onDismiss={() => setDismissedProviderStatusBannerKey(providerStatusBannerKey)}7647 onDismiss={() => setDismissedProviderStatusBannerKey(providerStatusBannerKey)}
7648 onOpenProviderSetup={openProviderSetup}
7628 />7649 />
7629 </div>7650 </div>
7630 {/* Messages Wrapper */}7651 {/* Messages Wrapper */}
...@@ -7836,6 +7857,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7836,6 +7857,7 @@ function ChatViewContent(props: ChatViewProps) {
7836 onChangeActivePendingUserInputCustomAnswer7857 onChangeActivePendingUserInputCustomAnswer
7837 }7858 }
7838 onProviderModelSelect={onProviderModelSelect}7859 onProviderModelSelect={onProviderModelSelect}
7860 onOpenProviderSetup={openProviderSetup}
7839 getModelDisabledReason={getModelDisabledReason}7861 getModelDisabledReason={getModelDisabledReason}
7840 toggleInteractionMode={toggleInteractionMode}7862 toggleInteractionMode={toggleInteractionMode}
7841 handleRuntimeModeChange={handleRuntimeModeChange}7863 handleRuntimeModeChange={handleRuntimeModeChange}
apps/web/src/components/chat/ChatComposer.tsx+167-176
...@@ -47,7 +47,13 @@ import {...@@ -47,7 +47,13 @@ import {
47 replaceTextRange,47 replaceTextRange,
48} from "../../composer-logic";48} from "../../composer-logic";
49import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder";49import { 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";
51import {57import {
52 dataTransferHasComposerMention,58 dataTransferHasComposerMention,
53 makeComposerMentionDragHandlers,59 makeComposerMentionDragHandlers,
...@@ -767,13 +773,11 @@ import {...@@ -767,13 +773,11 @@ import {
767 XIcon,773 XIcon,
768} from "lucide-react";774} from "lucide-react";
769import { proposedPlanTitle } from "../../proposedPlan";775import { proposedPlanTitle } from "../../proposedPlan";
770import { getProviderInteractionModeToggle } from "../../providerModels";776import { hasProviderSetup } from "./ProviderStatusBanner";
771import {777import {
772 applyProviderInstanceSettings,778 applyProviderInstanceSettings,
773 deriveProviderInstanceEntries,779 deriveProviderInstanceEntries,
774 NO_PROVIDER_MODEL_SELECTION,780 NO_PROVIDER_MODEL_SELECTION,
775 resolveProviderDriverKindForInstanceSelection,
776 resolveSelectableProviderInstanceEntry,
777 sortProviderInstanceEntries,781 sortProviderInstanceEntries,
778 type ProviderInstanceEntry,782 type ProviderInstanceEntry,
779} from "../../providerInstances";783} from "../../providerInstances";
...@@ -1116,6 +1120,8 @@ export interface ChatComposerHandle {...@@ -1116,6 +1120,8 @@ export interface ChatComposerHandle {
1116 selectedProvider: ProviderDriverKind;1120 selectedProvider: ProviderDriverKind;
1117 selectedModel: string;1121 selectedModel: string;
1118 selectedProviderModels: ReadonlyArray<ServerProvider["models"][number]>;1122 selectedProviderModels: ReadonlyArray<ServerProvider["models"][number]>;
1123 interactionMode: ProviderInteractionMode;
1124 interactionModeEnabled: boolean;
1119 };1125 };
1120 /** Validate the fully composed text immediately before a provider turn starts. */1126 /** Validate the fully composed text immediately before a provider turn starts. */
1121 validateProviderInput: (providerInput: string) => boolean;1127 validateProviderInput: (providerInput: string) => boolean;
...@@ -1165,7 +1171,11 @@ export interface ChatComposerProps {...@@ -1165,7 +1171,11 @@ export interface ChatComposerProps {
1165 isLastQuestion: boolean;1171 isLastQuestion: boolean;
1166 canAdvance: boolean;1172 canAdvance: boolean;
1167 customAnswer: string;1173 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;
1169 } | null;1179 } | null;
1170 activePendingResolvedAnswers: Record<string, unknown> | null;1180 activePendingResolvedAnswers: Record<string, unknown> | null;
1171 activePendingIsResponding: boolean;1181 activePendingIsResponding: boolean;
...@@ -1227,7 +1237,7 @@ export interface ChatComposerProps {...@@ -1227,7 +1237,7 @@ export interface ChatComposerProps {
1227 requestId: ApprovalRequestId,1237 requestId: ApprovalRequestId,
1228 decision: ProviderApprovalDecision,1238 decision: ProviderApprovalDecision,
1229 ) => Promise<unknown>;1239 ) => Promise<unknown>;
1230 onSelectActivePendingUserInputOption: (questionId: string, optionLabel: string) => void;1240 onSelectActivePendingUserInputOption: (questionId: string, optionValue: string) => void;
1231 onAdvanceActivePendingUserInput: () => void;1241 onAdvanceActivePendingUserInput: () => void;
1232 onPreviousActivePendingUserInputQuestion: () => void;1242 onPreviousActivePendingUserInputQuestion: () => void;
1233 onChangeActivePendingUserInputCustomAnswer: (1243 onChangeActivePendingUserInputCustomAnswer: (
...@@ -1239,6 +1249,7 @@ export interface ChatComposerProps {...@@ -1239,6 +1249,7 @@ export interface ChatComposerProps {
1239 ) => void;1249 ) => void;
12401250
1241 onProviderModelSelect: (instanceId: ProviderInstanceId, model: string) => void;1251 onProviderModelSelect: (instanceId: ProviderInstanceId, model: string) => void;
1252 onOpenProviderSetup: (instanceId: ProviderInstanceId) => void;
1242 getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null;1253 getModelDisabledReason: (instanceId: ProviderInstanceId, model: string) => string | null;
1243 toggleInteractionMode: () => void;1254 toggleInteractionMode: () => void;
1244 handleRuntimeModeChange: (mode: RuntimeMode) => void;1255 handleRuntimeModeChange: (mode: RuntimeMode) => void;
...@@ -1290,7 +1301,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1290,7 +1301,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1290 showPlanFollowUpPrompt,1301 showPlanFollowUpPrompt,
1291 activeProposedPlan,1302 activeProposedPlan,
1292 runtimeMode,1303 runtimeMode,
1293 interactionMode,1304 interactionMode: requestedInteractionMode,
1294 lockedProvider,1305 lockedProvider,
1295 providerStatuses,1306 providerStatuses,
1296 activeProjectDefaultModelSelection,1307 activeProjectDefaultModelSelection,
...@@ -1327,6 +1338,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1327,6 +1338,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1327 onPreviousActivePendingUserInputQuestion,1338 onPreviousActivePendingUserInputQuestion,
1328 onChangeActivePendingUserInputCustomAnswer,1339 onChangeActivePendingUserInputCustomAnswer,
1329 onProviderModelSelect,1340 onProviderModelSelect,
1341 onOpenProviderSetup,
1330 getModelDisabledReason,1342 getModelDisabledReason,
1331 toggleInteractionMode,1343 toggleInteractionMode,
1332 handleRuntimeModeChange,1344 handleRuntimeModeChange,
...@@ -1393,10 +1405,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1393,10 +1405,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1393 environmentId,1405 environmentId,
1394 })1406 })
1395 : null);1407 : null);
1396 const sendDisabledReason =
1397 externalSendDisabledReason ?? (activePendingProgress ? null : attachmentBlockReason);
1398 const isSendDisabled = sendDisabledReason !== null;
1399
1400 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);1408 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
1401 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);1409 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);
1402 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);1410 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
...@@ -1523,105 +1531,43 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1523,105 +1531,43 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1523 [providerStatuses, settings],1531 [providerStatuses, settings],
1524 );1532 );
1525 const selectedProviderByThreadId = composerDraft.activeProvider ?? null;1533 const selectedProviderByThreadId = composerDraft.activeProvider ?? null;
1526 const threadProvider =1534 const {
1527 activeThread?.session?.providerInstanceId ??1535 selectedProviderEntry,
1528 activeThreadModelSelection?.instanceId ??1536 requestedDriverKind,
1529 activeProjectDefaultModelSelection?.instanceId ??1537 lockedContinuationGroupKey,
1530 null;1538 unavailableProviderInstanceId,
1531 const explicitSelectedInstanceId = selectedProviderByThreadId ?? threadProvider;1539 } = useMemo(
15321540 () =>
1533 const unlockedSelectedProvider =1541 resolveComposerProviderSelection({
1534 resolveProviderDriverKindForInstanceSelection(1542 entries: providerInstanceEntries,
1535 providerInstanceEntries,1543 candidateInstanceIds: [
1536 providerStatuses,1544 selectedProviderByThreadId,
1537 explicitSelectedInstanceId,1545 activeThread?.session?.providerInstanceId,
1538 ) ??1546 activeThreadModelSelection?.instanceId,
1539 providerInstanceEntries[0]?.driverKind ??1547 activeProjectDefaultModelSelection?.instanceId,
1540 ProviderDriverKind.make("unconfigured");1548 ],
1541 const requestedDriverKind: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider;1549 lockedProvider,
1542 const lockedContinuationGroupKey = useMemo((): string | null => {1550 lockedInstanceId:
1543 if (!lockedProvider || !activeThread) return null;1551 activeThread?.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId,
1544 const lockedInstanceId =1552 }),
1545 activeThread.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId;1553 [
1546 if (!lockedInstanceId) return null;1554 activeProjectDefaultModelSelection?.instanceId,
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,
1571 activeThread?.session?.providerInstanceId,1555 activeThread?.session?.providerInstanceId,
1572 activeThreadModelSelection?.instanceId,1556 activeThreadModelSelection?.instanceId,
1573 activeProjectDefaultModelSelection?.instanceId,1557 selectedProviderByThreadId,
1574 ];1558 lockedProvider,
1575 for (const candidate of candidates) {1559 providerInstanceEntries,
1576 if (!candidate) continue;1560 ],
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],
1623 );1561 );
1562 const selectedInstanceId =
1563 selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId;
1624 const noProviderAvailable = selectedProviderEntry === undefined;1564 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;
1625 const resolvedCompactDisabledReason =1571 const resolvedCompactDisabledReason =
1626 compactDisabledReason ?? (noProviderAvailable ? "Compacting is unavailable right now" : null);1572 compactDisabledReason ?? (noProviderAvailable ? "Compacting is unavailable right now" : null);
1627 // The driver kind follows the instance that will actually run the turn,1573 // The driver kind follows the instance that will actually run the turn,
...@@ -1639,6 +1585,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1639,6 +1585,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1639 projectModelSelection: activeProjectDefaultModelSelection,1585 projectModelSelection: activeProjectDefaultModelSelection,
1640 settings,1586 settings,
1641 });1587 });
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;
1642 const selectedProviderStatus = useMemo(1596 const selectedProviderStatus = useMemo(
1643 () => selectedProviderEntry?.snapshot ?? null,1597 () => selectedProviderEntry?.snapshot ?? null,
1644 [selectedProviderEntry],1598 [selectedProviderEntry],
...@@ -1735,17 +1689,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1735,17 +1689,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
17351689
1736 const selectedPromptEffort = composerProviderState.promptEffort;1690 const selectedPromptEffort = composerProviderState.promptEffort;
1737 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;1691 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;
1738 // Plan mode is a legacy feature behind Settings → Beta. With the flag off,1692 const { enabled: planModeUiEnabled, interactionMode } = resolveComposerInteractionMode({
1739 // ChatView forces the effective mode to "default", so hiding the toggle1693 planModeEnabled: settings.planModeEnabled,
1740 // can't trap anyone in plan mode.1694 provider: selectedProviderStatus,
1741 const planModeUiEnabled = settings.planModeEnabled;1695 interactionMode: requestedInteractionMode,
1742 const composerProviderControls = useMemo(1696 });
1743 () => ({
1744 showInteractionModeToggle:
1745 planModeUiEnabled && getProviderInteractionModeToggle(providerStatuses, selectedProvider),
1746 }),
1747 [planModeUiEnabled, providerStatuses, selectedProvider],
1748 );
1749 const selectedModelSelection = useMemo<ModelSelection>(1697 const selectedModelSelection = useMemo<ModelSelection>(
1750 () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch),1698 () => createModelSelection(selectedInstanceId, selectedModel, selectedModelOptionsForDispatch),
1751 [selectedInstanceId, selectedModel, selectedModelOptionsForDispatch],1699 [selectedInstanceId, selectedModel, selectedModelOptionsForDispatch],
...@@ -2027,6 +1975,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2027,6 +1975,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
20271975
2028 const isComposerApprovalState = activePendingApproval !== null;1976 const isComposerApprovalState = activePendingApproval !== null;
2029 const activePendingUserInput = pendingUserInputs[0] ?? null;1977 const activePendingUserInput = pendingUserInputs[0] ?? null;
1978 const isChoiceOnlyPendingQuestion =
1979 activePendingProgress?.activeQuestion?.allowCustomAnswer === false;
2030 const showComposerTopDrawer =1980 const showComposerTopDrawer =
2031 isComposerApprovalState ||1981 isComposerApprovalState ||
2032 pendingUserInputs.length > 0 ||1982 pendingUserInputs.length > 0 ||
...@@ -2519,6 +2469,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2519,6 +2469,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2519 ) => {2469 ) => {
2520 expandComposerForEditorChange();2470 expandComposerForEditorChange();
2521 if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) {2471 if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) {
2472 if (activePendingProgress.activeQuestion.allowCustomAnswer === false) return;
2522 setComposerCursor(nextCursor);2473 setComposerCursor(nextCursor);
2523 setComposerTrigger(2474 setComposerTrigger(
2524 cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),2475 cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),
...@@ -2572,6 +2523,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2572,6 +2523,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2572 citationComment?: { start: number; sourceAnchor: AssistantCitationSourceAnchor };2523 citationComment?: { start: number; sourceAnchor: AssistantCitationSourceAnchor };
2573 },2524 },
2574 ): boolean => {2525 ): boolean => {
2526 if (
2527 activePendingUserInput &&
2528 activePendingProgress?.activeQuestion?.allowCustomAnswer === false
2529 ) {
2530 return false;
2531 }
2575 const currentText = promptRef.current;2532 const currentText = promptRef.current;
2576 const safeStart = Math.max(0, Math.min(currentText.length, rangeStart));2533 const safeStart = Math.max(0, Math.min(currentText.length, rangeStart));
2577 const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd));2534 const safeEnd = Math.max(safeStart, Math.min(currentText.length, rangeEnd));
...@@ -2696,6 +2653,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2696,6 +2653,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2696 }2653 }
2697 return;2654 return;
2698 }2655 }
2656 if (!planModeUiEnabled) return;
2699 void handleInteractionModeChange(item.command === "plan" ? "plan" : "default");2657 void handleInteractionModeChange(item.command === "plan" ? "plan" : "default");
2700 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {2658 const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", {
2701 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),2659 expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd),
...@@ -2742,7 +2700,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2742,7 +2700,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2742 return;2700 return;
2743 }2701 }
2744 },2702 },
2745 [applyPromptReplacement, handleInteractionModeChange, resolveActiveComposerTrigger],2703 [
2704 applyPromptReplacement,
2705 handleInteractionModeChange,
2706 planModeUiEnabled,
2707 resolveActiveComposerTrigger,
2708 ],
2746 );2709 );
27472710
2748 const onComposerMenuItemHighlighted = useCallback(2711 const onComposerMenuItemHighlighted = useCallback(
...@@ -3766,7 +3729,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3766,7 +3729,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3766 id: "mode",3729 id: "mode",
3767 content: (3730 content: (
3768 <ComposerFooterModeControls3731 <ComposerFooterModeControls
3769 showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}3732 showInteractionModeToggle={planModeUiEnabled}
3770 interactionMode={interactionMode}3733 interactionMode={interactionMode}
3771 runtimeMode={runtimeMode}3734 runtimeMode={runtimeMode}
3772 size={composerControlsInStrip ? "xs" : "sm"}3735 size={composerControlsInStrip ? "xs" : "sm"}
...@@ -3784,12 +3747,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3784,12 +3747,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3784 type="button"3747 type="button"
3785 size="sm"3748 size="sm"
3786 variant="ghost"3749 variant="ghost"
3787 disabled3750 disabled={!providerSetupInstanceId}
3751 onClick={() => {
3752 if (providerSetupInstanceId) {
3753 onOpenProviderSetup(providerSetupInstanceId);
3754 }
3755 }}
3788 data-chat-provider-unavailable="true"3756 data-chat-provider-unavailable="true"
3789 className="shrink-0 gap-2 px-2 text-secondary-label sm:px-3"3757 className="shrink-0 gap-2 px-2 text-secondary-label sm:px-3"
3790 >3758 >
3791 <CircleAlertIcon className="size-4" />3759 <CircleAlertIcon className="size-4" />
3792 No provider available3760 {providerSetupInstanceId ? "Open provider settings" : "No provider available"}
3793 </Button>3761 </Button>
3794 ) : (3762 ) : (
3795 <>3763 <>
...@@ -3835,13 +3803,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3835,13 +3803,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3835 onOpenChange={setIsComposerModelPickerOpen}3803 onOpenChange={setIsComposerModelPickerOpen}
3836 getModelDisabledReason={getModelDisabledReason}3804 getModelDisabledReason={getModelDisabledReason}
3837 onInstanceModelChange={onProviderModelSelect}3805 onInstanceModelChange={onProviderModelSelect}
3806 onOpenProviderSetup={onOpenProviderSetup}
3838 />3807 />
38393808
3840 {composerControlsCompact ? (3809 {composerControlsCompact ? (
3841 <CompactComposerControlsMenu3810 <CompactComposerControlsMenu
3842 interactionMode={interactionMode}3811 interactionMode={interactionMode}
3843 runtimeMode={runtimeMode}3812 runtimeMode={runtimeMode}
3844 showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}3813 showInteractionModeToggle={planModeUiEnabled}
3845 traitsMenuContent={providerTraitsMenuContent}3814 traitsMenuContent={providerTraitsMenuContent}
3846 onToggleInteractionMode={toggleInteractionMode}3815 onToggleInteractionMode={toggleInteractionMode}
3847 onRuntimeModeChange={handleRuntimeModeChange}3816 onRuntimeModeChange={handleRuntimeModeChange}
...@@ -3884,8 +3853,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3884,8 +3853,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3884 size="xs"3853 size="xs"
3885 hidden={hiddenRestingBlockIds.length === 0}3854 hidden={hiddenRestingBlockIds.length === 0}
3886 showInteractionModeToggle={3855 showInteractionModeToggle={
3887 composerProviderControls.showInteractionModeToggle &&3856 planModeUiEnabled && hiddenRestingBlockIds.includes("mode")
3888 hiddenRestingBlockIds.includes("mode")
3889 }3857 }
3890 traitsMenuContent={3858 traitsMenuContent={
3891 hiddenRestingBlockIds.includes("traits") ? providerTraitsMenuContent : undefined3859 hiddenRestingBlockIds.includes("traits") ? providerTraitsMenuContent : undefined
...@@ -4454,7 +4422,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4454,7 +4422,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4454 );4422 );
4455 },4423 },
4456 addTerminalContext: (selection: TerminalContextSelection) => {4424 addTerminalContext: (selection: TerminalContextSelection) => {
4457 if (!activeThread) return;4425 if (!activeThread || isChoiceOnlyPendingQuestion) return;
4458 const snapshot = composerEditorRef.current?.readSnapshot() ?? {4426 const snapshot = composerEditorRef.current?.readSnapshot() ?? {
4459 value: promptRef.current,4427 value: promptRef.current,
4460 cursor: composerCursor,4428 cursor: composerCursor,
...@@ -4499,10 +4467,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4499,10 +4467,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4499 selectedPromptEffort,4467 selectedPromptEffort,
4500 selectedModelOptionsForDispatch,4468 selectedModelOptionsForDispatch,
4501 selectedModelSelection,4469 selectedModelSelection,
4502 providerAvailable: !noProviderAvailable,4470 providerAvailable: !noProviderAvailable && providerSendBlockReason === null,
4503 selectedProvider,4471 selectedProvider,
4504 selectedModel,4472 selectedModel,
4505 selectedProviderModels,4473 selectedProviderModels,
4474 interactionMode,
4475 interactionModeEnabled: planModeUiEnabled,
4506 }),4476 }),
4507 validateProviderInput: (providerInput: string) => {4477 validateProviderInput: (providerInput: string) => {
4508 const validationMessage = getComposerSubmissionValidationMessage({4478 const validationMessage = getComposerSubmissionValidationMessage({
...@@ -4534,6 +4504,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4534,6 +4504,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4534 focusComposer,4504 focusComposer,
4535 isConnecting,4505 isConnecting,
4536 isComposerApprovalState,4506 isComposerApprovalState,
4507 isChoiceOnlyPendingQuestion,
4537 pendingUserInputs.length,4508 pendingUserInputs.length,
4538 projectSelectionRequired,4509 projectSelectionRequired,
4539 applyPromptReplacement,4510 applyPromptReplacement,
...@@ -4543,9 +4514,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4543,9 +4514,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4543 selectedModelOptionsForDispatch,4514 selectedModelOptionsForDispatch,
4544 selectedModelSelection,4515 selectedModelSelection,
4545 noProviderAvailable,4516 noProviderAvailable,
4517 providerSendBlockReason,
4546 selectedPromptEffort,4518 selectedPromptEffort,
4547 selectedProvider,4519 selectedProvider,
4548 selectedProviderModels,4520 selectedProviderModels,
4521 interactionMode,
4522 planModeUiEnabled,
4549 compactThreadContext,4523 compactThreadContext,
4550 ],4524 ],
4551 );4525 );
...@@ -4710,54 +4684,61 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4710,54 +4684,61 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4710 onToggleOption={onSelectActivePendingUserInputOption}4684 onToggleOption={onSelectActivePendingUserInputOption}
4711 onAdvance={onAdvanceActivePendingUserInput}4685 onAdvance={onAdvanceActivePendingUserInput}
4712 />4686 />
4713 <ComposerBanner.Body>4687 {!isChoiceOnlyPendingQuestion ||
4714 <div4688 activePendingProgress?.activeQuestion?.multiSelect ? (
4715 data-chat-composer-mobile-pending-compact="true"4689 <ComposerBanner.Body>
4716 className={cn(4690 <div
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",4691 data-chat-composer-mobile-pending-compact="true"
4718 !activePendingProgress?.activeQuestion?.multiSelect && "p-0",
4719 )}
4720 >
4721 <button
4722 type="button"
4723 className={cn(4692 className={cn(
4724 "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm",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",
4725 activePendingProgress?.customAnswer4694 !activePendingProgress?.activeQuestion?.multiSelect && "p-0",
4726 ? "text-foreground"
4727 : "text-placeholder",
4728 !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2",
4729 )}4695 )}
4730 onPointerDown={(event) => event.preventDefault()}
4731 onClick={expandMobileComposer}
4732 aria-label="Write custom answer"
4733 >4696 >
4734 {activePendingProgress?.customAnswer || "Write custom answer"}4697 {!isChoiceOnlyPendingQuestion ? (
4735 </button>4698 <button
4736 {activePendingProgress?.activeQuestion?.multiSelect ? (4699 type="button"
4737 <ComposerPrimaryActions4700 className={cn(
4738 compact4701 "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm",
4739 pendingAction={pendingPrimaryAction}4702 activePendingProgress?.customAnswer
4740 isRunning={false}4703 ? "text-foreground"
4741 showPlanFollowUpPrompt={false}4704 : "text-placeholder",
4742 promptHasText={false}4705 !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2",
4743 isSendBusy={isSendBusy}4706 )}
4744 sendDisabledReason={sendDisabledReason}4707 onPointerDown={(event) => event.preventDefault()}
4745 isConnecting={isConnecting}4708 onClick={expandMobileComposer}
4746 isEnvironmentUnavailable={4709 aria-label="Write custom answer"
4747 environmentUnavailable !== null ||4710 >
4748 noProviderAvailable ||4711 {activePendingProgress?.customAnswer || "Write custom answer"}
4749 projectSelectionRequired4712 </button>
4750 }4713 ) : null}
4751 isPreparingWorktree={false}4714 {activePendingProgress?.activeQuestion?.multiSelect ? (
4752 hasSendableContent={false}4715 <ComposerPrimaryActions
4753 preserveComposerFocusOnPointerDown4716 compact
4754 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}4717 pendingAction={pendingPrimaryAction}
4755 onInterrupt={handleInterruptPrimaryAction}4718 isRunning={false}
4756 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}4719 showPlanFollowUpPrompt={false}
4757 />4720 promptHasText={false}
4758 ) : null}4721 isSendBusy={isSendBusy}
4759 </div>4722 sendDisabledReason={sendDisabledReason}
4760 </ComposerBanner.Body>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}
4761 </div>4742 </div>
4762 ) : null}4743 ) : null}
4763 </ComposerBanner.Root>4744 </ComposerBanner.Root>
...@@ -4823,12 +4804,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4823,12 +4804,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4823 : "text-placeholder",4804 : "text-placeholder",
4824 )}4805 )}
4825 onPointerDown={(event) => event.preventDefault()}4806 onPointerDown={(event) => event.preventDefault()}
4826 onClick={expandMobileComposer}4807 onClick={isChoiceOnlyPendingQuestion ? undefined : expandMobileComposer}
4808 disabled={isChoiceOnlyPendingQuestion}
4827 aria-label="Expand composer"4809 aria-label="Expand composer"
4828 >4810 >
4829 {activePendingProgress4811 {activePendingProgress
4830 ? activePendingProgress.customAnswer ||4812 ? isChoiceOnlyPendingQuestion
4831 "Type your own answer, or leave this blank to use the selected option"4813 ? "Choose an option above"
4814 : activePendingProgress.customAnswer ||
4815 "Type your own answer, or leave this blank to use the selected option"
4832 : prompt.trim() ||4816 : prompt.trim() ||
4833 (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}4817 (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
4834 </button>4818 </button>
...@@ -5268,7 +5252,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5268,7 +5252,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5268 ? (activePendingApproval?.detail ??5252 ? (activePendingApproval?.detail ??
5269 "Resolve this approval request to continue")5253 "Resolve this approval request to continue")
5270 : activePendingProgress5254 : 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"
5272 : showPlanFollowUpPrompt && activeProposedPlan5258 : showPlanFollowUpPrompt && activeProposedPlan
5273 ? "Add feedback to refine the plan, or leave this blank to implement it"5259 ? "Add feedback to refine the plan, or leave this blank to implement it"
5274 : projectSelectionRequired5260 : projectSelectionRequired
...@@ -5279,7 +5265,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5279,7 +5265,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5279 ? DISCONNECTED_COMPOSER_PLACEHOLDER5265 ? DISCONNECTED_COMPOSER_PLACEHOLDER
5280 : "Ask anything, @tag files/folders, $use skills, or / for commands"5266 : "Ask anything, @tag files/folders, $use skills, or / for commands"
5281 }5267 }
5282 disabled={isConnecting || isComposerApprovalState || projectSelectionRequired}5268 disabled={
5269 isConnecting ||
5270 isComposerApprovalState ||
5271 projectSelectionRequired ||
5272 isChoiceOnlyPendingQuestion
5273 }
5283 />5274 />
5284 {isComposerResting ? collapsedComposerImagePreviews : null}5275 {isComposerResting ? collapsedComposerImagePreviews : null}
5285 {showMobilePendingAnswerActions ? (5276 {showMobilePendingAnswerActions ? (
apps/web/src/components/chat/ComposerPendingApprovalActions.test.tsx+25
...@@ -41,6 +41,31 @@ describe("ComposerPendingApprovalActions", () => {...@@ -41,6 +41,31 @@ describe("ComposerPendingApprovalActions", () => {
41 expect(markup).not.toContain("Always allow this session");41 expect(markup).not.toContain("Always allow this session");
42 });42 });
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
44 it("limits provider-supplied approval labels so narrow rows can wrap", () => {69 it("limits provider-supplied approval labels so narrow rows can wrap", () => {
45 const label = "Allow ".repeat(40).trim();70 const label = "Allow ".repeat(40).trim();
46 const markup = renderToStaticMarkup(71 const markup = renderToStaticMarkup(
apps/web/src/components/chat/ComposerPendingApprovalActions.tsx+38-18
...@@ -4,7 +4,9 @@ import {...@@ -4,7 +4,9 @@ import {
4 type ProviderApprovalOption,4 type ProviderApprovalOption,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import { memo } from "react";6import { memo } from "react";
7import { TriangleAlertIcon } from "lucide-react";
7import { Button } from "../ui/button";8import { Button } from "../ui/button";
9import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
810
9interface ComposerPendingApprovalActionsProps {11interface ComposerPendingApprovalActionsProps {
10 requestId: ApprovalRequestId;12 requestId: ApprovalRequestId;
...@@ -32,24 +34,42 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro...@@ -32,24 +34,42 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro
32}: ComposerPendingApprovalActionsProps) {34}: ComposerPendingApprovalActionsProps) {
33 return (35 return (
34 <>36 <>
35 {options.map((option) => (37 {options.map((option) => {
36 <Button38 const button = (
37 key={option.decision}39 <Button
38 size="micro"40 key={option.decision}
39 variant="ghost-muted"41 size="micro"
40 className={`${APPROVAL_ACTION_CLASS_NAME}${42 variant="ghost-muted"
41 option.decision === "decline"43 className={`${APPROVAL_ACTION_CLASS_NAME}${
42 ? " text-destructive-foreground [:hover,[data-pressed]]:text-destructive-foreground"44 option.decision === "decline"
43 : option.decision === "accept"45 ? " text-destructive-foreground [:hover,[data-pressed]]:text-destructive-foreground"
44 ? " text-foreground"46 : option.decision === "accept"
45 : ""47 ? " text-foreground"
46 }`}48 : option.warning
47 disabled={isResponding}49 ? " text-warning"
48 onClick={() => void onRespondToApproval(requestId, option.decision)}50 : ""
49 >51 }`}
50 <span className="max-w-40 truncate">{option.label}</span>52 disabled={isResponding}
51 </Button>53 aria-description={option.warning}
52 ))}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 })}
53 </>73 </>
54 );74 );
55});75});
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+15-14
...@@ -15,7 +15,7 @@ interface PendingUserInputPanelProps {...@@ -15,7 +15,7 @@ interface PendingUserInputPanelProps {
15 respondingRequestIds: ApprovalRequestId[];15 respondingRequestIds: ApprovalRequestId[];
16 answers: Record<string, PendingUserInputDraftAnswer>;16 answers: Record<string, PendingUserInputDraftAnswer>;
17 questionIndex: number;17 questionIndex: number;
18 onToggleOption: (questionId: string, optionLabel: string) => void;18 onToggleOption: (questionId: string, optionValue: string) => void;
19 onAdvance: () => void;19 onAdvance: () => void;
20}20}
2121
...@@ -56,7 +56,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -56,7 +56,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
56 isResponding: boolean;56 isResponding: boolean;
57 answers: Record<string, PendingUserInputDraftAnswer>;57 answers: Record<string, PendingUserInputDraftAnswer>;
58 questionIndex: number;58 questionIndex: number;
59 onToggleOption: (questionId: string, optionLabel: string) => void;59 onToggleOption: (questionId: string, optionValue: string) => void;
60 onAdvance: () => void;60 onAdvance: () => void;
61}) {61}) {
62 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);62 const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
...@@ -65,7 +65,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -65,7 +65,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
65 const onAdvanceRef = useRef(onAdvance);65 const onAdvanceRef = useRef(onAdvance);
66 const [optimisticSingleSelect, setOptimisticSingleSelect] = useState<{66 const [optimisticSingleSelect, setOptimisticSingleSelect] = useState<{
67 questionId: string;67 questionId: string;
68 optionLabel: string;68 optionValue: string;
69 } | null>(null);69 } | null>(null);
70 // Collapsing hides everything but the header so a tall prompt stops covering70 // Collapsing hides everything but the header so a tall prompt stops covering
71 // the thread the user is trying to read. Scoped to a single question: the card71 // the thread the user is trying to read. Scoped to a single question: the card
...@@ -90,7 +90,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -90,7 +90,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
90 }90 }
91 if (91 if (
92 progress.customAnswer.trim().length === 0 &&92 progress.customAnswer.trim().length === 0 &&
93 progress.selectedOptionLabels.includes(optimisticSingleSelect.optionLabel)93 progress.selectedOptionValues.includes(optimisticSingleSelect.optionValue)
94 ) {94 ) {
95 setOptimisticSingleSelect(null);95 setOptimisticSingleSelect(null);
96 }96 }
...@@ -98,7 +98,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -98,7 +98,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
98 activeQuestion,98 activeQuestion,
99 optimisticSingleSelect,99 optimisticSingleSelect,
100 progress.customAnswer,100 progress.customAnswer,
101 progress.selectedOptionLabels,101 progress.selectedOptionValues,
102 ]);102 ]);
103103
104 // Clear auto-advance timer on unmount104 // Clear auto-advance timer on unmount
...@@ -111,13 +111,13 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -111,13 +111,13 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
111 }, []);111 }, []);
112112
113 const handleOptionSelection = useCallback(113 const handleOptionSelection = useCallback(
114 (questionId: string, optionLabel: string) => {114 (questionId: string, optionValue: string) => {
115 if (activeQuestion?.multiSelect) {115 if (activeQuestion?.multiSelect) {
116 onToggleOption(questionId, optionLabel);116 onToggleOption(questionId, optionValue);
117 return;117 return;
118 }118 }
119 setOptimisticSingleSelect({ questionId, optionLabel });119 setOptimisticSingleSelect({ questionId, optionValue });
120 onToggleOption(questionId, optionLabel);120 onToggleOption(questionId, optionValue);
121 if (autoAdvanceTimerRef.current !== null) {121 if (autoAdvanceTimerRef.current !== null) {
122 window.clearTimeout(autoAdvanceTimerRef.current);122 window.clearTimeout(autoAdvanceTimerRef.current);
123 }123 }
...@@ -154,7 +154,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -154,7 +154,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
154 const option = activeQuestion.options[optionIndex];154 const option = activeQuestion.options[optionIndex];
155 if (!option) return;155 if (!option) return;
156 event.preventDefault();156 event.preventDefault();
157 handleOptionSelection(activeQuestion.id, option.label);157 handleOptionSelection(activeQuestion.id, option.value ?? option.label);
158 };158 };
159 document.addEventListener("keydown", handler);159 document.addEventListener("keydown", handler);
160 return () => document.removeEventListener("keydown", handler);160 return () => document.removeEventListener("keydown", handler);
...@@ -208,12 +208,13 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -208,12 +208,13 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
208 ) : null}208 ) : null}
209 <div className="mt-2 space-y-0.5">209 <div className="mt-2 space-y-0.5">
210 {activeQuestion.options.map((option, index) => {210 {activeQuestion.options.map((option, index) => {
211 const optionValue = option.value ?? option.label;
211 const isOptimisticallySelected =212 const isOptimisticallySelected =
212 optimisticSingleSelect?.questionId === activeQuestion.id &&213 optimisticSingleSelect?.questionId === activeQuestion.id &&
213 optimisticSingleSelect.optionLabel === option.label;214 optimisticSingleSelect.optionValue === optionValue;
214 const isSelected =215 const isSelected =
215 isOptimisticallySelected ||216 isOptimisticallySelected ||
216 (!customAnswerActive && progress.selectedOptionLabels.includes(option.label));217 (!customAnswerActive && progress.selectedOptionValues.includes(optionValue));
217 const shortcutKey = index < 9 ? index + 1 : null;218 const shortcutKey = index < 9 ? index + 1 : null;
218 const className = cn(219 const className = cn(
219 "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",220 "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(...@@ -246,11 +247,11 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
246 );247 );
247 return (248 return (
248 <button249 <button
249 key={`${activeQuestion.id}:${option.label}`}250 key={`${activeQuestion.id}:${optionValue}`}
250 type="button"251 type="button"
251 disabled={isResponding}252 disabled={isResponding}
252 onClick={() => {253 onClick={() => {
253 handleOptionSelection(activeQuestion.id, option.label);254 handleOptionSelection(activeQuestion.id, optionValue);
254 }}255 }}
255 className={className}256 className={className}
256 >257 >
apps/web/src/components/chat/ModelPickerContent.test.ts+158-10
...@@ -1,14 +1,23 @@...@@ -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";
2import { describe, expect, it } from "vite-plus/test";7import { describe, expect, it } from "vite-plus/test";
38
4import { deriveProviderInstanceEntries } from "../../providerInstances";9import { 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") {
8 return deriveProviderInstanceEntries([17 return deriveProviderInstanceEntries([
9 {18 {
10 instanceId: ProviderInstanceId.make("opencode_work"),19 instanceId: ProviderInstanceId.make(`${driver}_work`),
11 driver: ProviderDriverKind.make("opencode"),20 driver: ProviderDriverKind.make(driver),
12 enabled: true,21 enabled: true,
13 installed: true,22 installed: true,
14 version: null,23 version: null,
...@@ -23,12 +32,36 @@ function entry(status: ServerProvider["status"]) {...@@ -23,12 +32,36 @@ function entry(status: ServerProvider["status"]) {
23}32}
2433
25describe("shouldIncludeModelPickerOption", () => {34describe("shouldIncludeModelPickerOption", () => {
26 it.each(["error", "warning"] as const)(35 it.each(["ready", "error"] as const)(
27 "keeps only the active synthetic OpenCode row when the provider status is %s",36 "never offers the internal Antigravity default marker as a model when %s",
28 (status) => {37 (status) => {
29 const providerEntry = entry(status);38 const providerEntry = entry(status, "antigravity");
30 const activeInstanceId = ProviderInstanceId.make("opencode_work");39 expect(
31 const activeModel = "openrouter/kimi-k3";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
33 expect(66 expect(
34 shouldIncludeModelPickerOption({67 shouldIncludeModelPickerOption({
...@@ -62,6 +95,121 @@ describe("shouldIncludeModelPickerOption", () => {...@@ -62,6 +95,121 @@ describe("shouldIncludeModelPickerOption", () => {
62 activeModel,95 activeModel,
63 }),96 }),
64 ).toBe(false);97 ).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);
65 },106 },
66 );107 );
67});108});
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 @@...@@ -1,4 +1,5 @@
1import {1import {
2 ANTIGRAVITY_DEFAULT_MODEL,
2 type ProviderInstanceId,3 type ProviderInstanceId,
3 type ProviderDriverKind,4 type ProviderDriverKind,
4 type ResolvedKeybindingsConfig,5 type ResolvedKeybindingsConfig,
...@@ -9,6 +10,7 @@ import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRe...@@ -9,6 +10,7 @@ import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRe
9import { ChevronRightIcon, SearchIcon } from "lucide-react";10import { ChevronRightIcon, SearchIcon } from "lucide-react";
10import { ModelListRow } from "./ModelListRow";11import { ModelListRow } from "./ModelListRow";
11import { ModelPickerSidebar } from "./ModelPickerSidebar";12import { ModelPickerSidebar } from "./ModelPickerSidebar";
13import { getProviderStatusMessage, hasProviderSetup } from "./ProviderStatusBanner";
12import {14import {
13 modelPickerLegacySectionKey,15 modelPickerLegacySectionKey,
14 modelPickerModelKey,16 modelPickerModelKey,
...@@ -34,6 +36,7 @@ import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"...@@ -34,6 +36,7 @@ import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"
34import { cn } from "~/lib/utils";36import { cn } from "~/lib/utils";
35import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";37import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";
36import { TooltipProvider } from "../ui/tooltip";38import { TooltipProvider } from "../ui/tooltip";
39import { Button } from "../ui/button";
37import {40import {
38 isProviderInstancePickerReady,41 isProviderInstancePickerReady,
39 isProviderInstancePickerVisible,42 isProviderInstancePickerVisible,
...@@ -56,22 +59,57 @@ type ModelPickerItem = {...@@ -56,22 +59,57 @@ type ModelPickerItem = {
56 isUnavailable?: boolean | undefined;59 isUnavailable?: boolean | undefined;
57};60};
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
59export function shouldIncludeModelPickerOption(input: {79export function shouldIncludeModelPickerOption(input: {
60 readonly entry: ProviderInstanceEntry;80 readonly entry: ProviderInstanceEntry;
61 readonly option: ModelEsque;81 readonly option: ModelEsque;
62 readonly activeInstanceId: ProviderInstanceId;82 readonly activeInstanceId: ProviderInstanceId;
63 readonly activeModel: string;83 readonly activeModel: string;
64}): boolean {84}): boolean {
85 if (input.entry.driverKind === "antigravity" && input.option.slug === ANTIGRAVITY_DEFAULT_MODEL) {
86 return false;
87 }
65 if (isProviderInstancePickerReady(input.entry)) return true;88 if (isProviderInstancePickerReady(input.entry)) return true;
66 return (89 return (
67 input.entry.enabled &&90 input.entry.enabled &&
68 input.entry.driverKind === "opencode" &&91 (input.entry.driverKind === "opencode" || input.entry.driverKind === "antigravity") &&
69 input.entry.instanceId === input.activeInstanceId &&92 input.entry.instanceId === input.activeInstanceId &&
70 input.option.slug === input.activeModel &&93 input.option.slug === input.activeModel &&
71 input.option.isUnavailable === true94 input.option.isUnavailable === true
72 );95 );
73}96}
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
75const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>();113const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>();
76114
77function ModelListSeparator() {115function ModelListSeparator() {
...@@ -107,6 +145,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -107,6 +145,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
107 modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>;145 modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>;
108 terminalOpen: boolean;146 terminalOpen: boolean;
109 onRequestClose?: () => void;147 onRequestClose?: () => void;
148 onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void;
110 getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null;149 getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null;
111 onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void;150 onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void;
112}) {151}) {
...@@ -127,6 +166,16 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -127,6 +166,16 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
127 const activeEntry = props.instanceEntries.find(166 const activeEntry = props.instanceEntries.find(
128 (entry) => entry.instanceId === props.activeInstanceId,167 (entry) => entry.instanceId === props.activeInstanceId,
129 );168 );
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;
130 const activeInstanceHasSelectableUnavailableModel =179 const activeInstanceHasSelectableUnavailableModel =
131 activeEntry !== undefined &&180 activeEntry !== undefined &&
132 (modelOptionsByInstance.get(props.activeInstanceId) ?? []).some((option) =>181 (modelOptionsByInstance.get(props.activeInstanceId) ?? []).some((option) =>
...@@ -134,15 +183,25 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -134,15 +183,25 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
134 entry: activeEntry,183 entry: activeEntry,
135 option,184 option,
136 activeInstanceId: props.activeInstanceId,185 activeInstanceId: props.activeInstanceId,
137 activeModel: props.model,186 activeModel: activeModelSlug,
138 }),187 }),
139 ) &&188 ) &&
140 !isProviderInstancePickerReady(activeEntry);189 !isProviderInstancePickerReady(activeEntry);
190 const activeInstanceNeedsSetup =
191 props.onOpenProviderSetup !== undefined &&
192 activeEntry !== undefined &&
193 shouldOfferModelPickerSetup(
194 activeEntry,
195 modelOptionsByInstance.get(props.activeInstanceId) ?? [],
196 );
141 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | "favorites">(197 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | "favorites">(
142 () => {198 () => {
143 if (props.lockedProvider !== null || activeInstanceHasSelectableUnavailableModel) {199 if (
144 // When locked, prime the sidebar to the currently-active instance200 props.lockedProvider !== null ||
145 // so jumping into the picker keeps the focused instance visible.201 activeInstanceHasSelectableUnavailableModel ||
202 activeInstanceNeedsSetup
203 ) {
204 // Keep the active instance visible when it is locked or needs setup.
146 return props.activeInstanceId;205 return props.activeInstanceId;
147 }206 }
148 return favorites.length > 0 ? "favorites" : props.activeInstanceId;207 return favorites.length > 0 ? "favorites" : props.activeInstanceId;
...@@ -153,7 +212,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -153,7 +212,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
153 new Set<ProviderInstanceId>(212 new Set<ProviderInstanceId>(
154 modelOptionsByInstance213 modelOptionsByInstance
155 .get(props.activeInstanceId)214 .get(props.activeInstanceId)
156 ?.some((model) => model.slug === props.model && model.isLegacy)215 ?.some((model) => model.slug === activeModelSlug && model.isLegacy)
157 ? [props.activeInstanceId]216 ? [props.activeInstanceId]
158 : [],217 : [],
159 ),218 ),
...@@ -221,11 +280,27 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -221,11 +280,27 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
221 );280 );
222281
223 const selectableUnavailableInstanceIds = useMemo(() => {282 const selectableUnavailableInstanceIds = useMemo(() => {
224 if (!activeInstanceHasSelectableUnavailableModel) {283 const instanceIds = new Set<ProviderInstanceId>();
225 return undefined;284 if (activeInstanceHasSelectableUnavailableModel) {
285 instanceIds.add(props.activeInstanceId);
226 }286 }
227 return new Set([props.activeInstanceId]);287 if (props.onOpenProviderSetup) {
228 }, [activeInstanceHasSelectableUnavailableModel, props.activeInstanceId]);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
230 // Flatten models into a searchable array. One pass over the305 // Flatten models into a searchable array. One pass over the
231 // instance-keyed map; each model carries its instance id + driver kind306 // instance-keyed map; each model carries its instance id + driver kind
...@@ -246,7 +321,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -246,7 +321,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
246 entry,321 entry,
247 option: model,322 option: model,
248 activeInstanceId: props.activeInstanceId,323 activeInstanceId: props.activeInstanceId,
249 activeModel: props.model,324 activeModel: activeModelSlug,
250 })325 })
251 ) {326 ) {
252 continue;327 continue;
...@@ -270,7 +345,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -270,7 +345,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
270 }345 }
271 }346 }
272 return out;347 return out;
273 }, [modelOptionsByInstance, entryByInstanceId, props.activeInstanceId, props.model]);348 }, [modelOptionsByInstance, entryByInstanceId, props.activeInstanceId, activeModelSlug]);
274349
275 const isLocked = props.lockedProvider !== null;350 const isLocked = props.lockedProvider !== null;
276 const isSearching = searchQuery.trim().length > 0;351 const isSearching = searchQuery.trim().length > 0;
...@@ -441,6 +516,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -441,6 +516,23 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
441 ];516 ];
442 }, [filteredModels, legacySection]);517 }, [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
444 const toggleLegacySection = useCallback((instanceId: ProviderInstanceId) => {536 const toggleLegacySection = useCallback((instanceId: ProviderInstanceId) => {
445 setExpandedLegacyInstances((expanded) => {537 setExpandedLegacyInstances((expanded) => {
446 const next = new Set(expanded);538 const next = new Set(expanded);
...@@ -666,7 +758,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -666,7 +758,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
666 autoHighlight758 autoHighlight
667 open759 open
668 virtualized760 virtualized
669 value={modelPickerModelKey(props.activeInstanceId, props.model)}761 value={activeModelKey}
670 onItemHighlighted={(modelKey, eventDetails) => {762 onItemHighlighted={(modelKey, eventDetails) => {
671 highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null;763 highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null;
672 if (eventDetails.reason === "keyboard" && eventDetails.index >= 0) {764 if (eventDetails.reason === "keyboard" && eventDetails.index >= 0) {
...@@ -799,9 +891,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -799,9 +891,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
799 isFavorite={favoritesSet.has(891 isFavorite={favoritesSet.has(
800 providerModelKey(model.instanceId, model.slug),892 providerModelKey(model.instanceId, model.slug),
801 )}893 )}
802 isSelected={894 isSelected={modelKey === activeModelKey}
803 modelKey === modelPickerModelKey(props.activeInstanceId, props.model)
804 }
805 showProvider895 showProvider
806 preferShortName={!isLocked}896 preferShortName={!isLocked}
807 useTriggerLabel={false}897 useTriggerLabel={false}
...@@ -830,9 +920,34 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -830,9 +920,34 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
830 />920 />
831 </ComboboxListVirtualized>921 </ComboboxListVirtualized>
832 </div>922 </div>
833 <ComboboxEmpty className="not-empty:py-6 empty:h-0 text-xs font-normal leading-snug">923 {providerSetupEntries.length > 0 ? (
834 No models found924 <div className="max-h-44 shrink-0 overflow-y-auto border-t border-border/70 p-2">
835 </ComboboxEmpty>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 )}
836 </div>951 </div>
837 </Combobox>952 </Combobox>
838 </div>953 </div>
apps/web/src/components/chat/ProviderModelPicker.test.tsx+53-11
...@@ -1,4 +1,9 @@...@@ -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";
2import { renderToStaticMarkup } from "react-dom/server";7import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it } from "vite-plus/test";8import { describe, expect, it } from "vite-plus/test";
49
...@@ -45,17 +50,54 @@ function renderPicker(input: {...@@ -45,17 +50,54 @@ function renderPicker(input: {
45}50}
4651
47describe("ProviderModelPicker", () => {52describe("ProviderModelPicker", () => {
48 it("shows a missing model slug for a custom OpenCode instance", () => {53 it.each(["", ANTIGRAVITY_DEFAULT_MODEL])(
49 const markup = renderPicker({54 "shows a choice prompt before Antigravity has an account catalog for %s",
50 instanceId: "team_runtime",55 (model) => {
51 driver: "opencode",56 const markup = renderPicker({
52 model: "openrouter/missing-model",57 instanceId: "antigravity",
53 options: [{ slug: "openrouter/fallback", name: "Fallback model" }],58 driver: "antigravity",
54 });59 model,
60 options: [],
61 });
5562
56 expect(markup).toContain("openrouter/missing-model");63 expect(markup).toContain("Choose model");
57 expect(markup).not.toContain("Fallback model");64 expect(markup).not.toContain(ANTIGRAVITY_DEFAULT_MODEL);
58 });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
60 it.each(["codex", "claudeAgent", "cursor", "grok"])(102 it.each(["codex", "claudeAgent", "cursor", "grok"])(
61 "uses the first option label for a missing %s model",103 "uses the first option label for a missing %s model",
apps/web/src/components/chat/ProviderModelPicker.tsx+19-7
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 ANTIGRAVITY_DEFAULT_MODEL,
2 type ProviderInstanceId,3 type ProviderInstanceId,
3 type ProviderDriverKind,4 type ProviderDriverKind,
4 type ResolvedKeybindingsConfig,5 type ResolvedKeybindingsConfig,
...@@ -10,7 +11,7 @@ import { buttonVariants } from "../ui/button";...@@ -10,7 +11,7 @@ import { buttonVariants } from "../ui/button";
10import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";11import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
11import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";12import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
12import { cn } from "~/lib/utils";13import { cn } from "~/lib/utils";
13import { ModelPickerContent } from "./ModelPickerContent";14import { ModelPickerContent, resolveModelPickerSelectedModel } from "./ModelPickerContent";
14import { ProviderInstanceIcon } from "./ProviderInstanceIcon";15import { ProviderInstanceIcon } from "./ProviderInstanceIcon";
15import {16import {
16 ModelEsque,17 ModelEsque,
...@@ -50,6 +51,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -50,6 +51,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
50 triggerClassName?: string;51 triggerClassName?: string;
51 triggerAriaLabel?: string;52 triggerAriaLabel?: string;
52 onOpenChange?: (open: boolean) => void;53 onOpenChange?: (open: boolean) => void;
54 onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void;
53 getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null;55 getModelDisabledReason?: (instanceId: ProviderInstanceId, model: string) => string | null;
54 onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void;56 onInstanceModelChange: (instanceId: ProviderInstanceId, model: string) => void;
55}) {57}) {
...@@ -68,15 +70,24 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -68,15 +70,24 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
6870
69 const activeInstanceId = props.activeInstanceId;71 const activeInstanceId = props.activeInstanceId;
70 const selectedInstanceOptions = props.modelOptionsByInstance.get(activeInstanceId) ?? [];72 const selectedInstanceOptions = props.modelOptionsByInstance.get(activeInstanceId) ?? [];
71 // OpenCode can keep a model through a transient catalog refresh. Other73 // Account-specific catalogs must keep the selected model label while unavailable.
72 // providers keep the active instance's first option as their normal fallback.
73 const selectedModel =74 const selectedModel =
74 selectedInstanceOptions.find((option) => option.slug === props.model) ??75 resolveModelPickerSelectedModel({
75 (activeEntry?.driverKind === "opencode" ? undefined : selectedInstanceOptions[0]);76 driverKind: activeEntry?.driverKind,
76 const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel) : props.model;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";
77 const triggerLabel = selectedModel88 const triggerLabel = selectedModel
78 ? `${getTriggerDisplayModelLabel(selectedModel)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}`89 ? `${getTriggerDisplayModelLabel(selectedModel)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}`
79 : props.model;90 : triggerTitle;
80 const showInstanceBadge =91 const showInstanceBadge =
81 activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries);92 activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries);
8293
...@@ -225,6 +236,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -225,6 +236,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
225 modelOptionsByInstance={props.modelOptionsByInstance}236 modelOptionsByInstance={props.modelOptionsByInstance}
226 terminalOpen={props.terminalOpen ?? false}237 terminalOpen={props.terminalOpen ?? false}
227 onRequestClose={() => setIsMenuOpen(false)}238 onRequestClose={() => setIsMenuOpen(false)}
239 {...(props.onOpenProviderSetup ? { onOpenProviderSetup: props.onOpenProviderSetup } : {})}
228 {...(props.getModelDisabledReason240 {...(props.getModelDisabledReason
229 ? { getModelDisabledReason: props.getModelDisabledReason }241 ? { getModelDisabledReason: props.getModelDisabledReason }
230 : {})}242 : {})}
apps/web/src/components/chat/ProviderStatusBanner.test.tsx+53
...@@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";
44
5import {5import {
6 getProviderStatusBannerKey,6 getProviderStatusBannerKey,
7 getProviderStatusMessage,
7 ProviderStatusBanner,8 ProviderStatusBanner,
8 shouldShowProviderStatusBanner,9 shouldShowProviderStatusBanner,
9} from "./ProviderStatusBanner";10} from "./ProviderStatusBanner";
...@@ -64,3 +65,55 @@ describe("ProviderStatusBanner", () => {...@@ -64,3 +65,55 @@ describe("ProviderStatusBanner", () => {
64 expect(markup).toContain('aria-label="Dismiss Codex provider error"');65 expect(markup).toContain('aria-label="Dismiss Codex provider error"');
65 });66 });
66});67});
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 @@...@@ -1,4 +1,4 @@
1import { type ServerProvider } from "@t3tools/contracts";1import { type ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
2import { memo } from "react";2import { memo } from "react";
3import { InfoIcon, XIcon } from "lucide-react";3import { InfoIcon, XIcon } from "lucide-react";
4import { cn } from "~/lib/utils";4import { cn } from "~/lib/utils";
...@@ -20,11 +20,43 @@ export function shouldShowProviderStatusBanner(...@@ -20,11 +20,43 @@ export function shouldShowProviderStatusBanner(
20 return bannerKey !== null && bannerKey !== dismissedBannerKey;20 return bannerKey !== null && bannerKey !== dismissedBannerKey;
21}21}
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
23export const ProviderStatusBanner = memo(function ProviderStatusBanner({53export const ProviderStatusBanner = memo(function ProviderStatusBanner({
24 onDismiss,54 onDismiss,
55 onOpenProviderSetup,
25 status,56 status,
26}: {57}: {
27 onDismiss: () => void;58 onDismiss: () => void;
59 onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void;
28 status: ServerProvider | null;60 status: ServerProvider | null;
29}) {61}) {
30 if (!status || status.status === "ready" || status.status === "disabled") {62 if (!status || status.status === "ready" || status.status === "disabled") {
...@@ -36,12 +68,7 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({...@@ -36,12 +68,7 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
36 const title = isUnauthenticated68 const title = isUnauthenticated
37 ? `${providerName} is unauthenticated`69 ? `${providerName} is unauthenticated`
38 : `${providerName} provider status`;70 : `${providerName} provider status`;
39 const message = isUnauthenticated71 const message = getProviderStatusMessage(status);
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.`));
4572
46 return (73 return (
47 <div className="pointer-events-auto mx-auto w-fit max-w-[calc(100%-2rem)] pt-3">74 <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({...@@ -66,6 +93,16 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
66 {message}93 {message}
67 </TooltipPopup>94 </TooltipPopup>
68 </Tooltip>95 </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}
69 </div>106 </div>
70 <Button107 <Button
71 aria-label={`Dismiss ${providerName} provider ${status.status}`}108 aria-label={`Dismiss ${providerName} provider ${status.status}`}
apps/web/src/components/chat/providerIconUtils.ts+12-1
...@@ -1,5 +1,13 @@...@@ -1,5 +1,13 @@
1import { ProviderDriverKind } from "@t3tools/contracts";1import { 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
4export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = {12export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = {
5 [ProviderDriverKind.make("codex")]: OpenAI,13 [ProviderDriverKind.make("codex")]: OpenAI,
...@@ -7,6 +15,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>...@@ -7,6 +15,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>
7 [ProviderDriverKind.make("opencode")]: OpenCodeIcon,15 [ProviderDriverKind.make("opencode")]: OpenCodeIcon,
8 [ProviderDriverKind.make("cursor")]: CursorIcon,16 [ProviderDriverKind.make("cursor")]: CursorIcon,
9 [ProviderDriverKind.make("grok")]: GrokIcon,17 [ProviderDriverKind.make("grok")]: GrokIcon,
18 [ProviderDriverKind.make("antigravity")]: AntigravityIcon,
10};19};
1120
12export type ModelEsque = {21export type ModelEsque = {
...@@ -14,6 +23,8 @@ export type ModelEsque = {...@@ -14,6 +23,8 @@ export type ModelEsque = {
14 name: string;23 name: string;
15 shortName?: string | undefined;24 shortName?: string | undefined;
16 subProvider?: string | undefined;25 subProvider?: string | undefined;
26 aliases?: ReadonlyArray<string> | undefined;
27 isDefault?: boolean | undefined;
17 badge?: "new" | undefined;28 badge?: "new" | undefined;
18 isLegacy?: boolean | undefined;29 isLegacy?: boolean | undefined;
19 isUnavailable?: boolean | undefined;30 isUnavailable?: boolean | undefined;
apps/web/src/components/settings/ProjectSettingsPanel.tsx+6
...@@ -907,6 +907,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -907,6 +907,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
907 modelOptionsByInstance={modelOptionsByInstance}907 modelOptionsByInstance={modelOptionsByInstance}
908 triggerVariant="outline"908 triggerVariant="outline"
909 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}909 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
910 onOpenProviderSetup={(instanceId) => {
911 void navigate({
912 to: "/settings/providers",
913 search: { environmentId: representative.environmentId, instanceId },
914 });
915 }}
910 onInstanceModelChange={(instanceId, model) => {916 onInstanceModelChange={(instanceId, model) => {
911 setDefaultModel(createModelSelection(instanceId, model));917 setDefaultModel(createModelSelection(instanceId, model));
912 }}918 }}
apps/web/src/components/settings/ProviderInstanceCard.tsx+5-1
...@@ -369,6 +369,7 @@ interface ProviderInstanceCardProps {...@@ -369,6 +369,7 @@ interface ProviderInstanceCardProps {
369 * omit it.369 * omit it.
370 */370 */
371 readonly headerAction?: ReactNode | undefined;371 readonly headerAction?: ReactNode | undefined;
372 readonly setup?: ReactNode;
372 readonly hiddenModels: ReadonlyArray<string>;373 readonly hiddenModels: ReadonlyArray<string>;
373 readonly favoriteModels: ReadonlyArray<string>;374 readonly favoriteModels: ReadonlyArray<string>;
374 readonly modelOrder: ReadonlyArray<string>;375 readonly modelOrder: ReadonlyArray<string>;
...@@ -410,6 +411,7 @@ export function ProviderInstanceCard({...@@ -410,6 +411,7 @@ export function ProviderInstanceCard({
410 onUpdate,411 onUpdate,
411 onDelete,412 onDelete,
412 headerAction,413 headerAction,
414 setup,
413 hiddenModels,415 hiddenModels,
414 favoriteModels,416 favoriteModels,
415 modelOrder,417 modelOrder,
...@@ -474,7 +476,8 @@ export function ProviderInstanceCard({...@@ -474,7 +476,8 @@ export function ProviderInstanceCard({
474 : null;476 : null;
475 const visibleTab = driverOption === undefined ? "configuration" : activeTab;477 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");
478 // Server-returned models may lag behind settings writes. Treat probe481 // Server-returned models may lag behind settings writes. Treat probe
479 // models as the source for built-ins only; custom rows come directly482 // models as the source for built-ins only; custom rows come directly
480 // from the current instance config so add/remove reflects immediately.483 // from the current instance config so add/remove reflects immediately.
...@@ -843,6 +846,7 @@ export function ProviderInstanceCard({...@@ -843,6 +846,7 @@ export function ProviderInstanceCard({
843 className="lg:h-full"846 className="lg:h-full"
844 hidden={visibleTab !== "configuration"}847 hidden={visibleTab !== "configuration"}
845 >848 >
849 {setup ? <div className="border-b border-border/60 px-4 py-3">{setup}</div> : null}
846 <div850 <div
847 inert={readOnly}851 inert={readOnly}
848 aria-disabled={readOnly || undefined}852 aria-disabled={readOnly || undefined}
apps/web/src/components/settings/ProviderModelsSection.tsx+5-2
...@@ -202,6 +202,7 @@ export function ProviderModelsSection({...@@ -202,6 +202,7 @@ export function ProviderModelsSection({
202 }, [displayModels]);202 }, [displayModels]);
203203
204 const handleAdd = () => {204 const handleAdd = () => {
205 if (driverKind === "antigravity") return;
205 const normalized = normalizeCustomModelSlug(input);206 const normalized = normalizeCustomModelSlug(input);
206 if (!normalized) {207 if (!normalized) {
207 setError("Enter a model slug.");208 setError("Enter a model slug.");
...@@ -505,7 +506,7 @@ export function ProviderModelsSection({...@@ -505,7 +506,7 @@ export function ProviderModelsSection({
505 })}506 })}
506 </div>507 </div>
507508
508 {isAdding ? (509 {driverKind === "antigravity" ? null : isAdding ? (
509 <div className="mt-3 flex flex-col gap-2 sm:flex-row">510 <div className="mt-3 flex flex-col gap-2 sm:flex-row">
510 <Input511 <Input
511 id={`provider-instance-${instanceId}-custom-model`}512 id={`provider-instance-${instanceId}-custom-model`}
...@@ -551,7 +552,9 @@ export function ProviderModelsSection({...@@ -551,7 +552,9 @@ export function ProviderModelsSection({
551 </Button>552 </Button>
552 )}553 )}
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}
555 </div>558 </div>
556 );559 );
557}560}
apps/web/src/components/settings/ProviderSettingsForm.test.ts+23
...@@ -37,6 +37,29 @@ describe("ProviderSettingsForm helpers", () => {...@@ -37,6 +37,29 @@ describe("ProviderSettingsForm helpers", () => {
37 });37 });
38 });38 });
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
40 it("shows the auto-compaction threshold for Claude providers", () => {63 it("shows the auto-compaction threshold for Claude providers", () => {
41 const claude = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("claudeAgent")];64 const claude = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("claudeAgent")];
42 expect(claude).toBeDefined();65 expect(claude).toBeDefined();
apps/web/src/components/settings/ProviderSettingsForm.tsx+77
...@@ -6,12 +6,14 @@ import * as Schema from "effect/Schema";...@@ -6,12 +6,14 @@ import * as Schema from "effect/Schema";
6import type {6import type {
7 ProviderSettingsFormAnnotation,7 ProviderSettingsFormAnnotation,
8 ProviderSettingsFormControl,8 ProviderSettingsFormControl,
9 ProviderSettingsFormOption,
9 ProviderSettingsFormSchemaAnnotation,10 ProviderSettingsFormSchemaAnnotation,
10} from "@t3tools/contracts";11} from "@t3tools/contracts";
1112
12import { cn } from "../../lib/utils";13import { cn } from "../../lib/utils";
13import { DraftInput } from "../ui/draft-input";14import { DraftInput } from "../ui/draft-input";
14import { Input } from "../ui/input";15import { Input } from "../ui/input";
16import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
15import { Switch } from "../ui/switch";17import { Switch } from "../ui/switch";
16import { Textarea } from "../ui/textarea";18import { Textarea } from "../ui/textarea";
17import type { ProviderClientDefinition } from "./providerDriverMeta";19import type { ProviderClientDefinition } from "./providerDriverMeta";
...@@ -24,6 +26,8 @@ export interface ProviderSettingsFieldModel {...@@ -24,6 +26,8 @@ export interface ProviderSettingsFieldModel {
24 readonly placeholder?: string | undefined;26 readonly placeholder?: string | undefined;
25 readonly clearWhenEmpty: "omit" | "persist";27 readonly clearWhenEmpty: "omit" | "persist";
26 readonly defaultBooleanValue?: boolean | undefined;28 readonly defaultBooleanValue?: boolean | undefined;
29 /** Choices for a `select` control. The first entry is the default. */
30 readonly options?: ReadonlyArray<ProviderSettingsFormOption> | undefined;
27}31}
2832
29function titleizeFieldKey(key: string): string {33function titleizeFieldKey(key: string): string {
...@@ -106,6 +110,9 @@ export function deriveProviderSettingsFields(...@@ -106,6 +110,9 @@ export function deriveProviderSettingsFields(
106 ...(formAnnotation.control === "switch"110 ...(formAnnotation.control === "switch"
107 ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) }111 ? { defaultBooleanValue: readFieldBooleanDefault(fieldSchema) }
108 : {}),112 : {}),
113 ...(formAnnotation.control === "select" && formAnnotation.options
114 ? { options: formAnnotation.options }
115 : {}),
109 } satisfies ProviderSettingsFieldModel,116 } satisfies ProviderSettingsFieldModel,
110 ];117 ];
111 });118 });
...@@ -168,6 +175,48 @@ interface ProviderSettingsFormProps {...@@ -168,6 +175,48 @@ interface ProviderSettingsFormProps {
168 readonly onChange: (nextConfig: Record<string, unknown> | undefined) => void;175 readonly onChange: (nextConfig: Record<string, unknown> | undefined) => void;
169}176}
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
171function FieldFrame(props: {220function FieldFrame(props: {
172 readonly variant: ProviderSettingsFormProps["variant"];221 readonly variant: ProviderSettingsFormProps["variant"];
173 readonly children: ReactNode;222 readonly children: ReactNode;
...@@ -229,6 +278,15 @@ function ProviderSettingsFieldRow({...@@ -229,6 +278,15 @@ function ProviderSettingsFieldRow({
229 aria-describedby={descriptionId}278 aria-describedby={descriptionId}
230 />279 />
231 </span>280 </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 />
232 ) : field.control === "textarea" ? (290 ) : field.control === "textarea" ? (
233 <Textarea291 <Textarea
234 id={inputId}292 id={inputId}
...@@ -285,6 +343,25 @@ function ProviderSettingsFieldRow({...@@ -285,6 +343,25 @@ function ProviderSettingsFieldRow({
285 );343 );
286 }344 }
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
288 if (field.control === "textarea") {365 if (field.control === "textarea") {
289 return (366 return (
290 <FieldFrame variant={variant}>367 <FieldFrame variant={variant}>
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+28-1
...@@ -132,12 +132,16 @@ function provider(): ServerProvider {...@@ -132,12 +132,16 @@ function provider(): ServerProvider {
132132
133function renderPanel(options?: {133function renderPanel(options?: {
134 readonly readOnly?: boolean;134 readonly readOnly?: boolean;
135 readonly targetInstanceId?: ProviderInstanceId;
135}): ReactElement<Record<string, unknown>> {136}): ReactElement<Record<string, unknown>> {
136 hooks.beginRender();137 hooks.beginRender();
137 return EnvironmentProviderSettings({138 return EnvironmentProviderSettings({
138 environmentId,139 environmentId,
139 environmentLabel: "Remote device",140 environmentLabel: "Remote device",
140 ...(options?.readOnly === undefined ? {} : { readOnly: options.readOnly }),141 ...(options?.readOnly === undefined ? {} : { readOnly: options.readOnly }),
142 ...(options?.targetInstanceId === undefined
143 ? {}
144 : { targetInstanceId: options.targetInstanceId }),
141 }) as ReactElement<Record<string, unknown>>;145 }) as ReactElement<Record<string, unknown>>;
142}146}
143147
...@@ -204,7 +208,10 @@ describe("EnvironmentProviderSettings routing", () => {...@@ -204,7 +208,10 @@ describe("EnvironmentProviderSettings routing", () => {
204 (refreshButton?.props.onClick as (() => void) | undefined)?.();208 (refreshButton?.props.onClick as (() => void) | undefined)?.();
205 await flushPromises();209 await flushPromises();
206210
207 expect(commands.refresh).toHaveBeenCalledWith({ environmentId, input: {} });211 expect(commands.refresh).toHaveBeenCalledWith({
212 environmentId,
213 input: { refreshModels: true },
214 });
208215
209 const providerCard = visitElements(216 const providerCard = visitElements(
210 panel,217 panel,
...@@ -221,6 +228,26 @@ describe("EnvironmentProviderSettings routing", () => {...@@ -221,6 +228,26 @@ describe("EnvironmentProviderSettings routing", () => {
221 });228 });
222 });229 });
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
224 it("keeps provider selection available while write controls are read only", () => {251 it("keeps provider selection available while write controls are read only", () => {
225 settingsState.value = {252 settingsState.value = {
226 ...DEFAULT_UNIFIED_SETTINGS,253 ...DEFAULT_UNIFIED_SETTINGS,
apps/web/src/components/settings/ProviderSettingsPanel.tsx+105-22
...@@ -69,6 +69,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";...@@ -69,6 +69,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
69import { stackedThreadToast, toastManager } from "../ui/toast";69import { stackedThreadToast, toastManager } from "../ui/toast";
70import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";70import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
71import { ProviderInstanceCard } from "./ProviderInstanceCard";71import { ProviderInstanceCard } from "./ProviderInstanceCard";
72import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection";
72import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";73import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";
73import { providerSettingsTabClassName } from "./providerSettingsTabs";74import { providerSettingsTabClassName } from "./providerSettingsTabs";
74import { searchableSetting } from "./settingsSearch";75import { searchableSetting } from "./settingsSearch";
...@@ -119,6 +120,11 @@ const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({...@@ -119,6 +120,11 @@ const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({
119 provider: definition.value,120 provider: definition.value,
120}));121}));
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
122function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {128function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {
123 useRelativeTimeTick();129 useRelativeTimeTick();
124 const lastCheckedRelative = getRelativeTimeState(lastCheckedAt);130 const lastCheckedRelative = getRelativeTimeState(lastCheckedAt);
...@@ -183,15 +189,23 @@ function EnvironmentUnavailableRow({...@@ -183,15 +189,23 @@ function EnvironmentUnavailableRow({
183 );189 );
184}190}
185191
186export function ProviderSettingsPanel() {192interface ProviderSettingsTarget {
193 readonly environmentId?: EnvironmentId;
194 readonly instanceId?: ProviderInstanceId;
195}
196
197export function ProviderSettingsPanel(target: ProviderSettingsTarget) {
187 return (198 return (
188 <SettingsPageContainer className="gap-8">199 <SettingsPageContainer className="gap-8">
189 <ProviderSettingsPanelContent />200 <ProviderSettingsPanelContent
201 key={`${target.environmentId ?? ""}:${target.instanceId ?? ""}`}
202 {...target}
203 />
190 </SettingsPageContainer>204 </SettingsPageContainer>
191 );205 );
192}206}
193207
194function ProviderSettingsPanelContent() {208function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
195 const { environments, isReady } = useEnvironments();209 const { environments, isReady } = useEnvironments();
196 const primaryEnvironmentId = usePrimaryEnvironmentId();210 const primaryEnvironmentId = usePrimaryEnvironmentId();
197 const searchTargetId = useSettingsSearchTargetId();211 const searchTargetId = useSettingsSearchTargetId();
...@@ -203,13 +217,15 @@ function ProviderSettingsPanelContent() {...@@ -203,13 +217,15 @@ function ProviderSettingsPanelContent() {
203 // device that drops out of the catalog falls back without erasing the pick —217 // device that drops out of the catalog falls back without erasing the pick —
204 // if it reappears (e.g. after a reconnect) the selection is restored.218 // if it reappears (e.g. after a reconnect) the selection is restored.
205 const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(219 const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(
206 primaryEnvironmentId,220 target.environmentId ?? primaryEnvironmentId,
207 );
208 const effectiveEnvironmentId = resolveSelectedProviderEnvironmentId(
209 options,
210 selectedEnvironmentId,
211 primaryEnvironmentId,
212 );221 );
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);
213 const selectedEnvironment =229 const selectedEnvironment =
214 options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null;230 options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null;
215 const selectedEnvironmentCanRenderSettings =231 const selectedEnvironmentCanRenderSettings =
...@@ -288,7 +304,16 @@ function ProviderSettingsPanelContent() {...@@ -288,7 +304,16 @@ function ProviderSettingsPanelContent() {
288304
289 return (305 return (
290 <>306 <>
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 ? (
292 <SettingsSection {...searchableSetting("providers")}>317 <SettingsSection {...searchableSetting("providers")}>
293 <SettingsRow318 <SettingsRow
294 title={isReady ? "No connected devices" : "Loading devices"}319 title={isReady ? "No connected devices" : "Loading devices"}
...@@ -306,6 +331,12 @@ function ProviderSettingsPanelContent() {...@@ -306,6 +331,12 @@ function ProviderSettingsPanelContent() {
306 key={selectedEnvironment.environmentId}331 key={selectedEnvironment.environmentId}
307 environment={selectedEnvironment}332 environment={selectedEnvironment}
308 deviceTabs={deviceTabs}333 deviceTabs={deviceTabs}
334 targetInstanceId={
335 target.environmentId === undefined ||
336 selectedEnvironment.environmentId === target.environmentId
337 ? target.instanceId
338 : undefined
339 }
309 />340 />
310 ) : null}341 ) : null}
311 </>342 </>
...@@ -315,9 +346,11 @@ function ProviderSettingsPanelContent() {...@@ -315,9 +346,11 @@ function ProviderSettingsPanelContent() {
315function SelectedEnvironmentProviderSettings({346function SelectedEnvironmentProviderSettings({
316 environment,347 environment,
317 deviceTabs,348 deviceTabs,
349 targetInstanceId,
318}: {350}: {
319 readonly environment: EnvironmentPresentation;351 readonly environment: EnvironmentPresentation;
320 readonly deviceTabs?: ReactNode;352 readonly deviceTabs?: ReactNode;
353 readonly targetInstanceId?: ProviderInstanceId | undefined;
321}) {354}) {
322 const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget";355 const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget";
323 if (isPrimary) {356 if (isPrimary) {
...@@ -329,22 +362,35 @@ function SelectedEnvironmentProviderSettings({...@@ -329,22 +362,35 @@ function SelectedEnvironmentProviderSettings({
329 environment={environment}362 environment={environment}
330 operateAccess="granted"363 operateAccess="granted"
331 deviceTabs={deviceTabs}364 deviceTabs={deviceTabs}
365 targetInstanceId={targetInstanceId}
332 />366 />
333 );367 );
334 }368 }
335 return (369 return (
336 <PrimarySessionGatedProviderSettings environment={environment} deviceTabs={deviceTabs} />370 <PrimarySessionGatedProviderSettings
371 environment={environment}
372 deviceTabs={deviceTabs}
373 targetInstanceId={targetInstanceId}
374 />
337 );375 );
338 }376 }
339 return <RemoteSessionGatedProviderSettings environment={environment} deviceTabs={deviceTabs} />;377 return (
378 <RemoteSessionGatedProviderSettings
379 environment={environment}
380 deviceTabs={deviceTabs}
381 targetInstanceId={targetInstanceId}
382 />
383 );
340}384}
341385
342function PrimarySessionGatedProviderSettings({386function PrimarySessionGatedProviderSettings({
343 environment,387 environment,
344 deviceTabs,388 deviceTabs,
389 targetInstanceId,
345}: {390}: {
346 readonly environment: EnvironmentPresentation;391 readonly environment: EnvironmentPresentation;
347 readonly deviceTabs?: ReactNode;392 readonly deviceTabs?: ReactNode;
393 readonly targetInstanceId?: ProviderInstanceId | undefined;
348}) {394}) {
349 const primarySessionState = usePrimarySessionState();395 const primarySessionState = usePrimarySessionState();
350 const operateAccess = resolvePrimaryOperateAccess({396 const operateAccess = resolvePrimaryOperateAccess({
...@@ -359,6 +405,7 @@ function PrimarySessionGatedProviderSettings({...@@ -359,6 +405,7 @@ function PrimarySessionGatedProviderSettings({
359 environment={environment}405 environment={environment}
360 operateAccess={operateAccess}406 operateAccess={operateAccess}
361 deviceTabs={deviceTabs}407 deviceTabs={deviceTabs}
408 targetInstanceId={targetInstanceId}
362 />409 />
363 );410 );
364}411}
...@@ -366,9 +413,11 @@ function PrimarySessionGatedProviderSettings({...@@ -366,9 +413,11 @@ function PrimarySessionGatedProviderSettings({
366function RemoteSessionGatedProviderSettings({413function RemoteSessionGatedProviderSettings({
367 environment,414 environment,
368 deviceTabs,415 deviceTabs,
416 targetInstanceId,
369}: {417}: {
370 readonly environment: EnvironmentPresentation;418 readonly environment: EnvironmentPresentation;
371 readonly deviceTabs?: ReactNode;419 readonly deviceTabs?: ReactNode;
420 readonly targetInstanceId?: ProviderInstanceId | undefined;
372}) {421}) {
373 const sessionState = useEnvironmentSessionState(environment.environmentId);422 const sessionState = useEnvironmentSessionState(environment.environmentId);
374 const operateAccess = resolveRemoteOperateAccess({423 const operateAccess = resolveRemoteOperateAccess({
...@@ -381,6 +430,7 @@ function RemoteSessionGatedProviderSettings({...@@ -381,6 +430,7 @@ function RemoteSessionGatedProviderSettings({
381 environment={environment}430 environment={environment}
382 operateAccess={operateAccess}431 operateAccess={operateAccess}
383 deviceTabs={deviceTabs}432 deviceTabs={deviceTabs}
433 targetInstanceId={targetInstanceId}
384 />434 />
385 );435 );
386}436}
...@@ -389,10 +439,12 @@ function AccessGatedProviderSettings({...@@ -389,10 +439,12 @@ function AccessGatedProviderSettings({
389 environment,439 environment,
390 operateAccess,440 operateAccess,
391 deviceTabs,441 deviceTabs,
442 targetInstanceId,
392}: {443}: {
393 readonly environment: EnvironmentPresentation;444 readonly environment: EnvironmentPresentation;
394 readonly operateAccess: ProviderOperateAccess;445 readonly operateAccess: ProviderOperateAccess;
395 readonly deviceTabs?: ReactNode;446 readonly deviceTabs?: ReactNode;
447 readonly targetInstanceId?: ProviderInstanceId | undefined;
396}) {448}) {
397 const access = classifyProviderEnvironmentAccess({449 const access = classifyProviderEnvironmentAccess({
398 connectionPhase: environment.connection.phase,450 connectionPhase: environment.connection.phase,
...@@ -414,6 +466,7 @@ function AccessGatedProviderSettings({...@@ -414,6 +466,7 @@ function AccessGatedProviderSettings({
414 environmentLabel={environment.label}466 environmentLabel={environment.label}
415 readOnly={access.kind === "read-only"}467 readOnly={access.kind === "read-only"}
416 deviceTabs={deviceTabs}468 deviceTabs={deviceTabs}
469 targetInstanceId={targetInstanceId}
417 />470 />
418 );471 );
419}472}
...@@ -423,10 +476,12 @@ export function EnvironmentProviderSettings({...@@ -423,10 +476,12 @@ export function EnvironmentProviderSettings({
423 environmentLabel,476 environmentLabel,
424 readOnly = false,477 readOnly = false,
425 deviceTabs,478 deviceTabs,
479 targetInstanceId,
426}: {480}: {
427 readonly environmentId: EnvironmentId;481 readonly environmentId: EnvironmentId;
428 readonly environmentLabel: string;482 readonly environmentLabel: string;
429 readonly deviceTabs?: ReactNode;483 readonly deviceTabs?: ReactNode;
484 readonly targetInstanceId?: ProviderInstanceId | undefined;
430 /**485 /**
431 * Grey out and freeze every write control when this session's credential486 * Grey out and freeze every write control when this session's credential
432 * lacks `orchestration:operate` on the environment. Selecting providers and487 * lacks `orchestration:operate` on the environment. Selecting providers and
...@@ -448,7 +503,9 @@ export function EnvironmentProviderSettings({...@@ -448,7 +503,9 @@ export function EnvironmentProviderSettings({
448 });503 });
449 const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);504 const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
450 const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false);505 const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false);
451 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | null>(null);506 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | null>(
507 targetInstanceId ?? null,
508 );
452 const [advancedOpen, setAdvancedOpen] = useState(false);509 const [advancedOpen, setAdvancedOpen] = useState(false);
453 const searchTargetId = useSettingsSearchTargetId();510 const searchTargetId = useSettingsSearchTargetId();
454 const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState<511 const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState<
...@@ -504,7 +561,7 @@ export function EnvironmentProviderSettings({...@@ -504,7 +561,7 @@ export function EnvironmentProviderSettings({
504 void (async () => {561 void (async () => {
505 const result = await refreshServerProviders({562 const result = await refreshServerProviders({
506 environmentId,563 environmentId,
507 input: {},564 input: { refreshModels: true },
508 });565 });
509 refreshingRef.current = false;566 refreshingRef.current = false;
510 setIsRefreshingProviders(false);567 setIsRefreshingProviders(false);
...@@ -653,7 +710,13 @@ export function EnvironmentProviderSettings({...@@ -653,7 +710,13 @@ export function EnvironmentProviderSettings({
653 }710 }
654 }711 }
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
658 const updateProviderInstance = (721 const updateProviderInstance = (
659 row: InstanceRow,722 row: InstanceRow,
...@@ -786,6 +849,21 @@ export function EnvironmentProviderSettings({...@@ -786,6 +849,21 @@ export function EnvironmentProviderSettings({
786 selected={mode === "list" && selectedRow?.instanceId === row.instanceId}849 selected={mode === "list" && selectedRow?.instanceId === row.instanceId}
787 onSelect={mode === "list" ? () => setSelectedInstanceId(row.instanceId) : undefined}850 onSelect={mode === "list" ? () => setSelectedInstanceId(row.instanceId) : undefined}
788 readOnly={readOnly}851 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 }
789 onUpdate={(next) => {867 onUpdate={(next) => {
790 const wasEnabled = resolveProviderInstanceEnabled(row.instance);868 const wasEnabled = resolveProviderInstanceEnabled(row.instance);
791 const isDisabling = next.enabled === false && wasEnabled;869 const isDisabling = next.enabled === false && wasEnabled;
...@@ -861,16 +939,17 @@ export function EnvironmentProviderSettings({...@@ -861,16 +939,17 @@ export function EnvironmentProviderSettings({
861 size="xs"939 size="xs"
862 variant="ghost-muted"940 variant="ghost-muted"
863 disabled={isRefreshingProviders}941 disabled={isRefreshingProviders}
942 aria-busy={isRefreshingProviders}
864 onClick={() => void refreshProviders()}943 onClick={() => void refreshProviders()}
865 >944 >
866 {isRefreshingProviders ? (945 <RefreshCwIcon />
867 <LoaderIcon className="animate-spin" />
868 ) : (
869 <RefreshCwIcon />
870 )}
871 <span className="sr-only">Refresh provider status</span>946 <span className="sr-only">Refresh provider status</span>
872 <span className="hidden min-w-0 truncate sm:inline">947 <span className="hidden min-w-0 truncate sm:inline">
873 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />948 {isRefreshingProviders ? (
949 "Refreshing providers"
950 ) : (
951 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
952 )}
874 </span>953 </span>
875 </Button>954 </Button>
876 }955 }
...@@ -922,7 +1001,11 @@ export function EnvironmentProviderSettings({...@@ -922,7 +1001,11 @@ export function EnvironmentProviderSettings({
922 {selectedRow ? (1001 {selectedRow ? (
923 renderProviderInstance(selectedRow, "editor")1002 renderProviderInstance(selectedRow, "editor")
924 ) : (1003 ) : (
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>
926 )}1009 )}
927 </div>1010 </div>
928 </div>1011 </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 @@...@@ -1,5 +1,5 @@
1import { ArchiveIcon, ArchiveX, ChevronRightIcon, LoaderIcon, SettingsIcon } from "lucide-react";1import { ArchiveIcon, ArchiveX, ChevronRightIcon, LoaderIcon, SettingsIcon } from "lucide-react";
2import { Link } from "@tanstack/react-router";2import { Link, useNavigate } from "@tanstack/react-router";
3import type { CSSProperties, ReactNode } from "react";3import type { CSSProperties, ReactNode } from "react";
4import { useCallback, useEffect, useMemo, useRef, useState } from "react";4import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5import { useAtomValue } from "@effect/atom-react";5import { useAtomValue } from "@effect/atom-react";
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 type BackgroundActivityProfile,7 type BackgroundActivityProfile,
8 type DesktopUpdateChannel,8 type DesktopUpdateChannel,
9 ProviderDriverKind,9 ProviderDriverKind,
10 type ProviderInstanceId,
10 type ScopedThreadRef,11 type ScopedThreadRef,
11 type SidebarProjectGroupingMode,12 type SidebarProjectGroupingMode,
12} from "@t3tools/contracts";13} from "@t3tools/contracts";
...@@ -89,6 +90,7 @@ import {...@@ -89,6 +90,7 @@ import {
89 primaryServerProvidersAtom,90 primaryServerProvidersAtom,
90} from "../../state/server";91} from "../../state/server";
91import { useProjects } from "../../state/entities";92import { useProjects } from "../../state/entities";
93import { usePrimaryEnvironmentId } from "../../state/environments";
92import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";94import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
93import { formatRelativeTimeLabel } from "../../timestampFormat";95import { formatRelativeTimeLabel } from "../../timestampFormat";
94import { Button } from "../ui/button";96import { Button } from "../ui/button";
...@@ -1998,6 +2000,8 @@ function LegacyFeaturesSection() {...@@ -1998,6 +2000,8 @@ function LegacyFeaturesSection() {
1998export function GeneralSettingsPanel() {2000export function GeneralSettingsPanel() {
1999 const settings = usePrimarySettings();2001 const settings = usePrimarySettings();
2000 const updateSettings = useUpdatePrimarySettings();2002 const updateSettings = useUpdatePrimarySettings();
2003 const navigate = useNavigate();
2004 const environmentId = usePrimaryEnvironmentId();
2001 const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false);2005 const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false);
2002 const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>(2006 const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>(
2003 readLastEnabledProjectGroupingMode(),2007 readLastEnabledProjectGroupingMode(),
...@@ -2014,12 +2018,21 @@ export function GeneralSettingsPanel() {...@@ -2014,12 +2018,21 @@ export function GeneralSettingsPanel() {
2014 otlpMetricsUrl: observability?.otlpMetricsUrl,2018 otlpMetricsUrl: observability?.otlpMetricsUrl,
2015 });2019 });
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 );
2018 const textGenInstanceId = textGenerationModelSelection.instanceId;2028 const textGenInstanceId = textGenerationModelSelection.instanceId;
2019 const textGenModel = textGenerationModelSelection.model;2029 const textGenModel = textGenerationModelSelection.model;
2020 const textGenModelOptions = textGenerationModelSelection.options;2030 const textGenModelOptions = textGenerationModelSelection.options;
2021 const textGenerationModelInstanceEntries = sortProviderInstanceEntries(2031 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,
2023 );2036 );
2024 const textGenInstanceEntry = textGenerationModelInstanceEntries.find(2037 const textGenInstanceEntry = textGenerationModelInstanceEntries.find(
2025 (entry) => entry.instanceId === textGenInstanceId,2038 (entry) => entry.instanceId === textGenInstanceId,
...@@ -2028,7 +2041,7 @@ export function GeneralSettingsPanel() {...@@ -2028,7 +2041,7 @@ export function GeneralSettingsPanel() {
2028 textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND;2041 textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND;
2029 const textGenerationModelOptionsByInstance = getCustomModelOptionsByInstance(2042 const textGenerationModelOptionsByInstance = getCustomModelOptionsByInstance(
2030 settings,2043 settings,
2031 serverProviders,2044 textGenerationProviders,
2032 textGenInstanceId,2045 textGenInstanceId,
2033 textGenModel,2046 textGenModel,
2034 );2047 );
...@@ -2711,61 +2724,79 @@ export function GeneralSettingsPanel() {...@@ -2711,61 +2724,79 @@ export function GeneralSettingsPanel() {
2711 ) : null2724 ) : null
2712 }2725 }
2713 control={2726 control={
2714 <div className="flex flex-wrap items-center justify-end gap-1.5">2727 !hasTextGenerationProvider ? (
2715 <ProviderModelPicker2728 <span className="text-sm text-muted-foreground">
2716 activeInstanceId={textGenInstanceId}2729 No text generation providers available.
2717 model={textGenModel}2730 </span>
2718 lockedProvider={null}2731 ) : (
2719 instanceEntries={textGenerationModelInstanceEntries}2732 <div className="flex flex-wrap items-center justify-end gap-1.5">
2720 modelOptionsByInstance={textGenerationModelOptionsByInstance}2733 <ProviderModelPicker
2721 triggerVariant="outline"2734 activeInstanceId={textGenInstanceId}
2722 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}2735 model={textGenModel}
2723 onInstanceModelChange={(instanceId, model) => {2736 lockedProvider={null}
2724 updateSettings({2737 instanceEntries={textGenerationModelInstanceEntries}
2725 textGenerationModelSelection: resolveAppModelSelectionState(2738 modelOptionsByInstance={textGenerationModelOptionsByInstance}
2726 {2739 triggerVariant="outline"
2727 ...settings,2740 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
2728 textGenerationModelSelection: createModelSelection(instanceId, model),2741 {...(environmentId
2729 },2742 ? {
2730 serverProviders,2743 onOpenProviderSetup: (instanceId: ProviderInstanceId) => {
2731 ),2744 void navigate({
2732 });2745 to: "/settings/providers",
2733 }}2746 search: { environmentId, instanceId },
2734 />2747 });
2735 <TraitsPicker2748 },
2736 provider={textGenProvider}2749 }
2737 models={2750 : {})}
2738 // Use the exact instance's models (rather than the2751 onInstanceModelChange={(instanceId, model) => {
2739 // first-kind-match) so a custom text-gen instance like2752 updateSettings({
2740 // `codex_personal` gets its own model list, not the2753 textGenerationModelSelection: resolveAppModelSelectionState(
2741 // default Codex one.2754 {
2742 textGenInstanceEntry?.models ?? []2755 ...settings,
2743 }2756 textGenerationModelSelection: createModelSelection(instanceId, model),
2744 model={textGenModel}2757 },
2745 prompt=""2758 textGenerationProviders,
2746 onPromptChange={() => {}}2759 ),
2747 modelOptions={textGenModelOptions}2760 });
2748 allowPromptInjectedEffort={false}2761 }}
2749 planModeEnabled={settings.planModeEnabled}2762 />
2750 triggerVariant="outline"2763 {textGenInstanceEntry ? (
2751 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}2764 <TraitsPicker
2752 onModelOptionsChange={(nextOptions) => {2765 provider={textGenProvider}
2753 updateSettings({2766 models={
2754 textGenerationModelSelection: resolveAppModelSelectionState(2767 // Use the exact instance's models (rather than the
2755 {2768 // first-kind-match) so a custom text-gen instance like
2756 ...settings,2769 // `codex_personal` gets its own model list, not the
2757 textGenerationModelSelection: createModelSelection(2770 // default Codex one.
2758 textGenInstanceId,2771 textGenInstanceEntry?.models ?? []
2759 textGenModel,2772 }
2760 nextOptions,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,
2761 ),2793 ),
2762 },2794 });
2763 serverProviders,2795 }}
2764 ),2796 />
2765 });2797 ) : null}
2766 }}2798 </div>
2767 />2799 )
2768 </div>
2769 }2800 }
2770 />2801 />
2771 </SettingsSection>2802 </SettingsSection>
apps/web/src/components/settings/SourceControlWritingSettings.tsx+41-12
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { useNavigate } from "@tanstack/react-router";
2import { useRef } from "react";3import { useRef } from "react";
3import type { SourceControlWritingStyleMode } from "@t3tools/contracts";4import type { ProviderInstanceId, SourceControlWritingStyleMode } from "@t3tools/contracts";
4import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";5import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
5import { createModelSelection } from "@t3tools/shared/model";6import { createModelSelection } from "@t3tools/shared/model";
6import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings";7import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings";
...@@ -16,6 +17,7 @@ import {...@@ -16,6 +17,7 @@ import {
16 resolveAppModelSelectionState,17 resolveAppModelSelectionState,
17} from "../../modelSelection";18} from "../../modelSelection";
18import { primaryServerProvidersAtom } from "../../state/server";19import { primaryServerProvidersAtom } from "../../state/server";
20import { usePrimaryEnvironmentId } from "../../state/environments";
19import { ProviderModelPicker } from "../chat/ProviderModelPicker";21import { ProviderModelPicker } from "../chat/ProviderModelPicker";
20import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";22import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
21import { Switch } from "../ui/switch";23import { Switch } from "../ui/switch";
...@@ -49,6 +51,8 @@ const MODE_OPTIONS: Record<SourceControlWritingStyleMode, { label: string; descr...@@ -49,6 +51,8 @@ const MODE_OPTIONS: Record<SourceControlWritingStyleMode, { label: string; descr
49export function SourceControlWritingSettingsSection() {51export function SourceControlWritingSettingsSection() {
50 const settings = usePrimarySettings();52 const settings = usePrimarySettings();
51 const updateSettings = useUpdatePrimarySettings();53 const updateSettings = useUpdatePrimarySettings();
54 const navigate = useNavigate();
55 const environmentId = usePrimaryEnvironmentId();
52 const serverProviders = useAtomValue(primaryServerProvidersAtom);56 const serverProviders = useAtomValue(primaryServerProvidersAtom);
53 const customInstructionsRef = useRef<HTMLTextAreaElement>(null);57 const customInstructionsRef = useRef<HTMLTextAreaElement>(null);
54 const style = settings.sourceControlWritingStyle;58 const style = settings.sourceControlWritingStyle;
...@@ -56,22 +60,31 @@ export function SourceControlWritingSettingsSection() {...@@ -56,22 +60,31 @@ export function SourceControlWritingSettingsSection() {
56 const isSourceControlWritingStyleDirty =60 const isSourceControlWritingStyleDirty =
57 style.mode !== defaults.mode || style.customInstructions !== defaults.customInstructions;61 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);
60 const usesDedicatedModel = settings.sourceControlWriterModelSelection !== null;67 const usesDedicatedModel = settings.sourceControlWriterModelSelection !== null;
61 const resolvedSourceControlWriterSelection = resolveSourceControlWriterModelSelection(68 const activeSelection = resolveAppModelSelectionState(
62 settings,69 {
63 serverProviders,70 ...settings,
71 textGenerationModelSelection: resolveSourceControlWriterModelSelection(
72 settings,
73 textGenerationProviders,
74 ),
75 },
76 textGenerationProviders,
64 );77 );
65 const activeSelection =
66 resolvedSourceControlWriterSelection === settings.textGenerationModelSelection
67 ? defaultModelSelection
68 : resolvedSourceControlWriterSelection;
69 const instanceEntries = sortProviderInstanceEntries(78 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,
71 );84 );
72 const modelOptionsByInstance = getCustomModelOptionsByInstance(85 const modelOptionsByInstance = getCustomModelOptionsByInstance(
73 settings,86 settings,
74 serverProviders,87 textGenerationProviders,
75 activeSelection.instanceId,88 activeSelection.instanceId,
76 activeSelection.model,89 activeSelection.model,
77 );90 );
...@@ -186,7 +199,12 @@ export function SourceControlWritingSettingsSection() {...@@ -186,7 +199,12 @@ export function SourceControlWritingSettingsSection() {
186 description="Optional model override for change descriptions, change request titles and descriptions, and branch or bookmark names. Off uses the global text generation model."199 description="Optional model override for change descriptions, change request titles and descriptions, and branch or bookmark names. Off uses the global text generation model."
187 control={200 control={
188 <div className="flex flex-wrap items-center justify-end gap-2">201 <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 ? (
190 <ProviderModelPicker208 <ProviderModelPicker
191 activeInstanceId={activeSelection.instanceId}209 activeInstanceId={activeSelection.instanceId}
192 model={activeSelection.model}210 model={activeSelection.model}
...@@ -196,6 +214,16 @@ export function SourceControlWritingSettingsSection() {...@@ -196,6 +214,16 @@ export function SourceControlWritingSettingsSection() {
196 triggerVariant="outline"214 triggerVariant="outline"
197 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}215 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
198 triggerAriaLabel="Source control writer model"216 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 : {})}
199 onInstanceModelChange={(instanceId, model) => {227 onInstanceModelChange={(instanceId, model) => {
200 updateSettings({228 updateSettings({
201 sourceControlWriterModelSelection: createModelSelection(instanceId, model),229 sourceControlWriterModelSelection: createModelSelection(instanceId, model),
...@@ -205,6 +233,7 @@ export function SourceControlWritingSettingsSection() {...@@ -205,6 +233,7 @@ export function SourceControlWritingSettingsSection() {
205 ) : null}233 ) : null}
206 <Switch234 <Switch
207 checked={usesDedicatedModel}235 checked={usesDedicatedModel}
236 disabled={!usesDedicatedModel && !canEnableDedicatedModel}
208 onCheckedChange={(checked) =>237 onCheckedChange={(checked) =>
209 updateSettings({238 updateSettings({
210 sourceControlWriterModelSelection: checked239 sourceControlWriterModelSelection: checked
apps/web/src/components/settings/providerDriverMeta.ts+16-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 AntigravitySettings,
2 ClaudeSettings,3 ClaudeSettings,
3 CodexSettings,4 CodexSettings,
4 CursorSettings,5 CursorSettings,
...@@ -7,7 +8,15 @@ import {...@@ -7,7 +8,15 @@ import {
7 ProviderDriverKind,8 ProviderDriverKind,
8} from "@t3tools/contracts";9} from "@t3tools/contracts";
9import type * as Schema from "effect/Schema";10import 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
12type ProviderSettingsSchema = {21type ProviderSettingsSchema = {
13 readonly fields: Readonly<Record<string, Schema.Top>>;22 readonly fields: Readonly<Record<string, Schema.Top>>;
...@@ -67,6 +76,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] =...@@ -67,6 +76,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] =
67 icon: OpenCodeIcon,76 icon: OpenCodeIcon,
68 settingsSchema: OpenCodeSettings,77 settingsSchema: OpenCodeSettings,
69 },78 },
79 {
80 value: ProviderDriverKind.make("antigravity"),
81 label: "Antigravity",
82 icon: AntigravityIcon,
83 settingsSchema: AntigravitySettings,
84 },
70];85];
7186
72export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial<87export 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) {...@@ -87,7 +87,15 @@ export function getProviderSummary(provider: ServerProvider | undefined) {
87 */87 */
88export function getProviderVersionLabel(version: string | null | undefined) {88export function getProviderVersionLabel(version: string | null | undefined) {
89 if (!version) return null;89 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;
91}99}
92100
93export function getProviderVersionAdvisoryPresentation(101export function getProviderVersionAdvisoryPresentation(
apps/web/src/components/settings/settingsSearch.test.ts+2
...@@ -87,6 +87,8 @@ describe("searchSettings", () => {...@@ -87,6 +87,8 @@ describe("searchSettings", () => {
87 expect(searchSettings("push notifications")[0]?.id).toBe("publish-agent-activity");87 expect(searchSettings("push notifications")[0]?.id).toBe("publish-agent-activity");
88 expect(searchSettings("battery saver")[0]?.id).toBe("background-activity");88 expect(searchSettings("battery saver")[0]?.id).toBe("background-activity");
89 expect(searchSettings("binary path")[0]?.id).toBe("providers");89 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");
90 expect(searchSettings("authorized clients")[0]?.id).toBe("connections-environment");92 expect(searchSettings("authorized clients")[0]?.id).toBe("connections-environment");
91 expect(searchSettings("administrative access")[0]?.id).toBe("connections-environment");93 expect(searchSettings("administrative access")[0]?.id).toBe("connections-environment");
92 });94 });
apps/web/src/components/settings/settingsSearch.ts+1-1
...@@ -313,7 +313,7 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -313,7 +313,7 @@ export const SETTINGS_SEARCH_ITEMS = [
313 title: "Providers",313 title: "Providers",
314 to: "/settings/providers",314 to: "/settings/providers",
315 searchTerms: [315 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",
317 ],317 ],
318 },318 },
319 {319 {
apps/web/src/composerDraftStore.ts+8-1
...@@ -1147,6 +1147,8 @@ export function deriveEffectiveComposerModelState(input: {...@@ -1147,6 +1147,8 @@ export function deriveEffectiveComposerModelState(input: {
1147 { preserveUnavailableSelection: preserveThreadModel },1147 { preserveUnavailableSelection: preserveThreadModel },
1148 )1148 )
1149 : null) ??1149 : null) ??
1150 // Antigravity has no static model or cross-account catalog fallback.
1151 (input.selectedProvider === "antigravity" && input.selectedInstanceId ? "" : null) ??
1150 resolveAppModelSelection(1152 resolveAppModelSelection(
1151 input.selectedProvider,1153 input.selectedProvider,
1152 input.settings,1154 input.settings,
...@@ -1163,7 +1165,11 @@ export function deriveEffectiveComposerModelState(input: {...@@ -1163,7 +1165,11 @@ export function deriveEffectiveComposerModelState(input: {
1163 ? input.draft?.modelSelectionByProvider?.[input.selectedInstanceId]1165 ? input.draft?.modelSelectionByProvider?.[input.selectedInstanceId]
1164 : undefined;1166 : undefined;
1165 const legacySelection =1167 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)];
1167 const activeSelection = instanceSelection ?? legacySelection;1173 const activeSelection = instanceSelection ?? legacySelection;
1168 const activeSelectionInstanceId = instanceSelection1174 const activeSelectionInstanceId = instanceSelection
1169 ? (input.selectedInstanceId ?? ProviderInstanceId.make(input.selectedProvider))1175 ? (input.selectedInstanceId ?? ProviderInstanceId.make(input.selectedProvider))
...@@ -1176,6 +1182,7 @@ export function deriveEffectiveComposerModelState(input: {...@@ -1176,6 +1182,7 @@ export function deriveEffectiveComposerModelState(input: {
1176 activeSelection.model,1182 activeSelection.model,
1177 { preserveUnavailableSelection: true },1183 { preserveUnavailableSelection: true },
1178 ) ??1184 ) ??
1185 (input.selectedProvider === "antigravity" ? "" : null) ??
1179 resolveAppModelSelection(1186 resolveAppModelSelection(
1180 input.selectedProvider,1187 input.selectedProvider,
1181 input.settings,1188 input.settings,
apps/web/src/modelSelection.test.ts+319-124
...@@ -1,10 +1,15 @@...@@ -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";
2import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings";7import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings";
3import { describe, expect, it } from "vite-plus/test";8import { describe, expect, it } from "vite-plus/test";
4import { createModelSelection } from "@t3tools/shared/model";9import { createModelSelection } from "@t3tools/shared/model";
5import { deriveEffectiveComposerModelState } from "./composerDraftStore";10import { deriveEffectiveComposerModelState } from "./composerDraftStore";
6import { getComposerProviderState } from "./components/chat/composerProviderState";11import { getComposerProviderState } from "./components/chat/composerProviderState";
7import { deriveProviderInstanceEntries } from "./providerInstances";12import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "./providerInstances";
8import {13import {
9 getCustomModelOptionsByInstance,14 getCustomModelOptionsByInstance,
10 getAppModelOptionsForInstance,15 getAppModelOptionsForInstance,
...@@ -334,134 +339,147 @@ describe("instance-scoped model selection", () => {...@@ -334,134 +339,147 @@ describe("instance-scoped model selection", () => {
334 ).toBe("claude-sonnet-4-6");339 ).toBe("claude-sonnet-4-6");
335 });340 });
336341
337 it("preserves an existing OpenCode model when a catalog refresh no longer contains it", () => {342 describe.each([
338 const providers = [343 {
339 provider({344 driverName: "opencode",
340 provider: ProviderDriverKind.make("opencode"),345 availableModel: "opencode/big-pickle",
341 instanceId: "opencode",346 missingModel: "opencode/kimi-k3",
342 models: ["opencode/big-pickle"],347 },
343 }),348 {
344 ];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(382 it("adds the selected missing model as an unavailable option", () => {
347 resolveAppModelSelectionForInstance(383 const providers = [
348 ProviderInstanceId.make("opencode"),384 provider({
349 settingsWithProviderInstances(),385 provider: ProviderDriverKind.make(driverName),
350 providers,386 instanceId: driverName,
351 "opencode/kimi-k3",387 models: [availableModel],
352 { preserveUnavailableSelection: true },388 }),
353 ),389 ];
354 ).toBe("opencode/kimi-k3");390 const entry = deriveProviderInstanceEntries(providers)[0]!;
355 expect(391
356 resolveAppModelSelectionForInstance(392 expect(
357 ProviderInstanceId.make("opencode"),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(
358 settingsWithProviderInstances(),413 settingsWithProviderInstances(),
359 providers,414 providers,
360 "opencode/kimi-k3",415 selectedInstanceId,
361 ),416 missingModel,
362 ).toBe("opencode/big-pickle");417 );
363 });418
364419 expect(options.get(selectedInstanceId)).toEqual([
365 it("adds the selected missing OpenCode model as an unavailable option", () => {420 expect.objectContaining({ slug: missingModel, isUnavailable: true }),
366 const providers = [421 ]);
367 provider({422 expect(options.get(otherInstanceId)).toEqual([]);
368 provider: ProviderDriverKind.make("opencode"),423 });
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 ];
418424
419 expect(425 it("replaces the unavailable marker with catalog metadata after recovery", () => {
420 getAppModelOptionsForInstance(426 const instanceId = ProviderInstanceId.make(driverName);
421 settingsWithProviderInstances(),427 const driver = ProviderDriverKind.make(driverName);
422 deriveProviderInstanceEntries(pendingProviders)[0]!,428 const selectedModel = missingModel;
423 selectedModel,429 const pendingProviders = [
424 ).find((option) => option.slug === selectedModel)?.isUnavailable,430 provider({ provider: driver, instanceId, models: [availableModel] }),
425 ).toBe(true);431 ];
426 expect(432 const recoveredProviders = [
427 getAppModelOptionsForInstance(433 provider({ provider: driver, instanceId, models: [availableModel, selectedModel] }),
428 settingsWithProviderInstances(),434 ];
429 deriveProviderInstanceEntries(recoveredProviders)[0]!,435
430 selectedModel,436 expect(
431 ).find((option) => option.slug === selectedModel)?.isUnavailable,437 getAppModelOptionsForInstance(
432 ).toBeUndefined();438 settingsWithProviderInstances(),
433 expect(439 deriveProviderInstanceEntries(pendingProviders)[0]!,
434 resolveAppModelSelectionForInstance(440 selectedModel,
435 instanceId,441 ).find((option) => option.slug === selectedModel)?.isUnavailable,
436 settingsWithProviderInstances(),442 ).toBe(true);
437 recoveredProviders,443 expect(
438 selectedModel,444 getAppModelOptionsForInstance(
439 { preserveUnavailableSelection: true },445 settingsWithProviderInstances(),
440 ),446 deriveProviderInstanceEntries(recoveredProviders)[0]!,
441 ).toBe(selectedModel);447 selectedModel,
442 });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", () => {461 it("does not resurrect a hidden model when the raw catalog omits it", () => {
445 const instanceId = ProviderInstanceId.make("opencode");462 const instanceId = ProviderInstanceId.make(driverName);
446 const driver = ProviderDriverKind.make("opencode");463 const driver = ProviderDriverKind.make(driverName);
447 const settings: UnifiedSettings = {464 const settings: UnifiedSettings = {
448 ...settingsWithProviderInstances(),465 ...settingsWithProviderInstances(),
449 providerModelPreferences: {466 providerModelPreferences: {
450 [instanceId]: {467 [instanceId]: {
451 hiddenModels: ["opencode/kimi-k3"],468 hiddenModels: [missingModel],
452 modelOrder: [],469 modelOrder: [],
470 },
453 },471 },
454 },472 };
455 };473 const providers = [provider({ provider: driver, instanceId, models: [] })];
456 const providers = [provider({ provider: driver, instanceId, models: [] })];474 const entry = deriveProviderInstanceEntries(providers)[0]!;
457 const entry = deriveProviderInstanceEntries(providers)[0]!;475
458476 expect(getAppModelOptionsForInstance(settings, entry, missingModel)).toEqual([]);
459 expect(getAppModelOptionsForInstance(settings, entry, "opencode/kimi-k3")).toEqual([]);477 expect(
460 expect(478 resolveAppModelSelectionForInstance(instanceId, settings, providers, missingModel, {
461 resolveAppModelSelectionForInstance(instanceId, settings, providers, "opencode/kimi-k3", {479 preserveUnavailableSelection: true,
462 preserveUnavailableSelection: true,480 }),
463 }),481 ).toBeNull();
464 ).toBeNull();482 });
465 });483 });
466484
467 it("does not add unavailable options for other providers", () => {485 it("does not add unavailable options for other providers", () => {
...@@ -547,6 +565,159 @@ describe("instance-scoped model selection", () => {...@@ -547,6 +565,159 @@ describe("instance-scoped model selection", () => {
547 expect(state.modelOptions?.[instanceId]).toEqual(draftSelection.options);565 expect(state.modelOptions?.[instanceId]).toEqual(draftSelection.options);
548 });566 });
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
550 it("preserves saved options through dispatch when the model is absent from the catalog", () => {721 it("preserves saved options through dispatch when the model is absent from the catalog", () => {
551 const instanceId = ProviderInstanceId.make("opencode");722 const instanceId = ProviderInstanceId.make("opencode");
552 const driver = ProviderDriverKind.make("opencode");723 const driver = ProviderDriverKind.make("opencode");
...@@ -639,6 +810,30 @@ describe("instance-scoped model selection", () => {...@@ -639,6 +810,30 @@ describe("instance-scoped model selection", () => {
639 model: "openai/gpt-5.5",810 model: "openai/gpt-5.5",
640 });811 });
641 });812 });
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 });
642});837});
643838
644describe("withoutPlanAgentSelection", () => {839describe("withoutPlanAgentSelection", () => {
apps/web/src/modelSelection.ts+35-33
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import {1import {
2 ANTIGRAVITY_DEFAULT_MODEL,
2 DEFAULT_TEXT_GENERATION_MODEL,3 DEFAULT_TEXT_GENERATION_MODEL,
3 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER,4 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER,
4 defaultInstanceIdForDriver,5 defaultInstanceIdForDriver,
...@@ -23,7 +24,11 @@ import {...@@ -23,7 +24,11 @@ import {
23 resolveSelectableProvider,24 resolveSelectableProvider,
24} from "./providerModels";25} from "./providerModels";
25import { ModelEsque } from "./components/chat/providerIconUtils";26import { 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";
27import { sortModelsForProviderInstance } from "./modelOrdering";32import { sortModelsForProviderInstance } from "./modelOrdering";
2833
29const MAX_CUSTOM_MODEL_COUNT = 32;34const MAX_CUSTOM_MODEL_COUNT = 32;
...@@ -51,6 +56,7 @@ function readInstanceCustomModels(...@@ -51,6 +56,7 @@ function readInstanceCustomModels(
51 instanceId: ProviderInstanceId,56 instanceId: ProviderInstanceId,
52 driverKind: ProviderDriverKind,57 driverKind: ProviderDriverKind,
53): ReadonlyArray<string> {58): ReadonlyArray<string> {
59 if (driverKind === "antigravity") return [];
54 const instance = settings.providerInstances?.[instanceId];60 const instance = settings.providerInstances?.[instanceId];
55 const config = instance?.config;61 const config = instance?.config;
56 if (config !== null && typeof config === "object") {62 if (config !== null && typeof config === "object") {
...@@ -83,20 +89,21 @@ export interface AppModelOption {...@@ -83,20 +89,21 @@ export interface AppModelOption {
83 isUnavailable?: boolean;89 isUnavailable?: boolean;
84}90}
8591
86function appendUnavailableOpenCodeSelection(92function appendUnavailableDynamicModelSelection(
87 options: AppModelOption[],93 options: AppModelOption[],
88 rawModels: ReadonlyArray<ServerProvider["models"][number]>,94 rawModels: ReadonlyArray<ServerProvider["models"][number]>,
89 provider: ProviderDriverKind,95 provider: ProviderDriverKind,
90 selectedModel: string | null | undefined,96 selectedModel: string | null | undefined,
91 hiddenModels: ReadonlyArray<string>,97 hiddenModels: ReadonlyArray<string>,
92): AppModelOption[] {98): AppModelOption[] {
93 if (provider !== "opencode") return options;99 if (provider !== "opencode" && provider !== "antigravity") return options;
94 const slug = normalizeCustomModelSlug(selectedModel);100 const slug = normalizeCustomModelSlug(selectedModel);
95 if (!slug) return options;101 if (!slug) return options;
102 if (provider === "antigravity" && slug === ANTIGRAVITY_DEFAULT_MODEL) return options;
96103
97 // A model that exists in the raw catalog can be absent from `options`104 // A model that exists in the raw catalog can be absent from `options`
98 // because the user hid it. Keep that preference authoritative.105 // 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;
100 if (hiddenModels.includes(slug)) return options;107 if (hiddenModels.includes(slug)) return options;
101 if (options.some((option) => option.slug === slug)) return options;108 if (options.some((option) => option.slug === slug)) return options;
102109
...@@ -212,7 +219,7 @@ export function getAppModelOptions(...@@ -212,7 +219,7 @@ export function getAppModelOptions(
212 }219 }
213220
214 const preferences = readInstanceModelPreferences(settings, defaultInstanceId);221 const preferences = readInstanceModelPreferences(settings, defaultInstanceId);
215 return appendUnavailableOpenCodeSelection(222 return appendUnavailableDynamicModelSelection(
216 applyInstanceModelPreferences(options, preferences),223 applyInstanceModelPreferences(options, preferences),
217 rawModels,224 rawModels,
218 provider,225 provider,
...@@ -260,7 +267,7 @@ export function getAppModelOptionsForInstance(...@@ -260,7 +267,7 @@ export function getAppModelOptionsForInstance(
260 }267 }
261268
262 const preferences = readInstanceModelPreferences(settings, entry.instanceId);269 const preferences = readInstanceModelPreferences(settings, entry.instanceId);
263 return appendUnavailableOpenCodeSelection(270 return appendUnavailableDynamicModelSelection(
264 applyInstanceModelPreferences(options, preferences),271 applyInstanceModelPreferences(options, preferences),
265 entry.models,272 entry.models,
266 entry.driverKind,273 entry.driverKind,
...@@ -303,20 +310,22 @@ export function resolveAppModelSelectionForInstance(...@@ -303,20 +310,22 @@ export function resolveAppModelSelectionForInstance(
303 if (resolvedSelection) {310 if (resolvedSelection) {
304 return resolvedSelection;311 return resolvedSelection;
305 }312 }
306 if (resolutionOptions?.preserveUnavailableSelection && entry.driverKind === "opencode") {313 if (
314 resolutionOptions?.preserveUnavailableSelection &&
315 (entry.driverKind === "opencode" || entry.driverKind === "antigravity")
316 ) {
307 const unavailableSelection = normalizeCustomModelSlug(selectedModel);317 const unavailableSelection = normalizeCustomModelSlug(selectedModel);
308 const hiddenModels = readInstanceModelPreferences(settings, entry.instanceId).hiddenModels;318 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 ) {
310 return unavailableSelection;325 return unavailableSelection;
311 }326 }
312 }327 }
313 return (328 return options.find((option) => option.isDefault)?.slug ?? options[0]?.slug ?? null;
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 );
320}329}
321330
322/**331/**
...@@ -397,7 +406,10 @@ export function resolveAppModelSelectionState(...@@ -397,7 +406,10 @@ export function resolveAppModelSelectionState(
397 instanceId: DEFAULT_TEXT_GENERATION_INSTANCE_ID,406 instanceId: DEFAULT_TEXT_GENERATION_INSTANCE_ID,
398 model: DEFAULT_TEXT_GENERATION_MODEL,407 model: DEFAULT_TEXT_GENERATION_MODEL,
399 };408 };
400 const entries = deriveProviderInstanceEntries(providers);409 const supportedProviders = providers.filter(
410 (provider) => provider.supportsTextGeneration !== false,
411 );
412 const entries = deriveProviderInstanceEntries(supportedProviders);
401 const selectedEntry = entries.find(413 const selectedEntry = entries.find(
402 (entry) => entry.instanceId === selection.instanceId && entry.enabled && entry.isAvailable,414 (entry) => entry.instanceId === selection.instanceId && entry.enabled && entry.isAvailable,
403 );415 );
...@@ -408,7 +420,12 @@ export function resolveAppModelSelectionState(...@@ -408,7 +420,12 @@ export function resolveAppModelSelectionState(
408 // don't carry over the old instance's model — use the fallback instance's default.420 // don't carry over the old instance's model — use the fallback instance's default.
409 const selectedModel = selectedEntry ? selection.model : null;421 const selectedModel = selectedEntry ? selection.model : null;
410 const model =422 const model =
411 resolveAppModelSelectionForInstance(entry.instanceId, settings, providers, selectedModel) ??423 resolveAppModelSelectionForInstance(
424 entry.instanceId,
425 settings,
426 supportedProviders,
427 selectedModel,
428 ) ??
412 entry.models[0]?.slug ??429 entry.models[0]?.slug ??
413 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind];430 DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind];
414 if (!model) {431 if (!model) {
...@@ -426,20 +443,5 @@ export function resolveAppModelSelectionState(...@@ -426,20 +443,5 @@ export function resolveAppModelSelectionState(
426 return createModelSelection(entry.instanceId, model, modelOptionsForDispatch);443 return createModelSelection(entry.instanceId, model, modelOptionsForDispatch);
427 }444 }
428445
429 const provider = resolveSelectableProvider(providers, null);446 return NO_PROVIDER_MODEL_SELECTION;
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);
445}447}
apps/web/src/pendingUserInput.test.ts+92-14
...@@ -39,11 +39,23 @@ const multiSelectQuestion = {...@@ -39,11 +39,23 @@ const multiSelectQuestion = {
39 multiSelect: true,39 multiSelect: true,
40} as const;40} 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
42describe("resolvePendingUserInputAnswer", () => {54describe("resolvePendingUserInputAnswer", () => {
43 it("prefers a custom answer over selected options", () => {55 it("prefers a custom answer over selected options", () => {
44 expect(56 expect(
45 resolvePendingUserInputAnswer(singleSelectQuestion, {57 resolvePendingUserInputAnswer(singleSelectQuestion, {
46 selectedOptionLabels: ["Orchestration-first"],58 selectedOptionValues: ["Orchestration-first"],
47 customAnswer: "Keep the existing envelope for one release",59 customAnswer: "Keep the existing envelope for one release",
48 }),60 }),
49 ).toBe("Keep the existing envelope for one release");61 ).toBe("Keep the existing envelope for one release");
...@@ -52,7 +64,7 @@ describe("resolvePendingUserInputAnswer", () => {...@@ -52,7 +64,7 @@ describe("resolvePendingUserInputAnswer", () => {
52 it("falls back to the selected option for single-select questions", () => {64 it("falls back to the selected option for single-select questions", () => {
53 expect(65 expect(
54 resolvePendingUserInputAnswer(singleSelectQuestion, {66 resolvePendingUserInputAnswer(singleSelectQuestion, {
55 selectedOptionLabels: ["Orchestration-first"],67 selectedOptionValues: ["Orchestration-first"],
56 }),68 }),
57 ).toBe("Orchestration-first");69 ).toBe("Orchestration-first");
58 });70 });
...@@ -60,7 +72,7 @@ describe("resolvePendingUserInputAnswer", () => {...@@ -60,7 +72,7 @@ describe("resolvePendingUserInputAnswer", () => {
60 it("returns all selected labels for multi-select questions", () => {72 it("returns all selected labels for multi-select questions", () => {
61 expect(73 expect(
62 resolvePendingUserInputAnswer(multiSelectQuestion, {74 resolvePendingUserInputAnswer(multiSelectQuestion, {
63 selectedOptionLabels: ["Server", "Web"],75 selectedOptionValues: ["Server", "Web"],
64 }),76 }),
65 ).toEqual(["Server", "Web"]);77 ).toEqual(["Server", "Web"]);
66 });78 });
...@@ -69,7 +81,7 @@ describe("resolvePendingUserInputAnswer", () => {...@@ -69,7 +81,7 @@ describe("resolvePendingUserInputAnswer", () => {
69 expect(81 expect(
70 setPendingUserInputCustomAnswer(82 setPendingUserInputCustomAnswer(
71 {83 {
72 selectedOptionLabels: ["Server", "Web"],84 selectedOptionValues: ["Server", "Web"],
73 },85 },
74 "doesn't matter",86 "doesn't matter",
75 ),87 ),
...@@ -77,6 +89,23 @@ describe("resolvePendingUserInputAnswer", () => {...@@ -77,6 +89,23 @@ describe("resolvePendingUserInputAnswer", () => {
77 customAnswer: "doesn't matter",89 customAnswer: "doesn't matter",
78 });90 });
79 });91 });
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 });
80});109});
81110
82describe("togglePendingUserInputOptionSelection", () => {111describe("togglePendingUserInputOptionSelection", () => {
...@@ -84,7 +113,7 @@ describe("togglePendingUserInputOptionSelection", () => {...@@ -84,7 +113,7 @@ describe("togglePendingUserInputOptionSelection", () => {
84 expect(togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Server")).toEqual(113 expect(togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Server")).toEqual(
85 {114 {
86 customAnswer: "",115 customAnswer: "",
87 selectedOptionLabels: ["Server"],116 selectedOptionValues: ["Server"],
88 },117 },
89 );118 );
90119
...@@ -92,13 +121,32 @@ describe("togglePendingUserInputOptionSelection", () => {...@@ -92,13 +121,32 @@ describe("togglePendingUserInputOptionSelection", () => {
92 togglePendingUserInputOptionSelection(121 togglePendingUserInputOptionSelection(
93 multiSelectQuestion,122 multiSelectQuestion,
94 {123 {
95 selectedOptionLabels: ["Server", "Web"],124 selectedOptionValues: ["Server", "Web"],
96 },125 },
97 "Server",126 "Server",
98 ),127 ),
99 ).toEqual({128 ).toEqual({
100 customAnswer: "",129 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"],
102 });150 });
103 });151 });
104});152});
...@@ -124,7 +172,7 @@ describe("buildPendingUserInputAnswers", () => {...@@ -124,7 +172,7 @@ describe("buildPendingUserInputAnswers", () => {
124 ],172 ],
125 {173 {
126 scope: {174 scope: {
127 selectedOptionLabels: ["Orchestration-first"],175 selectedOptionValues: ["Orchestration-first"],
128 },176 },
129 compat: {177 compat: {
130 customAnswer: "Keep the current envelope for one release window",178 customAnswer: "Keep the current envelope for one release window",
...@@ -141,7 +189,7 @@ describe("buildPendingUserInputAnswers", () => {...@@ -141,7 +189,7 @@ describe("buildPendingUserInputAnswers", () => {
141 expect(189 expect(
142 buildPendingUserInputAnswers([multiSelectQuestion], {190 buildPendingUserInputAnswers([multiSelectQuestion], {
143 areas: {191 areas: {
144 selectedOptionLabels: ["Server", "Web"],192 selectedOptionValues: ["Server", "Web"],
145 },193 },
146 }),194 }),
147 ).toEqual({195 ).toEqual({
...@@ -152,6 +200,22 @@ describe("buildPendingUserInputAnswers", () => {...@@ -152,6 +200,22 @@ describe("buildPendingUserInputAnswers", () => {
152 it("returns null when any question is unanswered", () => {200 it("returns null when any question is unanswered", () => {
153 expect(buildPendingUserInputAnswers([singleSelectQuestion], {})).toBeNull();201 expect(buildPendingUserInputAnswers([singleSelectQuestion], {})).toBeNull();
154 });202 });
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 });
155});219});
156220
157describe("pending user input question progress", () => {221describe("pending user input question progress", () => {
...@@ -175,7 +239,7 @@ describe("pending user input question progress", () => {...@@ -175,7 +239,7 @@ describe("pending user input question progress", () => {
175 expect(239 expect(
176 countAnsweredPendingUserInputQuestions(questions, {240 countAnsweredPendingUserInputQuestions(questions, {
177 scope: {241 scope: {
178 selectedOptionLabels: ["Orchestration-first"],242 selectedOptionValues: ["Orchestration-first"],
179 },243 },
180 }),244 }),
181 ).toBe(1);245 ).toBe(1);
...@@ -187,7 +251,7 @@ describe("pending user input question progress", () => {...@@ -187,7 +251,7 @@ describe("pending user input question progress", () => {
187 questions,251 questions,
188 {252 {
189 scope: {253 scope: {
190 selectedOptionLabels: ["Orchestration-first"],254 selectedOptionValues: ["Orchestration-first"],
191 },255 },
192 },256 },
193 0,257 0,
...@@ -195,7 +259,7 @@ describe("pending user input question progress", () => {...@@ -195,7 +259,7 @@ describe("pending user input question progress", () => {
195 ).toMatchObject({259 ).toMatchObject({
196 questionIndex: 0,260 questionIndex: 0,
197 activeQuestion: questions[0],261 activeQuestion: questions[0],
198 selectedOptionLabels: ["Orchestration-first"],262 selectedOptionValues: ["Orchestration-first"],
199 customAnswer: "",263 customAnswer: "",
200 resolvedAnswer: "Orchestration-first",264 resolvedAnswer: "Orchestration-first",
201 answeredQuestionCount: 1,265 answeredQuestionCount: 1,
...@@ -211,16 +275,30 @@ describe("pending user input question progress", () => {...@@ -211,16 +275,30 @@ describe("pending user input question progress", () => {
211 [multiSelectQuestion],275 [multiSelectQuestion],
212 {276 {
213 areas: {277 areas: {
214 selectedOptionLabels: ["Server", "Web"],278 selectedOptionValues: ["Server", "Web"],
215 },279 },
216 },280 },
217 0,281 0,
218 ),282 ),
219 ).toMatchObject({283 ).toMatchObject({
220 selectedOptionLabels: ["Server", "Web"],284 selectedOptionValues: ["Server", "Web"],
221 resolvedAnswer: ["Server", "Web"],285 resolvedAnswer: ["Server", "Web"],
222 canAdvance: true,286 canAdvance: true,
223 isComplete: true,287 isComplete: true,
224 });288 });
225 });289 });
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 });
226});304});
apps/web/src/pendingUserInput.ts+31-33
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import type { UserInputQuestion } from "@t3tools/contracts";1import type { UserInputQuestion } from "@t3tools/contracts";
22
3export interface PendingUserInputDraftAnswer {3export interface PendingUserInputDraftAnswer {
4 selectedOptionLabels?: string[];4 selectedOptionValues?: string[];
5 customAnswer?: string;5 customAnswer?: string;
6}6}
77
...@@ -9,7 +9,7 @@ export interface PendingUserInputProgress {...@@ -9,7 +9,7 @@ export interface PendingUserInputProgress {
9 questionIndex: number;9 questionIndex: number;
10 activeQuestion: UserInputQuestion | null;10 activeQuestion: UserInputQuestion | null;
11 activeDraft: PendingUserInputDraftAnswer | undefined;11 activeDraft: PendingUserInputDraftAnswer | undefined;
12 selectedOptionLabels: string[];12 selectedOptionValues: string[];
13 customAnswer: string;13 customAnswer: string;
14 resolvedAnswer: string | string[] | null;14 resolvedAnswer: string | string[] | null;
15 usingCustomAnswer: boolean;15 usingCustomAnswer: boolean;
...@@ -28,77 +28,72 @@ function normalizeDraftAnswer(value: string | undefined): string | null {...@@ -28,77 +28,72 @@ function normalizeDraftAnswer(value: string | undefined): string | null {
28 return trimmed.length > 0 ? trimmed : null;28 return trimmed.length > 0 ? trimmed : null;
29}29}
3030
31function normalizeSelectedOptionLabels(value: string[] | undefined): string[] {31function normalizeSelectedOptionValues(value: string[] | undefined): string[] {
32 if (!Array.isArray(value)) {32 if (!Array.isArray(value)) {
33 return [];33 return [];
34 }34 }
3535
36 const normalized: string[] = [];36 // Provider option IDs must stay unchanged, including whitespace.
37 for (const entry of value) {37 return Array.from(new Set(value.filter((entry) => typeof entry === "string")));
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));
46}38}
4739
48export function resolvePendingUserInputAnswer(40export function resolvePendingUserInputAnswer(
49 question: UserInputQuestion,41 question: UserInputQuestion,
50 draft: PendingUserInputDraftAnswer | undefined,42 draft: PendingUserInputDraftAnswer | undefined,
51): string | string[] | null {43): string | string[] | null {
52 const customAnswer = normalizeDraftAnswer(draft?.customAnswer);44 const customAnswer =
45 question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer);
53 if (customAnswer) {46 if (customAnswer) {
54 return customAnswer;47 return customAnswer;
55 }48 }
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 );
58 if (question.multiSelect) {53 if (question.multiSelect) {
59 return selectedOptionLabels.length > 0 ? selectedOptionLabels : null;54 return selectedOptionValues.length > 0 ? selectedOptionValues : null;
60 }55 }
6156
62 return selectedOptionLabels[0] ?? null;57 return selectedOptionValues[0] ?? null;
63}58}
6459
65export function setPendingUserInputCustomAnswer(60export function setPendingUserInputCustomAnswer(
66 draft: PendingUserInputDraftAnswer | undefined,61 draft: PendingUserInputDraftAnswer | undefined,
67 customAnswer: string,62 customAnswer: string,
68): PendingUserInputDraftAnswer {63): PendingUserInputDraftAnswer {
69 const selectedOptionLabels =64 const selectedOptionValues =
70 customAnswer.trim().length > 065 customAnswer.trim().length > 0
71 ? undefined66 ? undefined
72 : normalizeSelectedOptionLabels(draft?.selectedOptionLabels);67 : normalizeSelectedOptionValues(draft?.selectedOptionValues);
7368
74 return {69 return {
75 customAnswer,70 customAnswer,
76 ...(selectedOptionLabels && selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}),71 ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}),
77 };72 };
78}73}
7974
80export function togglePendingUserInputOptionSelection(75export function togglePendingUserInputOptionSelection(
81 question: UserInputQuestion,76 question: UserInputQuestion,
82 draft: PendingUserInputDraftAnswer | undefined,77 draft: PendingUserInputDraftAnswer | undefined,
83 optionLabel: string,78 optionValue: string,
84): PendingUserInputDraftAnswer {79): PendingUserInputDraftAnswer {
85 if (question.multiSelect) {80 if (question.multiSelect) {
86 const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels);81 const selectedOptionValues = normalizeSelectedOptionValues(draft?.selectedOptionValues);
87 const nextSelectedOptionLabels = selectedOptionLabels.includes(optionLabel)82 const nextSelectedOptionValues = selectedOptionValues.includes(optionValue)
88 ? selectedOptionLabels.filter((label) => label !== optionLabel)83 ? selectedOptionValues.filter((value) => value !== optionValue)
89 : [...selectedOptionLabels, optionLabel];84 : [...selectedOptionValues, optionValue];
9085
91 return {86 return {
92 customAnswer: "",87 customAnswer: "",
93 ...(nextSelectedOptionLabels.length > 088 ...(nextSelectedOptionValues.length > 0
94 ? { selectedOptionLabels: nextSelectedOptionLabels }89 ? { selectedOptionValues: nextSelectedOptionValues }
95 : {}),90 : {}),
96 };91 };
97 }92 }
9893
99 return {94 return {
100 customAnswer: "",95 customAnswer: "",
101 selectedOptionLabels: [optionLabel],96 selectedOptionValues: [optionValue],
102 };97 };
103}98}
10499
...@@ -110,7 +105,7 @@ export function buildPendingUserInputAnswers(...@@ -110,7 +105,7 @@ export function buildPendingUserInputAnswers(
110105
111 for (const question of questions) {106 for (const question of questions) {
112 const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]);107 const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]);
113 if (!answer) {108 if (answer === null) {
114 return null;109 return null;
115 }110 }
116 answers[question.id] = answer;111 answers[question.id] = answer;
...@@ -124,7 +119,9 @@ export function countAnsweredPendingUserInputQuestions(...@@ -124,7 +119,9 @@ export function countAnsweredPendingUserInputQuestions(
124 draftAnswers: Record<string, PendingUserInputDraftAnswer>,119 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
125): number {120): number {
126 return questions.reduce((count, question) => {121 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;
128 }, 0);125 }, 0);
129}126}
130127
...@@ -140,7 +137,8 @@ export function derivePendingUserInputProgress(...@@ -140,7 +137,8 @@ export function derivePendingUserInputProgress(
140 const resolvedAnswer = activeQuestion137 const resolvedAnswer = activeQuestion
141 ? resolvePendingUserInputAnswer(activeQuestion, activeDraft)138 ? resolvePendingUserInputAnswer(activeQuestion, activeDraft)
142 : null;139 : null;
143 const customAnswer = activeDraft?.customAnswer ?? "";140 const customAnswer =
141 activeQuestion?.allowCustomAnswer === false ? "" : (activeDraft?.customAnswer ?? "");
144 const answeredQuestionCount = countAnsweredPendingUserInputQuestions(questions, draftAnswers);142 const answeredQuestionCount = countAnsweredPendingUserInputQuestions(questions, draftAnswers);
145 const isLastQuestion =143 const isLastQuestion =
146 questions.length === 0 ? true : normalizedQuestionIndex >= questions.length - 1;144 questions.length === 0 ? true : normalizedQuestionIndex >= questions.length - 1;
...@@ -149,13 +147,13 @@ export function derivePendingUserInputProgress(...@@ -149,13 +147,13 @@ export function derivePendingUserInputProgress(
149 questionIndex: normalizedQuestionIndex,147 questionIndex: normalizedQuestionIndex,
150 activeQuestion,148 activeQuestion,
151 activeDraft,149 activeDraft,
152 selectedOptionLabels: normalizeSelectedOptionLabels(activeDraft?.selectedOptionLabels),150 selectedOptionValues: normalizeSelectedOptionValues(activeDraft?.selectedOptionValues),
153 customAnswer,151 customAnswer,
154 resolvedAnswer,152 resolvedAnswer,
155 usingCustomAnswer: customAnswer.trim().length > 0,153 usingCustomAnswer: customAnswer.trim().length > 0,
156 answeredQuestionCount,154 answeredQuestionCount,
157 isLastQuestion,155 isLastQuestion,
158 isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null,156 isComplete: buildPendingUserInputAnswers(questions, draftAnswers) !== null,
159 canAdvance: Boolean(resolvedAnswer),157 canAdvance: resolvedAnswer !== null,
160 };158 };
161}159}
apps/web/src/routes/settings.providers.tsx+11-1
...@@ -1,11 +1,21 @@...@@ -1,11 +1,21 @@
1import { createFileRoute } from "@tanstack/react-router";1import { createFileRoute } from "@tanstack/react-router";
2import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts";
23
3import { ProviderSettingsPanel } from "../components/settings/ProviderSettingsPanel";4import { ProviderSettingsPanel } from "../components/settings/ProviderSettingsPanel";
45
5function SettingsProvidersRoute() {6function SettingsProvidersRoute() {
6 return <ProviderSettingsPanel />;7 const target = Route.useSearch();
8 return <ProviderSettingsPanel {...target} />;
7}9}
810
9export const Route = createFileRoute("/settings/providers")({11export 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 }),
10 component: SettingsProvidersRoute,20 component: SettingsProvidersRoute,
11});21});
apps/web/src/session-logic.test.ts+24
...@@ -250,6 +250,30 @@ describe("derivePendingApprovals", () => {...@@ -250,6 +250,30 @@ describe("derivePendingApprovals", () => {
250});250});
251251
252describe("derivePendingUserInputs", () => {252describe("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
253 it("tracks open structured prompts and removes resolved ones", () => {277 it("tracks open structured prompts and removes resolved ones", () => {
254 const activities: OrchestrationThreadActivity[] = [278 const activities: OrchestrationThreadActivity[] = [
255 makeActivity({279 makeActivity({
apps/web/src/session-logic.ts+10
...@@ -61,6 +61,12 @@ export const PROVIDER_OPTIONS: Array<{...@@ -61,6 +61,12 @@ export const PROVIDER_OPTIONS: Array<{
61 available: true,61 available: true,
62 pickerSidebarBadge: "new",62 pickerSidebarBadge: "new",
63 },63 },
64 {
65 value: ProviderDriverKind.make("antigravity"),
66 label: "Antigravity",
67 available: true,
68 pickerSidebarBadge: "new",
69 },
64];70];
6571
66export type WorkLogToolLifecycleStatus =72export type WorkLogToolLifecycleStatus =
...@@ -522,6 +528,7 @@ function parseUserInputQuestions(...@@ -522,6 +528,7 @@ function parseUserInputQuestions(
522 return {528 return {
523 label: optionRecord.label,529 label: optionRecord.label,
524 description: optionRecord.description,530 description: optionRecord.description,
531 ...(typeof optionRecord.value === "string" ? { value: optionRecord.value } : {}),
525 };532 };
526 })533 })
527 .filter((option): option is UserInputQuestion["options"][number] => option !== null);534 .filter((option): option is UserInputQuestion["options"][number] => option !== null);
...@@ -534,6 +541,9 @@ function parseUserInputQuestions(...@@ -534,6 +541,9 @@ function parseUserInputQuestions(
534 question: question.question,541 question: question.question,
535 options,542 options,
536 multiSelect: question.multiSelect === true,543 multiSelect: question.multiSelect === true,
544 ...(typeof question.allowCustomAnswer === "boolean"
545 ? { allowCustomAnswer: question.allowCustomAnswer }
546 : {}),
537 };547 };
538 })548 })
539 .filter((question): question is UserInputQuestion => question !== null);549 .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...@@ -95,7 +95,7 @@ The live backend agent implementation and its event stream. The main service is
9595
96#### Provider96#### 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
100#### Session100#### Session
101101
docs/internals/overview.md+4-4
...@@ -18,13 +18,13 @@ there, never in the client....@@ -18,13 +18,13 @@ there, never in the client.
18┌──────────────────â–¼─────────────────────────────┐18┌──────────────────â–¼─────────────────────────────┐
19│ apps/server │19│ apps/server │
20│ orchestration engine (event-sourced) │20│ orchestration engine (event-sourced) │
21│ provider driver registry (5 built-in drivers) │21│ provider driver registry (6 built-in drivers) │
22│ checkpointing, VCS, terminals, filesystem │22│ checkpointing, VCS, terminals, filesystem │
23└──────────────────┬─────────────────────────────┘23└──────────────────┬─────────────────────────────┘
24 │ per-driver transport24 │ per-driver transport
25┌──────────────────â–¼─────────────────────────────┐25┌──────────────────â–¼─────────────────────────────┐
26│ Agent CLIs: Codex, Claude, Cursor, Grok, │26│ Agent CLIs: Codex, Claude, Cursor, Grok, │
27│ OpenCode │27│ OpenCode, Antigravity │
28└────────────────────────────────────────────────┘28└────────────────────────────────────────────────┘
29```29```
3030
...@@ -122,8 +122,8 @@ build production behavior on receipts....@@ -122,8 +122,8 @@ build production behavior on receipts.
122122
123## Provider drivers123## Provider drivers
124124
125Five drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`:125Six 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 a126Codex, Claude, Cursor, Grok, OpenCode, and Antigravity. A driver declares its kind and config schema and creates a
127scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves127scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves
128an instance to its adapter, so `ProviderService` routes session and turn operations without knowing128an instance to its adapter, so `ProviderService` routes session and turn operations without knowing
129which agent is behind them. See [providers.md](./providers.md).129which 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....@@ -7,15 +7,16 @@ orchestration layer does not know which one is behind a thread.
77
8## Built-in drivers8## 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 |12| Driver kind | Driver source |
13| ------------- | --------------------------------------- |13| ------------- | --------------------------------------------- |
14| `codex` | [`Drivers/CodexDriver.ts`][codex] |14| `codex` | [`Drivers/CodexDriver.ts`][codex] |
15| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] |15| `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] |
16| `cursor` | [`Drivers/CursorDriver.ts`][cursor] |16| `cursor` | [`Drivers/CursorDriver.ts`][cursor] |
17| `grok` | [`Drivers/GrokDriver.ts`][grok] |17| `grok` | [`Drivers/GrokDriver.ts`][grok] |
18| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] |18| `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] |
19| `antigravity` | [`Drivers/AntigravityDriver.ts`][antigravity] |
1920
20Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an21Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an
21adapter in a child scope. Adapter implementations live beside them in22adapter 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...@@ -54,6 +55,142 @@ list instead of persisting `error` over a working install. The built-in `grok-bu
54CLI's product name, not an ACP model id. `applyGrokAcpModelSelection` treats it as "keep the55CLI's product name, not an ACP model id. `applyGrokAcpModelSelection` treats it as "keep the
55session's current model" and never sends it in `session/set_model`.56session'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
57## OpenCode server ownership and catalog194## OpenCode server ownership and catalog
58195
59Each OpenCode provider instance owns one lazy local server for catalog discovery and196Each 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...@@ -118,9 +255,18 @@ attachment to the provider adapter. Each adapter decides what its provider inges
118255
119- Codex, Claude, Cursor, and Grok send images as native image inputs and skip generic files. For256- Codex, Claude, Cursor, and Grok send images as native image inputs and skip generic files. For
120 these providers, generic files reach the agent only as file paths in the turn text.257 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.
121- OpenCode sends PNG/JPEG/GIF/WebP images, text files, and PDFs up to 20 MB as native file parts263- OpenCode sends PNG/JPEG/GIF/WebP images, text files, and PDFs up to 20 MB as native file parts
122 with their real mime type. Everything else (ZIP and other binaries, image formats model APIs264 with their real mime type. Everything else (ZIP and other binaries, image formats model APIs
123 reject, oversized files) falls back to the file path in the turn text, like the other providers.265 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
125Claude receives the attachment directory as an allowed additional directory. Codex keeps its271Claude receives the attachment directory as an allowed additional directory. Codex keeps its
126configured sandbox policy, so access depends on that policy and the selected runtime mode. OpenCode272configured 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...@@ -180,6 +326,16 @@ when a request opens (approval) or user input is requested, via
180[cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts326[cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts
181[grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts327[grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts
182[opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts328[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
183[opencode-server-owner]: ../../apps/server/src/provider/OpenCodeServerOwner.ts339[opencode-server-owner]: ../../apps/server/src/provider/OpenCodeServerOwner.ts
184[adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts340[adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts
185[instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts341[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....@@ -6,7 +6,8 @@ T3 Code is a web and desktop GUI for running coding agents on your machine.
66
7Node.js `^22.16 || ^23.11 || >=24.10` on the machine that runs the T3 Code server.7Node.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
11## Run Without Installing12## Run Without Installing
1213
...@@ -81,19 +82,25 @@ application files under `/mnt/c` instead and reinstalls the runtime on the next...@@ -81,19 +82,25 @@ application files under `/mnt/c` instead and reinstalls the runtime on the next
8182
82## Providers83## Providers
8384
84T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want85T3 Code uses provider runtimes but does not bundle them. Install and authenticate each
85to use, then authenticate it.86provider's CLI, or use T3 Code's managed setup for Antigravity.
8687
87| Provider | CLI | Default binary | Log in with |88| Provider | CLI | Default binary | Log in with |
88| ---------- | ----------------------------------------------------- | -------------- | --------------------- |89| ----------- | ---------------------------------------------------------------------------------------------------------- | ------------------ | ---------------------------------- |
89| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` |90| 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| 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| 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| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` |
93| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth 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; turn97Codex and Claude are on by default. Cursor, Grok Build, OpenCode, and Antigravity are off by
96them on in **Settings** → the provider's card when you want to use them.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
98Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that105Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that
99T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`.106T3 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...@@ -102,8 +109,8 @@ Grok models that support adjustable reasoning show a **Reasoning** control besid
102The available levels and default come from the installed Grok Build CLI, so they can vary by model109The available levels and default come from the installed Grok Build CLI, so they can vary by model
103and CLI version.110and CLI version.
104111
105Run the login command on the machine running the T3 Code server, not on the device you browse112Run CLI login commands on the machine running the T3 Code server, not on the device you browse
106from.113from. Antigravity uses its sign-in controls in T3 Code instead of a CLI login command.
107114
108### Binary Discovery115### Binary Discovery
109116
...@@ -112,14 +119,17 @@ Each provider CLI must be on the server's `PATH`, or have an explicit binary pat...@@ -112,14 +119,17 @@ Each provider CLI must be on the server's `PATH`, or have an explicit binary pat
112manager or a non-standard install location keeps the CLI off the `PATH` of the shell that119manager or a non-standard install location keeps the CLI off the `PATH` of the shell that
113started T3 Code.120started 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
115### When Auth Is Needed125### When Auth Is Needed
116126
117Provider auth is required before you start a session with that provider, not before you start127Provider auth is required before you start a session with that provider, not before you start
118T3 Code. You can install T3 Code, open it, and add providers afterwards. A provider that is not128T3 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 command129authenticated shows its status and setup instructions in **Settings**.
120to run.
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
124## Next Steps134## Next Steps
125135
docs/user/permission-modes.md+10-1
...@@ -18,7 +18,7 @@ without prompting; commands and anything else still stop for approval....@@ -18,7 +18,7 @@ without prompting; commands and anything else still stop for approval.
18**Auto**: routine actions proceed without you; risky ones still ask. How this is enforced depends18**Auto**: routine actions proceed without you; risky ones still ask. How this is enforced depends
19on the provider: Codex delegates routine approvals to an AI reviewer, Claude uses its own auto19on the provider: Codex delegates routine approvals to an AI reviewer, Claude uses its own auto
20permission mode, Cursor uses Smart Auto review, and providers without an equivalent (such as20permission 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
23**Full access**: allow commands and edits without prompts. The default. The agent runs23**Full access**: allow commands and edits without prompts. The default. The agent runs
24unattended until it finishes or asks a question of its own.24unattended until it finishes or asks a question of its own.
...@@ -29,6 +29,11 @@ there....@@ -29,6 +29,11 @@ there.
29For Grok, **Always allow this session** remembers the matching command or tool input. Other29For Grok, **Always allow this session** remembers the matching command or tool input. Other
30actions still ask for approval. It does not change the thread to **Full access**.30actions 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
32## Choosing a Mode37## Choosing a Mode
3338
34Use **Full access** for work in a worktree or a sandbox you can throw away.39Use **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...@@ -49,3 +54,7 @@ set to always-approve, and **Full access** starts Grok with always-approve. The
49describe what you get; the exact per-provider translation is internal and may change.54describe what you get; the exact per-provider translation is internal and may change.
5055
51Mobile offers the same four modes with the same labels and descriptions.56Mobile 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...@@ -167,6 +167,21 @@ With mise, asdf, fnm, or nodenv, make sure the tool's shim directory is installe
167167
168If 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.168If 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
170## Updating a Remote Server185## Updating a Remote Server
171186
172When the T3 Code web or desktop app and a remote server use different versions, a warning appears in187When 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;...@@ -40,6 +40,8 @@ export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string;
40type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag];40type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag];
4141
42export type EnvironmentSubscriptionRpcTag =42export type EnvironmentSubscriptionRpcTag =
43 | typeof WS_METHODS.providerAuthSubscribe
44 | typeof WS_METHODS.providerInstallSubscribe
43 | typeof ORCHESTRATION_WS_METHODS.subscribeShell45 | typeof ORCHESTRATION_WS_METHODS.subscribeShell
44 | typeof ORCHESTRATION_WS_METHODS.subscribeThread46 | typeof ORCHESTRATION_WS_METHODS.subscribeThread
45 | typeof WS_METHODS.subscribeAuthAccess47 | typeof WS_METHODS.subscribeAuthAccess
packages/client-runtime/src/state/server.ts+52-1
...@@ -806,6 +806,52 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -806,6 +806,52 @@ export function createServerEnvironmentAtoms<R, E>(
806 updateStateAtom,806 updateStateAtom,
807 settingsValueAtom,807 settingsValueAtom,
808 providersValueAtom,808 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 }),
809 traceDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, {855 traceDiagnostics: createEnvironmentRpcQueryAtomFamily(runtime, {
810 label: "environment-data:server:trace-diagnostics",856 label: "environment-data:server:trace-diagnostics",
811 tag: WS_METHODS.serverGetTraceDiagnostics,857 tag: WS_METHODS.serverGetTraceDiagnostics,
...@@ -850,7 +896,12 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -850,7 +896,12 @@ export function createServerEnvironmentAtoms<R, E>(
850 concurrency: {896 concurrency: {
851 mode: "singleFlight",897 mode: "singleFlight",
852 key: ({ environmentId, input }) =>898 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 ]),
854 },905 },
855 }),906 }),
856 updateProvider: createEnvironmentRpcCommand(runtime, {907 updateProvider: createEnvironmentRpcCommand(runtime, {
packages/contracts/src/index.ts+1
...@@ -12,6 +12,7 @@ export * from "./ipc.ts";...@@ -12,6 +12,7 @@ export * from "./ipc.ts";
12export * from "./terminal.ts";12export * from "./terminal.ts";
13export * from "./provider.ts";13export * from "./provider.ts";
14export * from "./providerInstance.ts";14export * from "./providerInstance.ts";
15export * from "./providerSetup.ts";
15export * from "./providerRuntime.ts";16export * from "./providerRuntime.ts";
16export * from "./model.ts";17export * from "./model.ts";
17export * from "./keybindings.ts";18export * from "./keybindings.ts";
packages/contracts/src/model.ts+5
...@@ -145,6 +145,8 @@ export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray<string> = [...@@ -145,6 +145,8 @@ export const PREFERRED_DEFAULT_CODEX_MODELS: ReadonlyArray<string> = [
145 "gpt-5.6-terra",145 "gpt-5.6-terra",
146];146];
147export const DEFAULT_TEXT_GENERATION_MODEL = "gpt-5.6-luna";147export 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";
148export const DEFAULT_TEXT_GENERATION_REASONING_EFFORT = "low";150export const DEFAULT_TEXT_GENERATION_REASONING_EFFORT = "low";
149151
150export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, string>> = {152export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, string>> = {
...@@ -154,6 +156,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, strin...@@ -154,6 +156,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, strin
154 // Product slug, not an ACP model id. The Grok adapter treats it as "the session's current model".156 // Product slug, not an ACP model id. The Grok adapter treats it as "the session's current model".
155 [GROK_DRIVER_KIND]: "grok-build",157 [GROK_DRIVER_KIND]: "grok-build",
156 [OPENCODE_DRIVER_KIND]: "openai/gpt-5",158 [OPENCODE_DRIVER_KIND]: "openai/gpt-5",
159 [ProviderDriverKind.make("antigravity")]: ANTIGRAVITY_DEFAULT_MODEL,
157};160};
158161
159/** Per-provider text generation model defaults. */162/** Per-provider text generation model defaults. */
...@@ -161,6 +164,7 @@ export const DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER: Partial<...@@ -161,6 +164,7 @@ export const DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER: Partial<
161 Record<ProviderDriverKind, string>164 Record<ProviderDriverKind, string>
162> = {165> = {
163 [CODEX_DRIVER_KIND]: DEFAULT_TEXT_GENERATION_MODEL,166 [CODEX_DRIVER_KIND]: DEFAULT_TEXT_GENERATION_MODEL,
167 [ProviderDriverKind.make("antigravity")]: ANTIGRAVITY_DEFAULT_MODEL,
164 [CLAUDE_DRIVER_KIND]: "claude-haiku-4-5",168 [CLAUDE_DRIVER_KIND]: "claude-haiku-4-5",
165 [CURSOR_DRIVER_KIND]: "composer-2",169 [CURSOR_DRIVER_KIND]: "composer-2",
166 [OPENCODE_DRIVER_KIND]: "openai/gpt-5",170 [OPENCODE_DRIVER_KIND]: "openai/gpt-5",
...@@ -195,6 +199,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial<...@@ -195,6 +199,7 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial<
195// ── Provider display names ────────────────────────────────────────────199// ── Provider display names ────────────────────────────────────────────
196200
197export const PROVIDER_DISPLAY_NAMES: Partial<Record<ProviderDriverKind, string>> = {201export const PROVIDER_DISPLAY_NAMES: Partial<Record<ProviderDriverKind, string>> = {
202 [ProviderDriverKind.make("antigravity")]: "Antigravity",
198 [CODEX_DRIVER_KIND]: "Codex",203 [CODEX_DRIVER_KIND]: "Codex",
199 [CLAUDE_DRIVER_KIND]: "Claude",204 [CLAUDE_DRIVER_KIND]: "Claude",
200 [CURSOR_DRIVER_KIND]: "Cursor",205 [CURSOR_DRIVER_KIND]: "Cursor",
packages/contracts/src/orchestration.ts+2
...@@ -148,6 +148,8 @@ export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type;...@@ -148,6 +148,8 @@ export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type;
148export const ProviderApprovalOption = Schema.Struct({148export const ProviderApprovalOption = Schema.Struct({
149 decision: ProviderApprovalDecision,149 decision: ProviderApprovalDecision,
150 label: TrimmedNonEmptyString,150 label: TrimmedNonEmptyString,
151 /** Provider-supplied caution shown next to the option, such as a prompt injection warning. */
152 warning: Schema.optional(TrimmedNonEmptyString),
151});153});
152export type ProviderApprovalOption = typeof ProviderApprovalOption.Type;154export type ProviderApprovalOption = typeof ProviderApprovalOption.Type;
153export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unknown);155export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unknown);
packages/contracts/src/providerRuntime.ts+2
...@@ -492,6 +492,7 @@ export type RequestResolvedPayload = typeof RequestResolvedPayload.Type;...@@ -492,6 +492,7 @@ export type RequestResolvedPayload = typeof RequestResolvedPayload.Type;
492const UserInputQuestionOption = Schema.Struct({492const UserInputQuestionOption = Schema.Struct({
493 label: TrimmedNonEmptyStringSchema,493 label: TrimmedNonEmptyStringSchema,
494 description: TrimmedNonEmptyStringSchema,494 description: TrimmedNonEmptyStringSchema,
495 value: Schema.optional(Schema.String),
495});496});
496export type UserInputQuestionOption = typeof UserInputQuestionOption.Type;497export type UserInputQuestionOption = typeof UserInputQuestionOption.Type;
497498
...@@ -500,6 +501,7 @@ export const UserInputQuestion = Schema.Struct({...@@ -500,6 +501,7 @@ export const UserInputQuestion = Schema.Struct({
500 header: TrimmedNonEmptyStringSchema,501 header: TrimmedNonEmptyStringSchema,
501 question: TrimmedNonEmptyStringSchema,502 question: TrimmedNonEmptyStringSchema,
502 options: Schema.Array(UserInputQuestionOption),503 options: Schema.Array(UserInputQuestionOption),
504 allowCustomAnswer: Schema.optional(Schema.Boolean),
503 multiSelect: Schema.optional(Schema.Boolean).pipe(505 multiSelect: Schema.optional(Schema.Boolean).pipe(
504 Schema.withConstructorDefault(Effect.succeed(false)),506 Schema.withConstructorDefault(Effect.succeed(false)),
505 ),507 ),
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";...@@ -2,6 +2,15 @@ import * as Schema from "effect/Schema";
2import * as Rpc from "effect/unstable/rpc/Rpc";2import * as Rpc from "effect/unstable/rpc/Rpc";
3import * as RpcGroup from "effect/unstable/rpc/RpcGroup";3import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
4import { TrimmedNonEmptyString } from "./baseSchemas.ts";4import { TrimmedNonEmptyString } from "./baseSchemas.ts";
5import {
6 ProviderAuthCancelInput,
7 ProviderAuthCompleteInput,
8 ProviderAuthState,
9 ProviderInstallCancelInput,
10 ProviderInstallState,
11 ProviderSetupError,
12 ProviderSetupInput,
13} from "./providerSetup.ts";
514
6import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts";15import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts";
7import {16import {
...@@ -233,6 +242,15 @@ export const WS_METHODS = {...@@ -233,6 +242,15 @@ export const WS_METHODS = {
233242
234 // Provider methods243 // Provider methods
235 providerUploadFeedback: "provider.uploadFeedback",244 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
237 // VCS methods255 // VCS methods
238 vcsPull: "vcs.pull",256 vcsPull: "vcs.pull",
...@@ -376,9 +394,11 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv...@@ -376,9 +394,11 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv
376 */394 */
377 instanceId: Schema.optional(ProviderInstanceId),395 instanceId: Schema.optional(ProviderInstanceId),
378 cwd: Schema.optional(TrimmedNonEmptyString),396 cwd: Schema.optional(TrimmedNonEmptyString),
397 /** Explicit user request. Background status refreshes must not open agent sessions. */
398 refreshModels: Schema.optional(Schema.Boolean),
379 }),399 }),
380 success: ServerProviderUpdatedPayload,400 success: ServerProviderUpdatedPayload,
381 error: EnvironmentAuthorizationError,401 error: Schema.Union([EnvironmentAuthorizationError, ProviderSetupError]),
382});402});
383403
384export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, {404export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, {
...@@ -387,6 +407,64 @@ export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvide...@@ -387,6 +407,64 @@ export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvide
387 error: Schema.Union([ServerProviderUpdateError, EnvironmentAuthorizationError]),407 error: Schema.Union([ServerProviderUpdateError, EnvironmentAuthorizationError]),
388});408});
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
390export const WsServerUpdateServerRpc = Rpc.make(WS_METHODS.serverUpdateServer, {468export const WsServerUpdateServerRpc = Rpc.make(WS_METHODS.serverUpdateServer, {
391 payload: ServerSelfUpdateInput,469 payload: ServerSelfUpdateInput,
392 success: ServerSelfUpdateResult,470 success: ServerSelfUpdateResult,
...@@ -1066,6 +1144,15 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1066,6 +1144,15 @@ export const WsRpcGroup = RpcGroup.make(
1066 WsServerGetConfigRpc,1144 WsServerGetConfigRpc,
1067 WsServerRefreshProvidersRpc,1145 WsServerRefreshProvidersRpc,
1068 WsServerUpdateProviderRpc,1146 WsServerUpdateProviderRpc,
1147 WsProviderAuthStartRpc,
1148 WsProviderAuthCompleteRpc,
1149 WsProviderAuthCancelRpc,
1150 WsProviderAuthLogoutRpc,
1151 WsProviderAuthSubscribeRpc,
1152 WsProviderInstallStartRpc,
1153 WsProviderInstallCancelRpc,
1154 WsProviderInstallSubscribeRpc,
1155 WsProviderInstallRemoveRpc,
1069 WsServerUpdateServerRpc,1156 WsServerUpdateServerRpc,
1070 WsServerUpdateServerWithProgressRpc,1157 WsServerUpdateServerWithProgressRpc,
1071 WsServerCommitDesktopUpdateRpc,1158 WsServerCommitDesktopUpdateRpc,
packages/contracts/src/server.ts+8
...@@ -197,6 +197,14 @@ export const ServerProvider = Schema.Struct({...@@ -197,6 +197,14 @@ export const ServerProvider = Schema.Struct({
197 continuation: Schema.optional(ServerProviderContinuation),197 continuation: Schema.optional(ServerProviderContinuation),
198 showInteractionModeToggle: Schema.optional(Schema.Boolean),198 showInteractionModeToggle: Schema.optional(Schema.Boolean),
199 requiresNewThreadForModelChange: Schema.optional(Schema.Boolean),199 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 ),
200 enabled: Schema.Boolean,208 enabled: Schema.Boolean,
201 installed: Schema.Boolean,209 installed: Schema.Boolean,
202 version: Schema.NullOr(TrimmedNonEmptyString),210 version: Schema.NullOr(TrimmedNonEmptyString),
packages/contracts/src/settings.ts+103-1
...@@ -365,13 +365,20 @@ const makeBinaryPathSetting = (fallback: string) =>...@@ -365,13 +365,20 @@ const makeBinaryPathSetting = (fallback: string) =>
365 Schema.withDecodingDefault(Effect.succeed(fallback)),365 Schema.withDecodingDefault(Effect.succeed(fallback)),
366 );366 );
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
370export interface ProviderSettingsFormAnnotation {375export interface ProviderSettingsFormAnnotation {
371 readonly control?: ProviderSettingsFormControl | undefined;376 readonly control?: ProviderSettingsFormControl | undefined;
372 readonly placeholder?: string | undefined;377 readonly placeholder?: string | undefined;
373 readonly hidden?: boolean | undefined;378 readonly hidden?: boolean | undefined;
374 readonly clearWhenEmpty?: "omit" | "persist" | undefined;379 readonly clearWhenEmpty?: "omit" | "persist" | undefined;
380 /** Choices for a `select` control. The first entry is the default. */
381 readonly options?: ReadonlyArray<ProviderSettingsFormOption> | undefined;
375}382}
376383
377export interface ProviderSettingsFormSchemaAnnotation {384export interface ProviderSettingsFormSchemaAnnotation {
...@@ -585,6 +592,89 @@ export const GrokSettings = makeProviderSettingsSchema(...@@ -585,6 +592,89 @@ export const GrokSettings = makeProviderSettingsSchema(
585);592);
586export type GrokSettings = typeof GrokSettings.Type;593export 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
588export const OpenCodeSettings = makeProviderSettingsSchema(678export const OpenCodeSettings = makeProviderSettingsSchema(
589 {679 {
590 // Off by default (like Cursor and Grok): the binding is not yet stable680 // Off by default (like Cursor and Grok): the binding is not yet stable
...@@ -804,6 +894,7 @@ export const ServerSettings = Schema.Struct({...@@ -804,6 +894,7 @@ export const ServerSettings = Schema.Struct({
804 cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),894 cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
805 grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),895 grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
806 opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),896 opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
897 antigravity: AntigravitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
807 }).pipe(Schema.withDecodingDefault(Effect.succeed({}))),898 }).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
808 // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values899 // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values
809 // are `ProviderInstanceConfig` envelopes. The driver-specific config blob900 // are `ProviderInstanceConfig` envelopes. The driver-specific config blob
...@@ -949,6 +1040,16 @@ const GrokSettingsPatch = Schema.Struct({...@@ -949,6 +1040,16 @@ const GrokSettingsPatch = Schema.Struct({
949 customModels: Schema.optionalKey(Schema.Array(Schema.String)),1040 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
950});1041});
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
952const OpenCodeSettingsPatch = Schema.Struct({1053const OpenCodeSettingsPatch = Schema.Struct({
953 enabled: Schema.optionalKey(Schema.Boolean),1054 enabled: Schema.optionalKey(Schema.Boolean),
954 binaryPath: Schema.optionalKey(TrimmedString),1055 binaryPath: Schema.optionalKey(TrimmedString),
...@@ -1001,6 +1102,7 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1001,6 +1102,7 @@ export const ServerSettingsPatch = Schema.Struct({
1001 cursor: Schema.optionalKey(CursorSettingsPatch),1102 cursor: Schema.optionalKey(CursorSettingsPatch),
1002 grok: Schema.optionalKey(GrokSettingsPatch),1103 grok: Schema.optionalKey(GrokSettingsPatch),
1003 opencode: Schema.optionalKey(OpenCodeSettingsPatch),1104 opencode: Schema.optionalKey(OpenCodeSettingsPatch),
1105 antigravity: Schema.optionalKey(AntigravitySettingsPatch),
1004 }),1106 }),
1005 ),1107 ),
1006 // Whole-map replacement for the new instance config. Patching individual1108 // 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";...@@ -5,6 +5,7 @@ import { RpcClientError } from "effect/unstable/rpc";
5import * as AcpSchema from "../_generated/schema.gen.ts";5import * as AcpSchema from "../_generated/schema.gen.ts";
6import * as AcpError from "../errors.ts";6import * as AcpError from "../errors.ts";
7const isError = Schema.is(AcpSchema.Error);7const isError = Schema.is(AcpSchema.Error);
8const isAcpError = Schema.is(AcpError.AcpError);
89
9export const callRpc = <A>(10export const callRpc = <A>(
10 method: string,11 method: string,
...@@ -17,11 +18,13 @@ export const callRpc = <A>(...@@ -17,11 +18,13 @@ export const callRpc = <A>(
17 Effect.catchTags({18 Effect.catchTags({
18 RpcClientError: (cause) =>19 RpcClientError: (cause) =>
19 Effect.fail(20 Effect.fail(
20 new AcpError.AcpTransportError({21 cause.reason._tag === "RpcClientDefect" && isAcpError(cause.reason.cause)
21 operation: "call-rpc",22 ? cause.reason.cause
22 method,23 : new AcpError.AcpTransportError({
23 cause,24 operation: "call-rpc",
24 }),25 method,
26 cause,
27 }),
25 ),28 ),
26 }),29 }),
27 );30 );
packages/effect-acp/src/client.test.ts+204
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import * as Path from "effect/Path";1import * as Path from "effect/Path";
2import * as Cause from "effect/Cause";2import * as Cause from "effect/Cause";
3import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";5import * as Exit from "effect/Exit";
5import * as Fiber from "effect/Fiber";6import * as Fiber from "effect/Fiber";
...@@ -31,6 +32,10 @@ const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String...@@ -31,6 +32,10 @@ const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String
31const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean }));32const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean }));
32const PromptRequest = jsonRpcRequest("session/prompt", AcpSchema.PromptRequest);33const PromptRequest = jsonRpcRequest("session/prompt", AcpSchema.PromptRequest);
33const PromptResponse = jsonRpcResponse(AcpSchema.PromptResponse);34const PromptResponse = jsonRpcResponse(AcpSchema.PromptResponse);
35const SessionUpdateNotification = jsonRpcNotification(
36 "session/update",
37 AcpSchema.SessionNotification,
38);
34const decodePromptRequestLine = Schema.decodeEffect(Schema.fromJsonString(PromptRequest));39const decodePromptRequestLine = Schema.decodeEffect(Schema.fromJsonString(PromptRequest));
35const XAiPromptCompleteNotification = jsonRpcNotification(40const XAiPromptCompleteNotification = jsonRpcNotification(
36 "_x.ai/session/prompt_complete",41 "_x.ai/session/prompt_complete",
...@@ -59,6 +64,15 @@ const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) =>...@@ -59,6 +64,15 @@ const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) =>
59 path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"),64 path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"),
60);65);
61const mockPeerArgs = (path: string) => [path];66const 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
63function concatBytes(chunks: ReadonlyArray<Uint8Array>): Uint8Array {77function concatBytes(chunks: ReadonlyArray<Uint8Array>): Uint8Array {
64 const batch = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0));78 const batch = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0));
...@@ -82,6 +96,196 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => {...@@ -82,6 +96,196 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => {
82 return yield* spawner.spawn(command);96 return yield* spawner.spawn(command);
83 });97 });
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
85 it.effect("initializes, prompts, receives updates, and handles permission requests", () =>289 it.effect("initializes, prompts, receives updates, and handles permission requests", () =>
86 Effect.gen(function* () {290 Effect.gen(function* () {
87 const updates = yield* Ref.make<Array<unknown>>([]);291 const updates = yield* Ref.make<Array<unknown>>([]);
packages/effect-acp/src/client.ts+23-5
...@@ -1,6 +1,5 @@...@@ -1,6 +1,5 @@
1import * as Context from "effect/Context";1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Stdio from "effect/Stdio";
4import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
5import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
6import * as Scope from "effect/Scope";5import * as Scope from "effect/Scope";
...@@ -27,6 +26,16 @@ export interface AcpClientOptions {...@@ -27,6 +26,16 @@ export interface AcpClientOptions {
27 readonly logIncoming?: boolean;26 readonly logIncoming?: boolean;
28 readonly logOutgoing?: boolean;27 readonly logOutgoing?: boolean;
29 readonly logger?: (event: AcpProtocol.AcpProtocolLogEvent) => Effect.Effect<void, never>;28 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>;
30}39}
3140
32type AcpClientRaw = {41type AcpClientRaw = {
...@@ -307,7 +316,7 @@ interface BufferedNotificationHandler<A> {...@@ -307,7 +316,7 @@ interface BufferedNotificationHandler<A> {
307}316}
308317
309export const make = Effect.fn("effect-acp/AcpClient.make")(function* (318export const make = Effect.fn("effect-acp/AcpClient.make")(function* (
310 stdio: Stdio.Stdio,319 stdio: AcpProtocol.AcpStdio,
311 options: AcpClientOptions = {},320 options: AcpClientOptions = {},
312 terminationError?: Effect.Effect<AcpError.AcpError>,321 terminationError?: Effect.Effect<AcpError.AcpError>,
313): Effect.fn.Return<AcpClient["Service"], never, Scope.Scope> {322): Effect.fn.Return<AcpClient["Service"], never, Scope.Scope> {
...@@ -404,6 +413,10 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* (...@@ -404,6 +413,10 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* (
404 ...(options.logIncoming !== undefined ? { logIncoming: options.logIncoming } : {}),413 ...(options.logIncoming !== undefined ? { logIncoming: options.logIncoming } : {}),
405 ...(options.logOutgoing !== undefined ? { logOutgoing: options.logOutgoing } : {}),414 ...(options.logOutgoing !== undefined ? { logOutgoing: options.logOutgoing } : {}),
406 ...(options.logger ? { logger: options.logger } : {}),415 ...(options.logger ? { logger: options.logger } : {}),
416 ...(options.transformSessionUpdate
417 ? { transformSessionUpdate: options.transformSessionUpdate }
418 : {}),
419 ...(options.onTermination ? { onTermination: options.onTermination } : {}),
407 onNotification: dispatchNotification,420 onNotification: dispatchNotification,
408 onExtRequest: dispatchExtRequest,421 onExtRequest: dispatchExtRequest,
409 });422 });
...@@ -572,14 +585,19 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* (...@@ -572,14 +585,19 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* (
572 });585 });
573});586});
574587
575export const layer = (stdio: Stdio.Stdio, options: AcpClientOptions = {}): Layer.Layer<AcpClient> =>588export const layer = (
576 Layer.effect(AcpClient, make(stdio, options));589 stdio: AcpProtocol.AcpStdio,
590 options: AcpClientOptions = {},
591): Layer.Layer<AcpClient> => Layer.effect(AcpClient, make(stdio, options));
577592
578export const layerChildProcess = (593export const layerChildProcess = (
579 handle: ChildProcessSpawner.ChildProcessHandle,594 handle: ChildProcessSpawner.ChildProcessHandle,
580 options: AcpClientOptions = {},595 options: AcpClientOptions = {},
581): Layer.Layer<AcpClient> => {596): Layer.Layer<AcpClient> => {
582 const stdio = makeChildStdio(handle);597 const stdio = {
598 ...makeChildStdio(handle),
599 stdin: options.transformStdout?.(handle.stdout) ?? handle.stdout,
600 };
583 const terminationError = makeTerminationError(handle);601 const terminationError = makeTerminationError(handle);
584 return Layer.effect(AcpClient, make(stdio, options, terminationError));602 return Layer.effect(AcpClient, make(stdio, options, terminationError));
585};603};
packages/effect-acp/src/errors.test.ts+18
...@@ -36,6 +36,24 @@ describe("effect-acp errors", () => {...@@ -36,6 +36,24 @@ describe("effect-acp errors", () => {
36 });36 });
37 });37 });
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
39 it.effect("preserves protocol request errors as request errors", () => {57 it.effect("preserves protocol request errors as request errors", () => {
40 const failure = AcpSchema.Error.make({58 const failure = AcpSchema.Error.make({
41 code: -32602,59 code: -32602,
packages/effect-acp/src/protocol.test.ts+48
...@@ -175,9 +175,14 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {...@@ -175,9 +175,14 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
175 const secret = "acp-core-notification-secret-sentinel";175 const secret = "acp-core-notification-secret-sentinel";
176 const { stdio, input } = yield* makeInMemoryStdio();176 const { stdio, input } = yield* makeInMemoryStdio();
177 const termination = yield* Deferred.make<AcpError.AcpError>();177 const termination = yield* Deferred.make<AcpError.AcpError>();
178 let transformCalls = 0;
178 yield* AcpProtocol.makeAcpPatchedProtocol({179 yield* AcpProtocol.makeAcpPatchedProtocol({
179 stdio,180 stdio,
180 serverRequestMethods: new Set(),181 serverRequestMethods: new Set(),
182 transformSessionUpdate: (notification) => {
183 transformCalls += 1;
184 return notification;
185 },
181 onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid),186 onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid),
182 });187 });
183188
...@@ -199,6 +204,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {...@@ -199,6 +204,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
199 );204 );
200205
201 const error = yield* Deferred.await(termination);206 const error = yield* Deferred.await(termination);
207 assert.equal(transformCalls, 0);
202 assert.instanceOf(error, AcpError.AcpProtocolParseError);208 assert.instanceOf(error, AcpError.AcpProtocolParseError);
203 const parseError = error as AcpError.AcpProtocolParseError;209 const parseError = error as AcpError.AcpProtocolParseError;
204 const { cause, ...directDiagnostics } = parseError;210 const { cause, ...directDiagnostics } = parseError;
...@@ -719,4 +725,46 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {...@@ -719,4 +725,46 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
719 assert.equal(error.code, 0);725 assert.equal(error.code, 0);
720 }),726 }),
721 );727 );
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 }
722});770});
packages/effect-acp/src/protocol.ts+23-6
...@@ -4,6 +4,7 @@ import * as Deferred from "effect/Deferred";...@@ -4,6 +4,7 @@ import * as Deferred from "effect/Deferred";
4import * as Exit from "effect/Exit";4import * as Exit from "effect/Exit";
5import * as Queue from "effect/Queue";5import * as Queue from "effect/Queue";
6import * as Ref from "effect/Ref";6import * as Ref from "effect/Ref";
7import type * as PlatformError from "effect/PlatformError";
7import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
8import * as Scope from "effect/Scope";9import * as Scope from "effect/Scope";
9import * as Stream from "effect/Stream";10import * as Stream from "effect/Stream";
...@@ -42,13 +43,21 @@ export type AcpIncomingNotification =...@@ -42,13 +43,21 @@ export type AcpIncomingNotification =
42 readonly params: unknown;43 readonly params: unknown;
43 };44 };
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
45export interface AcpPatchedProtocolOptions {51export interface AcpPatchedProtocolOptions {
46 readonly stdio: Stdio.Stdio;52 readonly stdio: AcpStdio;
47 readonly terminationError?: Effect.Effect<AcpError.AcpError>;53 readonly terminationError?: Effect.Effect<AcpError.AcpError>;
48 readonly serverRequestMethods: ReadonlySet<string>;54 readonly serverRequestMethods: ReadonlySet<string>;
49 readonly logIncoming?: boolean;55 readonly logIncoming?: boolean;
50 readonly logOutgoing?: boolean;56 readonly logOutgoing?: boolean;
51 readonly logger?: (event: AcpProtocolLogEvent) => Effect.Effect<void, never>;57 readonly logger?: (event: AcpProtocolLogEvent) => Effect.Effect<void, never>;
58 readonly transformSessionUpdate?: (
59 notification: AcpSchema.SessionNotification,
60 ) => AcpSchema.SessionNotification;
52 readonly onNotification?: (61 readonly onNotification?: (
53 notification: AcpIncomingNotification,62 notification: AcpIncomingNotification,
54 ) => Effect.Effect<void, AcpError.AcpError, never>;63 ) => Effect.Effect<void, AcpError.AcpError, never>;
...@@ -102,8 +111,13 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi...@@ -102,8 +111,13 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
102 const outgoing = yield* Queue.unbounded<string | Uint8Array, Cause.Done<void>>();111 const outgoing = yield* Queue.unbounded<string | Uint8Array, Cause.Done<void>>();
103 const nextRequestId = yield* Ref.make(1);112 const nextRequestId = yield* Ref.make(1);
104 const terminationHandled = yield* Ref.make(false);113 const terminationHandled = yield* Ref.make(false);
114 const terminationFailure = yield* Deferred.make<never, AcpError.AcpError>();
105 const extPending = yield* Ref.make(new Map<string, AcpPendingRequest>());115 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
107 const logProtocol = (event: AcpProtocolLogEvent) => {121 const logProtocol = (event: AcpProtocolLogEvent) => {
108 if (event.direction === "incoming" && !options.logIncoming) {122 if (event.direction === "incoming" && !options.logIncoming) {
109 return Effect.void;123 return Effect.void;
...@@ -125,6 +139,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi...@@ -125,6 +139,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
125 if (message._tag === "Interrupt") {139 if (message._tag === "Interrupt") {
126 return;140 return;
127 }141 }
142 yield* ensureActive;
128 yield* logProtocol({143 yield* logProtocol({
129 direction: "outgoing",144 direction: "outgoing",
130 stage: "decoded",145 stage: "decoded",
...@@ -151,6 +166,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi...@@ -151,6 +166,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
151 payload: typeof encoded === "string" ? encoded : new TextDecoder().decode(encoded),166 payload: typeof encoded === "string" ? encoded : new TextDecoder().decode(encoded),
152 });167 });
153168
169 yield* ensureActive;
154 yield* Queue.offer(outgoing, encoded).pipe(Effect.asVoid);170 yield* Queue.offer(outgoing, encoded).pipe(Effect.asVoid);
155 }171 }
156 });172 });
...@@ -217,7 +233,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi...@@ -217,7 +233,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
217 }),233 }),
218 }).pipe(Effect.asVoid);234 }).pipe(Effect.asVoid);
219235
220 const handleTermination = (classify: () => Effect.Effect<AcpError.AcpError | undefined>) =>236 const handleTermination = (classify: () => Effect.Effect<AcpError.AcpError>) =>
221 Ref.modify(terminationHandled, (handled) => {237 Ref.modify(terminationHandled, (handled) => {
222 if (handled) {238 if (handled) {
223 return [Effect.void, true] as const;239 return [Effect.void, true] as const;
...@@ -226,9 +242,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi...@@ -226,9 +242,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
226 Effect.gen(function* () {242 Effect.gen(function* () {
227 yield* Queue.offer(disconnects, 0);243 yield* Queue.offer(disconnects, 0);
228 const error = yield* classify();244 const error = yield* classify();
229 if (!error) {245 yield* Deferred.fail(terminationFailure, error);
230 return;
231 }
232 yield* failAllExtPending(error);246 yield* failAllExtPending(error);
233 yield* emitClientProtocolError(error);247 yield* emitClientProtocolError(error);
234 if (options.onTermination) {248 if (options.onTermination) {
...@@ -289,7 +303,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi...@@ -289,7 +303,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
289 ({303 ({
290 _tag: "SessionUpdate",304 _tag: "SessionUpdate",
291 method: CLIENT_METHODS.session_update,305 method: CLIENT_METHODS.session_update,
292 params,306 params: options.transformSessionUpdate?.(params) ?? params,
293 }) satisfies AcpIncomingNotification,307 }) satisfies AcpIncomingNotification,
294 ),308 ),
295 Effect.mapError((cause) =>309 Effect.mapError((cause) =>
...@@ -544,6 +558,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi...@@ -544,6 +558,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
544 method: string,558 method: string,
545 payload: unknown,559 payload: unknown,
546 ) {560 ) {
561 yield* ensureActive;
547 yield* logProtocol({562 yield* logProtocol({
548 direction: "outgoing",563 direction: "outgoing",
549 stage: "decoded",564 stage: "decoded",
...@@ -559,10 +574,12 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi...@@ -559,10 +574,12 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
559 }574 }
560 const encoded = `${exit.value}\n`;575 const encoded = `${exit.value}\n`;
561 yield* logProtocol({ direction: "outgoing", stage: "raw", payload: encoded });576 yield* logProtocol({ direction: "outgoing", stage: "raw", payload: encoded });
577 yield* ensureActive;
562 yield* Queue.offer(outgoing, encoded);578 yield* Queue.offer(outgoing, encoded);
563 });579 });
564580
565 const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) {581 const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) {
582 yield* ensureActive;
566 const requestId = yield* Ref.modify(583 const requestId = yield* Ref.modify(
567 nextRequestId,584 nextRequestId,
568 (current) => [current, current + 1] as const,585 (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";...@@ -6,6 +6,10 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
66
7import * as AcpAgent from "../../src/agent.ts";7import * 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
9if (process.env.ACP_MOCK_MALFORMED_OUTPUT === "1") {13if (process.env.ACP_MOCK_MALFORMED_OUTPUT === "1") {
10 process.stdout.write("{not-json}\n");14 process.stdout.write("{not-json}\n");
11 process.exit(Number(process.env.ACP_MOCK_MALFORMED_OUTPUT_EXIT_CODE ?? "0"));15 process.exit(Number(process.env.ACP_MOCK_MALFORMED_OUTPUT_EXIT_CODE ?? "0"));
pnpm-lock.yaml+26
...@@ -505,6 +505,9 @@ importers:...@@ -505,6 +505,9 @@ importers:
505 yaml:505 yaml:
506 specifier: ^2.9.0506 specifier: ^2.9.0
507 version: 2.9.0507 version: 2.9.0
508 yauzl:
509 specifier: ^3.4.0
510 version: 3.4.0
508 devDependencies:511 devDependencies:
509 '@effect/vitest':512 '@effect/vitest':
510 specifier: 4.0.0-beta.103513 specifier: 4.0.0-beta.103
...@@ -527,6 +530,9 @@ importers:...@@ -527,6 +530,9 @@ importers:
527 '@types/node':530 '@types/node':
528 specifier: 24.12.4531 specifier: 24.12.4
529 version: 24.12.4532 version: 24.12.4
533 '@types/yauzl':
534 specifier: ^3.4.0
535 version: 3.4.0
530 effect-acp:536 effect-acp:
531 specifier: workspace:*537 specifier: workspace:*
532 version: link:../../packages/effect-acp538 version: link:../../packages/effect-acp
...@@ -4793,6 +4799,9 @@ packages:...@@ -4793,6 +4799,9 @@ packages:
4793 '@types/yargs@17.0.35':4799 '@types/yargs@17.0.35':
4794 resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==}4800 resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==}
47954801
4802 '@types/yauzl@3.4.0':
4803 resolution: {integrity: sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==}
4804
4796 '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260604.1':4805 '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260604.1':
4797 resolution: {integrity: sha512-zs616um9UuaODLsNlCu5Aw95rFcTV4u3hVt090r6k0lVvTxfaJOv8HKA6BpIotcEYlZlMQowrMSYCCdedo7iyA==}4806 resolution: {integrity: sha512-zs616um9UuaODLsNlCu5Aw95rFcTV4u3hVt090r6k0lVvTxfaJOv8HKA6BpIotcEYlZlMQowrMSYCCdedo7iyA==}
4798 engines: {node: '>=16.20.0'}4807 engines: {node: '>=16.20.0'}
...@@ -8527,6 +8536,9 @@ packages:...@@ -8527,6 +8536,9 @@ packages:
8527 resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==}8536 resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==}
8528 engines: {node: '>=12', npm: '>=6'}8537 engines: {node: '>=12', npm: '>=6'}
85298538
8539 pend@1.2.0:
8540 resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
8541
8530 pg-cloudflare@1.4.0:8542 pg-cloudflare@1.4.0:
8531 resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}8543 resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
85328544
...@@ -10339,6 +10351,10 @@ packages:...@@ -10339,6 +10351,10 @@ packages:
10339 resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}10351 resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
10340 engines: {node: '>=12'}10352 engines: {node: '>=12'}
1034110353
10354 yauzl@3.4.0:
10355 resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==}
10356 engines: {node: '>=12'}
10357
10342 yjs@13.6.31:10358 yjs@13.6.31:
10343 resolution: {integrity: sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==}10359 resolution: {integrity: sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==}
10344 engines: {node: '>=16.0.0', npm: '>=8.0.0'}10360 engines: {node: '>=16.0.0', npm: '>=8.0.0'}
...@@ -14619,6 +14635,10 @@ snapshots:...@@ -14619,6 +14635,10 @@ snapshots:
14619 dependencies:14635 dependencies:
14620 '@types/yargs-parser': 21.0.314636 '@types/yargs-parser': 21.0.3
1462114637
14638 '@types/yauzl@3.4.0':
14639 dependencies:
14640 '@types/node': 24.12.4
14641
14622 '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260604.1':14642 '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260604.1':
14623 optional: true14643 optional: true
1462414644
...@@ -19064,6 +19084,8 @@ snapshots:...@@ -19064,6 +19084,8 @@ snapshots:
1906419084
19065 pe-library@0.4.1: {}19085 pe-library@0.4.1: {}
1906619086
19087 pend@1.2.0: {}
19088
19067 pg-cloudflare@1.4.0:19089 pg-cloudflare@1.4.0:
19068 optional: true19090 optional: true
1906919091
...@@ -21124,6 +21146,10 @@ snapshots:...@@ -21124,6 +21146,10 @@ snapshots:
21124 y18n: 5.0.821146 y18n: 5.0.8
21125 yargs-parser: 21.1.121147 yargs-parser: 21.1.1
2112621148
21149 yauzl@3.4.0:
21150 dependencies:
21151 pend: 1.2.0
21152
21127 yjs@13.6.31:21153 yjs@13.6.31:
21128 dependencies:21154 dependencies:
21129 lib0: 0.2.11721155 lib0: 0.2.117