From c791c815e1632dc77ee0202887ad67f270fd5d24 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:32:57 +0800 Subject: [PATCH] perf: merge 3 cron tools into 1 cron manage tool, and add edit capability for cron tool. (#7445) * perf: replace cron tools with FutureTaskTool for improved task management * feat: enhance FutureTaskTool with edit functionality and improve descriptions * feat: add edit functionality for cron jobs and update related UI components --- astrbot/core/astr_main_agent.py | 10 +- astrbot/core/tools/cron_tools.py | 302 ++++++++++-------- astrbot/dashboard/routes/cron.py | 137 +++++++- .../src/i18n/locales/en-US/features/cron.json | 4 + .../src/i18n/locales/ru-RU/features/cron.json | 4 + .../src/i18n/locales/zh-CN/features/cron.json | 4 + dashboard/src/views/CronJobPage.vue | 171 ++++++++-- tests/unit/test_astr_main_agent.py | 29 +- tests/unit/test_cron_tools.py | 121 ++++++- 9 files changed, 583 insertions(+), 199 deletions(-) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 75f5d30e2..ebb95baa4 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -59,11 +59,7 @@ from astrbot.core.provider.entities import ProviderRequest from astrbot.core.skills.skill_manager import SkillManager, build_skills_prompt from astrbot.core.star.context import Context from astrbot.core.star.star_handler import star_map -from astrbot.core.tools.cron_tools import ( - CreateActiveCronTool, - DeleteCronJobTool, - ListCronJobsTool, -) +from astrbot.core.tools.cron_tools import FutureTaskTool from astrbot.core.tools.knowledge_base_tools import ( KnowledgeBaseQueryTool, retrieve_knowledge_base, @@ -1064,9 +1060,7 @@ def _proactive_cron_job_tools(req: ProviderRequest, plugin_context: Context) -> if req.func_tool is None: req.func_tool = ToolSet() tool_mgr = plugin_context.get_llm_tool_manager() - req.func_tool.add_tool(tool_mgr.get_builtin_tool(CreateActiveCronTool)) - req.func_tool.add_tool(tool_mgr.get_builtin_tool(DeleteCronJobTool)) - req.func_tool.add_tool(tool_mgr.get_builtin_tool(ListCronJobsTool)) + req.func_tool.add_tool(tool_mgr.get_builtin_tool(FutureTaskTool)) async def _apply_web_search_tools( diff --git a/astrbot/core/tools/cron_tools.py b/astrbot/core/tools/cron_tools.py index 599957e0a..ccc93172d 100644 --- a/astrbot/core/tools/cron_tools.py +++ b/astrbot/core/tools/cron_tools.py @@ -18,40 +18,58 @@ def _extract_job_session(job: Any) -> str | None: return str(session) if session is not None else None +def _parse_run_at(run_at: Any) -> datetime | None: + if run_at in (None, ""): + return None + return datetime.fromisoformat(str(run_at)) + + @builtin_tool @dataclass -class CreateActiveCronTool(FunctionTool[AstrAgentContext]): - name: str = "create_future_task" +class FutureTaskTool(FunctionTool[AstrAgentContext]): + name: str = "future_task" description: str = ( - "Create a future task for your future. Supports recurring cron expressions or one-time run_at datetime. " - "Use this when you or the user want scheduled follow-up or proactive actions." + "Manage your future tasks. " + "Use action='create' to schedule a recurring cron task or one-time run_at task. " + "Use action='edit' to update an existing task. " + "Use action='list' to inspect existing tasks. " + "Use action='delete' to remove a task by job_id." ) parameters: dict = Field( default_factory=lambda: { "type": "object", "properties": { + "action": { + "type": "string", + "enum": ["create", "edit", "delete", "list"], + "description": "Action to perform. 'list' takes no parameters. 'delete' requires only 'job_id'. 'edit' requires 'job_id' plus the fields to change.", + }, + "name": { + "type": "string", + "description": "Optional task label.", + }, "cron_expression": { "type": "string", - "description": "Cron expression defining recurring schedule (e.g., '0 8 * * *' or '0 23 * * mon-fri'). Prefer named weekdays like 'mon-fri' or 'sat,sun' instead of numeric day-of-week ranges such as '1-5' to avoid ambiguity across cron implementations.", - }, - "run_at": { - "type": "string", - "description": "ISO datetime for one-time execution, e.g., 2026-02-02T08:00:00+08:00. Use with run_once=true.", + "description": "Cron expression for a recurring schedule, e.g. '0 8 * * *' or '0 23 * * mon-fri'. Prefer named weekdays like 'mon-fri' or 'sat,sun' over numeric ranges like '1-5'.", }, "note": { "type": "string", "description": "Detailed instructions for your future agent to execute when it wakes.", }, - "name": { - "type": "string", - "description": "Optional label to recognize this future task.", - }, "run_once": { "type": "boolean", - "description": "If true, the task will run only once and then be deleted. Use run_at to specify the time.", + "description": "Run only once and delete after execution. Use with run_at.", + }, + "run_at": { + "type": "string", + "description": "ISO datetime for one-time execution, e.g. 2026-02-02T08:00:00+08:00.", + }, + "job_id": { + "type": "string", + "description": "Task ID. Required for 'delete' and 'edit'.", }, }, - "required": ["note"], + "required": ["action"], } ) @@ -62,130 +80,152 @@ class CreateActiveCronTool(FunctionTool[AstrAgentContext]): if cron_mgr is None: return "error: cron manager is not available." - cron_expression = kwargs.get("cron_expression") - run_at = kwargs.get("run_at") - run_once = bool(kwargs.get("run_once", False)) - note = str(kwargs.get("note", "")).strip() - name = str(kwargs.get("name") or "").strip() or "active_agent_task" + action = str(kwargs.get("action") or "").strip().lower() + if action == "create": + cron_expression = kwargs.get("cron_expression") + run_at = kwargs.get("run_at") + run_once = bool(kwargs.get("run_once", False)) + note = str(kwargs.get("note", "")).strip() + name = str(kwargs.get("name") or "").strip() or "active_agent_task" - if not note: - return "error: note is required." - if run_once and not run_at: - return "error: run_at is required when run_once=true." - if (not run_once) and not cron_expression: - return "error: cron_expression is required when run_once=false." - if run_once and cron_expression: - cron_expression = None - run_at_dt = None - if run_at: + if not note: + return "error: note is required when action=create." + if run_once and not run_at: + return "error: run_at is required when run_once=true." + if (not run_once) and not cron_expression: + return "error: cron_expression is required when run_once=false." + if run_once and cron_expression: + cron_expression = None try: - run_at_dt = datetime.fromisoformat(str(run_at)) + run_at_dt = _parse_run_at(run_at) except Exception: return "error: run_at must be ISO datetime, e.g., 2026-02-02T08:00:00+08:00" - payload = { - "session": context.context.event.unified_msg_origin, - "sender_id": context.context.event.get_sender_id(), - "note": note, - "origin": "tool", - } + payload = { + "session": context.context.event.unified_msg_origin, + "sender_id": context.context.event.get_sender_id(), + "note": note, + "origin": "tool", + } - job = await cron_mgr.add_active_job( - name=name, - cron_expression=str(cron_expression) if cron_expression else None, - payload=payload, - description=note, - run_once=run_once, - run_at=run_at_dt, - ) - next_run = job.next_run_time or run_at_dt - suffix = ( - f"one-time at {next_run}" - if run_once - else f"expression '{cron_expression}' (next {next_run})" - ) - return f"Scheduled future task {job.job_id} ({job.name}) {suffix}." - - -@builtin_tool -@dataclass -class DeleteCronJobTool(FunctionTool[AstrAgentContext]): - name: str = "delete_future_task" - description: str = "Delete a future task (cron job) by its job_id." - parameters: dict = Field( - default_factory=lambda: { - "type": "object", - "properties": { - "job_id": { - "type": "string", - "description": "The job_id returned when the job was created.", - } - }, - "required": ["job_id"], - } - ) - - async def call( - self, context: ContextWrapper[AstrAgentContext], **kwargs - ) -> ToolExecResult: - cron_mgr = context.context.context.cron_manager - if cron_mgr is None: - return "error: cron manager is not available." - current_umo = context.context.event.unified_msg_origin - job_id = kwargs.get("job_id") - if not job_id: - return "error: job_id is required." - job = await cron_mgr.db.get_cron_job(str(job_id)) - if not job: - return f"error: cron job {job_id} not found." - if _extract_job_session(job) != current_umo: - return "error: you can only delete future tasks in the current umo." - await cron_mgr.delete_job(str(job_id)) - return f"Deleted cron job {job_id}." - - -@builtin_tool -@dataclass -class ListCronJobsTool(FunctionTool[AstrAgentContext]): - name: str = "list_future_tasks" - description: str = "List existing future tasks (cron jobs) for inspection." - parameters: dict = Field( - default_factory=lambda: { - "type": "object", - "properties": { - "job_type": { - "type": "string", - "description": "Optional filter: basic or active_agent.", - } - }, - } - ) - - async def call( - self, context: ContextWrapper[AstrAgentContext], **kwargs - ) -> ToolExecResult: - cron_mgr = context.context.context.cron_manager - if cron_mgr is None: - return "error: cron manager is not available." - current_umo = context.context.event.unified_msg_origin - job_type = kwargs.get("job_type") - jobs = [ - job - for job in await cron_mgr.list_jobs(job_type) - if _extract_job_session(job) == current_umo - ] - if not jobs: - return "No cron jobs found." - lines = [] - for j in jobs: - lines.append( - f"{j.job_id} | {j.name} | {j.job_type} | run_once={getattr(j, 'run_once', False)} | enabled={j.enabled} | next={j.next_run_time}" + job = await cron_mgr.add_active_job( + name=name, + cron_expression=str(cron_expression) if cron_expression else None, + payload=payload, + description=note, + run_once=run_once, + run_at=run_at_dt, ) - return "\n".join(lines) + next_run = job.next_run_time or run_at_dt + suffix = ( + f"one-time at {next_run}" + if run_once + else f"expression '{cron_expression}' (next {next_run})" + ) + return f"Scheduled future task {job.job_id} ({job.name}) {suffix}." + + current_umo = context.context.event.unified_msg_origin + if action == "edit": + job_id = kwargs.get("job_id") + if not job_id: + return "error: job_id is required when action=edit." + if not any( + key in kwargs + for key in ("name", "note", "run_once", "cron_expression", "run_at") + ): + return "error: no editable fields were provided." + + job = await cron_mgr.db.get_cron_job(str(job_id)) + if not job: + return f"error: cron job {job_id} not found." + if _extract_job_session(job) != current_umo: + return "error: you can only edit future tasks in the current umo." + + payload = dict(job.payload) if isinstance(job.payload, dict) else {} + + updates: dict[str, Any] = {} + if "name" in kwargs: + name = str(kwargs.get("name") or "").strip() + if not name: + return "error: name cannot be empty when action=edit." + updates["name"] = name + + if "note" in kwargs: + note = str(kwargs.get("note") or "").strip() + if not note: + return "error: note cannot be empty when action=edit." + payload["note"] = note + updates["description"] = note + + current_run_at = payload.get("run_at") + run_once = ( + bool(kwargs["run_once"]) if "run_once" in kwargs else bool(job.run_once) + ) + cron_expression = ( + str(kwargs.get("cron_expression") or "").strip() + if "cron_expression" in kwargs + else job.cron_expression + ) + cron_expression = cron_expression or None + + try: + run_at_dt = ( + _parse_run_at(kwargs.get("run_at")) + if "run_at" in kwargs + else _parse_run_at(current_run_at) + ) + except Exception: + return "error: run_at must be ISO datetime, e.g., 2026-02-02T08:00:00+08:00" + + if run_once: + if run_at_dt is None: + return "error: run_at is required when run_once=true." + cron_expression = None + payload["run_at"] = run_at_dt.isoformat() + else: + if not cron_expression: + return "error: cron_expression is required when run_once=false." + payload.pop("run_at", None) + + updates["run_once"] = run_once + updates["cron_expression"] = cron_expression + updates["payload"] = payload + + job = await cron_mgr.update_job(str(job_id), **updates) + if not job: + return f"error: cron job {job_id} not found." + return f"Updated future task {job.job_id} ({job.name})." + + if action == "delete": + job_id = kwargs.get("job_id") + if not job_id: + return "error: job_id is required when action=delete." + job = await cron_mgr.db.get_cron_job(str(job_id)) + if not job: + return f"error: cron job {job_id} not found." + if _extract_job_session(job) != current_umo: + return "error: you can only delete future tasks in the current umo." + await cron_mgr.delete_job(str(job_id)) + return f"Deleted cron job {job_id}." + + if action == "list": + jobs = [ + job + for job in await cron_mgr.list_jobs() + if _extract_job_session(job) == current_umo + ] + if not jobs: + return "No cron jobs found." + lines = [] + for j in jobs: + lines.append( + f"{j.job_id} | {j.name} | {j.job_type} | run_once={getattr(j, 'run_once', False)} | enabled={j.enabled} | next={j.next_run_time}" + ) + return "\n".join(lines) + + return "error: action must be one of create, edit, delete, or list." __all__ = [ - "CreateActiveCronTool", - "DeleteCronJobTool", - "ListCronJobsTool", + "FutureTaskTool", ] diff --git a/astrbot/dashboard/routes/cron.py b/astrbot/dashboard/routes/cron.py index 8861fc5cc..af26c6525 100644 --- a/astrbot/dashboard/routes/cron.py +++ b/astrbot/dashboard/routes/cron.py @@ -135,22 +135,127 @@ class CronRoute(Route): if not isinstance(payload, dict): return jsonify(Response().error("Invalid payload").__dict__) - updates = { - "name": payload.get("name"), - "cron_expression": payload.get("cron_expression"), - "description": payload.get("description"), - "enabled": payload.get("enabled"), - "timezone": payload.get("timezone"), - "run_once": payload.get("run_once"), - "payload": payload.get("payload"), - } - # remove None values to avoid unwanted resets - updates = {k: v for k, v in updates.items() if v is not None} - if "run_at" in payload: - updates.setdefault("payload", {}) - if updates["payload"] is None: - updates["payload"] = {} - updates["payload"]["run_at"] = payload.get("run_at") + job = await cron_mgr.db.get_cron_job(job_id) + if not job: + return jsonify(Response().error("Job not found").__dict__) + + updates = {} + if "name" in payload: + name = str(payload.get("name") or "").strip() + if not name: + return jsonify(Response().error("name cannot be empty").__dict__) + updates["name"] = name + + if "enabled" in payload: + updates["enabled"] = bool(payload.get("enabled")) + + if "timezone" in payload: + timezone = payload.get("timezone") + updates["timezone"] = str(timezone).strip() or None + + next_run_once = ( + bool(payload.get("run_once")) + if "run_once" in payload + else bool(job.run_once) + ) + + if job.job_type == "active_agent": + merged_payload = ( + dict(job.payload) if isinstance(job.payload, dict) else {} + ) + if "payload" in payload and isinstance(payload.get("payload"), dict): + merged_payload.update(payload["payload"]) + + if "session" in payload: + session = str(payload.get("session") or "").strip() + if not session: + return jsonify( + Response().error("session cannot be empty").__dict__ + ) + merged_payload["session"] = session + + note_updated = False + if "note" in payload: + note = str(payload.get("note") or "").strip() + if not note: + return jsonify( + Response().error("note cannot be empty").__dict__ + ) + merged_payload["note"] = note + updates["description"] = note + note_updated = True + elif "description" in payload: + description = str(payload.get("description") or "").strip() + if not description: + return jsonify( + Response().error("description cannot be empty").__dict__ + ) + updates["description"] = description + merged_payload["note"] = description + note_updated = True + + if not note_updated and updates.get("description") is None: + existing_note = str( + merged_payload.get("note") or job.description or "" + ).strip() + if existing_note: + merged_payload["note"] = existing_note + + next_cron_expression = ( + payload.get("cron_expression") + if "cron_expression" in payload + else job.cron_expression + ) + if next_cron_expression is not None: + next_cron_expression = str(next_cron_expression).strip() or None + + run_at_raw = ( + payload.get("run_at") + if "run_at" in payload + else merged_payload.get("run_at") + ) + run_at_iso = None + if run_at_raw: + try: + run_at_iso = datetime.fromisoformat(str(run_at_raw)).isoformat() + except Exception: + return jsonify( + Response().error("run_at must be ISO datetime").__dict__ + ) + + if next_run_once: + if not run_at_iso: + return jsonify( + Response() + .error("run_at is required when run_once=true") + .__dict__ + ) + next_cron_expression = None + merged_payload["run_at"] = run_at_iso + else: + if not next_cron_expression: + return jsonify( + Response() + .error("cron_expression is required when run_once=false") + .__dict__ + ) + merged_payload.pop("run_at", None) + + updates["run_once"] = next_run_once + updates["cron_expression"] = next_cron_expression + updates["payload"] = merged_payload + else: + if "cron_expression" in payload: + cron_expression = str(payload.get("cron_expression") or "").strip() + if not cron_expression: + return jsonify( + Response().error("cron_expression cannot be empty").__dict__ + ) + updates["cron_expression"] = cron_expression + + if "description" in payload: + description = str(payload.get("description") or "").strip() + updates["description"] = description or None job = await cron_mgr.update_job(job_id, **updates) if not job: diff --git a/dashboard/src/i18n/locales/en-US/features/cron.json b/dashboard/src/i18n/locales/en-US/features/cron.json index 6635a6fbe..a3bafd4d6 100644 --- a/dashboard/src/i18n/locales/en-US/features/cron.json +++ b/dashboard/src/i18n/locales/en-US/features/cron.json @@ -14,9 +14,11 @@ }, "actions": { "create": "New Task", + "edit": "Edit", "refresh": "Refresh", "delete": "Delete", "cancel": "Cancel", + "save": "Save", "submit": "Create" }, "overview": { @@ -77,6 +79,7 @@ }, "form": { "title": "New Task", + "editTitle": "Edit Task", "chatHint": "You can ask AstrBot in chat to create future tasks instead of adding them here.", "runOnce": "One-off task", "name": "Task name", @@ -90,6 +93,7 @@ }, "messages": { "loadFailed": "Failed to load tasks", + "updateSuccess": "Updated successfully", "updateFailed": "Failed to update", "deleteSuccess": "Deleted", "deleteFailed": "Failed to delete", diff --git a/dashboard/src/i18n/locales/ru-RU/features/cron.json b/dashboard/src/i18n/locales/ru-RU/features/cron.json index a7d36864f..76b875ad1 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/cron.json +++ b/dashboard/src/i18n/locales/ru-RU/features/cron.json @@ -14,9 +14,11 @@ }, "actions": { "create": "Новая задача", + "edit": "Изменить", "refresh": "Обновить", "delete": "Удалить", "cancel": "Отмена", + "save": "Сохранить", "submit": "Создать" }, "overview": { @@ -77,6 +79,7 @@ }, "form": { "title": "Создать задачу", + "editTitle": "Редактировать задачу", "chatHint": "Вы можете ставить задачи прямо в чате, AstrBot создаст их автоматически без заполнения этой формы.", "runOnce": "Разовая задача", "name": "Имя задачи", @@ -90,6 +93,7 @@ }, "messages": { "loadFailed": "Ошибка загрузки задач", + "updateSuccess": "Задача обновлена", "updateFailed": "Ошибка обновления", "deleteSuccess": "Удалено", "deleteFailed": "Ошибка удаления", diff --git a/dashboard/src/i18n/locales/zh-CN/features/cron.json b/dashboard/src/i18n/locales/zh-CN/features/cron.json index 5e8b4b572..2345efe98 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/cron.json +++ b/dashboard/src/i18n/locales/zh-CN/features/cron.json @@ -14,9 +14,11 @@ }, "actions": { "create": "新建", + "edit": "编辑", "refresh": "刷新", "delete": "删除", "cancel": "取消", + "save": "保存", "submit": "创建" }, "overview": { @@ -77,6 +79,7 @@ }, "form": { "title": "新建任务", + "editTitle": "编辑任务", "chatHint": "你可以直接通过聊天的方式来让 AstrBot 创建未来任务,而不必在此添加。", "runOnce": "一次性任务", "name": "任务名称", @@ -90,6 +93,7 @@ }, "messages": { "loadFailed": "获取任务失败", + "updateSuccess": "更新成功", "updateFailed": "更新失败", "deleteSuccess": "已删除", "deleteFailed": "删除失败", diff --git a/dashboard/src/views/CronJobPage.vue b/dashboard/src/views/CronJobPage.vue index 0684be722..dbc8f410e 100644 --- a/dashboard/src/views/CronJobPage.vue +++ b/dashboard/src/views/CronJobPage.vue @@ -136,18 +136,31 @@
- - - {{ tm('actions.delete') }} - +
+ +
+
+ + {{ tm('actions.edit') }} + + + {{ tm('actions.delete') }} + +
@@ -162,7 +175,7 @@ - {{ tm('form.title') }} + {{ dialogTitle }} {{ tm('form.chatHint') }} @@ -206,8 +219,8 @@ {{ tm('actions.cancel') }} - - {{ tm('actions.submit') }} + + {{ dialogSubmitText }} @@ -231,6 +244,7 @@ const jobs = ref([]) const proactivePlatforms = ref<{ id: string; name: string; display_name?: string }[]>([]) const createDialog = ref(false) const creating = ref(false) +const editingJobId = ref('') const newJob = ref({ run_once: false, name: '', @@ -286,6 +300,10 @@ const overviewCards = computed(() => [ } ]) +const isEditing = computed(() => !!editingJobId.value) +const dialogTitle = computed(() => tm(isEditing.value ? 'form.editTitle' : 'form.title')) +const dialogSubmitText = computed(() => tm(isEditing.value ? 'actions.save' : 'actions.submit')) + function toast(message: string, color: 'success' | 'error' | 'warning' = 'success') { snackbar.value = { show: true, message, color } } @@ -394,10 +412,26 @@ async function deleteJob(job: any) { } function openCreate() { + editingJobId.value = '' resetNewJob() createDialog.value = true } +function toDatetimeLocalValue(value: any): string { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '' + const offset = date.getTimezoneOffset() + const local = new Date(date.getTime() - offset * 60_000) + return local.toISOString().slice(0, 16) +} + +function toIsoDatetime(value: string): string { + if (!value) return '' + const date = new Date(value) + return Number.isNaN(date.getTime()) ? value : date.toISOString() +} + function resetNewJob() { newJob.value = { run_once: false, @@ -411,6 +445,21 @@ function resetNewJob() { } } +function openEdit(job: any) { + editingJobId.value = job.job_id + newJob.value = { + run_once: !!job.run_once, + name: job.name || '', + note: job.note || job.description || '', + cron_expression: job.cron_expression || '', + run_at: toDatetimeLocalValue(job.run_at), + session: job.session || job?.payload?.session || '', + timezone: job.timezone || '', + enabled: job.enabled !== false + } + createDialog.value = true +} + async function createJob() { if (!newJob.value.session) { toast(tm('messages.sessionRequired'), 'warning') @@ -431,10 +480,15 @@ async function createJob() { creating.value = true try { - const res = await axios.post('/api/cron/jobs', { ...newJob.value }) + const payload = { + ...newJob.value, + run_at: newJob.value.run_once ? toIsoDatetime(newJob.value.run_at) : '' + } + const res = await axios.post('/api/cron/jobs', payload) if (res.data.status === 'ok') { toast(tm('messages.createSuccess')) createDialog.value = false + editingJobId.value = '' resetNewJob() await loadJobs() } else { @@ -447,6 +501,59 @@ async function createJob() { } } +async function updateJob() { + if (!editingJobId.value) { + return + } + if (!newJob.value.session) { + toast(tm('messages.sessionRequired'), 'warning') + return + } + if (!newJob.value.note) { + toast(tm('messages.noteRequired'), 'warning') + return + } + if (!newJob.value.run_once && !newJob.value.cron_expression) { + toast(tm('messages.cronRequired'), 'warning') + return + } + if (newJob.value.run_once && !newJob.value.run_at) { + toast(tm('messages.runAtRequired'), 'warning') + return + } + + creating.value = true + try { + const payload = { + ...newJob.value, + run_at: newJob.value.run_once ? toIsoDatetime(newJob.value.run_at) : '', + description: newJob.value.note + } + const res = await axios.patch(`/api/cron/jobs/${editingJobId.value}`, payload) + if (res.data.status === 'ok') { + toast(tm('messages.updateSuccess')) + createDialog.value = false + editingJobId.value = '' + resetNewJob() + await loadJobs() + } else { + toast(res.data.message || tm('messages.updateFailed'), 'error') + } + } catch (e: any) { + toast(e?.response?.data?.message || tm('messages.updateFailed'), 'error') + } finally { + creating.value = false + } +} + +async function submitJob() { + if (isEditing.value) { + await updateJob() + return + } + await createJob() +} + onMounted(() => { loadJobs() loadPlatforms() @@ -513,7 +620,7 @@ onMounted(() => { } .task-table .col-actions { - width: 170px; + width: 220px; } .task-table th { @@ -571,15 +678,34 @@ onMounted(() => { } .actions-col { - width: 170px; + width: 220px; } .table-actions { + display: grid; + gap: 10px; + justify-items: start; + min-width: 190px; +} + +.table-actions-toggle { display: flex; align-items: center; - justify-content: flex-end; +} + +.table-actions-switch { + flex: 0 0 auto; +} + +.cron-page :deep(.table-actions-switch .v-selection-control) { + min-width: auto; +} + +.table-actions-buttons { + display: flex; + align-items: center; + justify-content: flex-start; gap: 10px; - min-width: 140px; } .state-panel { @@ -600,6 +726,11 @@ onMounted(() => { @media (max-width: 900px) { .table-actions { + justify-items: start; + } + + .table-actions-buttons, + .table-actions-toggle { justify-content: flex-start; } } diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index 4619bb065..36629ecfe 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -1,7 +1,7 @@ """Tests for astr_main_agent module.""" import os -from unittest.mock import AsyncMock, MagicMock, call, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -402,26 +402,16 @@ class TestBuiltinToolInjection: req = ProviderRequest() tool_mgr = MagicMock() - create_tool = MagicMock(spec=FunctionTool) - create_tool.name = "create_future_task" - delete_tool = MagicMock(spec=FunctionTool) - delete_tool.name = "delete_future_task" - list_tool = MagicMock(spec=FunctionTool) - list_tool.name = "list_future_tasks" - tool_mgr.get_builtin_tool.side_effect = [create_tool, delete_tool, list_tool] + future_task_tool = MagicMock(spec=FunctionTool) + future_task_tool.name = "future_task" + tool_mgr.get_builtin_tool.return_value = future_task_tool mock_context.get_llm_tool_manager.return_value = tool_mgr module._proactive_cron_job_tools(req, mock_context) - assert tool_mgr.get_builtin_tool.call_args_list == [ - call(module.CreateActiveCronTool), - call(module.DeleteCronJobTool), - call(module.ListCronJobsTool), - ] + tool_mgr.get_builtin_tool.assert_called_once_with(module.FutureTaskTool) assert req.func_tool is not None - assert req.func_tool.get_tool("create_future_task") is create_tool - assert req.func_tool.get_tool("delete_future_task") is delete_tool - assert req.func_tool.get_tool("list_future_tasks") is list_tool + assert req.func_tool.get_tool("future_task") is future_task_tool class TestApplyFileExtract: @@ -621,9 +611,10 @@ class TestEnsurePersonaAndSkills: tmgr = mock_context.get_llm_tool_manager.return_value tmgr.func_list = [tool_a, tool_b] tmgr.get_full_tool_set.return_value = ToolSet([tool_a, tool_b]) - tmgr.get_func.side_effect = lambda name: {"tool_a": tool_a, "tool_b": tool_b}.get( - name - ) + tmgr.get_func.side_effect = lambda name: { + "tool_a": tool_a, + "tool_b": tool_b, + }.get(name) handoff = MagicMock() handoff.name = "transfer_to_planner" diff --git a/tests/unit/test_cron_tools.py b/tests/unit/test_cron_tools.py index 25f721248..a0e520d46 100644 --- a/tests/unit/test_cron_tools.py +++ b/tests/unit/test_cron_tools.py @@ -1,15 +1,126 @@ """Tests for cron tool metadata.""" -from astrbot.core.tools.cron_tools import CreateActiveCronTool +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from astrbot.core.tools.cron_tools import FutureTaskTool -def test_create_future_task_cron_description_prefers_named_weekdays(): - """The cron tool should steer users toward unambiguous named weekdays.""" - tool = CreateActiveCronTool() +def test_future_task_schema_has_action_and_create_cron_guidance(): + """The merged tool should expose action routing and unambiguous cron guidance.""" + tool = FutureTaskTool() + + assert tool.name == "future_task" + assert tool.parameters["required"] == ["action"] + assert tool.parameters["properties"]["action"]["enum"] == [ + "create", + "edit", + "delete", + "list", + ] description = tool.parameters["properties"]["cron_expression"]["description"] assert "mon-fri" in description assert "sat,sun" in description assert "1-5" in description - assert "avoid ambiguity" in description + assert "Prefer named weekdays" in description + + +def test_future_task_schema_has_no_job_type_and_delete_job_id(): + """The merged tool should remove job_type and document delete requirements.""" + tool = FutureTaskTool() + + assert "job_type" not in tool.parameters["properties"] + action_description = tool.parameters["properties"]["action"]["description"] + job_id_description = tool.parameters["properties"]["job_id"]["description"] + + assert "'edit' requires 'job_id'" in action_description + assert "Required for 'delete' and 'edit'" in job_id_description + + +@pytest.mark.asyncio +async def test_future_task_edit_requires_job_id(): + """Edit mode should require job_id.""" + tool = FutureTaskTool() + cron_mgr = SimpleNamespace() + context = SimpleNamespace( + context=SimpleNamespace( + context=SimpleNamespace(cron_manager=cron_mgr), + event=SimpleNamespace( + unified_msg_origin="test:private:session", + get_sender_id=lambda: "user-1", + ), + ) + ) + + result = await tool.call(context, action="edit") + + assert result == "error: job_id is required when action=edit." + + +@pytest.mark.asyncio +async def test_future_task_edit_updates_existing_job(): + """Edit mode should update note and one-time scheduling fields.""" + tool = FutureTaskTool() + existing_job = SimpleNamespace( + job_id="job-1", + name="old name", + job_type="active_agent", + run_once=False, + cron_expression="0 8 * * *", + payload={ + "session": "test:private:session", + "sender_id": "user-1", + "note": "old note", + "origin": "tool", + }, + ) + updated_job = SimpleNamespace( + job_id="job-1", + name="new name", + run_once=True, + cron_expression=None, + next_run_time=None, + ) + cron_mgr = SimpleNamespace( + db=SimpleNamespace(get_cron_job=AsyncMock(return_value=existing_job)), + update_job=AsyncMock(return_value=updated_job), + ) + context = SimpleNamespace( + context=SimpleNamespace( + context=SimpleNamespace(cron_manager=cron_mgr), + event=SimpleNamespace( + unified_msg_origin="test:private:session", + get_sender_id=lambda: "user-1", + ), + ) + ) + + result = await tool.call( + context, + action="edit", + job_id="job-1", + name="new name", + note="new note", + run_once=True, + run_at="2026-02-02T08:00:00+08:00", + ) + + cron_mgr.update_job.assert_awaited_once_with( + "job-1", + name="new name", + description="new note", + run_once=True, + cron_expression=None, + payload={ + "session": "test:private:session", + "sender_id": "user-1", + "note": "new note", + "origin": "tool", + "run_at": "2026-02-02T08:00:00+08:00", + }, + ) + assert result == "Updated future task job-1 (new name)."