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 #### 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 - **gateway:** `GET /api/threads/{thread_id}/runs/page` walks thread run history
with a `(created_at, run_id)` keyset cursor (`{data, has_more, 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` 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` - 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 - 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 - Duplicate an existing task into the create form as an editable draft without copying its run history
- Support `once` and `cron` schedules - Support `once` and `cron` schedules
- Run background scheduled executions as non-interactive DeerFlow runs (`ask_clarification` is not exposed there) - 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` 管理任务 - 在 `/workspace/scheduled-tasks` 管理任务
- 每个定时任务可以选择复用同一个 thread 及其历史对话,也可以选择每次运行新建一个 thread - 每个定时任务可以选择复用同一个 thread 及其历史对话,也可以选择每次运行新建一个 thread
- 每个任务可以固定使用 `lead_agent`(默认)或当前用户已有的自定义 agent未知名字会被拒绝
- 将现有任务复制到创建表单中作为可编辑草稿,不复制运行历史 - 将现有任务复制到创建表单中作为可编辑草稿,不复制运行历史
- 支持 `once``cron` 两种调度方式 - 支持 `once``cron` 两种调度方式
- 后台定时执行以非交互式 DeerFlow run 运行(那里不会暴露 `ask_clarification` - 后台定时执行以非交互式 DeerFlow run 运行(那里不会暴露 `ask_clarification`

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
@ -16,6 +17,7 @@ from app.gateway.deps import (
get_scheduled_task_service, get_scheduled_task_service,
get_thread_store, get_thread_store,
) )
from deerflow.config.agents_config import AGENT_NAME_PATTERN, load_agent_config
from deerflow.persistence.scheduled_tasks import ActiveScheduledTaskMutationConflict from deerflow.persistence.scheduled_tasks import ActiveScheduledTaskMutationConflict
from deerflow.scheduler.schedules import ( from deerflow.scheduler.schedules import (
next_run_at as compute_next_run_at, 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"]) router = APIRouter(prefix="/api", tags=["scheduled-tasks"])
_DEFAULT_ASSISTANT_ID = "lead_agent"
def _active_occurrence_conflict_detail(status: str) -> str: def _active_occurrence_conflict_detail(status: str) -> str:
detail = f"Scheduled task has an active {status} occurrence; retry after it finishes" 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 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: async def _ensure_task_mutable(task: dict[str, Any], repo) -> None:
if task.get("status") == "running": if task.get("status") == "running":
raise HTTPException( raise HTTPException(
@ -53,6 +87,7 @@ async def _ensure_task_mutable(task: dict[str, Any], repo) -> None:
class ScheduledTaskCreateRequest(BaseModel): class ScheduledTaskCreateRequest(BaseModel):
thread_id: ThreadId | None = None thread_id: ThreadId | None = None
context_mode: str = "fresh_thread_per_run" context_mode: str = "fresh_thread_per_run"
assistant_id: str | None = Field(default=None, min_length=1)
title: str = Field(min_length=1) title: str = Field(min_length=1)
prompt: str = Field(min_length=1) prompt: str = Field(min_length=1)
schedule_type: str schedule_type: str
@ -63,6 +98,7 @@ class ScheduledTaskCreateRequest(BaseModel):
class ScheduledTaskUpdateRequest(BaseModel): class ScheduledTaskUpdateRequest(BaseModel):
context_mode: str | None = None context_mode: str | None = None
thread_id: ThreadId | 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) title: str | None = Field(default=None, min_length=1)
prompt: str | None = Field(default=None, min_length=1) prompt: str | None = Field(default=None, min_length=1)
schedule_spec: dict[str, Any] | None = None 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"), 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( return await repo.create(
task_id=f"task-{uuid.uuid4().hex}", task_id=f"task-{uuid.uuid4().hex}",
user_id=str(user.id), user_id=str(user.id),
thread_id=body.thread_id, thread_id=body.thread_id,
context_mode=body.context_mode, context_mode=body.context_mode,
assistant_id="lead_agent", assistant_id=assistant_id,
title=body.title, title=body.title,
prompt=body.prompt, prompt=body.prompt,
schedule_type=body.schedule_type, 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) await _ensure_task_mutable(existing, repo)
updates = body.model_dump(exclude_none=True) 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 "context_mode" in updates:
if updates["context_mode"] not in {"fresh_thread_per_run", "reuse_thread"}: if updates["context_mode"] not in {"fresh_thread_per_run", "reuse_thread"}:
raise HTTPException(status_code=422, detail="Unsupported context_mode") 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. - **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. - 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. - 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`. - The MVP supports only `once` and `cron`.
- Manual trigger uses the same scheduled-task resource and run lifecycle. - Manual trigger uses the same scheduled-task resource and run lifecycle.
- Scheduled task definitions and task-run history are persisted in the application database. - Scheduled task definitions and task-run history are persisted in the application database.

View File

@ -1,7 +1,7 @@
import asyncio import asyncio
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock from unittest.mock import AsyncMock, patch
import pytest import pytest
from _router_auth_helpers import call_unwrapped from _router_auth_helpers import call_unwrapped
@ -73,6 +73,7 @@ class _Repo:
"user_id": kwargs["user_id"], "user_id": kwargs["user_id"],
"thread_id": kwargs["thread_id"], "thread_id": kwargs["thread_id"],
"context_mode": kwargs["context_mode"], "context_mode": kwargs["context_mode"],
"assistant_id": kwargs.get("assistant_id"),
"title": kwargs["title"], "title": kwargs["title"],
"prompt": kwargs["prompt"], "prompt": kwargs["prompt"],
"schedule_type": kwargs["schedule_type"], "schedule_type": kwargs["schedule_type"],
@ -202,6 +203,7 @@ async def test_create_scheduled_task_uses_repo():
assert created["title"] == "Daily summary" assert created["title"] == "Daily summary"
assert created["user_id"] == "user-1" 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) 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["status"] == "enabled"
assert result["next_run_at"] is not None 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"; "use client";
import { useQuery } from "@tanstack/react-query";
import { CopyIcon, TriangleAlertIcon } from "lucide-react"; import { CopyIcon, TriangleAlertIcon } from "lucide-react";
import { useSearchParams } from "next/navigation"; 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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -15,6 +16,13 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
ScheduledTaskScheduleInput, ScheduledTaskScheduleInput,
@ -25,6 +33,8 @@ import {
WorkspaceContainer, WorkspaceContainer,
WorkspaceHeader, WorkspaceHeader,
} from "@/components/workspace/workspace-container"; } from "@/components/workspace/workspace-container";
import { listAgents } from "@/core/agents/api";
import { useAgentsApiEnabled } from "@/core/agents/hooks";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
useCreateScheduledTask, useCreateScheduledTask,
@ -82,6 +92,18 @@ function formatTimestamp(value: string | null, locale: string): string {
}).format(date); }).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() { export default function ScheduledTasksPage() {
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const st = t.scheduledTasks; const st = t.scheduledTasks;
@ -89,6 +111,14 @@ export default function ScheduledTasksPage() {
const threadId = searchParams.get("thread_id"); const threadId = searchParams.get("thread_id");
const allTasksQuery = useScheduledTasks(); const allTasksQuery = useScheduledTasks();
const threadTasksQuery = useThreadScheduledTasks(threadId); 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 data = threadId ? threadTasksQuery.data : allTasksQuery.data;
const queryError = threadId ? threadTasksQuery.error : allTasksQuery.error; const queryError = threadId ? threadTasksQuery.error : allTasksQuery.error;
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
@ -99,6 +129,8 @@ export default function ScheduledTasksPage() {
const [targetThreadId, setTargetThreadId] = useState(threadId ?? ""); const [targetThreadId, setTargetThreadId] = useState(threadId ?? "");
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [prompt, setPrompt] = useState(""); const [prompt, setPrompt] = useState("");
const [createAssistantId, setCreateAssistantId] =
useState(DEFAULT_ASSISTANT_ID);
const [createSchedule, setCreateSchedule] = useState<ScheduleValue>({ const [createSchedule, setCreateSchedule] = useState<ScheduleValue>({
schedule_type: "cron", schedule_type: "cron",
schedule_spec: { cron: "0 9 * * *" }, schedule_spec: { cron: "0 9 * * *" },
@ -112,6 +144,7 @@ export default function ScheduledTasksPage() {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [editTitle, setEditTitle] = useState(""); const [editTitle, setEditTitle] = useState("");
const [editPrompt, setEditPrompt] = useState(""); const [editPrompt, setEditPrompt] = useState("");
const [editAssistantId, setEditAssistantId] = useState(DEFAULT_ASSISTANT_ID);
const [editSchedule, setEditSchedule] = useState<ScheduleValue>({ const [editSchedule, setEditSchedule] = useState<ScheduleValue>({
schedule_type: "cron", schedule_type: "cron",
schedule_spec: { cron: "0 9 * * *" }, schedule_spec: { cron: "0 9 * * *" },
@ -120,6 +153,30 @@ export default function ScheduledTasksPage() {
const [createNonce, setCreateNonce] = useState(0); const [createNonce, setCreateNonce] = useState(0);
const createFormRef = useRef<HTMLDivElement>(null); const createFormRef = useRef<HTMLDivElement>(null);
const createTitleRef = useRef<HTMLInputElement>(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 filteredData = (data ?? []).filter((task) => {
const statusPass = statusFilter === "all" || task.status === statusFilter; const statusPass = statusFilter === "all" || task.status === statusFilter;
const typePass = typeFilter === "all" || task.schedule_type === typeFilter; const typePass = typeFilter === "all" || task.schedule_type === typeFilter;
@ -170,6 +227,7 @@ export default function ScheduledTasksPage() {
setPrompt(task.prompt); setPrompt(task.prompt);
setContextMode(task.context_mode); setContextMode(task.context_mode);
setTargetThreadId(task.thread_id ?? ""); setTargetThreadId(task.thread_id ?? "");
setCreateAssistantId(task.assistant_id ?? DEFAULT_ASSISTANT_ID);
setCreateSchedule({ setCreateSchedule({
schedule_type: task.schedule_type, schedule_type: task.schedule_type,
schedule_spec: { ...task.schedule_spec }, schedule_spec: { ...task.schedule_spec },
@ -208,6 +266,7 @@ export default function ScheduledTasksPage() {
} }
setEditTitle(selectedTask.title); setEditTitle(selectedTask.title);
setEditPrompt(selectedTask.prompt); setEditPrompt(selectedTask.prompt);
setEditAssistantId(selectedTask.assistant_id ?? DEFAULT_ASSISTANT_ID);
const spec = selectedTask.schedule_spec as { const spec = selectedTask.schedule_spec as {
cron?: string; cron?: string;
run_at?: 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 <Input
ref={createTitleRef} ref={createTitleRef}
value={title} value={title}
@ -327,6 +405,7 @@ export default function ScheduledTasksPage() {
context_mode: contextMode, context_mode: contextMode,
thread_id: thread_id:
contextMode === "reuse_thread" ? targetThreadId : null, contextMode === "reuse_thread" ? targetThreadId : null,
assistant_id: createAssistantId,
title, title,
prompt, prompt,
schedule_type: createSchedule.schedule_type, schedule_type: createSchedule.schedule_type,
@ -339,6 +418,7 @@ export default function ScheduledTasksPage() {
setTitle(""); setTitle("");
setPrompt(""); setPrompt("");
setTargetThreadId(""); setTargetThreadId("");
setCreateAssistantId(DEFAULT_ASSISTANT_ID);
setContextMode("fresh_thread_per_run"); setContextMode("fresh_thread_per_run");
setCreateSchedule({ setCreateSchedule({
schedule_type: "cron", schedule_type: "cron",
@ -481,6 +561,13 @@ export default function ScheduledTasksPage() {
{st.detail.contextMode}:{" "} {st.detail.contextMode}:{" "}
{contextModeLabel(selectedTask.context_mode)} {contextModeLabel(selectedTask.context_mode)}
</div> </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"> <div className="text-muted-foreground text-sm">
{selectedTask.context_mode === "reuse_thread" {selectedTask.context_mode === "reuse_thread"
? `${st.detail.thread}: ${selectedTask.thread_id ?? NONE}` ? `${st.detail.thread}: ${selectedTask.thread_id ?? NONE}`
@ -523,6 +610,25 @@ export default function ScheduledTasksPage() {
onChange={(event) => setEditPrompt(event.target.value)} onChange={(event) => setEditPrompt(event.target.value)}
placeholder={st.edit.promptPlaceholder} 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 <ScheduledTaskScheduleInput
key={selectedTask.id} key={selectedTask.id}
initial={editSchedule} initial={editSchedule}
@ -531,14 +637,19 @@ export default function ScheduledTasksPage() {
/> />
<Button <Button
size="sm" size="sm"
onClick={() => onClick={() => {
const pinned =
selectedTask.assistant_id ?? DEFAULT_ASSISTANT_ID;
updateTask.mutate({ updateTask.mutate({
title: editTitle, title: editTitle,
prompt: editPrompt, prompt: editPrompt,
...(editAssistantId !== pinned
? { assistant_id: editAssistantId }
: {}),
schedule_spec: editSchedule.schedule_spec, schedule_spec: editSchedule.schedule_spec,
timezone: editSchedule.timezone || "UTC", timezone: editSchedule.timezone || "UTC",
}) });
} }}
disabled={updateTask.isPending} disabled={updateTask.isPending}
> >
{st.edit.submit} {st.edit.submit}

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@ export type ScheduledTask = {
id: string; id: string;
thread_id: string | null; thread_id: string | null;
context_mode: "fresh_thread_per_run" | "reuse_thread"; context_mode: "fresh_thread_per_run" | "reuse_thread";
assistant_id: string | null;
title: string; title: string;
prompt: string; prompt: string;
schedule_type: "once" | "cron"; 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, 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; id: string;
thread_id: string | null; thread_id: string | null;
context_mode?: "fresh_thread_per_run" | "reuse_thread"; context_mode?: "fresh_thread_per_run" | "reuse_thread";
assistant_id?: string | null;
last_thread_id?: string | null; last_thread_id?: string | null;
title: string; title: string;
prompt: string; prompt: string;
@ -484,6 +485,10 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
context_mode: context_mode:
(payload.context_mode as "fresh_thread_per_run" | "reuse_thread") ?? (payload.context_mode as "fresh_thread_per_run" | "reuse_thread") ??
"fresh_thread_per_run", "fresh_thread_per_run",
assistant_id:
typeof payload.assistant_id === "string"
? payload.assistant_id
: "lead_agent",
last_thread_id: null, last_thread_id: null,
title, title,
prompt, prompt,

View File

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