FN-7900: persist thinkingLevel override for schedule and routine AI steps

Adds a persisted, optional per-step reasoning-effort (thinkingLevel) override for AI-capable schedule and routine automation steps, surfaced in the editors and validated at the route layer.

- Add optional AutomationStep.thinkingLevel field (packages/core/src/automation.ts), riding the existing JSON steps blob so no DB migration is needed; runtime application of the level is deferred to a follow-up.
- Validate thinkingLevel in dashboard route step validation against the shared THINKING_LEVELS set, rejecting unknown values (packages/dashboard/src/routes.ts).
- Add Thinking Level controls to RoutineEditor, ScheduleForm, and ScheduleStepsEditor so users can set/inherit the override per step.
- Extend core and dashboard test suites (automation-store, routine-store, RoutineEditor, ScheduleForm, ScheduleStepsEditor, routes-automation) to cover persistence, validation, and UI behavior.
- Update dashboard-guide.md docs and add a minor changeset for the new feature.

Files changed:
 .changeset/fn-7900-automation-thinking-level.md    |   7 +
 docs/dashboard-guide.md                            |   3 +-
 .../core/src/__tests__/automation-store.test.ts    |  45 ++++++
 packages/core/src/__tests__/routine-store.test.ts  |  46 +++++++
 packages/core/src/automation.ts                    |   9 ++
 .../dashboard/app/components/RoutineEditor.tsx     |  21 ++-
 packages/dashboard/app/components/ScheduleForm.tsx |  28 +++-
 .../app/components/ScheduleStepsEditor.tsx         |  21 ++-
 .../components/__tests__/RoutineEditor.test.tsx    |  99 +++++++++++++-
 .../app/components/__tests__/ScheduleForm.test.tsx | 137 +++++++++++++++++--
 .../__tests__/ScheduleStepsEditor.test.tsx         |  83 +++++++++--
 .../src/__tests__/routes-automation.test.ts        | 152 +++++++++++++++++++++
 packages/dashboard/src/routes.ts                   |  10 ++
 13 files changed, 622 insertions(+), 39 deletions(-)

Fusion-Task-Id: FN-7900

Fusion-Task-Lineage: 812a9a8c-ad0f-462f-b1c6-9900f70e4261

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 19:43:41 -07:00
parent 7a51f95b38
commit 635d78248b
13 changed files with 622 additions and 39 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add per-step Thinking Level controls to schedule and routine AI actions.
category: feature
dev: Adds AutomationStep.thinkingLevel persistence and route validation; runtime application is tracked separately.

View File

@@ -142,8 +142,9 @@ On mobile viewports, the Right Dock never renders. The compact Header actions an
<!-- FNXC:AutomationTools 2026-06-26-00:00: Automation AI-prompt steps now default to the full coding tool set and expose per-step restrictions so operators can intentionally narrow tool access without breaking legacy schedules. -->
<!-- FNXC:AutomationLiveOutput 2026-06-26-00:00: Manual automation runs stream step, text, and tool activity into the Automations card while preserving the final run-result history after completion. -->
<!-- FNXC:Automations 2026-07-12-19:14: Schedule and routine AI-capable model selectors persist an optional Thinking Level on each step. Default/inherit stays empty, concrete off..xhigh values are stored with the JSON step configuration; runtime application is tracked separately. -->
Open **Automations** from the left sidebar (or the mobile More surfaces) to create cron, webhook, API, or manual routines. AI Prompt steps now run with all selectable coding tools by default: **Read**, **Bash**, **Edit**, **Write**, **Grep**, **Find**, and **Ls**. In the routine editor, use **Allowed tools** on a simple AI Prompt action or any multi-step AI Prompt step to clear or re-select tools. Leaving every tool selected stores the legacy default, so existing schedules continue to run with full tool access; clearing every box is an explicit no-tools configuration.
Open **Automations** from the left sidebar (or the mobile More surfaces) to create cron, webhook, API, or manual routines. AI Prompt steps now run with all selectable coding tools by default: **Read**, **Bash**, **Edit**, **Write**, **Grep**, **Find**, and **Ls**. In the routine editor, use **Allowed tools** on a simple AI Prompt action or any multi-step AI Prompt step to clear or re-select tools. Leaving every tool selected stores the legacy default, so existing schedules continue to run with full tool access; clearing every box is an explicit no-tools configuration. AI Prompt and Create Task action model selectors also include **Thinking Level**: leave it on **Default** to inherit the project setting, or choose a concrete reasoning effort to save it with that step.
When you choose **Run now**, the routine card opens a **Live output** panel while the manual run is active. The panel appends step status, AI text deltas, and tool start/finish activity as the run executes, then the card falls back to the persisted final run output and run history once the server records the result. The same `RoutineCard` surface is used by the floating modal and embedded Automations view, so live output appears in both presentations and collapses into a single-column card layout on mobile.

View File

