From c784c89fe3d1630b1dc01f161e9bd77cc2f19324 Mon Sep 17 00:00:00 2001 From: AaronZ345 <34849476+AaronZ345@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:58:44 +0800 Subject: [PATCH] fix(scheduler): preserve hidden fields on web edits --- channel/web/web_channel.py | 30 +++++++- tests/test_scheduler_web_update.py | 114 +++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 tests/test_scheduler_web_update.py diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 5ae2bfd4..3c01ae61 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -4610,11 +4610,33 @@ class SchedulerUpdateHandler: # Update action if "action" in body: - action = body["action"] - channel_type = action.get("channel_type", "web") - # Get the task's original channel_type - old_channel = original_task.get("action", {}).get("channel_type", "web") + original_action = original_task.get("action", {}) + if not isinstance(original_action, dict): + original_action = {} + action_patch = body["action"] + if not isinstance(action_patch, dict): + return json.dumps({ + "status": "error", + "message": "Action must be an object." + }, ensure_ascii=False) + + # The Web editor only exposes a subset of action fields. Merge + # that patch into the stored action so scheduler metadata such + # as notify_session_id, silent, and channel-specific delivery + # fields survive unrelated edits. + action = dict(original_action) + action.update(action_patch) + action_type = action.get("type") + if action_type == "send_message": + action.pop("task_description", None) + action.pop("silent", None) + elif action_type == "agent_task": + action.pop("content", None) + + old_channel = original_action.get("channel_type", "web") + channel_type = action.get("channel_type") or old_channel + action["channel_type"] = channel_type # If channel type changed or no receiver, reject the update. # Note: the web UI disables the channel selector, so this branch diff --git a/tests/test_scheduler_web_update.py b/tests/test_scheduler_web_update.py new file mode 100644 index 00000000..2b423ce2 --- /dev/null +++ b/tests/test_scheduler_web_update.py @@ -0,0 +1,114 @@ +"""Regression tests for scheduler edits made through the Web console.""" + +import json +import sys +import types +from datetime import datetime, timedelta +from unittest.mock import patch + +from agent.tools.scheduler.task_store import TaskStore + +# Keep this unit test independent from the optional web.py dependency. +if "web" not in sys.modules: + web_stub = types.ModuleType("web") + web_stub.HTTPError = type("HTTPError", (Exception,), {}) + web_stub.cookies = lambda: {} + web_stub.header = lambda *args, **kwargs: None + web_stub.data = lambda: b"{}" + web_stub.input = lambda **kwargs: types.SimpleNamespace(**kwargs) + web_stub.setcookie = lambda *args, **kwargs: None + web_stub.seeother = lambda *args, **kwargs: Exception("seeother") + web_stub.notfound = lambda *args, **kwargs: Exception("notfound") + web_stub.badrequest = lambda *args, **kwargs: Exception("badrequest") + web_stub.application = lambda *args, **kwargs: types.SimpleNamespace(wsgifunc=lambda: None) + web_stub.httpserver = types.SimpleNamespace( + LogMiddleware=type("LogMiddleware", (), {"log": lambda *args, **kwargs: None}), + StaticMiddleware=lambda app: app, + WSGIServer=lambda *args, **kwargs: types.SimpleNamespace(serve_forever=lambda: None), + ) + sys.modules["web"] = web_stub + +from channel.web.web_channel import SchedulerUpdateHandler + + +def _store_task(tmp_path, action): + store = TaskStore(str(tmp_path / "scheduler" / "tasks.json")) + store.add_task({ + "id": "task-1", + "name": "maintenance", + "enabled": True, + "created_at": datetime.now().isoformat(), + "updated_at": datetime.now().isoformat(), + "next_run_at": (datetime.now() + timedelta(hours=1)).isoformat(), + "schedule": {"type": "interval", "seconds": 3600}, + "action": action, + }) + return store + + +def _post_update(tmp_path, payload): + with patch("channel.web.web_channel._require_auth"), \ + patch("channel.web.web_channel.web.header"), \ + patch("channel.web.web_channel.web.data", return_value=json.dumps(payload).encode()), \ + patch("channel.web.web_channel._get_workspace_root", return_value=str(tmp_path)): + return json.loads(SchedulerUpdateHandler().POST()) + + +def test_web_edit_preserves_hidden_agent_action_fields(tmp_path): + store = _store_task(tmp_path, { + "type": "agent_task", + "task_description": "refresh the index", + "receiver": "user-1", + "receiver_name": "User", + "is_group": False, + "channel_type": "feishu", + "notify_session_id": "session-1", + "silent": True, + "delivery_extension": {"trace": True}, + }) + + result = _post_update(tmp_path, { + "task_id": "task-1", + "name": "renamed maintenance", + "action": { + "type": "agent_task", + "task_description": "refresh both indexes", + "receiver": "user-1", + "channel_type": "feishu", + }, + }) + + assert result["status"] == "success" + action = store.get_task("task-1")["action"] + assert action["task_description"] == "refresh both indexes" + assert action["silent"] is True + assert action["notify_session_id"] == "session-1" + assert action["delivery_extension"] == {"trace": True} + + +def test_switch_to_message_drops_agent_only_fields(tmp_path): + store = _store_task(tmp_path, { + "type": "agent_task", + "task_description": "refresh the index", + "receiver": "user-1", + "channel_type": "web", + "notify_session_id": "session-1", + "silent": True, + }) + + result = _post_update(tmp_path, { + "task_id": "task-1", + "action": { + "type": "send_message", + "content": "Index refresh reminder", + "receiver": "user-1", + "channel_type": "web", + }, + }) + + assert result["status"] == "success" + action = store.get_task("task-1")["action"] + assert action["content"] == "Index refresh reminder" + assert "task_description" not in action + assert "silent" not in action + assert action["notify_session_id"] == "session-1"