feat(scheduler): let scheduled tasks pin a custom agent (#5288)

* feat(scheduler): let scheduled tasks pin a custom agent

Create and update accept optional assistant_id, defaulting to lead_agent.
Custom names are normalized and must already exist for the task owner.
The workspace form exposes the same choice, and duplicate copies it.

Fixes #5286

* fix(scheduler): keep assistant-id PR free of interval tests

Drop the six interval tests that belonged to the interval schedule PR
and fail here because this tree still only accepts once/cron.

Treat lead_agent case-insensitively so LEAD_AGENT / lead-agent store
as the default. Omit unchanged assistant_id on edit so a deleted custom
agent does not 422 unrelated PATCH (rename, reschedule).

* fix(scheduler): format task page and browser tests

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
wutongyuonce 2026-09-10 15:36:49 +08:00 committed by GitHub
parent 8e86729aa0
commit 69f0f483eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 522 additions and 6 deletions

View File

@ -118,6 +118,10 @@ This section accumulates work toward the **2.1.0** milestone
#### Agents & runtime
- **scheduler:** Scheduled tasks can pin `assistant_id` to `lead_agent` (the
default) or a custom agent the owner already has. Unknown or malformed names
return 422. The workspace create/edit form exposes the same choice.
([#5286])
- **gateway:** `GET /api/threads/{thread_id}/runs/page` walks thread run history
with a `(created_at, run_id)` keyset cursor (`{data, has_more,
next_before_created_at, next_before_run_id}`). `GET /api/threads/{thread_id}/runs`

View File

@ -1533,6 +1533,7 @@ Current MVP capabilities:
- Manage tasks at `/workspace/scheduled-tasks`
- Choose whether each scheduled task reuses a thread and its conversation history or creates a fresh thread per run
- Pin each task to `lead_agent` (default) or a custom agent the owner already has; unknown names are rejected
- Duplicate an existing task into the create form as an editable draft without copying its run history
- Support `once` and `cron` schedules
- Run background scheduled executions as non-interactive DeerFlow runs (`ask_clarification` is not exposed there)

View File

@ -816,6 +816,7 @@ DeerFlow 现在在 workspace 里内置了一个一等的定时任务scheduled
- 在 `/workspace/scheduled-tasks` 管理任务
- 每个定时任务可以选择复用同一个 thread 及其历史对话,也可以选择每次运行新建一个 thread
- 每个任务可以固定使用 `lead_agent`(默认)或当前用户已有的自定义 agent未知名字会被拒绝
- 将现有任务复制到创建表单中作为可编辑草稿,不复制运行历史
- 支持 `once``cron` 两种调度方式
- 后台定时执行以非交互式 DeerFlow run 运行(那里不会暴露 `ask_clarification`

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import uuid
from datetime import UTC, datetime
from typing import Any
@ -16,6 +17,7 @@ from app.gateway.deps import (
get_scheduled_task_service,
get_thread_store,
)
from deerflow.config.agents_config import AGENT_NAME_PATTERN, load_agent_config
from deerflow.persistence.scheduled_tasks import ActiveScheduledTaskMutationConflict
from deerflow.scheduler.schedules import (
next_run_at as compute_next_run_at,
@ -28,6 +30,8 @@ from deerflow.utils.thread_id import ThreadId
router = APIRouter(prefix="/api", tags=["scheduled-tasks"])
_DEFAULT_ASSISTANT_ID = "lead_agent"
def _active_occurrence_conflict_detail(status: str) -> str:
detail = f"Scheduled task has an active {status} occurrence; retry after it finishes"
@ -36,6 +40,36 @@ def _active_occurrence_conflict_detail(status: str) -> str:
return detail
async def resolve_scheduled_task_assistant_id(raw: str | None, *, user_id: str) -> str:
"""Return a stored assistant id, defaulting to lead_agent.
Custom names are normalized the same way IM/run creation already does
(lowercase, underscore to hyphen) and must exist for this owner.
"""
if raw is None:
return _DEFAULT_ASSISTANT_ID
value = raw.strip()
if not value:
raise HTTPException(status_code=422, detail="assistant_id must not be empty")
normalized = value.lower().replace("_", "-")
if normalized == _DEFAULT_ASSISTANT_ID.replace("_", "-"):
return _DEFAULT_ASSISTANT_ID
if not AGENT_NAME_PATTERN.fullmatch(normalized):
raise HTTPException(
status_code=422,
detail=(f"Invalid assistant_id {raw!r}. Use 'lead_agent' or a custom agent name containing only letters, digits, and hyphens."),
)
try:
config = await asyncio.to_thread(load_agent_config, normalized, user_id=user_id)
except FileNotFoundError as exc:
raise HTTPException(status_code=422, detail=f"Unknown assistant_id {raw!r}") from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
if config is None:
raise HTTPException(status_code=422, detail=f"Unknown assistant_id {raw!r}")
return normalized
async def _ensure_task_mutable(task: dict[str, Any], repo) -> None:
if task.get("status") == "running":
raise HTTPException(
@ -53,6 +87,7 @@ async def _ensure_task_mutable(task: dict[str, Any], repo) -> None:
class ScheduledTaskCreateRequest(BaseModel):
thread_id: ThreadId | None = None
context_mode: str = "fresh_thread_per_run"
assistant_id: str | None = Field(default=None, min_length=1)
title: str = Field(min_length=1)
prompt: str = Field(min_length=1)
schedule_type: str
@ -63,6 +98,7 @@ class ScheduledTaskCreateRequest(BaseModel):
class ScheduledTaskUpdateRequest(BaseModel):
context_mode: str | None = None
thread_id: ThreadId | None = None
assistant_id: str | None = Field(default=None, min_length=1)
title: str | None = Field(default=None, min_length=1)
prompt: str | None = Field(default=None, min_length=1)
schedule_spec: dict[str, Any] | None = None
@ -124,12 +160,16 @@ async def create_scheduled_task(request: Request, body: ScheduledTaskCreateReque
detail=(f"once schedule must be at least {config.scheduler.min_once_delay_seconds} seconds in the future"),
)
assistant_id = await resolve_scheduled_task_assistant_id(
body.assistant_id,
user_id=str(user.id),
)
return await repo.create(
task_id=f"task-{uuid.uuid4().hex}",
user_id=str(user.id),
thread_id=body.thread_id,
context_mode=body.context_mode,
assistant_id="lead_agent",
assistant_id=assistant_id,
title=body.title,
prompt=body.prompt,
schedule_type=body.schedule_type,
@ -167,6 +207,11 @@ async def update_scheduled_task(task_id: str, request: Request, body: ScheduledT
await _ensure_task_mutable(existing, repo)
updates = body.model_dump(exclude_none=True)
if "assistant_id" in updates:
updates["assistant_id"] = await resolve_scheduled_task_assistant_id(
updates["assistant_id"],
user_id=str(user.id),
)
if "context_mode" in updates:
if updates["context_mode"] not in {"fresh_thread_per_run", "reuse_thread"}:
raise HTTPException(status_code=422, detail="Unsupported context_mode")

View File

@ -385,6 +385,7 @@ Notes:
- **Upgrade note:** `scheduler.multi_instance` and its related scheduler, ownership, and run-event settings are startup-only. Restart all Gateway Pods together after changing them; a ConfigMap update without a coordinated restart leaves the running service on its previous mode.
- Multi-worker deployments (`GATEWAY_WORKERS > 1`) must use the Postgres database backend, enable run ownership heartbeats, and set `run_events.backend: db`. SQLite silently ignores row-level locks, while memory and JSONL run-event stores are process-local and cannot enforce singleton delivery receipts across workers; startup rejects these combinations. The process-local agentic browser tool group is incompatible with multiple Gateway workers; keep `GATEWAY_WORKERS=1` while `browser_navigate` is enabled. Browser control also requires the backend `browser` extra (`cd backend && uv sync --extra browser && uv run playwright install chromium`); startup detects enabled browser config and fails fast when Playwright is missing, and `/api/features` reports `browser_control.enabled=false` until the runtime is available.
- The MVP supports thread reuse and fresh-thread-per-run execution modes.
- Create/update accept optional `assistant_id` (`lead_agent` by default, or an existing custom agent for the task owner).
- The MVP supports only `once` and `cron`.
- Manual trigger uses the same scheduled-task resource and run lifecycle.
- Scheduled task definitions and task-run history are persisted in the application database.

View File

@ -1,7 +1,7 @@
import asyncio
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, patch
import pytest
from _router_auth_helpers import call_unwrapped
@ -73,6 +73,7 @@ class _Repo:
"user_id": kwargs["user_id"],
"thread_id": kwargs["thread_id"],
"context_mode": kwargs["context_mode"],
"assistant_id": kwargs.get("assistant_id"),
"title": kwargs["title"],
"prompt": kwargs["prompt"],
"schedule_type": kwargs["schedule_type"],
@ -202,6 +203,7 @@ async def test_create_scheduled_task_uses_repo():
assert created["title"] == "Daily summary"
assert created["user_id"] == "user-1"
assert created["assistant_id"] == "lead_agent"
assert created["next_run_at"] == datetime(2027, 1, 1, 1, 0, tzinfo=UTC)
@ -940,3 +942,216 @@ async def test_update_terminal_once_task_with_future_run_at_rearms_it():
assert result["status"] == "enabled"
assert result["next_run_at"] is not None
def _create_request(**overrides):
kwargs = {
"title": "Daily summary",
"prompt": "Summarize thread",
"schedule_type": "cron",
"schedule_spec": {"cron": "0 9 * * *"},
"timezone": "UTC",
}
kwargs.update(overrides)
return scheduled_tasks.ScheduledTaskCreateRequest(**kwargs)
async def _call_create(body, repo=None):
repo = repo or _Repo()
user = SimpleNamespace(id="user-1")
thread_store = SimpleNamespace(check_access=AsyncMock(return_value=True))
old_repo = scheduled_tasks.get_scheduled_task_repo
old_thread_store = scheduled_tasks.get_thread_store
old_config = scheduled_tasks.get_config
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_thread_store = lambda _request: thread_store
scheduled_tasks.get_config = lambda: _Config()
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user)
return await call_unwrapped(
scheduled_tasks.create_scheduled_task,
request=SimpleNamespace(),
body=body,
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_repo
scheduled_tasks.get_thread_store = old_thread_store
scheduled_tasks.get_config = old_config
scheduled_tasks.get_optional_user_from_request = old_user
@pytest.mark.asyncio
async def test_create_explicit_lead_agent_is_accepted():
created = await _call_create(_create_request(assistant_id="lead_agent"))
assert created["assistant_id"] == "lead_agent"
@pytest.mark.asyncio
async def test_create_lead_agent_is_accepted_case_insensitively():
# Callers writing LEAD_AGENT / lead-agent mean the default, not a custom agent.
for raw in ("LEAD_AGENT", "Lead_Agent", "lead-agent"):
created = await _call_create(_create_request(assistant_id=raw))
assert created["assistant_id"] == "lead_agent"
@pytest.mark.asyncio
async def test_create_custom_assistant_id_is_normalized_and_persisted():
with patch(
"app.gateway.routers.scheduled_tasks.load_agent_config",
return_value=object(),
) as loader:
created = await _call_create(_create_request(assistant_id="Research_Bot"))
assert created["assistant_id"] == "research-bot"
loader.assert_called_once_with("research-bot", user_id="user-1")
@pytest.mark.asyncio
async def test_create_unknown_assistant_id_is_rejected():
with patch(
"app.gateway.routers.scheduled_tasks.load_agent_config",
side_effect=FileNotFoundError("missing"),
):
with pytest.raises(HTTPException) as exc_info:
await _call_create(_create_request(assistant_id="missing-bot"))
assert exc_info.value.status_code == 422
assert "Unknown assistant_id" in exc_info.value.detail
@pytest.mark.asyncio
async def test_create_invalid_assistant_id_is_rejected():
with pytest.raises(HTTPException) as exc_info:
await _call_create(_create_request(assistant_id="bad agent"))
assert exc_info.value.status_code == 422
assert "Invalid assistant_id" in exc_info.value.detail
@pytest.mark.asyncio
async def test_update_custom_assistant_id_is_persisted():
repo = _Repo()
task = await repo.create(
task_id="task-1",
user_id="user-1",
thread_id=None,
context_mode="fresh_thread_per_run",
assistant_id="lead_agent",
title="Daily summary",
prompt="Summarize thread",
schedule_type="cron",
schedule_spec={"cron": "0 9 * * *"},
timezone="UTC",
next_run_at=None,
)
old_repo = scheduled_tasks.get_scheduled_task_repo
old_config = scheduled_tasks.get_config
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_config = lambda: _Config()
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=SimpleNamespace(id="user-1"))
with patch(
"app.gateway.routers.scheduled_tasks.load_agent_config",
return_value=object(),
):
updated = await call_unwrapped(
scheduled_tasks.update_scheduled_task,
task_id=task["id"],
request=SimpleNamespace(),
body=scheduled_tasks.ScheduledTaskUpdateRequest(assistant_id="triage-bot"),
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_repo
scheduled_tasks.get_config = old_config
scheduled_tasks.get_optional_user_from_request = old_user
assert updated["assistant_id"] == "triage-bot"
async def _seed_task(repo: _Repo, **overrides):
kwargs = {
"task_id": "task-1",
"user_id": "user-1",
"thread_id": None,
"context_mode": "fresh_thread_per_run",
"assistant_id": "lead_agent",
"title": "Daily summary",
"prompt": "Summarize thread",
"schedule_type": "cron",
"schedule_spec": {"cron": "0 9 * * *"},
"timezone": "UTC",
"next_run_at": None,
}
kwargs.update(overrides)
return await repo.create(**kwargs)
async def _call_update(repo: _Repo, task_id: str, body):
old_repo = scheduled_tasks.get_scheduled_task_repo
old_config = scheduled_tasks.get_config
old_user = scheduled_tasks.get_optional_user_from_request
try:
scheduled_tasks.get_scheduled_task_repo = lambda _request: repo
scheduled_tasks.get_config = lambda: _Config()
scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=SimpleNamespace(id="user-1"))
return await call_unwrapped(
scheduled_tasks.update_scheduled_task,
task_id=task_id,
request=SimpleNamespace(),
body=body,
)
finally:
scheduled_tasks.get_scheduled_task_repo = old_repo
scheduled_tasks.get_config = old_config
scheduled_tasks.get_optional_user_from_request = old_user
@pytest.mark.asyncio
async def test_update_unknown_assistant_id_is_rejected():
repo = _Repo()
task = await _seed_task(repo)
with patch(
"app.gateway.routers.scheduled_tasks.load_agent_config",
side_effect=FileNotFoundError("missing"),
):
with pytest.raises(HTTPException) as exc_info:
await _call_update(
repo,
task["id"],
scheduled_tasks.ScheduledTaskUpdateRequest(assistant_id="missing-bot"),
)
assert exc_info.value.status_code == 422
assert "Unknown assistant_id" in exc_info.value.detail
assert repo.items[task["id"]]["assistant_id"] == "lead_agent"
@pytest.mark.asyncio
async def test_update_invalid_assistant_id_is_rejected():
repo = _Repo()
task = await _seed_task(repo)
with pytest.raises(HTTPException) as exc_info:
await _call_update(
repo,
task["id"],
scheduled_tasks.ScheduledTaskUpdateRequest(assistant_id="bad agent"),
)
assert exc_info.value.status_code == 422
assert "Invalid assistant_id" in exc_info.value.detail
@pytest.mark.asyncio
async def test_update_omitting_assistant_id_keeps_existing_even_if_agent_is_gone():
# Unrelated PATCH (rename, reschedule) must not re-resolve assistant_id.
# Otherwise a since-deleted custom agent makes the task uneditable.
repo = _Repo()
task = await _seed_task(repo, assistant_id="research-bot")
with patch(
"app.gateway.routers.scheduled_tasks.load_agent_config",
side_effect=FileNotFoundError("missing"),
) as loader:
updated = await _call_update(
repo,
task["id"],
scheduled_tasks.ScheduledTaskUpdateRequest(title="Renamed"),
)
loader.assert_not_called()
assert updated["title"] == "Renamed"
assert updated["assistant_id"] == "research-bot"

View File

@ -1,8 +1,9 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { CopyIcon, TriangleAlertIcon } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
@ -15,6 +16,13 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
ScheduledTaskScheduleInput,
@ -25,6 +33,8 @@ import {
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { listAgents } from "@/core/agents/api";
import { useAgentsApiEnabled } from "@/core/agents/hooks";
import { useI18n } from "@/core/i18n/hooks";
import {
useCreateScheduledTask,
@ -82,6 +92,18 @@ function formatTimestamp(value: string | null, locale: string): string {
}).format(date);
}
const DEFAULT_ASSISTANT_ID = "lead_agent";
function agentDisplayName(
assistantId: string | null | undefined,
leadLabel: string,
): string {
if (!assistantId || assistantId === DEFAULT_ASSISTANT_ID) {
return leadLabel;
}
return assistantId;
}
export default function ScheduledTasksPage() {
const { t, locale } = useI18n();
const st = t.scheduledTasks;
@ -89,6 +111,14 @@ export default function ScheduledTasksPage() {
const threadId = searchParams.get("thread_id");
const allTasksQuery = useScheduledTasks();
const threadTasksQuery = useThreadScheduledTasks(threadId);
const { enabled: agentsApiEnabled, isLoading: agentsApiLoading } =
useAgentsApiEnabled();
const agentsQuery = useQuery({
queryKey: ["agents"],
queryFn: listAgents,
enabled: !agentsApiLoading && agentsApiEnabled,
retry: false,
});
const data = threadId ? threadTasksQuery.data : allTasksQuery.data;
const queryError = threadId ? threadTasksQuery.error : allTasksQuery.error;
const [deleteOpen, setDeleteOpen] = useState(false);
@ -99,6 +129,8 @@ export default function ScheduledTasksPage() {
const [targetThreadId, setTargetThreadId] = useState(threadId ?? "");
const [title, setTitle] = useState("");
const [prompt, setPrompt] = useState("");
const [createAssistantId, setCreateAssistantId] =
useState(DEFAULT_ASSISTANT_ID);
const [createSchedule, setCreateSchedule] = useState<ScheduleValue>({
schedule_type: "cron",
schedule_spec: { cron: "0 9 * * *" },
@ -112,6 +144,7 @@ export default function ScheduledTasksPage() {
const [editing, setEditing] = useState(false);
const [editTitle, setEditTitle] = useState("");
const [editPrompt, setEditPrompt] = useState("");
const [editAssistantId, setEditAssistantId] = useState(DEFAULT_ASSISTANT_ID);
const [editSchedule, setEditSchedule] = useState<ScheduleValue>({
schedule_type: "cron",
schedule_spec: { cron: "0 9 * * *" },
@ -120,6 +153,30 @@ export default function ScheduledTasksPage() {
const [createNonce, setCreateNonce] = useState(0);
const createFormRef = useRef<HTMLDivElement>(null);
const createTitleRef = useRef<HTMLInputElement>(null);
const agentOptions = useMemo(() => {
const names = new Set((agentsQuery.data ?? []).map((agent) => agent.name));
const options = [
{
value: DEFAULT_ASSISTANT_ID,
label: st.create.leadAgent,
},
...(agentsQuery.data ?? [])
.filter((agent) => agent.name !== DEFAULT_ASSISTANT_ID)
.map((agent) => ({ value: agent.name, label: agent.name })),
];
for (const extra of [createAssistantId, editAssistantId]) {
if (extra && extra !== DEFAULT_ASSISTANT_ID && !names.has(extra)) {
options.push({ value: extra, label: extra });
names.add(extra);
}
}
return options;
}, [
agentsQuery.data,
createAssistantId,
editAssistantId,
st.create.leadAgent,
]);
const filteredData = (data ?? []).filter((task) => {
const statusPass = statusFilter === "all" || task.status === statusFilter;
const typePass = typeFilter === "all" || task.schedule_type === typeFilter;
@ -170,6 +227,7 @@ export default function ScheduledTasksPage() {
setPrompt(task.prompt);
setContextMode(task.context_mode);
setTargetThreadId(task.thread_id ?? "");
setCreateAssistantId(task.assistant_id ?? DEFAULT_ASSISTANT_ID);
setCreateSchedule({
schedule_type: task.schedule_type,
schedule_spec: { ...task.schedule_spec },
@ -208,6 +266,7 @@ export default function ScheduledTasksPage() {
}
setEditTitle(selectedTask.title);
setEditPrompt(selectedTask.prompt);
setEditAssistantId(selectedTask.assistant_id ?? DEFAULT_ASSISTANT_ID);
const spec = selectedTask.schedule_spec as {
cron?: string;
run_at?: string;
@ -287,6 +346,25 @@ export default function ScheduledTasksPage() {
/>
</>
)}
<Select
value={createAssistantId}
onValueChange={setCreateAssistantId}
>
<SelectTrigger
className="w-full"
data-testid="scheduled-task-create-agent"
aria-label={st.create.agent}
>
<SelectValue placeholder={st.create.agent} />
</SelectTrigger>
<SelectContent>
{agentOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
ref={createTitleRef}
value={title}
@ -327,6 +405,7 @@ export default function ScheduledTasksPage() {
context_mode: contextMode,
thread_id:
contextMode === "reuse_thread" ? targetThreadId : null,
assistant_id: createAssistantId,
title,
prompt,
schedule_type: createSchedule.schedule_type,
@ -339,6 +418,7 @@ export default function ScheduledTasksPage() {
setTitle("");
setPrompt("");
setTargetThreadId("");
setCreateAssistantId(DEFAULT_ASSISTANT_ID);
setContextMode("fresh_thread_per_run");
setCreateSchedule({
schedule_type: "cron",
@ -481,6 +561,13 @@ export default function ScheduledTasksPage() {
{st.detail.contextMode}:{" "}
{contextModeLabel(selectedTask.context_mode)}
</div>
<div className="text-muted-foreground text-sm">
{st.detail.agent}:{" "}
{agentDisplayName(
selectedTask.assistant_id,
st.create.leadAgent,
)}
</div>
<div className="text-muted-foreground text-sm">
{selectedTask.context_mode === "reuse_thread"
? `${st.detail.thread}: ${selectedTask.thread_id ?? NONE}`
@ -523,6 +610,25 @@ export default function ScheduledTasksPage() {
onChange={(event) => setEditPrompt(event.target.value)}
placeholder={st.edit.promptPlaceholder}
/>
<Select
value={editAssistantId}
onValueChange={setEditAssistantId}
>
<SelectTrigger
className="w-full"
data-testid="scheduled-task-edit-agent"
aria-label={st.create.agent}
>
<SelectValue placeholder={st.create.agent} />
</SelectTrigger>
<SelectContent>
{agentOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<ScheduledTaskScheduleInput
key={selectedTask.id}
initial={editSchedule}
@ -531,14 +637,19 @@ export default function ScheduledTasksPage() {
/>
<Button
size="sm"
onClick={() =>
onClick={() => {
const pinned =
selectedTask.assistant_id ?? DEFAULT_ASSISTANT_ID;
updateTask.mutate({
title: editTitle,
prompt: editPrompt,
...(editAssistantId !== pinned
? { assistant_id: editAssistantId }
: {}),
schedule_spec: editSchedule.schedule_spec,
timezone: editSchedule.timezone || "UTC",
})
}
});
}}
disabled={updateTask.isPending}
>
{st.edit.submit}

View File

@ -484,6 +484,8 @@ export const enUS: Translations = {
title: "Create scheduled task",
taskTitle: "Task title",
prompt: "Prompt",
agent: "Agent",
leadAgent: "Default agent (lead_agent)",
submit: "Create",
fillRequired: "Fill all required fields",
},
@ -507,6 +509,7 @@ export const enUS: Translations = {
},
detail: {
contextMode: "Context mode",
agent: "Agent",
thread: "Thread",
lastThread: "Last thread",
schedule: "Schedule",

View File

@ -394,6 +394,8 @@ export interface Translations {
title: string;
taskTitle: string;
prompt: string;
agent: string;
leadAgent: string;
submit: string;
fillRequired: string;
};
@ -416,6 +418,7 @@ export interface Translations {
};
detail: {
contextMode: string;
agent: string;
thread: string;
lastThread: string;
schedule: string;

View File

@ -459,6 +459,8 @@ export const zhCN: Translations = {
title: "创建定时任务",
taskTitle: "任务标题",
prompt: "提示词",
agent: "Agent",
leadAgent: "默认 Agentlead_agent",
submit: "创建",
fillRequired: "请填写所有必填项",
},
@ -482,6 +484,7 @@ export const zhCN: Translations = {
},
detail: {
contextMode: "上下文模式",
agent: "Agent",
thread: "线程",
lastThread: "上个线程",
schedule: "调度",

View File

@ -52,6 +52,7 @@ export async function fetchScheduledTaskRuns(
export type ScheduledTaskPayload = {
context_mode: "fresh_thread_per_run" | "reuse_thread";
thread_id?: string | null;
assistant_id?: string | null;
title: string;
prompt: string;
schedule_type: "once" | "cron";

View File

@ -2,6 +2,7 @@ export type ScheduledTask = {
id: string;
thread_id: string | null;
context_mode: "fresh_thread_per_run" | "reuse_thread";
assistant_id: string | null;
title: string;
prompt: string;
schedule_type: "once" | "cron";

View File

@ -337,3 +337,124 @@ test("detail pane falls back to a visible task after filters hide the selected t
0,
);
});
test("create posts the default lead_agent assistant_id", async ({ page }) => {
let createBody: Record<string, unknown> | null = null;
page.on("request", (request) => {
if (
request.method() === "POST" &&
new URL(request.url()).pathname.endsWith("/api/scheduled-tasks")
) {
createBody = request.postDataJSON() as Record<string, unknown>;
}
});
mockLangGraphAPI(page, { threads: [], scheduledTasks: [] });
await page.goto("/workspace/scheduled-tasks");
const createForm = page.getByTestId("scheduled-task-create-form");
await expect(
createForm.getByTestId("scheduled-task-create-agent"),
).toContainText(/Default agent \(lead_agent\)/i);
await createForm.getByRole("button", { name: "One-time" }).click();
await createForm.getByLabel("Run at").fill("2026-07-02T09:00");
await createForm.getByPlaceholder("Task title").fill("Agent pin");
await createForm.getByPlaceholder("Prompt").fill("Summarize thread");
await createForm.getByRole("button", { name: "Create" }).click();
await expect(page.getByRole("button", { name: /Agent pin/i })).toBeVisible();
expect(createBody).toMatchObject({ assistant_id: "lead_agent" });
await expect(page.getByTestId("scheduled-task-detail")).toContainText(
/Default agent \(lead_agent\)/i,
);
});
test("duplicate copies the source task assistant into the create form", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [],
scheduledTasks: [
{
id: "task-copy-agent",
thread_id: null,
context_mode: "fresh_thread_per_run",
assistant_id: "research-bot",
title: "Research digest",
prompt: "Summarize papers",
schedule_type: "cron",
schedule_spec: { cron: "0 9 * * *" },
timezone: "UTC",
status: "enabled",
next_run_at: "2026-07-02T01:00:00+00:00",
last_run_at: null,
last_run_id: null,
last_error: null,
run_count: 0,
created_at: "2026-07-01T00:00:00+00:00",
updated_at: "2026-07-01T00:00:00+00:00",
},
],
});
await page.goto("/workspace/scheduled-tasks");
await expect(page.getByTestId("scheduled-task-detail")).toContainText(
"research-bot",
);
await page
.getByTestId("scheduled-task-detail")
.getByRole("button", { name: "Duplicate" })
.click();
await expect(page.getByTestId("scheduled-task-create-agent")).toContainText(
"research-bot",
);
});
test("edit omits assistant_id when the agent is unchanged", async ({
page,
}) => {
let patchBody: Record<string, unknown> | null = null;
page.on("request", (request) => {
if (
request.method() === "PATCH" &&
new URL(request.url()).pathname.includes("/api/scheduled-tasks/")
) {
patchBody = request.postDataJSON() as Record<string, unknown>;
}
});
mockLangGraphAPI(page, {
threads: [],
scheduledTasks: [
{
id: "task-edit-agent",
thread_id: null,
context_mode: "fresh_thread_per_run",
assistant_id: "research-bot",
title: "Research digest",
prompt: "Summarize papers",
schedule_type: "cron",
schedule_spec: { cron: "0 9 * * *" },
timezone: "UTC",
status: "enabled",
next_run_at: "2026-07-02T01:00:00+00:00",
last_run_at: null,
last_run_id: null,
last_error: null,
run_count: 0,
created_at: "2026-07-01T00:00:00+00:00",
updated_at: "2026-07-01T00:00:00+00:00",
},
],
});
await page.goto("/workspace/scheduled-tasks");
await page
.getByTestId("scheduled-task-detail")
.getByRole("button", { name: "Edit" })
.click();
await page.getByPlaceholder("Edit title").fill("Renamed digest");
await page.getByRole("button", { name: "Save edit" }).click();
await expect(page.getByTestId("scheduled-task-detail")).toContainText(
"Renamed digest",
);
expect(patchBody).toMatchObject({ title: "Renamed digest" });
expect(patchBody).not.toHaveProperty("assistant_id");
});

View File

@ -71,6 +71,7 @@ export type MockAPIOptions = {
id: string;
thread_id: string | null;
context_mode?: "fresh_thread_per_run" | "reuse_thread";
assistant_id?: string | null;
last_thread_id?: string | null;
title: string;
prompt: string;
@ -484,6 +485,10 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
context_mode:
(payload.context_mode as "fresh_thread_per_run" | "reuse_thread") ??
"fresh_thread_per_run",
assistant_id:
typeof payload.assistant_id === "string"
? payload.assistant_id
: "lead_agent",
last_thread_id: null,
title,
prompt,

View File

@ -80,6 +80,7 @@ describe("scheduled tasks api", () => {
const payload: ScheduledTaskPayload = {
context_mode: "fresh_thread_per_run",
thread_id: null,
assistant_id: "research-bot",
title: "Daily summary",
prompt: "Summarize thread",
schedule_type: "cron",