From b0466742ebbc45ef6cca53d8df3eb80f5502a47d Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:43:25 +0000 Subject: [PATCH] feat: allow Agent Connections via remote feature flag --- docs/agent-mode-acp.md | 6 +- .../components/settings/SettingsLayout.tsx | 14 +- .../useAgentConnectionsAvailability.test.tsx | 272 ++++++++++++++++++ .../useAgentConnectionsAvailability.tsx | 106 +++++++ .../src/routes/settings.agent-connections.tsx | 8 +- .../agentConnectionsAvailability.test.ts | 36 +-- .../services/agentConnectionsAvailability.ts | 21 +- 7 files changed, 416 insertions(+), 47 deletions(-) create mode 100644 frontend/src/components/settings/useAgentConnectionsAvailability.test.tsx create mode 100644 frontend/src/components/settings/useAgentConnectionsAvailability.tsx diff --git a/docs/agent-mode-acp.md b/docs/agent-mode-acp.md index 8f56a1f8..eca46cfa 100644 --- a/docs/agent-mode-acp.md +++ b/docs/agent-mode-acp.md @@ -147,13 +147,15 @@ This keeps Maple's domain model a useful superset instead of forcing Maple, Taur ## Lifecycle and desktop configuration -The Agent connections settings surface is fail-closed and hidden by default. To expose it in a macOS or Linux Tauri Desktop development build, set the local Vite override in `frontend/.env.local` (or the build environment) and restart the frontend dev server: +The Agent connections settings surface is fail-closed and hidden by default. On macOS and Linux Tauri Desktop it can be enabled for a user by the remote `agent_connections` feature flag. For local development, set the Vite override in `frontend/.env.local` (or the build environment) and restart the frontend dev server: ```dotenv VITE_FORCE_FEATURE_FLAGS=agent_connections ``` -This preview gate uses only the local `VITE_FORCE_FEATURE_FLAGS` override; the remote feature-flag service cannot enable it. Web, mobile, and Windows builds keep both the navigation item and direct route unavailable even when the override is present. +The local override takes precedence; otherwise Maple checks the user-scoped remote flag. Web, mobile, and Windows builds keep both the navigation item and direct route unavailable even when either flag is enabled. This gate controls discovery of the preview settings surface only: it does not start the ACP service, which remains a manual action after every app launch. + +Before remote rollout, create `agent_connections` with a default value of `false` in each OS Flags environment. An absent key is treated as disabled. Flag changes are resolved when the settings tree mounts, successful values may remain cached for up to ten minutes, and the flag is not a live kill switch for an already running ACP service. The macOS/Linux desktop settings page is intentionally manual. It can: diff --git a/frontend/src/components/settings/SettingsLayout.tsx b/frontend/src/components/settings/SettingsLayout.tsx index db55353d..4c30dfa9 100644 --- a/frontend/src/components/settings/SettingsLayout.tsx +++ b/frontend/src/components/settings/SettingsLayout.tsx @@ -34,7 +34,6 @@ import { restoreMapleApiAuthForUser, stopAgentRuntimeForUser } from "@/services/agentRuntimeService"; -import { isAgentConnectionsAvailable } from "@/services/agentConnectionsAvailability"; import { resetWorkspaceModePreference } from "@/services/workspaceModePreference"; import { useLocalState } from "@/state/useLocalState"; import type { TeamStatus } from "@/types/team"; @@ -42,6 +41,10 @@ import { isIOS } from "@/utils/platform"; import { getTeamSeatMismatch } from "@/utils/teamSeats"; import { cn } from "@/utils/utils"; import packageJson from "../../../package.json"; +import { + AgentConnectionsAvailabilityProvider, + useAgentConnectionsAvailability +} from "./useAgentConnectionsAvailability"; type SettingsNavItem = { label: string; @@ -236,7 +239,8 @@ function SettingsLayoutContent() { }); const isIOSPlatform = isIOS(); - const supportsAgentConnections = isAgentConnectionsAvailable(); + const agentConnectionsAvailability = useAgentConnectionsAvailability(); + const supportsAgentConnections = agentConnectionsAvailability === "available"; const { data: products, isError: productsError } = useQuery({ queryKey: ["products-version-check", isIOSPlatform], queryFn: () => getBillingService().getProducts(`v${packageJson.version}`), @@ -526,9 +530,13 @@ function SettingsLayoutContent() { } export function SettingsLayout() { + const os = useOpenSecret(); + return ( - + + + ); } diff --git a/frontend/src/components/settings/useAgentConnectionsAvailability.test.tsx b/frontend/src/components/settings/useAgentConnectionsAvailability.test.tsx new file mode 100644 index 00000000..d0feca9c --- /dev/null +++ b/frontend/src/components/settings/useAgentConnectionsAvailability.test.tsx @@ -0,0 +1,272 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { FEATURE_FLAGS, FlagsClient } from "@/services/flags"; +import { + AgentConnectionsAvailabilityProvider, + useAgentConnectionsAvailability, + type AgentConnectionsAvailability, + type AgentConnectionsAvailabilityDependencies +} from "./useAgentConnectionsAvailability"; + +const USER_A = "00000000-0000-0000-0000-000000000001"; +const USER_B = "00000000-0000-0000-0000-000000000002"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function AvailabilityProbe({ + dependencies, + userId +}: { + dependencies: AgentConnectionsAvailabilityDependencies; + userId: string | null; +}) { + return ( + + + + ); +} + +function AvailabilityValue() { + const availability = useAgentConnectionsAvailability(); + return {availability}; +} + +function renderedAvailability(renderer: ReactTestRenderer): AgentConnectionsAvailability { + const [availability] = renderer.root.findByType("span").children; + if ( + availability !== "checking" && + availability !== "available" && + availability !== "unavailable" + ) { + throw new Error("Availability probe did not render a valid state"); + } + return availability; +} + +function dependencies({ + cached, + enabled, + platformSupported = true +}: { + cached?: boolean; + enabled?: (userId: string, key: string) => Promise; + platformSupported?: boolean; +} = {}) { + const peekIsEnabled = mock(() => cached); + const isEnabled = mock(enabled ?? (async () => cached === true)); + return { + dependencies: { + flagClient: { isEnabled, peekIsEnabled }, + isPlatformSupported: () => platformSupported + }, + isEnabled, + peekIsEnabled + }; +} + +describe("useAgentConnectionsAvailability", () => { + let renderer: ReactTestRenderer | null = null; + + afterEach(() => { + if (renderer) act(() => renderer?.unmount()); + renderer = null; + }); + + test("does not consult flags without a supported platform and authenticated user", () => { + const unsupported = dependencies({ platformSupported: false }); + + act(() => { + renderer = create( + + ); + }); + + expect(renderedAvailability(renderer!)).toBe("unavailable"); + expect(unsupported.peekIsEnabled).not.toHaveBeenCalled(); + expect(unsupported.isEnabled).not.toHaveBeenCalled(); + + const supported = dependencies(); + act(() => { + renderer?.update(); + }); + + expect(renderedAvailability(renderer!)).toBe("unavailable"); + expect(supported.peekIsEnabled).not.toHaveBeenCalled(); + expect(supported.isEnabled).not.toHaveBeenCalled(); + }); + + test("waits for a cold remote lookup before admitting the surface", async () => { + const lookup = deferred(); + const remote = dependencies({ enabled: () => lookup.promise }); + + act(() => { + renderer = create(); + }); + + expect(renderedAvailability(renderer!)).toBe("checking"); + expect(remote.peekIsEnabled).toHaveBeenCalledWith(USER_A, FEATURE_FLAGS.AGENT_CONNECTIONS); + expect(remote.isEnabled).toHaveBeenCalledWith(USER_A, FEATURE_FLAGS.AGENT_CONNECTIONS); + + await act(async () => { + lookup.resolve(true); + await lookup.promise; + }); + + expect(renderedAvailability(renderer!)).toBe("available"); + }); + + test("keeps a remotely disabled surface unavailable", async () => { + const lookup = deferred(); + const remote = dependencies({ enabled: () => lookup.promise }); + + act(() => { + renderer = create(); + }); + + await act(async () => { + lookup.resolve(false); + await lookup.promise; + }); + + expect(renderedAvailability(renderer!)).toBe("unavailable"); + }); + + test("uses a cached value on the first render", () => { + const cached = dependencies({ cached: true }); + + act(() => { + renderer = create(); + }); + + expect(renderedAvailability(renderer!)).toBe("available"); + }); + + test("honors the real local override without calling the remote service", () => { + const env = import.meta.env as { VITE_FORCE_FEATURE_FLAGS?: string }; + const previousOverride = env.VITE_FORCE_FEATURE_FLAGS; + const fetchFn = mock(async () => { + throw new Error("Local override should not fetch remote flags"); + }); + const localClient = new FlagsClient({ + baseUrl: "https://flags.example.test", + fetchFn + }); + env.VITE_FORCE_FEATURE_FLAGS = [previousOverride, FEATURE_FLAGS.AGENT_CONNECTIONS] + .filter(Boolean) + .join(","); + + try { + act(() => { + renderer = create( + true + }} + userId={USER_A} + /> + ); + }); + + expect(renderedAvailability(renderer!)).toBe("available"); + expect(fetchFn).not.toHaveBeenCalled(); + } finally { + if (previousOverride === undefined) delete env.VITE_FORCE_FEATURE_FLAGS; + else env.VITE_FORCE_FEATURE_FLAGS = previousOverride; + } + }); + + test("fails closed when the remote lookup errors", async () => { + const lookup = deferred(); + const remote = dependencies({ enabled: () => lookup.promise }); + const warning = spyOn(console, "warn").mockImplementation(() => {}); + + act(() => { + renderer = create(); + }); + + await act(async () => { + lookup.reject(new Error("flags unavailable")); + await lookup.promise.catch(() => undefined); + }); + + expect(renderedAvailability(renderer!)).toBe("unavailable"); + expect(warning).toHaveBeenCalledTimes(1); + warning.mockRestore(); + }); + + test("ignores a stale lookup after the authenticated user changes", async () => { + const userA = deferred(); + const userB = deferred(); + const remote = dependencies({ + enabled: (userId) => (userId === USER_A ? userA.promise : userB.promise) + }); + + act(() => { + renderer = create(); + }); + act(() => { + renderer?.update(); + }); + + await act(async () => { + userA.resolve(true); + await userA.promise; + }); + expect(renderedAvailability(renderer!)).toBe("checking"); + + await act(async () => { + userB.resolve(false); + await userB.promise; + }); + expect(renderedAvailability(renderer!)).toBe("unavailable"); + }); + + test("does not resurrect an old result after an A to B to A transition", async () => { + const firstUserA = deferred(); + const secondUserA = deferred(); + const userB = deferred(); + let userALookups = 0; + const remote = dependencies({ + enabled: (userId) => { + if (userId === USER_B) return userB.promise; + userALookups += 1; + return userALookups === 1 ? firstUserA.promise : secondUserA.promise; + } + }); + + act(() => { + renderer = create(); + }); + await act(async () => { + firstUserA.resolve(true); + await firstUserA.promise; + }); + expect(renderedAvailability(renderer!)).toBe("available"); + + act(() => { + renderer?.update(); + }); + act(() => { + renderer?.update(); + }); + + expect(renderedAvailability(renderer!)).toBe("checking"); + + await act(async () => { + secondUserA.resolve(false); + await secondUserA.promise; + }); + expect(renderedAvailability(renderer!)).toBe("unavailable"); + expect(userALookups).toBe(2); + }); +}); diff --git a/frontend/src/components/settings/useAgentConnectionsAvailability.tsx b/frontend/src/components/settings/useAgentConnectionsAvailability.tsx new file mode 100644 index 00000000..672fd5fa --- /dev/null +++ b/frontend/src/components/settings/useAgentConnectionsAvailability.tsx @@ -0,0 +1,106 @@ +import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { isAgentConnectionsPlatformSupported } from "@/services/agentConnectionsAvailability"; +import { FEATURE_FLAGS, flagsClient } from "@/services/flags"; + +export type AgentConnectionsAvailability = "checking" | "available" | "unavailable"; + +export interface AgentConnectionsFlagClient { + isEnabled: (userId: string, key: string) => Promise; + peekIsEnabled: (userId: string, key: string) => boolean | undefined; +} + +export interface AgentConnectionsAvailabilityDependencies { + flagClient: AgentConnectionsFlagClient; + isPlatformSupported: () => boolean; +} + +const defaultDependencies: AgentConnectionsAvailabilityDependencies = { + flagClient: flagsClient, + isPlatformSupported: isAgentConnectionsPlatformSupported +}; + +const AgentConnectionsAvailabilityContext = + createContext("unavailable"); + +type ResolvedAvailability = { + available: boolean; + lookupToken: object; +}; + +function useResolvedAgentConnectionsAvailability( + userId: string | null, + dependencies: AgentConnectionsAvailabilityDependencies +): AgentConnectionsAvailability { + const { flagClient, isPlatformSupported } = dependencies; + const platformSupported = isPlatformSupported(); + const lookupToken = useMemo( + () => ({ flagClient, platformSupported, userId }), + [flagClient, platformSupported, userId] + ); + const cachedAvailability = + platformSupported && userId + ? flagClient.peekIsEnabled(userId, FEATURE_FLAGS.AGENT_CONNECTIONS) + : undefined; + const [resolvedAvailability, setResolvedAvailability] = useState( + null + ); + + useEffect(() => { + if (!platformSupported || !userId) return; + + let disposed = false; + void flagClient.isEnabled(userId, FEATURE_FLAGS.AGENT_CONNECTIONS).then( + (available) => { + if (!disposed) setResolvedAvailability({ available, lookupToken }); + }, + (error: unknown) => { + console.warn( + "Unable to load the Agent connections feature flag; keeping it hidden.", + error + ); + if (!disposed) setResolvedAvailability({ available: false, lookupToken }); + } + ); + + return () => { + disposed = true; + }; + }, [flagClient, lookupToken, platformSupported, userId]); + + if (!platformSupported || !userId) return "unavailable"; + + const available = + resolvedAvailability?.lookupToken === lookupToken + ? resolvedAvailability.available + : cachedAvailability; + + if (available === undefined) return "checking"; + return available ? "available" : "unavailable"; +} + +/** + * Resolves the optional Agent connections surface once for the settings tree. + * FlagsClient applies the local override before its user-scoped remote lookup. + */ +export function AgentConnectionsAvailabilityProvider({ + children, + dependencies = defaultDependencies, + userId +}: { + children: ReactNode; + dependencies?: AgentConnectionsAvailabilityDependencies; + userId: string | null; +}) { + const availability = useResolvedAgentConnectionsAvailability(userId, dependencies); + return ( + + {children} + + ); +} + +// The consumer hook and provider intentionally share this module-private context. +// eslint-disable-next-line react-refresh/only-export-components +export function useAgentConnectionsAvailability(): AgentConnectionsAvailability { + return useContext(AgentConnectionsAvailabilityContext); +} diff --git a/frontend/src/routes/settings.agent-connections.tsx b/frontend/src/routes/settings.agent-connections.tsx index b6fba280..1362cb4f 100644 --- a/frontend/src/routes/settings.agent-connections.tsx +++ b/frontend/src/routes/settings.agent-connections.tsx @@ -1,13 +1,17 @@ import { createFileRoute, Navigate } from "@tanstack/react-router"; import { AgentConnectionsSettings } from "@/components/settings/AgentConnectionsSettings"; -import { isAgentConnectionsAvailable } from "@/services/agentConnectionsAvailability"; +import { useAgentConnectionsAvailability } from "@/components/settings/useAgentConnectionsAvailability"; export const Route = createFileRoute("/settings/agent-connections")({ component: AgentConnectionsRoute }); function AgentConnectionsRoute() { - if (!isAgentConnectionsAvailable()) { + const availability = useAgentConnectionsAvailability(); + + if (availability === "checking") return null; + + if (availability === "unavailable") { return ; } diff --git a/frontend/src/services/agentConnectionsAvailability.test.ts b/frontend/src/services/agentConnectionsAvailability.test.ts index 582c38d9..174e84bd 100644 --- a/frontend/src/services/agentConnectionsAvailability.test.ts +++ b/frontend/src/services/agentConnectionsAvailability.test.ts @@ -1,42 +1,26 @@ import { describe, expect, test } from "bun:test"; import { - isAgentConnectionsAvailable, - type AgentConnectionsAvailabilityChecks + isAgentConnectionsPlatformSupported, + type AgentConnectionsPlatformChecks } from "./agentConnectionsAvailability"; -import { FEATURE_FLAGS } from "./flags"; -function availabilityChecks({ - forcedOn = true, +function platformChecks({ tauriDesktop = true, macOS = false, linux = false }: { - forcedOn?: boolean; tauriDesktop?: boolean; macOS?: boolean; linux?: boolean; -} = {}): AgentConnectionsAvailabilityChecks { +} = {}): AgentConnectionsPlatformChecks { return { - isForcedOn: () => forcedOn, isTauriDesktop: () => tauriDesktop, isMacOS: () => macOS, isLinux: () => linux }; } -describe("isAgentConnectionsAvailable", () => { - test("requires the local agent_connections force flag", () => { - let requestedFlag = ""; - const checks = availabilityChecks({ macOS: true }); - checks.isForcedOn = (key) => { - requestedFlag = key; - return false; - }; - - expect(isAgentConnectionsAvailable(checks)).toBe(false); - expect(requestedFlag).toBe(FEATURE_FLAGS.AGENT_CONNECTIONS); - }); - +describe("isAgentConnectionsPlatformSupported", () => { test.each([ ["macOS Tauri Desktop", true, true, false, true], ["Linux Tauri Desktop", true, false, true, true], @@ -44,17 +28,17 @@ describe("isAgentConnectionsAvailable", () => { ["macOS outside Tauri Desktop", false, true, false, false], ["Linux outside Tauri Desktop", false, false, true, false] ])("returns the supported state for %s", (_name, tauriDesktop, macOS, linux, expected) => { - expect(isAgentConnectionsAvailable(availabilityChecks({ tauriDesktop, macOS, linux }))).toBe( - expected - ); + expect( + isAgentConnectionsPlatformSupported(platformChecks({ tauriDesktop, macOS, linux })) + ).toBe(expected); }); test("fails closed if an availability check throws", () => { - const checks = availabilityChecks({ macOS: true }); + const checks = platformChecks({ macOS: true }); checks.isTauriDesktop = () => { throw new Error("platform unavailable"); }; - expect(isAgentConnectionsAvailable(checks)).toBe(false); + expect(isAgentConnectionsPlatformSupported(checks)).toBe(false); }); }); diff --git a/frontend/src/services/agentConnectionsAvailability.ts b/frontend/src/services/agentConnectionsAvailability.ts index f684b6dc..3df5bb71 100644 --- a/frontend/src/services/agentConnectionsAvailability.ts +++ b/frontend/src/services/agentConnectionsAvailability.ts @@ -1,33 +1,26 @@ -import { FEATURE_FLAGS, isForcedOn } from "@/services/flags"; import { isLinux, isMacOS, isTauriDesktop } from "@/utils/platform"; -export interface AgentConnectionsAvailabilityChecks { - isForcedOn: (key: string) => boolean; +export interface AgentConnectionsPlatformChecks { isTauriDesktop: () => boolean; isMacOS: () => boolean; isLinux: () => boolean; } -const defaultChecks: AgentConnectionsAvailabilityChecks = { - isForcedOn, +const defaultChecks: AgentConnectionsPlatformChecks = { isTauriDesktop, isMacOS, isLinux }; /** - * Agent connections is a local desktop preview, not a remotely enabled feature. - * Keep this synchronous so navigation and direct-route admission share one gate. + * ACP hosting is currently supported only by the macOS and Linux desktop app. + * Use Maple's initialized platform helpers before consulting feature flags. */ -export function isAgentConnectionsAvailable( - checks: AgentConnectionsAvailabilityChecks = defaultChecks +export function isAgentConnectionsPlatformSupported( + checks: AgentConnectionsPlatformChecks = defaultChecks ): boolean { try { - return ( - checks.isForcedOn(FEATURE_FLAGS.AGENT_CONNECTIONS) && - checks.isTauriDesktop() && - (checks.isMacOS() || checks.isLinux()) - ); + return checks.isTauriDesktop() && (checks.isMacOS() || checks.isLinux()); } catch { return false; }