| author | |
| committer | |
| log | 06336460c9988f29c71e839c4c9c840c4552e077 |
| tree | 478dec832b8a765d27e98184359f4de24291fdb1 |
| parent | 652515a349741d234111b85f27597be3265d1ffc |
| signature | Signed by PGP key B5690EEEBB952194 |
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 Code | 1 | # T3 Code |
| 2 | 2 | ||
| 3 | T3 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. | 3 | T3 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. |
| 4 | 4 | ||
| 5 | You 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. | 5 | You 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. |
| 6 | 6 | ||
| ... | @@ -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 |
| 68 | 68 | ||
| 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 @@ |
| 2 | 2 | ||
| 3 | T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). | 3 | T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). |
| 4 | 4 | ||
| 5 | Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them. | 5 | Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, and Google Antigravity. If they're set up on your computer, T3 Code can control them. |
| 6 | 6 | ||
| 7 | ## "Wait, what are you selling me?" | 7 | ## "Wait, what are you selling me?" |
| 8 | 8 | ||
| ... | @@ -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 | ## Installation | 13 | ## Installation |
| 14 | 14 | ||
| 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. | ||
| 23 | 24 | ||
| 24 | ### Try it out (install-free) | 25 | ### Try it out (install-free) |
| 25 | 26 |
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 |
| 56 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; | 56 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; |
| 57 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; | 57 | import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; |
| 58 | import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; | 58 | import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; |
| 59 | import { SettingsProviderSetupRouteScreen } from "./features/settings/SettingsProviderSetupRouteScreen"; | ||
| 59 | import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; | 60 | import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; |
| 60 | import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; | 61 | import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; |
| 61 | import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; | 62 | import { 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 @@ |
| 1 | import { Image } from "expo-image"; | ||
| 1 | import { Path, Svg } from "react-native-svg"; | 2 | import { Path, Svg } from "react-native-svg"; |
| 2 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; | 3 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 3 | 4 | ||
| ... | @@ -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"; |
| 14 | 15 | ||
| 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 | <CloudEnvironmentRows | 111 | <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"; |
| 26 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | 26 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 27 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; | 27 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; |
| 28 | import { serverEnvironment } from "../../state/server"; | 28 | import { serverEnvironment } from "../../state/server"; |
| 29 | import { ProviderSetupLink } from "../settings/ProviderSetupLink"; | ||
| 30 | import type { ProviderSetupRouteParams } from "../settings/SettingsProviderSetupRouteScreen"; | ||
| 29 | import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; | 31 | import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation"; |
| 30 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; | 32 | import { hasCloudPublicConfig } from "../cloud/publicConfig"; |
| 31 | import { ConnectionStatusDot } from "./ConnectionStatusDot"; | 33 | import { ConnectionStatusDot } from "./ConnectionStatusDot"; |
| ... | @@ -34,6 +36,7 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect | ... | @@ -34,6 +36,7 @@ import { type RelayEnvironmentView, useConnectionController } from "./useConnect |
| 34 | interface CloudEnvironmentRowsProps { | 36 | interface 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 | <CloudEnvironmentRowShell | 224 | <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 | } |
| 239 | 263 |
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"; |
| 15 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | 15 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 16 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; | 16 | import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; |
| 17 | import { serverEnvironment } from "../../state/server"; | 17 | import { serverEnvironment } from "../../state/server"; |
| 18 | import { ProviderSetupLink } from "../settings/ProviderSetupLink"; | ||
| 19 | import type { ProviderSetupRouteParams } from "../settings/SettingsProviderSetupRouteScreen"; | ||
| 18 | import { ConnectionStatusDot } from "./ConnectionStatusDot"; | 20 | import { ConnectionStatusDot } from "./ConnectionStatusDot"; |
| 19 | 21 | ||
| 20 | function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null { | 22 | function 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 | )} |
| 181 | 184 | ||
| 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 | <Pressable | 203 | <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 @@ | ||
| 1 | import type { ServerProvider } from "@t3tools/contracts"; | ||
| 2 | import { Pressable } from "react-native"; | ||
| 3 | |||
| 4 | import { AppText as Text } from "../../components/AppText"; | ||
| 5 | import { SymbolView } from "../../components/AppSymbol"; | ||
| 6 | import { ProviderIcon } from "../../components/ProviderIcon"; | ||
| 7 | import { providerNeedsSetup } from "./provider-setup-state"; | ||
| 8 | |||
| 9 | export 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 | <CloudEnvironmentRows | 170 | <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_ENABLED | 179 | {...(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 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; | ||
| 3 | import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; | ||
| 4 | import { | ||
| 5 | ANTIGRAVITY_AUTH_METHODS, | ||
| 6 | AuthOrchestrationOperateScope, | ||
| 7 | EnvironmentId, | ||
| 8 | ProviderInstanceId, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 11 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 12 | import { useCallback, useEffect, useRef, useState } from "react"; | ||
| 13 | import { Alert, Platform, Pressable, ScrollView, View } from "react-native"; | ||
| 14 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
| 15 | |||
| 16 | import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; | ||
| 17 | import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; | ||
| 18 | import { ProviderIcon } from "../../components/ProviderIcon"; | ||
| 19 | import { cn } from "../../lib/cn"; | ||
| 20 | import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | ||
| 21 | import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; | ||
| 22 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | ||
| 23 | import { appAtomRegistry } from "../../state/atom-registry"; | ||
| 24 | import { useEnvironments } from "../../state/environments"; | ||
| 25 | import { useEnvironmentQuery } from "../../state/query"; | ||
| 26 | import { serverEnvironment } from "../../state/server"; | ||
| 27 | import { environmentSession } from "../../state/session"; | ||
| 28 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 29 | import { | ||
| 30 | antigravityEnabledPatch, | ||
| 31 | readAntigravityAuthMethod, | ||
| 32 | resolveProviderSignInPresentation, | ||
| 33 | } from "./provider-setup-state"; | ||
| 34 | |||
| 35 | export type ProviderSetupRouteParams = { | ||
| 36 | readonly environmentId: EnvironmentId; | ||
| 37 | readonly instanceId: ProviderInstanceId; | ||
| 38 | }; | ||
| 39 | |||
| 40 | const PRIVATE_COMMAND_OPTIONS = { reportFailure: false, reportDefect: false } as const; | ||
| 41 | const isEnvironmentId = Schema.is(EnvironmentId); | ||
| 42 | const isProviderInstanceId = Schema.is(ProviderInstanceId); | ||
| 43 | |||
| 44 | function 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. */ | ||
| 75 | export 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 | |||
| 96 | function 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 @@ | ||
| 1 | import { | ||
| 2 | DEFAULT_SERVER_SETTINGS, | ||
| 3 | ProviderDriverKind, | ||
| 4 | ProviderInstanceId, | ||
| 5 | ServerProvider, | ||
| 6 | resolveProviderInstanceEnabled, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | import { describe, expect, it } from "vite-plus/test"; | ||
| 10 | |||
| 11 | import { antigravityEnabledPatch, resolveProviderSignInPresentation } from "./provider-setup-state"; | ||
| 12 | |||
| 13 | const 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 | |||
| 25 | describe("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 | |||
| 92 | describe("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 @@ | ||
| 1 | import { | ||
| 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. */ | ||
| 12 | export 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. */ | ||
| 23 | export 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. */ | ||
| 36 | export 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. */ | ||
| 67 | export 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"; |
| 86 | import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; | 86 | import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; |
| 87 | import { resolveSelectableModelSelection } from "../../lib/modelOptions"; | 87 | import { |
| 88 | isModelSelectionUnavailable, | ||
| 89 | resolveSelectableModelSelection, | ||
| 90 | } from "../../lib/modelOptions"; | ||
| 91 | import { resolveProviderInteractionMode } from "./legacy-plan-mode"; | ||
| 88 | import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; | 92 | import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; |
| 89 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; | 93 | import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; |
| 90 | import { enqueueThreadOutboxMessage } from "../../state/thread-outbox"; | 94 | import { 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 = selectedProject | 182 | 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 gate | 870 | // Read the latest explicit pick. Antigravity selections stay unchanged |
| 866 | // still applies so a stored selection on a disabled provider falls back | 871 | // 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.planModeEnabled | 883 | 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(); |
| 883 | 890 | ||
| 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 (it | 910 | // 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 everything | 911 | // 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> |
| 1233 | 1251 | ||
| 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 | <ComposerSurface | 1263 | <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 | } |
| 19 | 19 | ||
| 20 | const DEFAULT_APPROVAL_OPTIONS = [ | 20 | const 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 | ]; |
| 25 | 25 | ||
| 26 | export function PendingApprovalCard(props: PendingApprovalCardProps) { | 26 | export 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 feed | 30 | // 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 | <Pressable | 52 | <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 | <Pressable | 275 | <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 | selected | 279 | 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 | <TextInput | 312 | {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"; |
| 63 | import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; | 63 | import { |
| 64 | buildModelOptions, | ||
| 65 | groupByProvider, | ||
| 66 | isModelSelectionUnavailable, | ||
| 67 | } from "../../lib/modelOptions"; | ||
| 64 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; | 68 | import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; |
| 65 | import type { RemoteClientConnectionState } from "../../lib/connection"; | 69 | import type { RemoteClientConnectionState } from "../../lib/connection"; |
| 66 | import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; | 70 | import { 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; | ||
| 387 | 401 | ||
| 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} |
| 597 | 612 | ||
| 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 | <ComposerSurface | 619 | <ComposerSurface |
| 599 | style={ | 620 | style={ |
| 600 | isExpanded | 621 | 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 @@ |
| 1 | import type { | 1 | import 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"; |
| 10 | import { useAtomValue } from "@effect/atom-react"; | ||
| 8 | import type { LegendListRenderItemProps } from "@legendapp/list/react-native"; | 11 | import type { LegendListRenderItemProps } from "@legendapp/list/react-native"; |
| 9 | import { AnimatedLegendList } from "@legendapp/list/reanimated"; | 12 | import { AnimatedLegendList } from "@legendapp/list/reanimated"; |
| 10 | import { HeaderHeightContext } from "@react-navigation/elements"; | 13 | import { HeaderHeightContext } from "@react-navigation/elements"; |
| ... | @@ -49,6 +52,11 @@ import { | ... | @@ -49,6 +52,11 @@ import { |
| 49 | } from "../../native/StackHeader"; | 52 | } from "../../native/StackHeader"; |
| 50 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; | 53 | import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; |
| 51 | import { serverEnvironment } from "../../state/server"; | 54 | import { serverEnvironment } from "../../state/server"; |
| 55 | import { ProviderSetupLink } from "../settings/ProviderSetupLink"; | ||
| 56 | import { | ||
| 57 | SettingsProviderSetupRouteScreen, | ||
| 58 | type ProviderSetupRouteParams, | ||
| 59 | } from "../settings/SettingsProviderSetupRouteScreen"; | ||
| 52 | import { useAtomCommand } from "../../state/use-atom-command"; | 60 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 53 | import { useNewTaskFlow } from "./new-task-flow-provider"; | 61 | import { useNewTaskFlow } from "./new-task-flow-provider"; |
| 54 | import { | 62 | import { |
| ... | @@ -62,8 +70,10 @@ import { | ... | @@ -62,8 +70,10 @@ import { |
| 62 | } from "../layout/native-mail-search-toolbar"; | 70 | } from "../layout/native-mail-search-toolbar"; |
| 63 | import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-options"; | 71 | import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-options"; |
| 64 | import { | 72 | import { |
| 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"; |
| 69 | 79 | ||
| ... | @@ -72,7 +82,11 @@ import { | ... | @@ -72,7 +82,11 @@ import { |
| 72 | * and friends) starts folded so a 300-model catalog cannot bury the list. All | 82 | * 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 | */ |
| 75 | const PRIMARY_PROVIDER_DRIVERS: ReadonlySet<string> = new Set(["claudeAgent", "codex"]); | 85 | const 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's | 91 | * 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 | <Pressable | 116 | <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 = |
| 301 | 322 | ||
| 302 | type ThreadSettingsSessionProps = { | 323 | type 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() { |
| 353 | 375 | ||
| 354 | type ThreadSettingsSessionValue = { | 376 | type 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]); | ||
| 429 | 457 | ||
| 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. */ |
| 761 | function ThreadSettingsMainContent(props: { | 796 | function 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 && hasActiveCatalogFilter | 820 | ...(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 | ); |
| 822 | 881 | ||
| 823 | return ( | 882 | return ( |
| ... | @@ -943,6 +1002,7 @@ function ThreadSettingsChoiceContent(props: { | ... | @@ -943,6 +1002,7 @@ function ThreadSettingsChoiceContent(props: { |
| 943 | type ThreadSettingsPickerStackParams = { | 1002 | type ThreadSettingsPickerStackParams = { |
| 944 | ThreadSettingsModels: undefined; | 1003 | ThreadSettingsModels: undefined; |
| 945 | ThreadSettingsChoice: ThreadSettingsSubmenuPage & { readonly title: string }; | 1004 | ThreadSettingsChoice: ThreadSettingsSubmenuPage & { readonly title: string }; |
| 1005 | ThreadSettingsProviderSetup: ProviderSetupRouteParams; | ||
| 946 | }; | 1006 | }; |
| 947 | 1007 | ||
| 948 | type ThreadSettingsPickerPresentation = { | 1008 | type 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 | <ThreadSettingsMainContent | 1148 | <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 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode"; | 3 | import { |
| 4 | resolvePendingTaskInteractionMode, | ||
| 5 | resolveProviderInteractionMode, | ||
| 6 | } from "./legacy-plan-mode"; | ||
| 7 | |||
| 8 | describe("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 | }); | ||
| 4 | 24 | ||
| 5 | describe("resolvePendingTaskInteractionMode", () => { | 25 | describe("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 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 5 | 6 | ||
| 7 | type InteractionModeProvider = Pick<ServerProvider, "showInteractionModeToggle">; | ||
| 8 | |||
| 9 | /** Normalize saved T3 mode choices without changing native slash commands. */ | ||
| 10 | export 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 | |||
| 6 | export function resolveLegacyPlanModeEnabled(input: { | 19 | export 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"; |
| 85 | import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; | 85 | import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; |
| 86 | import { resolvePendingTaskInteractionMode } from "./legacy-plan-mode"; | 86 | import { |
| 87 | resolvePendingTaskInteractionMode, | ||
| 88 | resolveProviderInteractionMode, | ||
| 89 | } from "./legacy-plan-mode"; | ||
| 87 | import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; | 90 | import { useLegacyPlanModeState } from "./use-legacy-plan-mode-enabled"; |
| 88 | import { | 91 | import { |
| 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; | ||
| 418 | 419 | ||
| 419 | // Stored selections only count while their provider is usable on the | 420 | // Antigravity keeps unavailable selections so sign-out or a catalog change |
| 420 | // server; otherwise the server's default model wins instead of silently | 421 | // cannot switch the user's model. Other providers retain their fallback |
| 421 | // targeting a disabled provider. The draft selection is an explicit pick | 422 | // 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 setSelectedModelOptions | 482 | // 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 | ); |
| 821 | 835 | ||
| 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 stored | 876 | // 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"; |
| 10 | 10 | ||
| 11 | describe("mobile provider catalog refresh", () => { | 11 | describe("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", () => { |
| 25 | 25 | ||
| 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 | }); | ||
| 29 | 32 | ||
| 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 { |
| 7 | 7 | ||
| 8 | type RefreshProvidersTarget = { | 8 | type RefreshProvidersTarget = { |
| 9 | readonly environmentId: EnvironmentId; | 9 | readonly environmentId: EnvironmentId; |
| 10 | readonly input: Record<never, never>; | 10 | readonly input: { readonly refreshModels: true }; |
| 11 | }; | 11 | }; |
| 12 | 12 | ||
| 13 | /** Deduplicates taps while the server refresh command is still running. */ | 13 | /** Explicit model-picker refresh. Repeated taps share the pending discovery. */ |
| 14 | export function createProviderCatalogRefreshRunner<Result>( | 14 | export 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>( |
| 18 | 18 | ||
| 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts"; | 3 | import { |
| 4 | ProviderInstanceId, | ||
| 5 | ServerProvider, | ||
| 6 | type ProviderOptionSelection, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 4 | 9 | ||
| 5 | import type { ModelOption } from "../../lib/modelOptions"; | 10 | import type { ModelOption } from "../../lib/modelOptions"; |
| 6 | import { modelMatchesCatalogQuery, pendingModelAfterPress } from "./thread-settings-sheet-state"; | 11 | import { |
| 12 | canCommitPendingModel, | ||
| 13 | modelMatchesCatalogQuery, | ||
| 14 | pendingModelAfterPress, | ||
| 15 | providerSetupCandidates, | ||
| 16 | } from "./thread-settings-sheet-state"; | ||
| 7 | 17 | ||
| 8 | function modelOption( | 18 | function 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 | |||
| 127 | const decodeServerProvider = Schema.decodeSync(ServerProvider); | ||
| 128 | |||
| 129 | function 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 | |||
| 146 | describe("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 @@ |
| 1 | import type { ModelOption } from "../../lib/modelOptions"; | 1 | import type { ProviderInstanceId, ServerProvider } from "@t3tools/contracts"; |
| 2 | import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; | ||
| 3 | import { providerNeedsSetup } from "../settings/provider-setup-state"; | ||
| 4 | |||
| 5 | /** Read setup choices from this environment, not the selectable model list. */ | ||
| 6 | export 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 | } | ||
| 2 | 24 | ||
| 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. */ |
| 4 | export function modelMatchesCatalogQuery(input: { | 26 | export 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 | } |
| 33 | 55 | ||
| 56 | /** A model can disappear while its setup page is open inside the picker. */ | ||
| 57 | export 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 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import { ProviderDriverKind } from "@t3tools/contracts"; | ||
| 2 | 3 | ||
| 3 | vi.mock("../../state/queries", () => ({ | 4 | vi.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 | })); |
| 12 | 13 | ||
| 13 | import { composerSelectionAtEnd } from "./use-composer-command-menu"; | 14 | import { |
| 15 | buildComposerSlashCommandItems, | ||
| 16 | composerSelectionAtEnd, | ||
| 17 | resolveComposerCommandSelection, | ||
| 18 | } from "./use-composer-command-menu"; | ||
| 14 | 19 | ||
| 15 | describe("composerSelectionAtEnd", () => { | 20 | describe("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 | |||
| 26 | describe("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"; |
| 7 | import { | 8 | import { |
| 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 | } |
| 32 | 33 | ||
| 34 | export 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 | |||
| 97 | export 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. */ |
| 34 | export function useComposerCommandMenu({ | 136 | export 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({ |
| 157 | 258 | ||
| 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 | } | ||
| 213 | 268 | ||
| 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 | })); |
| 223 | 278 | ||
| 224 | return [...builtIn, ...providerCommands, ...skillItems]; | 279 | return [...commandItems, ...skillItems]; |
| 225 | } | 280 | } |
| 226 | 281 | ||
| 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 | ]); |
| 334 | 388 | ||
| ... | @@ -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; |
| 338 | 392 | ||
| 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 | ); |
| 372 | 415 | ||
| 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/ |
| 19 | import { makeTurnCommandMetadata, type TurnCommandMetadata } from "../../lib/commandMetadata"; | 19 | import { makeTurnCommandMetadata, type TurnCommandMetadata } from "../../lib/commandMetadata"; |
| 20 | import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn"; | 20 | import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn"; |
| 21 | import { randomHex } from "../../lib/uuid"; | 21 | import { randomHex } from "../../lib/uuid"; |
| 22 | import { isModelSelectionUnavailable } from "../../lib/modelOptions"; | ||
| 22 | import { useAtomCommand } from "../../state/use-atom-command"; | 23 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 23 | import { scheduleUnusedComposerAttachmentCleanup } from "../../state/use-composer-drafts"; | 24 | import { scheduleUnusedComposerAttachmentCleanup } from "../../state/use-composer-drafts"; |
| 24 | import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; | 25 | import { setPendingConnectionError } from "../../state/use-remote-environment-registry"; |
| 25 | import { validateProjectThreadCreation } from "./projectThreadCreationValidation"; | 26 | import { validateProjectThreadCreation } from "./projectThreadCreationValidation"; |
| 26 | import { appAtomRegistry } from "../../state/atom-registry"; | 27 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 27 | import { serverEnvironment } from "../../state/server"; | 28 | import { serverEnvironment } from "../../state/server"; |
| 29 | import { resolveProviderInteractionMode } from "./legacy-plan-mode"; | ||
| 28 | 30 | ||
| 29 | export function useCreateProjectThread() { | 31 | export 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 | } |
| 112 | 114 | ||
| 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 | })); |
| 20 | 20 | ||
| 21 | import { | 21 | import { copyTextWithHaptic, tryCopyTextWithHaptic } from "./copyTextWithHaptic"; |
| 22 | CopyTextClipboardWriteError, | ||
| 23 | CopyTextHapticFeedbackError, | ||
| 24 | copyTextWithHaptic, | ||
| 25 | tryCopyTextWithHaptic, | ||
| 26 | } from "./copyTextWithHaptic"; | ||
| 27 | 22 | ||
| 28 | describe("copyTextWithHaptic", () => { | 23 | describe("copyTextWithHaptic", () => { |
| 29 | beforeEach(() => { | 24 | beforeEach(() => { |
| ... | @@ -69,36 +64,29 @@ describe("copyTextWithHaptic", () => { | ... | @@ -69,36 +64,29 @@ describe("copyTextWithHaptic", () => { |
| 69 | }); | 64 | }); |
| 70 | 65 | ||
| 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); |
| 77 | 73 | ||
| 78 | copyTextWithHaptic("secret clipboard contents", { target: "connection-trace-id" }); | 74 | await tryCopyTextWithHaptic(content, { target: "provider-sign-in-link" }); |
| 79 | 75 | ||
| 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({ |
| 83 | 79 | _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"); |
| 94 | 89 | 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 | })(); |
| 74 | 65 |
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 |
| 5 | import { | 5 | import { |
| 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 | }); |
| 197 | 199 | ||
| 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 | } |
| 62 | 63 | ||
| 64 | /** Whether a known Antigravity selection needs setup or a different model. */ | ||
| 65 | export 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 is | 89 | * Keep Antigravity selections when setup or catalog changes make them |
| 65 | * currently enabled, installed, and authenticated on the server. Returns the | 90 | * unavailable. Other providers fall through to the server default when they |
| 66 | * selection unchanged when usable, otherwise `null` so callers fall through to | 91 | * 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 | */ |
| 70 | export function resolveSelectableModelSelection( | 93 | export 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 | } |
| 87 | 115 | ||
| 88 | /** | 116 | /** |
| 89 | * Like resolveSelectableModelSelection, but additionally rejects legacy | 117 | * Reject legacy models for implicit defaults, except Antigravity selections, |
| 90 | * models. Used for implicit defaults (stored draft, project last-used): a | 118 | * 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 fall | 119 | * the settings sheet are unaffected. |
| 92 | * through to the provider's default instead. Explicit picks in the settings | ||
| 93 | * sheet are unaffected. | ||
| 94 | */ | 120 | */ |
| 95 | export function resolveDefaultableModelSelection( | 121 | export 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 | } |
| 107 | 133 | ||
| 108 | export function resolveNewTaskModelSelection(input: { | 134 | export 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 | null | 146 | 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>(); |
| 129 | 155 | ||
| 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 | } |
| 134 | 165 | ||
| ... | @@ -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 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | import { Linking } from "react-native"; | 2 | import { Linking } from "react-native"; |
| 3 | 3 | ||
| 4 | const ExternalUrlTarget = Schema.Literals(["file-preview", "markdown-link", "pull-request"]); | 4 | const ExternalUrlTarget = Schema.Literals([ |
| 5 | "file-preview", | ||
| 6 | "markdown-link", | ||
| 7 | "pull-request", | ||
| 8 | "provider-auth", | ||
| 9 | ]); | ||
| 5 | 10 | ||
| 6 | export type ExternalUrlTarget = typeof ExternalUrlTarget.Type; | 11 | export type ExternalUrlTarget = typeof ExternalUrlTarget.Type; |
| 7 | 12 |
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; |
| 76 | 77 | ||
| 78 | const 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 | |||
| 77 | describe("pending user input answers", () => { | 90 | describe("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"] }); |
| 86 | 120 | ||
| 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"] }); |
| 100 | 134 | ||
| 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 | }); |
| 132 | 167 | ||
| 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 | }); |
| 145 | 241 | ||
| 146 | describe("pending approvals", () => { | 242 | describe("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 | } |
| 53 | 53 | ||
| 54 | export interface PendingUserInputDraftAnswer { | 54 | export interface PendingUserInputDraftAnswer { |
| 55 | readonly selectedOptionLabels?: ReadonlyArray<string>; | 55 | readonly selectedOptionValues?: ReadonlyArray<string>; |
| 56 | readonly customAnswer?: string; | 56 | readonly customAnswer?: string; |
| 57 | } | 57 | } |
| 58 | 58 | ||
| ... | @@ -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 | } |
| 270 | 274 | ||
| 271 | function normalizeSelectedOptionLabels( | 275 | function 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 | |||
| 290 | function 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 | } |
| 277 | 297 | ||
| 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 | } |
| 282 | 306 | ||
| ... | @@ -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 | } |
| 291 | 316 | ||
| 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 | } |
| 298 | 323 | ||
| 299 | /** Codex children settle via task.updated (idle/failed/interrupted), never | 324 | /** 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 | } |
| 1760 | 1785 | ||
| 1761 | export function setPendingUserInputCustomAnswer( | 1786 | export 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 > 0 | 1796 | customAnswer.trim().length > 0 |
| 1767 | ? undefined | 1797 | ? 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 | } |
| 1774 | 1804 | ||
| 1775 | export function isPendingUserInputOptionSelected( | 1805 | export 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 | } |
| 1782 | 1813 | ||
| 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 | } |
| 1785 | 1822 | ||
| 1786 | export function togglePendingUserInputOptionSelection( | 1823 | export 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 | } | ||
| 1792 | 1832 | ||
| 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]; | ||
| 1798 | 1841 | ||
| 1799 | return { | 1842 | return { |
| 1800 | customAnswer: "", | 1843 | customAnswer: "", |
| 1801 | ...(nextSelectedOptionLabels.length > 0 | 1844 | ...(nextSelectedOptionValues.length > 0 |
| 1802 | ? { selectedOptionLabels: nextSelectedOptionLabels } | 1845 | ? { selectedOptionValues: nextSelectedOptionValues } |
| 1803 | : {}), | 1846 | : {}), |
| 1804 | }; | 1847 | }; |
| 1805 | } | 1848 | } |
| 1806 | 1849 | ||
| 1807 | return { | 1850 | return { |
| 1808 | customAnswer: "", | 1851 | customAnswer: "", |
| 1809 | selectedOptionLabels: [normalizedOptionLabel], | 1852 | selectedOptionValues: [resolvedOptionValue], |
| 1810 | }; | 1853 | }; |
| 1811 | } | 1854 | } |
| 1812 | 1855 | ||
| ... | @@ -1818,7 +1861,7 @@ export function buildPendingUserInputAnswers( | ... | @@ -1818,7 +1861,7 @@ export function buildPendingUserInputAnswers( |
| 1818 | 1861 | ||
| 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"; |
| 22 | import * as Schema from "effect/Schema"; | 23 | import * as Schema from "effect/Schema"; |
| 23 | 24 | ||
| 24 | import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; | 25 | import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; |
| 25 | import type { DraftComposerAttachment } from "../lib/composerImages"; | 26 | import type { DraftComposerAttachment } from "../lib/composerImages"; |
| 26 | import { scopedThreadKey } from "../lib/scopedEntities"; | 27 | import { scopedThreadKey } from "../lib/scopedEntities"; |
| 28 | import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; | ||
| 27 | 29 | ||
| 28 | const THREAD_OUTBOX_SCHEMA_VERSION = 3; | 30 | const THREAD_OUTBOX_SCHEMA_VERSION = 3; |
| 29 | const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; | 31 | const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; |
| ... | @@ -93,11 +95,19 @@ export interface ThreadSettingsSnapshot { | ... | @@ -93,11 +95,19 @@ export interface ThreadSettingsSnapshot { |
| 93 | export function resolveQueuedThreadSettings( | 95 | export 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 | } |
| 103 | 113 | ||
| ... | @@ -184,11 +194,9 @@ export type ThreadOutboxDispatchStep = | ... | @@ -184,11 +194,9 @@ export type ThreadOutboxDispatchStep = |
| 184 | | { readonly step: "send" }; | 194 | | { readonly step: "send" }; |
| 185 | 195 | ||
| 186 | /** | 196 | /** |
| 187 | * Orders the resolved delivery action against the file-capability gate. The | 197 | * Wait for provider and file capabilities before sending. Cleanup does not |
| 188 | * gate applies only to a message that will send: a message whose thread | 198 | * 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 is | 199 | * 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 | */ |
| 193 | export function resolveThreadOutboxDispatchStep(input: { | 201 | export 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 | }); |
| 152 | 152 | ||
| 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 | }); |
| 940 | 996 | ||
| 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 | }); |
| 950 | 1013 |
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< |
| 31 | function setUserInputDraftOption( | 31 | function 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( |
| 49 | 49 | ||
| 50 | function setUserInputDraftCustomAnswer( | 50 | function 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; |
| 109 | 110 | ||
| 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 | } |
| 115 | 116 | ||
| 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 | ); |
| 121 | 122 | ||
| 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 | } |
| 127 | 131 | ||
| 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 | ); |
| 133 | 137 | ||
| 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"; |
| 5 | 5 | ||
| 6 | import { | 6 | import { |
| 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" |
| 24 | import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; | 25 | import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; |
| 25 | 26 | ||
| 26 | import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; | 27 | import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; |
| 28 | import { isModelSelectionUnavailable } from "../lib/modelOptions"; | ||
| 29 | import { resolveProviderInteractionMode } from "../features/threads/legacy-plan-mode"; | ||
| 27 | import { | 30 | import { |
| 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; | ||
| 149 | 160 | ||
| 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 | } |
| 209 | 220 | ||
| 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 | ); |
| 462 | 496 | ||
| 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 | ); |
| 482 | 524 | ||
| 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 | })); |
| 82 | 82 | ||
| 83 | vi.mock("./server", async () => { | ||
| 84 | const { Atom } = await import("effect/unstable/reactivity"); | ||
| 85 | return { serverEnvironment: { configValueAtom: Atom.family(() => Atom.make(null)) } }; | ||
| 86 | }); | ||
| 87 | |||
| 83 | vi.mock("./threads", () => ({ | 88 | vi.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"; |
| 20 | import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; | 20 | import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; |
| 21 | import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload"; | 21 | import { prepareTurnAttachments, type PreparedTurnAttachments } from "../lib/attachmentUpload"; |
| 22 | import { randomHex } from "../lib/uuid"; | 22 | import { randomHex } from "../lib/uuid"; |
| 23 | import { isModelSelectionUnavailable } from "../lib/modelOptions"; | ||
| 23 | import { appAtomRegistry } from "./atom-registry"; | 24 | import { appAtomRegistry } from "./atom-registry"; |
| 24 | import { useProjects, useServerConfigs, useThreadShells } from "./entities"; | 25 | import { useProjects, useServerConfigs, useThreadShells } from "./entities"; |
| 26 | import { serverEnvironment } from "./server"; | ||
| 25 | import { | 27 | import { |
| 26 | confirmThreadOutboxMessageQueued, | 28 | confirmThreadOutboxMessageQueued, |
| 27 | ensureThreadOutboxLoaded, | 29 | ensureThreadOutboxLoaded, |
| ... | @@ -630,7 +632,17 @@ export function useThreadOutboxDrain(): void { | ... | @@ -630,7 +632,17 @@ export function useThreadOutboxDrain(): void { |
| 630 | 632 | ||
| 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); |
| 635 | 647 | ||
| 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.capabilities | 701 | 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 | ); |
| 766 | 791 | ||
| ... | @@ -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.capabilities | 827 | 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 | ); |
| 864 | 922 | ||
| 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 applies | 1006 | // The delivery action resolves first; capability checks apply only to |
| 949 | // only to a message that will send. Gating earlier would restore a | 1007 | // a message that will send. Checking earlier would restore a |
| 950 | // creation whose startTurn already made the thread as a duplicate draft | 1008 | // 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"; |
| 48 | import { ProviderService } from "../src/provider/Services/ProviderService.ts"; | 48 | import { ProviderService } from "../src/provider/Services/ProviderService.ts"; |
| 49 | import { ProviderAuthService } from "../src/provider/Services/ProviderAuthService.ts"; | ||
| 49 | import { AnalyticsService } from "../src/telemetry/AnalyticsService.ts"; | 50 | import { AnalyticsService } from "../src/telemetry/AnalyticsService.ts"; |
| 50 | import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; | 51 | import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; |
| 51 | import * as RepositoryIdentityResolver from "../src/project/RepositoryIdentityResolver.ts"; | 52 | import * 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 @@ |
| 3 | import * as NodeFS from "node:fs"; | 3 | import * as NodeFS from "node:fs"; |
| 4 | 4 | ||
| 5 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Deferred from "effect/Deferred"; | ||
| 6 | 7 | ||
| 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 8 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 8 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | 9 | import * 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"; |
| 13 | 14 | ||
| 14 | const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; | 15 | const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; |
| 15 | const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH; | 16 | const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH; |
| 17 | const antigravityProfile = process.env.T3_ACP_ANTIGRAVITY === "1"; | ||
| 16 | const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; | 18 | const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; |
| 17 | const emitInterleavedAssistantToolCalls = | 19 | const 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"; |
| 29 | const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1"; | 31 | const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1"; |
| 30 | const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1"; | 32 | const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1"; |
| 31 | const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; | 33 | const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; |
| 34 | const waitForResumeRelease = process.env.T3_ACP_WAIT_FOR_RESUME_RELEASE === "1"; | ||
| 35 | const completeFirstPromptOnCancel = process.env.T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL === "1"; | ||
| 36 | const floodStderr = process.env.T3_ACP_FLOOD_STDERR === "1"; | ||
| 32 | const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; | 37 | const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; |
| 33 | const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1"; | 38 | const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1"; |
| 34 | const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL === "1"; | 39 | const 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 | ); |
| 63 | const sessionId = "mock-session-1"; | 68 | const sessionId = "mock-session-1"; |
| 64 | 69 | ||
| 65 | let currentModeId = "ask"; | 70 | let currentModeId = antigravityProfile ? "default" : "ask"; |
| 66 | let currentModelId = "default"; | 71 | let currentModelId = antigravityProfile ? "gemini-test-low" : "default"; |
| 67 | let parameterizedModelPicker = false; | 72 | let parameterizedModelPicker = false; |
| 68 | let currentReasoning = "medium"; | 73 | let currentReasoning = "medium"; |
| 69 | let currentContext = "272k"; | 74 | let currentContext = "272k"; |
| ... | @@ -109,6 +114,26 @@ process.once("exit", (code) => { | ... | @@ -109,6 +114,26 @@ process.once("exit", (code) => { |
| 109 | }); | 114 | }); |
| 110 | 115 | ||
| 111 | function configOptions(): ReadonlyArray<AcpSchema.SessionConfigOption> { | 116 | function 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 | } |
| 270 | 295 | ||
| 271 | const availableModes: ReadonlyArray<AcpSchema.SessionMode> = [ | 296 | const 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 | }, | 301 | const 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 | ]; | ||
| 288 | 324 | ||
| 289 | function modeState(): AcpSchema.SessionModeState { | 325 | function 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 | ]; |
| 315 | 351 | ||
| 316 | function modelState(): AcpSchema.SessionModelState { | 352 | function 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 | ? currentModelId | 357 | ? 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 { |
| 325 | 364 | ||
| 326 | const program = Effect.gen(function* () { | 365 | const 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 | }); | ||
| 328 | 381 | ||
| 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 provider | 410 | // 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 | ); |
| 342 | 416 | ||
| 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 | } | ||
| 344 | 433 | ||
| 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 | ); |
| 353 | 471 | ||
| ... | @@ -415,7 +533,7 @@ const program = Effect.gen(function* () { | ... | @@ -415,7 +533,7 @@ const program = Effect.gen(function* () { |
| 415 | 533 | ||
| 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; |
| 492 | 620 | ||
| 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 | ); |
| 1073 | 1233 | ||
| 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 | }); |
| 1120 | 1334 | ||
| 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"; |
| 22 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 22 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 23 | import * as Clock from "effect/Clock"; | 23 | import * as Clock from "effect/Clock"; |
| 24 | import * as Deferred from "effect/Deferred"; | ||
| 24 | import * as Effect from "effect/Effect"; | 25 | import * as Effect from "effect/Effect"; |
| 25 | import * as Exit from "effect/Exit"; | 26 | import * as Exit from "effect/Exit"; |
| 26 | import * as Layer from "effect/Layer"; | 27 | import * 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"; |
| 28 | import * as PubSub from "effect/PubSub"; | 29 | import * as PubSub from "effect/PubSub"; |
| 29 | import * as Scope from "effect/Scope"; | 30 | import * as Scope from "effect/Scope"; |
| 30 | import * as Stream from "effect/Stream"; | 31 | import * as Stream from "effect/Stream"; |
| 32 | import { it as effectIt } from "@effect/vitest"; | ||
| 31 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | 33 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 32 | 34 | ||
| 33 | import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; | 35 | import * 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"; |
| 58 | import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; | 60 | import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; |
| 61 | import { ProviderValidationError } from "../../provider/Errors.ts"; | ||
| 59 | import { ServerConfig } from "../../config.ts"; | 62 | import { ServerConfig } from "../../config.ts"; |
| 60 | import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; | 63 | import * as WorkspaceEntries from "../../workspace/WorkspaceEntries.ts"; |
| 61 | import * as WorkspacePaths from "../../workspace/WorkspacePaths.ts"; | 64 | import * 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); | ||
| 90 | 96 | ||
| 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( |
| 137 | 144 | ||
| 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 | }); |
| 1004 | 1012 | ||
| 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 | } |
| 757 | 757 | ||
| 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"; |
| 13 | import { createModelSelection } from "@t3tools/shared/model"; | 14 | import { createModelSelection } from "@t3tools/shared/model"; |
| 14 | import { | 15 | import { |
| ... | @@ -31,6 +32,7 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; | ... | @@ -31,6 +32,7 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; |
| 31 | import * as PubSub from "effect/PubSub"; | 32 | import * as PubSub from "effect/PubSub"; |
| 32 | import * as Scope from "effect/Scope"; | 33 | import * as Scope from "effect/Scope"; |
| 33 | import * as Stream from "effect/Stream"; | 34 | import * as Stream from "effect/Stream"; |
| 35 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 34 | import { it as effectIt } from "@effect/vitest"; | 36 | import { it as effectIt } from "@effect/vitest"; |
| 35 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | 37 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 36 | 38 | ||
| ... | @@ -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"; |
| 49 | import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts"; | ||
| 47 | import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts"; | 50 | import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts"; |
| 48 | import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; | 51 | import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; |
| 49 | import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; | 52 | import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; |
| ... | @@ -109,7 +112,10 @@ async function waitFor( | ... | @@ -109,7 +112,10 @@ async function waitFor( |
| 109 | 112 | ||
| 110 | describe("ProviderCommandReactor", () => { | 113 | describe("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 | unknown | 119 | 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 | } |
| 555 | 586 | ||
| 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 |
| 33 | import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; | 33 | import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; |
| 34 | import type { ProviderServiceError } from "../../provider/Errors.ts"; | 34 | import type { ProviderServiceError } from "../../provider/Errors.ts"; |
| 35 | import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; | 35 | import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; |
| 36 | import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts"; | ||
| 36 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; | 37 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 37 | import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts"; | 38 | import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.ts"; |
| 38 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | 39 | import { 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 | } |
| 1153 | 1155 | ||
| 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 | ); |
| 1222 | 1191 | ||
| 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 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | ||
| 3 | import { ProviderInstanceId, ProviderSetupError, type ProviderAuthState } from "@t3tools/contracts"; | ||
| 4 | import * as Deferred from "effect/Deferred"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Exit from "effect/Exit"; | ||
| 7 | import * as Fiber from "effect/Fiber"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | import * as Scope from "effect/Scope"; | ||
| 10 | import * as Stream from "effect/Stream"; | ||
| 11 | import * as TestClock from "effect/testing/TestClock"; | ||
| 12 | import * as AcpErrors from "effect-acp/errors"; | ||
| 13 | import type * as AcpSchema from "effect-acp/schema"; | ||
| 14 | |||
| 15 | import { | ||
| 16 | makeAntigravityAuth, | ||
| 17 | type AntigravityAuth, | ||
| 18 | type AntigravityAuthRuntime, | ||
| 19 | } from "./AntigravityAuth.ts"; | ||
| 20 | import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; | ||
| 21 | |||
| 22 | const instanceId = ProviderInstanceId.make("antigravity-auth-test"); | ||
| 23 | const owner = "t3-auth-session-owner"; | ||
| 24 | const otherOwner = "t3-auth-session-other"; | ||
| 25 | const 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"; | ||
| 27 | const callbackUrl = "http://127.0.0.1:51234/?state=test-state&code=test-code"; | ||
| 28 | |||
| 29 | const initialized = { | ||
| 30 | protocolVersion: 1, | ||
| 31 | authMethods: [{ id: "oauth-personal", name: "Log in with Google" }], | ||
| 32 | agentCapabilities: { auth: { logout: {} } }, | ||
| 33 | } satisfies AcpSchema.InitializeResponse; | ||
| 34 | const 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 | |||
| 47 | const 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 | |||
| 54 | const 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 | |||
| 130 | it.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 @@ | ||
| 1 | import { | ||
| 2 | ProviderSetupError, | ||
| 3 | type ProviderAuthState, | ||
| 4 | type ProviderInstanceId, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import * as Cause from "effect/Cause"; | ||
| 7 | import * as Clock from "effect/Clock"; | ||
| 8 | import * as Crypto from "effect/Crypto"; | ||
| 9 | import * as DateTime from "effect/DateTime"; | ||
| 10 | import * as Deferred from "effect/Deferred"; | ||
| 11 | import * as Effect from "effect/Effect"; | ||
| 12 | import * as Exit from "effect/Exit"; | ||
| 13 | import * as Fiber from "effect/Fiber"; | ||
| 14 | import * as Option from "effect/Option"; | ||
| 15 | import * as Schema from "effect/Schema"; | ||
| 16 | import * as Scope from "effect/Scope"; | ||
| 17 | import * as Semaphore from "effect/Semaphore"; | ||
| 18 | import * as Stream from "effect/Stream"; | ||
| 19 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 20 | import * as AcpErrors from "effect-acp/errors"; | ||
| 21 | |||
| 22 | import type { AcpSessionRuntime, AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; | ||
| 23 | import { parseAntigravityAuthorizationUrl } from "./antigravityAuthSupport.ts"; | ||
| 24 | import { | ||
| 25 | forwardAntigravityCallback, | ||
| 26 | validateAntigravityCallbackUrl, | ||
| 27 | type AntigravityPendingCallback, | ||
| 28 | } from "./antigravityCallback.ts"; | ||
| 29 | import type { ProviderAuthController } from "./Services/ProviderAuthService.ts"; | ||
| 30 | |||
| 31 | const AUTH_TIMEOUT_MS = 300_000; | ||
| 32 | const FORWARDING_FAILED_MESSAGE = "Could not deliver the sign-in response. Start sign-in again."; | ||
| 33 | const isSetupError = Schema.is(ProviderSetupError); | ||
| 34 | const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError); | ||
| 35 | |||
| 36 | interface AuthSnapshot { | ||
| 37 | readonly ownerSessionId: string | null; | ||
| 38 | readonly state: ProviderAuthState; | ||
| 39 | } | ||
| 40 | |||
| 41 | interface 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 | |||
| 52 | interface OwnedProcess { | ||
| 53 | readonly stop: Effect.Effect<void>; | ||
| 54 | startup: Fiber.Fiber<unknown, unknown> | undefined; | ||
| 55 | } | ||
| 56 | |||
| 57 | export 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 | |||
| 66 | export type AntigravityAuthRuntime = Pick< | ||
| 67 | AcpSessionRuntime["Service"], | ||
| 68 | "initialize" | "start" | "request" | ||
| 69 | >; | ||
| 70 | |||
| 71 | export 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 | |||
| 88 | function 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 | |||
| 102 | function 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. */ | ||
| 126 | export 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 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { expect, it } from "@effect/vitest"; | ||
| 3 | import { | ||
| 4 | HostProcessArchitecture, | ||
| 5 | HostProcessEnvironment, | ||
| 6 | HostProcessPlatform, | ||
| 7 | } from "@t3tools/shared/hostProcess"; | ||
| 8 | import * as Deferred from "effect/Deferred"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Exit from "effect/Exit"; | ||
| 11 | import * as Fiber from "effect/Fiber"; | ||
| 12 | import * as FileSystem from "effect/FileSystem"; | ||
| 13 | import * as Option from "effect/Option"; | ||
| 14 | import * as Path from "effect/Path"; | ||
| 15 | import * as PlatformError from "effect/PlatformError"; | ||
| 16 | import * as Queue from "effect/Queue"; | ||
| 17 | import * as Schema from "effect/Schema"; | ||
| 18 | import * as Scope from "effect/Scope"; | ||
| 19 | import * as Sink from "effect/Sink"; | ||
| 20 | import * as Stream from "effect/Stream"; | ||
| 21 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | ||
| 22 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | ||
| 23 | import * as NodeCrypto from "node:crypto"; | ||
| 24 | |||
| 25 | import { | ||
| 26 | makeAntigravityInstallation, | ||
| 27 | type AntigravityExecutable, | ||
| 28 | type AntigravityInstallation, | ||
| 29 | type AntigravityInstallationOptions, | ||
| 30 | } from "./AntigravityInstallation.ts"; | ||
| 31 | import { ANTIGRAVITY_AUTH_BROWSER_MARKER } from "./antigravityAuthSupport.ts"; | ||
| 32 | import type { AntigravityReleaseAsset } from "./antigravityRelease.ts"; | ||
| 33 | |||
| 34 | const serverContents = "antigravity runtime\n"; | ||
| 35 | const harnessContents = "local harness\n"; | ||
| 36 | const previousReleaseId = "1".repeat(64); | ||
| 37 | const previousVersion = "fixture-old"; | ||
| 38 | const encodeJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 39 | |||
| 40 | // Small ZIPs made with Python's zipfile module. The unsafe entries are intentional. | ||
| 41 | const 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 | |||
| 58 | const completeArchive = Buffer.from(zipFixtures.complete, "base64"); | ||
| 59 | |||
| 60 | function 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 | |||
| 77 | const 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 | |||
| 109 | interface 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 | |||
| 124 | const 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 | |||
| 207 | const 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 | |||
| 214 | const 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 | |||
| 229 | it.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. | ||
| 2 | import * as EffectNodeStream from "@effect/platform-node/NodeStream"; | ||
| 3 | import { ProviderDriverKind, type ProviderInstallState } from "@t3tools/contracts"; | ||
| 4 | import { | ||
| 5 | HostProcessArchitecture, | ||
| 6 | HostProcessEnvironment, | ||
| 7 | HostProcessPlatform, | ||
| 8 | } from "@t3tools/shared/hostProcess"; | ||
| 9 | import * as Clock from "effect/Clock"; | ||
| 10 | import * as Cause from "effect/Cause"; | ||
| 11 | import * as Context from "effect/Context"; | ||
| 12 | import * as Crypto from "effect/Crypto"; | ||
| 13 | import * as Effect from "effect/Effect"; | ||
| 14 | import * as Exit from "effect/Exit"; | ||
| 15 | import * as Fiber from "effect/Fiber"; | ||
| 16 | import * as FileSystem from "effect/FileSystem"; | ||
| 17 | import * as Layer from "effect/Layer"; | ||
| 18 | import * as Option from "effect/Option"; | ||
| 19 | import * as Path from "effect/Path"; | ||
| 20 | import * as Schema from "effect/Schema"; | ||
| 21 | import * as Scope from "effect/Scope"; | ||
| 22 | import * as Semaphore from "effect/Semaphore"; | ||
| 23 | import * as Stream from "effect/Stream"; | ||
| 24 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 25 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 26 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | ||
| 27 | import * as NodeCrypto from "node:crypto"; | ||
| 28 | import * as NodeFSP from "node:fs/promises"; | ||
| 29 | import type * as NodeStream from "node:stream"; | ||
| 30 | import * as Yauzl from "yauzl"; | ||
| 31 | |||
| 32 | import { ServerConfig } from "../config.ts"; | ||
| 33 | import { makeAntigravityAcpRuntime } from "./acp/AntigravityAcpSupport.ts"; | ||
| 34 | import { | ||
| 35 | buildAntigravityAcpSpawnInput, | ||
| 36 | prepareAntigravityProfile, | ||
| 37 | } from "./antigravityAuthSupport.ts"; | ||
| 38 | import { | ||
| 39 | resolveAntigravityReleaseAsset, | ||
| 40 | type AntigravityReleaseAsset, | ||
| 41 | } from "./antigravityRelease.ts"; | ||
| 42 | |||
| 43 | const DRIVER = ProviderDriverKind.make("antigravity"); | ||
| 44 | const DOWNLOAD_TIMEOUT = "45 minutes"; | ||
| 45 | const VALIDATION_TIMEOUT = "90 seconds"; | ||
| 46 | const FREE_SPACE_MARGIN = 256 * 1024 * 1024; | ||
| 47 | const RECORD_MAX_BYTES = 8 * 1024; | ||
| 48 | const RELEASE_RECORD = ".install-complete.json"; | ||
| 49 | |||
| 50 | const ReleaseId = Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/u)); | ||
| 51 | const ActiveRelease = Schema.Struct({ releaseId: ReleaseId }); | ||
| 52 | const 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 | }); | ||
| 58 | type InstalledRelease = typeof InstalledRelease.Type; | ||
| 59 | const encodeActiveRelease = Schema.encodeEffect(Schema.fromJsonString(ActiveRelease)); | ||
| 60 | const encodeInstalledRelease = Schema.encodeEffect(Schema.fromJsonString(InstalledRelease)); | ||
| 61 | |||
| 62 | export 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 | } | ||
| 74 | const isInstallationError = Schema.is(AntigravityInstallationError); | ||
| 75 | |||
| 76 | export 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 | |||
| 84 | interface 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 | |||
| 106 | export 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 | |||
| 119 | export 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 | |||
| 128 | const installationError = (operation: string, detail: string, cause?: unknown) => | ||
| 129 | new AntigravityInstallationError({ | ||
| 130 | operation, | ||
| 131 | detail, | ||
| 132 | ...(cause === undefined ? {} : { cause }), | ||
| 133 | }); | ||
| 134 | |||
| 135 | const wrapFailure = (operation: string, detail: string) => (cause: unknown) => | ||
| 136 | isInstallationError(cause) ? cause : installationError(operation, detail, cause); | ||
| 137 | |||
| 138 | function 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 | |||
| 144 | function 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. */ | ||
| 151 | const 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 | |||
| 265 | export 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 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { expect, it } from "@effect/vitest"; | ||
| 3 | import { | ||
| 4 | ANTIGRAVITY_DEFAULT_MODEL, | ||
| 5 | ProviderInstanceId, | ||
| 6 | type AntigravitySettings, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { | ||
| 9 | HostProcessEnvironment, | ||
| 10 | HostProcessExecutablePath, | ||
| 11 | HostProcessPlatform, | ||
| 12 | } from "@t3tools/shared/hostProcess"; | ||
| 13 | import * as Effect from "effect/Effect"; | ||
| 14 | import * as FileSystem from "effect/FileSystem"; | ||
| 15 | import * as Layer from "effect/Layer"; | ||
| 16 | import * as Path from "effect/Path"; | ||
| 17 | import * as Schema from "effect/Schema"; | ||
| 18 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | ||
| 19 | |||
| 20 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | ||
| 21 | import { ServerConfig } from "../../config.ts"; | ||
| 22 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 23 | import { | ||
| 24 | AntigravityInstallation, | ||
| 25 | AntigravityInstallationError, | ||
| 26 | type AntigravityExecutable, | ||
| 27 | } from "../AntigravityInstallation.ts"; | ||
| 28 | import { | ||
| 29 | ANTIGRAVITY_AUTH_STDOUT_PREFIX, | ||
| 30 | resolveAntigravityProfileDirectory, | ||
| 31 | } from "../antigravityAuthSupport.ts"; | ||
| 32 | import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; | ||
| 33 | import * as ModelManifest from "../ModelManifest.ts"; | ||
| 34 | import { AntigravityDriver } from "./AntigravityDriver.ts"; | ||
| 35 | |||
| 36 | const hostPlatform = HostProcessPlatform.defaultValue(); | ||
| 37 | const windowsHost = hostPlatform === "win32"; | ||
| 38 | const 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 | ); | ||
| 46 | const blockedCredentialKeys = new Set([ | ||
| 47 | "GEMINI_API_KEY", | ||
| 48 | "GOOGLE_API_KEY", | ||
| 49 | "GOOGLE_APPLICATION_CREDENTIALS", | ||
| 50 | "GOOGLE_GENAI_USE_VERTEXAI", | ||
| 51 | ]); | ||
| 52 | |||
| 53 | function shellQuote(value: string): string { | ||
| 54 | return `'${value.replaceAll("'", `'"'"'`)}'`; | ||
| 55 | } | ||
| 56 | |||
| 57 | const 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 | |||
| 226 | const 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 | |||
| 240 | it.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 @@ | ||
| 1 | import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; | ||
| 2 | import * as Crypto from "effect/Crypto"; | ||
| 3 | import * as Deferred from "effect/Deferred"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as Exit from "effect/Exit"; | ||
| 6 | import * as FileSystem from "effect/FileSystem"; | ||
| 7 | import * as Path from "effect/Path"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | import * as Scope from "effect/Scope"; | ||
| 10 | import * as Stream from "effect/Stream"; | ||
| 11 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | ||
| 12 | import type { AcpError } from "effect-acp/errors"; | ||
| 13 | |||
| 14 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | ||
| 15 | import { ServerConfig } from "../../config.ts"; | ||
| 16 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 17 | import { | ||
| 18 | isAntigravityTextGenerationAvailable, | ||
| 19 | makeAntigravityTextGeneration, | ||
| 20 | } from "../../textGeneration/AntigravityTextGeneration.ts"; | ||
| 21 | import { makeAntigravityAuth, type AntigravityAuth } from "../AntigravityAuth.ts"; | ||
| 22 | import { AntigravityInstallation } from "../AntigravityInstallation.ts"; | ||
| 23 | import { | ||
| 24 | antigravityAuthConfigIssue, | ||
| 25 | antigravityAuthLabel, | ||
| 26 | antigravityAuthUsesBrowser, | ||
| 27 | buildAntigravityAcpSpawnInput, | ||
| 28 | isAntigravitySignInRequiredError, | ||
| 29 | prepareAntigravityProfile, | ||
| 30 | resolveAntigravityProfileDirectory, | ||
| 31 | type AntigravityAuthConfig, | ||
| 32 | } from "../antigravityAuthSupport.ts"; | ||
| 33 | import { | ||
| 34 | makeAntigravityAcpRuntime, | ||
| 35 | type AntigravityAcpRuntimeInput, | ||
| 36 | } from "../acp/AntigravityAcpSupport.ts"; | ||
| 37 | import type { AcpSessionRuntime, AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts"; | ||
| 38 | import type { ServerProviderDraft } from "../providerSnapshot.ts"; | ||
| 39 | import { removeAntigravitySessionFiles } from "../acp/AntigravitySessionFiles.ts"; | ||
| 40 | import { ProviderDriverError } from "../Errors.ts"; | ||
| 41 | import { makeAntigravityAdapter } from "../Layers/AntigravityAdapter.ts"; | ||
| 42 | import { makeAntigravityProvider } from "../Layers/AntigravityProvider.ts"; | ||
| 43 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; | ||
| 44 | import * as ModelManifest from "../ModelManifest.ts"; | ||
| 45 | import { | ||
| 46 | defaultProviderContinuationIdentity, | ||
| 47 | type ProviderDriver, | ||
| 48 | type ProviderInstance, | ||
| 49 | } from "../ProviderDriver.ts"; | ||
| 50 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; | ||
| 51 | import { withInstanceIdentity } from "./instanceIdentity.ts"; | ||
| 52 | import { discoverAntigravitySkills } from "./AntigravitySkills.ts"; | ||
| 53 | |||
| 54 | const DRIVER = ProviderDriverKind.make("antigravity"); | ||
| 55 | const decodeSettings = Schema.decodeSync(AntigravitySettings); | ||
| 56 | |||
| 57 | export 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. */ | ||
| 70 | export 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 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Path from "effect/Path"; | ||
| 6 | |||
| 7 | import { discoverAntigravitySkills } from "./AntigravitySkills.ts"; | ||
| 8 | |||
| 9 | const 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 | |||
| 18 | const 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 | |||
| 30 | it.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 @@ | ||
| 1 | import type { ServerProviderSkill } from "@t3tools/contracts"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as FileSystem from "effect/FileSystem"; | ||
| 4 | import * as Path from "effect/Path"; | ||
| 5 | import type * as PlatformError from "effect/PlatformError"; | ||
| 6 | import * as Schema from "effect/Schema"; | ||
| 7 | import * as Stream from "effect/Stream"; | ||
| 8 | import { parse as parseYamlDocument } from "yaml"; | ||
| 9 | |||
| 10 | const MAX_SKILL_BYTES = 1_000_000; | ||
| 11 | const MAX_SCAN_BYTES = 8_000_000; | ||
| 12 | const MAX_SCAN_ENTRIES = 10_000; | ||
| 13 | |||
| 14 | const SkillFrontmatter = Schema.Struct({ | ||
| 15 | name: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 16 | description: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 17 | }); | ||
| 18 | const decodeSkillFrontmatter = Schema.decodeUnknownSync(SkillFrontmatter); | ||
| 19 | |||
| 20 | export 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 | |||
| 35 | interface ScanBudget { | ||
| 36 | remainingBytes: number; | ||
| 37 | remainingEntries: number; | ||
| 38 | } | ||
| 39 | |||
| 40 | const 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 | |||
| 55 | function 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. */ | ||
| 75 | function 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. */ | ||
| 83 | const 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 | */ | ||
| 119 | export 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 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { expect, it } from "@effect/vitest"; | ||
| 3 | import { | ||
| 4 | AntigravitySettings, | ||
| 5 | ApprovalRequestId, | ||
| 6 | ProviderInstanceId, | ||
| 7 | ThreadId, | ||
| 8 | type ProviderRuntimeEvent, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import * as Deferred from "effect/Deferred"; | ||
| 11 | import * as Crypto from "effect/Crypto"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Exit from "effect/Exit"; | ||
| 14 | import * as Fiber from "effect/Fiber"; | ||
| 15 | import * as FileSystem from "effect/FileSystem"; | ||
| 16 | import * as Layer from "effect/Layer"; | ||
| 17 | import * as Path from "effect/Path"; | ||
| 18 | import * as Queue from "effect/Queue"; | ||
| 19 | import * as Schema from "effect/Schema"; | ||
| 20 | import * as Stream from "effect/Stream"; | ||
| 21 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | ||
| 22 | import * as AcpErrors from "effect-acp/errors"; | ||
| 23 | import type * as AcpSchema from "effect-acp/schema"; | ||
| 24 | |||
| 25 | import { ServerConfig } from "../../config.ts"; | ||
| 26 | import { ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE } from "../antigravityAuthSupport.ts"; | ||
| 27 | import type { AcpSessionRuntimeEvent } from "../acp/AcpSessionRuntime.ts"; | ||
| 28 | import { makeAntigravityAcpRuntime } from "../acp/AntigravityAcpSupport.ts"; | ||
| 29 | import { makeAntigravityAdapter, type AntigravityAdapterOptions } from "./AntigravityAdapter.ts"; | ||
| 30 | |||
| 31 | const instanceId = ProviderInstanceId.make("antigravity-test"); | ||
| 32 | const threadId = ThreadId.make("antigravity-thread"); | ||
| 33 | const nativeSessionId = "b75db7e9-cd99-40e5-aa63-ac2b4674a6a9"; | ||
| 34 | const nativeDefault = "gemini-test-low"; | ||
| 35 | const nativeAlternative = "gemini-test-high"; | ||
| 36 | const decodeSettings = Schema.decodeSync(AntigravitySettings); | ||
| 37 | const decodeRequestLog = Schema.decodeEffect( | ||
| 38 | Schema.Array( | ||
| 39 | Schema.fromJsonString( | ||
| 40 | Schema.Struct({ method: Schema.String, params: Schema.optional(Schema.Unknown) }), | ||
| 41 | ), | ||
| 42 | ), | ||
| 43 | ); | ||
| 44 | |||
| 45 | interface NativePrompt { | ||
| 46 | readonly index: number; | ||
| 47 | readonly result: Deferred.Deferred<AcpSchema.PromptResponse, AcpErrors.AcpError>; | ||
| 48 | } | ||
| 49 | |||
| 50 | type Runtime = Effect.Success<ReturnType<AntigravityAdapterOptions["makeRuntime"]>>; | ||
| 51 | |||
| 52 | const 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 | |||
| 279 | const layer = ServerConfig.layerTest(process.cwd(), { | ||
| 280 | prefix: "t3-antigravity-adapter-test-", | ||
| 281 | }).pipe(Layer.provideMerge(NodeServices.layer)); | ||
| 282 | |||
| 283 | it.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 @@ | ||
| 1 | import { | ||
| 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"; | ||
| 19 | import * as Cause from "effect/Cause"; | ||
| 20 | import * as Crypto from "effect/Crypto"; | ||
| 21 | import * as DateTime from "effect/DateTime"; | ||
| 22 | import * as Deferred from "effect/Deferred"; | ||
| 23 | import * as Effect from "effect/Effect"; | ||
| 24 | import * as Exit from "effect/Exit"; | ||
| 25 | import * as Fiber from "effect/Fiber"; | ||
| 26 | import * as FileSystem from "effect/FileSystem"; | ||
| 27 | import * as Option from "effect/Option"; | ||
| 28 | import * as Path from "effect/Path"; | ||
| 29 | import * as PubSub from "effect/PubSub"; | ||
| 30 | import * as Schema from "effect/Schema"; | ||
| 31 | import * as Scope from "effect/Scope"; | ||
| 32 | import * as Semaphore from "effect/Semaphore"; | ||
| 33 | import * as Stream from "effect/Stream"; | ||
| 34 | import * as SynchronizedRef from "effect/SynchronizedRef"; | ||
| 35 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 36 | import type * as EffectAcpSchema from "effect-acp/schema"; | ||
| 37 | |||
| 38 | import { ServerConfig } from "../../config.ts"; | ||
| 39 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; | ||
| 40 | import type { AntigravityAuth } from "../AntigravityAuth.ts"; | ||
| 41 | import { | ||
| 42 | ProviderAdapterRequestError, | ||
| 43 | ProviderAdapterSessionClosedError, | ||
| 44 | ProviderAdapterSessionNotFoundError, | ||
| 45 | ProviderAdapterValidationError, | ||
| 46 | type ProviderAdapterError, | ||
| 47 | } from "../Errors.ts"; | ||
| 48 | import { | ||
| 49 | ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE, | ||
| 50 | isAntigravitySignInRequiredError, | ||
| 51 | } from "../antigravityAuthSupport.ts"; | ||
| 52 | import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; | ||
| 53 | import { | ||
| 54 | makeAcpAssistantItemEvent, | ||
| 55 | makeAcpContentDeltaEvent, | ||
| 56 | makeAcpPlanUpdatedEvent, | ||
| 57 | makeAcpRequestOpenedEvent, | ||
| 58 | makeAcpRequestResolvedEvent, | ||
| 59 | makeAcpToolCallEvent, | ||
| 60 | } from "../acp/AcpCoreRuntimeEvents.ts"; | ||
| 61 | import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; | ||
| 62 | import { parsePermissionRequest, type AcpToolCallState } from "../acp/AcpRuntimeModel.ts"; | ||
| 63 | import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; | ||
| 64 | import { | ||
| 65 | antigravityPermissionMode, | ||
| 66 | antigravityModelOptions, | ||
| 67 | applyAntigravityAcpModelSelection, | ||
| 68 | buildAntigravityPrompt, | ||
| 69 | type AntigravityAcpRuntimeInput, | ||
| 70 | resolveAntigravityModel, | ||
| 71 | } from "../acp/AntigravityAcpSupport.ts"; | ||
| 72 | import { | ||
| 73 | antigravityApprovalOptions, | ||
| 74 | extractAntigravityUserInputQuestion, | ||
| 75 | isAntigravityOpenCommand, | ||
| 76 | isAntigravityUserInputRequest, | ||
| 77 | makeAntigravityUserInputResponse, | ||
| 78 | normalizeAntigravityToolCall, | ||
| 79 | sanitizeAntigravityToolPayload, | ||
| 80 | selectAntigravityPermissionOptionId, | ||
| 81 | } from "../acp/AntigravityProtocol.ts"; | ||
| 82 | import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; | ||
| 83 | import type { EventNdjsonLogger } from "./EventNdjsonLogger.ts"; | ||
| 84 | |||
| 85 | const PROVIDER = ProviderDriverKind.make("antigravity"); | ||
| 86 | const ResumeCursor = Schema.Struct({ | ||
| 87 | schemaVersion: Schema.Literal(1), | ||
| 88 | sessionId: Schema.NonEmptyString, | ||
| 89 | }); | ||
| 90 | const decodeResumeCursor = Schema.decodeUnknownOption(ResumeCursor); | ||
| 91 | const isAcpError = Schema.is(EffectAcpErrors.AcpError); | ||
| 92 | |||
| 93 | type Adapter = ProviderAdapterShape<ProviderAdapterError>; | ||
| 94 | type 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 | >; | ||
| 108 | type NativePermission = EffectAcpSchema.RequestPermissionRequest; | ||
| 109 | type NativePermissionResponse = EffectAcpSchema.RequestPermissionResponse; | ||
| 110 | |||
| 111 | function 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 | |||
| 122 | export 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 | |||
| 142 | interface PendingApproval { | ||
| 143 | readonly request: NativePermission; | ||
| 144 | readonly response: Deferred.Deferred<{ | ||
| 145 | readonly decision: ProviderApprovalDecision; | ||
| 146 | readonly result: NativePermissionResponse; | ||
| 147 | }>; | ||
| 148 | } | ||
| 149 | |||
| 150 | interface PendingQuestion { | ||
| 151 | readonly request: NativePermission; | ||
| 152 | readonly response: Deferred.Deferred<{ | ||
| 153 | readonly answers: ProviderUserInputAnswers; | ||
| 154 | readonly result: NativePermissionResponse; | ||
| 155 | }>; | ||
| 156 | } | ||
| 157 | |||
| 158 | interface OpenCommand { | ||
| 159 | readonly toolCall: AcpToolCallState; | ||
| 160 | readonly turnId: TurnId | undefined; | ||
| 161 | readonly promoted: boolean; | ||
| 162 | } | ||
| 163 | |||
| 164 | interface TurnIntent { | ||
| 165 | readonly turnId: TurnId; | ||
| 166 | readonly generation: number; | ||
| 167 | settled: boolean; | ||
| 168 | } | ||
| 169 | |||
| 170 | interface 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 | |||
| 192 | const CLIENT_FILE_MAX_BYTES = 8 * 1024 * 1024; | ||
| 193 | |||
| 194 | function 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. */ | ||
| 200 | const 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 | |||
| 226 | const 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 | |||
| 264 | const 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. */ | ||
| 281 | export 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 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | ANTIGRAVITY_DEFAULT_MODEL, | ||
| 4 | AntigravitySettings, | ||
| 5 | ProviderDriverKind, | ||
| 6 | ProviderInstanceId, | ||
| 7 | ProviderSetupError, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import * as Deferred from "effect/Deferred"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Fiber from "effect/Fiber"; | ||
| 12 | import * as Layer from "effect/Layer"; | ||
| 13 | import * as Ref from "effect/Ref"; | ||
| 14 | import * as Schema from "effect/Schema"; | ||
| 15 | import * as Stream from "effect/Stream"; | ||
| 16 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 17 | import type * as EffectAcpSchema from "effect-acp/schema"; | ||
| 18 | |||
| 19 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | ||
| 20 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 21 | import type { AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts"; | ||
| 22 | import { | ||
| 23 | buildAntigravityModelsFromSession, | ||
| 24 | makeAntigravityProvider, | ||
| 25 | } from "./AntigravityProvider.ts"; | ||
| 26 | |||
| 27 | const decodeSettings = Schema.decodeSync(AntigravitySettings); | ||
| 28 | const instanceId = ProviderInstanceId.make("antigravity-test"); | ||
| 29 | const driver = ProviderDriverKind.make("antigravity"); | ||
| 30 | |||
| 31 | const 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 | |||
| 46 | const 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 | |||
| 60 | const 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 | |||
| 69 | const 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 | |||
| 91 | const started = { | ||
| 92 | sessionId: "session-1", | ||
| 93 | initializeResult, | ||
| 94 | sessionSetupResult, | ||
| 95 | modelConfigId: "model", | ||
| 96 | } satisfies AcpSessionRuntimeStartResult; | ||
| 97 | |||
| 98 | const 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 | |||
| 103 | const testLayer = Layer.merge( | ||
| 104 | Layer.mock(BackgroundPolicy.BackgroundPolicy)({ | ||
| 105 | shouldRunScopeWork: () => Effect.succeed(false), | ||
| 106 | }), | ||
| 107 | ServerSettingsService.layerTest(), | ||
| 108 | ); | ||
| 109 | |||
| 110 | type ProbeError = EffectAcpErrors.AcpError | ProviderSetupError; | ||
| 111 | |||
| 112 | const 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 | |||
| 157 | describe("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 | |||
| 215 | it.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 @@ | ||
| 1 | import { | ||
| 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"; | ||
| 10 | import { createModelCapabilities } from "@t3tools/shared/model"; | ||
| 11 | import * as DateTime from "effect/DateTime"; | ||
| 12 | import * as Effect from "effect/Effect"; | ||
| 13 | import * as Option from "effect/Option"; | ||
| 14 | import * as Predicate from "effect/Predicate"; | ||
| 15 | import * as Result from "effect/Result"; | ||
| 16 | import * as Stream from "effect/Stream"; | ||
| 17 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 18 | import type * as EffectAcpErrors from "effect-acp/errors"; | ||
| 19 | import type * as EffectAcpSchema from "effect-acp/schema"; | ||
| 20 | |||
| 21 | import type { AcpSessionRuntimeStartResult } from "../acp/AcpSessionRuntime.ts"; | ||
| 22 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | ||
| 23 | import { | ||
| 24 | makeManualOnlyProviderMaintenanceCapabilities, | ||
| 25 | type ProviderMaintenanceCapabilities, | ||
| 26 | } from "../providerMaintenance.ts"; | ||
| 27 | import { | ||
| 28 | buildServerProvider, | ||
| 29 | isCommandMissingCause, | ||
| 30 | type ServerProviderDraft, | ||
| 31 | } from "../providerSnapshot.ts"; | ||
| 32 | |||
| 33 | const EMPTY_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); | ||
| 34 | const MAX_WORKSPACE_SNAPSHOTS = 32; | ||
| 35 | const SIGN_IN_MESSAGE = "Sign in with Google to use Antigravity."; | ||
| 36 | const AUTH_UNCHECKED_MESSAGE = | ||
| 37 | "Antigravity is installed. Google account access is not checked yet."; | ||
| 38 | |||
| 39 | type SessionSetupResult = AcpSessionRuntimeStartResult["sessionSetupResult"]; | ||
| 40 | |||
| 41 | /** Keep the native model IDs, including model-specific thinking levels. */ | ||
| 42 | export 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 | |||
| 77 | function 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 | |||
| 96 | function 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 | |||
| 110 | interface AntigravityProviderState { | ||
| 111 | readonly draft: ServerProviderDraft; | ||
| 112 | readonly authRevision: number; | ||
| 113 | } | ||
| 114 | |||
| 115 | interface 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. */ | ||
| 128 | export 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 @@ | ||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | import { | ||
| 3 | ProviderDriverKind, | ||
| 4 | ProviderInstanceId, | ||
| 5 | ProviderSetupError, | ||
| 6 | ThreadId, | ||
| 7 | type ProviderAuthState, | ||
| 8 | type ProviderSession, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Deferred from "effect/Deferred"; | ||
| 12 | import * as Fiber from "effect/Fiber"; | ||
| 13 | import * as Layer from "effect/Layer"; | ||
| 14 | import * as PubSub from "effect/PubSub"; | ||
| 15 | import * as Queue from "effect/Queue"; | ||
| 16 | import * as Stream from "effect/Stream"; | ||
| 17 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 18 | |||
| 19 | import { | ||
| 20 | ProviderSessionDirectoryPersistenceError, | ||
| 21 | ProviderValidationError, | ||
| 22 | type ProviderServiceError, | ||
| 23 | } from "../Errors.ts"; | ||
| 24 | import type { ProviderInstance } from "../ProviderDriver.ts"; | ||
| 25 | import type { ProviderAuthController } from "../Services/ProviderAuthService.ts"; | ||
| 26 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; | ||
| 27 | import { ProviderService } from "../Services/ProviderService.ts"; | ||
| 28 | import { | ||
| 29 | ProviderSessionDirectory, | ||
| 30 | type ProviderRuntimeBindingWithMetadata, | ||
| 31 | } from "../Services/ProviderSessionDirectory.ts"; | ||
| 32 | import { makeProviderAuthService } from "./ProviderAuthService.ts"; | ||
| 33 | |||
| 34 | const instanceId = ProviderInstanceId.make("antigravity-personal"); | ||
| 35 | const otherInstanceId = ProviderInstanceId.make("antigravity-work"); | ||
| 36 | const unsupportedInstanceId = ProviderInstanceId.make("codex"); | ||
| 37 | const driverKind = ProviderDriverKind.make("antigravity"); | ||
| 38 | const owner = "paired-client-owner"; | ||
| 39 | const otherOwner = "paired-client-other"; | ||
| 40 | const flowId = "test-sign-in-flow"; | ||
| 41 | const callbackUrl = "http://127.0.0.1:48123/?state=test-state&code=test-code"; | ||
| 42 | const now = "2026-09-02T00:00:00.000Z"; | ||
| 43 | const idleAuthState: ProviderAuthState = { | ||
| 44 | instanceId, | ||
| 45 | phase: "idle", | ||
| 46 | flowId: null, | ||
| 47 | authorizationUrl: null, | ||
| 48 | expiresAt: null, | ||
| 49 | message: null, | ||
| 50 | }; | ||
| 51 | const 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 | |||
| 59 | function 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 | |||
| 81 | function 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 | |||
| 95 | function 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 | |||
| 107 | const 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 | |||
| 227 | const 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 | |||
| 254 | const 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 | |||
| 296 | const 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 | |||
| 307 | describe("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 @@ | ||
| 1 | import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as Stream from "effect/Stream"; | ||
| 5 | |||
| 6 | import { ProviderAuthService } from "../Services/ProviderAuthService.ts"; | ||
| 7 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; | ||
| 8 | import { ProviderService } from "../Services/ProviderService.ts"; | ||
| 9 | import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; | ||
| 10 | |||
| 11 | export 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 | |||
| 120 | export 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"; |
| 42 | 42 | ||
| 43 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | 43 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; |
| 44 | import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; | 44 | import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; |
| 45 | import { AntigravityInstallation } from "../AntigravityInstallation.ts"; | ||
| 45 | import { ServerConfig } from "../../config.ts"; | 46 | import { ServerConfig } from "../../config.ts"; |
| 46 | import { ServerSettingsService } from "../../serverSettings.ts"; | 47 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 47 | import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; | 48 | import { 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`'s | 309 | // 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"; |
| 35 | import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; | 35 | import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; |
| 36 | import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; | 36 | import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; |
| 37 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | 37 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; |
| 38 | import { AntigravityInstallation } from "../AntigravityInstallation.ts"; | ||
| 38 | import * as ModelManifest from "../ModelManifest.ts"; | 39 | import * as ModelManifest from "../ModelManifest.ts"; |
| 39 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; | 40 | import * as OpenCodeRuntime from "../opencodeRuntime.ts"; |
| 40 | import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; | 41 | import * 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 | }); |
| 899 | 900 | ||
| 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 | ); |
| 1934 | 2049 | ||
| 1935 | // Guards the second half of the reported bug: changing | 2050 | // 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 |
| 2002 | 2121 | ||
| 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 with | 2124 | const codexSnapshots = registry.streamChanges.pipe( |
| 2006 | // `firstMissing`, so the real spawner yields ENOENT and the | 2125 | 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 += 1 | 2133 | 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]); |
| 2026 | 2141 | ||
| 2027 | // Drive a settings change. The Hydration layer's | 2142 | const pendingRebuild = yield* Stream.toPull( |
| 2028 | // `SettingsWatcherLive` consumes this via `subscribeChanges`, | 2143 | codexSnapshots.pipe( |
| 2029 | // calls `reconcile`, which rebuilds the codex instance (the | 2144 | 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); |
| 2043 | 2155 | ||
| 2044 | // Poll until the injected process boundary observes the new | 2156 | // Hold the second probe until the aggregator sees the rebuilt |
| 2045 | // executable. This verifies the public settings-to-probe behavior | 2157 | // 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 | ); |
| 2214 | 2316 | ||
| 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 | } |
| 102 | 102 | ||
| 103 | const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { | 103 | const 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 | } |
| 107 | 108 | ||
| 108 | // OpenCode's initial snapshot is deliberately non-authoritative while its | 109 | if (isAntigravity && (!provider.enabled || provider.auth.status === "unauthenticated")) { |
| 109 | // first probe is still running. A probe error from an installed CLI/server | 110 | 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 after | 113 | // 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 | }; |
| 119 | 124 | ||
| 120 | const shouldRetainMissingOpenCodeMetadata = (provider: ServerProvider): boolean => | 125 | const 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 | }; |
| 122 | 122 | ||
| 123 | function makeFakeCodexAdapter(provider: ProviderDriverKind = CODEX_DRIVER) { | 123 | function 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>()); |
| 126 | 129 | ||
| ... | @@ -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 = ( |
| 313 | function makeProviderServiceLayer( | 317 | function 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 | }); | ||
| 326 | 334 | ||
| 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 |
| 636 | 644 | ||
| 637 | const routing = makeProviderServiceLayer(); | 645 | const routing = makeProviderServiceLayer(); |
| 638 | 646 | ||
| 647 | const antigravityDriver = ProviderDriverKind.make("antigravity"); | ||
| 648 | const replacementAntigravity = makeFakeCodexAdapter(antigravityDriver); | ||
| 649 | const originalAntigravityInstanceId = ProviderInstanceId.make("antigravity-personal"); | ||
| 650 | const replacementAntigravityInstanceId = ProviderInstanceId.make("antigravity"); | ||
| 651 | const antigravityRegistry = makeAdapterRegistryMock({ | ||
| 652 | [antigravityDriver]: replacementAntigravity.adapter, | ||
| 653 | }); | ||
| 654 | let originalAntigravityInstanceAvailable = true; | ||
| 655 | const 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 | }); | ||
| 673 | antigravityInstanceRouting.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 | |||
| 720 | const unsupportedRollback = makeProviderServiceLayer({ supportsConversationRollback: false }); | ||
| 721 | unsupportedRollback.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 | |||
| 639 | it.effect( | 760 | it.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(); |
| 1273 | 1394 | ||
| 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 === resolvedInstanceId | 645 | (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); |
| 1106 | 1126 | ||
| 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 multiple | 1298 | // 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"; |
| 9 | import * as ServerConfig from "../config.ts"; | 9 | import * as ServerConfig from "../config.ts"; |
| 10 | import * as ServerSettings from "../serverSettings.ts"; | 10 | import * as ServerSettings from "../serverSettings.ts"; |
| 11 | import { | 11 | import { |
| 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 | }); |
| 61 | 62 | ||
| 63 | describe("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 | |||
| 62 | describe("resolveProviderCatalog", () => { | 93 | describe("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. */ | ||
| 230 | export 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 | */ | ||
| 244 | export 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 | } |
| 221 | 266 | ||
| 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"; |
| 35 | import type { ProviderAdapterError, ProviderDriverError } from "./Errors.ts"; | 35 | import type { ProviderAdapterError, ProviderDriverError } from "./Errors.ts"; |
| 36 | import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts"; | 36 | import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts"; |
| 37 | import type { ServerProviderShape } from "./Services/ServerProvider.ts"; | 37 | import type { ServerProviderShape } from "./Services/ServerProvider.ts"; |
| 38 | import type { ProviderAuthController } from "./Services/ProviderAuthService.ts"; | ||
| 38 | 39 | ||
| 39 | /** | 40 | /** |
| 40 | * Static metadata advertised by a driver. Used for default presentation | 41 | * 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 | } |
| 77 | 80 | ||
| 78 | export interface ProviderContinuationIdentity { | 81 | export 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 the | 35 | /** 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 | } |
| 39 | 41 | ||
| 40 | export interface ProviderThreadTurnSnapshot { | 42 | export interface ProviderThreadTurnSnapshot { |
apps/server/src/provider/Services/ProviderAuthService.ts created+59| ... | @@ -0,0 +1,59 @@ | ||
| 1 | import type { ProviderAuthState, ProviderInstanceId, ProviderSetupError } from "@t3tools/contracts"; | ||
| 2 | import * as Context from "effect/Context"; | ||
| 3 | import type * as Effect from "effect/Effect"; | ||
| 4 | import type * as Stream from "effect/Stream"; | ||
| 5 | |||
| 6 | export 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 | |||
| 27 | interface ProviderAuthTarget { | ||
| 28 | readonly instanceId: ProviderInstanceId; | ||
| 29 | } | ||
| 30 | |||
| 31 | export 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 | |||
| 56 | export 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>; |
| 101 | 101 | ||
| 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 | }; |
| 29 | 29 | ||
| 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"); | ||
| 51 | 51 | ||
| 52 | expect( | 52 | expect( |
| 53 | makeAcpRequestResolvedEvent({ | 53 | makeAcpRequestResolvedEvent({ |
| ... | @@ -68,6 +68,36 @@ describe("AcpCoreRuntimeEvents", () => { | ... | @@ -68,6 +68,36 @@ describe("AcpCoreRuntimeEvents", () => { |
| 68 | }); | 68 | }); |
| 69 | }); | 69 | }); |
| 70 | 70 | ||
| 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" }; |
| 73 | 103 | ||
| ... | @@ -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"; |
| 6 | 6 | ||
| 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 8 | import { it } from "@effect/vitest"; | 8 | import { it } from "@effect/vitest"; |
| 9 | import * as Deferred from "effect/Deferred"; | ||
| 9 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Exit from "effect/Exit"; | ||
| 10 | import * as Fiber from "effect/Fiber"; | 12 | import * as Fiber from "effect/Fiber"; |
| 11 | import * as Option from "effect/Option"; | 13 | import * as Option from "effect/Option"; |
| 14 | import * as Scope from "effect/Scope"; | ||
| 12 | import * as TestClock from "effect/testing/TestClock"; | 15 | import * as TestClock from "effect/testing/TestClock"; |
| 13 | import * as Stream from "effect/Stream"; | 16 | import * as Stream from "effect/Stream"; |
| 14 | import { describe, expect } from "vite-plus/test"; | 17 | import { 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)); |
| 20 | const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); | 23 | const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); |
| 21 | const mockAgentCommand = "node"; | 24 | const mockAgentCommand = "node"; |
| 22 | const mockAgentArgs = [mockAgentPath]; | 25 | const mockAgentArgs = [mockAgentPath]; |
| 26 | const 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; | ||
| 23 | 32 | ||
| 24 | describe("AcpSessionRuntime", () => { | 33 | describe("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 | }); |
| 341 | 341 | ||
| 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 | }; |
| 112 | 122 | ||
| 113 | type AcpSessionSetupResponse = | 123 | type 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; |
| 779 | 789 | ||
| 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"; |
| 5 | import * as Deferred from "effect/Deferred"; | 5 | import * as Deferred from "effect/Deferred"; |
| 6 | import * as Duration from "effect/Duration"; | 6 | import * as Duration from "effect/Duration"; |
| 7 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Exit from "effect/Exit"; | ||
| 8 | import * as Fiber from "effect/Fiber"; | 9 | import * as Fiber from "effect/Fiber"; |
| 9 | import * as Layer from "effect/Layer"; | 10 | import * as Layer from "effect/Layer"; |
| 10 | import * as Option from "effect/Option"; | 11 | import * 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 | } |
| 55 | 56 | ||
| 56 | export type AcpSessionRuntimeEvent = AcpParsedSessionEvent | AcpSessionEventStreamBarrier; | 57 | export type AcpSessionRuntimeEvent = |
| 58 | | AcpParsedSessionEvent | ||
| 59 | | AcpSessionEventStreamBarrier | ||
| 60 | | { | ||
| 61 | readonly _tag: "ConnectionTerminated"; | ||
| 62 | readonly error: EffectAcpErrors.AcpError; | ||
| 63 | }; | ||
| 57 | 64 | ||
| 58 | const defaultSessionLoadTimeout = Duration.seconds(90); | 65 | const defaultSessionLoadTimeout = Duration.seconds(90); |
| 59 | const defaultSessionLoadReplayIdleGap = Duration.seconds(2); | 66 | const defaultSessionLoadReplayIdleGap = Duration.seconds(2); |
| 67 | const defaultCancelTimeout = Duration.seconds(15); | ||
| 68 | const maxStartupMetadataUpdates = 32; | ||
| 69 | const maxStderrChunkLength = 8_192; | ||
| 60 | 70 | ||
| 61 | export interface AcpSpawnInput { | 71 | export 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 | } |
| 67 | 78 | ||
| 68 | export interface AcpSessionRuntimeOptions { | 79 | export 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 | } |
| 290 | 315 | ||
| 316 | interface AcpActivePrompt { | ||
| 317 | readonly fiber: Fiber.Fiber<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>; | ||
| 318 | readonly completed: Deferred.Deferred<void>; | ||
| 319 | } | ||
| 320 | |||
| 291 | export const make = ( | 321 | export 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()); |
| 322 | 360 | ||
| 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; |
| 325 | 394 | ||
| ... | @@ -351,16 +420,16 @@ export const make = ( | ... | @@ -351,16 +420,16 @@ export const make = ( |
| 351 | ), | 420 | ), |
| 352 | ); | 421 | ); |
| 353 | 422 | ||
| 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* spawner | 427 | 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 | ); |
| 377 | 446 | ||
| 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 !== undefined | 463 | ...(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 = ( |
| 389 | 472 | ||
| 390 | const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); | 473 | const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); |
| 391 | 474 | ||
| 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 need | 504 | }), |
| 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.sessionId | 508 | 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"]>; |
| 441 | 555 | ||
| 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.LoadSessionResponse | 694 | | EffectAcpSchema.LoadSessionResponse |
| 580 | | EffectAcpSchema.NewSessionResponse | 695 | | 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 | }); |
| 681 | 835 | ||
| 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.AcpError | 840 | 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 | }); |
| 713 | 882 | ||
| 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/prompt | 1008 | 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 | }); |
| 839 | 1064 | ||
| ... | @@ -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 | } |
| 871 | 1096 | ||
| 1097 | function 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 | |||
| 872 | const handleSessionUpdate = ({ | 1108 | const 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 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import { | ||
| 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"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as FileSystem from "effect/FileSystem"; | ||
| 12 | import * as Path from "effect/Path"; | ||
| 13 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 14 | import type * as EffectAcpSchema from "effect-acp/schema"; | ||
| 15 | |||
| 16 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | ||
| 17 | import { | ||
| 18 | ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES, | ||
| 19 | antigravityPermissionMode, | ||
| 20 | applyAntigravityAcpModelSelection, | ||
| 21 | buildAntigravityPrompt, | ||
| 22 | } from "./AntigravityAcpSupport.ts"; | ||
| 23 | |||
| 24 | const 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 | |||
| 35 | function 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 | |||
| 50 | describe("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 | |||
| 175 | describe("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 | |||
| 189 | const 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 | |||
| 197 | const 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 | |||
| 205 | const 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 | |||
| 213 | const 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 | |||
| 234 | it.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 @@ | ||
| 1 | import { | ||
| 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"; | ||
| 9 | import * as Crypto from "effect/Crypto"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as FileSystem from "effect/FileSystem"; | ||
| 12 | import * as Layer from "effect/Layer"; | ||
| 13 | import * as Path from "effect/Path"; | ||
| 14 | import * as Scope from "effect/Scope"; | ||
| 15 | import * as Stream from "effect/Stream"; | ||
| 16 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | ||
| 17 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 18 | import type * as EffectAcpSchema from "effect-acp/schema"; | ||
| 19 | |||
| 20 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | ||
| 21 | import { | ||
| 22 | drainAntigravityStderr, | ||
| 23 | makeAntigravityStdoutTransform, | ||
| 24 | } from "../antigravityAuthSupport.ts"; | ||
| 25 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; | ||
| 26 | import { normalizeAntigravitySessionUpdate } from "./AntigravityProtocol.ts"; | ||
| 27 | |||
| 28 | export 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`. */ | ||
| 53 | export 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 | |||
| 87 | export 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 | |||
| 99 | export 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 | */ | ||
| 113 | export 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. */ | ||
| 128 | export 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 | |||
| 167 | const 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. | ||
| 169 | const 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 | ]); | ||
| 182 | export const ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES = 20 * 1024 * 1024; | ||
| 183 | const 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 | ]); | ||
| 193 | const 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 | ]); | ||
| 242 | export const ANTIGRAVITY_MAX_TEXT_ATTACHMENT_BYTES = 1024 * 1024; | ||
| 243 | const MAX_TOTAL_ATTACHMENT_BYTES = PROVIDER_SEND_TURN_MAX_FILE_BYTES; | ||
| 244 | |||
| 245 | /** Sends uploads as native ACP content instead of workspace path hints. */ | ||
| 246 | export 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 | |||
| 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 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | import * as EffectAcpSchema from "effect-acp/schema"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | extractAntigravityUserInputQuestion, | ||
| 7 | isAntigravityOpenCommand, | ||
| 8 | antigravityApprovalOptions, | ||
| 9 | isAntigravityUserInputRequest, | ||
| 10 | makeAntigravityUserInputResponse, | ||
| 11 | normalizeAntigravitySessionUpdate, | ||
| 12 | normalizeAntigravityToolCall, | ||
| 13 | sanitizeAntigravityToolPayload, | ||
| 14 | selectAntigravityPermissionOptionId, | ||
| 15 | } from "./AntigravityProtocol.ts"; | ||
| 16 | import { mergeToolCallState, parseSessionUpdateEvent } from "./AcpRuntimeModel.ts"; | ||
| 17 | |||
| 18 | const isSessionNotification = Schema.is(EffectAcpSchema.SessionNotification); | ||
| 19 | |||
| 20 | const 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 | |||
| 34 | const 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 | |||
| 46 | const 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 | |||
| 63 | function 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 | |||
| 72 | describe("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 | |||
| 239 | describe("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 @@ | ||
| 1 | import type { | ||
| 2 | ProviderApprovalDecision, | ||
| 3 | ProviderApprovalOption, | ||
| 4 | ProviderUserInputAnswers, | ||
| 5 | UserInputQuestion, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | import * as Predicate from "effect/Predicate"; | ||
| 10 | import * as Schema from "effect/Schema"; | ||
| 11 | import * as EffectAcpSchema from "effect-acp/schema"; | ||
| 12 | |||
| 13 | import type { AcpToolCallState } from "./AcpRuntimeModel.ts"; | ||
| 14 | |||
| 15 | const TOOL_TEXT_LIMIT = 8_000; | ||
| 16 | const TOOL_TEXT_TRUNCATED = "[Earlier output truncated]\n\n"; | ||
| 17 | const QUESTION_LABEL_LIMIT = 512; | ||
| 18 | |||
| 19 | const 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 | }); | ||
| 35 | const decodeNativeToolFields = Schema.decodeUnknownOption(NativeToolFields); | ||
| 36 | const decodeSingleAnswer = Schema.decodeUnknownOption( | ||
| 37 | Schema.Union([Schema.String, Schema.Tuple([Schema.String])]), | ||
| 38 | ); | ||
| 39 | const decodeToolCallContent = Schema.decodeUnknownOption(EffectAcpSchema.ToolCallContent); | ||
| 40 | |||
| 41 | /** Native questions share the permission method, but their choices are not approvals. */ | ||
| 42 | export function isAntigravityUserInputRequest( | ||
| 43 | request: EffectAcpSchema.RequestPermissionRequest, | ||
| 44 | ): boolean { | ||
| 45 | return request.toolCall.toolCallId.startsWith("interaction_"); | ||
| 46 | } | ||
| 47 | |||
| 48 | export 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. */ | ||
| 62 | const SECURITY_WARNING_META_KEY = "agy.security.warning"; | ||
| 63 | const WARNING_TEXT_LIMIT = 512; | ||
| 64 | const 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 | */ | ||
| 75 | export 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. */ | ||
| 89 | export 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 | |||
| 116 | function copyBoundedText(text: string): string { | ||
| 117 | return Buffer.from(text, "utf16le").toString("utf16le"); | ||
| 118 | } | ||
| 119 | |||
| 120 | function 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 | |||
| 127 | export 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. */ | ||
| 159 | export 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 | |||
| 180 | function 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 | |||
| 186 | interface ToolPayloadBudget { | ||
| 187 | nodes: number; | ||
| 188 | text: number; | ||
| 189 | } | ||
| 190 | |||
| 191 | function 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. */ | ||
| 235 | export 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. */ | ||
| 240 | export 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 | |||
| 272 | function 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 | |||
| 293 | export 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. */ | ||
| 349 | export 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 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as FileSystem from "effect/FileSystem"; | ||
| 3 | import * as Path from "effect/Path"; | ||
| 4 | import * as Schema from "effect/Schema"; | ||
| 5 | |||
| 6 | const isNativeSessionId = Schema.is(Schema.String.check(Schema.isUUID(4))); | ||
| 7 | const 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. */ | ||
| 12 | export 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 | ||
| 2 | import * as NodeChildProcess from "node:child_process"; | ||
| 3 | |||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 5 | import { ProviderInstanceId } from "@t3tools/contracts"; | ||
| 6 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 7 | import { describe, expect, it } from "@effect/vitest"; | ||
| 8 | import * as Effect from "effect/Effect"; | ||
| 9 | import * as FileSystem from "effect/FileSystem"; | ||
| 10 | import * as Path from "effect/Path"; | ||
| 11 | import * as Result from "effect/Result"; | ||
| 12 | import * as Schema from "effect/Schema"; | ||
| 13 | import * as Stream from "effect/Stream"; | ||
| 14 | import * as Ndjson from "effect/unstable/encoding/Ndjson"; | ||
| 15 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; | ||
| 16 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | ||
| 17 | import * as AcpErrors from "effect-acp/errors"; | ||
| 18 | |||
| 19 | import { | ||
| 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 | |||
| 35 | const 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"; | ||
| 39 | const authLine = `${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\n`; | ||
| 40 | const encode = (text: string) => new TextEncoder().encode(text); | ||
| 41 | const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 42 | |||
| 43 | const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 44 | |||
| 45 | describe("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 | |||
| 215 | describe("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 | |||
| 260 | describe("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 | |||
| 286 | describe("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 | |||
| 391 | it.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 @@ | ||
| 1 | import * 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. | ||
| 3 | import * as NodePath from "node:path"; | ||
| 4 | |||
| 5 | import type { AntigravityAuthMethod, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 6 | import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as FileSystem from "effect/FileSystem"; | ||
| 9 | import * as Path from "effect/Path"; | ||
| 10 | import type * as PlatformError from "effect/PlatformError"; | ||
| 11 | import * as Schema from "effect/Schema"; | ||
| 12 | import * as Stream from "effect/Stream"; | ||
| 13 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; | ||
| 14 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | ||
| 15 | import * as AcpErrors from "effect-acp/errors"; | ||
| 16 | |||
| 17 | import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; | ||
| 18 | import type { AcpSpawnInput } from "./acp/AcpSessionRuntime.ts"; | ||
| 19 | |||
| 20 | export const ANTIGRAVITY_AUTH_STDOUT_PREFIX = | ||
| 21 | "Open the following link to authenticate the ACP server: "; | ||
| 22 | export const ANTIGRAVITY_AUTH_BROWSER_MARKER = "__T3_ANTIGRAVITY_AUTH_URL__"; | ||
| 23 | export const ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE = | ||
| 24 | "Sign in to Antigravity in Settings before you continue."; | ||
| 25 | |||
| 26 | const maxAuthorizationUrlLength = 16_384; | ||
| 27 | const maxStdoutLineBytes = 16 * 1024 * 1024; | ||
| 28 | const authPrefixBytes = new TextEncoder().encode(ANTIGRAVITY_AUTH_STDOUT_PREFIX); | ||
| 29 | const decodeUrl = Schema.decodeUnknownEffect(Schema.URLFromString); | ||
| 30 | const 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 | }); | ||
| 39 | const encodeProfileSettings = Schema.encodeSync(Schema.fromJsonString(ProfileSettingsFile)); | ||
| 40 | const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError); | ||
| 41 | const 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. | ||
| 46 | const 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))`; | ||
| 50 | const browserPreflightUrl = "https://example.invalid/t3-antigravity-browser-preflight"; | ||
| 51 | |||
| 52 | const 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 | |||
| 72 | export 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 | */ | ||
| 85 | export interface AntigravityAuthConfig { | ||
| 86 | readonly authMethod: AntigravityAuthMethod; | ||
| 87 | readonly apiKey: string; | ||
| 88 | readonly gcpProject: string; | ||
| 89 | readonly gcpLocation: string; | ||
| 90 | } | ||
| 91 | |||
| 92 | export 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. */ | ||
| 100 | export 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. */ | ||
| 105 | export 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 | */ | ||
| 122 | export 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 | */ | ||
| 145 | export 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 | |||
| 156 | export interface AntigravityAuthorizationUrl { | ||
| 157 | readonly authorizationUrl: string; | ||
| 158 | readonly redirectUri: string; | ||
| 159 | readonly state: string; | ||
| 160 | } | ||
| 161 | |||
| 162 | function authSupportError(detail: string) { | ||
| 163 | return new AcpErrors.AcpTransportError({ detail, cause: undefined }); | ||
| 164 | } | ||
| 165 | |||
| 166 | /** Recognizes native auth failures and interactive login blocked by T3. */ | ||
| 167 | export 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. */ | ||
| 175 | export 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 | |||
| 183 | function quoteBrowserArgument(value: string): string { | ||
| 184 | return `'${value.replaceAll("'", `'"'"'`)}'`; | ||
| 185 | } | ||
| 186 | |||
| 187 | function 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. */ | ||
| 218 | export 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. */ | ||
| 328 | export 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. */ | ||
| 355 | export 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 | |||
| 392 | export 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. */ | ||
| 459 | export const drainAntigravityStderr = (_text: string): Effect.Effect<void> => Effect.void; | ||
apps/server/src/provider/antigravityCallback.test.ts created+48| ... | @@ -0,0 +1,48 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { ProviderInstanceId } from "@t3tools/contracts"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Exit from "effect/Exit"; | ||
| 5 | |||
| 6 | import { validateAntigravityCallbackUrl } from "./antigravityCallback.ts"; | ||
| 7 | |||
| 8 | const instanceId = ProviderInstanceId.make("antigravity-callback-test"); | ||
| 9 | const pending = { redirectUri: "http://127.0.0.1:51234/", state: "owned-state" }; | ||
| 10 | |||
| 11 | it.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 | |||
| 21 | it.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. | ||
| 2 | import * as NodeHttp from "node:http"; | ||
| 3 | |||
| 4 | import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | |||
| 7 | export 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. */ | ||
| 13 | export 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. */ | ||
| 66 | export 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 @@ | ||
| 1 | export const ANTIGRAVITY_RELEASE_VERSION = "agy_acp_server_20260818_01_RC01"; | ||
| 2 | |||
| 3 | export 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 | ||
| 20 | const 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 | |||
| 78 | export 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"; |
| 25 | import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; | 25 | import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; |
| 26 | import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; | 26 | import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; |
| 27 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; | 27 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; |
| 28 | import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts"; | ||
| 28 | import type { AnyProviderDriver } from "./ProviderDriver.ts"; | 29 | import type { AnyProviderDriver } from "./ProviderDriver.ts"; |
| 29 | 30 | ||
| 30 | /** | 31 | /** |
| ... | @@ -37,7 +38,8 @@ export type BuiltInDriversEnv = | ... | @@ -37,7 +38,8 @@ export type BuiltInDriversEnv = |
| 37 | | CodexDriverEnv | 38 | | CodexDriverEnv |
| 38 | | CursorDriverEnv | 39 | | CursorDriverEnv |
| 39 | | GrokDriverEnv | 40 | | GrokDriverEnv |
| 40 | | OpenCodeDriverEnv; | 41 | | OpenCodeDriverEnv |
| 42 | | AntigravityDriverEnv; | ||
| 41 | 43 | ||
| 42 | /** | 44 | /** |
| 43 | * Ordered list of built-in drivers. Order matters only for tie-breaking in | 45 | * 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": 1000000 | 233 | "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": 1000000 | 286 | "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 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, describe, it } from "@effect/vitest"; | ||
| 3 | import { | ||
| 4 | ProviderDriverKind, | ||
| 5 | ProviderInstanceId, | ||
| 6 | type ProviderInstallState, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as FileSystem from "effect/FileSystem"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | import * as Path from "effect/Path"; | ||
| 13 | import * as Stream from "effect/Stream"; | ||
| 14 | |||
| 15 | import { layerTest as settingsLayerTest } from "../serverSettings.ts"; | ||
| 16 | import { AntigravityInstallation } from "./AntigravityInstallation.ts"; | ||
| 17 | import type { ProviderInstance } from "./ProviderDriver.ts"; | ||
| 18 | import { makeProviderInstallation } from "./providerInstallation.ts"; | ||
| 19 | import { ProviderInstanceRegistry } from "./Services/ProviderInstanceRegistry.ts"; | ||
| 20 | import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; | ||
| 21 | |||
| 22 | const instanceId = ProviderInstanceId.make("antigravity"); | ||
| 23 | const driver = ProviderDriverKind.make("antigravity"); | ||
| 24 | const 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 | |||
| 36 | function 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 | |||
| 55 | const 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 | |||
| 105 | describe("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 @@ | ||
| 1 | import { | ||
| 2 | AntigravitySettings, | ||
| 3 | ProviderDriverKind, | ||
| 4 | type ProviderInstallCancelInput, | ||
| 5 | type ProviderInstanceId, | ||
| 6 | ProviderSetupError, | ||
| 7 | type ProviderSetupInput, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { resolveCommandPath } from "@t3tools/shared/shell"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Schema from "effect/Schema"; | ||
| 12 | import * as Stream from "effect/Stream"; | ||
| 13 | |||
| 14 | import { ServerSettingsService } from "../serverSettings.ts"; | ||
| 15 | import { | ||
| 16 | AntigravityInstallation, | ||
| 17 | type AntigravityInstallationError, | ||
| 18 | } from "./AntigravityInstallation.ts"; | ||
| 19 | import { deriveProviderInstanceConfigMap } from "./Layers/ProviderInstanceRegistryHydration.ts"; | ||
| 20 | import { ProviderInstanceRegistry } from "./Services/ProviderInstanceRegistry.ts"; | ||
| 21 | import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; | ||
| 22 | import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; | ||
| 23 | |||
| 24 | const ANTIGRAVITY = ProviderDriverKind.make("antigravity"); | ||
| 25 | const hasBinaryPath = Schema.is(Schema.Struct({ binaryPath: Schema.String })); | ||
| 26 | const decodeAntigravitySettings = Schema.decodeUnknownEffect(AntigravitySettings); | ||
| 27 | |||
| 28 | /** Route instance setup to the environment-owned installer without owning the download. */ | ||
| 29 | export 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 | }; |
| 31 | 31 | ||
| 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 | */ | ||
| 36 | const BUILT_IN_DRIVER_ORDER: ReadonlyArray<string> = [ | ||
| 37 | "codex", | ||
| 38 | "claudeAgent", | ||
| 39 | "cursor", | ||
| 40 | "grok", | ||
| 41 | "opencode", | ||
| 42 | "antigravity", | ||
| 43 | ]; | ||
| 44 | |||
| 45 | const 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 | |||
| 32 | export const orderProviderSnapshots = ( | 50 | export 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 | ); |
| 41 | 60 |
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"; |
| 112 | import { PersistenceSqlError } from "./persistence/Errors.ts"; | 115 | import { PersistenceSqlError } from "./persistence/Errors.ts"; |
| 113 | import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; | 116 | import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; |
| 114 | import * as ProviderService from "./provider/Services/ProviderService.ts"; | 117 | import * as ProviderService from "./provider/Services/ProviderService.ts"; |
| 118 | import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts"; | ||
| 119 | import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; | ||
| 120 | import { | ||
| 121 | AntigravityInstallation, | ||
| 122 | AntigravityInstallationError, | ||
| 123 | } from "./provider/AntigravityInstallation.ts"; | ||
| 124 | import type { ProviderInstance } from "./provider/ProviderDriver.ts"; | ||
| 115 | import { ProviderAdapterRequestError } from "./provider/Errors.ts"; | 125 | import { ProviderAdapterRequestError } from "./provider/Errors.ts"; |
| 116 | import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; | 126 | import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; |
| 117 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; | 127 | import * 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; |
| 194 | 204 | ||
| 205 | const providerSetupInstanceId = ProviderInstanceId.make("antigravity-custom-profile"); | ||
| 206 | const providerSetupDriver = ProviderDriverKind.make("antigravity"); | ||
| 207 | const 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 | }; | ||
| 218 | const providerSetupAuthState: ProviderAuthState = { | ||
| 219 | instanceId: providerSetupInstanceId, | ||
| 220 | phase: "idle", | ||
| 221 | flowId: null, | ||
| 222 | authorizationUrl: null, | ||
| 223 | expiresAt: null, | ||
| 224 | message: null, | ||
| 225 | }; | ||
| 226 | const 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 | |||
| 195 | const makeLiveToolActivityEvent = ( | 246 | const 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 | ); |
| 4983 | 5049 | ||
| 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 @@ |
| 1 | import { EnvironmentHttpApi } from "@t3tools/contracts"; | 1 | import { EnvironmentHttpApi, ProviderDriverKind } from "@t3tools/contracts"; |
| 2 | import * as Duration from "effect/Duration"; | 2 | import * as Duration from "effect/Duration"; |
| 3 | import * as Deferred from "effect/Deferred"; | 3 | import * as Deferred from "effect/Deferred"; |
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Schedule from "effect/Schedule"; | 6 | import * as Schedule from "effect/Schedule"; |
| 7 | import * as Stream from "effect/Stream"; | ||
| 7 | import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; | 8 | import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; |
| 8 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | 9 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; |
| 9 | 10 | ||
| ... | @@ -35,6 +36,10 @@ import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRe | ... | @@ -35,6 +36,10 @@ import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRe |
| 35 | import * as ModelManifest from "./provider/ModelManifest.ts"; | 36 | import * as ModelManifest from "./provider/ModelManifest.ts"; |
| 36 | import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; | 37 | import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; |
| 37 | import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; | 38 | import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; |
| 39 | import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts"; | ||
| 40 | import { AntigravityInstallation } from "./provider/AntigravityInstallation.ts"; | ||
| 41 | import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; | ||
| 42 | import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; | ||
| 38 | import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; | 43 | import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts"; |
| 39 | import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; | 44 | import * as OpenCodeRuntime from "./provider/opencodeRuntime.ts"; |
| 40 | import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; | 45 | import * 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 | ); |
| 408 | 413 | ||
| 414 | const 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 | |||
| 409 | const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( | 441 | const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 442 | Layer.provideMerge(AntigravityInstallationRefreshLive), | ||
| 443 | Layer.provideMerge(ProviderAuthServiceLive), | ||
| 410 | // Core Services | 444 | // 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-instance | 467 | // Shared native/canonical NDJSON writers used by both the per-instance |
| 432 | // drivers (native stream, written from inside each `<X>Adapter`) and | 468 | // 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 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { it } from "@effect/vitest"; | ||
| 3 | import { | ||
| 4 | ANTIGRAVITY_DEFAULT_MODEL, | ||
| 5 | ProviderInstanceId, | ||
| 6 | ProviderSetupError, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as Deferred from "effect/Deferred"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Exit from "effect/Exit"; | ||
| 11 | import * as Fiber from "effect/Fiber"; | ||
| 12 | import * as FileSystem from "effect/FileSystem"; | ||
| 13 | import * as Path from "effect/Path"; | ||
| 14 | import * as Queue from "effect/Queue"; | ||
| 15 | import * as Schema from "effect/Schema"; | ||
| 16 | import * as Scope from "effect/Scope"; | ||
| 17 | import * as Stream from "effect/Stream"; | ||
| 18 | import * as TestClock from "effect/testing/TestClock"; | ||
| 19 | import { type AcpError, AcpRequestError } from "effect-acp/errors"; | ||
| 20 | import type * as AcpSchema from "effect-acp/schema"; | ||
| 21 | import { expect } from "vite-plus/test"; | ||
| 22 | |||
| 23 | import type { AcpSessionRuntimeEvent } from "../provider/acp/AcpSessionRuntime.ts"; | ||
| 24 | import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts"; | ||
| 25 | |||
| 26 | import { | ||
| 27 | type AntigravityTextGenerationOptions, | ||
| 28 | isAntigravityTextGenerationAvailable, | ||
| 29 | makeAntigravityTextGeneration, | ||
| 30 | } from "./AntigravityTextGeneration.ts"; | ||
| 31 | |||
| 32 | type TextRuntime = Effect.Success<ReturnType<AntigravityTextGenerationOptions["makeRuntime"]>>; | ||
| 33 | |||
| 34 | const SESSION_ID = "047c62f6-607b-44db-bfbe-f83b67e9e8b1"; | ||
| 35 | const modelSelection = { | ||
| 36 | instanceId: ProviderInstanceId.make("antigravity-test"), | ||
| 37 | model: "gemini-test", | ||
| 38 | }; | ||
| 39 | const encodeMetadata = Schema.encodeEffect( | ||
| 40 | Schema.fromJsonString(Schema.Struct({ cwd: Schema.String })), | ||
| 41 | ); | ||
| 42 | |||
| 43 | interface 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 | |||
| 56 | const 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 | |||
| 280 | it.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 @@ | ||
| 1 | import { | ||
| 2 | type ModelSelection, | ||
| 3 | type ProviderSetupError, | ||
| 4 | TextGenerationError, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
| 7 | import { extractJsonObject } from "@t3tools/shared/schemaJson"; | ||
| 8 | import * as Deferred from "effect/Deferred"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Exit from "effect/Exit"; | ||
| 11 | import * as FileSystem from "effect/FileSystem"; | ||
| 12 | import * as Option from "effect/Option"; | ||
| 13 | import * as Path from "effect/Path"; | ||
| 14 | import * as Ref from "effect/Ref"; | ||
| 15 | import * as Schema from "effect/Schema"; | ||
| 16 | import * as Scope from "effect/Scope"; | ||
| 17 | import * as Stream from "effect/Stream"; | ||
| 18 | import { type AcpError, AcpRequestError } from "effect-acp/errors"; | ||
| 19 | |||
| 20 | import { applyAntigravityAcpModelSelection } from "../provider/acp/AntigravityAcpSupport.ts"; | ||
| 21 | import { removeAntigravitySessionFiles } from "../provider/acp/AntigravitySessionFiles.ts"; | ||
| 22 | import type { AcpSessionRuntime } from "../provider/acp/AcpSessionRuntime.ts"; | ||
| 23 | import type * as TextGeneration from "./TextGeneration.ts"; | ||
| 24 | import { | ||
| 25 | buildBranchNamePrompt, | ||
| 26 | buildCommitMessagePrompt, | ||
| 27 | buildPrContentPrompt, | ||
| 28 | buildThreadTitlePrompt, | ||
| 29 | } from "./TextGenerationPrompts.ts"; | ||
| 30 | import { | ||
| 31 | sanitizeCommitSubject, | ||
| 32 | sanitizePrTitle, | ||
| 33 | sanitizeThreadTitle, | ||
| 34 | } from "./TextGenerationUtils.ts"; | ||
| 35 | |||
| 36 | const ANTIGRAVITY_TIMEOUT_MS = 180_000; | ||
| 37 | const MAX_OUTPUT_CHARS = 128_000; | ||
| 38 | const isTextGenerationError = Schema.is(TextGenerationError); | ||
| 39 | const isNativeSessionId = Schema.is(Schema.String.check(Schema.isUUID(4))); | ||
| 40 | const Configuration = Schema.Record(Schema.String, Schema.Unknown); | ||
| 41 | const decodeConfiguration = Schema.decodeEffect(Schema.fromJsonString(Configuration)); | ||
| 42 | const decodeConfigurationObject = Schema.decodeUnknownEffect(Configuration); | ||
| 43 | |||
| 44 | type 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 | |||
| 66 | export 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. */ | ||
| 82 | export 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. */ | ||
| 112 | export 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 { |
| 95 | import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; | 96 | import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; |
| 96 | import * as ProviderService from "./provider/Services/ProviderService.ts"; | 97 | import * as ProviderService from "./provider/Services/ProviderService.ts"; |
| 97 | import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; | 98 | import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; |
| 99 | import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts"; | ||
| 100 | import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; | ||
| 101 | import { makeProviderInstallation } from "./provider/providerInstallation.ts"; | ||
| 98 | import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; | 102 | import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; |
| 99 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; | 103 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; |
| 100 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; | 104 | import * 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 !== undefined | 1676 | 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 !== undefined | 1681 | }) |
| 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 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 9 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 12 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 10 | 13 | ||
| 11 | import type { Thread, ThreadShell } from "../types"; | 14 | import type { Thread, ThreadShell, TurnDiffSummary } from "../types"; |
| 15 | import type { TimelineEntry } from "../session-logic"; | ||
| 16 | import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; | ||
| 12 | import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; | 17 | import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; |
| 13 | import type { RightPanelSurface } from "../rightPanelStore"; | 18 | import type { RightPanelSurface } from "../rightPanelStore"; |
| 14 | import { | 19 | import { |
| ... | @@ -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 | }); |
| 663 | 672 | ||
| 673 | describe("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 | |||
| 876 | describe("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 | |||
| 918 | describe("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 | |||
| 664 | describe("deriveComposerSendState", () => { | 990 | describe("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 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 36 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; | 39 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; |
| 37 | import * as Schema from "effect/Schema"; | 40 | import * 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"; |
| 47 | import type { TimelineEntry } from "../session-logic"; | 50 | import type { TimelineEntry } from "../session-logic"; |
| 48 | import type { DesktopPreviewOverlay } from "../previewStateStore"; | 51 | import type { DesktopPreviewOverlay } from "../previewStateStore"; |
| 49 | import type { RightPanelSurface } from "../rightPanelStore"; | 52 | import type { RightPanelSurface } from "../rightPanelStore"; |
| 53 | import { | ||
| 54 | NO_PROVIDER_MODEL_SELECTION, | ||
| 55 | resolveSelectableProviderInstanceEntry, | ||
| 56 | type ProviderInstanceEntry, | ||
| 57 | } from "../providerInstances"; | ||
| 50 | 58 | ||
| 51 | export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; | 59 | export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; |
| 52 | export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; | 60 | export 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 | } |
| 319 | 327 | ||
| 328 | /** Use the same enabled instance for the composer, provider status, and chat actions. */ | ||
| 329 | export 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. */ | ||
| 385 | export 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 | |||
| 400 | export 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 | |||
| 433 | export 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 | |||
| 320 | export function reconcileMountedTerminalThreadIds(input: { | 473 | export 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 { |
| 206 | import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; | 205 | import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; |
| 207 | import { useBrowserHistoryStore } from "~/browserHistoryStore"; | 206 | import { useBrowserHistoryStore } from "~/browserHistoryStore"; |
| 208 | import { registerFaviconProjectForThread } from "~/browserFaviconStore"; | 207 | import { registerFaviconProjectForThread } from "~/browserFaviconStore"; |
| 209 | import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; | 208 | import { getProviderModelCapabilities } from "../providerModels"; |
| 210 | import { | 209 | import { |
| 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"; |
| 215 | import { | 215 | import { |
| ... | @@ -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, |
| 2876 | 2913 | }), | |
| 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]); | ||
| 2901 | 2921 | ||
| 2902 | const gitCwd = activeProject | 2922 | 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: lockedProvider | 2950 | 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) { |
| 3612 | 3606 | ||
| 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; |
| 5865 | 5871 | ||
| 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 | ); |
| 5920 | 5934 | ||
| ... | @@ -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 | ); |
| 6773 | 6789 | ||
| 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 | } |
| 6889 | 6909 | ||
| 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 | } |
| 7034 | 7054 | ||
| 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 | <ProviderStatusBanner | 7645 | <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 | onChangeActivePendingUserInputCustomAnswer | 7857 | 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"; |
| 49 | import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; | 49 | import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; |
| 50 | import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; | 50 | import { |
| 51 | deriveComposerSendState, | ||
| 52 | getAntigravitySendBlockReason, | ||
| 53 | readFileAsDataUrl, | ||
| 54 | resolveComposerInteractionMode, | ||
| 55 | resolveComposerProviderSelection, | ||
| 56 | } from "../ChatView.logic"; | ||
| 51 | import { | 57 | import { |
| 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"; |
| 769 | import { proposedPlanTitle } from "../../proposedPlan"; | 775 | import { proposedPlanTitle } from "../../proposedPlan"; |
| 770 | import { getProviderInteractionModeToggle } from "../../providerModels"; | 776 | import { hasProviderSetup } from "./ProviderStatusBanner"; |
| 771 | import { | 777 | import { |
| 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; |
| 1240 | 1250 | ||
| 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( |
| 1532 | 1540 | () => | |
| 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) |
| 1735 | 1689 | ||
| 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 toggle | 1693 | 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) |
| 2027 | 1975 | ||
| 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 | ); |
| 2747 | 2710 | ||
| 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 | <ComposerFooterModeControls | 3731 | <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 | disabled | 3750 | 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 available | 3760 | {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 | /> |
| 3839 | 3808 | ||
| 3840 | {composerControlsCompact ? ( | 3809 | {composerControlsCompact ? ( |
| 3841 | <CompactComposerControlsMenu | 3810 | <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 : undefined | 3859 | 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 | <div | 4688 | 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?.customAnswer | 4694 | !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 | <ComposerPrimaryActions | 4700 | className={cn( |
| 4738 | compact | 4701 | "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 | projectSelectionRequired | 4712 | </button> |
| 4750 | } | 4713 | ) : null} |
| 4751 | isPreparingWorktree={false} | 4714 | {activePendingProgress?.activeQuestion?.multiSelect ? ( |
| 4752 | hasSendableContent={false} | 4715 | <ComposerPrimaryActions |
| 4753 | preserveComposerFocusOnPointerDown | 4716 | 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 | {activePendingProgress | 4811 | {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 | : activePendingProgress | 5254 | : 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 && activeProposedPlan | 5258 | : 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 | : projectSelectionRequired | 5260 | : 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_PLACEHOLDER | 5265 | ? 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 | }); |
| 43 | 43 | ||
| 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"; |
| 6 | import { memo } from "react"; | 6 | import { memo } from "react"; |
| 7 | import { TriangleAlertIcon } from "lucide-react"; | ||
| 7 | import { Button } from "../ui/button"; | 8 | import { Button } from "../ui/button"; |
| 9 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 8 | 10 | ||
| 9 | interface ComposerPendingApprovalActionsProps { | 11 | interface 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 | <Button | 38 | 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 | } |
| 21 | 21 | ||
| ... | @@ -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 covering | 70 | // 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 card | 71 | // 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 | ]); |
| 103 | 103 | ||
| 104 | // Clear auto-advance timer on unmount | 104 | // 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 | }, []); |
| 112 | 112 | ||
| 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 | <button | 249 | <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 @@ |
| 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | 1 | import { |
| 2 | ANTIGRAVITY_DEFAULT_MODEL, | ||
| 3 | ProviderDriverKind, | ||
| 4 | ProviderInstanceId, | ||
| 5 | type ServerProvider, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | 7 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 8 | ||
| 4 | import { deriveProviderInstanceEntries } from "../../providerInstances"; | 9 | import { deriveProviderInstanceEntries } from "../../providerInstances"; |
| 5 | import { shouldIncludeModelPickerOption } from "./ModelPickerContent"; | 10 | import { |
| 11 | resolveModelPickerSelectedModel, | ||
| 12 | shouldIncludeModelPickerOption, | ||
| 13 | shouldOfferModelPickerSetup, | ||
| 14 | } from "./ModelPickerContent"; | ||
| 6 | 15 | ||
| 7 | function entry(status: ServerProvider["status"]) { | 16 | function 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 | } |
| 24 | 33 | ||
| 25 | describe("shouldIncludeModelPickerOption", () => { | 34 | describe("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"; | ||
| 32 | 65 | ||
| 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 | |||
| 110 | describe("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 | |||
| 156 | describe("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 @@ |
| 1 | import { | 1 | import { |
| 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 |
| 9 | import { ChevronRightIcon, SearchIcon } from "lucide-react"; | 10 | import { ChevronRightIcon, SearchIcon } from "lucide-react"; |
| 10 | import { ModelListRow } from "./ModelListRow"; | 11 | import { ModelListRow } from "./ModelListRow"; |
| 11 | import { ModelPickerSidebar } from "./ModelPickerSidebar"; | 12 | import { ModelPickerSidebar } from "./ModelPickerSidebar"; |
| 13 | import { getProviderStatusMessage, hasProviderSetup } from "./ProviderStatusBanner"; | ||
| 12 | import { | 14 | import { |
| 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" |
| 34 | import { cn } from "~/lib/utils"; | 36 | import { cn } from "~/lib/utils"; |
| 35 | import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; | 37 | import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; |
| 36 | import { TooltipProvider } from "../ui/tooltip"; | 38 | import { TooltipProvider } from "../ui/tooltip"; |
| 39 | import { Button } from "../ui/button"; | ||
| 37 | import { | 40 | import { |
| 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 | }; |
| 58 | 61 | ||
| 62 | export 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 | |||
| 59 | export function shouldIncludeModelPickerOption(input: { | 79 | export 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 === true | 94 | input.option.isUnavailable === true |
| 72 | ); | 95 | ); |
| 73 | } | 96 | } |
| 74 | 97 | ||
| 98 | export 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 | |||
| 75 | const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>(); | 113 | const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>(); |
| 76 | 114 | ||
| 77 | function ModelListSeparator() { | 115 | function 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 instance | 200 | 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 | modelOptionsByInstance | 213 | 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 | ); |
| 222 | 281 | ||
| 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 | ]); | ||
| 229 | 304 | ||
| 230 | // Flatten models into a searchable array. One pass over the | 305 | // Flatten models into a searchable array. One pass over the |
| 231 | // instance-keyed map; each model carries its instance id + driver kind | 306 | // 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]); |
| 274 | 349 | ||
| 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]); |
| 443 | 518 | ||
| 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 | autoHighlight | 758 | autoHighlight |
| 667 | open | 759 | open |
| 668 | virtualized | 760 | 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 | showProvider | 895 | 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 found | 924 | <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 @@ |
| 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | 1 | import { |
| 2 | ANTIGRAVITY_DEFAULT_MODEL, | ||
| 3 | ProviderDriverKind, | ||
| 4 | ProviderInstanceId, | ||
| 5 | type ServerProvider, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | 7 | import { renderToStaticMarkup } from "react-dom/server"; |
| 3 | import { describe, expect, it } from "vite-plus/test"; | 8 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | 9 | ||
| ... | @@ -45,17 +50,54 @@ function renderPicker(input: { | ... | @@ -45,17 +50,54 @@ function renderPicker(input: { |
| 45 | } | 50 | } |
| 46 | 51 | ||
| 47 | describe("ProviderModelPicker", () => { | 52 | describe("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 | }); | ||
| 55 | 62 | ||
| 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 | ); | ||
| 59 | 101 | ||
| 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 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 10 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | 11 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 11 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 12 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 12 | import { cn } from "~/lib/utils"; | 13 | import { cn } from "~/lib/utils"; |
| 13 | import { ModelPickerContent } from "./ModelPickerContent"; | 14 | import { ModelPickerContent, resolveModelPickerSelectedModel } from "./ModelPickerContent"; |
| 14 | import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; | 15 | import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; |
| 15 | import { | 16 | import { |
| 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: { |
| 68 | 70 | ||
| 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. Other | 73 | // 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 = selectedModel | 88 | 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); |
| 82 | 93 | ||
| ... | @@ -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.getModelDisabledReason | 240 | {...(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"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 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 | |||
| 69 | describe("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 @@ |
| 1 | import { type ServerProvider } from "@t3tools/contracts"; | 1 | import { type ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; |
| 2 | import { memo } from "react"; | 2 | import { memo } from "react"; |
| 3 | import { InfoIcon, XIcon } from "lucide-react"; | 3 | import { InfoIcon, XIcon } from "lucide-react"; |
| 4 | import { cn } from "~/lib/utils"; | 4 | import { 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 | } |
| 22 | 22 | ||
| 23 | export 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. */ | ||
| 32 | export 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 | |||
| 23 | export const ProviderStatusBanner = memo(function ProviderStatusBanner({ | 53 | export 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 = isUnauthenticated | 68 | const title = isUnauthenticated |
| 37 | ? `${providerName} is unauthenticated` | 69 | ? `${providerName} is unauthenticated` |
| 38 | : `${providerName} provider status`; | 70 | : `${providerName} provider status`; |
| 39 | const message = isUnauthenticated | 71 | 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.`)); | ||
| 45 | 72 | ||
| 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 | <Button | 107 | <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 @@ |
| 1 | import { ProviderDriverKind } from "@t3tools/contracts"; | 1 | import { ProviderDriverKind } from "@t3tools/contracts"; |
| 2 | import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; | 2 | import { |
| 3 | AntigravityIcon, | ||
| 4 | ClaudeAI, | ||
| 5 | CursorIcon, | ||
| 6 | GrokIcon, | ||
| 7 | Icon, | ||
| 8 | OpenAI, | ||
| 9 | OpenCodeIcon, | ||
| 10 | } from "../Icons"; | ||
| 3 | 11 | ||
| 4 | export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = { | 12 | export 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 | }; |
| 11 | 20 | ||
| 12 | export type ModelEsque = { | 21 | export 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; |
| 476 | 478 | ||
| 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 probe | 481 | // Server-returned models may lag behind settings writes. Treat probe |
| 479 | // models as the source for built-ins only; custom rows come directly | 482 | // 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 | <div | 850 | <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]); |
| 203 | 203 | ||
| 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> |
| 507 | 508 | ||
| 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 | <Input | 511 | <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 | )} |
| 553 | 554 | ||
| 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 | }); |
| 39 | 39 | ||
| 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"; |
| 6 | import type { | 6 | import type { |
| 7 | ProviderSettingsFormAnnotation, | 7 | ProviderSettingsFormAnnotation, |
| 8 | ProviderSettingsFormControl, | 8 | ProviderSettingsFormControl, |
| 9 | ProviderSettingsFormOption, | ||
| 9 | ProviderSettingsFormSchemaAnnotation, | 10 | ProviderSettingsFormSchemaAnnotation, |
| 10 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 11 | 12 | ||
| 12 | import { cn } from "../../lib/utils"; | 13 | import { cn } from "../../lib/utils"; |
| 13 | import { DraftInput } from "../ui/draft-input"; | 14 | import { DraftInput } from "../ui/draft-input"; |
| 14 | import { Input } from "../ui/input"; | 15 | import { Input } from "../ui/input"; |
| 16 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | ||
| 15 | import { Switch } from "../ui/switch"; | 17 | import { Switch } from "../ui/switch"; |
| 16 | import { Textarea } from "../ui/textarea"; | 18 | import { Textarea } from "../ui/textarea"; |
| 17 | import type { ProviderClientDefinition } from "./providerDriverMeta"; | 19 | import 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 | } |
| 28 | 32 | ||
| 29 | function titleizeFieldKey(key: string): string { | 33 | function 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 | } |
| 170 | 177 | ||
| 178 | /** Stores the default choice as an omitted key so unchanged configs stay small. */ | ||
| 179 | function 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 | |||
| 171 | function FieldFrame(props: { | 220 | function 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 | <Textarea | 291 | <Textarea |
| 234 | id={inputId} | 292 | id={inputId} |
| ... | @@ -285,6 +343,25 @@ function ProviderSettingsFieldRow({ | ... | @@ -285,6 +343,25 @@ function ProviderSettingsFieldRow({ |
| 285 | ); | 343 | ); |
| 286 | } | 344 | } |
| 287 | 345 | ||
| 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 { |
| 132 | 132 | ||
| 133 | function renderPanel(options?: { | 133 | function 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 | } |
| 143 | 147 | ||
| ... | @@ -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(); |
| 206 | 210 | ||
| 207 | expect(commands.refresh).toHaveBeenCalledWith({ environmentId, input: {} }); | 211 | expect(commands.refresh).toHaveBeenCalledWith({ |
| 212 | environmentId, | ||
| 213 | input: { refreshModels: true }, | ||
| 214 | }); | ||
| 208 | 215 | ||
| 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 | }); |
| 223 | 230 | ||
| 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"; |
| 69 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 69 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 70 | import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; | 70 | import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; |
| 71 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; | 71 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; |
| 72 | import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection"; | ||
| 72 | import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; | 73 | import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; |
| 73 | import { providerSettingsTabClassName } from "./providerSettingsTabs"; | 74 | import { providerSettingsTabClassName } from "./providerSettingsTabs"; |
| 74 | import { searchableSetting } from "./settingsSearch"; | 75 | import { 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 | })); |
| 121 | 122 | ||
| 123 | function 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 | |||
| 122 | function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { | 128 | function 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 | } |
| 185 | 191 | ||
| 186 | export function ProviderSettingsPanel() { | 192 | interface ProviderSettingsTarget { |
| 193 | readonly environmentId?: EnvironmentId; | ||
| 194 | readonly instanceId?: ProviderInstanceId; | ||
| 195 | } | ||
| 196 | |||
| 197 | export 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 | } |
| 193 | 207 | ||
| 194 | function ProviderSettingsPanelContent() { | 208 | function 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() { |
| 288 | 304 | ||
| 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 | <SettingsRow | 318 | <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() { |
| 315 | function SelectedEnvironmentProviderSettings({ | 346 | function 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 | } |
| 341 | 385 | ||
| 342 | function PrimarySessionGatedProviderSettings({ | 386 | function 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({ |
| 366 | function RemoteSessionGatedProviderSettings({ | 413 | function 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 credential | 486 | * Grey out and freeze every write control when this session's credential |
| 432 | * lacks `orchestration:operate` on the environment. Selecting providers and | 487 | * 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 | } |
| 655 | 712 | ||
| 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)); | ||
| 657 | 720 | ||
| 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 @@ | ||
| 1 | import type { FunctionComponent, ReactElement } from "react"; | ||
| 2 | import { | ||
| 3 | EnvironmentId, | ||
| 4 | ProviderDriverKind, | ||
| 5 | ProviderInstanceId, | ||
| 6 | type ProviderAuthState, | ||
| 7 | type ProviderInstallState, | ||
| 8 | type ServerProvider, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 11 | |||
| 12 | import { visitElements } from "../../test/reactElementTree"; | ||
| 13 | import { reactHookHarness as hooks } from "../../test/reactHookHarness"; | ||
| 14 | |||
| 15 | const 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 | |||
| 30 | vi.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 | |||
| 40 | vi.mock("react/compiler-runtime", async () => { | ||
| 41 | const { reactHookHarness } = await import("../../test/reactHookHarness"); | ||
| 42 | return { c: reactHookHarness.useMemoCache }; | ||
| 43 | }); | ||
| 44 | |||
| 45 | vi.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 | |||
| 59 | vi.mock("../../state/use-atom-command", () => ({ | ||
| 60 | useAtomCommand: (command: unknown) => command, | ||
| 61 | })); | ||
| 62 | |||
| 63 | vi.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 | |||
| 72 | vi.mock("../../localApi", () => ({ | ||
| 73 | ensureLocalApi: () => ({ dialogs: { confirm: setup.confirm } }), | ||
| 74 | })); | ||
| 75 | |||
| 76 | import { ProviderSetupSection } from "./ProviderSetupSection"; | ||
| 77 | |||
| 78 | const environmentId = EnvironmentId.make("remote-google"); | ||
| 79 | const instanceId = ProviderInstanceId.make("antigravity_work"); | ||
| 80 | const 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 | |||
| 95 | function 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 | |||
| 107 | function 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 | |||
| 138 | function button(view: unknown, label: string) { | ||
| 139 | return visitElements( | ||
| 140 | view, | ||
| 141 | (element) => element.props.children === label && typeof element.props.onClick === "function", | ||
| 142 | ); | ||
| 143 | } | ||
| 144 | |||
| 145 | function 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 | |||
| 152 | function 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 | |||
| 161 | function 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 | |||
| 169 | async function flushPromises() { | ||
| 170 | await Promise.resolve(); | ||
| 171 | await Promise.resolve(); | ||
| 172 | await Promise.resolve(); | ||
| 173 | } | ||
| 174 | |||
| 175 | describe("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 @@ | ||
| 1 | import { | ||
| 2 | isAtomCommandInterrupted, | ||
| 3 | squashAtomCommandFailure, | ||
| 4 | type AtomCommandResult, | ||
| 5 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 6 | import { | ||
| 7 | ANTIGRAVITY_AUTH_METHODS, | ||
| 8 | type AntigravityAuthMethod, | ||
| 9 | type EnvironmentId, | ||
| 10 | type ProviderAuthState, | ||
| 11 | type ProviderInstanceId, | ||
| 12 | type ServerProvider, | ||
| 13 | } from "@t3tools/contracts"; | ||
| 14 | import { useRef, useState } from "react"; | ||
| 15 | |||
| 16 | import { writeTextToClipboard } from "../../hooks/useCopyToClipboard"; | ||
| 17 | import { ensureLocalApi } from "../../localApi"; | ||
| 18 | import { useEnvironmentQuery } from "../../state/query"; | ||
| 19 | import { serverEnvironment } from "../../state/server"; | ||
| 20 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 21 | import { Button } from "../ui/button"; | ||
| 22 | import { Input } from "../ui/input"; | ||
| 23 | |||
| 24 | interface 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 | |||
| 36 | const 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. */ | ||
| 47 | const 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. */ | ||
| 58 | export 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. */ | ||
| 69 | export 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 | |||
| 105 | function 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 @@ |
| 1 | import { ArchiveIcon, ArchiveX, ChevronRightIcon, LoaderIcon, SettingsIcon } from "lucide-react"; | 1 | import { ArchiveIcon, ArchiveX, ChevronRightIcon, LoaderIcon, SettingsIcon } from "lucide-react"; |
| 2 | import { Link } from "@tanstack/react-router"; | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | import type { CSSProperties, ReactNode } from "react"; | 3 | import type { CSSProperties, ReactNode } from "react"; |
| 4 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 4 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 5 | import { useAtomValue } from "@effect/atom-react"; | 5 | import { 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"; |
| 91 | import { useProjects } from "../../state/entities"; | 92 | import { useProjects } from "../../state/entities"; |
| 93 | import { usePrimaryEnvironmentId } from "../../state/environments"; | ||
| 92 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; | 94 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; |
| 93 | import { formatRelativeTimeLabel } from "../../timestampFormat"; | 95 | import { formatRelativeTimeLabel } from "../../timestampFormat"; |
| 94 | import { Button } from "../ui/button"; | 96 | import { Button } from "../ui/button"; |
| ... | @@ -1998,6 +2000,8 @@ function LegacyFeaturesSection() { | ... | @@ -1998,6 +2000,8 @@ function LegacyFeaturesSection() { |
| 1998 | export function GeneralSettingsPanel() { | 2000 | export 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 | }); |
| 2016 | 2020 | ||
| 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 | ) : null | 2724 | ) : null |
| 2712 | } | 2725 | } |
| 2713 | control={ | 2726 | control={ |
| 2714 | <div className="flex flex-wrap items-center justify-end gap-1.5"> | 2727 | !hasTextGenerationProvider ? ( |
| 2715 | <ProviderModelPicker | 2728 | <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 | <TraitsPicker | 2748 | }, |
| 2736 | provider={textGenProvider} | 2749 | } |
| 2737 | models={ | 2750 | : {})} |
| 2738 | // Use the exact instance's models (rather than the | 2751 | onInstanceModelChange={(instanceId, model) => { |
| 2739 | // first-kind-match) so a custom text-gen instance like | 2752 | updateSettings({ |
| 2740 | // `codex_personal` gets its own model list, not the | 2753 | 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 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useNavigate } from "@tanstack/react-router"; | ||
| 2 | import { useRef } from "react"; | 3 | import { useRef } from "react"; |
| 3 | import type { SourceControlWritingStyleMode } from "@t3tools/contracts"; | 4 | import type { ProviderInstanceId, SourceControlWritingStyleMode } from "@t3tools/contracts"; |
| 4 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; | 5 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| 5 | import { createModelSelection } from "@t3tools/shared/model"; | 6 | import { createModelSelection } from "@t3tools/shared/model"; |
| 6 | import { resolveSourceControlWriterModelSelection } from "@t3tools/shared/serverSettings"; | 7 | import { 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"; |
| 18 | import { primaryServerProvidersAtom } from "../../state/server"; | 19 | import { primaryServerProvidersAtom } from "../../state/server"; |
| 20 | import { usePrimaryEnvironmentId } from "../../state/environments"; | ||
| 19 | import { ProviderModelPicker } from "../chat/ProviderModelPicker"; | 21 | import { ProviderModelPicker } from "../chat/ProviderModelPicker"; |
| 20 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | 22 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 21 | import { Switch } from "../ui/switch"; | 23 | import { 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 |
| 49 | export function SourceControlWritingSettingsSection() { | 51 | export 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; |
| 58 | 62 | ||
| 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 | <ProviderModelPicker | 208 | <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 | <Switch | 234 | <Switch |
| 207 | checked={usesDedicatedModel} | 235 | checked={usesDedicatedModel} |
| 236 | disabled={!usesDedicatedModel && !canEnableDedicatedModel} | ||
| 208 | onCheckedChange={(checked) => | 237 | onCheckedChange={(checked) => |
| 209 | updateSettings({ | 238 | updateSettings({ |
| 210 | sourceControlWriterModelSelection: checked | 239 | sourceControlWriterModelSelection: checked |
apps/web/src/components/settings/providerDriverMeta.ts+16-1| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 9 | import type * as Schema from "effect/Schema"; | 10 | import type * as Schema from "effect/Schema"; |
| 10 | import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; | 11 | import { |
| 12 | AntigravityIcon, | ||
| 13 | ClaudeAI, | ||
| 14 | CursorIcon, | ||
| 15 | GrokIcon, | ||
| 16 | type Icon, | ||
| 17 | OpenAI, | ||
| 18 | OpenCodeIcon, | ||
| 19 | } from "../Icons"; | ||
| 11 | 20 | ||
| 12 | type ProviderSettingsSchema = { | 21 | type 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 | ]; |
| 71 | 86 | ||
| 72 | export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< | 87 | export 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 | */ |
| 88 | export function getProviderVersionLabel(version: string | null | undefined) { | 88 | export 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 | } |
| 92 | 100 | ||
| 93 | export function getProviderVersionAdvisoryPresentation( | 101 | export 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 = instanceSelection | 1174 | 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 @@ |
| 1 | import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; | 1 | import { |
| 2 | ANTIGRAVITY_DEFAULT_MODEL, | ||
| 3 | ProviderDriverKind, | ||
| 4 | ProviderInstanceId, | ||
| 5 | type ServerProvider, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 2 | import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; | 7 | import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; |
| 3 | import { describe, expect, it } from "vite-plus/test"; | 8 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | import { createModelSelection } from "@t3tools/shared/model"; | 9 | import { createModelSelection } from "@t3tools/shared/model"; |
| 5 | import { deriveEffectiveComposerModelState } from "./composerDraftStore"; | 10 | import { deriveEffectiveComposerModelState } from "./composerDraftStore"; |
| 6 | import { getComposerProviderState } from "./components/chat/composerProviderState"; | 11 | import { getComposerProviderState } from "./components/chat/composerProviderState"; |
| 7 | import { deriveProviderInstanceEntries } from "./providerInstances"; | 12 | import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "./providerInstances"; |
| 8 | import { | 13 | import { |
| 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 | }); |
| 336 | 341 | ||
| 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 | }); | ||
| 345 | 381 | ||
| 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 | |
| 364 | 419 | 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 | ]; | ||
| 418 | 424 | ||
| 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 | }); | ||
| 443 | 460 | ||
| 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 | |
| 458 | 476 | 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 | }); |
| 466 | 484 | ||
| 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 | }); |
| 549 | 567 | ||
| 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 | }); |
| 643 | 838 | ||
| 644 | describe("withoutPlanAgentSelection", () => { | 839 | describe("withoutPlanAgentSelection", () => { |
apps/web/src/modelSelection.ts+35-33| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 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"; |
| 25 | import { ModelEsque } from "./components/chat/providerIconUtils"; | 26 | import { ModelEsque } from "./components/chat/providerIconUtils"; |
| 26 | import { type ProviderInstanceEntry, deriveProviderInstanceEntries } from "./providerInstances"; | 27 | import { |
| 28 | type ProviderInstanceEntry, | ||
| 29 | deriveProviderInstanceEntries, | ||
| 30 | NO_PROVIDER_MODEL_SELECTION, | ||
| 31 | } from "./providerInstances"; | ||
| 27 | import { sortModelsForProviderInstance } from "./modelOrdering"; | 32 | import { sortModelsForProviderInstance } from "./modelOrdering"; |
| 28 | 33 | ||
| 29 | const MAX_CUSTOM_MODEL_COUNT = 32; | 34 | const 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 | } |
| 85 | 91 | ||
| 86 | function appendUnavailableOpenCodeSelection( | 92 | function 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; | ||
| 96 | 103 | ||
| 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; |
| 102 | 109 | ||
| ... | @@ -212,7 +219,7 @@ export function getAppModelOptions( | ... | @@ -212,7 +219,7 @@ export function getAppModelOptions( |
| 212 | } | 219 | } |
| 213 | 220 | ||
| 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 | } |
| 261 | 268 | ||
| 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 | } |
| 321 | 330 | ||
| 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 | } |
| 428 | 445 | ||
| 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; |
| 41 | 41 | ||
| 42 | const 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 | |||
| 42 | describe("resolvePendingUserInputAnswer", () => { | 54 | describe("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 | }); |
| 81 | 110 | ||
| 82 | describe("togglePendingUserInputOptionSelection", () => { | 111 | describe("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 | ); |
| 90 | 119 | ||
| ... | @@ -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 | }); |
| 156 | 220 | ||
| 157 | describe("pending user input question progress", () => { | 221 | describe("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 @@ |
| 1 | import type { UserInputQuestion } from "@t3tools/contracts"; | 1 | import type { UserInputQuestion } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | export interface PendingUserInputDraftAnswer { | 3 | export interface PendingUserInputDraftAnswer { |
| 4 | selectedOptionLabels?: string[]; | 4 | selectedOptionValues?: string[]; |
| 5 | customAnswer?: string; | 5 | customAnswer?: string; |
| 6 | } | 6 | } |
| 7 | 7 | ||
| ... | @@ -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 | } |
| 30 | 30 | ||
| 31 | function normalizeSelectedOptionLabels(value: string[] | undefined): string[] { | 31 | function normalizeSelectedOptionValues(value: string[] | undefined): string[] { |
| 32 | if (!Array.isArray(value)) { | 32 | if (!Array.isArray(value)) { |
| 33 | return []; | 33 | return []; |
| 34 | } | 34 | } |
| 35 | 35 | ||
| 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 | } |
| 47 | 39 | ||
| 48 | export function resolvePendingUserInputAnswer( | 40 | export 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 | } |
| 56 | 49 | ||
| 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 | } |
| 61 | 56 | ||
| 62 | return selectedOptionLabels[0] ?? null; | 57 | return selectedOptionValues[0] ?? null; |
| 63 | } | 58 | } |
| 64 | 59 | ||
| 65 | export function setPendingUserInputCustomAnswer( | 60 | export 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 > 0 | 65 | customAnswer.trim().length > 0 |
| 71 | ? undefined | 66 | ? undefined |
| 72 | : normalizeSelectedOptionLabels(draft?.selectedOptionLabels); | 67 | : normalizeSelectedOptionValues(draft?.selectedOptionValues); |
| 73 | 68 | ||
| 74 | return { | 69 | return { |
| 75 | customAnswer, | 70 | customAnswer, |
| 76 | ...(selectedOptionLabels && selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}), | 71 | ...(selectedOptionValues && selectedOptionValues.length > 0 ? { selectedOptionValues } : {}), |
| 77 | }; | 72 | }; |
| 78 | } | 73 | } |
| 79 | 74 | ||
| 80 | export function togglePendingUserInputOptionSelection( | 75 | export 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]; |
| 90 | 85 | ||
| 91 | return { | 86 | return { |
| 92 | customAnswer: "", | 87 | customAnswer: "", |
| 93 | ...(nextSelectedOptionLabels.length > 0 | 88 | ...(nextSelectedOptionValues.length > 0 |
| 94 | ? { selectedOptionLabels: nextSelectedOptionLabels } | 89 | ? { selectedOptionValues: nextSelectedOptionValues } |
| 95 | : {}), | 90 | : {}), |
| 96 | }; | 91 | }; |
| 97 | } | 92 | } |
| 98 | 93 | ||
| 99 | return { | 94 | return { |
| 100 | customAnswer: "", | 95 | customAnswer: "", |
| 101 | selectedOptionLabels: [optionLabel], | 96 | selectedOptionValues: [optionValue], |
| 102 | }; | 97 | }; |
| 103 | } | 98 | } |
| 104 | 99 | ||
| ... | @@ -110,7 +105,7 @@ export function buildPendingUserInputAnswers( | ... | @@ -110,7 +105,7 @@ export function buildPendingUserInputAnswers( |
| 110 | 105 | ||
| 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 | } |
| 130 | 127 | ||
| ... | @@ -140,7 +137,8 @@ export function derivePendingUserInputProgress( | ... | @@ -140,7 +137,8 @@ export function derivePendingUserInputProgress( |
| 140 | const resolvedAnswer = activeQuestion | 137 | 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 @@ |
| 1 | import { createFileRoute } from "@tanstack/react-router"; | 1 | import { createFileRoute } from "@tanstack/react-router"; |
| 2 | import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 2 | 3 | ||
| 3 | import { ProviderSettingsPanel } from "../components/settings/ProviderSettingsPanel"; | 4 | import { ProviderSettingsPanel } from "../components/settings/ProviderSettingsPanel"; |
| 4 | 5 | ||
| 5 | function SettingsProvidersRoute() { | 6 | function SettingsProvidersRoute() { |
| 6 | return <ProviderSettingsPanel />; | 7 | const target = Route.useSearch(); |
| 8 | return <ProviderSettingsPanel {...target} />; | ||
| 7 | } | 9 | } |
| 8 | 10 | ||
| 9 | export const Route = createFileRoute("/settings/providers")({ | 11 | export 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 | }); |
| 251 | 251 | ||
| 252 | describe("derivePendingUserInputs", () => { | 252 | describe("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 | ]; |
| 65 | 71 | ||
| 66 | export type WorkLogToolLifecycleStatus = | 72 | export 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 |
| 95 | 95 | ||
| 96 | #### Provider | 96 | #### Provider |
| 97 | 97 | ||
| 98 | The 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. | 98 | The 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. |
| 99 | 99 | ||
| 100 | #### Session | 100 | #### Session |
| 101 | 101 |
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 transport | 24 | │ 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 | ``` |
| 30 | 30 | ||
| ... | @@ -122,8 +122,8 @@ build production behavior on receipts. | ... | @@ -122,8 +122,8 @@ build production behavior on receipts. |
| 122 | 122 | ||
| 123 | ## Provider drivers | 123 | ## Provider drivers |
| 124 | 124 | ||
| 125 | Five drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: | 125 | Six drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: |
| 126 | Codex, Claude, Cursor, Grok, and OpenCode. A driver declares its kind and config schema and creates a | 126 | Codex, Claude, Cursor, Grok, OpenCode, and Antigravity. A driver declares its kind and config schema and creates a |
| 127 | scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves | 127 | scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves |
| 128 | an instance to its adapter, so `ProviderService` routes session and turn operations without knowing | 128 | an instance to its adapter, so `ProviderService` routes session and turn operations without knowing |
| 129 | which agent is behind them. See [providers.md](./providers.md). | 129 | which 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. |
| 7 | 7 | ||
| 8 | ## Built-in drivers | 8 | ## Built-in drivers |
| 9 | 9 | ||
| 10 | [`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries: | 10 | [`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries: |
| 11 | 11 | ||
| 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] | | ||
| 19 | 20 | ||
| 20 | Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an | 21 | Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an |
| 21 | adapter in a child scope. Adapter implementations live beside them in | 22 | adapter 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 |
| 54 | CLI's product name, not an ACP model id. `applyGrokAcpModelSelection` treats it as "keep the | 55 | CLI's product name, not an ACP model id. `applyGrokAcpModelSelection` treats it as "keep the |
| 55 | session's current model" and never sends it in `session/set_model`. | 56 | session's current model" and never sends it in `session/set_model`. |
| 56 | 57 | ||
| 58 | ## Antigravity ownership and protocol | ||
| 59 | |||
| 60 | [`AntigravityDriver`][antigravity] uses Google's official ACP executable. The instance config | ||
| 61 | selects 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 | ||
| 63 | methods pass the configured key to the agent as `GEMINI_API_KEY` or `GOOGLE_API_KEY` and never | ||
| 64 | open a browser. A GCP project and location are written to the profile's `settings.json` on each | ||
| 65 | launch. The driver never reuses CLI credentials or ambient `GOOGLE_*` variables and never falls | ||
| 66 | back to another method. Antigravity is disabled by default and supports multiple provider | ||
| 67 | instances. The open driver and instance identifiers require no database migration. | ||
| 68 | |||
| 69 | ### Runtime installation | ||
| 70 | |||
| 71 | [`AntigravityInstallation`][antigravity-installation] belongs to the environment, outside | ||
| 72 | WebSocket and provider-instance scopes. Instances share an explicit download operation and the | ||
| 73 | completed runtime. Client disconnects and instance rebuilds do not cancel installation. | ||
| 74 | |||
| 75 | The fixed [release table][antigravity-release] contains official Google URLs, SHA-256 hashes, | ||
| 76 | archive sizes, and the exact executable pair for each published host. Downloads stream to disk. | ||
| 77 | Lazy `yauzl` entry streams extract only that pair, with member names, types, duplicates, and | ||
| 78 | sizes checked. Validation runs ACP `initialize` in a temporary profile without authentication | ||
| 79 | or a session. Progress updates are bounded, not sent for every network chunk. | ||
| 80 | |||
| 81 | Complete releases live in immutable version directories under the T3 home | ||
| 82 | `tools/antigravity-acp/<platform>-<arch>/versions`. An atomic `active.json` change selects the | ||
| 83 | release for new processes. Each process holds a version lease until it exits. Updates do not | ||
| 84 | replace running executables. Removal refuses active leases or explicit binary paths that still | ||
| 85 | reference the managed files. Failure or cancellation removes owned partial files, not the | ||
| 86 | previous release or account data. | ||
| 87 | |||
| 88 | Resolution order is explicit `binaryPath`, active managed release, then the instance's `PATH`. | ||
| 89 | An invalid explicit path fails without fallback. Manual installations are never changed by the | ||
| 90 | installer. Every launch pins `ANTIGRAVITY_HARNESS_PATH` to the selected executable's sibling. | ||
| 91 | |||
| 92 | ### Google profiles and sign-in | ||
| 93 | |||
| 94 | Each 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 | ||
| 98 | the 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 | ||
| 100 | filesystem permissions. | ||
| 101 | |||
| 102 | The launch environment removes API-key and cloud-billing variables, disables inherited | ||
| 103 | environment extension, sets `PYTHONUNBUFFERED=1`, and controls `BROWSER`. A tested Node or | ||
| 104 | Electron-as-Node helper prevents the official agent from opening a browser on the environment. | ||
| 105 | The same launch factory serves setup, health checks, chat, and text generation. | ||
| 106 | |||
| 107 | The official agent prints one non-JSON OAuth line on stdout. Only the exact known prefix is | ||
| 108 | filtered before ACP decoding. Fragmented lines are joined and bounded. Other malformed | ||
| 109 | protocol output remains fatal. Authorization URLs are validated before use. Native stderr is | ||
| 110 | drained without logging because it can contain OAuth data. Normal work rejects an interactive | ||
| 111 | login 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 | ||
| 114 | scope. Only the initiating T3 auth session receives its URL and flow ID or can complete or | ||
| 115 | cancel it. Other clients receive busy state without those values. Subscriptions follow | ||
| 116 | controller replacement when settings rebuild an instance. | ||
| 117 | |||
| 118 | For remote completion, the client sends the full return URL through the typed setup RPC. | ||
| 119 | The server validates the pending loopback origin, port, root path, and single matching state | ||
| 120 | before forwarding once to the owned listener. It does not probe the listener or follow | ||
| 121 | redirects. Google's process owns PKCE, token exchange, refresh, and storage. Callback HTTP | ||
| 122 | success is not authentication success. The controller waits for authenticated session setup | ||
| 123 | and catalog discovery. Cancellation closes the process instead of sending a synthetic denial. | ||
| 124 | |||
| 125 | Auth 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 | |||
| 130 | Sign-out closes process admission for the instance, stops provider bindings through | ||
| 131 | [`ProviderAuthService`][provider-auth-service], then stops owned startup and helper processes. | ||
| 132 | A fresh official process calls `initialize` and native `logout` without authenticating. | ||
| 133 | Only then does the provider clear auth, models, commands, skills, and workspace metadata. | ||
| 134 | Thread history and native session files remain. Settings sign-out and a text-only `/logout` | ||
| 135 | use this same path. The command is handled before model prompting or title generation. | ||
| 136 | Disabling an instance closes its processes but keeps credentials. Account replacement is | ||
| 137 | explicit 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 | ||
| 142 | native `session/resume` without transcript replay and reapplies the persisted model and | ||
| 143 | permission mode after new or resumed setup. An unavailable explicit model fails instead of | ||
| 144 | accepting the native default. Steering cancels the previous prompt, waits for its result and | ||
| 145 | event drain, then sends the replacement. Native background commands use T3's existing | ||
| 146 | background-task state. | ||
| 147 | |||
| 148 | The 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 | ||
| 152 | these values. Ordinary approval replies use only offered option IDs, including `allow_always` | ||
| 153 | only when present. Existing providers keep their prior behavior when the optional fields are | ||
| 154 | absent. | ||
| 155 | |||
| 156 | `showInteractionModeToggle=false` keeps native `/plan` separate from T3 Plan mode. | ||
| 157 | `supportsConversationRollback=false` hides unsupported client actions and makes checkpoint | ||
| 158 | revert fail before filesystem changes. Checkpoint capture and diffs remain supported. | ||
| 159 | |||
| 160 | Automatic status refreshes, reconnects, and workspace checks do not open catalog sessions. | ||
| 161 | Health probes use `initialize` only. Disabled instances do not run background probes. | ||
| 162 | An explicit `serverRefreshProviders` request with `refreshModels: true` calls the driver's | ||
| 163 | optional `refreshModels` operation. Antigravity opens a short-lived catalog session under the | ||
| 164 | instance's process admission guard, uses saved credentials, publishes models and commands, | ||
| 165 | then 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. | ||
| 167 | Account access starts unknown and becomes authenticated after successful session setup, | ||
| 168 | including an explicit model refresh. | ||
| 169 | The [provider snapshot][antigravity-provider] takes models and commands from setup and native | ||
| 170 | updates. It preserves returned Gemini model IDs, labels, order, and thinking-level choices. | ||
| 171 | The registry treats a successful empty catalog as authoritative and clears cached metadata | ||
| 172 | after sign-out. It must not retain a previous account's models. Cached models do not prove | ||
| 173 | current access. The auth response does not supply an email, plan tier, or reliable quota. | ||
| 174 | |||
| 175 | Some upstream failures arrive as assistant text followed by `end_turn`. Preserve that message | ||
| 176 | without 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, | ||
| 181 | and PR text through the same instance and Google sign-in. Each helper uses a temporary empty | ||
| 182 | workspace, no injected MCP servers, native `default` mode, and explicit denial of tools and | ||
| 183 | questions. Output is bounded, parsed against the existing schemas, and sanitized. Cancellation, | ||
| 184 | timeout, and sign-out close the process. Cleanup removes only that helper's verified temporary | ||
| 185 | native session files. | ||
| 186 | |||
| 187 | The official agent has no verified hard no-tools setting. Global hooks and MCP configuration | ||
| 188 | can 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 | ||
| 190 | configuration. `supportsTextGeneration=false` keeps such an instance out of system-model | ||
| 191 | pickers. Empty managed profiles are supported. Do not describe prompt-time denial as a native | ||
| 192 | sandbox. | ||
| 193 | |||
| 57 | ## OpenCode server ownership and catalog | 194 | ## OpenCode server ownership and catalog |
| 58 | 195 | ||
| 59 | Each OpenCode provider instance owns one lazy local server for catalog discovery and | 196 | Each 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 |
| 118 | 255 | ||
| 119 | - Codex, Claude, Cursor, and Grok send images as native image inputs and skip generic files. For | 256 | - 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 parts | 263 | - 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 APIs | 264 | 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. | ||
| 124 | 270 | ||
| 125 | Claude receives the attachment directory as an allowed additional directory. Codex keeps its | 271 | Claude receives the attachment directory as an allowed additional directory. Codex keeps its |
| 126 | configured sandbox policy, so access depends on that policy and the selected runtime mode. OpenCode | 272 | configured 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.ts | 326 | [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts |
| 181 | [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts | 327 | [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts |
| 182 | [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts | 328 | [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.ts | 339 | [opencode-server-owner]: ../../apps/server/src/provider/OpenCodeServerOwner.ts |
| 184 | [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts | 340 | [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts |
| 185 | [instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts | 341 | [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. |
| 6 | 6 | ||
| 7 | Node.js `^22.16 || ^23.11 || >=24.10` on the machine that runs the T3 Code server. | 7 | Node.js `^22.16 || ^23.11 || >=24.10` on the machine that runs the T3 Code server. |
| 8 | 8 | ||
| 9 | At least one provider CLI, installed and authenticated. See [Providers](#providers) below. | 9 | At least one provider runtime, installed and authenticated. You can install Antigravity from |
| 10 | T3 Code settings. See [Providers](#providers) below. | ||
| 10 | 11 | ||
| 11 | ## Run Without Installing | 12 | ## Run Without Installing |
| 12 | 13 | ||
| ... | @@ -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 |
| 81 | 82 | ||
| 82 | ## Providers | 83 | ## Providers |
| 83 | 84 | ||
| 84 | T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want | 85 | T3 Code uses provider runtimes but does not bundle them. Install and authenticate each |
| 85 | to use, then authenticate it. | 86 | provider's CLI, or use T3 Code's managed setup for Antigravity. |
| 86 | 87 | ||
| 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 | | ||
| 94 | 96 | ||
| 95 | Codex and Claude are on by default. Cursor, Grok Build, and OpenCode are off by default; turn | 97 | Codex and Claude are on by default. Cursor, Grok Build, OpenCode, and Antigravity are off by |
| 96 | them on in **Settings** → the provider's card when you want to use them. | 98 | default. Turn them on in **Settings** > **Providers** when you want to use them. |
| 99 | |||
| 100 | For Antigravity, select the environment in provider settings, then install and sign in there. | ||
| 101 | The runtime and credentials stay on that environment, even when you use a phone or remote | ||
| 102 | browser. See [Antigravity setup](./providers-antigravity.md) for Google sign-in, remote callback | ||
| 103 | steps, and supported hosts. | ||
| 97 | 104 | ||
| 98 | Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that | 105 | Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that |
| 99 | T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`. | 106 | T3 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 |
| 102 | The available levels and default come from the installed Grok Build CLI, so they can vary by model | 109 | The available levels and default come from the installed Grok Build CLI, so they can vary by model |
| 103 | and CLI version. | 110 | and CLI version. |
| 104 | 111 | ||
| 105 | Run the login command on the machine running the T3 Code server, not on the device you browse | 112 | Run CLI login commands on the machine running the T3 Code server, not on the device you browse |
| 106 | from. | 113 | from. Antigravity uses its sign-in controls in T3 Code instead of a CLI login command. |
| 107 | 114 | ||
| 108 | ### Binary Discovery | 115 | ### Binary Discovery |
| 109 | 116 | ||
| ... | @@ -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 |
| 112 | manager or a non-standard install location keeps the CLI off the `PATH` of the shell that | 119 | manager or a non-standard install location keeps the CLI off the `PATH` of the shell that |
| 113 | started T3 Code. | 120 | started T3 Code. |
| 114 | 121 | ||
| 122 | Antigravity can use its managed runtime without a `PATH` entry. Its optional **Binary path** | ||
| 123 | overrides the managed runtime and must point to the official ACP executable. | ||
| 124 | |||
| 115 | ### When Auth Is Needed | 125 | ### When Auth Is Needed |
| 116 | 126 | ||
| 117 | Provider auth is required before you start a session with that provider, not before you start | 127 | Provider auth is required before you start a session with that provider, not before you start |
| 118 | T3 Code. You can install T3 Code, open it, and add providers afterwards. A provider that is not | 128 | T3 Code. You can install T3 Code, open it, and add providers afterwards. A provider that is not |
| 119 | authenticated shows its status in **Settings** and fails at session start with the login command | 129 | authenticated shows its status and setup instructions in **Settings**. |
| 120 | to run. | ||
| 121 | 130 | ||
| 122 | For multi-account setups, see [Codex](./providers-codex.md) and [Claude](./providers-claude.md). | 131 | For multi-account setups, see [Codex](./providers-codex.md), [Claude](./providers-claude.md), and |
| 132 | [Antigravity](./providers-antigravity.md#accounts-and-removal). | ||
| 123 | 133 | ||
| 124 | ## Next Steps | 134 | ## Next Steps |
| 125 | 135 |
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 depends | 18 | **Auto**: routine actions proceed without you; risky ones still ask. How this is enforced depends |
| 19 | on the provider: Codex delegates routine approvals to an AI reviewer, Claude uses its own auto | 19 | on the provider: Codex delegates routine approvals to an AI reviewer, Claude uses its own auto |
| 20 | permission mode, Cursor uses Smart Auto review, and providers without an equivalent (such as | 20 | permission mode, Cursor uses Smart Auto review, and providers without an equivalent (such as |
| 21 | OpenCode) fall back to asking, like Supervised. | 21 | OpenCode and Antigravity) fall back to asking, like Supervised. |
| 22 | 22 | ||
| 23 | **Full access**: allow commands and edits without prompts. The default. The agent runs | 23 | **Full access**: allow commands and edits without prompts. The default. The agent runs |
| 24 | unattended until it finishes or asks a question of its own. | 24 | unattended until it finishes or asks a question of its own. |
| ... | @@ -29,6 +29,11 @@ there. | ... | @@ -29,6 +29,11 @@ there. |
| 29 | For Grok, **Always allow this session** remembers the matching command or tool input. Other | 29 | For Grok, **Always allow this session** remembers the matching command or tool input. Other |
| 30 | actions still ask for approval. It does not change the thread to **Full access**. | 30 | actions still ask for approval. It does not change the thread to **Full access**. |
| 31 | 31 | ||
| 32 | Antigravity uses its own permission policy for each mode. T3 Code still shows any approval or | ||
| 33 | question the official agent sends in **Full access**. A remembered approval is available only | ||
| 34 | when the agent offers it for that action. Fixed-choice questions require one of the offered | ||
| 35 | answers and do not accept custom text. | ||
| 36 | |||
| 32 | ## Choosing a Mode | 37 | ## Choosing a Mode |
| 33 | 38 | ||
| 34 | Use **Full access** for work in a worktree or a sandbox you can throw away. | 39 | Use **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 |
| 49 | describe what you get; the exact per-provider translation is internal and may change. | 54 | describe what you get; the exact per-provider translation is internal and may change. |
| 50 | 55 | ||
| 51 | Mobile offers the same four modes with the same labels and descriptions. | 56 | Mobile offers the same four modes with the same labels and descriptions. |
| 57 | |||
| 58 | Antigravity's native `/plan` command requests a plan. It does not change the permission mode. | ||
| 59 | T3 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 | |||
| 3 | T3 Code runs Google's official Antigravity ACP agent. By default it signs in with your personal | ||
| 4 | Google account and uses that account's Antigravity access, including access provided by a | ||
| 5 | Google AI subscription. It never falls back to a different sign-in method than the one you | ||
| 6 | select. | ||
| 7 | |||
| 8 | The agent, files, conversation state, and Google credentials stay on the selected environment. | ||
| 9 | Your browser or phone controls that environment. Signing in to the Antigravity IDE or CLI does | ||
| 10 | not sign in this T3 Code provider. | ||
| 11 | |||
| 12 | ## Set up Antigravity | ||
| 13 | |||
| 14 | On web or desktop, open **Settings** > **Providers**, select the device that runs your project, | ||
| 15 | then select **Antigravity**. On mobile, open **Settings** > **Environments**, expand the | ||
| 16 | environment, then choose **Set up Antigravity**. An existing setup shows **Manage Antigravity**. | ||
| 17 | The model picker can open setup for the selected environment too. | ||
| 18 | |||
| 19 | 1. Choose **Enable Antigravity**. Antigravity is off by default. | ||
| 20 | 2. Choose **Install Antigravity**. T3 Code downloads the official runtime from Google to that | ||
| 21 | environment. Installation continues if you leave the page or reconnect. | ||
| 22 | 3. Choose **Sign in with Google**. | ||
| 23 | 4. 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. | ||
| 25 | 5. Complete Google sign-in. Use the account you use for Antigravity. | ||
| 26 | 6. 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 | |||
| 29 | Setup requires a connection with permission to operate the environment. If setup is unavailable | ||
| 30 | on an older server, update that environment first. | ||
| 31 | |||
| 32 | ### Sign in from a remote device | ||
| 33 | |||
| 34 | Google returns to a `127.0.0.1` address on the device running your browser. If that is the same | ||
| 35 | machine as the T3 Code environment, sign-in can finish directly. | ||
| 36 | |||
| 37 | On a phone or another computer, the final page will usually fail to load. This is expected. | ||
| 38 | Copy the full address from the browser, including everything after `?`, and paste it into the | ||
| 39 | return URL field in T3 Code. Choose **Continue** on web or desktop, or **Complete sign-in** on | ||
| 40 | mobile. Do not change the address to your server's hostname. | ||
| 41 | |||
| 42 | Return to the same T3 Code client and environment where you started sign-in. Another client | ||
| 43 | can see that sign-in is in progress, but cannot complete that attempt. The setup screen shows | ||
| 44 | the expiry time. If it expires, choose **Retry Google sign-in** and use the new link. | ||
| 45 | |||
| 46 | The return URL contains a temporary sign-in code. Paste it only into the setup field, not into | ||
| 47 | a thread or bug report. T3 Code waits for Google's agent to confirm sign-in. A successful | ||
| 48 | callback 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 | ||
| 53 | agent 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 | |||
| 62 | Gemini Enterprise resolves your license for the project and location you enter. Agent Platform | ||
| 63 | with a project and location uses Application Default Credentials on the environment. API keys | ||
| 64 | are stored in plain text in T3 Code settings on that environment and are passed only to the | ||
| 65 | Antigravity agent process. Ambient `GEMINI_API_KEY` or `GOOGLE_*` variables on the environment | ||
| 66 | are ignored. | ||
| 67 | |||
| 68 | Changing the method stops the instance's sessions. Sign out or disconnect before you switch | ||
| 69 | accounts. | ||
| 70 | |||
| 71 | ## Runtime installation | ||
| 72 | |||
| 73 | Managed 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 | |||
| 81 | Google does not publish a local Intel Mac runtime. Use an Intel Mac as a client connected to | ||
| 82 | a supported remote environment. | ||
| 83 | |||
| 84 | The current Linux x64 runtime downloads about 543 MB and uses about 1.65 GB after extraction. | ||
| 85 | Allow at least 2.5 GB free for installation. An update keeps the previous runtime too. | ||
| 86 | T3 Code does not download it until you choose to install it. | ||
| 87 | |||
| 88 | On web or desktop, **Update Antigravity** appears when T3 Code has a newer managed release. | ||
| 89 | Running sessions keep their current runtime. New sessions use the installed update. | ||
| 90 | |||
| 91 | ### Use a manual installation | ||
| 92 | |||
| 93 | Download the correct archive from the [official ACP Registry][registry] and extract both the | ||
| 94 | ACP executable and its `localharness_external` helper into the same directory. Keep both files | ||
| 95 | at the same version and make them executable on macOS or Linux. Windows uses `.exe` files. | ||
| 96 | |||
| 97 | On web or desktop, set **Binary path** in the Antigravity provider settings to the ACP | ||
| 98 | executable on the selected environment. Do not point it at the helper or the Antigravity IDE. | ||
| 99 | |||
| 100 | A nonempty **Binary path** takes priority. With the field empty, T3 Code uses its managed | ||
| 101 | runtime, then an installation on the environment's `PATH`. An invalid explicit path reports | ||
| 102 | an error instead of selecting another installation. T3 Code does not update or remove manual | ||
| 103 | installations. Clear **Binary path** to use managed installation controls. | ||
| 104 | |||
| 105 | ## Models and threads | ||
| 106 | |||
| 107 | The current official ACP exposes Gemini models only. T3 Code uses the model IDs and names | ||
| 108 | returned for your account, including any model choices with different thinking levels. Models | ||
| 109 | available in other Antigravity apps might not be available through this agent. | ||
| 110 | |||
| 111 | New threads use Gemini 3.8 Flash (High) when your account offers it. Older Gemini generations | ||
| 112 | stay available under **Legacy models** in the picker. | ||
| 113 | |||
| 114 | Threads keep their selected model when you resume them. If that model is no longer available, | ||
| 115 | T3 Code asks you to select an available model instead of silently changing it. | ||
| 116 | |||
| 117 | Use Antigravity's native `/plan` command to request a plan. T3 Code's separate Plan mode control | ||
| 118 | is not available for this provider. | ||
| 119 | |||
| 120 | Antigravity reads and edits workspace files through T3 Code. Each write shows up as a file | ||
| 121 | change approval with the content, so **Supervised** and **Auto-accept edits** behave the same way | ||
| 122 | they do for other providers. Attach images, PDFs, text files, or audio clips to a message and | ||
| 123 | the agent receives them directly. | ||
| 124 | |||
| 125 | When the agent offers **Allow for this thread** on a shell or web tool, T3 Code shows Google's | ||
| 126 | prompt injection warning next to that choice. Untrusted content could re-run the same action | ||
| 127 | without asking again. | ||
| 128 | |||
| 129 | Antigravity can ask you to choose from a fixed set of answers. Select one of the offered | ||
| 130 | choices. These questions do not accept custom text and still appear in **Full access** mode. | ||
| 131 | See [Permission modes](./permission-modes.md) for tool approvals. | ||
| 132 | |||
| 133 | T3 Code keeps thread history and file diffs. Antigravity does not support conversation rewind, | ||
| 134 | so reverting a thread or editing and resubmitting an earlier turn is unavailable. Send a | ||
| 135 | follow-up message or start a new thread instead. | ||
| 136 | |||
| 137 | ## Accounts and removal | ||
| 138 | |||
| 139 | Each 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 | ||
| 141 | account. 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 | |||
| 149 | Send `/logout` by itself in an Antigravity thread to sign out that provider instance. This has | ||
| 150 | the same effect as **Sign out of Google**, including stopping its other sessions. | ||
| 151 | |||
| 152 | Before removing a managed runtime, disable the instances that use it and cancel any active | ||
| 153 | installation. T3 Code refuses removal while the runtime is in use. An explicit **Binary path** | ||
| 154 | that points into the managed runtime must be cleared first. | ||
| 155 | |||
| 156 | ## Account access and errors | ||
| 157 | |||
| 158 | Google controls account eligibility, models, and usage limits. T3 Code does not report your | ||
| 159 | paid-plan tier or remaining subscription quota. See Google's [Antigravity plans][plans] and | ||
| 160 | [personal Google sign-in guide][google-setup]. | ||
| 161 | |||
| 162 | After an environment restarts, Google sign-in can show as not checked until an authenticated | ||
| 163 | session succeeds. To check account access and reload models, use **Refresh provider status** | ||
| 164 | in web or desktop provider settings, or **Refresh models** in the mobile model picker. Refresh | ||
| 165 | uses saved Google sign-in and does not open a login page. If sign-in is required, use the | ||
| 166 | provider's setup controls. Automatic status checks verify the installation only. | ||
| 167 | |||
| 168 | If Google reports `SUBSCRIPTION_REQUIRED`, an account restriction, or a usage limit, read the | ||
| 169 | provider's message. A finished turn can contain an upstream error instead of completed work. | ||
| 170 | Use 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 |
| 167 | 167 | ||
| 168 | If 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. | 168 | If 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. |
| 169 | 169 | ||
| 170 | ## Antigravity Google sign-in | ||
| 171 | |||
| 172 | Antigravity runs and saves its Google credentials on the selected environment. You can install | ||
| 173 | it and sign in from a remote web, desktop, or mobile client without an SSH login. | ||
| 174 | |||
| 175 | Start in **Settings** > **Providers** on web or desktop. On mobile, open **Settings** > | ||
| 176 | **Environments**, expand the environment, then choose **Set up Antigravity**. | ||
| 177 | |||
| 178 | After Google sign-in, a remote browser usually reaches a `127.0.0.1` page that cannot load. | ||
| 179 | Copy 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. | ||
| 181 | Do not paste the return URL into a thread or bug report. | ||
| 182 | |||
| 183 | See [Antigravity setup](./providers-antigravity.md) for installation, expiry, and account changes. | ||
| 184 | |||
| 170 | ## Updating a Remote Server | 185 | ## Updating a Remote Server |
| 171 | 186 | ||
| 172 | When the T3 Code web or desktop app and a remote server use different versions, a warning appears in | 187 | When 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; |
| 40 | type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag]; | 40 | type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag]; |
| 41 | 41 | ||
| 42 | export type EnvironmentSubscriptionRpcTag = | 42 | export type EnvironmentSubscriptionRpcTag = |
| 43 | | typeof WS_METHODS.providerAuthSubscribe | ||
| 44 | | typeof WS_METHODS.providerInstallSubscribe | ||
| 43 | | typeof ORCHESTRATION_WS_METHODS.subscribeShell | 45 | | typeof ORCHESTRATION_WS_METHODS.subscribeShell |
| 44 | | typeof ORCHESTRATION_WS_METHODS.subscribeThread | 46 | | typeof ORCHESTRATION_WS_METHODS.subscribeThread |
| 45 | | typeof WS_METHODS.subscribeAuthAccess | 47 | | 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"; |
| 12 | export * from "./terminal.ts"; | 12 | export * from "./terminal.ts"; |
| 13 | export * from "./provider.ts"; | 13 | export * from "./provider.ts"; |
| 14 | export * from "./providerInstance.ts"; | 14 | export * from "./providerInstance.ts"; |
| 15 | export * from "./providerSetup.ts"; | ||
| 15 | export * from "./providerRuntime.ts"; | 16 | export * from "./providerRuntime.ts"; |
| 16 | export * from "./model.ts"; | 17 | export * from "./model.ts"; |
| 17 | export * from "./keybindings.ts"; | 18 | export * 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 | ]; |
| 147 | export const DEFAULT_TEXT_GENERATION_MODEL = "gpt-5.6-luna"; | 147 | export const DEFAULT_TEXT_GENERATION_MODEL = "gpt-5.6-luna"; |
| 148 | /** Keep the official Antigravity session's current model. Never send this ID to ACP. */ | ||
| 149 | export const ANTIGRAVITY_DEFAULT_MODEL = "antigravity-default"; | ||
| 148 | export const DEFAULT_TEXT_GENERATION_REASONING_EFFORT = "low"; | 150 | export const DEFAULT_TEXT_GENERATION_REASONING_EFFORT = "low"; |
| 149 | 151 | ||
| 150 | export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, string>> = { | 152 | export 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 | }; |
| 158 | 161 | ||
| 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 ──────────────────────────────────────────── |
| 196 | 200 | ||
| 197 | export const PROVIDER_DISPLAY_NAMES: Partial<Record<ProviderDriverKind, string>> = { | 201 | export 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; |
| 148 | export const ProviderApprovalOption = Schema.Struct({ | 148 | export 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 | }); |
| 152 | export type ProviderApprovalOption = typeof ProviderApprovalOption.Type; | 154 | export type ProviderApprovalOption = typeof ProviderApprovalOption.Type; |
| 153 | export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unknown); | 155 | export 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; |
| 492 | const UserInputQuestionOption = Schema.Struct({ | 492 | const UserInputQuestionOption = Schema.Struct({ |
| 493 | label: TrimmedNonEmptyStringSchema, | 493 | label: TrimmedNonEmptyStringSchema, |
| 494 | description: TrimmedNonEmptyStringSchema, | 494 | description: TrimmedNonEmptyStringSchema, |
| 495 | value: Schema.optional(Schema.String), | ||
| 495 | }); | 496 | }); |
| 496 | export type UserInputQuestionOption = typeof UserInputQuestionOption.Type; | 497 | export type UserInputQuestionOption = typeof UserInputQuestionOption.Type; |
| 497 | 498 | ||
| ... | @@ -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 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | |||
| 3 | import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas.ts"; | ||
| 4 | import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; | ||
| 5 | |||
| 6 | export const ProviderSetupInput = Schema.Struct({ | ||
| 7 | instanceId: ProviderInstanceId, | ||
| 8 | }); | ||
| 9 | export type ProviderSetupInput = typeof ProviderSetupInput.Type; | ||
| 10 | |||
| 11 | const SetupOperationId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); | ||
| 12 | |||
| 13 | export 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 | }); | ||
| 29 | export type ProviderAuthState = typeof ProviderAuthState.Type; | ||
| 30 | |||
| 31 | export const ProviderAuthCompleteInput = Schema.Struct({ | ||
| 32 | instanceId: ProviderInstanceId, | ||
| 33 | flowId: SetupOperationId, | ||
| 34 | callbackUrl: TrimmedNonEmptyString.check(Schema.isMaxLength(16_384)), | ||
| 35 | }); | ||
| 36 | export type ProviderAuthCompleteInput = typeof ProviderAuthCompleteInput.Type; | ||
| 37 | |||
| 38 | export const ProviderAuthCancelInput = Schema.Struct({ | ||
| 39 | instanceId: ProviderInstanceId, | ||
| 40 | flowId: SetupOperationId, | ||
| 41 | }); | ||
| 42 | export type ProviderAuthCancelInput = typeof ProviderAuthCancelInput.Type; | ||
| 43 | |||
| 44 | const ByteCount = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); | ||
| 45 | |||
| 46 | export 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 | }); | ||
| 65 | export type ProviderInstallState = typeof ProviderInstallState.Type; | ||
| 66 | |||
| 67 | export const ProviderInstallCancelInput = Schema.Struct({ | ||
| 68 | instanceId: ProviderInstanceId, | ||
| 69 | operationId: SetupOperationId, | ||
| 70 | }); | ||
| 71 | export type ProviderInstallCancelInput = typeof ProviderInstallCancelInput.Type; | ||
| 72 | |||
| 73 | /** Safe setup failure text. Never include OAuth codes, URLs, or native token data. */ | ||
| 74 | export 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"; |
| 2 | import * as Rpc from "effect/unstable/rpc/Rpc"; | 2 | import * as Rpc from "effect/unstable/rpc/Rpc"; |
| 3 | import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; | 3 | import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; |
| 4 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; | 4 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 5 | import { | ||
| 6 | ProviderAuthCancelInput, | ||
| 7 | ProviderAuthCompleteInput, | ||
| 8 | ProviderAuthState, | ||
| 9 | ProviderInstallCancelInput, | ||
| 10 | ProviderInstallState, | ||
| 11 | ProviderSetupError, | ||
| 12 | ProviderSetupInput, | ||
| 13 | } from "./providerSetup.ts"; | ||
| 5 | 14 | ||
| 6 | import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts"; | 15 | import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts"; |
| 7 | import { | 16 | import { |
| ... | @@ -233,6 +242,15 @@ export const WS_METHODS = { | ... | @@ -233,6 +242,15 @@ export const WS_METHODS = { |
| 233 | 242 | ||
| 234 | // Provider methods | 243 | // 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", | ||
| 236 | 254 | ||
| 237 | // VCS methods | 255 | // 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 | }); |
| 383 | 403 | ||
| 384 | export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { | 404 | export 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 | }); |
| 389 | 409 | ||
| 410 | const ProviderSetupRpcError = Schema.Union([ProviderSetupError, EnvironmentAuthorizationError]); | ||
| 411 | |||
| 412 | export const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, { | ||
| 413 | payload: ProviderSetupInput, | ||
| 414 | success: ProviderAuthState, | ||
| 415 | error: ProviderSetupRpcError, | ||
| 416 | }); | ||
| 417 | |||
| 418 | export const WsProviderAuthCompleteRpc = Rpc.make(WS_METHODS.providerAuthComplete, { | ||
| 419 | payload: ProviderAuthCompleteInput, | ||
| 420 | success: ProviderAuthState, | ||
| 421 | error: ProviderSetupRpcError, | ||
| 422 | }); | ||
| 423 | |||
| 424 | export const WsProviderAuthCancelRpc = Rpc.make(WS_METHODS.providerAuthCancel, { | ||
| 425 | payload: ProviderAuthCancelInput, | ||
| 426 | success: ProviderAuthState, | ||
| 427 | error: ProviderSetupRpcError, | ||
| 428 | }); | ||
| 429 | |||
| 430 | export const WsProviderAuthLogoutRpc = Rpc.make(WS_METHODS.providerAuthLogout, { | ||
| 431 | payload: ProviderSetupInput, | ||
| 432 | success: ProviderAuthState, | ||
| 433 | error: ProviderSetupRpcError, | ||
| 434 | }); | ||
| 435 | |||
| 436 | export const WsProviderAuthSubscribeRpc = Rpc.make(WS_METHODS.providerAuthSubscribe, { | ||
| 437 | payload: ProviderSetupInput, | ||
| 438 | success: ProviderAuthState, | ||
| 439 | error: ProviderSetupRpcError, | ||
| 440 | stream: true, | ||
| 441 | }); | ||
| 442 | |||
| 443 | export const WsProviderInstallStartRpc = Rpc.make(WS_METHODS.providerInstallStart, { | ||
| 444 | payload: ProviderSetupInput, | ||
| 445 | success: ProviderInstallState, | ||
| 446 | error: ProviderSetupRpcError, | ||
| 447 | }); | ||
| 448 | |||
| 449 | export const WsProviderInstallCancelRpc = Rpc.make(WS_METHODS.providerInstallCancel, { | ||
| 450 | payload: ProviderInstallCancelInput, | ||
| 451 | success: ProviderInstallState, | ||
| 452 | error: ProviderSetupRpcError, | ||
| 453 | }); | ||
| 454 | |||
| 455 | export const WsProviderInstallSubscribeRpc = Rpc.make(WS_METHODS.providerInstallSubscribe, { | ||
| 456 | payload: ProviderSetupInput, | ||
| 457 | success: ProviderInstallState, | ||
| 458 | error: ProviderSetupRpcError, | ||
| 459 | stream: true, | ||
| 460 | }); | ||
| 461 | |||
| 462 | export const WsProviderInstallRemoveRpc = Rpc.make(WS_METHODS.providerInstallRemove, { | ||
| 463 | payload: ProviderSetupInput, | ||
| 464 | success: ProviderInstallState, | ||
| 465 | error: ProviderSetupRpcError, | ||
| 466 | }); | ||
| 467 | |||
| 390 | export const WsServerUpdateServerRpc = Rpc.make(WS_METHODS.serverUpdateServer, { | 468 | export 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 | ); |
| 367 | 367 | ||
| 368 | export type ProviderSettingsFormControl = "text" | "password" | "textarea" | "switch"; | 368 | export type ProviderSettingsFormControl = "text" | "password" | "textarea" | "switch" | "select"; |
| 369 | |||
| 370 | export interface ProviderSettingsFormOption { | ||
| 371 | readonly value: string; | ||
| 372 | readonly label: string; | ||
| 373 | } | ||
| 369 | 374 | ||
| 370 | export interface ProviderSettingsFormAnnotation { | 375 | export 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 | } |
| 376 | 383 | ||
| 377 | export interface ProviderSettingsFormSchemaAnnotation { | 384 | export interface ProviderSettingsFormSchemaAnnotation { |
| ... | @@ -585,6 +592,89 @@ export const GrokSettings = makeProviderSettingsSchema( | ... | @@ -585,6 +592,89 @@ export const GrokSettings = makeProviderSettingsSchema( |
| 585 | ); | 592 | ); |
| 586 | export type GrokSettings = typeof GrokSettings.Type; | 593 | export type GrokSettings = typeof GrokSettings.Type; |
| 587 | 594 | ||
| 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 | */ | ||
| 600 | export 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>; | ||
| 606 | export const AntigravityAuthMethod = Schema.Literals( | ||
| 607 | ANTIGRAVITY_AUTH_METHODS.map((method) => method.value), | ||
| 608 | ); | ||
| 609 | export type AntigravityAuthMethod = typeof AntigravityAuthMethod.Type; | ||
| 610 | |||
| 611 | export 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 | ); | ||
| 676 | export type AntigravitySettings = typeof AntigravitySettings.Type; | ||
| 677 | |||
| 588 | export const OpenCodeSettings = makeProviderSettingsSchema( | 678 | export const OpenCodeSettings = makeProviderSettingsSchema( |
| 589 | { | 679 | { |
| 590 | // Off by default (like Cursor and Grok): the binding is not yet stable | 680 | // 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`; values | 899 | // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values |
| 809 | // are `ProviderInstanceConfig` envelopes. The driver-specific config blob | 900 | // 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 | }); |
| 951 | 1042 | ||
| 1043 | const 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 | |||
| 952 | const OpenCodeSettingsPatch = Schema.Struct({ | 1053 | const 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 individual | 1108 | // 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"; |
| 5 | import * as AcpSchema from "../_generated/schema.gen.ts"; | 5 | import * as AcpSchema from "../_generated/schema.gen.ts"; |
| 6 | import * as AcpError from "../errors.ts"; | 6 | import * as AcpError from "../errors.ts"; |
| 7 | const isError = Schema.is(AcpSchema.Error); | 7 | const isError = Schema.is(AcpSchema.Error); |
| 8 | const isAcpError = Schema.is(AcpError.AcpError); | ||
| 8 | 9 | ||
| 9 | export const callRpc = <A>( | 10 | export 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 @@ |
| 1 | import * as Path from "effect/Path"; | 1 | import * as Path from "effect/Path"; |
| 2 | import * as Cause from "effect/Cause"; | 2 | import * as Cause from "effect/Cause"; |
| 3 | import * as Deferred from "effect/Deferred"; | ||
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 4 | import * as Exit from "effect/Exit"; | 5 | import * as Exit from "effect/Exit"; |
| 5 | import * as Fiber from "effect/Fiber"; | 6 | import * 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 |
| 31 | const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean })); | 32 | const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean })); |
| 32 | const PromptRequest = jsonRpcRequest("session/prompt", AcpSchema.PromptRequest); | 33 | const PromptRequest = jsonRpcRequest("session/prompt", AcpSchema.PromptRequest); |
| 33 | const PromptResponse = jsonRpcResponse(AcpSchema.PromptResponse); | 34 | const PromptResponse = jsonRpcResponse(AcpSchema.PromptResponse); |
| 35 | const SessionUpdateNotification = jsonRpcNotification( | ||
| 36 | "session/update", | ||
| 37 | AcpSchema.SessionNotification, | ||
| 38 | ); | ||
| 34 | const decodePromptRequestLine = Schema.decodeEffect(Schema.fromJsonString(PromptRequest)); | 39 | const decodePromptRequestLine = Schema.decodeEffect(Schema.fromJsonString(PromptRequest)); |
| 35 | const XAiPromptCompleteNotification = jsonRpcNotification( | 40 | const 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 | ); |
| 61 | const mockPeerArgs = (path: string) => [path]; | 66 | const mockPeerArgs = (path: string) => [path]; |
| 67 | const mockStartupNotice = "Mock ACP startup notice"; | ||
| 68 | const 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 | ); | ||
| 62 | 76 | ||
| 63 | function concatBytes(chunks: ReadonlyArray<Uint8Array>): Uint8Array { | 77 | function 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 | }); |
| 84 | 98 | ||
| 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 @@ |
| 1 | import * as Context from "effect/Context"; | 1 | import * as Context from "effect/Context"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Stdio from "effect/Stdio"; | ||
| 4 | import * as Layer from "effect/Layer"; | 3 | import * as Layer from "effect/Layer"; |
| 5 | import * as Schema from "effect/Schema"; | 4 | import * as Schema from "effect/Schema"; |
| 6 | import * as Scope from "effect/Scope"; | 5 | import * 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 | } |
| 31 | 40 | ||
| 32 | type AcpClientRaw = { | 41 | type AcpClientRaw = { |
| ... | @@ -307,7 +316,7 @@ interface BufferedNotificationHandler<A> { | ... | @@ -307,7 +316,7 @@ interface BufferedNotificationHandler<A> { |
| 307 | } | 316 | } |
| 308 | 317 | ||
| 309 | export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( | 318 | export 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 | }); |
| 574 | 587 | ||
| 575 | export const layer = (stdio: Stdio.Stdio, options: AcpClientOptions = {}): Layer.Layer<AcpClient> => | 588 | export 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)); | ||
| 577 | 592 | ||
| 578 | export const layerChildProcess = ( | 593 | export 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 | }); |
| 38 | 38 | ||
| 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 | }); |
| 183 | 188 | ||
| ... | @@ -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 | ); |
| 200 | 205 | ||
| 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"; |
| 4 | import * as Exit from "effect/Exit"; | 4 | import * as Exit from "effect/Exit"; |
| 5 | import * as Queue from "effect/Queue"; | 5 | import * as Queue from "effect/Queue"; |
| 6 | import * as Ref from "effect/Ref"; | 6 | import * as Ref from "effect/Ref"; |
| 7 | import type * as PlatformError from "effect/PlatformError"; | ||
| 7 | import * as Schema from "effect/Schema"; | 8 | import * as Schema from "effect/Schema"; |
| 8 | import * as Scope from "effect/Scope"; | 9 | import * as Scope from "effect/Scope"; |
| 9 | import * as Stream from "effect/Stream"; | 10 | import * 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 | }; |
| 44 | 45 | ||
| 46 | /** Standard I/O whose input can report provider-specific ACP failures. */ | ||
| 47 | export interface AcpStdio extends Omit<Stdio.Stdio, "stdin"> { | ||
| 48 | readonly stdin: Stream.Stream<Uint8Array, PlatformError.PlatformError | AcpError.AcpError>; | ||
| 49 | } | ||
| 50 | |||
| 45 | export interface AcpPatchedProtocolOptions { | 51 | export 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>()); |
| 106 | 116 | ||
| 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 | }); |
| 153 | 168 | ||
| 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); |
| 219 | 235 | ||
| 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 | }); |
| 564 | 580 | ||
| 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"; |
| 6 | 6 | ||
| 7 | import * as AcpAgent from "../../src/agent.ts"; | 7 | import * as AcpAgent from "../../src/agent.ts"; |
| 8 | 8 | ||
| 9 | if (process.env.ACP_MOCK_STDOUT_PREFIX !== undefined) { | ||
| 10 | process.stdout.write(process.env.ACP_MOCK_STDOUT_PREFIX); | ||
| 11 | } | ||
| 12 | |||
| 9 | if (process.env.ACP_MOCK_MALFORMED_OUTPUT === "1") { | 13 | if (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.0 | 506 | specifier: ^2.9.0 |
| 507 | version: 2.9.0 | 507 | 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.103 | 513 | 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.4 | 531 | specifier: 24.12.4 |
| 529 | version: 24.12.4 | 532 | 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-acp | 538 | 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==} |
| 4795 | 4801 | ||
| 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'} |
| 8529 | 8538 | ||
| 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==} |
| 8532 | 8544 | ||
| ... | @@ -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'} |
| 10341 | 10353 | ||
| 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.3 | 14636 | '@types/yargs-parser': 21.0.3 |
| 14621 | 14637 | ||
| 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: true | 14643 | optional: true |
| 14624 | 14644 | ||
| ... | @@ -19064,6 +19084,8 @@ snapshots: | ... | @@ -19064,6 +19084,8 @@ snapshots: |
| 19064 | 19084 | ||
| 19065 | pe-library@0.4.1: {} | 19085 | pe-library@0.4.1: {} |
| 19066 | 19086 | ||
| 19087 | pend@1.2.0: {} | ||
| 19088 | |||
| 19067 | pg-cloudflare@1.4.0: | 19089 | pg-cloudflare@1.4.0: |
| 19068 | optional: true | 19090 | optional: true |
| 19069 | 19091 | ||
| ... | @@ -21124,6 +21146,10 @@ snapshots: | ... | @@ -21124,6 +21146,10 @@ snapshots: |
| 21124 | y18n: 5.0.8 | 21146 | y18n: 5.0.8 |
| 21125 | yargs-parser: 21.1.1 | 21147 | yargs-parser: 21.1.1 |
| 21126 | 21148 | ||
| 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.117 | 21155 | lib0: 0.2.117 |