diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx index ca8192dd894e3..6d2541fd0e569 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx @@ -5,6 +5,9 @@ import { EnvironmentId, ProjectId, ProviderInstanceId, + ProviderDriverKind, + type ServerProvider, + type UnifiedSettings, ScheduledTaskId, ThreadId, type ScheduledTask, @@ -13,6 +16,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import "~/index.css"; +import { createModelCapabilities } from "@t3tools/shared/model"; import { act, render } from "../../../test/render"; import { ScheduledTasksSettings } from "./ScheduledTasksSettings"; @@ -47,14 +51,26 @@ const threadFamilyRead = { threads: [{ id: task.threadId, title: "Fix login bug" }], }; -const state = vi.hoisted(() => ({ connected: true, tasksLoaded: true, secondary: false })); +const state = vi.hoisted(() => ({ + connected: true, + tasksLoaded: true, + tasks: [] as ScheduledTask[], + secondary: false, + providers: null as unknown as Atom.Writable>, + settings: null as unknown as UnifiedSettings, + upsert: vi.fn(async () => ({ _tag: "Success" as const })), +})); const secondaryId = EnvironmentId.make("secondary"); const permissions = new Map>(); function setup() { state.connected = true; state.tasksLoaded = true; + state.tasks = [task]; state.secondary = false; + state.providers = Atom.make>([]); + state.settings = DEFAULT_UNIFIED_SETTINGS; + state.upsert.mockClear(); permissions.clear(); permissions.set(environmentId, Atom.make(true)); permissions.set(secondaryId, Atom.make(true)); @@ -104,6 +120,7 @@ vi.mock(import("../../state/server"), async (original) => { ...actual, serverEnvironment: { ...actual.serverEnvironment, + providersValueAtom: (() => state.providers) as never, upsertScheduledTask: { ...actual.serverEnvironment.upsertScheduledTask, permissionAtom: (id: string | null) => permissions.get(id ?? "")!, @@ -125,7 +142,7 @@ vi.mock(import("../../state/query"), async (original) => ({ data: request === null || request === threadFamilyRead ? request - : { tasks: state.tasksLoaded ? [task] : [] }, + : { tasks: state.tasksLoaded ? state.tasks : [] }, error: null, isPending: false, refresh: vi.fn(), @@ -142,10 +159,123 @@ vi.mock(import("../../state/entities"), async (original) => ({ })); vi.mock(import("../../hooks/useSettings"), async (original) => ({ ...(await original()), - useEnvironmentSettings: (() => DEFAULT_UNIFIED_SETTINGS) as never, + useEnvironmentSettings: (() => state.settings) as never, +})); + +vi.mock(import("../../state/use-atom-command"), async (original) => ({ + ...(await original()), + useAtomCommand: (() => state.upsert) as never, +})); +vi.mock(import("../../state/session"), async (original) => ({ + ...(await original()), + readEnvironmentScope: () => true, })); describe("ScheduledTasksSettings", () => { + it.each([false, true])( + "preserves and edits model options (existing task: %s)", + async (editing) => { + setup(); + const selection = { + ...task.modelSelection, + options: [{ id: "reasoningEffort", value: "high" }], + }; + state.tasks = [{ ...task, modelSelection: selection }]; + state.settings = { ...DEFAULT_UNIFIED_SETTINGS, defaultModelSelection: selection }; + state.providers = Atom.make>([ + { + instanceId: selection.instanceId, + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-11-03T00:00:00.000Z", + models: [ + { + slug: selection.model, + name: "GPT-5.4", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + { + id: "reasoningEffort", + label: "Reasoning effort", + type: "select", + options: [ + { id: "low", label: "Low", isDefault: true }, + { id: "high", label: "High" }, + ], + currentValue: "low", + }, + ], + }), + }, + { slug: "plain", name: "Plain model", isCustom: false, capabilities: null }, + ], + skills: [], + slashCommands: [], + }, + ]); + await using screen = await render( + , + ); + if (!editing) await screen.getByRole("button", { name: "New task" }).click(); + await userEvent.fill(page.getByRole("textbox", { name: "Name" }), "Effort task"); + await userEvent.fill( + page.getByRole("textbox", { name: "Prompt", exact: true }), + "Review issues", + ); + await page.getByRole("button", { name: "GPT-5.4", exact: true }).click(); + await page.getByRole("option", { name: /GPT-5.4/ }).click(); + expect(page.getByRole("button", { name: "High", exact: true }).query()).not.toBeNull(); + await page.getByRole("button", { name: editing ? "Save task" : "Create task" }).click(); + await vi.waitFor(() => + expect(state.upsert).toHaveBeenLastCalledWith({ + environmentId, + input: expect.objectContaining({ modelSelection: selection }), + }), + ); + + await screen.rerender(); + await screen.rerender(); + await userEvent.fill(page.getByRole("textbox", { name: "Name" }), "Low effort task"); + await userEvent.fill( + page.getByRole("textbox", { name: "Prompt", exact: true }), + "Review issues", + ); + await page.getByRole("button", { name: "High", exact: true }).click(); + await page.getByRole("menuitemradio", { name: /^Low/ }).click(); + await expect.element(page.getByRole("button", { name: "Low", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Save task" }).click(); + await vi.waitFor(() => + expect(state.upsert).toHaveBeenLastCalledWith({ + environmentId, + input: expect.objectContaining({ + modelSelection: { + ...selection, + options: expect.arrayContaining([{ id: "reasoningEffort", value: "low" }]), + }, + }), + }), + ); + await screen.rerender(); + await screen.rerender(); + await page.getByRole("button", { name: "GPT-5.4", exact: true }).click(); + await page.getByRole("option", { name: /Plain model/ }).click(); + expect(page.getByRole("button", { name: "High", exact: true }).query()).toBeNull(); + await page.getByRole("button", { name: "Save task" }).click(); + await vi.waitFor(() => + expect(state.upsert).toHaveBeenLastCalledWith({ + environmentId, + input: expect.objectContaining({ + modelSelection: { instanceId: selection.instanceId, model: "plain" }, + }), + }), + ); + }, + ); it("creates on a permitted secondary and reacts to permission changes", async () => { setup(); state.secondary = true; diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 2b8b37d4790e3..e595fb3238637 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -14,7 +14,6 @@ import { import { type ReactNode, useRef, useState } from "react"; import type { EnvironmentId, - ModelSelection, OrchestrationV2ThreadLaunchWorkspaceStrategy, ProjectId, ScheduledTask, @@ -101,6 +100,8 @@ import { Menu, MenuTrigger, MenuPopup, MenuItem, MenuSeparator } from "../ui/men import { ToggleGroup, Toggle } from "../ui/toggle-group"; import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyMedia } from "../ui/empty"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; +import { TraitsPicker } from "../chat/TraitsPicker"; +import { createModelSelection } from "@t3tools/shared/model"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { @@ -153,10 +154,9 @@ const EMPTY_DRAFT: DraftState = { baseRef: "main", startFromOrigin: true, existingWorktreePath: "", - modelKey: "", + modelSelection: null, runtimeMode: "full-access", interactionMode: "default", - baseModelSelection: null, signatureEnabled: false, ...WEBHOOK_SIGNATURE_DEFAULTS, signatureSecret: "", @@ -285,15 +285,6 @@ function Field({ ); } -function splitModelKey(value: string): ModelSelection | null { - const index = value.indexOf(":"); - if (index <= 0 || index === value.length - 1) return null; - return { - instanceId: ProviderInstanceId.make(value.slice(0, index)), - model: value.slice(index + 1), - }; -} - const ONCE_FORMATTER = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short", @@ -1248,7 +1239,7 @@ function ScheduledTaskEditorForm({ ); const firstInstance = instanceEntries[0]; - const draftSelection = draft.modelKey ? splitModelKey(draft.modelKey) : null; + const draftSelection = draft.modelSelection; const activeSelection = draftSelection && instanceEntries.some((entry) => entry.instanceId === draftSelection.instanceId && entry.enabled) @@ -1257,6 +1248,7 @@ function ScheduledTaskEditorForm({ const activeInstanceId = activeSelection?.instanceId ?? firstInstance?.instanceId ?? ("" as ProviderInstanceId); const activeModel = activeSelection?.model ?? ""; + const activeEntry = instanceEntries.find((entry) => entry.instanceId === activeInstanceId); const modelOptionsByInstance = getCustomModelOptionsByInstance( settings, providers, @@ -1332,12 +1324,6 @@ function ScheduledTaskEditorForm({ reportFailure("Checkout path is required", "Enter the path of the checkout to run in."); return; } - const modelSelection = - draft.baseModelSelection !== null && - draft.baseModelSelection.instanceId === selection.instanceId && - draft.baseModelSelection.model === selection.model - ? draft.baseModelSelection - : selection; const workspaceStrategy: OrchestrationV2ThreadLaunchWorkspaceStrategy = draft.workspaceMode === "root" ? { type: "root" } @@ -1357,7 +1343,7 @@ function ScheduledTaskEditorForm({ projectId: selectedProjectId as ProjectId, threadId: draft.threadId ? (draft.threadId as ThreadId) : null, workspaceStrategy, - modelSelection, + modelSelection: selection, // Tasks have no mode picker; a mode above the administrator's ceiling would fail every run. runtimeMode: clampManagedRuntimeMode(environment?.serverConfig?.managedPolicy, draft.runtimeMode) ?? @@ -1407,8 +1393,7 @@ function ScheduledTaskEditorForm({ setDraft((current) => ({ ...current, projectId: defaultScheduledTaskProjectId(projectsOn(next.environmentId), scope), - modelKey: "", - baseModelSelection: null, + modelSelection: null, baseRef: "main", startFromOrigin: true, existingWorktreePath: "", @@ -1583,7 +1568,7 @@ function ScheduledTaskEditorForm({ -
+
- setDraft((current) => ({ ...current, modelKey: `${instanceId}:${model}` })) - } + onInstanceModelChange={(instanceId, model) => { + if (instanceId === activeSelection?.instanceId && model === activeSelection.model) + return; + setDraft((current) => ({ + ...current, + modelSelection: createModelSelection(instanceId, model), + })); + }} /> + {activeSelection && activeEntry ? ( + + setDraft((current) => ({ + ...current, + modelSelection: createModelSelection(activeInstanceId, activeModel, options), + })) + } + /> + ) : null}
diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 9e0d0dad27cb9..77776aa2f7e23 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -74,11 +74,9 @@ export interface DraftState { readonly baseRef: string; readonly startFromOrigin: boolean; readonly existingWorktreePath: string; - readonly modelKey: string; + readonly modelSelection: ModelSelection | null; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; - /** Retain the source selection's provider options when the picker leaves instanceId:model unchanged. */ - readonly baseModelSelection: ModelSelection | null; readonly signatureEnabled: boolean; readonly signatureHeader: string; readonly signatureEncoding: "hex" | "base64"; @@ -264,10 +262,9 @@ export function taskToDraft(task: ScheduledTask): DraftState { task.workspaceStrategy.type === "existing_worktree" ? task.workspaceStrategy.worktreePath : "", - modelKey: `${task.modelSelection.instanceId}:${task.modelSelection.model}`, + modelSelection: task.modelSelection, runtimeMode: task.runtimeMode, interactionMode: task.interactionMode, - baseModelSelection: task.modelSelection, ...(schedule.type === "webhook" && schedule.signature !== null ? { signatureEnabled: true,