Skip to content

Commit fd97c9a

Browse files
committed
🤖 feat: Add userOverridable and showInSettings for experiments
Allow per-experiment control over whether users can override remote PostHog assignments and whether experiments appear in Settings. ExperimentDefinition changes: - userOverridable?: boolean - when true, local toggle takes precedence - showInSettings?: boolean - when false, hide from Settings UI Resolution priority (when userOverridable=true): 1. Local localStorage toggle (user's explicit choice) 2. Remote PostHog assignment 3. Default (enabledByDefault) Implementation: - experiments.ts: Add new optional fields, set POST_COMPACTION_CONTEXT as userOverridable=true - ExperimentsContext: hasLocalOverride() helper, updated useExperimentValue() and useAllExperiments() to respect userOverridable - ExperimentsSection: Filter by showInSettings, enable toggle when canOverride - WorkspaceService: Respect userOverridable in both list() and sendMessage() Change-Id: I3afc8514c74151b8b72991aa13ab98296cfd19bb Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent 8ec1a14 commit fd97c9a

File tree

5 files changed

+181
-59
lines changed

5 files changed

+181
-59
lines changed

src/browser/components/Settings/sections/ExperimentsSection.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import React, { useCallback } from "react";
1+
import React, { useCallback, useMemo } from "react";
22
import { useExperiment, useRemoteExperimentValue } from "@/browser/contexts/ExperimentsContext";
33
import {
4+
EXPERIMENTS,
45
getExperimentList,
56
EXPERIMENT_IDS,
67
type ExperimentId,
@@ -16,21 +17,24 @@ interface ExperimentRowProps {
1617
}
1718

1819
function ExperimentRow(props: ExperimentRowProps) {
20+
const experiment = EXPERIMENTS[props.experimentId];
1921
const [enabled, setEnabled] = useExperiment(props.experimentId);
2022
const remote = useRemoteExperimentValue(props.experimentId);
2123
const isRemoteControlled = remote ? remote.source !== "disabled" : false;
24+
const canOverride = experiment.userOverridable === true;
2225
const { onToggle } = props;
2326

2427
const handleToggle = useCallback(
2528
(value: boolean) => {
26-
if (isRemoteControlled) {
29+
// Allow toggle if not remote-controlled OR if user can override
30+
if (isRemoteControlled && !canOverride) {
2731
return;
2832
}
2933

3034
setEnabled(value);
3135
onToggle?.(value);
3236
},
33-
[isRemoteControlled, setEnabled, onToggle]
37+
[isRemoteControlled, canOverride, setEnabled, onToggle]
3438
);
3539

3640
return (
@@ -40,13 +44,14 @@ function ExperimentRow(props: ExperimentRowProps) {
4044
<div className="text-muted mt-0.5 text-xs">{props.description}</div>
4145
{isRemoteControlled ? (
4246
<div className="text-muted mt-0.5 text-xs">
43-
PostHog: {remote?.value ?? "loading"} ({remote?.source})
47+
PostHog: {String(remote?.value ?? "loading")} ({remote?.source})
48+
{canOverride ? " • overridable" : null}
4449
</div>
4550
) : null}
4651
</div>
4752
<Switch
4853
checked={enabled}
49-
disabled={isRemoteControlled}
54+
disabled={isRemoteControlled && !canOverride}
5055
onCheckedChange={handleToggle}
5156
aria-label={`Toggle ${props.name}`}
5257
/>
@@ -55,9 +60,15 @@ function ExperimentRow(props: ExperimentRowProps) {
5560
}
5661

5762
export function ExperimentsSection() {
58-
const experiments = getExperimentList();
63+
const allExperiments = getExperimentList();
5964
const { refreshWorkspaceMetadata } = useWorkspaceContext();
6065

66+
// Filter to only show experiments where showInSettings !== false
67+
const experiments = useMemo(
68+
() => allExperiments.filter((exp) => exp.showInSettings !== false),
69+
[allExperiments]
70+
);
71+
6172
// When post-compaction experiment is toggled, refresh metadata to fetch/clear bundled state
6273
const handlePostCompactionToggle = useCallback(() => {
6374
void refreshWorkspaceMetadata();

src/browser/contexts/ExperimentsContext.tsx

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ function subscribeToExperiment(experimentId: ExperimentId, callback: () => void)
3838

3939
/**
4040
* Get current experiment state from localStorage.
41+
* Returns the stored value or the default if not set.
4142
*/
4243
function getExperimentSnapshot(experimentId: ExperimentId): boolean {
4344
const experiment = EXPERIMENTS[experimentId];
@@ -54,6 +55,20 @@ function getExperimentSnapshot(experimentId: ExperimentId): boolean {
5455
}
5556
}
5657

58+
/**
59+
* Check if user has explicitly set a local override for an experiment.
60+
* Returns true if there's a value in localStorage (not using default).
61+
*/
62+
function hasLocalOverride(experimentId: ExperimentId): boolean {
63+
const key = getExperimentKey(experimentId);
64+
try {
65+
const stored = window.localStorage.getItem(key);
66+
return stored !== null && stored !== "undefined";
67+
} catch {
68+
return false;
69+
}
70+
}
71+
5772
/**
5873
* Set experiment state to localStorage and dispatch sync event.
5974
*/
@@ -155,10 +170,16 @@ export function ExperimentsProvider(props: { children: React.ReactNode }) {
155170
* Uses useSyncExternalStore for efficient, selective re-renders.
156171
* Only re-renders when THIS specific experiment changes.
157172
*
173+
* Resolution priority:
174+
* - If userOverridable && user has explicitly set a local value → use local
175+
* - If remote PostHog assignment exists → use remote
176+
* - Otherwise → use local (which may be default)
177+
*
158178
* @param experimentId - The experiment to subscribe to
159179
* @returns Whether the experiment is enabled
160180
*/
161181
export function useExperimentValue(experimentId: ExperimentId): boolean {
182+
const experiment = EXPERIMENTS[experimentId];
162183
const subscribe = useCallback(
163184
(callback: () => void) => subscribeToExperiment(experimentId, callback),
164185
[experimentId]
@@ -171,10 +192,17 @@ export function useExperimentValue(experimentId: ExperimentId): boolean {
171192
const context = useContext(ExperimentsContext);
172193
const remote = context?.remoteExperiments?.[experimentId];
173194

195+
// User-overridable: local wins if explicitly set
196+
if (experiment.userOverridable && hasLocalOverride(experimentId)) {
197+
return localEnabled;
198+
}
199+
200+
// Remote assignment (if available and not disabled)
174201
if (remote && remote.source !== "disabled" && remote.value !== null) {
175202
return getRemoteExperimentEnabled(remote.value);
176203
}
177204

205+
// Fallback to local (which may be default)
178206
return localEnabled;
179207
}
180208

@@ -237,20 +265,25 @@ export function useAllExperiments(): Record<ExperimentId, boolean> {
237265

238266
const getSnapshot = useCallback(() => {
239267
const result: Partial<Record<ExperimentId, boolean>> = {};
268+
240269
for (const exp of experiments) {
241-
result[exp.id] = getExperimentSnapshot(exp.id);
242-
}
270+
const localValue = getExperimentSnapshot(exp.id);
271+
const remote = remoteExperiments?.[exp.id];
243272

244-
if (remoteExperiments) {
245-
for (const [experimentId, remote] of Object.entries(remoteExperiments) as Array<
246-
[ExperimentId, ExperimentValue]
247-
>) {
248-
if (!remote || remote.source === "disabled" || remote.value === null) {
249-
continue;
250-
}
273+
// User-overridable: local wins if explicitly set
274+
if (exp.userOverridable && hasLocalOverride(exp.id)) {
275+
result[exp.id] = localValue;
276+
continue;
277+
}
251278

252-
result[experimentId] = getRemoteExperimentEnabled(remote.value);
279+
// Remote assignment (if available and not disabled)
280+
if (remote && remote.source !== "disabled" && remote.value !== null) {
281+
result[exp.id] = getRemoteExperimentEnabled(remote.value);
282+
continue;
253283
}
284+
285+
// Fallback to local (which may be default)
286+
result[exp.id] = localValue;
254287
}
255288

256289
return result as Record<ExperimentId, boolean>;

src/common/constants/experiments.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ export interface ExperimentDefinition {
1717
description: string;
1818
/** Default state - false means disabled by default */
1919
enabledByDefault: boolean;
20+
/**
21+
* When true, user can override remote PostHog assignment via Settings toggle.
22+
* When false (default), remote assignment is authoritative.
23+
*/
24+
userOverridable?: boolean;
25+
/**
26+
* When false, experiment is hidden from Settings → Experiments.
27+
* Defaults to true. Use false for invisible A/B tests.
28+
*/
29+
showInSettings?: boolean;
2030
}
2131

2232
/**
@@ -29,6 +39,8 @@ export const EXPERIMENTS: Record<ExperimentId, ExperimentDefinition> = {
2939
name: "Post-Compaction Context",
3040
description: "Re-inject plan file and edited file diffs after compaction to preserve context",
3141
enabledByDefault: false,
42+
userOverridable: true, // User can opt-out via Settings
43+
showInSettings: true,
3244
},
3345
};
3446

src/node/services/telemetryService.featureFlags.test.ts

Lines changed: 68 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -17,41 +17,77 @@ describe("TelemetryService feature flag properties", () => {
1717
});
1818

1919
test("capture includes $feature/<flagKey> properties when set", () => {
20-
const telemetry = new TelemetryService(tempDir);
21-
22-
const capture = mock((_args: unknown) => undefined);
23-
24-
// NOTE: TelemetryService only checks that client + distinctId are set.
25-
// We set them directly to avoid any real network calls.
26-
// @ts-expect-error - Accessing private property for test
27-
telemetry.client = { capture };
28-
// @ts-expect-error - Accessing private property for test
29-
telemetry.distinctId = "distinct-id";
30-
31-
telemetry.setFeatureFlagVariant("post-compaction-context", "test");
32-
33-
const payload: TelemetryEventPayload = {
34-
event: "message_sent",
35-
properties: {
36-
workspaceId: "workspace-id",
37-
model: "test-model",
38-
mode: "exec",
39-
message_length_b2: 128,
40-
runtimeType: "local",
41-
frontendPlatform: {
42-
userAgent: "ua",
43-
platform: "platform",
44-
},
45-
thinkingLevel: "off",
46-
},
20+
// The capture method checks isTelemetryDisabledByEnv which checks NODE_ENV=test,
21+
// JEST_WORKER_ID, VITEST, CI, etc. We need to temporarily clear these for the test.
22+
const savedEnv = {
23+
NODE_ENV: process.env.NODE_ENV,
24+
JEST_WORKER_ID: process.env.JEST_WORKER_ID,
25+
VITEST: process.env.VITEST,
26+
CI: process.env.CI,
27+
GITHUB_ACTIONS: process.env.GITHUB_ACTIONS,
28+
MUX_DISABLE_TELEMETRY: process.env.MUX_DISABLE_TELEMETRY,
29+
MUX_E2E: process.env.MUX_E2E,
30+
TEST_INTEGRATION: process.env.TEST_INTEGRATION,
4731
};
4832

49-
telemetry.capture(payload);
33+
// Clear all telemetry-disabling env vars
34+
process.env.NODE_ENV = "production";
35+
delete process.env.JEST_WORKER_ID;
36+
delete process.env.VITEST;
37+
delete process.env.CI;
38+
delete process.env.GITHUB_ACTIONS;
39+
delete process.env.MUX_DISABLE_TELEMETRY;
40+
delete process.env.MUX_E2E;
41+
delete process.env.TEST_INTEGRATION;
42+
43+
try {
44+
const telemetry = new TelemetryService(tempDir);
45+
46+
const capture = mock((_args: unknown) => undefined);
47+
48+
// NOTE: TelemetryService only checks that client + distinctId are set.
49+
// We set them directly to avoid any real network calls.
50+
// @ts-expect-error - Accessing private property for test
51+
telemetry.client = { capture };
52+
// @ts-expect-error - Accessing private property for test
53+
telemetry.distinctId = "distinct-id";
54+
55+
telemetry.setFeatureFlagVariant("post-compaction-context", "test");
56+
57+
const payload: TelemetryEventPayload = {
58+
event: "message_sent",
59+
properties: {
60+
workspaceId: "workspace-id",
61+
model: "test-model",
62+
mode: "exec",
63+
message_length_b2: 128,
64+
runtimeType: "local",
65+
frontendPlatform: {
66+
userAgent: "ua",
67+
platform: "platform",
68+
},
69+
thinkingLevel: "off",
70+
},
71+
};
72+
73+
telemetry.capture(payload);
5074

51-
expect(capture).toHaveBeenCalled();
75+
expect(capture).toHaveBeenCalled();
5276

53-
const call = capture.mock.calls[0]?.[0] as { properties?: Record<string, unknown> } | undefined;
54-
expect(call?.properties).toBeDefined();
55-
expect(call?.properties?.["$feature/post-compaction-context"]).toBe("test");
77+
const call = capture.mock.calls[0]?.[0] as
78+
| { properties?: Record<string, unknown> }
79+
| undefined;
80+
expect(call?.properties).toBeDefined();
81+
expect(call?.properties?.["$feature/post-compaction-context"]).toBe("test");
82+
} finally {
83+
// Restore all env vars
84+
for (const [key, value] of Object.entries(savedEnv)) {
85+
if (value === undefined) {
86+
delete process.env[key];
87+
} else {
88+
process.env[key] = value;
89+
}
90+
}
91+
}
5692
});
5793
});

src/node/services/workspaceService.ts

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type { AIService } from "@/node/services/aiService";
1414
import type { InitStateManager } from "@/node/services/initStateManager";
1515
import type { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService";
1616
import type { ExperimentsService } from "@/node/services/experimentsService";
17-
import { EXPERIMENT_IDS } from "@/common/constants/experiments";
17+
import { EXPERIMENT_IDS, EXPERIMENTS } from "@/common/constants/experiments";
1818
import type { MCPServerManager } from "@/node/services/mcpServerManager";
1919
import { createRuntime, IncompatibleRuntimeError } from "@/node/runtime/runtimeFactory";
2020
import { validateWorkspaceName } from "@/common/utils/validation/workspaceValidation";
@@ -687,10 +687,26 @@ export class WorkspaceService extends EventEmitter {
687687
try {
688688
const metadata = await this.config.getAllWorkspaceMetadata();
689689

690-
const includePostCompaction =
691-
this.experimentsService?.isRemoteEvaluationEnabled() === true
692-
? this.experimentsService.isExperimentEnabled(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT)
693-
: options?.includePostCompaction === true;
690+
// For list(), we use includePostCompaction from options since it's already resolved
691+
// by the frontend based on experiment state. The frontend's useExperimentValue()
692+
// handles userOverridable logic, so we trust the passed value.
693+
const postCompactionExperiment = EXPERIMENTS[EXPERIMENT_IDS.POST_COMPACTION_CONTEXT];
694+
let includePostCompaction: boolean;
695+
if (
696+
postCompactionExperiment.userOverridable &&
697+
options?.includePostCompaction !== undefined
698+
) {
699+
// User-overridable: trust frontend value
700+
includePostCompaction = options.includePostCompaction;
701+
} else if (this.experimentsService?.isRemoteEvaluationEnabled() === true) {
702+
// Remote evaluation: use PostHog assignment
703+
includePostCompaction = this.experimentsService.isExperimentEnabled(
704+
EXPERIMENT_IDS.POST_COMPACTION_CONTEXT
705+
);
706+
} else {
707+
// Fallback to frontend value or false
708+
includePostCompaction = options?.includePostCompaction === true;
709+
}
694710

695711
if (!includePostCompaction) {
696712
return metadata;
@@ -1007,12 +1023,26 @@ export class WorkspaceService extends EventEmitter {
10071023
void this.updateRecencyTimestamp(workspaceId, messageTimestamp);
10081024
}
10091025

1010-
// Experiments: resolve backend-authoritative flags when telemetry is enabled.
1011-
// When telemetry is disabled, fall back to the renderer-provided experiment toggles.
1012-
const postCompactionContextEnabled =
1013-
this.experimentsService?.isRemoteEvaluationEnabled() === true
1014-
? this.experimentsService.isExperimentEnabled(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT)
1015-
: options?.experiments?.postCompactionContext;
1026+
// Experiments: resolve flags respecting userOverridable setting.
1027+
// - If userOverridable && frontend provides a value → use frontend value (user's choice)
1028+
// - Else if remote evaluation enabled → use PostHog assignment
1029+
// - Else → use frontend value (dev fallback) or default
1030+
const postCompactionExperiment = EXPERIMENTS[EXPERIMENT_IDS.POST_COMPACTION_CONTEXT];
1031+
const frontendValue = options?.experiments?.postCompactionContext;
1032+
1033+
let postCompactionContextEnabled: boolean | undefined;
1034+
if (postCompactionExperiment.userOverridable && frontendValue !== undefined) {
1035+
// User-overridable: trust frontend value (user's explicit choice)
1036+
postCompactionContextEnabled = frontendValue;
1037+
} else if (this.experimentsService?.isRemoteEvaluationEnabled() === true) {
1038+
// Remote evaluation: use PostHog assignment
1039+
postCompactionContextEnabled = this.experimentsService.isExperimentEnabled(
1040+
EXPERIMENT_IDS.POST_COMPACTION_CONTEXT
1041+
);
1042+
} else {
1043+
// Fallback to frontend value (dev mode or telemetry disabled)
1044+
postCompactionContextEnabled = frontendValue;
1045+
}
10161046

10171047
const resolvedOptions =
10181048
postCompactionContextEnabled === undefined

0 commit comments

Comments
 (0)