Skip to content

Commit eacd94f

Browse files
committed
🤖 fix: don't send default experiment value to backend
- useSendMessageOptions now passes undefined unless user explicitly overrides - add useExperimentOverrideValue() helper - harden localStorage parsing + add tests Change-Id: I33d9f1c8d3bba4f083132c4f645c76328327726a Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent fbdf9c3 commit eacd94f

File tree

5 files changed

+109
-36
lines changed

5 files changed

+109
-36
lines changed

src/browser/contexts/ExperimentsContext.tsx

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -37,38 +37,42 @@ function subscribeToExperiment(experimentId: ExperimentId, callback: () => void)
3737
}
3838

3939
/**
40-
* Get current experiment state from localStorage.
41-
* Returns the stored value or the default if not set.
40+
* Get explicit localStorage override for an experiment.
41+
* Returns undefined if no value is set or parsing fails.
4242
*/
43-
function getExperimentSnapshot(experimentId: ExperimentId): boolean {
44-
const experiment = EXPERIMENTS[experimentId];
43+
function getExperimentOverrideSnapshot(experimentId: ExperimentId): boolean | undefined {
4544
const key = getExperimentKey(experimentId);
4645

4746
try {
4847
const stored = window.localStorage.getItem(key);
48+
// Check for literal "undefined" string defensively - this can occur if
49+
// JSON.stringify(undefined) is accidentally stored (it returns "undefined")
4950
if (stored === null || stored === "undefined") {
50-
return experiment.enabledByDefault;
51+
return undefined;
5152
}
52-
return JSON.parse(stored) as boolean;
53+
54+
const parsed = JSON.parse(stored) as unknown;
55+
return typeof parsed === "boolean" ? parsed : undefined;
5356
} catch {
54-
return experiment.enabledByDefault;
57+
return undefined;
5558
}
5659
}
5760

61+
/**
62+
* Get current experiment state from localStorage.
63+
* Returns the stored value or the default if not set.
64+
*/
65+
function getExperimentSnapshot(experimentId: ExperimentId): boolean {
66+
const experiment = EXPERIMENTS[experimentId];
67+
return getExperimentOverrideSnapshot(experimentId) ?? experiment.enabledByDefault;
68+
}
69+
5870
/**
5971
* Check if user has explicitly set a local override for an experiment.
6072
* Returns true if there's a value in localStorage (not using default).
6173
*/
6274
function hasLocalOverride(experimentId: ExperimentId): boolean {
63-
const key = getExperimentKey(experimentId);
64-
try {
65-
const stored = window.localStorage.getItem(key);
66-
// Check for literal "undefined" string defensively - this can occur if
67-
// JSON.stringify(undefined) is accidentally stored (it returns "undefined")
68-
return stored !== null && stored !== "undefined";
69-
} catch {
70-
return false;
71-
}
75+
return getExperimentOverrideSnapshot(experimentId) !== undefined;
7276
}
7377

7478
/**
@@ -210,6 +214,27 @@ export function useExperimentValue(experimentId: ExperimentId): boolean {
210214
return localEnabled;
211215
}
212216

217+
/**
218+
* Hook to read only an explicit local override for an experiment.
219+
*
220+
* Returns `undefined` when the user has not explicitly set a value in localStorage.
221+
* This is important for user-overridable experiments: the backend can then apply
222+
* the PostHog assignment instead of treating the default value as a user choice.
223+
*/
224+
export function useExperimentOverrideValue(experimentId: ExperimentId): boolean | undefined {
225+
const subscribe = useCallback(
226+
(callback: () => void) => subscribeToExperiment(experimentId, callback),
227+
[experimentId]
228+
);
229+
230+
const getSnapshot = useCallback(
231+
() => getExperimentOverrideSnapshot(experimentId),
232+
[experimentId]
233+
);
234+
235+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
236+
}
237+
213238
/**
214239
* Hook to get setter function for experiments.
215240
* Use this in components that need to toggle experiments (e.g., Settings).
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* Tests for isExperimentEnabled()
3+
*
4+
* Key invariant:
5+
* - For user-overridable experiments, absence of a localStorage entry must be treated as
6+
* "no explicit override" (undefined), so the backend can apply PostHog assignment.
7+
*/
8+
9+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10+
import { GlobalWindow } from "happy-dom";
11+
import { EXPERIMENT_IDS, getExperimentKey } from "@/common/constants/experiments";
12+
import { isExperimentEnabled } from "./useExperiments";
13+
14+
describe("isExperimentEnabled", () => {
15+
beforeEach(() => {
16+
globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
17+
globalThis.document = globalThis.window.document;
18+
globalThis.window.localStorage.clear();
19+
});
20+
21+
afterEach(() => {
22+
globalThis.window = undefined as unknown as Window & typeof globalThis;
23+
globalThis.document = undefined as unknown as Document;
24+
});
25+
26+
test("returns undefined when no local override exists for a user-overridable experiment", () => {
27+
expect(isExperimentEnabled(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT)).toBeUndefined();
28+
});
29+
30+
test("returns boolean when local override exists", () => {
31+
const key = getExperimentKey(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT);
32+
33+
globalThis.window.localStorage.setItem(key, JSON.stringify(true));
34+
expect(isExperimentEnabled(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT)).toBe(true);
35+
36+
globalThis.window.localStorage.setItem(key, JSON.stringify(false));
37+
expect(isExperimentEnabled(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT)).toBe(false);
38+
});
39+
40+
test('treats literal "undefined" as no override', () => {
41+
const key = getExperimentKey(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT);
42+
43+
globalThis.window.localStorage.setItem(key, "undefined");
44+
expect(isExperimentEnabled(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT)).toBeUndefined();
45+
});
46+
47+
test("treats non-boolean stored value as no override", () => {
48+
const key = getExperimentKey(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT);
49+
50+
globalThis.window.localStorage.setItem(key, JSON.stringify("test"));
51+
expect(isExperimentEnabled(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT)).toBeUndefined();
52+
});
53+
});

src/browser/hooks/useExperiments.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { type ExperimentId, EXPERIMENTS, getExperimentKey } from "@/common/const
55
export {
66
useExperiment,
77
useExperimentValue,
8+
useExperimentOverrideValue,
89
useSetExperiment,
910
useAllExperiments,
1011
} from "@/browser/contexts/ExperimentsContext";
@@ -14,7 +15,8 @@ export {
1415
* Use when you need a one-time read (e.g., constructing send options at send time)
1516
* or outside of React components.
1617
*
17-
* For reactive updates in React components, use useExperimentValue instead.
18+
* For reactive updates in React components, use useExperimentValue (UI gating) or
19+
* useExperimentOverrideValue (backend send options).
1820
*
1921
* IMPORTANT: For user-overridable experiments, returns `undefined` when no explicit
2022
* localStorage override exists. This signals to the backend to use the PostHog
@@ -30,18 +32,11 @@ export function isExperimentEnabled(experimentId: ExperimentId): boolean | undef
3032
// For user-overridable experiments: only return a value if user explicitly set one.
3133
// This allows the backend to use PostHog assignment when there's no override.
3234
if (experiment.userOverridable) {
33-
const raw = window.localStorage.getItem(key);
34-
// Check for null (never set) or literal "undefined" (defensive - see hasLocalOverride)
35-
if (raw === null || raw === "undefined") {
36-
return undefined; // Let backend use PostHog
37-
}
38-
try {
39-
return JSON.parse(raw) as boolean;
40-
} catch {
41-
return undefined;
42-
}
35+
const stored = readPersistedState<unknown>(key, undefined);
36+
return typeof stored === "boolean" ? stored : undefined;
4337
}
4438

4539
// Non-overridable: always use default (these are local-only experiments)
46-
return readPersistedState<boolean>(key, experiment.enabledByDefault);
40+
const stored = readPersistedState<unknown>(key, experiment.enabledByDefault);
41+
return typeof stored === "boolean" ? stored : experiment.enabledByDefault;
4742
}

src/browser/hooks/useSendMessageOptions.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions"
1313
import { enforceThinkingPolicy } from "@/browser/utils/thinking/policy";
1414
import { useProviderOptions } from "./useProviderOptions";
1515
import type { GatewayState } from "./useGatewayModels";
16-
import { useExperimentValue } from "./useExperiments";
16+
import { useExperimentOverrideValue } from "./useExperiments";
1717
import { EXPERIMENT_IDS } from "@/common/constants/experiments";
1818

1919
/**
@@ -47,7 +47,7 @@ function constructSendMessageOptions(
4747
providerOptions: MuxProviderOptions,
4848
fallbackModel: string,
4949
gateway: GatewayState,
50-
postCompactionContext: boolean
50+
postCompactionContext: boolean | undefined
5151
): SendMessageOptions {
5252
// Ensure model is always a valid string (defensive against corrupted localStorage)
5353
const rawModel =
@@ -110,8 +110,9 @@ export function useSendMessageOptions(workspaceId: string): SendMessageOptionsWi
110110
// Subscribe to gateway state so we re-render when user toggles gateway
111111
const gateway = useGateway();
112112

113-
// Subscribe to experiment state so toggles apply immediately
114-
const postCompactionContext = useExperimentValue(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT);
113+
// Subscribe to local override state so toggles apply immediately.
114+
// If undefined, the backend will apply the PostHog assignment.
115+
const postCompactionContext = useExperimentOverrideValue(EXPERIMENT_IDS.POST_COMPACTION_CONTEXT);
115116

116117
// Compute base model (canonical format) for UI components
117118
const rawModel =

src/node/services/workspaceService.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -687,9 +687,8 @@ export class WorkspaceService extends EventEmitter {
687687
try {
688688
const metadata = await this.config.getAllWorkspaceMetadata();
689689

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.
690+
// For list(), treat includePostCompaction as an explicit frontend override when provided.
691+
// If it's undefined (e.g., user hasn't overridden), fall back to PostHog assignment.
693692
const postCompactionExperiment = EXPERIMENTS[EXPERIMENT_IDS.POST_COMPACTION_CONTEXT];
694693
let includePostCompaction: boolean;
695694
if (
@@ -1024,7 +1023,7 @@ export class WorkspaceService extends EventEmitter {
10241023
}
10251024

10261025
// Experiments: resolve flags respecting userOverridable setting.
1027-
// - If userOverridable && frontend provides a value → use frontend value (user's choice)
1026+
// - If userOverridable && frontend provides a value (explicit override) → use frontend value
10281027
// - Else if remote evaluation enabled → use PostHog assignment
10291028
// - Else → use frontend value (dev fallback) or default
10301029
const postCompactionExperiment = EXPERIMENTS[EXPERIMENT_IDS.POST_COMPACTION_CONTEXT];

0 commit comments

Comments
 (0)