@@ -843,6 +843,51 @@ describe("AutomationStore", () => {
expect(fetchedStep.timeoutMs).toBe(60000);
expect(fetchedStep.continueOnFailure).toBe(true);
});
it("round-trips and clears optional step thinkingLevel", async () => {
const inheritedStep = makeStep({
type: "ai-prompt",
name: "Inherited thinking",
prompt: "Use defaults",
command: undefined,
});
const explicitStep = makeStep({
type: "ai-prompt",
name: "Explicit thinking",
prompt: "Think harder",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
thinkingLevel: "high",
command: undefined,
});
const schedule = await store.createSchedule({
name: "Thinking schedule",
command: "",
scheduleType: "daily",
steps: [inheritedStep, explicitStep],
});
const fetched = await store.getSchedule(schedule.id);
expect(fetched.steps![0].thinkingLevel).toBeUndefined();
expect(fetched.steps![1].thinkingLevel).toBe("high");
const cleared = await store.updateSchedule(schedule.id, {
steps: [
{ ...inheritedStep },
{
...explicitStep,
thinkingLevel: undefined,
},
],
});
expect(cleared.steps![0].thinkingLevel).toBeUndefined();
expect(cleared.steps![1].thinkingLevel).toBeUndefined();
const refetched = await store.getSchedule(schedule.id);
expect(refetched.steps![0].thinkingLevel).toBeUndefined();
expect(refetched.steps![1].thinkingLevel).toBeUndefined();
});
});
// ── reorderSteps ──────────────────────────────────────────────────

View File

@@ -4,6 +4,7 @@ import { rm } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import type { AutomationStep } from "../automation.js";
import type {
Routine,
RoutineCreateInput,
@@ -371,6 +372,51 @@ describe("RoutineStore", () => {
await store.updateRoutine(routine.id, { name: "Updated" });
expect(listener).toHaveBeenCalledTimes(1);
});
it("round-trips and clears optional step thinkingLevel", async () => {
const inheritedStep: AutomationStep = {
id: "step-inherited",
type: "ai-prompt",
name: "Inherited thinking",
prompt: "Use defaults",
};
const explicitStep: AutomationStep = {
id: "step-explicit",
type: "ai-prompt",
name: "Explicit thinking",
prompt: "Think harder",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
thinkingLevel: "high",
};
const routine = await store.createRoutine({
name: "Thinking routine",
agentId: "test-agent",
trigger: { type: "manual" },
steps: [inheritedStep, explicitStep],
});
const fetched = await store.getRoutine(routine.id);
expect(fetched.steps![0].thinkingLevel).toBeUndefined();
expect(fetched.steps![1].thinkingLevel).toBe("high");
const cleared = await store.updateRoutine(routine.id, {
steps: [
{ ...inheritedStep },
{
...explicitStep,
thinkingLevel: undefined,
},
],
});
expect(cleared.steps![0].thinkingLevel).toBeUndefined();
expect(cleared.steps![1].thinkingLevel).toBeUndefined();
const refetched = await store.getRoutine(routine.id);
expect(refetched.steps![0].thinkingLevel).toBeUndefined();
expect(refetched.steps![1].thinkingLevel).toBeUndefined();
});
});
// ── deleteRoutine ───────────────────────────────────────────────

View File

