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:
committed by
gsxdsm
parent
fbf7e2cb4d
commit
9c2b61eec6
@@ -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)
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
18
packages/dashboard/app/components/TestModeBanner.css
Normal file
18
packages/dashboard/app/components/TestModeBanner.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
packages/dashboard/app/components/TestModeBanner.tsx
Normal file
19
packages/dashboard/app/components/TestModeBanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user