mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-10 14:08:52 +00:00
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:
parent
8e86729aa0
commit
69f0f483eb
@ -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`
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -816,6 +816,7 @@ DeerFlow 现在在 workspace 里内置了一个一等的定时任务(scheduled
|
||||
|
||||
- 在 `/workspace/scheduled-tasks` 管理任务
|
||||
- 每个定时任务可以选择复用同一个 thread 及其历史对话,也可以选择每次运行新建一个 thread
|
||||
- 每个任务可以固定使用 `lead_agent`(默认)或当前用户已有的自定义 agent;未知名字会被拒绝
|
||||
- 将现有任务复制到创建表单中作为可编辑草稿,不复制运行历史
|
||||
- 支持 `once` 和 `cron` 两种调度方式
|
||||
- 后台定时执行以非交互式 DeerFlow run 运行(那里不会暴露 `ask_clarification`)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -459,6 +459,8 @@ export const zhCN: Translations = {
|
||||
title: "创建定时任务",
|
||||
taskTitle: "任务标题",
|
||||
prompt: "提示词",
|
||||
agent: "Agent",
|
||||
leadAgent: "默认 Agent(lead_agent)",
|
||||
submit: "创建",
|
||||
fillRequired: "请填写所有必填项",
|
||||
},
|
||||
@ -482,6 +484,7 @@ export const zhCN: Translations = {
|
||||
},
|
||||
detail: {
|
||||
contextMode: "上下文模式",
|
||||
agent: "Agent",
|
||||
thread: "线程",
|
||||
lastThread: "上个线程",
|
||||
schedule: "调度",
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user