From 635d78248b98c09ec761714c352d73f3decb9a4b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 12 Jul 2026 19:43:41 -0700 Subject: [PATCH] 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) --- .../fn-7900-automation-thinking-level.md | 7 + docs/dashboard-guide.md | 3 +- .../src/__tests__/automation-store.test.ts | 45 ++++++ .../core/src/__tests__/routine-store.test.ts | 46 ++++++ packages/core/src/automation.ts | 9 ++ .../app/components/RoutineEditor.tsx | 21 ++- .../dashboard/app/components/ScheduleForm.tsx | 28 +++- .../app/components/ScheduleStepsEditor.tsx | 21 ++- .../__tests__/RoutineEditor.test.tsx | 99 +++++++++++- .../__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(-) create mode 100644 .changeset/fn-7900-automation-thinking-level.md diff --git a/.changeset/fn-7900-automation-thinking-level.md b/.changeset/fn-7900-automation-thinking-level.md new file mode 100644 index 0000000000..4febeabf5d --- /dev/null +++ b/.changeset/fn-7900-automation-thinking-level.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 71c443fc78..6048ea847a 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -142,8 +142,9 @@ On mobile viewports, the Right Dock never renders. The compact Header actions an + -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. diff --git a/packages/core/src/__tests__/automation-store.test.ts b/packages/core/src/__tests__/automation-store.test.ts index af086c3fb9..a9ae75b0dd 100644 --- a/packages/core/src/__tests__/automation-store.test.ts +++ b/packages/core/src/__tests__/automation-store.test.ts @@ -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 ────────────────────────────────────────────────── diff --git a/packages/core/src/__tests__/routine-store.test.ts b/packages/core/src/__tests__/routine-store.test.ts index cb269ba28f..3b90b47df9 100644 --- a/packages/core/src/__tests__/routine-store.test.ts +++ b/packages/core/src/__tests__/routine-store.test.ts @@ -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 ─────────────────────────────────────────────── diff --git a/packages/core/src/automation.ts b/packages/core/src/automation.ts index 7f4548e76a..5817cccc26 100644 --- a/packages/core/src/automation.ts +++ b/packages/core/src/automation.ts @@ -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. * diff --git a/packages/dashboard/app/components/RoutineEditor.tsx b/packages/dashboard/app/components/RoutineEditor.tsx index 7caffe284d..3545c902d5 100644 --- a/packages/dashboard/app/components/RoutineEditor.tsx +++ b/packages/dashboard/app/components/RoutineEditor.tsx @@ -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([]); const [modelsLoading, setModelsLoading] = useState(false); const [modelsError, setModelsError] = useState(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
- + {modelsError && {modelsError}} {errors.model && {errors.model}}
@@ -709,7 +722,7 @@ export function RoutineEditor({ routine, onSubmit, onCancel, scope: formScope, p
- + {modelsError && {modelsError}} {errors.model && {errors.model}}
diff --git a/packages/dashboard/app/components/ScheduleForm.tsx b/packages/dashboard/app/components/ScheduleForm.tsx index df108bd280..441f215ddd 100644 --- a/packages/dashboard/app/components/ScheduleForm.tsx +++ b/packages/dashboard/app/components/ScheduleForm.tsx @@ -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 && {modelsError}} {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 && {modelsError}} {errors.model ? ( diff --git a/packages/dashboard/app/components/ScheduleStepsEditor.tsx b/packages/dashboard/app/components/ScheduleStepsEditor.tsx index 3bb2c15f11..30f8790dad 100644 --- a/packages/dashboard/app/components/ScheduleStepsEditor.tsx +++ b/packages/dashboard/app/components/ScheduleStepsEditor.tsx @@ -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 (
@@ -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 && {modelsError}} {t("schedule.modelHelp", "AI model for this step. Uses default if not selected.")} @@ -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 && {modelsError}} {errors.modelProvider && {errors.modelProvider}} diff --git a/packages/dashboard/app/components/__tests__/RoutineEditor.test.tsx b/packages/dashboard/app/components/__tests__/RoutineEditor.test.tsx index 65fae093cc..180212a673 100644 --- a/packages/dashboard/app/components/__tests__/RoutineEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/RoutineEditor.test.tsx @@ -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) => ( +
+ + {showThinkingLevel && ( + + )} +
+ ), +})); function makeRoutine(overrides: Partial = {}): Routine { return { @@ -415,6 +444,72 @@ describe("RoutineEditor", () => { }); }); + it("omits thinkingLevel by default for simple AI Prompt routines", async () => { + render(); + + 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(); + + 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(); + + 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(); fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Weekly Routine" } }); diff --git a/packages/dashboard/app/components/__tests__/ScheduleForm.test.tsx b/packages/dashboard/app/components/__tests__/ScheduleForm.test.tsx index 9bfb8bd0a1..e108f645fe 100644 --- a/packages/dashboard/app/components/__tests__/ScheduleForm.test.tsx +++ b/packages/dashboard/app/components/__tests__/ScheduleForm.test.tsx @@ -42,20 +42,34 @@ vi.mock("../api", () => ({ // Mock CustomModelDropdown vi.mock("../CustomModelDropdown", () => ({ - CustomModelDropdown: ({ value, onChange, disabled, models }: any) => ( - + CustomModelDropdown: ({ id, value, onChange, disabled, models, showThinkingLevel, thinkingLevel, onThinkingLevelChange }: any) => ( +
+ + {showThinkingLevel && ( + + )} +
), })); @@ -184,6 +198,80 @@ describe("ScheduleForm", () => { }); }); + it("omits thinkingLevel by default in simple AI Prompt mode", async () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); @@ -990,6 +1078,27 @@ describe("ScheduleForm", () => { expect(onSubmit).not.toHaveBeenCalled(); }); + it("submits default and explicit thinkingLevel in simple Create Task mode", async () => { + render(); + + 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(); diff --git a/packages/dashboard/app/components/__tests__/ScheduleStepsEditor.test.tsx b/packages/dashboard/app/components/__tests__/ScheduleStepsEditor.test.tsx index e03a4e0d2d..f5cc60cfce 100644 --- a/packages/dashboard/app/components/__tests__/ScheduleStepsEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/ScheduleStepsEditor.test.tsx @@ -45,20 +45,34 @@ vi.mock("../CustomModelDropdown", () => ({ // Store the last value for debugging (window as any).__lastModelDropdownProps = props; return ( - +
+ + {props.showThinkingLevel && ( + + )} +
); }, })); @@ -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(); + + 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(); + 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(); @@ -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(); + + 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 () => { diff --git a/packages/dashboard/src/__tests__/routes-automation.test.ts b/packages/dashboard/src/__tests__/routes-automation.test.ts index 2adcc74a9f..8b37efda3b 100644 --- a/packages/dashboard/src/__tests__/routes-automation.test.ts +++ b/packages/dashboard/src/__tests__/routes-automation.test.ts @@ -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({ diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 55120bfa7e..1f861297c6 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -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; }