Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import {
EnvironmentId,
ProjectId,
ProviderInstanceId,
ProviderDriverKind,
type ServerProvider,
type UnifiedSettings,
ScheduledTaskId,
ThreadId,
type ScheduledTask,
Expand All @@ -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";

Expand Down Expand Up @@ -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<ReadonlyArray<ServerProvider>>,
settings: null as unknown as UnifiedSettings,
upsert: vi.fn(async () => ({ _tag: "Success" as const })),
}));
const secondaryId = EnvironmentId.make("secondary");
const permissions = new Map<string, Atom.Writable<boolean>>();

function setup() {
state.connected = true;
state.tasksLoaded = true;
state.tasks = [task];
state.secondary = false;
state.providers = Atom.make<ReadonlyArray<ServerProvider>>([]);
state.settings = DEFAULT_UNIFIED_SETTINGS;
state.upsert.mockClear();
permissions.clear();
permissions.set(environmentId, Atom.make(true));
permissions.set(secondaryId, Atom.make(true));
Expand Down Expand Up @@ -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 ?? "")!,
Expand All @@ -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(),
Expand All @@ -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<ReadonlyArray<ServerProvider>>([
{
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(
<ScheduledTasksSettings {...(editing ? { taskId: task.id } : {})} />,
);
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(<ScheduledTasksSettings />);
await screen.rerender(<ScheduledTasksSettings taskId={task.id} />);
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(<ScheduledTasksSettings />);
await screen.rerender(<ScheduledTasksSettings taskId={task.id} />);
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;
Expand Down
60 changes: 34 additions & 26 deletions apps/web/src/components/settings/ScheduledTasksSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
import { type ReactNode, useRef, useState } from "react";
import type {
EnvironmentId,
ModelSelection,
OrchestrationV2ThreadLaunchWorkspaceStrategy,
ProjectId,
ScheduledTask,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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: "",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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" }
Expand All @@ -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) ??
Expand Down Expand Up @@ -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: "",
Expand Down Expand Up @@ -1583,7 +1568,7 @@ function ScheduledTaskEditorForm({
</Field>

<Field label="Model">
<div>
<div className="flex flex-wrap items-center gap-1.5">
<ProviderModelPicker
environmentId={environmentId}
disabled={saving || !connected}
Expand All @@ -1594,10 +1579,33 @@ function ScheduledTaskEditorForm({
modelOptionsByInstance={modelOptionsByInstance}
isComposerOwned={false}
triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
onInstanceModelChange={(instanceId, model) =>
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 ? (
<TraitsPicker
provider={activeEntry.driverKind}
models={activeEntry.models}
model={activeModel}
modelOptions={activeSelection.options ?? []}
allowPromptInjectedEffort={false}
planModeEnabled={settings.planModeEnabled}
disabled={saving || !connected}
triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
onModelOptionsChange={(options) =>
setDraft((current) => ({
...current,
modelSelection: createModelSelection(activeInstanceId, activeModel, options),
}))
}
/>
) : null}
</div>
</Field>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
Loading