feat(FN-1658): merge fusion/fn-1658

This commit is contained in:
gsxdsm
2026-04-14 09:29:02 -07:00
parent 687d594b3c
commit e6becfa41b
6 changed files with 422 additions and 0 deletions

View File

@@ -98,6 +98,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
| `requirePlanApproval` | `boolean` | `false` | Require manual approval before triage → todo. | | `requirePlanApproval` | `boolean` | `false` | Require manual approval before triage → todo. |
| `reviewHandoffPolicy` | `"disabled" \| "comment-triggered" \| "always"` | `"disabled"` | Policy for agent-to-user review handoff. | | `reviewHandoffPolicy` | `"disabled" \| "comment-triggered" \| "always"` | `"disabled"` | Policy for agent-to-user review handoff. |
| `showQuickChatFAB` | `boolean` | `false` | Show floating quick-chat button. Chat accessible from More menu when hidden. | | `showQuickChatFAB` | `boolean` | `false` | Show floating quick-chat button. Chat accessible from More menu when hidden. |
| `experimentalFeatures` | `Record<string, boolean>` | `{}` | Project-scoped experimental feature toggles. Each key is a feature flag name, and the value indicates whether it is enabled. Features not present in this map are considered disabled. This allows teams to explicitly mark capabilities as experimental and toggle them on/off from the Settings dashboard. |
| `taskStuckTimeoutMs` | `number` | `undefined` | Inactivity timeout for stuck-task recovery. | | `taskStuckTimeoutMs` | `number` | `undefined` | Inactivity timeout for stuck-task recovery. |
| `specStalenessEnabled` | `boolean` | `false` | Enable automatic re-triaging of tasks with stale specifications. | | `specStalenessEnabled` | `boolean` | `false` | Enable automatic re-triaging of tasks with stale specifications. |
| `specStalenessMaxAgeMs` | `number` | `21600000` | Maximum age in ms before a specification (PROMPT.md) is considered stale and requires re-specification. Default: 6 hours. | | `specStalenessMaxAgeMs` | `number` | `21600000` | Maximum age in ms before a specification (PROMPT.md) is considered stale and requires re-specification. Default: 6 hours. |
@@ -303,6 +304,42 @@ See also: [Workflow Steps](./workflow-steps.md) for how `scripts` and workflow m
--- ---
## Experimental Features
The `experimentalFeatures` setting provides a first-class mechanism for managing project-scoped experimental feature toggles. This allows teams to explicitly mark capabilities as experimental and toggle them on/off from a dedicated section in the Settings dashboard.
### How It Works
1. **Feature Registry**: Features are stored as key-value pairs where keys are feature names and values indicate enabled/disabled state.
2. **Default Behavior**: Features not present in the map are considered disabled (fallback to `false`).
3. **UI Integration**: The Experimental Features section in Settings provides toggle controls for each configured feature.
4. **Consumption**: Engine code can read `experimentalFeatures[key]` to check if a feature is enabled.
### Example JSON Shape
```json
{
"settings": {
"experimentalFeatures": {
"my-new-feature": true,
"another-experiment": false
}
}
}
```
### Dashboard UI
The Experimental Features section in Settings shows:
- Feature name and enabled/disabled toggle for each configured feature
- Project scope indicator (features are project-specific, not global)
- Description explaining the purpose of experimental features
---
## Background Memory Summarization & Audit ## Background Memory Summarization & Audit
Fusion can automatically extract insights from project memory and prune transient content on a schedule. This feature is disabled by default and can be enabled via settings. Fusion can automatically extract insights from project memory and prune transient content on a schedule. This feature is disabled by default and can be enabled via settings.

View File

