refactor(scheduler): move manual runs to console controls

This commit is contained in:
AaronZ345
2026-07-17 16:44:30 +08:00
parent f7a0184ea2
commit 209f1f0a6f
10 changed files with 199 additions and 53 deletions

View File

@@ -24,7 +24,6 @@ class SchedulerTool(BaseTool):
"⚠️ 重要:仅当需要「定时/提醒/每天/每周/X分钟后/X点」等延迟或周期执行时才使用此工具。" "⚠️ 重要:仅当需要「定时/提醒/每天/每周/X分钟后/X点」等延迟或周期执行时才使用此工具。"
"使用方法:\n" "使用方法:\n"
"- 创建action='create', name='任务名', message/ai_task='内容', schedule_type='once/interval/cron', schedule_value='...'\n" "- 创建action='create', name='任务名', message/ai_task='内容', schedule_type='once/interval/cron', schedule_value='...'\n"
"- 立即执行action='run', task_id='任务ID'(不改变原调度时间)\n"
"- 查询action='list' / action='get', task_id='任务ID'\n" "- 查询action='list' / action='get', task_id='任务ID'\n"
"- 管理action='delete/enable/disable', task_id='任务ID'\n\n" "- 管理action='delete/enable/disable', task_id='任务ID'\n\n"
"调度类型:\n" "调度类型:\n"
@@ -38,12 +37,12 @@ class SchedulerTool(BaseTool):
"properties": { "properties": {
"action": { "action": {
"type": "string", "type": "string",
"enum": ["create", "run", "list", "get", "delete", "enable", "disable"], "enum": ["create", "list", "get", "delete", "enable", "disable"],
"description": "操作类型: create(创建), run(立即执行), list(列表), get(查询), delete(删除), enable(启用), disable(禁用)" "description": "操作类型: create(创建), list(列表), get(查询), delete(删除), enable(启用), disable(禁用)"
}, },
"task_id": { "task_id": {
"type": "string", "type": "string",
"description": "任务ID (用于 run/get/delete/enable/disable 操作)" "description": "任务ID (用于 get/delete/enable/disable 操作)"
}, },
"name": { "name": {
"type": "string", "type": "string",
@@ -89,7 +88,7 @@ class SchedulerTool(BaseTool):
Args: Args:
params: Dictionary containing: params: Dictionary containing:
- action: Operation type (create/run/list/get/delete/enable/disable) - action: Operation type (create/list/get/delete/enable/disable)
- Other parameters depending on action - Other parameters depending on action
Returns: Returns:
@@ -106,9 +105,6 @@ class SchedulerTool(BaseTool):
if action == "create": if action == "create":
result = self._create_task(**kwargs) result = self._create_task(**kwargs)
return ToolResult.success(result) return ToolResult.success(result)
elif action == "run":
result = self._run_task(**kwargs)
return ToolResult.success(result)
elif action == "list": elif action == "list":
result = self._list_tasks(**kwargs) result = self._list_tasks(**kwargs)
return ToolResult.success(result) return ToolResult.success(result)
@@ -307,24 +303,6 @@ class SchedulerTool(BaseTool):
self.task_store.delete_task(task_id) self.task_store.delete_task(task_id)
return f"✅ 任务 '{task['name']}' ({task_id}) 已删除" return f"✅ 任务 '{task['name']}' ({task_id}) 已删除"
def _run_task(self, **kwargs) -> str:
"""Queue an existing task for immediate execution."""
task_id = kwargs.get("task_id")
if not task_id:
return "错误: 缺少任务ID (task_id)"
task = self.task_store.get_task(task_id)
if not task:
return f"错误: 任务 '{task_id}' 不存在"
from agent.tools.scheduler.integration import get_scheduler_service
service = get_scheduler_service()
if not service:
return "错误: 定时任务系统未运行"
service.run_task_now(task_id)
return f"✅ 任务 '{task['name']}' ({task_id}) 已开始立即执行,原调度时间保持不变"
def _enable_task(self, **kwargs) -> str: def _enable_task(self, **kwargs) -> str:
"""Enable a task""" """Enable a task"""

View File

@@ -217,6 +217,11 @@ const I18N = {
task_delete_btn: '删除任务', task_delete_btn: '删除任务',
task_delete_confirm_title: '删除定时任务', task_delete_confirm_title: '删除定时任务',
task_delete_confirm_msg: '确定删除该定时任务吗?此操作无法撤销。', task_delete_confirm_msg: '确定删除该定时任务吗?此操作无法撤销。',
task_run_now: '立即执行',
task_run_confirm_title: '立即执行任务',
task_run_confirm_msg: '该任务会立即向已配置的通道和接收者发送内容。是否继续?',
task_run_started: '已开始执行',
task_run_failed: '执行失败',
logs_title: '日志', logs_desc: '实时日志输出 (run.log)', logs_title: '日志', logs_desc: '实时日志输出 (run.log)',
logs_live: '实时', logs_coming_msg: '日志流即将在此提供。将连接 run.log 实现类似 tail -f 的实时输出。', logs_live: '实时', logs_coming_msg: '日志流即将在此提供。将连接 run.log 实现类似 tail -f 的实时输出。',
new_chat: '新对话', new_chat: '新对话',
@@ -464,6 +469,11 @@ const I18N = {
task_delete_btn: '刪除任務', task_delete_btn: '刪除任務',
task_delete_confirm_title: '刪除定時任務', task_delete_confirm_title: '刪除定時任務',
task_delete_confirm_msg: '確定刪除該定時任務嗎?此操作無法撤銷。', task_delete_confirm_msg: '確定刪除該定時任務嗎?此操作無法撤銷。',
task_run_now: '立即執行',
task_run_confirm_title: '立即執行任務',
task_run_confirm_msg: '該任務會立即向已設定的通道和接收者傳送內容。是否繼續?',
task_run_started: '已開始執行',
task_run_failed: '執行失敗',
logs_title: '日誌', logs_desc: '實時日誌輸出 (run.log)', logs_title: '日誌', logs_desc: '實時日誌輸出 (run.log)',
logs_live: '實時', logs_coming_msg: '日誌流即將在此提供。將連線 run.log 實現類似 tail -f 的實時輸出。', logs_live: '實時', logs_coming_msg: '日誌流即將在此提供。將連線 run.log 實現類似 tail -f 的實時輸出。',
new_chat: '新對話', new_chat: '新對話',
@@ -710,6 +720,11 @@ const I18N = {
task_delete_btn: 'Delete Task', task_delete_btn: 'Delete Task',
task_delete_confirm_title: 'Delete Task', task_delete_confirm_title: 'Delete Task',
task_delete_confirm_msg: 'Delete this scheduled task? This action cannot be undone.', task_delete_confirm_msg: 'Delete this scheduled task? This action cannot be undone.',
task_run_now: 'Run now',
task_run_confirm_title: 'Run task now',
task_run_confirm_msg: 'This task will immediately send to its configured channel and receiver. Continue?',
task_run_started: 'Run started',
task_run_failed: 'Run failed',
logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)', logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)',
logs_live: 'Live', logs_coming_msg: 'Log streaming will be available here. Connects to run.log for real-time output similar to tail -f.', logs_live: 'Live', logs_coming_msg: 'Log streaming will be available here. Connects to run.log for real-time output similar to tail -f.',
new_chat: 'New Chat', new_chat: 'New Chat',
@@ -7935,6 +7950,38 @@ function refreshTasksView() {
btn.disabled = false; btn.disabled = false;
}, 500); }, 500);
} }
function runTaskNow(task, button) {
showConfirmDialog({
title: t('task_run_confirm_title'),
message: `${task.name || task.id}: ${t('task_run_confirm_msg')}`,
okText: t('task_run_now'),
onConfirm: () => {
const originalHtml = button.innerHTML;
button.disabled = true;
button.innerHTML = `<i class="fas fa-spinner fa-spin mr-1"></i>${t('task_run_now')}`;
fetch('/api/scheduler/run', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({task_id: task.id})
}).then(r => r.json()).then(res => {
if (res.status !== 'success') throw new Error(res.message || t('task_run_failed'));
button.innerHTML = `<i class="fas fa-check mr-1"></i>${t('task_run_started')}`;
setTimeout(() => {
button.innerHTML = originalHtml;
button.disabled = false;
}, 1500);
}).catch(() => {
button.innerHTML = `<i class="fas fa-triangle-exclamation mr-1"></i>${t('task_run_failed')}`;
setTimeout(() => {
button.innerHTML = originalHtml;
button.disabled = false;
}, 2000);
});
}
});
}
function loadTasksView() { function loadTasksView() {
if (tasksLoaded) return; if (tasksLoaded) return;
fetch('/api/scheduler').then(r => r.json()).then(data => { fetch('/api/scheduler').then(r => r.json()).then(data => {
@@ -7996,11 +8043,19 @@ function loadTasksView() {
<div class="flex items-center gap-4 text-xs text-slate-400 dark:text-slate-500"> <div class="flex items-center gap-4 text-xs text-slate-400 dark:text-slate-500">
<span><i class="fas fa-clock mr-1"></i>${currentLang === 'zh' ? '下次执行' : 'Next run'}: ${nextRun}</span> <span><i class="fas fa-clock mr-1"></i>${currentLang === 'zh' ? '下次执行' : 'Next run'}: ${nextRun}</span>
<div class="flex-1"></div> <div class="flex-1"></div>
<button type="button" class="task-run-now px-2 py-1 rounded-md text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-500/10 transition-colors">
<i class="fas fa-play mr-1"></i>${t('task_run_now')}
</button>
<label class="relative inline-flex items-center cursor-pointer" for="${toggleId}"> <label class="relative inline-flex items-center cursor-pointer" for="${toggleId}">
<input type="checkbox" id="${toggleId}" class="sr-only peer" ${isEnabled ? 'checked' : ''}> <input type="checkbox" id="${toggleId}" class="sr-only peer" ${isEnabled ? 'checked' : ''}>
<div class="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary-500 dark:bg-slate-600 dark:peer-checked:bg-primary-500"></div> <div class="w-9 h-5 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary-500 dark:bg-slate-600 dark:peer-checked:bg-primary-500"></div>
</label> </label>
</div>`; </div>`;
const runButton = card.querySelector('.task-run-now');
runButton.addEventListener('click', function(e) {
e.stopPropagation();
runTaskNow(task, runButton);
});
const checkbox = card.querySelector('#' + toggleId); const checkbox = card.querySelector('#' + toggleId);
checkbox.addEventListener('change', function() { checkbox.addEventListener('change', function() {
const newEnabled = this.checked; const newEnabled = this.checked;

View File

@@ -1309,6 +1309,7 @@ class WebChannel(ChatChannel):
'/api/knowledge/action', 'KnowledgeActionHandler', '/api/knowledge/action', 'KnowledgeActionHandler',
'/api/knowledge/import', 'KnowledgeImportHandler', '/api/knowledge/import', 'KnowledgeImportHandler',
'/api/scheduler', 'SchedulerHandler', '/api/scheduler', 'SchedulerHandler',
'/api/scheduler/run', 'SchedulerRunHandler',
'/api/scheduler/toggle', 'SchedulerToggleHandler', '/api/scheduler/toggle', 'SchedulerToggleHandler',
'/api/scheduler/update', 'SchedulerUpdateHandler', '/api/scheduler/update', 'SchedulerUpdateHandler',
'/api/scheduler/delete', 'SchedulerDeleteHandler', '/api/scheduler/delete', 'SchedulerDeleteHandler',
@@ -4537,6 +4538,34 @@ class SchedulerHandler:
return json.dumps({"status": "error", "message": str(e)}) return json.dumps({"status": "error", "message": str(e)})
class SchedulerRunHandler:
def POST(self):
_require_auth()
web.header('Content-Type', 'application/json; charset=utf-8')
try:
body = json.loads(web.data())
task_id = body.get("task_id")
if not task_id:
return json.dumps({"status": "error", "message": "task_id required"})
from agent.tools.scheduler.integration import get_scheduler_service
service = get_scheduler_service()
if service is None:
return json.dumps({
"status": "error",
"message": "Scheduler service is not running",
})
service.run_task_now(task_id)
return json.dumps({
"status": "success",
"message": f"Task '{task_id}' queued for immediate execution",
}, ensure_ascii=False)
except Exception as e:
logger.error(f"[WebChannel] Scheduler manual run error: {e}")
return json.dumps({"status": "error", "message": str(e)})
class SchedulerToggleHandler: class SchedulerToggleHandler:
def POST(self): def POST(self):
_require_auth() _require_auth()

View File

@@ -372,6 +372,13 @@ class ApiClient {
return data.tasks return data.tasks
} }
async runTask(taskId: string): Promise<ApiResult> {
return this.request('/api/scheduler/run', {
method: 'POST',
body: JSON.stringify({ task_id: taskId }),
})
}
async toggleTask(taskId: string, enabled: boolean): Promise<{ status: string; task: SchedulerTask }> { async toggleTask(taskId: string, enabled: boolean): Promise<{ status: string; task: SchedulerTask }> {
return this.request('/api/scheduler/toggle', { return this.request('/api/scheduler/toggle', {
method: 'POST', method: 'POST',

View File

@@ -352,6 +352,10 @@ const translations: Record<string, Record<string, string>> = {
task_delete: '删除', task_delete: '删除',
task_delete_confirm: '确定删除该任务吗?此操作不可撤销。', task_delete_confirm: '确定删除该任务吗?此操作不可撤销。',
task_save_error: '保存失败', task_save_error: '保存失败',
task_run_now: '立即执行',
task_run_confirm: '该任务会立即向已配置的通道和接收者发送内容。是否继续?',
task_run_started: '任务已开始执行',
task_run_error: '执行失败',
logs_title: '日志', logs_title: '日志',
logs_desc: '实时日志输出 (run.log)', logs_desc: '实时日志输出 (run.log)',
logs_live: '实时', logs_live: '实时',
@@ -740,6 +744,10 @@ const translations: Record<string, Record<string, string>> = {
task_delete: 'Delete', task_delete: 'Delete',
task_delete_confirm: 'Delete this task? This cannot be undone.', task_delete_confirm: 'Delete this task? This cannot be undone.',
task_save_error: 'Failed to save', task_save_error: 'Failed to save',
task_run_now: 'Run now',
task_run_confirm: 'This task will immediately send to its configured channel and receiver. Continue?',
task_run_started: 'Task run started',
task_run_error: 'Failed to run task',
logs_title: 'Logs', logs_title: 'Logs',
logs_desc: 'Real-time log output (run.log)', logs_desc: 'Real-time log output (run.log)',
logs_live: 'Live', logs_live: 'Live',

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { Loader2, Clock, CalendarClock } from 'lucide-react' import { Loader2, Clock, CalendarClock, Play } from 'lucide-react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { t } from '../i18n' import { t } from '../i18n'
import apiClient from '../api/client' import apiClient from '../api/client'
@@ -165,6 +165,8 @@ const TaskEditModal: React.FC<{
const [actionType, setActionType] = useState<TaskAction['type']>(task.action.type || 'send_message') const [actionType, setActionType] = useState<TaskAction['type']>(task.action.type || 'send_message')
const [content, setContent] = useState(task.action.content || task.action.task_description || '') const [content, setContent] = useState(task.action.content || task.action.task_description || '')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [running, setRunning] = useState(false)
const [runStatus, setRunStatus] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const buildSchedule = (): TaskSchedule => { const buildSchedule = (): TaskSchedule => {
@@ -209,6 +211,22 @@ const TaskEditModal: React.FC<{
} }
} }
const runNow = async () => {
if (!window.confirm(t('task_run_confirm'))) return
setRunning(true)
setRunStatus('')
setError('')
try {
const result = await apiClient.runTask(task.id)
if (result.status !== 'success') throw new Error(result.message || t('task_run_error'))
setRunStatus(t('task_run_started'))
} catch (e) {
setError(e instanceof Error ? e.message : t('task_run_error'))
} finally {
setRunning(false)
}
}
return ( return (
<Modal <Modal
open open
@@ -219,6 +237,10 @@ const TaskEditModal: React.FC<{
<Btn variant="danger" onClick={del} disabled={saving} className="mr-auto"> <Btn variant="danger" onClick={del} disabled={saving} className="mr-auto">
{t('task_delete')} {t('task_delete')}
</Btn> </Btn>
<Btn variant="ghost" onClick={runNow} disabled={saving || running}>
{running ? <Loader2 size={14} className="inline animate-spin mr-1" /> : <Play size={14} className="inline mr-1" />}
{t('task_run_now')}
</Btn>
<Btn variant="ghost" onClick={onClose} disabled={saving}> <Btn variant="ghost" onClick={onClose} disabled={saving}>
{t('task_cancel')} {t('task_cancel')}
</Btn> </Btn>
@@ -298,6 +320,7 @@ const TaskEditModal: React.FC<{
)} )}
<p className="text-xs text-content-tertiary">{t('task_channel_locked')}</p> <p className="text-xs text-content-tertiary">{t('task_channel_locked')}</p>
{runStatus && <p className="text-xs text-success">{runStatus}</p>}
{error && <p className="text-xs text-danger">{error}</p>} {error && <p className="text-xs text-danger">{error}</p>}
</Modal> </Modal>
) )

View File

@@ -34,14 +34,17 @@ Create and manage scheduled tasks with natural language:
- "Check server status every 2 hours" - "Check server status every 2 hours"
- "Remind me about the meeting tomorrow at 3 PM" - "Remind me about the meeting tomorrow at 3 PM"
- "Show all scheduled tasks" - "Show all scheduled tasks"
- "Run task a1b2c3d4 now"
## Run an existing task now ## Test-run an existing task
Use `action="run"` with a task ID to queue an immediate test run. Manual Open the task in the authenticated Web console or Desktop app and click **Run
execution works for disabled tasks and does not enable, delete, or reschedule now**. After you confirm the delivery, CowAgent queues one immediate execution
the task. Its original `next_run_at` remains unchanged. A task cannot be run to the task's configured channel and receiver. This user-initiated action is not
manually while the same task is already executing on schedule. exposed to the agent's scheduler tool.
Manual execution works for disabled tasks and does not enable, delete, or
reschedule the task. Its original `next_run_at` remains unchanged. A task cannot
be run manually while the same task is already executing on schedule.
<Frame> <Frame>
<img src="https://cdn.link-ai.tech/doc/20260202195402.png" width="800" /> <img src="https://cdn.link-ai.tech/doc/20260202195402.png" width="800" />

View File

@@ -34,11 +34,12 @@ description: 创建和管理定时任务
- "每隔 2 小时检查一下服务器状态" - "每隔 2 小时检查一下服务器状态"
- "明天下午 3 点提醒我开会" - "明天下午 3 点提醒我开会"
- "查看所有定时任务" - "查看所有定时任务"
- "立即执行任务 a1b2c3d4"
## 立即执行已有任务 ## 测试执行已有任务
使用任务 ID 调用 `action="run"`,可以排队进行一次即时测试执行。禁用状态的任务也可手动执行,且不会因此被启用、删除或重新调度,原有 `next_run_at` 保持不变。同一任务正在按计划执行时,不允许重复手动执行 在经过身份认证的 Web 控制台或 Desktop 应用中打开任务,点击 **立即执行**。确认发送后CowAgent 会立即按任务已配置的通道和接收者执行一次。这个入口只允许用户手动触发,不会暴露给 Agent 的 scheduler tool
禁用状态的任务也可手动执行,且不会因此被启用、删除或重新调度,原有 `next_run_at` 保持不变。同一任务正在按计划执行时,不允许重复手动执行。
<Frame> <Frame>
<img src="https://cdn.link-ai.tech/doc/20260202195402.png" width="800" /> <img src="https://cdn.link-ai.tech/doc/20260202195402.png" width="800" />

View File

@@ -3,7 +3,6 @@
import threading import threading
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from unittest.mock import Mock, patch
import pytest import pytest
@@ -72,18 +71,7 @@ def test_manual_run_rejects_duplicate_execution(tmp_path):
release.set() release.set()
def test_scheduler_tool_queues_existing_task(): def test_scheduler_tool_does_not_expose_manual_execution():
store = Mock() actions = SchedulerTool.params["properties"]["action"]["enum"]
store.get_task.return_value = {"id": "task-1", "name": "refresh index"}
service = Mock()
tool = SchedulerTool()
tool.task_store = store
with patch( assert "run" not in actions
"agent.tools.scheduler.integration.get_scheduler_service",
return_value=service,
):
result = tool.execute({"action": "run", "task_id": "task-1"})
assert result.status == "success"
service.run_task_now.assert_called_once_with("task-1")

View File

@@ -4,7 +4,8 @@ import json
import sys import sys
import types import types
from datetime import datetime, timedelta from datetime import datetime, timedelta
from unittest.mock import patch from pathlib import Path
from unittest.mock import Mock, patch
from agent.tools.scheduler.task_store import TaskStore from agent.tools.scheduler.task_store import TaskStore
@@ -28,7 +29,9 @@ if "web" not in sys.modules:
) )
sys.modules["web"] = web_stub sys.modules["web"] = web_stub
from channel.web.web_channel import SchedulerUpdateHandler from channel.web import web_channel
SchedulerUpdateHandler = web_channel.SchedulerUpdateHandler
def _store_task(tmp_path, action): def _store_task(tmp_path, action):
@@ -54,6 +57,57 @@ def _post_update(tmp_path, payload):
return json.loads(SchedulerUpdateHandler().POST()) return json.loads(SchedulerUpdateHandler().POST())
def test_web_manual_run_is_authenticated_and_delegates_to_scheduler():
assert hasattr(web_channel, "SchedulerRunHandler")
service = Mock()
with patch("channel.web.web_channel._require_auth") as require_auth, \
patch("channel.web.web_channel.web.header"), \
patch("channel.web.web_channel.web.data", return_value=b'{"task_id":"task-1"}'), \
patch("agent.tools.scheduler.integration.get_scheduler_service", return_value=service):
response = json.loads(web_channel.SchedulerRunHandler().POST())
require_auth.assert_called_once_with()
service.run_task_now.assert_called_once_with("task-1")
assert response == {
"status": "success",
"message": "Task 'task-1' queued for immediate execution",
}
def test_web_manual_run_rejects_unavailable_scheduler():
assert hasattr(web_channel, "SchedulerRunHandler")
with patch("channel.web.web_channel._require_auth"), \
patch("channel.web.web_channel.web.header"), \
patch("channel.web.web_channel.web.data", return_value=b'{"task_id":"task-1"}'), \
patch("agent.tools.scheduler.integration.get_scheduler_service", return_value=None):
response = json.loads(web_channel.SchedulerRunHandler().POST())
assert response == {
"status": "error",
"message": "Scheduler service is not running",
}
def test_manual_run_is_exposed_by_explicit_web_and_desktop_controls():
root = Path(__file__).parents[1]
web_source = (root / "channel/web/web_channel.py").read_text(encoding="utf-8")
web_console = (root / "channel/web/static/js/console.js").read_text(encoding="utf-8")
desktop_client = (root / "desktop/src/renderer/src/api/client.ts").read_text(encoding="utf-8")
desktop_page = (root / "desktop/src/renderer/src/pages/TasksPage.tsx").read_text(encoding="utf-8")
assert "'/api/scheduler/run', 'SchedulerRunHandler'" in web_source
assert "function runTaskNow(task, button)" in web_console
assert "fetch('/api/scheduler/run'" in web_console
web_run = web_console[web_console.index("function runTaskNow(task, button)"):]
assert "showConfirmDialog({" in web_run[:2500]
assert "async runTask(taskId: string)" in desktop_client
assert "'/api/scheduler/run'" in desktop_client
assert "const runNow = async ()" in desktop_page
assert "window.confirm(t('task_run_confirm'))" in desktop_page
def test_web_edit_preserves_hidden_agent_action_fields(tmp_path): def test_web_edit_preserves_hidden_agent_action_fields(tmp_path):
store = _store_task(tmp_path, { store = _store_task(tmp_path, {
"type": "agent_task", "type": "agent_task",