@@ -1,3 +1,5 @@
import type { ThinkingLevel } from "./types.js";
/** Schedule type presets plus a custom cron option. */
export type ScheduleType = "hourly" | "daily" | "weekly" | "monthly" | "custom" | "every15Minutes" | "every30Minutes" | "every2Hours" | "every6Hours" | "every12Hours" | "weekdays";
@@ -47,6 +49,13 @@ export interface AutomationStep {
modelProvider?: string;
/** AI model ID (for ai-prompt steps). */
modelId?: string;
/**
* Optional reasoning-effort override for AI-capable steps.
*
* FNXC:Automations 2026-07-12-19:14:
* Schedule and routine AI-capable steps share this persisted override through AutomationStep. Undefined means inherit the resolved default thinking level, and the value rides the JSON steps blob so no DB migration is needed. Runtime application is intentionally deferred to a follow-up.
*/
thinkingLevel?: ThinkingLevel;
/**
* Optional tool allowlist for ai-prompt steps.
*

View File

@@ -129,6 +129,10 @@ function generateStepId(): string {
return `step-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
function normalizeThinkingLevel(value: string): AutomationStep["thinkingLevel"] {
return (value.trim() || undefined) as AutomationStep["thinkingLevel"];
}
type ActionMode = "simple" | "advanced";
type SimpleActionType = "command" | "ai-prompt" | "create-task";
@@ -224,6 +228,13 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
const [modelId, setModelId] = useState(
isSimpleAiPrompt || isSimpleCreateTask ? routine.steps?.[0]?.modelId ?? "" : ""
);
/*
FNXC:Automations 2026-07-12-19:14:
Simple routine AI-capable model selectors also capture an optional per-step thinking level. Empty string means inherit the default; concrete values persist on the AutomationStep JSON payload.
*/
const [thinkingLevel, setThinkingLevel] = useState(
isSimpleAiPrompt || isSimpleCreateTask ? routine.steps?.[0]?.thinkingLevel ?? "" : ""
);
const [models, setModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
@@ -323,7 +334,7 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
if (timeoutMs < 1000) e.timeoutMs = t("schedule.errorTimeoutMin", "Timeout must be at least 1 second (1000ms)");
setErrors(e);
return Object.keys(e).length === 0;
}, [name, triggerType, cronExpression, cronPreset, webhookPath, endpoint, localScope, projectId, actionMode, simpleActionType, command, prompt, taskDescription, modelProvider, modelId, steps, hasEditingSteps, timeoutMs]);
}, [name, triggerType, cronExpression, cronPreset, webhookPath, endpoint, localScope, projectId, actionMode, simpleActionType, command, prompt, taskDescription, modelProvider, modelId, thinkingLevel, steps, hasEditingSteps, timeoutMs]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
@@ -352,6 +363,7 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
prompt: prompt.trim(),
modelProvider: modelProvider.trim() || undefined,
modelId: modelId.trim() || undefined,
thinkingLevel: normalizeThinkingLevel(thinkingLevel),
}];
} else {
actionSteps = [{
@@ -363,6 +375,7 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
taskColumn,
modelProvider: modelProvider.trim() || undefined,
modelId: modelId.trim() || undefined,
thinkingLevel: normalizeThinkingLevel(thinkingLevel),
}];
}
} else {
@@ -388,7 +401,7 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
}
}
},
[validate, onSubmit, name, description, triggerType, cronExpression, webhookPath, webhookSecret, endpoint, actionMode, simpleActionType, command, prompt, modelProvider, modelId, taskTitle, taskDescription, taskColumn, steps, timeoutMs, executionPolicy, catchUpPolicy, enabled, localScope, projectId, routine?.scope, routine?.agentId],
[validate, onSubmit, name, description, triggerType, cronExpression, webhookPath, webhookSecret, endpoint, actionMode, simpleActionType, command, prompt, modelProvider, modelId, thinkingLevel, taskTitle, taskDescription, taskColumn, steps, timeoutMs, executionPolicy, catchUpPolicy, enabled, localScope, projectId, routine?.scope, routine?.agentId],
);
const nameErrorId = "routine-name-error";
@@ -684,7 +697,7 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
</div>
<div className="form-group">
<label htmlFor="routine-model">{t("schedule.modelLabel", "Model (optional)")}</label>
<CustomModelDropdown id="routine-model" label={t("schedule.modelDropdownLabel", "Model")} models={models} value={modelValue} onChange={handleModelChange} placeholder={t("schedule.modelPlaceholder", "Use default")} disabled={modelsLoading} />
<CustomModelDropdown id="routine-model" label={t("schedule.modelDropdownLabel", "Model")} models={models} value={modelValue} onChange={handleModelChange} placeholder={t("schedule.modelPlaceholder", "Use default")} disabled={modelsLoading} thinkingLevel={thinkingLevel} onThinkingLevelChange={setThinkingLevel} defaultThinkingLevel="off" showThinkingLevel />
{modelsError && <small className="field-error">{modelsError}</small>}
{errors.model && <small id={modelErrorId} className="field-error">{errors.model}</small>}
</div>
@@ -709,7 +722,7 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
</div>
<div className="form-group">
<label htmlFor="routine-task-model">{t("schedule.executorModelLabel", "Executor Model (optional)")}</label>
<CustomModelDropdown id="routine-task-model" label={t("schedule.executorModelDropdownLabel", "Executor Model")} models={models} value={modelValue} onChange={handleModelChange} placeholder={t("schedule.modelPlaceholder", "Use default")} disabled={modelsLoading} />
<CustomModelDropdown id="routine-task-model" label={t("schedule.executorModelDropdownLabel", "Executor Model")} models={models} value={modelValue} onChange={handleModelChange} placeholder={t("schedule.modelPlaceholder", "Use default")} disabled={modelsLoading} thinkingLevel={thinkingLevel} onThinkingLevelChange={setThinkingLevel} defaultThinkingLevel="off" showThinkingLevel />
{modelsError && <small className="field-error">{modelsError}</small>}
{errors.model && <small id={modelErrorId} className="field-error">{errors.model}</small>}
</div>

View File

