feat(FN-5204): add test mode activation across settings, engine, and dashboard

- Add core settings schema/types support for testMode with model-resolution override handling
- Enforce engine session lane overrides in test mode with targeted helper coverage
- Add dashboard settings toggle plus persistent test-mode banner and related component tests
- Update settings documentation and parity/roundtrip tests for the new test mode behavior
This commit is contained in:
Fusion (runfusion.ai)
2026-05-22 00:15:14 -07:00
committed by gsxdsm
parent fbf7e2cb4d
commit 9c2b61eec6
23 changed files with 557 additions and 73 deletions

View File

@@ -146,6 +146,10 @@ Per-task opt-out exists: `task.scopeOverride = true` (log the reason).
When `settings.autoMerge: false`, `in-review` is terminal-until-merged by a human. Lifecycle-mutating self-healing must not move these tasks backward, pause/fail them, or re-enqueue them for execution. When `settings.autoMerge: false`, `in-review` is terminal-until-merged by a human. Lifecycle-mutating self-healing must not move these tasks backward, pause/fail them, or re-enqueue them for execution.
### Mock provider (test mode)
`testMode?: boolean` is now available in both project and global settings. If project `testMode === true` (or the resolved default provider is `"mock"` at any tier), every AI lane is forced to `mock/scripted`, overriding per-task and per-lane model selections. The dashboard exposes this via the Settings Modal "Enable test mode" toggle and a persistent "Test mode — no real AI calls" banner.
--- ---
## Reference docs (deeper detail) ## Reference docs (deeper detail)

View File

