Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/agent-mode-acp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
14 changes: 11 additions & 3 deletions frontend/src/components/settings/SettingsLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,17 @@ 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";
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;
Expand Down Expand Up @@ -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}`),
Expand Down Expand Up @@ -526,9 +530,13 @@ function SettingsLayoutContent() {
}

export function SettingsLayout() {
const os = useOpenSecret();

return (
<SettingsNavigationLockProvider>
<SettingsLayoutContent />
<AgentConnectionsAvailabilityProvider userId={os.auth.user?.user.id ?? null}>
<SettingsLayoutContent />
</AgentConnectionsAvailabilityProvider>
</SettingsNavigationLockProvider>
);
}
Original file line number Diff line number Diff line change
@@ -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<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}

function AvailabilityProbe({
dependencies,
userId
}: {
dependencies: AgentConnectionsAvailabilityDependencies;
userId: string | null;
}) {
return (
<AgentConnectionsAvailabilityProvider dependencies={dependencies} userId={userId}>
<AvailabilityValue />
</AgentConnectionsAvailabilityProvider>
);
}

function AvailabilityValue() {
const availability = useAgentConnectionsAvailability();
return <span>{availability}</span>;
}

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<boolean>;
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(
<AvailabilityProbe dependencies={unsupported.dependencies} userId={USER_A} />
);
});

expect(renderedAvailability(renderer!)).toBe("unavailable");
expect(unsupported.peekIsEnabled).not.toHaveBeenCalled();
expect(unsupported.isEnabled).not.toHaveBeenCalled();

const supported = dependencies();
act(() => {
renderer?.update(<AvailabilityProbe dependencies={supported.dependencies} userId={null} />);
});

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<boolean>();
const remote = dependencies({ enabled: () => lookup.promise });

act(() => {
renderer = create(<AvailabilityProbe dependencies={remote.dependencies} userId={USER_A} />);
});

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<boolean>();
const remote = dependencies({ enabled: () => lookup.promise });

act(() => {
renderer = create(<AvailabilityProbe dependencies={remote.dependencies} userId={USER_A} />);
});

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(<AvailabilityProbe dependencies={cached.dependencies} userId={USER_A} />);
});

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(
<AvailabilityProbe
dependencies={{
flagClient: localClient,
isPlatformSupported: () => 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<boolean>();
const remote = dependencies({ enabled: () => lookup.promise });
const warning = spyOn(console, "warn").mockImplementation(() => {});

act(() => {
renderer = create(<AvailabilityProbe dependencies={remote.dependencies} userId={USER_A} />);
});

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<boolean>();
const userB = deferred<boolean>();
const remote = dependencies({
enabled: (userId) => (userId === USER_A ? userA.promise : userB.promise)
});

act(() => {
renderer = create(<AvailabilityProbe dependencies={remote.dependencies} userId={USER_A} />);
});
act(() => {
renderer?.update(<AvailabilityProbe dependencies={remote.dependencies} userId={USER_B} />);
});

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<boolean>();
const secondUserA = deferred<boolean>();
const userB = deferred<boolean>();
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(<AvailabilityProbe dependencies={remote.dependencies} userId={USER_A} />);
});
await act(async () => {
firstUserA.resolve(true);
await firstUserA.promise;
});
expect(renderedAvailability(renderer!)).toBe("available");

act(() => {
renderer?.update(<AvailabilityProbe dependencies={remote.dependencies} userId={USER_B} />);
});
act(() => {
renderer?.update(<AvailabilityProbe dependencies={remote.dependencies} userId={USER_A} />);
});

expect(renderedAvailability(renderer!)).toBe("checking");

await act(async () => {
secondUserA.resolve(false);
await secondUserA.promise;
});
expect(renderedAvailability(renderer!)).toBe("unavailable");
expect(userALookups).toBe(2);
});
});
Loading
Loading