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
9 changes: 8 additions & 1 deletion packages/types/src/experiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import type { Keys, Equals, AssertEqual } from "./type-fu.js"
* ExperimentId
*/

export const experimentIds = ["preventFocusDisruption", "imageGeneration", "runSlashCommand", "customTools"] as const
export const experimentIds = [
"preventFocusDisruption",
"imageGeneration",
"runSlashCommand",
"customTools",
"parallelToolExecution",
] as const

export const experimentIdsSchema = z.enum(experimentIds)

Expand All @@ -21,6 +27,7 @@ export const experimentsSchema = z.object({
imageGeneration: z.boolean().optional(),
runSlashCommand: z.boolean().optional(),
customTools: z.boolean().optional(),
parallelToolExecution: z.boolean().optional(),
})

export type Experiments = z.infer<typeof experimentsSchema>
Expand Down
21 changes: 21 additions & 0 deletions src/shared/__tests__/experiments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ describe("experiments", () => {
imageGeneration: false,
runSlashCommand: false,
customTools: false,
parallelToolExecution: false,
}
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(false)
})
Expand All @@ -31,6 +32,7 @@ describe("experiments", () => {
imageGeneration: false,
runSlashCommand: false,
customTools: false,
parallelToolExecution: false,
}
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(true)
})
Expand All @@ -41,8 +43,27 @@ describe("experiments", () => {
imageGeneration: false,
runSlashCommand: false,
customTools: false,
parallelToolExecution: false,
}
expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(false)
})
})

describe("PARALLEL_TOOL_EXECUTION", () => {
it("is configured correctly", () => {
expect(EXPERIMENT_IDS.PARALLEL_TOOL_EXECUTION).toBe("parallelToolExecution")
expect(experimentConfigsMap.PARALLEL_TOOL_EXECUTION).toMatchObject({
enabled: false,
showInSettings: false,
})
})

it("returns false by default", () => {
expect(Experiments.isEnabled({}, "parallelToolExecution")).toBe(false)
})

it("returns true when enabled", () => {
expect(Experiments.isEnabled({ parallelToolExecution: true }, "parallelToolExecution")).toBe(true)
})
})
})
5 changes: 5 additions & 0 deletions src/shared/experiments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const EXPERIMENT_IDS = {
IMAGE_GENERATION: "imageGeneration",
RUN_SLASH_COMMAND: "runSlashCommand",
CUSTOM_TOOLS: "customTools",
PARALLEL_TOOL_EXECUTION: "parallelToolExecution",
} as const satisfies Record<string, ExperimentId>

type _AssertExperimentIds = AssertEqual<Equals<ExperimentId, Values<typeof EXPERIMENT_IDS>>>
Expand All @@ -13,13 +14,17 @@ type ExperimentKey = Keys<typeof EXPERIMENT_IDS>

interface ExperimentConfig {
enabled: boolean

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Document the showInSettings contract

The showInSettings !== false filter in ExperimentalSettings.tsx relies on undefined !== false being true, so all pre-existing configs (which omit the field) remain visible. This is correct and backward-compatible, but the implicit "undefined = visible" contract isn't documented here.

Impact: Maintainability — a future contributor could misread the optional field and assume omission hides the flag.

Suggestion: Add a one-line JSDoc, e.g. /** Defaults to true; set to false to hide from the Settings panel. */, so the convention is self-documenting.

/** Defaults to true; set to false to hide from the Settings panel. */
showInSettings?: boolean
}

export const experimentConfigsMap: Record<ExperimentKey, ExperimentConfig> = {
PREVENT_FOCUS_DISRUPTION: { enabled: false },
IMAGE_GENERATION: { enabled: false },
RUN_SLASH_COMMAND: { enabled: false },
CUSTOM_TOOLS: { enabled: false },
// TODO: add i18n keys (settings:experimental.PARALLEL_TOOL_EXECUTION.name/.description) in the same PR that sets showInSettings: true
PARALLEL_TOOL_EXECUTION: { enabled: false, showInSettings: false },
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Translation key TODO for when visibility is enabled

No translation key (settings:experimental.PARALLEL_TOOL_EXECUTION.name / .description) was added to en/settings.json. This is harmless now because the flag is filtered out before render, so the key is never looked up.

Impact: None today. When "Epic 4" flips showInSettings to true (or removes the filter), the UI will render the raw key string instead of a human-readable label until translations are added. The repo's scripts/find-missing-i18n-key.js scans for referenced keys, and since the key is never referenced while hidden, it won't catch this gap at that transition.

Suggestion: Leave a TODO anchored to this flag (or note in issue #363) that translation entries must be added in the same PR that enables showInSettings: true.


export const experimentDefault = Object.fromEntries(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const ExperimentalSettings = ({
<Section>
{Object.entries(experimentConfigsMap)
.filter(([key]) => key in EXPERIMENT_IDS)
.filter(([, config]) => config.showInSettings !== false)
.map((config) => {
// Use the same translation key pattern as ExperimentalFeature
const experimentKey = config[0]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { render, screen } from "@testing-library/react"

import { experimentDefault } from "@roo/experiments"

import { ExperimentalSettings } from "../ExperimentalSettings"

vi.mock("@src/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => key,
}),
}))

describe("ExperimentalSettings", () => {
const defaultProps = {
experiments: experimentDefault,
setExperimentEnabled: vi.fn(),
setImageGenerationProvider: vi.fn(),
setOpenRouterImageApiKey: vi.fn(),
setImageGenerationSelectedModel: vi.fn(),
}

beforeEach(() => {
vi.clearAllMocks()
})

it("does not render internal-only experiment flags", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Optional: assert special-cased entries still render

The test asserts PREVENT_FOCUS_DISRUPTION and RUN_SLASH_COMMAND render and PARALLEL_TOOL_EXECUTION does not, but doesn't assert that the special-cased IMAGE_GENERATION and CUSTOM_TOOLS entries still render. Those branches are exercised by the same .filter pipeline, so a regression that accidentally hid them wouldn't be caught here.

Impact: Low — test coverage gap. The test's stated intent ("does not render internal-only experiment flags") implicitly promises that non-internal flags do render, but only the generic-path flags are checked.

Suggestion: Optionally add assertions that the IMAGE_GENERATION and CUSTOM_TOOLS labels (or their rendered wrappers) are present, so the test guards the full filter behavior rather than a subset.

render(<ExperimentalSettings {...defaultProps} />)

expect(screen.getByText("settings:experimental.PREVENT_FOCUS_DISRUPTION.name")).toBeInTheDocument()
expect(screen.getByText("settings:experimental.RUN_SLASH_COMMAND.name")).toBeInTheDocument()
expect(screen.getByText("settings:experimental.IMAGE_GENERATION.name")).toBeInTheDocument()
expect(screen.getByText("settings:experimental.CUSTOM_TOOLS.name")).toBeInTheDocument()
expect(screen.queryByText("settings:experimental.PARALLEL_TOOL_EXECUTION.name")).not.toBeInTheDocument()
})
})
Loading