@@ -1303,6 +1303,8 @@ Per-task and global script overrides live in `mockScriptRegistry` (`setMockScrip
The mock runtime never registers with pi's `ModelRegistry` and is guarded by tests that fail on any `fetch`, `http.request`, or `https.request` usage. The mock runtime never registers with pi's `ModelRegistry` and is guarded by tests that fail on any `fetch`, `http.request`, or `https.request` usage.
Activation UX/settings affordances are handled separately in FN-5204. Activation UX/settings affordances are handled separately in FN-5204.
`testMode?: boolean` exists at both global and project scopes. Project `testMode: true` takes precedence and forces planning, executor, reviewer/validator, merger, and heartbeat to `mock/scripted` regardless of per-task or per-lane overrides. The dashboard surfaces this with the Settings Modal "Enable test mode" toggle and the shell banner: "Test mode — no real AI calls".
## Per-task token budget precedence ## Per-task token budget precedence
1. `task.tokenBudgetOverride` 1. `task.tokenBudgetOverride`

View File

@@ -219,6 +219,15 @@ describe("GlobalSettingsStore", () => {
expect(settings.themeMode).toBe("dark"); // preserved default expect(settings.themeMode).toBe("dark"); // preserved default
}); });
it("round-trips testMode in global settings", async () => {
await store.init();
await store.updateSettings({ testMode: true });
const settings = await store.getSettings();
expect(settings.testMode).toBe(true);
});
it("creates directory if missing", async () => { it("creates directory if missing", async () => {
const nested = join(dir, "auto", "create"); const nested = join(dir, "auto", "create");
const nestedStore = new GlobalSettingsStore(nested); const nestedStore = new GlobalSettingsStore(nested);

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
applyTestModeOverrides,
resolveExecutionSettingsModel, resolveExecutionSettingsModel,
resolvePlanningSettingsModel, resolvePlanningSettingsModel,
resolveProjectDefaultModel, resolveProjectDefaultModel,
@@ -8,6 +9,7 @@ import {
resolveTaskValidatorModel, resolveTaskValidatorModel,
resolveTitleSummarizerSettingsModel, resolveTitleSummarizerSettingsModel,
resolveValidatorSettingsModel, resolveValidatorSettingsModel,
TEST_MODE_RESOLVED,
} from "../model-resolution.js"; } from "../model-resolution.js";
describe("model-resolution", () => { describe("model-resolution", () => {
@@ -116,4 +118,60 @@ describe("model-resolution", () => {
), ),
).toEqual({ provider: "openai", modelId: "gpt-4.1" }); ).toEqual({ provider: "openai", modelId: "gpt-4.1" });
}); });
it("forces every lane to mock when testMode is true", () => {
const settings = {
testMode: true,
executionProvider: "anthropic",
executionModelId: "claude-sonnet-4-5",
planningProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
validatorProvider: "anthropic",
validatorModelId: "claude-sonnet-4-5",
titleSummarizerProvider: "anthropic",
titleSummarizerModelId: "claude-sonnet-4-5",
defaultProviderOverride: "anthropic",
defaultModelIdOverride: "claude-sonnet-4-5",
};
const taskOverrides = {
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "anthropic",
validatorModelId: "claude-sonnet-4-5",
planningModelProvider: "anthropic",
planningModelId: "claude-sonnet-4-5",
};
expect(resolveProjectDefaultModel(settings)).toEqual(TEST_MODE_RESOLVED);
expect(resolveExecutionSettingsModel(settings)).toEqual(TEST_MODE_RESOLVED);
expect(resolvePlanningSettingsModel(settings)).toEqual(TEST_MODE_RESOLVED);
expect(resolveValidatorSettingsModel(settings)).toEqual(TEST_MODE_RESOLVED);
expect(resolveTitleSummarizerSettingsModel(settings)).toEqual(TEST_MODE_RESOLVED);
expect(resolveTaskExecutionModel(taskOverrides, settings)).toEqual(TEST_MODE_RESOLVED);
expect(resolveTaskValidatorModel(taskOverrides, settings)).toEqual(TEST_MODE_RESOLVED);
expect(resolveTaskPlanningModel(taskOverrides, settings)).toEqual(TEST_MODE_RESOLVED);
});
it("forces mock when defaultProvider is mock without testMode", () => {
const settings = {
defaultProvider: "mock",
defaultModelId: "anything",
executionProvider: "anthropic",
executionModelId: "claude-sonnet-4-5",
};
expect(resolveExecutionSettingsModel(settings)).toEqual(TEST_MODE_RESOLVED);
expect(resolvePlanningSettingsModel(settings)).toEqual(TEST_MODE_RESOLVED);
expect(resolveValidatorSettingsModel(settings)).toEqual(TEST_MODE_RESOLVED);
});
it("passes through when test mode is inactive", () => {
const resolved = resolveExecutionSettingsModel({
executionProvider: "anthropic",
executionModelId: "claude-sonnet-4-5",
});
expect(applyTestModeOverrides(resolved, { testMode: false })).toEqual(resolved);
expect(applyTestModeOverrides(resolved, {})).toEqual(resolved);
});
}); });

View File

@@ -299,6 +299,7 @@ describe("settings key parity", () => {
const projectKeySet = new Set(PROJECT_SETTINGS_KEYS as readonly string[]); const projectKeySet = new Set(PROJECT_SETTINGS_KEYS as readonly string[]);
const overlap = (GLOBAL_SETTINGS_KEYS as readonly string[]).filter((key) => projectKeySet.has(key)); const overlap = (GLOBAL_SETTINGS_KEYS as readonly string[]).filter((key) => projectKeySet.has(key));
expect(overlap).toEqual([ expect(overlap).toEqual([
"testMode",
"taskTokenBudget", "taskTokenBudget",
"githubTrackingDefaultRepo", "githubTrackingDefaultRepo",
"worktrunk", "worktrunk",
@@ -434,6 +435,11 @@ describe("model lane key parity regression (FN-1729)", () => {
expect(isGlobalSettingsKey("defaultModelIdOverride")).toBe(false); expect(isGlobalSettingsKey("defaultModelIdOverride")).toBe(false);
}); });
it("testMode key is recognized in both project and global scopes", () => {
expect(isProjectSettingsKey("testMode")).toBe(true);
expect(isGlobalSettingsKey("testMode")).toBe(true);
});
it("no model lane provider exists without its corresponding modelId key", () => { it("no model lane provider exists without its corresponding modelId key", () => {
const allKeys = new Set([...GLOBAL_SETTINGS_KEYS, ...PROJECT_SETTINGS_KEYS]); const allKeys = new Set([...GLOBAL_SETTINGS_KEYS, ...PROJECT_SETTINGS_KEYS]);

View File

@@ -1465,6 +1465,20 @@ describe("TaskStore", () => {
expect(settings.maxConcurrent).toBe(8); expect(settings.maxConcurrent).toBe(8);
}); });
it("round-trips testMode in both project and global scopes", async () => {
await harness.store().updateGlobalSettings({ testMode: true });
let merged = await harness.store().getSettings();
expect(merged.testMode).toBe(true);
await harness.store().updateSettings({ testMode: false });
merged = await harness.store().getSettings();
expect(merged.testMode).toBe(false);
const { global, project } = await harness.store().getSettingsByScope();
expect(global.testMode).toBe(true);
expect(project.testMode).toBe(false);
});
it("updateSettings silently filters out global-only fields", async () => { it("updateSettings silently filters out global-only fields", async () => {
// themeMode is a global field — should not be persisted to project config // themeMode is a global field — should not be persisted to project config
await harness.store().updateSettings({ maxConcurrent: 5, themeMode: "light" } as any); await harness.store().updateSettings({ maxConcurrent: 5, themeMode: "light" } as any);

View File

@@ -581,6 +581,8 @@ export {
__resetSummarizeState, __resetSummarizeState,
} from "./ai-summarize.js"; } from "./ai-summarize.js";
export { export {
applyTestModeOverrides,
isTestModeActive,
resolveExecutionSettingsModel, resolveExecutionSettingsModel,
resolvePlanningSettingsModel, resolvePlanningSettingsModel,
resolveProjectDefaultModel, resolveProjectDefaultModel,
@@ -589,6 +591,7 @@ export {
resolveTaskValidatorModel, resolveTaskValidatorModel,
resolveTitleSummarizerSettingsModel, resolveTitleSummarizerSettingsModel,
resolveValidatorSettingsModel, resolveValidatorSettingsModel,
TEST_MODE_RESOLVED,
} from "./model-resolution.js"; } from "./model-resolution.js";
export type { ResolvedModelSelection } from "./model-resolution.js"; export type { ResolvedModelSelection } from "./model-resolution.js";

View File

@@ -5,6 +5,19 @@ export interface ResolvedModelSelection {
modelId?: string; modelId?: string;
} }
export const TEST_MODE_RESOLVED: ResolvedModelSelection = { provider: "mock", modelId: "scripted" };
export function isTestModeActive(settings?: Partial<Settings>): boolean {
return settings?.testMode === true || settings?.defaultProvider?.trim().toLowerCase() === "mock";
}
export function applyTestModeOverrides(
resolved: ResolvedModelSelection,
settings?: Partial<Settings>,
): ResolvedModelSelection {
return isTestModeActive(settings) ? TEST_MODE_RESOLVED : resolved;
}
type ModelPair = type ModelPair =
| ResolvedModelSelection | ResolvedModelSelection
| { | {
@@ -36,75 +49,90 @@ function pickFirstModelPair(...pairs: ModelPair[]): ResolvedModelSelection {
} }
export function resolveProjectDefaultModel(settings?: Partial<Settings>): ResolvedModelSelection { export function resolveProjectDefaultModel(settings?: Partial<Settings>): ResolvedModelSelection {
return pickFirstModelPair( return applyTestModeOverrides(
{ pickFirstModelPair(
provider: settings?.defaultProviderOverride, {
modelId: settings?.defaultModelIdOverride, provider: settings?.defaultProviderOverride,
}, modelId: settings?.defaultModelIdOverride,
{ },
provider: settings?.defaultProvider, {
modelId: settings?.defaultModelId, provider: settings?.defaultProvider,
}, modelId: settings?.defaultModelId,
},
),
settings,
); );
} }
export function resolveExecutionSettingsModel(settings?: Partial<Settings>): ResolvedModelSelection { export function resolveExecutionSettingsModel(settings?: Partial<Settings>): ResolvedModelSelection {
return pickFirstModelPair( return applyTestModeOverrides(
{ pickFirstModelPair(
provider: settings?.executionProvider, {
modelId: settings?.executionModelId, provider: settings?.executionProvider,
}, modelId: settings?.executionModelId,
{ },
provider: settings?.executionGlobalProvider, {
modelId: settings?.executionGlobalModelId, provider: settings?.executionGlobalProvider,
}, modelId: settings?.executionGlobalModelId,
resolveProjectDefaultModel(settings), },
resolveProjectDefaultModel(settings),
),
settings,
); );
} }
export function resolvePlanningSettingsModel(settings?: Partial<Settings>): ResolvedModelSelection { export function resolvePlanningSettingsModel(settings?: Partial<Settings>): ResolvedModelSelection {
return pickFirstModelPair( return applyTestModeOverrides(
{ pickFirstModelPair(
provider: settings?.planningProvider, {
modelId: settings?.planningModelId, provider: settings?.planningProvider,
}, modelId: settings?.planningModelId,
{ },
provider: settings?.planningGlobalProvider, {
modelId: settings?.planningGlobalModelId, provider: settings?.planningGlobalProvider,
}, modelId: settings?.planningGlobalModelId,
resolveProjectDefaultModel(settings), },
resolveProjectDefaultModel(settings),
),
settings,
); );
} }
export function resolveValidatorSettingsModel(settings?: Partial<Settings>): ResolvedModelSelection { export function resolveValidatorSettingsModel(settings?: Partial<Settings>): ResolvedModelSelection {
return pickFirstModelPair( return applyTestModeOverrides(
{ pickFirstModelPair(
provider: settings?.validatorProvider, {
modelId: settings?.validatorModelId, provider: settings?.validatorProvider,
}, modelId: settings?.validatorModelId,
{ },
provider: settings?.validatorGlobalProvider, {
modelId: settings?.validatorGlobalModelId, provider: settings?.validatorGlobalProvider,
}, modelId: settings?.validatorGlobalModelId,
resolveProjectDefaultModel(settings), },
resolveProjectDefaultModel(settings),
),
settings,
); );
} }
export function resolveTitleSummarizerSettingsModel(settings?: Partial<Settings>): ResolvedModelSelection { export function resolveTitleSummarizerSettingsModel(settings?: Partial<Settings>): ResolvedModelSelection {
return pickFirstModelPair( return applyTestModeOverrides(
{ pickFirstModelPair(
provider: settings?.titleSummarizerProvider, {
modelId: settings?.titleSummarizerModelId, provider: settings?.titleSummarizerProvider,
}, modelId: settings?.titleSummarizerModelId,
{ },
provider: settings?.titleSummarizerGlobalProvider, {
modelId: settings?.titleSummarizerGlobalModelId, provider: settings?.titleSummarizerGlobalProvider,
}, modelId: settings?.titleSummarizerGlobalModelId,
{ },
provider: settings?.planningProvider, {
modelId: settings?.planningModelId, provider: settings?.planningProvider,
}, modelId: settings?.planningModelId,
resolveProjectDefaultModel(settings), },
resolveProjectDefaultModel(settings),
),
settings,
); );
} }
@@ -112,12 +140,15 @@ export function resolveTaskExecutionModel(
task: TaskModelLike, task: TaskModelLike,
settings?: Partial<Settings>, settings?: Partial<Settings>,
): ResolvedModelSelection { ): ResolvedModelSelection {
return pickFirstModelPair( return applyTestModeOverrides(
{ pickFirstModelPair(
provider: task.modelProvider, {
modelId: task.modelId, provider: task.modelProvider,
}, modelId: task.modelId,
resolveExecutionSettingsModel(settings), },
resolveExecutionSettingsModel(settings),
),
settings,
); );
} }
@@ -125,12 +156,15 @@ export function resolveTaskValidatorModel(
task: TaskModelLike, task: TaskModelLike,
settings?: Partial<Settings>, settings?: Partial<Settings>,
): ResolvedModelSelection { ): ResolvedModelSelection {
return pickFirstModelPair( return applyTestModeOverrides(
{ pickFirstModelPair(
provider: task.validatorModelProvider, {
modelId: task.validatorModelId, provider: task.validatorModelProvider,
}, modelId: task.validatorModelId,
resolveValidatorSettingsModel(settings), },
resolveValidatorSettingsModel(settings),
),
settings,
); );
} }
@@ -138,11 +172,14 @@ export function resolveTaskPlanningModel(
task: TaskModelLike, task: TaskModelLike,
settings?: Partial<Settings>, settings?: Partial<Settings>,
): ResolvedModelSelection { ): ResolvedModelSelection {
return pickFirstModelPair( return applyTestModeOverrides(
{ pickFirstModelPair(
provider: task.planningModelProvider, {
modelId: task.planningModelId, provider: task.planningModelProvider,
}, modelId: task.planningModelId,
resolvePlanningSettingsModel(settings), },
resolvePlanningSettingsModel(settings),
),
settings,
); );
} }

View File

@@ -17,6 +17,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
dashboardFontScalePct: 100, dashboardFontScalePct: 100,
defaultProvider: undefined, defaultProvider: undefined,
defaultModelId: undefined, defaultModelId: undefined,
testMode: undefined,
fallbackProvider: undefined, fallbackProvider: undefined,
fallbackModelId: undefined, fallbackModelId: undefined,
defaultThinkingLevel: undefined, defaultThinkingLevel: undefined,
@@ -192,6 +193,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
groupOverlappingFiles: true, groupOverlappingFiles: true,
overlapIgnorePaths: [], overlapIgnorePaths: [],
autoMerge: true, autoMerge: true,
testMode: undefined,
mergeStrategy: "direct", mergeStrategy: "direct",
directMergeCommitStrategy: "auto", directMergeCommitStrategy: "auto",
mergeIntegrationWorktree: "reuse-task-worktree", mergeIntegrationWorktree: "reuse-task-worktree",

View File

@@ -2227,6 +2227,10 @@ export interface GlobalSettings {
* Must be set together with `defaultProvider`. When both are undefined, * Must be set together with `defaultProvider`. When both are undefined,
* the engine uses pi's automatic model resolution. */ * the engine uses pi's automatic model resolution. */
defaultModelId?: string; defaultModelId?: string;
/** When true, force every AI lane onto the deterministic mock provider regardless
* of per-task or per-lane overrides. No network calls, zero token cost.
* Project `testMode` takes precedence over the global value. */
testMode?: boolean;
/** Fallback AI model provider used when the primary default model fails due to /** Fallback AI model provider used when the primary default model fails due to
* transient provider-side issues such as rate limits or overloaded capacity. * transient provider-side issues such as rate limits or overloaded capacity.
* Must be set together with `fallbackModelId`. */ * Must be set together with `fallbackModelId`. */
@@ -2710,6 +2714,9 @@ export interface ProjectSettings {
* active in-progress tasks and in-review tasks with unmerged worktrees. */ * active in-progress tasks and in-review tasks with unmerged worktrees. */
overlapIgnorePaths?: string[]; overlapIgnorePaths?: string[];
autoMerge: boolean; autoMerge: boolean;
/** When true, force every AI lane onto the deterministic mock provider regardless
* of per-task or per-lane overrides. No network calls, zero token cost. */
testMode?: boolean;
/** How completed in-review tasks should be finalized when autoMerge is enabled. /** How completed in-review tasks should be finalized when autoMerge is enabled.
* - "direct": preserve the existing local squash-merge flow into the current branch * - "direct": preserve the existing local squash-merge flow into the current branch
* - "pull-request": create or reuse a GitHub PR and wait for GitHub-side checks/reviews * - "pull-request": create or reuse a GitHub PR and wait for GitHub-side checks/reviews

View File

@@ -24,6 +24,7 @@ import { SessionNotificationBanner } from "./components/SessionNotificationBanne
import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner"; import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner";
import { SetupWarningBanner } from "./components/SetupWarningBanner"; import { SetupWarningBanner } from "./components/SetupWarningBanner";
import { CapacityRiskBanner } from "./components/CapacityRiskBanner"; import { CapacityRiskBanner } from "./components/CapacityRiskBanner";
import { TestModeBanner } from "./components/TestModeBanner";
import { TaskIdIntegrityBanner } from "./components/TaskIdIntegrityBanner"; import { TaskIdIntegrityBanner } from "./components/TaskIdIntegrityBanner";
import { DbCorruptionBanner } from "./components/DbCorruptionBanner"; import { DbCorruptionBanner } from "./components/DbCorruptionBanner";
import { UpdateAvailableBanner } from "./components/UpdateAvailableBanner"; import { UpdateAvailableBanner } from "./components/UpdateAvailableBanner";
@@ -773,6 +774,7 @@ function AppInner() {
autoMerge, autoMerge,
globalPaused, globalPaused,
enginePaused, enginePaused,
isTestMode,
taskStuckTimeoutMs, taskStuckTimeoutMs,
staleHighFanoutBlockerAgeThresholdMs, staleHighFanoutBlockerAgeThresholdMs,
capacityRiskBannerEnabled, capacityRiskBannerEnabled,
@@ -1777,6 +1779,9 @@ function AppInner() {
) : undefined ) : undefined
} }
/> />
{viewMode === "project" && currentProject && (
<TestModeBanner isActive={isTestMode} />
)}
{viewMode === "project" && currentProject && !nodesOpen && taskView !== "missions" && !modalManager.isPlanningOpen && !sessionBannersHidden && ( {viewMode === "project" && currentProject && !nodesOpen && taskView !== "missions" && !modalManager.isPlanningOpen && !sessionBannersHidden && (
<SessionNotificationBanner <SessionNotificationBanner
sessions={sessionsNeedingInput} sessions={sessionsNeedingInput}

View File

@@ -4522,6 +4522,23 @@ export function SettingsModal({
<small>When enabled, tasks that pass review are automatically merged into the main branch</small> <small>When enabled, tasks that pass review are automatically merged into the main branch</small>
</details> </details>
</div> </div>
<div className="form-group">
<label htmlFor="testMode" className="checkbox-label">
<input
id="testMode"
type="checkbox"
checked={form.testMode === true}
onChange={(e) =>
setForm((f) => ({ ...f, testMode: e.target.checked }))
}
/>
Enable test mode
</label>
<details className="settings-option-details">
<summary>More details</summary>
<small>Forces all AI lanes to use the deterministic mock provider. No network calls, zero token cost.</small>
</details>
</div>
<div className="form-group"> <div className="form-group">
<label htmlFor="workflowRevisionForkOnScopeMismatch" className="checkbox-label"> <label htmlFor="workflowRevisionForkOnScopeMismatch" className="checkbox-label">
<input <input

View File

@@ -0,0 +1,18 @@
.test-mode-banner {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-md);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
border-inline-start: var(--space-xs) solid var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 18%, transparent);
color: var(--text);
}
@media (max-width: 768px) {
.test-mode-banner {
padding: var(--space-sm);
align-items: flex-start;
}
}

View File

@@ -0,0 +1,19 @@
import { FlaskConical } from "lucide-react";
import "./TestModeBanner.css";
interface TestModeBannerProps {
isActive: boolean;
}
export function TestModeBanner({ isActive }: TestModeBannerProps) {
if (!isActive) {
return null;
}
return (
<div className="test-mode-banner" role="status" aria-live="polite">
<FlaskConical aria-hidden="true" />
<span>Test mode no real AI calls</span>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SettingsModal } from "../SettingsModal";
const mockFetchSettings = vi.fn();
const mockFetchSettingsByScope = vi.fn();
vi.mock("../../api", async (importOriginal) => {
const { createDashboardApiMock } = await import("../../test/mockApi");
return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), {
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
fetchSettingsByScope: (...args: unknown[]) => mockFetchSettingsByScope(...args),
});
});
vi.mock("../../hooks/useMemoryBackendStatus", () => ({
useMemoryBackendStatus: () => ({ status: null, capabilities: null, loading: false, error: null, refresh: vi.fn() }),
}));
vi.mock("../../hooks/useViewportMode", () => ({ useViewportMode: () => "desktop" }));
vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: () => ({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false }),
}));
vi.mock("../../hooks/useMobileScrollLock", () => ({ useMobileScrollLock: vi.fn() }));
vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: vi.fn() }) }));
vi.mock("../../hooks/useWorkspaceFileBrowser", () => ({
useWorkspaceFileBrowser: () => ({ entries: [], currentPath: ".", setPath: vi.fn(), loading: false, error: null, refresh: vi.fn() }),
}));
vi.mock("../../hooks/useWorktrunkInstallStatus", () => ({
useWorktrunkInstallStatus: () => ({ status: "idle", requestInstall: vi.fn() }),
}));
function buildSettings(testMode: boolean) {
return {
autoMerge: true,
testMode,
maxConcurrent: 2,
maxTriageConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
heartbeatMultiplier: 1,
groupOverlappingFiles: true,
overlapIgnorePaths: [],
mergeStrategy: "direct",
mergeIntegrationWorktree: "reuse-task-worktree",
recycleWorktrees: false,
executorAllowSiblingBranchRename: false,
worktreeNaming: "random",
worktreesDir: "",
worktrunk: { enabled: false, binaryPath: "", onFailure: "fail" },
includeTaskIdInCommit: true,
ntfyEnabled: false,
failureNotificationMode: "sticky-only",
failureNotificationDelayMs: 30000,
webhookEnabled: false,
experimentalFeatures: {},
};
}
describe("SettingsModal testMode toggle", () => {
beforeEach(() => {
vi.clearAllMocks();
const merged = buildSettings(true);
mockFetchSettings.mockResolvedValue(merged);
mockFetchSettingsByScope.mockResolvedValue({ global: {}, project: { testMode: true } });
});
it("renders merge test mode toggle with initial checked state", async () => {
render(<SettingsModal onClose={() => {}} addToast={() => {}} initialSection="merge" />);
const toggle = await screen.findByLabelText("Enable test mode");
expect(toggle).toBeChecked();
});
it("flips checkbox state when clicked", async () => {
render(<SettingsModal onClose={() => {}} addToast={() => {}} initialSection="merge" />);
const toggle = await screen.findByLabelText("Enable test mode");
expect(toggle).toBeChecked();
await userEvent.click(toggle);
await waitFor(() => {
expect(toggle).not.toBeChecked();
});
});
});

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { TestModeBanner } from "../TestModeBanner";
describe("TestModeBanner", () => {
it("renders nothing when inactive", () => {
const { container } = render(<TestModeBanner isActive={false} />);
expect(container).toBeEmptyDOMElement();
});
it("renders status copy when active", () => {
render(<TestModeBanner isActive />);
expect(screen.getByRole("status")).toHaveTextContent("Test mode — no real AI calls");
});
});

View File

@@ -43,6 +43,8 @@ describe("useAppSettings", () => {
expect(result.current.maxConcurrent).toBe(4); expect(result.current.maxConcurrent).toBe(4);
expect(result.current.rootDir).toBe("/workspace/project"); expect(result.current.rootDir).toBe("/workspace/project");
expect(result.current.autoMerge).toBe(false); expect(result.current.autoMerge).toBe(false);
expect(result.current.testMode).toBe(false);
expect(result.current.isTestMode).toBe(false);
expect(result.current.globalPaused).toBe(true); expect(result.current.globalPaused).toBe(true);
expect(result.current.enginePaused).toBe(false); expect(result.current.enginePaused).toBe(false);
expect(result.current.prAuthAvailable).toBe(true); expect(result.current.prAuthAvailable).toBe(true);
@@ -120,6 +122,25 @@ describe("useAppSettings", () => {
); );
}); });
it("derives isTestMode from defaultProvider=mock", async () => {
mockFetchSettings.mockResolvedValueOnce({
autoMerge: false,
globalPause: true,
enginePaused: false,
prAuthAvailable: true,
taskStuckTimeoutMs: 600000,
showQuickChatFAB: false,
defaultProvider: "mock",
} as never);
const { result } = renderHook(() => useAppSettings("proj_123"));
await waitFor(() => {
expect(result.current.testMode).toBe(false);
expect(result.current.isTestMode).toBe(true);
});
});
it("propagates capacity risk settings from fetchSettings", async () => { it("propagates capacity risk settings from fetchSettings", async () => {
mockFetchSettings.mockResolvedValueOnce({ mockFetchSettings.mockResolvedValueOnce({
autoMerge: false, autoMerge: false,

View File

@@ -9,6 +9,8 @@ export interface UseAppSettingsResult {
maxConcurrent: number; maxConcurrent: number;
rootDir: string; rootDir: string;
autoMerge: boolean; autoMerge: boolean;
testMode: boolean;
isTestMode: boolean;
globalPaused: boolean; globalPaused: boolean;
enginePaused: boolean; enginePaused: boolean;
taskStuckTimeoutMs: number | undefined; taskStuckTimeoutMs: number | undefined;
@@ -42,6 +44,8 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
const [maxConcurrent, setMaxConcurrent] = useState(2); const [maxConcurrent, setMaxConcurrent] = useState(2);
const [rootDir, setRootDir] = useState<string>("."); const [rootDir, setRootDir] = useState<string>(".");
const [autoMerge, setAutoMerge] = useState(true); const [autoMerge, setAutoMerge] = useState(true);
const [testMode, setTestMode] = useState(false);
const [isTestMode, setIsTestMode] = useState(false);
const [globalPaused, setGlobalPaused] = useState(false); const [globalPaused, setGlobalPaused] = useState(false);
const [enginePaused, setEnginePaused] = useState(false); const [enginePaused, setEnginePaused] = useState(false);
const [taskStuckTimeoutMs, setTaskStuckTimeoutMs] = useState<number | undefined>(undefined); const [taskStuckTimeoutMs, setTaskStuckTimeoutMs] = useState<number | undefined>(undefined);
@@ -78,6 +82,10 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
if (settingsResult.status === "fulfilled") { if (settingsResult.status === "fulfilled") {
const settings = settingsResult.value; const settings = settingsResult.value;
setAutoMerge(Boolean(settings.autoMerge)); setAutoMerge(Boolean(settings.autoMerge));
const nextTestMode = settings.testMode === true;
const nextIsTestMode = nextTestMode || settings.defaultProvider?.trim().toLowerCase() === "mock";
setTestMode(nextTestMode);
setIsTestMode(nextIsTestMode);
setGlobalPaused(Boolean(settings.globalPause)); setGlobalPaused(Boolean(settings.globalPause));
setEnginePaused(Boolean(settings.enginePaused)); setEnginePaused(Boolean(settings.enginePaused));
setPrAuthAvailable(Boolean(settings.prAuthAvailable)); setPrAuthAvailable(Boolean(settings.prAuthAvailable));
@@ -183,6 +191,8 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
maxConcurrent, maxConcurrent,
rootDir, rootDir,
autoMerge, autoMerge,
testMode,
isTestMode,
globalPaused, globalPaused,
enginePaused, enginePaused,
taskStuckTimeoutMs, taskStuckTimeoutMs,

View File

@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Fusion</title> <title>Fusion</title>
<link rel="icon" type="image/svg+xml" href="/logo.svg" /> <link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />

View File

@@ -3189,6 +3189,13 @@ input[type="range"]:focus-visible {
} }
} }
@media (min-width: 769px) and (max-width: 1024px) {
.board {
grid-template-columns: repeat(6, minmax(0, 1fr));
overflow-x: hidden;
}
}
/* === Mobile Responsive Overrides === /* === Mobile Responsive Overrides ===
Cross-cutting mobile rules only. Component-specific overrides have been Cross-cutting mobile rules only. Component-specific overrides have been
moved into co-located @media blocks at the bottom of each component's moved into co-located @media blocks at the bottom of each component's

View File

@@ -12,7 +12,7 @@ const qualityAppTests = [
"app/api/**/*.test.ts", "app/api/**/*.test.ts",
// Representative workflow/component coverage. Exhaustive modal/view suites // Representative workflow/component coverage. Exhaustive modal/view suites
// stay available in the full `dashboard-app` project. // stay available in the full `dashboard-app` project.
"app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.scroll-to-top,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MergeAdvanceNotice,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.worktrunk,StashConflictModal,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx", "app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.scroll-to-top,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MergeAdvanceNotice,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.testMode,SettingsModal.worktrunk,StashConflictModal,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TestModeBanner,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx",
// Hooks and utilities are fast, user-visible state/formatting behavior. // Hooks and utilities are fast, user-visible state/formatting behavior.
"app/context/**/*.test.tsx", "app/context/**/*.test.tsx",
"app/hooks/__tests__/{useAgents,useAgentLogs,useAgentLogs.resume-instrumentation,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodes.resume-instrumentation,useNodeSettingsSync,useProjects,useProjects.resume-instrumentation,useMeshState.resume-instrumentation,useManagedDockerNodes.resume-instrumentation,usePrChecksStream.resume-instrumentation,useDevServerLogs.resume-instrumentation,useResearch.resume-instrumentation,useBackgroundSessions.resume-instrumentation,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms.resume-instrumentation,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}", "app/hooks/__tests__/{useAgents,useAgentLogs,useAgentLogs.resume-instrumentation,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodes.resume-instrumentation,useNodeSettingsSync,useProjects,useProjects.resume-instrumentation,useMeshState.resume-instrumentation,useManagedDockerNodes.resume-instrumentation,usePrChecksStream.resume-instrumentation,useDevServerLogs.resume-instrumentation,useResearch.resume-instrumentation,useBackgroundSessions.resume-instrumentation,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms.resume-instrumentation,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",

View File

@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import {
resolveExecutorSessionModel,
resolveHeartbeatSessionModels,
resolveMergerSessionModel,
resolvePlanningSessionModel,
} from "../agent-session-helpers.js";
const assignedAgentRuntimeConfig = {
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
};
describe("agent-session-helpers test mode overrides", () => {
it("forces every resolver lane to mock when settings.testMode is true", () => {
const settings = {
testMode: true,
defaultProvider: "openai",
defaultModelId: "gpt-4.1",
executionProvider: "openai",
executionModelId: "gpt-4.1",
planningProvider: "openai",
planningModelId: "gpt-4.1",
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4.1",
};
expect(resolveExecutorSessionModel("openai", "gpt-4.1", settings, assignedAgentRuntimeConfig)).toEqual({
provider: "mock",
modelId: "scripted",
});
expect(resolvePlanningSessionModel("openai", "gpt-4.1", settings, assignedAgentRuntimeConfig)).toEqual({
provider: "mock",
modelId: "scripted",
});
expect(resolveMergerSessionModel(settings, assignedAgentRuntimeConfig)).toEqual({
provider: "mock",
modelId: "scripted",
});
expect(resolveHeartbeatSessionModels(settings, assignedAgentRuntimeConfig)).toEqual({
defaultProvider: "mock",
defaultModelId: "scripted",
fallbackProvider: undefined,
fallbackModelId: undefined,
});
});
it("forces every resolver lane to mock when defaultProvider is mock", () => {
const settings = {
defaultProvider: "mock",
defaultModelId: "custom",
executionProvider: "openai",
executionModelId: "gpt-4.1",
planningProvider: "openai",
planningModelId: "gpt-4.1",
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4.1",
};
expect(resolveExecutorSessionModel("openai", "gpt-4.1", settings, assignedAgentRuntimeConfig)).toEqual({
provider: "mock",
modelId: "scripted",
});
expect(resolvePlanningSessionModel("openai", "gpt-4.1", settings, assignedAgentRuntimeConfig)).toEqual({
provider: "mock",
modelId: "scripted",
});
expect(resolveMergerSessionModel(settings, assignedAgentRuntimeConfig)).toEqual({
provider: "mock",
modelId: "scripted",
});
expect(resolveHeartbeatSessionModels(settings, assignedAgentRuntimeConfig)).toEqual({
defaultProvider: "mock",
defaultModelId: "scripted",
fallbackProvider: undefined,
fallbackModelId: undefined,
});
});
it("keeps existing behavior when test mode is inactive", () => {
const settings = {
executionProvider: "openai",
executionModelId: "gpt-4.1",
planningProvider: "openai",
planningModelId: "gpt-4.1",
defaultProviderOverride: "openai",
defaultModelIdOverride: "gpt-4.1",
defaultProvider: "openai",
defaultModelId: "gpt-4.1",
};
expect(resolveExecutorSessionModel("task-provider", "task-model", settings, assignedAgentRuntimeConfig)).toEqual({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
});
expect(resolvePlanningSessionModel("task-provider", "task-model", settings, assignedAgentRuntimeConfig)).toEqual({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
});
expect(resolveMergerSessionModel(settings, assignedAgentRuntimeConfig)).toEqual({
provider: "anthropic",
modelId: "claude-sonnet-4-5",
});
expect(resolveHeartbeatSessionModels(settings, assignedAgentRuntimeConfig)).toEqual({
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
fallbackProvider: "openai",
fallbackModelId: "gpt-4.1",
});
});
});

View File

@@ -12,9 +12,11 @@ import type { SkillSelectionContext } from "./skill-resolver.js";
import type { PluginRunner } from "./plugin-runner.js"; import type { PluginRunner } from "./plugin-runner.js";
import type { AgentSession } from "@mariozechner/pi-coding-agent"; import type { AgentSession } from "@mariozechner/pi-coding-agent";
import { import {
isTestModeActive,
resolveExecutionSettingsModel, resolveExecutionSettingsModel,
resolveTaskExecutionModel, resolveTaskExecutionModel,
resolveTaskPlanningModel, resolveTaskPlanningModel,
TEST_MODE_RESOLVED,
type Settings, type Settings,
} from "@fusion/core"; } from "@fusion/core";
import { resolveRuntime, buildRuntimeResolutionContext, isMockProviderId, type SessionPurpose } from "./runtime-resolution.js"; import { resolveRuntime, buildRuntimeResolutionContext, isMockProviderId, type SessionPurpose } from "./runtime-resolution.js";
@@ -122,6 +124,13 @@ export function resolveExecutorSessionModel(
settings: Partial<Settings> | undefined, settings: Partial<Settings> | undefined,
assignedAgentRuntimeConfig?: Record<string, unknown>, assignedAgentRuntimeConfig?: Record<string, unknown>,
): { provider: string | undefined; modelId: string | undefined } { ): { provider: string | undefined; modelId: string | undefined } {
if (isTestModeActive(settings)) {
return {
provider: TEST_MODE_RESOLVED.provider,
modelId: TEST_MODE_RESOLVED.modelId,
};
}
const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig); const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig);
if (assignedRuntimeModel.provider && assignedRuntimeModel.modelId) { if (assignedRuntimeModel.provider && assignedRuntimeModel.modelId) {
return assignedRuntimeModel; return assignedRuntimeModel;
@@ -147,6 +156,13 @@ export function resolvePlanningSessionModel(
settings: Partial<Settings> | undefined, settings: Partial<Settings> | undefined,
assignedAgentRuntimeConfig?: Record<string, unknown>, assignedAgentRuntimeConfig?: Record<string, unknown>,
): { provider: string | undefined; modelId: string | undefined } { ): { provider: string | undefined; modelId: string | undefined } {
if (isTestModeActive(settings)) {
return {
provider: TEST_MODE_RESOLVED.provider,
modelId: TEST_MODE_RESOLVED.modelId,
};
}
const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig); const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig);
if (assignedRuntimeModel.provider && assignedRuntimeModel.modelId) { if (assignedRuntimeModel.provider && assignedRuntimeModel.modelId) {
return assignedRuntimeModel; return assignedRuntimeModel;
@@ -175,6 +191,15 @@ export function resolveHeartbeatSessionModels(
fallbackProvider: string | undefined; fallbackProvider: string | undefined;
fallbackModelId: string | undefined; fallbackModelId: string | undefined;
} { } {
if (isTestModeActive(settings)) {
return {
defaultProvider: TEST_MODE_RESOLVED.provider,
defaultModelId: TEST_MODE_RESOLVED.modelId,
fallbackProvider: undefined,
fallbackModelId: undefined,
};
}
const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig); const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig);
const executionSettingsModel = resolveExecutionSettingsModel(settings); const executionSettingsModel = resolveExecutionSettingsModel(settings);
@@ -197,6 +222,13 @@ export function resolveMergerSessionModel(
settings: Partial<Settings> | undefined, settings: Partial<Settings> | undefined,
assignedAgentRuntimeConfig?: Record<string, unknown>, assignedAgentRuntimeConfig?: Record<string, unknown>,
): { provider: string | undefined; modelId: string | undefined } { ): { provider: string | undefined; modelId: string | undefined } {
if (isTestModeActive(settings)) {
return {
provider: TEST_MODE_RESOLVED.provider,
modelId: TEST_MODE_RESOLVED.modelId,
};
}
const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig); const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig);
if (assignedRuntimeModel.provider && assignedRuntimeModel.modelId) { if (assignedRuntimeModel.provider && assignedRuntimeModel.modelId) {
return assignedRuntimeModel; return assignedRuntimeModel;