@@ -68,6 +68,10 @@ function normalizeAllowedTools(selectedTools: string[]): string[] | undefined {
return selectedTools.length === ALL_AUTOMATION_TOOLS.length ? undefined : selectedTools;
}
function normalizeThinkingLevel(value: string): AutomationStep["thinkingLevel"] {
return (value.trim() || undefined) as AutomationStep["thinkingLevel"];
}
function resolveAllowedToolSelection(step?: AutomationStep): string[] {
return step?.allowedTools === undefined ? ALL_AUTOMATION_TOOLS : step.allowedTools;
}
@@ -158,6 +162,16 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
return "";
});
/*
FNXC:Automations 2026-07-12-19:14:
Simple schedule AI-capable model selectors also capture an optional per-step thinking level. Empty string means inherit the default; concrete values persist on the AutomationStep JSON payload.
*/
const [thinkingLevel, setThinkingLevel] = useState(() => {
if (schedule?.steps && schedule.steps.length === 1 && (schedule.steps[0].type === "ai-prompt" || schedule.steps[0].type === "create-task") && !schedule.command) {
return schedule.steps[0].thinkingLevel ?? "";
}
return "";
});
/*
FNXC:AutomationTools 2026-06-26-00:00:
Automation AI prompts default to every selectable coding tool for legacy schedules. Persist undefined for all-selected, but preserve an explicit empty array as the operator's no-tools choice.
*/
@@ -325,7 +339,7 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
}
setErrors(e);
return Object.keys(e).length === 0;
}, [name, command, prompt, modelProvider, modelId, mode, simpleType, steps, scheduleType, cronExpression, timeoutMs, hasEditingSteps, taskDescription, localScope]);
}, [name, command, prompt, modelProvider, modelId, thinkingLevel, mode, simpleType, steps, scheduleType, cronExpression, timeoutMs, hasEditingSteps, taskDescription, localScope]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
@@ -364,6 +378,7 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
prompt: prompt.trim(),
modelProvider: modelProvider.trim() || undefined,
modelId: modelId.trim() || undefined,
thinkingLevel: normalizeThinkingLevel(thinkingLevel),
allowedTools: normalizeAllowedTools(simpleAllowedTools),
};
submitData = {
@@ -388,6 +403,7 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
taskColumn: taskColumn,
modelProvider: modelProvider.trim() || undefined,
modelId: modelId.trim() || undefined,
thinkingLevel: normalizeThinkingLevel(thinkingLevel),
};
submitData = {
name: name.trim(),
@@ -420,7 +436,7 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
setSubmitting(false);
}
},
[validate, onSubmit, name, description, scheduleType, cronExpression, command, prompt, modelProvider, modelId, simpleAllowedTools, enabled, timeoutMs, mode, simpleType, steps, localScope, projectId, schedule?.scope, taskTitle, taskDescription, taskColumn],
[validate, onSubmit, name, description, scheduleType, cronExpression, command, prompt, modelProvider, modelId, thinkingLevel, simpleAllowedTools, enabled, timeoutMs, mode, simpleType, steps, localScope, projectId, schedule?.scope, taskTitle, taskDescription, taskColumn],
);
const cronFieldId = "schedule-cron";
@@ -663,6 +679,10 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
onChange={handleModelChange}
placeholder={t("schedule.modelPlaceholder", "Use default")}
disabled={modelsLoading}
thinkingLevel={thinkingLevel}
onThinkingLevelChange={setThinkingLevel}
defaultThinkingLevel="off"
showThinkingLevel
/>
{modelsError && <small className="field-error">{modelsError}</small>}
{errors.model ? (
@@ -754,6 +774,10 @@ export function ScheduleForm({ schedule, onSubmit, onCancel, scope: formScope, p
onChange={handleModelChange}
placeholder={t("schedule.executorModelPlaceholder", "Use default")}
disabled={modelsLoading}
thinkingLevel={thinkingLevel}
onThinkingLevelChange={setThinkingLevel}
defaultThinkingLevel="off"
showThinkingLevel
/>
{modelsError && <small className="field-error">{modelsError}</small>}
{errors.model ? (

View File

@@ -81,6 +81,11 @@ function StepEditor({ step, onSave, onCancel }: StepEditorProps) {
const [modelProvider, setModelProvider] = useState(step.modelProvider ?? "");
const [modelId, setModelId] = useState(step.modelId ?? "");
/*
FNXC:Automations 2026-07-12-19:14:
Multi-step schedule and routine AI-capable model selectors capture an optional per-step thinking level. Empty string means inherit the default; concrete values persist on the AutomationStep JSON payload.
*/
const [thinkingLevel, setThinkingLevel] = useState(step.thinkingLevel ?? "");
/*
FNXC:AutomationTools 2026-06-26-00:00:
Multi-step AI prompts share the simple form's default-all contract: undefined means every selectable coding tool, while [] intentionally removes all tools for the step.
*/
@@ -140,7 +145,7 @@ function StepEditor({ step, onSave, onCancel }: StepEditorProps) {
}
setErrors(e);
return Object.keys(e).length === 0;
}, [name, type, command, prompt, taskDescription, modelProvider, modelId, timeoutMs, t]);
}, [name, type, command, prompt, taskDescription, modelProvider, modelId, thinkingLevel, timeoutMs, t]);
// Compute combined model value from separate fields
const modelValue = (modelProvider && modelId) ? `${modelProvider}/${modelId}` : "";
@@ -174,6 +179,7 @@ function StepEditor({ step, onSave, onCancel }: StepEditorProps) {
taskColumn: type === "create-task" ? taskColumn : undefined,
modelProvider: (type === "ai-prompt" || type === "create-task") && modelProvider.trim() ? modelProvider.trim() : undefined,
modelId: (type === "ai-prompt" || type === "create-task") && modelId.trim() ? modelId.trim() : undefined,
thinkingLevel: (type === "ai-prompt" || type === "create-task") && thinkingLevel.trim() ? thinkingLevel.trim() : undefined,
allowedTools: type === "ai-prompt" ? normalizeAllowedTools(allowedTools) : undefined,
timeoutMs: timeoutMs || undefined,
continueOnFailure,
@@ -189,9 +195,12 @@ function StepEditor({ step, onSave, onCancel }: StepEditorProps) {
delete baseStep.taskDescription;
delete baseStep.taskColumn;
}
if (type !== "ai-prompt" && type !== "create-task") {
delete baseStep.thinkingLevel;
}
onSave(baseStep as AutomationStep);
}, [validate, onSave, step, name, type, command, prompt, taskTitle, taskDescription, taskColumn, modelProvider, modelId, allowedTools, timeoutMs, continueOnFailure]);
}, [validate, onSave, step, name, type, command, prompt, taskTitle, taskDescription, taskColumn, modelProvider, modelId, thinkingLevel, allowedTools, timeoutMs, continueOnFailure]);
return (
<div className="step-editor">
@@ -261,6 +270,10 @@ function StepEditor({ step, onSave, onCancel }: StepEditorProps) {
onChange={handleModelChange}
placeholder={t("schedule.useDefault", "Use default")}
disabled={modelsLoading}
thinkingLevel={thinkingLevel}
onThinkingLevelChange={setThinkingLevel}
defaultThinkingLevel="off"
showThinkingLevel
/>
{modelsError && <small className="field-error">{modelsError}</small>}
<small>{t("schedule.modelHelp", "AI model for this step. Uses default if not selected.")}</small>
@@ -347,6 +360,10 @@ function StepEditor({ step, onSave, onCancel }: StepEditorProps) {
onChange={handleModelChange}
placeholder={t("schedule.useDefault", "Use default")}
disabled={modelsLoading}
thinkingLevel={thinkingLevel}
onThinkingLevelChange={setThinkingLevel}
defaultThinkingLevel="off"
showThinkingLevel
/>
{modelsError && <small className="field-error">{modelsError}</small>}
{errors.modelProvider && <small className="field-error">{errors.modelProvider}</small>}

View File

@@ -15,11 +15,40 @@ vi.mock("lucide-react", () => ({
// Mock API
vi.mock("../api", () => ({
fetchModels: vi.fn(() => new Promise(() => {})),
fetchModels: vi.fn(() => Promise.resolve({
models: [
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet", reasoning: false, contextWindow: 200000 },
],
favoriteProviders: [],
favoriteModels: [],
})),
}));
// Mock @fusion/core
vi.mock("@fusion/core", () => ({}));
vi.mock("@fusion/core", () => ({
AUTOMATION_SELECTABLE_TOOLS: ["Read", "Bash", "Edit", "Write", "Grep", "Find", "Ls"],
}));
vi.mock("../CustomModelDropdown", () => ({
CustomModelDropdown: ({ id, value, onChange, disabled, models, showThinkingLevel, thinkingLevel, onThinkingLevelChange }: any) => (
<div data-testid={`${id}-mock`}>
<select data-testid="model-dropdown" value={value || ""} onChange={(e) => onChange(e.target.value)} disabled={disabled}>
<option value="">Use default</option>
{models?.map((m: any) => (
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>{m.name}</option>
))}
</select>
{showThinkingLevel && (
<select data-testid={`${id}-thinking-level`} aria-label={`${id} thinking level`} value={thinkingLevel || ""} onChange={(e) => onThinkingLevelChange?.(e.target.value)}>
<option value="">Default (off)</option>
<option value="off">Off</option>
<option value="high">High</option>
</select>
)}
</div>
),
}));
function makeRoutine(overrides: Partial<Routine> = {}): Routine {
return {
@@ -415,6 +444,72 @@ describe("RoutineEditor", () => {
});
});
it("omits thinkingLevel by default for simple AI Prompt routines", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Routine" } });
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
expect(screen.getByTestId("routine-model-thinking-level")).toBeDefined();
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize recent activity" } });
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: [expect.objectContaining({ type: "ai-prompt", thinkingLevel: undefined })],
}),
);
});
});
it("submits explicit thinkingLevel and shares it with simple Create Task routines", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Task Routine" } });
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
fireEvent.change(screen.getByTestId("routine-model-thinking-level"), { target: { value: "high" } });
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
expect(screen.getByTestId("routine-task-model-thinking-level")).toHaveValue("high");
fireEvent.change(screen.getByLabelText("Task Description"), { target: { value: "Review dependencies" } });
fireEvent.click(screen.getByText("Create Routine"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: [expect.objectContaining({ type: "create-task", thinkingLevel: "high" })],
}),
);
});
});
it("clears thinkingLevel when editing a simple AI Prompt routine", async () => {
const routine = makeRoutine({
command: undefined,
steps: [
{
id: "step-1",
type: "ai-prompt",
name: "AI Routine",
prompt: "Summarize recent activity",
thinkingLevel: "high",
},
],
});
render(<RoutineEditor routine={routine} onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByTestId("routine-model-thinking-level")).toHaveValue("high");
fireEvent.change(screen.getByTestId("routine-model-thinking-level"), { target: { value: "" } });
fireEvent.click(screen.getByText("Save Changes"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: [expect.objectContaining({ thinkingLevel: undefined })],
}),
);
});
});
it("submitting with a preset sends the correct cron expression", async () => {
render(<RoutineEditor onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Weekly Routine" } });

View File

@@ -42,20 +42,34 @@ vi.mock("../api", () => ({
// Mock CustomModelDropdown
vi.mock("../CustomModelDropdown", () => ({
CustomModelDropdown: ({ value, onChange, disabled, models }: any) => (
<select
data-testid="model-dropdown"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
>
<option value="">Use default</option>
{models?.map((m: any) => (
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
{m.name}
</option>
))}
</select>
CustomModelDropdown: ({ id, value, onChange, disabled, models, showThinkingLevel, thinkingLevel, onThinkingLevelChange }: any) => (
<div data-testid={`${id}-mock`}>
<select
data-testid="model-dropdown"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
>
<option value="">Use default</option>
{models?.map((m: any) => (
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
{m.name}
</option>
))}
</select>
{showThinkingLevel && (
<select
aria-label={`${id} thinking level`}
data-testid={`${id}-thinking-level`}
value={thinkingLevel || ""}
onChange={(e) => onThinkingLevelChange?.(e.target.value)}
>
<option value="">Default (off)</option>
<option value="off">Off</option>
<option value="high">High</option>
</select>
)}
</div>
),
}));
@@ -184,6 +198,80 @@ describe("ScheduleForm", () => {
});
});
it("omits thinkingLevel by default in simple AI Prompt mode", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } });
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
expect(screen.getByTestId("schedule-model-thinking-level")).toBeDefined();
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize recent commits" } });
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: [expect.objectContaining({ thinkingLevel: undefined })],
}),
);
});
});
it("submits explicit thinkingLevel from simple AI Prompt mode", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "AI Job" } });
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
fireEvent.change(screen.getByTestId("schedule-model-thinking-level"), { target: { value: "high" } });
fireEvent.change(screen.getByLabelText("Prompt"), { target: { value: "Summarize recent commits" } });
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: [expect.objectContaining({ thinkingLevel: "high" })],
}),
);
});
});
it("clears inherited thinkingLevel when editing a simple AI Prompt schedule", async () => {
const schedule = makeSchedule({
command: "",
steps: [
{
id: "step-1",
type: "ai-prompt",
name: "AI Job",
prompt: "Summarize recent commits",
thinkingLevel: "high",
},
],
});
render(<ScheduleForm schedule={schedule} onSubmit={onSubmit} onCancel={onCancel} />);
expect(screen.getByTestId("schedule-model-thinking-level")).toHaveValue("high");
fireEvent.change(screen.getByTestId("schedule-model-thinking-level"), { target: { value: "" } });
fireEvent.click(screen.getByText("Save Changes"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: [expect.objectContaining({ thinkingLevel: undefined })],
}),
);
});
});
it("shares thinkingLevel state between simple AI Prompt and Create Task selectors", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.click(screen.getByRole("radio", { name: "AI Prompt" }));
fireEvent.change(screen.getByTestId("schedule-model-thinking-level"), { target: { value: "high" } });
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
expect(screen.getByTestId("schedule-task-model-thinking-level")).toHaveValue("high");
});
it("shows all automation tools checked by default in simple AI Prompt mode", () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
@@ -990,6 +1078,27 @@ describe("ScheduleForm", () => {
expect(onSubmit).not.toHaveBeenCalled();
});
it("submits default and explicit thinkingLevel in simple Create Task mode", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Create Task Schedule" } });
fireEvent.click(screen.getByRole("radio", { name: "Create Task" }));
expect(screen.getByTestId("schedule-task-model-thinking-level")).toHaveValue("");
fireEvent.change(screen.getByTestId("schedule-task-model-thinking-level"), { target: { value: "high" } });
fireEvent.change(screen.getByLabelText("Task Description (required)"), {
target: { value: "Check npm dependencies for security vulnerabilities" },
});
fireEvent.click(screen.getByText("Create Schedule"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
steps: [expect.objectContaining({ type: "create-task", thinkingLevel: "high" })],
}),
);
});
});
it("submits single create-task step when simple mode uses Create Task", async () => {
render(<ScheduleForm onSubmit={onSubmit} onCancel={onCancel} />);

View File

@@ -45,20 +45,34 @@ vi.mock("../CustomModelDropdown", () => ({
// Store the last value for debugging
(window as any).__lastModelDropdownProps = props;
return (
<select
data-testid="model-dropdown"
value={props.value ?? ""}
onChange={(e) => props.onChange?.(e.target.value)}
disabled={props.disabled}
data-value={props.value}
>
<option value="">Use default</option>
{props.models?.map((m: any) => (
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
{m.name}
</option>
))}
</select>
<div data-testid={`${props.id}-mock`}>
<select
data-testid="model-dropdown"
value={props.value ?? ""}
onChange={(e) => props.onChange?.(e.target.value)}
disabled={props.disabled}
data-value={props.value}
>
<option value="">Use default</option>
{props.models?.map((m: any) => (
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
{m.name}
</option>
))}
</select>
{props.showThinkingLevel && (
<select
aria-label={`${props.id} thinking level`}
data-testid={`${props.id}-thinking-level`}
value={props.thinkingLevel || ""}
onChange={(e) => props.onThinkingLevelChange?.(e.target.value)}
>
<option value="">Default (off)</option>
<option value="off">Off</option>
<option value="high">High</option>
</select>
)}
</div>
);
},
}));
@@ -467,6 +481,33 @@ describe("ScheduleStepsEditor", () => {
// but we can verify the callback is properly wired
});
it("saves AI prompt step with default, explicit, and cleared thinkingLevel", async () => {
const steps = [makeStep({ id: "s1", name: "AI Step", type: "ai-prompt", prompt: "Test prompt" })];
const { unmount } = render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Edit AI Step"));
await waitFor(() => expect(screen.getByTestId("step-model-s1-thinking-level")).toBeDefined());
expect(screen.getByTestId("step-model-s1-thinking-level")).toHaveValue("");
fireEvent.click(screen.getByText("Save Step"));
expect(onChange).toHaveBeenLastCalledWith([expect.objectContaining({ thinkingLevel: undefined })]);
onChange.mockClear();
fireEvent.click(screen.getByLabelText("Edit AI Step"));
fireEvent.change(screen.getByTestId("step-model-s1-thinking-level"), { target: { value: "high" } });
fireEvent.click(screen.getByText("Save Step"));
expect(onChange).toHaveBeenLastCalledWith([expect.objectContaining({ thinkingLevel: "high" })]);
onChange.mockClear();
unmount();
const stepsWithThinking = [makeStep({ id: "s1", name: "AI Step", type: "ai-prompt", prompt: "Test prompt", thinkingLevel: "high" })];
render(<ScheduleStepsEditor steps={stepsWithThinking} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Edit AI Step"));
await waitFor(() => expect(screen.getByTestId("step-model-s1-thinking-level")).toHaveValue("high"));
fireEvent.change(screen.getByTestId("step-model-s1-thinking-level"), { target: { value: "" } });
fireEvent.click(screen.getByText("Save Step"));
expect(onChange).toHaveBeenLastCalledWith([expect.objectContaining({ thinkingLevel: undefined })]);
});
it("shows model dropdown for create-task step type", async () => {
const steps = [makeStep({ id: "s1", type: "create-task", name: "Create Task", taskDescription: "Test description" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
@@ -477,6 +518,20 @@ describe("ScheduleStepsEditor", () => {
await waitFor(() => expect(screen.getByTestId("model-dropdown")).toBeDefined());
expect(screen.getByTestId("model-dropdown")).toBeDefined();
expect(screen.getByTestId("step-task-model-s1-thinking-level")).toBeDefined();
});
it("saves create-task step with explicit thinkingLevel", async () => {
const steps = [makeStep({ id: "s1", type: "create-task", name: "Create Task", taskDescription: "Test description" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Edit Create Task"));
await waitFor(() => expect(screen.getByTestId("step-task-model-s1-thinking-level")).toBeDefined());
expect(screen.getByTestId("step-task-model-s1-thinking-level")).toHaveValue("");
fireEvent.change(screen.getByTestId("step-task-model-s1-thinking-level"), { target: { value: "high" } });
fireEvent.click(screen.getByText("Save Step"));
expect(onChange).toHaveBeenLastCalledWith([expect.objectContaining({ type: "create-task", thinkingLevel: "high" })]);
});
it("pre-populates model dropdown when editing create-task step with existing model", async () => {

View File

@@ -716,6 +716,84 @@ describe("Automation routes", () => {
expect(automationStore.createSchedule).toHaveBeenCalledTimes(1);
});
it("accepts and forwards valid step thinkingLevel for schedules", async () => {
const mockStore = createMockAutomationStore();
mockStore.createSchedule.mockResolvedValue({
...FAKE_SCHEDULE,
command: "",
steps: [
{
id: "step-ai",
type: "ai-prompt",
name: "AI",
prompt: "Summarize",
thinkingLevel: "high",
},
],
});
const { app, automationStore } = buildApp(mockStore);
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
name: "Test",
command: "",
scheduleType: "hourly",
steps: [
{
id: "step-ai",
type: "ai-prompt",
name: "AI",
prompt: "Summarize",
thinkingLevel: "high",
},
],
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(automationStore.createSchedule).toHaveBeenCalledWith(expect.objectContaining({
steps: [expect.objectContaining({ thinkingLevel: "high" })],
}));
expect(res.body.steps[0].thinkingLevel).toBe("high");
});
it("accepts schedule steps without thinkingLevel", async () => {
const { app, automationStore } = buildApp();
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
name: "Test",
command: "",
scheduleType: "hourly",
steps: [
{
id: "step-ai",
type: "ai-prompt",
name: "AI",
prompt: "Summarize",
},
],
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(automationStore.createSchedule).toHaveBeenCalledWith(expect.objectContaining({
steps: [expect.not.objectContaining({ thinkingLevel: expect.anything() })],
}));
});
it("returns 400 for invalid schedule step thinkingLevel", async () => {
const { app } = buildApp();
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
name: "Test",
command: "",
scheduleType: "hourly",
steps: [
{
id: "step-ai",
type: "ai-prompt",
name: "AI",
prompt: "Summarize",
thinkingLevel: "maximum",
},
],
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("thinkingLevel must be one of off, minimal, low, medium, high, xhigh");
});
it("returns 400 for missing name", async () => {
const { app } = buildApp();
const res = await REQUEST(app, "POST", "/api/automations", JSON.stringify({
@@ -1853,6 +1931,80 @@ describe("Routine routes", () => {
}));
});
it("accepts and forwards valid step thinkingLevel for routines", async () => {
const mockStore = createMockRoutineStore();
mockStore.createRoutine.mockResolvedValue({
...FAKE_ROUTINE,
steps: [
{
id: "routine-step-ai",
type: "ai-prompt",
name: "AI",
prompt: "Summarize",
thinkingLevel: "high",
},
],
});
const { app, routineStore } = buildRoutineApp(mockStore);
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
name: "Test",
trigger: { type: "manual" },
steps: [
{
id: "routine-step-ai",
type: "ai-prompt",
name: "AI",
prompt: "Summarize",
thinkingLevel: "high",
},
],
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(routineStore.createRoutine).toHaveBeenCalledWith(expect.objectContaining({
steps: [expect.objectContaining({ thinkingLevel: "high" })],
}));
expect(res.body.steps[0].thinkingLevel).toBe("high");
});
it("accepts routine steps without thinkingLevel", async () => {
const { app, routineStore } = buildRoutineApp();
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
name: "Test",
trigger: { type: "manual" },
steps: [
{
id: "routine-step-ai",
type: "ai-prompt",
name: "AI",
prompt: "Summarize",
},
],
}), { "Content-Type": "application/json" });
expect(res.status).toBe(201);
expect(routineStore.createRoutine).toHaveBeenCalledWith(expect.objectContaining({
steps: [expect.not.objectContaining({ thinkingLevel: expect.anything() })],
}));
});
it("returns 400 for invalid routine step thinkingLevel", async () => {
const { app } = buildRoutineApp();
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({
name: "Test",
trigger: { type: "manual" },
steps: [
{
id: "routine-step-ai",
type: "ai-prompt",
name: "AI",
prompt: "Summarize",
thinkingLevel: "maximum",
},
],
}), { "Content-Type": "application/json" });
expect(res.status).toBe(400);
expect(res.body.error).toContain("thinkingLevel must be one of off, minimal, low, medium, high, xhigh");
});
it("creates a routine with webhook trigger (requires secret)", async () => {
const { app, routineStore } = buildRoutineApp();
const res = await REQUEST(app, "POST", "/api/routines", JSON.stringify({

View File

@@ -20,6 +20,7 @@ import {
type PiExtensionSettings,
AutomationStore,
AUTOMATION_SELECTABLE_TOOLS,
THINKING_LEVELS,
MemoryBackendError,
RoutineStore,
discoverPiExtensions,
@@ -4979,6 +4980,15 @@ function validateAutomationSteps(steps: unknown[]): string | null {
if ((hasProvider && !hasModelId) || (!hasProvider && hasModelId)) {
return `Step ${i + 1}: modelProvider and modelId must both be present or both absent`;
}
/*
FNXC:Automations 2026-07-12-19:14:
Schedule and routine AI-capable steps can persist an optional reasoning-effort override. Validate it against the central THINKING_LEVELS set so routes accept omission/inherit plus known levels and reject drift before JSON step storage.
*/
if (step.thinkingLevel !== undefined) {
if (typeof step.thinkingLevel !== "string" || !THINKING_LEVELS.includes(step.thinkingLevel as (typeof THINKING_LEVELS)[number])) {
return `Step ${i + 1}: thinkingLevel must be one of ${THINKING_LEVELS.join(", ")}`;
}
}
}
return null;
}