@@ -109,6 +109,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
reflectionAfterTask: true, reflectionAfterTask: true,
reviewHandoffPolicy: "disabled", reviewHandoffPolicy: "disabled",
showQuickChatFAB: false, showQuickChatFAB: false,
experimentalFeatures: {},
} satisfies CompleteSettings<ProjectSettings>; } satisfies CompleteSettings<ProjectSettings>;
/** /**

View File

@@ -1160,6 +1160,136 @@ describe("TaskStore", () => {
}); });
}); });
// ── Experimental Features Tests ─────────────────────────────────
describe("experimentalFeatures settings", () => {
it("defaults to empty object {}", async () => {
const settings = await store.getSettings();
expect(settings.experimentalFeatures).toEqual({});
});
it("can set experimental features via updateSettings", async () => {
await store.updateSettings({
experimentalFeatures: { "my-feature": true, "another-feature": false },
});
const settings = await store.getSettings();
expect(settings.experimentalFeatures).toEqual({ "my-feature": true, "another-feature": false });
});
it("can enable a single experimental feature", async () => {
await store.updateSettings({
experimentalFeatures: { "my-feature": true },
});
const settings = await store.getSettings();
expect(settings.experimentalFeatures).toEqual({ "my-feature": true });
});
it("can update an existing experimental feature", async () => {
await store.updateSettings({
experimentalFeatures: { "my-feature": true },
});
await store.updateSettings({
experimentalFeatures: { "my-feature": false },
});
const settings = await store.getSettings();
expect(settings.experimentalFeatures).toEqual({ "my-feature": false });
});
it("can add a new experimental feature without removing existing ones", async () => {
await store.updateSettings({
experimentalFeatures: { "feature-a": true },
});
await store.updateSettings({
experimentalFeatures: { "feature-b": true },
});
const settings = await store.getSettings();
expect(settings.experimentalFeatures).toEqual({ "feature-a": true, "feature-b": true });
});
it("can remove an experimental feature by setting it to undefined (field stays)", async () => {
// Note: We cannot selectively remove a single key from experimentalFeatures
// since it's a simple Record<string, boolean> not a nested object with special handling.
// Users should replace the entire object if they need to remove specific keys.
await store.updateSettings({
experimentalFeatures: { "feature-a": true, "feature-b": true },
});
// Replace with only feature-b
await store.updateSettings({
experimentalFeatures: { "feature-b": true },
});
const settings = await store.getSettings();
expect(settings.experimentalFeatures).toEqual({ "feature-b": true });
});
it("can clear experimentalFeatures with null (falls back to default {})", async () => {
await store.updateSettings({
experimentalFeatures: { "my-feature": true },
});
await store.updateSettings({
experimentalFeatures: null as unknown as undefined,
});
const settings = await store.getSettings();
expect(settings.experimentalFeatures).toEqual({});
});
it("preserves other settings when experimentalFeatures changes", async () => {
await store.updateSettings({
maxConcurrent: 5,
autoMerge: false,
});
await store.updateSettings({
experimentalFeatures: { "my-feature": true },
});
const settings = await store.getSettings();
expect(settings.maxConcurrent).toBe(5);
expect(settings.autoMerge).toBe(false);
expect(settings.experimentalFeatures).toEqual({ "my-feature": true });
});
it("preserves experimentalFeatures when updating other settings", async () => {
await store.updateSettings({
experimentalFeatures: { "feature-a": true, "feature-b": false },
});
await store.updateSettings({ maxConcurrent: 7 });
const settings = await store.getSettings();
expect(settings.maxConcurrent).toBe(7);
expect(settings.experimentalFeatures).toEqual({ "feature-a": true, "feature-b": false });
});
it("handles experimentalFeatures in getSettingsByScope", async () => {
await store.updateSettings({
experimentalFeatures: { "scoped-feature": true },
});
const { project } = await store.getSettingsByScope();
expect(project.experimentalFeatures).toEqual({ "scoped-feature": true });
});
it("handles experimentalFeatures in getSettingsFast", async () => {
await store.updateSettings({
experimentalFeatures: { "fast-feature": true },
});
const settings = await store.getSettingsFast();
expect(settings.experimentalFeatures).toEqual({ "fast-feature": true });
});
});
// ── Concurrent stress test ─────────────────────────────────────── // ── Concurrent stress test ───────────────────────────────────────
describe("concurrent stress", () => { describe("concurrent stress", () => {

View File

@@ -1200,6 +1200,20 @@ export interface ProjectSettings {
* When false, the FAB is hidden but chat remains accessible via the More menu. * When false, the FAB is hidden but chat remains accessible via the More menu.
* Default: false. */ * Default: false. */
showQuickChatFAB?: boolean; showQuickChatFAB?: boolean;
/** Project-scoped experimental feature toggles.
* Each key is a feature flag name, and the value indicates whether it is enabled.
* Features not present in this map are considered disabled (fallback to false).
* This allows teams to explicitly mark capabilities as experimental and toggle
* them on/off from the Settings dashboard.
*
* Example shape:
* {
* "my-new-feature": true,
* "another-experiment": false
* }
*
* Default: {} (empty object — no experimental features enabled). */
experimentalFeatures?: Record<string, boolean>;
} }
/** /**

View File

@@ -394,4 +394,193 @@ describe("SettingsModal", () => {
expect(editor.value).toBe(""); expect(editor.value).toBe("");
}); });
}); });
describe("Experimental Features section", () => {
it("renders the Experimental Features section in the sidebar", async () => {
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
expect(screen.getByText("Experimental Features")).toBeDefined();
});
it("shows empty state message when no experimental features are configured", async () => {
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Experimental Features"));
expect(screen.getByText(/No experimental features configured/i)).toBeInTheDocument();
});
it("shows feature flags when experimentalFeatures is set", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: { "my-feature": true, "another-feature": false },
});
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Experimental Features"));
expect(screen.getByText("my-feature")).toBeInTheDocument();
expect(screen.getByText("another-feature")).toBeInTheDocument();
});
it("feature flags are unchecked when value is false", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: { "my-feature": false },
});
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Experimental Features"));
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
expect(checkbox.checked).toBe(false);
});
it("feature flags are checked when value is true", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: { "my-feature": true },
});
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Experimental Features"));
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
expect(checkbox.checked).toBe(true);
});
it("toggling a feature flag updates the form state", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: { "my-feature": false },
});
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Experimental Features"));
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
expect(checkbox.checked).toBe(false);
// Toggle it
await userEvent.click(checkbox);
expect(checkbox.checked).toBe(true);
});
it("saving with toggled feature flag includes experimentalFeatures in payload", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: { "my-feature": false },
});
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Experimental Features"));
// Toggle the feature
const checkbox = screen.getByLabelText("my-feature") as HTMLInputElement;
await userEvent.click(checkbox);
// Save
await userEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
});
const payload = mockUpdateSettings.mock.calls[0][0];
expect(payload.experimentalFeatures).toEqual({ "my-feature": true });
});
it("shows project scope banner in Experimental Features section", async () => {
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Experimental Features"));
// Should show project scope indicator
expect(screen.getByText(/only affect this project/i)).toBeInTheDocument();
});
it("handles undefined experimentalFeatures (falls back to empty)", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: undefined,
});
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Experimental Features"));
// Should show empty state since undefined falls back to {}
expect(screen.getByText(/No experimental features configured/i)).toBeInTheDocument();
});
it("saves experimentalFeatures with multiple toggled flags", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
experimentalFeatures: { "feature-a": true, "feature-b": false },
});
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Experimental Features"));
// Toggle feature-b to true
const checkboxB = screen.getByLabelText("feature-b") as HTMLInputElement;
await userEvent.click(checkboxB);
// Save
await userEvent.click(screen.getByText("Save"));
await waitFor(() => {
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
});
const payload = mockUpdateSettings.mock.calls[0][0];
expect(payload.experimentalFeatures).toEqual({ "feature-a": true, "feature-b": true });
});
});
}); });

View File

@@ -70,6 +70,7 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
{ id: "commands", label: "Commands", scope: "project" }, { id: "commands", label: "Commands", scope: "project" },
{ id: "merge", label: "Merge", scope: "project" }, { id: "merge", label: "Merge", scope: "project" },
{ id: "memory", label: "Memory", scope: "project" }, { id: "memory", label: "Memory", scope: "project" },
{ id: "experimental", label: "Experimental Features", scope: "project" },
{ id: "prompts", label: "Prompts", scope: "project" }, { id: "prompts", label: "Prompts", scope: "project" },
{ id: "backups", label: "Backups", scope: "project" }, { id: "backups", label: "Backups", scope: "project" },
{ id: "plugins", label: "Plugins", scope: "project" }, { id: "plugins", label: "Plugins", scope: "project" },
@@ -1796,6 +1797,56 @@ export function SettingsModal({
</> </>
); );
} }
case "experimental": {
const experimentalFeatures = form.experimentalFeatures ?? {};
const featureFlags = Object.entries(experimentalFeatures).sort(([a], [b]) => a.localeCompare(b));
return (
<>
{renderScopeBanner()}
<h4 className="settings-section-heading">Experimental Features</h4>
<div className="form-group">
<small>
Experimental features are early capabilities that are not yet fully stable.
Enable them to test new functionality, but be aware they may change or be removed.
</small>
</div>
{featureFlags.length === 0 ? (
<div className="form-group">
<small className="settings-muted">
No experimental features configured. Features will appear here once added by the system.
</small>
</div>
) : (
<div className="form-group">
<label>Feature Flags</label>
<div style={{ display: "flex", flexDirection: "column", gap: "var(--space-sm)" }}>
{featureFlags.map(([key, enabled]) => (
<label key={key} htmlFor={`experimental-${key}`} className="checkbox-label">
<input
id={`experimental-${key}`}
type="checkbox"
checked={enabled}
onChange={(e) => {
setForm((f) => ({
...f,
experimentalFeatures: {
...(f.experimentalFeatures ?? {}),
[key]: e.target.checked,
},
}));
}}
/>
<span>{key}</span>
</label>
))}
</div>
</div>
)}
</>
);
}
case "backups": case "backups":
return ( return (
<> <>