feat: add Feishu group notifications and fix mention caret placement (#6762)

* feat(feishu): deliver scheduled-task notifications to bound users

* feat: add Feishu group notifications and fix mention caret placement

* fix(frontend): preserve group targets while capabilities load

---------

Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
This commit is contained in:
Totoro 2026-10-13 07:17:56 +08:00 committed by GitHub
parent 8cec469738
commit 5ae95e6a34
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 2263 additions and 71 deletions

View File

@ -1903,6 +1903,8 @@ Unsent Web UI composer drafts survive page reloads and switching between convers
The Web UI composer supports `@` references at the cursor or through the `@` button. Search enabled skills, documents in the current project, and other conversations in a grouped picker; selected references appear inline alongside the draft text and can be removed with normal editing. Multiple skills can be selected in one message (up to 16 unique skills); selecting a checked skill again removes it, with each selection checked against the current user registry and agent allowlist. Project files are attached only after ingestion succeeds; conversation references require the `read_conversation` capability and retain its per-run limit. If capability discovery fails, saved conversation references remain intact and sending waits for a successful retry. Polishing preserves complete reference labels when instructions are reordered. The file group also offers local uploads. `/goal`, `/compact`, and legacy `/skill-name` input remain supported.
After picking an inline reference, typing continues immediately after it. Moving the cursor keeps the position you chose, including when editing text beside a reference.
The Web UI composer also supports browser-based voice dictation when the browser exposes the Web Speech API. The microphone button transcribes speech into the local draft only; DeerFlow receives only the transcribed text, while audio handling is delegated to the browser or operating system speech-recognition service according to that environment's policy. Users can review or edit the text before sending. Dictation keeps words separated when browsers trim transcript segments, preserves Chinese and Japanese joins, and attaches leading punctuation and percent signs without adding spaces.
The Web UI displays a localized AI-generated-content disclaimer below the composer in both standard and custom-agent conversations, reminding users to verify important
@ -2808,7 +2810,7 @@ Current MVP capabilities:
- Pause, resume, trigger, inspect history, and delete tasks
- Search task titles or prompts, combined with status/type filters and the current thread scope.
- Execute scheduled work through the normal DeerFlow run lifecycle
- When `channel_connections.enabled: true`, send scheduled task updates to the task owner's connected IM identities (outbox + delivery worker) on apps that support proactive push, which today is WeCom; Settings shows for each app whether updates are sent there, and other apps get none. Each occurrence sends at most one message: a finished, failed or goal-missed run, the automatic pause after three missed goals, a pause by the agent (its stop condition was met) or the task finishing (all `max_runs` done, `end_at` reached); when several apply, the pause or finish wins and still says how the last run went. A one-time task sends only its run's outcome. The message is queued in the same database transaction that records the outcome, so runs finalized after a crash or a lost lease notify exactly once. It reads on its own, in your web UI language (else `channel_connections.notification_locale`): the task title, what happened, a one-line result when the agent replied, and "Open DeerFlow → Scheduled tasks for details.", with no IDs and no links. Plain manual "run now" trials and interrupted runs stay silent, and so do occurrences that end without a finished run (launch error, queue timeout, interrupted by a restart). Channel/transport outages park deliveries without exhausting retries, for up to about a day; platform rejections retry for roughly 15 minutes before settling as `failed`. An identity you disconnect while a delivery is waiting is never pushed to: the row is dropped as `failed`.
- When `channel_connections.enabled: true`, send scheduled task updates to the task owner's connected IM identities (outbox + delivery worker) on apps that support proactive push, which today is WeCom and Feishu/Lark; Settings shows for each app whether updates are sent there, and other apps get none. Each occurrence queues at most one message per target: a finished, failed or goal-missed run, the automatic pause after three missed goals, a pause by the agent (its stop condition was met) or the task finishing (all `max_runs` done, `end_at` reached); when several apply, the pause or finish wins and still says how the last run went. A one-time task sends only its run's outcome. The message is queued in the same database transaction that records the outcome, so runs finalized after a crash or a lost lease do not enqueue a duplicate notice for the same target. It reads on its own, in your web UI language (else `channel_connections.notification_locale`): the task title, what happened, a one-line result when the agent replied, and "Open DeerFlow → Scheduled tasks for details.", with no IDs and no links. Plain manual "run now" trials and interrupted runs stay silent, and so do occurrences that end without a finished run (launch error, queue timeout, interrupted by a restart). Channel/transport outages park deliveries without exhausting retries, for up to about a day; platform rejections retry for roughly 15 minutes before settling as `failed`. An identity you disconnect while a delivery is waiting is never pushed to: the row is dropped as `failed`.
- Browse execution history in pages of 50; older pages pause automatic refresh, with an explicit return to the latest runs. Counts appear only after a successful read; loading and failed reads are not reported as zero runs.
Derived run summaries omit leading inline `<think>` reasoning before limiting the text, so a long thought does not replace the actual answer in previews or scheduled notices. Original response history and literal tags in the answer are unchanged; previously stored run summaries are not rewritten.
@ -2828,6 +2830,23 @@ Enable background polling with `config.yaml -> scheduler.enabled`. Manual trigge
In the task form, switching an hourly, daily, weekly, or monthly schedule to **Custom cron** starts from its current timing, including selected weekdays or day of the month. Switching back to Custom cron after preset edits uses those edits rather than an older expression.
### Optional Feishu group notifications
In a task's create or edit form, **Also notify a Feishu group (optional)** adds
one chosen group to the existing personal notifications. Add the app bot to the
group, use **Bind a Feishu group** to get a command, send it while mentioning the
bot in that group, then refresh the list and select the group by name. The app
needs bot-send permission and `im:chat:read` to display its group names.
While the form checks group-notification support, it shows a loading hint and
retains the saved selection.
No group is selected automatically. Clearing the selection stops new group
notices; changing it affects future notices, while already queued notices keep
their original target. A disconnected binding never receives a waiting notice.
The first version is configured through the task form or REST API; conversation
schedule tools keep their existing inputs. See the [group delivery guide](backend/docs/IM_CHANNEL_CONNECTIONS.md#feishu-group-notifications).
### Lifecycle, safety caps and stop conditions
- The tasks page and the REST API (`POST` / `PATCH /api/scheduled-tasks`) accept the same per-run goal (`goal_objective`), safety cap (`max_runs`, `end_at`) and stop condition (`stop_condition`) as a conversation. Sending `null` in a PATCH clears any of these four; an `end_at` without a UTC offset is wall-clock time in the task's timezone.

View File

@ -13,7 +13,7 @@ Cursor writes drain before cancellation; token expiry drains cursor/auth clearin
- `manager.py` - Core dispatcher: creates threads via `client.threads.create()`, routes commands including `/goal` (setting a goal persists it through Gateway and then routes the objective as a chat turn) and `/agent` (`list` is owner-scoped; `use` creates a fresh thread and persists the Custom Agent selection under both the channel restart key and Web's canonical routing metadata so existing checkpoint lineage never changes agent across IM/Web), keeps Slack/Discord on `client.runs.wait()`, uses `client.runs.stream(["messages-tuple", "values"])` for Feishu/Telegram incremental outbound updates, serializes same-thread Feishu turns in-manager when the channel's `ChannelRunPolicy.serialize_thread_runs=True` so rapid follow-ups queue instead of tripping the runtime busy reply, and switches to `client.runs.create()` (fire-and-forget, returns once the run is `pending`) for channels whose `ChannelRunPolicy.fire_and_forget=True` so long autonomous runs do not hit the SDK default 300s `httpx.ReadTimeout`
Streaming `error` frames use the same failure path as transport exceptions. Publish the final outbound before releasing the inbound dedupe key, so provider redelivery can retry without overtaking the terminal reply. Log the frame's error name and message at WARNING with the thread ID; keep the synthetic error generic so payload details do not change reply selection.
**What may be published from the stream is an allowlist, not a denylist** (`_accumulate_stream_text`): only assistant message types — LangChain serializes `AIMessage.type` as `"ai"` and `AIMessageChunk.type` as `"AIMessageChunk"`, plus the OpenAI-style `"assistant"` spelling for foreign runtimes — become displayable text. The previous rule rejected only payloads whose `type` contained `"tool"` and therefore published everything else, which leaked DeerFlow's hidden model context to every streaming IM channel: `DynamicContextMiddleware` injects recalled memory as a hidden `HumanMessage` (`type == "human"`) and rewrites the user's own turn into a new `HumanMessage`, `DurableContextMiddleware` injects a hidden `<durable_context_data>` `HumanMessage`, and LangGraph fans those state writes out on the `messages-tuple` stream. Proved live on a Buzz relay, which published a `<memory>` fact block and, in another run, a verbatim echo of the user's own message as the assistant's reply. Matching is by prefix (`ai` / `assistant`), never substring, because ordinary words contain `"ai"` (`chain`, `domain`). The message type is resolved by `_stream_payload_type`, which handles both the `model_dump()` shape DeerFlow's own gateway emits and LangChain's `to_json()` constructor shape (whose top-level `type` is the literal `"constructor"`, with the class name at the tail of the `id` path). A bare `str` payload is no longer accepted at all: it carries no type information, so it cannot be attributed to the assistant, and nothing in DeerFlow produces one (`runtime/serialization.py::serialize_messages_tuple` always emits `[message_dict, metadata]`).
- `base.py` - Abstract `Channel` base class (start/stop/send lifecycle). Provider callbacks that submit coroutines from SDK threads must use `_submit_threadsafe_coroutine()` (or `_submit_threadsafe_coroutine_future()` to await the outcome through `asyncio.shield`): it creates and retains the real `asyncio.Task` on the owner loop instead of treating `run_coroutine_threadsafe()`'s proxy Future as a completion signal. Submission is closed atomically with shutdown, and `stop()` must call `_close_and_drain_threadsafe_futures()` before tearing down SDK resources. Blocking SDK teardown in `stop()` (a client `close()`, a thread `join()`) must run off the event loop through `asyncio.to_thread`, since every run and channel on the Gateway shares that loop and `stop()` also serves `POST /api/channels/{name}/restart`; Slack's `SocketModeClient.close()` is a shielded, tracked task so a cancelled stop leaves it running and a retried stop awaits the same close. Anchored by `tests/blocking_io/test_channel_stop_offloop.py`. **Proactive push** (`Channel.send_notification`): the scheduled-task outbox pushes markdown to a bound identity with no inbound frame. The base default raises `NotImplementedError` (after a `ChannelUnavailable` check when not running). Scheduler/Settings read the `proactive_notifications` flag in `capabilities.py`; providers without it get no rows (legacy rows end terminal `failed`); `test_channel_capabilities.py` pins it to the registry and overrides. Adding push: await ACK, raise `ChannelUnavailable` on transport failure (parks; rejections count), flip the flag. **WeCom/Feishu push**; Feishu uses owner-scoped p2p routing. WeCom uses `WSClient.send_message`; its SDK raises `RuntimeError` for a dead socket and a rejection alike, so only the `Reply ack error` prefix counts; other `RuntimeError`s, `ConnectionError`/`TimeoutError`/`OSError` and the `websockets` close exception park. Before sending, the worker re-checks the binding (owner-scoped `resolve_connections`, before liveness): a gone target is `mark_failed(terminal=True)`; a lookup failure parks. Terminal rows ignore later status writes. Scheduler side: `_on_finalization` enqueues each notice in the outcome's transaction, so recovery notifies once (unique key; idle ends use `idle:`); one message per occurrence (stop > pause > finish > run outcome); plain trials and interrupts stay silent; text (`scheduler/notification_text.py`) is localized, without IDs or links. Lifespan starts `NotificationDeliveryWorker` after the channel service (detaching enqueue if it cannot) and stops it first.
- `base.py` - Abstract `Channel` base class (start/stop/send lifecycle). Provider callbacks that submit coroutines from SDK threads must use `_submit_threadsafe_coroutine()` (or `_submit_threadsafe_coroutine_future()` to await the outcome through `asyncio.shield`): it creates and retains the real `asyncio.Task` on the owner loop instead of treating `run_coroutine_threadsafe()`'s proxy Future as a completion signal. Submission is closed atomically with shutdown, and `stop()` must call `_close_and_drain_threadsafe_futures()` before tearing down SDK resources. Blocking SDK teardown in `stop()` (a client `close()`, a thread `join()`) must run off the event loop through `asyncio.to_thread`, since every run and channel on the Gateway shares that loop and `stop()` also serves `POST /api/channels/{name}/restart`; Slack's `SocketModeClient.close()` is a shielded, tracked task so a cancelled stop leaves it running and a retried stop awaits the same close. Anchored by `tests/blocking_io/test_channel_stop_offloop.py`. **Proactive push** (`Channel.send_notification`): the scheduled-task outbox pushes markdown to a bound identity with no inbound frame. The base default raises `NotImplementedError` (after a `ChannelUnavailable` check when not running). `capabilities.py` gates enqueue/UI; unsupported legacy rows fail terminally (`test_channel_capabilities.py`). Await ACK; `ChannelUnavailable` parks transport failures, rejections count. Group targets use owner-bound connection IDs and a separate group hook; see [group delivery](../../docs/IM_CHANNEL_CONNECTIONS.md#feishu-group-notifications). **WeCom/Feishu push**; Feishu uses owner-scoped p2p routing. WeCom uses `WSClient.send_message`; its SDK raises `RuntimeError` for a dead socket and a rejection alike, so only the `Reply ack error` prefix counts; other `RuntimeError`s, `ConnectionError`/`TimeoutError`/`OSError` and the `websockets` close exception park. Before sending, the worker re-checks the binding (owner-scoped `resolve_connections`, before liveness): a gone target is `mark_failed(terminal=True)`; a lookup failure parks. Terminal rows ignore later status writes. Scheduler side: `_on_finalization` enqueues each notice in the outcome's transaction, so recovery notifies once (unique key; idle ends use `idle:`); one message per occurrence (stop > pause > finish > run outcome); plain trials and interrupts stay silent; text (`scheduler/notification_text.py`) is localized, without IDs or links. Lifespan starts `NotificationDeliveryWorker` after the channel service (detaching enqueue if it cannot) and stops it first.
- `wecom.py` - WeCom WebSocket channel with proactive `send_notification`; `_ws_transport_up` maps disconnects to `ChannelUnavailable` (no retry used). `capabilities.py` - SDK-free `CHANNEL_CAPABILITIES`.
- `service.py` - Drain manager work before transports; retain timed-out instances for retry. Live Feishu/DingTalk/Telegram joins raise `ChannelStopTimeout`: retain, defer replacement/restart, log WARNING without traceback; wedged instances report not running. Reload returns snapshots; restart owns cached fallback/publication under the channel lock. `_stopping` fences starts/publication (including after pre-start I/O) until shutdown succeeds. Lock manager startup through stale cleanup; capture the shutdown generation before locking to fence queued starts. `stop()` stays unlocked to fence in-flight startup.
- `slack.py` / `feishu.py` / `telegram.py` / `discord.py` / `dingtalk.py` - Platform-specific implementations (`feishu.py` tracks the running card `message_id` in memory and patches the same card in place; `telegram.py` accepts inbound text/photos/documents, preserves media captions, hands token-free attachment bytes to the shared upload pipeline, edits the "Working on it..." stream target in place via `editMessageText`, and can optionally send final Markdown replies as Rich Messages through `channels.telegram.rich_messages` — the Rich path only fires when `rich_messages` is on **and** the text contains a rich construct (fenced code, emphasis, a table, a task list, `<details>`, block math, or a link), so structured command/error replies (none of these) stay plain text and their newlines and `<placeholder>` tokens survive; `discord.py` runs `_on_message` on discord.py's private loop, so connection-repository work (identity lookup with the reservation commit, `/connect` binding) goes to the Gateway loop through `_submit_threadsafe_coroutine` — the SQLAlchemy engine is bound there — and bind replies return through `_run_on_discord_loop`; typing registers before that hand-off settles, and a failed hand-off skips the ack and stops the indicator it started unless another message still relies on it; `discord.py` registers typing loops before inbound handling yields and `_start_typing()` refuses work once `_running` is false; each tick sends the indicator with discord.py 2.x's `await channel.typing()` (`trigger_typing()` was removed in 2.0, and each typing loop logs its first failed tick at WARNING, later ones at DEBUG); because `stop()` runs on the main loop while typing tasks belong to `_discord_loop`, normal cross-thread cancellation, awaiting, and map cleanup are scheduled there with a bounded wait, while `_run_client()` drains tasks in its `finally` block before an exception/disconnect can make that loop unusable; an already-stopped foreign loop must never have its tasks awaited from the main loop; to that end every outbound cross-loop call (`send` / `send_file` / `_get_channel_or_thread`) goes through `_run_on_discord_loop`, which bounds the await (`DISCORD_OUTBOUND_TIMEOUT_SECONDS` 30s; `DISCORD_UPLOAD_TIMEOUT_SECONDS` 120s for file uploads, whose unbounded-size payload needs room for a slow uplink plus 429 retry-after) and fails fast with a `RuntimeError` when the client loop is missing or not running — a dead client becomes a logged send failure instead of a permanently hung `ChannelManager` worker — and `is_running` reports client-thread aliveness (like `feishu.py`) so `ensure_channel_ready` can restart the channel after `_run_client()` exits on a fatal error; `dingtalk.py` optionally uses AI Card streaming for in-place updates when `card_template_id` is configured, and overrides `receive_file` to download inbound images (`picture`/`richText`) and documents (`file`) by `downloadCode` into the thread uploads bucket, mirroring `feishu.py`)

View File

@ -157,6 +157,14 @@ class Channel(ABC):
"""
await self.send_notification(target=target, text_markdown=text_markdown)
async def send_group_notification_for_owner(self, *, owner_user_id: str | None, target: str, text_markdown: str) -> None:
"""Push to an explicitly selected group binding, never a personal fallback.
``target`` is the owner's connection ID, not a provider chat ID.
Providers must revalidate that binding before sending to its group.
"""
raise NotImplementedError(f"channel '{self.name}' does not support group notifications")
# -- helpers -----------------------------------------------------------
async def _send_with_retry(

View File

@ -36,6 +36,7 @@ from app.channels.message_bus import (
OutboundMessage,
ResolvedAttachment,
)
from app.channels.notification_groups import is_feishu_notification_group
from app.channels.sandbox_files import sync_file_to_thread_sandbox
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths
from deerflow.runtime.user_context import get_effective_user_id
@ -189,6 +190,7 @@ class FeishuChannel(Channel):
self._CreateImageRequest = None
self._CreateImageRequestBody = None
self._GetMessageResourceRequest = None
self._GetChatRequest = None
self._thread_lock = threading.Lock()
# Per-chat FIFO of messages whose routing is deferred to the Gateway loop
# (unresolved store lookups); guarded by ``_thread_lock``. A chat with a
@ -286,6 +288,7 @@ class FeishuChannel(Channel):
CreateMessageRequest,
CreateMessageRequestBody,
Emoji,
GetChatRequest,
GetMessageResourceRequest,
PatchMessageRequest,
PatchMessageRequestBody,
@ -318,6 +321,7 @@ class FeishuChannel(Channel):
self._CreateImageRequest = CreateImageRequest
self._CreateImageRequestBody = CreateImageRequestBody
self._GetMessageResourceRequest = GetMessageResourceRequest
self._GetChatRequest = GetChatRequest
app_id = self.config.get("app_id", "")
app_secret = self.config.get("app_secret", "")
@ -485,6 +489,21 @@ class FeishuChannel(Channel):
if not response.success():
raise RuntimeError(f"Feishu notification rejected: code={response.code}")
async def send_group_notification_for_owner(self, *, owner_user_id: str | None, target: str, text_markdown: str) -> None:
if self._connection_repo is None or not owner_user_id:
raise ChannelUnavailable("Feishu notification binding lookup is unavailable")
try:
connections = await self._connection_repo.list_connections(owner_user_id)
except Exception as exc:
raise ChannelUnavailable("Feishu notification binding lookup is unavailable") from exc
for connection in connections:
if connection.get("owner_user_id") != owner_user_id or connection.get("id") != target:
continue
if is_feishu_notification_group(provider=connection.get("provider"), status=connection.get("status"), workspace_id=connection.get("workspace_id"), metadata=connection.get("metadata")):
await self._send_notification_card(receive_id=connection["workspace_id"], receive_id_type="chat_id", text_markdown=text_markdown)
return
raise RuntimeError("Feishu notification group is no longer connected")
async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool:
if not self._api_client:
return False
@ -1679,6 +1698,19 @@ class FeishuChannel(Channel):
workspace_id=inbound.chat_id,
)
async def _get_group_name(self, chat_id: str) -> str | None:
"""Best-effort display name; missing read permission must not break binding."""
if not self._api_client or not self._GetChatRequest:
return None
try:
request = self._GetChatRequest.builder().chat_id(chat_id).build()
response = await asyncio.to_thread(self._api_client.im.v1.chat.get, request)
if response.success():
return self._non_empty_str(getattr(response.data, "name", None))
except Exception:
logger.debug("[Feishu] group name lookup unavailable; binding without a display name")
return None
async def _bind_connection_from_connect_code(self, *, message_id: str, chat_id: str, user_id: str, code: str, chat_type: str | None = None) -> bool:
if self._connection_repo is None or not code:
return False
@ -1697,6 +1729,7 @@ class FeishuChannel(Channel):
provider="feishu",
external_account_id=user_id,
workspace_id=chat_id,
workspace_name=await self._get_group_name(chat_id) if chat_type == "group" else None,
metadata={
"chat_id": chat_id,
"message_id": message_id,

View File

@ -0,0 +1,6 @@
"""SDK-free checks for explicit Feishu group notification destinations."""
def is_feishu_notification_group(*, provider: object, status: object, workspace_id: object, metadata: object) -> bool:
"""Check the group shape; callers must separately verify owner and ID."""
return provider == "feishu" and status == "connected" and isinstance(workspace_id, str) and bool(workspace_id.strip()) and isinstance(metadata, dict) and metadata.get("chat_type") == "group" and metadata.get("chat_id") == workspace_id

View File

@ -73,6 +73,7 @@ class ScheduledTasksFeature(BaseModel):
running: bool = Field(..., description="Whether this process's scheduler poller is running, so tasks run on schedule and new tasks can be created")
tool_enabled: bool = Field(..., description="Whether chats can create and manage tasks and scheduled runs can stop their own schedule")
min_interval_seconds: int = Field(..., description="Shortest interval and earliest one-time delay, in seconds")
group_notifications: bool = Field(..., description="Whether scheduled tasks support explicitly selected Feishu group notifications")
class ThreadActivityFeature(BaseModel):
@ -143,11 +144,13 @@ def _scheduled_tasks_feature(request: Request, config: AppConfig) -> ScheduledTa
service = getattr(state, "scheduled_task_service", None)
running = bool(getattr(service, "is_running", False))
scheduler = getattr(config, "scheduler", None)
repository_available = getattr(state, "scheduled_task_repo", None) is not None
return ScheduledTasksFeature(
available=getattr(state, "scheduled_task_repo", None) is not None,
available=repository_available,
running=running,
tool_enabled=running and scheduler_tools_enabled(config),
min_interval_seconds=int(getattr(scheduler, "min_once_delay_seconds", 60)),
group_notifications=repository_available and bool(getattr(getattr(config, "channel_connections", None), "enabled", False)),
)

View File

@ -9,6 +9,7 @@ from zoneinfo import ZoneInfo
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import AwareDatetime, BaseModel, Field
from app.channels.notification_groups import is_feishu_notification_group
from app.gateway.authz import require_permission
from app.gateway.deps import (
get_config,
@ -104,6 +105,32 @@ async def _task_response(repo, task: dict[str, Any]) -> dict[str, Any]:
return {**task, "automatic_runs_used": used.get(task["id"], 0), "active_run_status": await repo.get_active_run_status(task["id"])}
async def _validate_notification_group(request: Request, connection_id: str | None, *, user_id: str) -> str | None:
"""Resolve an explicitly selected group without changing personal delivery."""
if connection_id is None:
return None
if not connection_id.strip() or len(connection_id) > 64:
raise scheduler_error(422, "invalid_notification_group", "Select a connected Feishu group belonging to this user")
# Reuse the connection UI's current config and owner-scoped repository.
# Keep this REST-only: the conversation scheduling tools have no group field.
from app.gateway.routers import channel_connections
try:
config = await channel_connections._get_channel_connections_config(request)
connections = await channel_connections._get_repository(request, config).list_connections(user_id) if config.enabled else []
except Exception as exc:
raise scheduler_error(503, "scheduler_unavailable", "Notification group lookup is unavailable; retry later") from exc
if not isinstance(connections, list):
raise scheduler_error(503, "scheduler_unavailable", "Notification group lookup is unavailable; retry later")
for connection in connections:
if not isinstance(connection, dict) or connection.get("id") != connection_id or connection.get("owner_user_id") != user_id:
continue
if is_feishu_notification_group(provider=connection.get("provider"), status=connection.get("status"), workspace_id=connection.get("workspace_id"), metadata=connection.get("metadata")):
return connection_id
raise scheduler_error(422, "invalid_notification_group", "Select a connected Feishu group belonging to this user")
class ScheduledTaskCreateRequest(BaseModel):
# Types only: value rules (max_runs >= 1, lengths, end-time rules) raise
# coded errors from the shared validation, never a FastAPI list detail.
@ -122,6 +149,7 @@ class ScheduledTaskCreateRequest(BaseModel):
end_at: datetime | None = None
# The user's "stop when ..." rule, stored in its own column.
stop_condition: str | None = None
notification_group_connection_id: str | None = None
class ScheduledTaskUpdateRequest(BaseModel):
@ -139,6 +167,7 @@ class ScheduledTaskUpdateRequest(BaseModel):
max_runs: int | bool | None = None
end_at: datetime | None = None
stop_condition: str | None = None
notification_group_connection_id: str | None = None
class ScheduledTaskResumeRequest(BaseModel):
@ -226,6 +255,7 @@ async def create_scheduled_task(request: Request, body: ScheduledTaskCreateReque
assistant_resolver=resolve_scheduled_task_assistant_id,
now=datetime.now(UTC),
)
definition["notification_group_connection_id"] = await _validate_notification_group(request, body.notification_group_connection_id, user_id=str(user.id))
# After validation, so a bad request still reports what to fix: a task
# created while this process's poller is off would silently never run.
if not is_scheduler_running(request):
@ -279,6 +309,8 @@ async def update_scheduled_task(task_id: str, request: Request, body: ScheduledT
await _ensure_task_mutable(existing, repo)
used = (await repo.automatic_runs_used_for([task_id])).get(task_id, 0)
fields = body.model_dump(exclude_unset=True)
# Group destination edits are independent of schedule/goal lifecycle rules.
fields.pop("notification_group_connection_id", None)
# Only a reused conversation needs the thread store (access check).
reuses_thread = (fields.get("context_mode") or existing["context_mode"]) == "reuse_thread"
updates = await validate_scheduled_task_update(
@ -291,6 +323,8 @@ async def update_scheduled_task(task_id: str, request: Request, body: ScheduledT
used=used,
now=datetime.now(UTC),
)
if "notification_group_connection_id" in body.model_fields_set:
updates["notification_group_connection_id"] = await _validate_notification_group(request, body.notification_group_connection_id, user_id=str(user.id))
try:
updated = await repo.update(
task_id,

View File

@ -34,6 +34,7 @@ SCHEDULER_UI_ERROR_CODES: frozenset[str] = frozenset(
"invalid_goal",
"goal_requires_fresh_thread",
"invalid_stop_condition",
"invalid_notification_group",
"invalid_max_runs",
"end_at_in_past",
"end_at_before_first_run",

View File

@ -20,6 +20,7 @@ from typing import Any
from app.channels.base import ChannelUnavailable
from app.channels.capabilities import supports_proactive_notifications
from app.channels.notification_groups import is_feishu_notification_group
from app.scheduler.notification_text import DEFAULT_NOTIFICATION_LOCALE, redact_egress_text, render_notification_text, wants_result_summary
__all__ = ["NotificationDeliveryWorker", "redact_egress_text", "render_notification_text"]
@ -186,6 +187,7 @@ class NotificationDeliveryWorker:
owner_user_id = delivery.get("owner_user_id")
provider = delivery.get("provider")
target = delivery.get("target")
is_group = (delivery.get("payload") or {}).get("target_type") == "group"
if not owner_user_id or not provider or not target:
return None
try:
@ -212,6 +214,14 @@ class NotificationDeliveryWorker:
row_owner = connection.get("owner_user_id")
if row_owner is not None and row_owner != owner_user_id:
continue
if is_group:
if (
row_owner == owner_user_id
and connection.get("id") == target
and is_feishu_notification_group(provider=connection.get("provider"), status=connection.get("status"), workspace_id=connection.get("workspace_id"), metadata=connection.get("metadata"))
):
return True
continue
if connection.get("provider") == provider and connection.get("external_account_id") == target and connection.get("status") == "connected":
return True
return False
@ -219,6 +229,10 @@ class NotificationDeliveryWorker:
async def _deliver(self, delivery: dict[str, Any]) -> None:
delivery_id = delivery["id"]
provider = delivery.get("provider") or ""
target_type = (delivery.get("payload") or {}).get("target_type")
if target_type not in (None, "group") or (target_type == "group" and provider != "feishu"):
await self._delivery_repo.mark_failed(delivery_id, claim_token=delivery.get("claim_token"), error="unsupported notification target type", terminal=True)
return
if not supports_proactive_notifications(provider):
# The scheduler enqueues only for providers with proactive push;
# a row for any other provider predates that rule. It can never
@ -230,7 +244,7 @@ class NotificationDeliveryWorker:
terminal=True,
)
return
if self._resolve_connections is not None:
if self._resolve_connections is not None or target_type == "group":
# Re-check the binding at delivery time, before anything else: a
# target the owner has disconnected since enqueue must never
# receive the push, however long the row waited, and it must not
@ -271,7 +285,8 @@ class NotificationDeliveryWorker:
enriched = dict(delivery)
enriched["payload"] = {**(delivery.get("payload") or {}), "result_summary": summary}
try:
await channel.send_notification_for_owner(
send = channel.send_group_notification_for_owner if target_type == "group" else channel.send_notification_for_owner
await send(
owner_user_id=delivery.get("owner_user_id") or "",
target=delivery.get("target") or "",
text_markdown=render_notification_text(enriched, default_locale=self._default_locale),

View File

@ -357,21 +357,27 @@ class ScheduledTaskService:
for binding in await session.scalars(select(ChannelConnectionRow).where(ChannelConnectionRow.owner_user_id == task.user_id, ChannelConnectionRow.status == "connected"))
if binding.provider and binding.external_account_id and supports_proactive_notifications(binding.provider)
]
if not bindings:
group_connection_id = getattr(task, "notification_group_connection_id", None)
if not bindings and not group_connection_id:
return
payload = _notice_payload(task, occurrence, event, reason, locale=await self._owner_locale(session, task.user_id))
task_run_id = occurrence.id if occurrence is not None else _idle_notice_key(task.id, lifecycle_anchor(task, occurrence, now=now))
for binding in bindings:
recipients = [(binding.provider, binding.external_account_id, payload) for binding in bindings]
if group_connection_id:
# Keep the selected binding identity even if it was removed: the
# worker records an explicit failed delivery after its owner check.
recipients.append(("feishu", group_connection_id, {**payload, "target_type": "group"}))
for provider, target, notice_payload in recipients:
await self._notification_repo.enqueue_in_session(
session,
task_id=task.id,
task_run_id=task_run_id,
run_id=occurrence.run_id if occurrence is not None else None,
event=event,
provider=binding.provider,
target=binding.external_account_id,
provider=provider,
target=target,
owner_user_id=task.user_id,
payload=payload,
payload=notice_payload,
)
@staticmethod

View File

@ -1578,15 +1578,21 @@ POST /api/scheduled-tasks/preview-cron
| `max_runs` | integer \| null | Safety cap: automatic runs over the task's lifetime (trial runs excluded); at least 1. |
| `end_at` | date-time \| null | Safety cap: no automatic run after this time. Without a UTC offset it is wall-clock time in the task's timezone. |
| `stop_condition` | string \| null | The user's "stop when …" rule, stored in its own field. Whitespace collapses to single spaces; at most 500 characters; blank means none. It is never part of `prompt`: each launch appends it as an instruction to call `stop_scheduled_task` (or, with `scheduler.tool_enabled` off, to report a met rule). |
| `notification_group_connection_id` | string \| null | One additional connected Feishu group belonging to the task owner. Select a group binding returned by `GET /api/channels/connections`; it must record `chat_type=group` and a matching chat/workspace ID. Omitted in PATCH preserves the target; null clears it. Invalid choices return `invalid_notification_group` (422), lookup outages return `scheduler_unavailable` (503). |
In a PATCH, sending `null` clears `goal_objective`, `max_runs`, `end_at` or
`stop_condition`; for every other field `null` means unchanged (`assistant_id:
`stop_condition` or `notification_group_connection_id`; for every other field `null` means unchanged (`assistant_id:
null` restores `lead_agent`). PATCH may change `schedule_type` together with
`schedule_spec`. Changing the goal, `prompt` or `stop_condition` starts a new
count for the automatic pause after three unmet runs. A PATCH that changes the
schedule of a finished task re-arms it (then its safety cap applies); a PATCH
that only changes the cap leaves a finished task finished.
Group-target edits alone never change the schedule or re-arm a finished task.
Personal recipients are retained, and pending outbox rows keep their original
target. `/api/features.scheduled_tasks.group_notifications` reports support;
the conversation scheduling tool's inputs are unchanged in this version.
Task responses (list, get, create, PATCH, pause, resume) return the stored task
plus `automatic_runs_used` (integer) and `active_run_status` (`queued` |
`launching` | `running` | null). A recurring task's `status` stays `enabled`
@ -1677,7 +1683,7 @@ Three errors keep their old shape: a `403` from route permissions is the plain
string `"Permission denied: <permission>"`, FastAPI's own `422` for malformed
JSON or wrong types (for example `"max_runs": "abc"`) keeps its list `detail`,
and the shared `503` `"Thread metadata store not available"` (a Gateway without
a thread store) stays a plain string. Codes the web UI translates: `invalid_request`, `invalid_schedule`, `invalid_schedule_type`, `invalid_timezone`, `interval_too_short`, `interval_too_long`, `once_in_past`, `once_too_soon`, `once_time_passed`, `invalid_context_mode`, `reuse_thread_requires_thread`, `thread_not_found`, `invalid_assistant`, `unknown_assistant`, `invalid_goal`, `goal_requires_fresh_thread`, `invalid_stop_condition`, `invalid_max_runs`, `end_at_in_past`, `end_at_before_first_run`, `frequent_requires_limit`, `max_runs_not_above_used`, `limits_exhausted`, `task_not_found`, `task_running`, `run_queued`, `task_changed`, `task_finished`, `task_quota_exceeded`, `scheduler_not_running`, `scheduler_unavailable` (503: this Gateway has no scheduled-task persistence), `trigger_failed`. Codes only the chat capability
a thread store) stays a plain string. Codes the web UI translates: `invalid_request`, `invalid_schedule`, `invalid_schedule_type`, `invalid_timezone`, `interval_too_short`, `interval_too_long`, `once_in_past`, `once_too_soon`, `once_time_passed`, `invalid_context_mode`, `reuse_thread_requires_thread`, `thread_not_found`, `invalid_assistant`, `unknown_assistant`, `invalid_goal`, `goal_requires_fresh_thread`, `invalid_stop_condition`, `invalid_notification_group`, `invalid_max_runs`, `end_at_in_past`, `end_at_before_first_run`, `frequent_requires_limit`, `max_runs_not_above_used`, `limits_exhausted`, `task_not_found`, `task_running`, `run_queued`, `task_changed`, `task_finished`, `task_quota_exceeded`, `scheduler_not_running`, `scheduler_unavailable` (503: this Gateway has no scheduled-task persistence), `trigger_failed`. Codes only the chat capability
returns: `timezone_required`, `authentication_required`, `permission_denied`, `scheduler_tools_disabled`, `authority_expired`, `conversation_not_found`, `interactive_run_required`, `unsupported_action`, `unsupported_fields`, `task_id_required`, `note_not_verbatim`, `note_limit_reached`, `trial_requires_direct_request`, `no_stop_authority`, `occurrence_not_active`. The machine-readable list is
`contracts/scheduled_task_errors_contract.json`.

View File

@ -567,6 +567,7 @@ Notes:
- Scheduled task definitions and task-run history are persisted in the application database.
- With `channel_connections.enabled: true`, the scheduler sends IM notices ("scheduled task updates") to the task owner's connected identities. A delivery worker (same poll cadence as the scheduler) pushes them.
- **Which apps:** only providers whose channel implements proactive push get notices; today those are WeCom and Feishu/Lark (`proactive_notifications` in `app/channels/capabilities.py`, also returned by `GET /api/channels/providers`, where Settings shows "sent here" or "not available for this app yet"). Other providers get no outbox rows at all, and rows queued for them before the upgrade end once as `failed` without retries.
- **Optional Feishu group:** each task may select one additional owner-bound group through the task form or `notification_group_connection_id` in REST. No group is selected automatically; personal delivery is retained. The app needs `im:chat:read` for names, and a private binding never becomes a group destination. [Group setup and delivery](IM_CHANNEL_CONNECTIONS.md#feishu-group-notifications).
- **Events:** `run_completed`, `run_failed`, `run_unmet` (goal missed), `task_paused` (automatic pause after three missed goals), `task_stopped` (paused by the agent because its stop condition was met) and `task_finished` (all `max_runs` done or `end_at` reached).
- **One message per occurrence:** when one occurrence produces several events, only the first of `task_stopped` > `task_paused` > `task_finished` > `run_failed` > `run_unmet` > `run_completed` is sent, and that message still says how the last run went ("The last run failed." / "didn't meet the goal"). A one-time task sends only its run's outcome, never a separate `task_finished`.
- **Transactional:** notices are queued by the finalization observer in the same database transaction that records the outcome and are deduplicated per occurrence (or per end time for a task that ends between runs), so an occurrence finalized by crash or lease recovery notifies exactly once. Plain manual "run now" trials and interrupted runs stay silent; a trial that pauses or finishes the task still sends that notice. Occurrences that end without a finished run (launch error, queue timeout, interrupted by a restart) stay silent.

View File

@ -474,6 +474,39 @@ can retry an ambiguously acknowledged send; it does not promise exactly-once
messages at the provider. Record whether domestic Feishu or international Lark
was actually exercised.
### Feishu group notifications
Tasks may explicitly select one additional connected Feishu group. Personal
notifications keep their existing recipients. The task stores the chosen
`notification_group_connection_id`; a group notice stores that connection ID as
its outbox target with `payload.target_type="group"`. It never infers a group
destination from all of the owner's bindings.
Add the app bot to a test or work group, grant `im:chat:read` for the group-name
lookup, and publish the app changes. In the task form choose **Bind a Feishu
group**, mention the bot and send the displayed `/connect` command in that
group, then refresh and select its name. Name lookup failure does not discard
the binding, but unnamed groups cannot be newly selected in the form: check
the read permission and bind again. API selection and each delivery verify the
task owner, connection ID, connected status, and recorded group/chat identity.
The provider sends to that verified `chat_id`; a lost binding cannot fall back
to another group or a private chat. A bot removed from the group produces a
normal platform rejection handled by the existing retry budget.
Omitting the field in PATCH preserves it; `null` clears it. Destination-only
edits do not re-arm completed tasks or change the run budget. The destination
is fixed when each notification is enqueued; later task edits affect future
notices, and unbinding still blocks pending ones. Migration `0040` adds a nullable
reference with no backfill. Upgrade Gateway workers and the frontend together;
the form uses `/api/features.scheduled_tasks.group_notifications` to hide new
selection on older Gateways. The conversation scheduling tool does not set
group targets in this version.
Real-provider acceptance should confirm that a selected task appears in the
specified group, an unselected task remains private, and removing the bot or
revoking its binding prevents a successful group send. Record the account and
tenant setup actually used; the outbox remains at-least-once.
### QQ WebSocket setup (MVP)
QQ uses an outbound WebSocket connection for events and the QQ Open Platform

View File

@ -0,0 +1,28 @@
"""Add an optional Feishu group notification connection to scheduled tasks.
Revision ID: 0040_scheduled_group_notice
Revises: 0039_user_disabled
Legacy tasks keep NULL and their existing owner notifications. There is no
foreign key: disconnecting a group must retain the selected target so pending
delivery can report the loss instead of silently losing the destination.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from deerflow.persistence.migrations._helpers import safe_add_column, safe_drop_column
revision: str = "0040_scheduled_group_notice"
down_revision: str | None = "0039_user_disabled"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
safe_add_column("scheduled_tasks", sa.Column("notification_group_connection_id", sa.String(64), nullable=True))
def downgrade() -> None:
safe_drop_column("scheduled_tasks", "notification_group_connection_id")

View File

@ -19,6 +19,8 @@ class ScheduledTaskRow(Base):
goal_objective: Mapped[str | None] = mapped_column(Text, nullable=True)
# The user's normalized "stop when ..." rule; never part of ``prompt``.
stop_condition: Mapped[str | None] = mapped_column(Text, nullable=True)
# Optional additional Feishu group; keep the reference after disconnection.
notification_group_connection_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
max_runs: Mapped[int | None] = mapped_column(Integer, nullable=True)
end_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
standing_notes: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)

View File

@ -149,6 +149,7 @@ class ScheduledTaskRepository:
end_at: datetime | str | None = None,
standing_notes: list[str] | None = None,
stop_condition: str | None = None,
notification_group_connection_id: str | None = None,
) -> dict[str, Any]:
if goal_objective is not None:
normalize_goal_objective(goal_objective)
@ -166,6 +167,7 @@ class ScheduledTaskRepository:
origin_thread_id=origin_thread_id,
goal_objective=goal_objective,
stop_condition=stop_condition,
notification_group_connection_id=notification_group_connection_id,
max_runs=max_runs,
end_at=_coerce_datetime(end_at),
standing_notes=list(standing_notes) if standing_notes is not None else None,

View File

@ -37,6 +37,7 @@ def _app_with_config(
scheduled_task_repo_available: bool = False,
scheduler_running: bool = False,
scheduler_tool_enabled: bool = False,
channel_connections_enabled: bool | None = None,
) -> FastAPI:
app = FastAPI()
app.state.scheduled_task_repo = object() if scheduled_task_repo_available else None
@ -62,6 +63,8 @@ def _app_with_config(
),
scheduler=SimpleNamespace(enabled=scheduler_running, tool_enabled=scheduler_tool_enabled, min_once_delay_seconds=60),
)
if channel_connections_enabled is not None:
fake_config.channel_connections = SimpleNamespace(enabled=channel_connections_enabled)
search_tool = SimpleNamespace(use=knowledge_search_provider) if knowledge_search_provider is not None else None
fake_config.get_tool_config = lambda name: search_tool if name == "knowledge_search" else None
app.dependency_overrides[get_config] = lambda: fake_config
@ -86,7 +89,7 @@ def test_features_reports_agents_api_enabled() -> None:
"knowledge_base": {
"scope_selection_enabled": False,
},
"scheduled_tasks": {"available": False, "running": False, "tool_enabled": False, "min_interval_seconds": 60},
"scheduled_tasks": {"available": False, "running": False, "tool_enabled": False, "min_interval_seconds": 60, "group_notifications": False},
"thread_activity": {"available": False},
}
@ -109,7 +112,7 @@ def test_features_reports_agents_api_disabled() -> None:
"knowledge_base": {
"scope_selection_enabled": False,
},
"scheduled_tasks": {"available": False, "running": False, "tool_enabled": False, "min_interval_seconds": 60},
"scheduled_tasks": {"available": False, "running": False, "tool_enabled": False, "min_interval_seconds": 60, "group_notifications": False},
"thread_activity": {"available": False},
}
@ -256,7 +259,19 @@ def test_features_reports_scheduled_tasks_process_state(repo: bool, running: boo
with TestClient(app) as client:
response = client.get("/api/features")
assert response.status_code == 200
assert response.json()["scheduled_tasks"] == {**expected, "min_interval_seconds": 60}
assert response.json()["scheduled_tasks"] == {**expected, "min_interval_seconds": 60, "group_notifications": False}
@pytest.mark.parametrize("repo", [False, True])
@pytest.mark.parametrize("connections", [None, False, True])
@pytest.mark.parametrize("running", [False, True])
def test_features_group_notifications_require_repository_and_channel_connections(repo, connections, running):
app = _app_with_config(agents_api_enabled=True, scheduled_task_repo_available=repo, scheduler_running=running, channel_connections_enabled=connections)
with TestClient(app) as client:
response = client.get("/api/features")
assert response.status_code == 200
# Existing tasks remain configurable even while the local poller is stopped.
assert response.json()["scheduled_tasks"]["group_notifications"] is (repo and connections is True)
class _SqlRunStore:

View File

@ -19,7 +19,7 @@ from app.channels.message_bus import MessageBus
@pytest.fixture
def channel():
from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody
from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody, GetChatRequest
instance = FeishuChannel(MessageBus(), {})
instance._running = True
@ -27,6 +27,7 @@ def channel():
instance._thread.is_alive.return_value = True
instance._CreateMessageRequest = CreateMessageRequest
instance._CreateMessageRequestBody = CreateMessageRequestBody
instance._GetChatRequest = GetChatRequest
instance._api_client = MagicMock()
instance._api_client.im.v1.message.create.return_value = SimpleNamespace(success=lambda: True)
return instance
@ -44,6 +45,83 @@ def _private_binding(**overrides):
}
def _group_binding(**overrides):
return _private_binding(id="group-binding", workspace_id="oc_group", metadata={"chat_id": "oc_group", "chat_type": "group"}, **overrides)
@pytest.mark.asyncio
async def test_base_group_notification_is_unsupported_and_never_falls_back(channel):
channel.send_notification = AsyncMock()
with pytest.raises(NotImplementedError):
await Channel.send_group_notification_for_owner(channel, owner_user_id="owner-1", target="group-binding", text_markdown="Done")
channel.send_notification.assert_not_called()
@pytest.mark.asyncio
async def test_group_notification_uses_selected_binding_and_group_chat(channel):
channel._connection_repo = SimpleNamespace(list_connections=AsyncMock(return_value=[_private_binding(), _group_binding()]))
await channel.send_group_notification_for_owner(owner_user_id="owner-1", target="group-binding", text_markdown="Done")
channel._connection_repo.list_connections.assert_awaited_once_with("owner-1")
create = channel._api_client.im.v1.message.create
create.assert_called_once()
request = create.call_args.args[0]
assert request.receive_id_type == "chat_id"
assert request.request_body.receive_id == "oc_group"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"overrides",
[
{"id": "different-binding"},
{"owner_user_id": "another-owner"},
{"provider": "wecom"},
{"status": "revoked"},
{"workspace_id": "oc_other"},
{"metadata": {"chat_id": "oc_group", "chat_type": "p2p"}},
{"metadata": {"chat_id": "oc_group"}},
],
)
async def test_group_notification_refuses_invalid_binding_without_private_fallback(channel, overrides):
binding = {**_group_binding(), **overrides}
channel._connection_repo = SimpleNamespace(list_connections=AsyncMock(return_value=[binding]))
with pytest.raises(RuntimeError, match="no longer connected"):
await channel.send_group_notification_for_owner(owner_user_id="owner-1", target="group-binding", text_markdown="Done")
channel._api_client.im.v1.message.create.assert_not_called()
@pytest.mark.asyncio
async def test_group_notification_binding_lookup_failure_parks(channel):
channel._connection_repo = SimpleNamespace(list_connections=AsyncMock(side_effect=OSError("private database detail")))
with pytest.raises(ChannelUnavailable):
await channel.send_group_notification_for_owner(owner_user_id="owner-1", target="group-binding", text_markdown="Done")
channel._api_client.im.v1.message.create.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("outcome", ["success", "rejected", "exception"])
async def test_group_binding_reads_name_offloop_without_blocking_binding_on_failure(channel, outcome):
channel._connection_repo = SimpleNamespace(consume_oauth_state=AsyncMock(return_value={"owner_user_id": "owner-1"}), upsert_connection=AsyncMock())
channel._reply_card = AsyncMock()
loop_thread = threading.get_ident()
worker_threads = []
def get_name(request):
worker_threads.append(threading.get_ident())
assert request.chat_id == "oc_group"
if outcome == "exception":
raise OSError("private transport detail")
return SimpleNamespace(success=lambda: outcome == "success", code=0 if outcome == "success" else 99991672, data=SimpleNamespace(name="Release team"))
channel._api_client.im.v1.chat.get.side_effect = get_name
assert await channel._bind_connection_from_connect_code(message_id="om_bind", chat_id="oc_group", user_id="ou_bound_user", code="connect-code", chat_type="group")
binding = channel._connection_repo.upsert_connection.call_args.kwargs
assert worker_threads and worker_threads != [loop_thread]
assert binding.get("workspace_name") == ("Release team" if outcome == "success" else None)
assert binding["metadata"]["chat_type"] == "group"
assert binding["metadata"]["chat_id"] == "oc_group"
@pytest.mark.asyncio
async def test_base_owner_notification_preserves_existing_provider_send(channel):
channel.send_notification = AsyncMock()

View File

@ -23,7 +23,7 @@ OUTBOX = "0027_notification_deliveries"
PARKED = "0028_parked_attempts"
SCHEDULER = "0029_scheduler_agent_tasks"
CLAIM_TOKENS = "0030_notification_claim_tokens"
HEAD = "0039_user_disabled"
HEAD = "0040_scheduled_group_notice"
PREVIOUS = "0026_mcp_task_lease_tokens"
TABLE = "notification_deliveries"

View File

@ -23,7 +23,7 @@ from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
from deerflow.persistence.scheduled_tasks import ScheduledTaskRepository
REVISION = "0029_scheduler_agent_tasks"
CURRENT_HEAD = "0039_user_disabled"
CURRENT_HEAD = "0040_scheduled_group_notice"
NEXT = "0030_notification_claim_tokens"
PREVIOUS = "0028_parked_attempts"
TASK_FIELDS = {"origin_thread_id", "goal_objective", "max_runs", "end_at", "standing_notes"}

View File

@ -24,7 +24,7 @@ IDEMPOTENCY = "0036_run_idempotency_request"
DOCUMENT_SUMMARIES = "0037_project_document_summaries"
CHANNEL_BINDINGS = "0038_channel_thread_bindings"
USER_DISABLED = "0039_user_disabled"
CURRENT_HEAD = "0039_user_disabled"
CURRENT_HEAD = "0040_scheduled_group_notice"
PREVIOUS = "0031_scheduled_streak_boundary"
TABLES = {"thread_read_markers", "thread_read_versions", "scheduled_task_events"}
INDEXES = {

View File

@ -24,7 +24,7 @@ IDEMPOTENCY = "0036_run_idempotency_request"
DOCUMENT_SUMMARIES = "0037_project_document_summaries"
CHANNEL_BINDINGS = "0038_channel_thread_bindings"
USER_DISABLED = "0039_user_disabled"
CURRENT_HEAD = "0039_user_disabled"
CURRENT_HEAD = "0040_scheduled_group_notice"
TABLE = "run_event_thread_seq"
pytestmark = pytest.mark.asyncio

View File

@ -25,7 +25,7 @@ IDEMPOTENCY = "0036_run_idempotency_request"
DOCUMENT_SUMMARIES = "0037_project_document_summaries"
CHANNEL_BINDINGS = "0038_channel_thread_bindings"
USER_DISABLED = "0039_user_disabled"
CURRENT_HEAD = "0039_user_disabled"
CURRENT_HEAD = "0040_scheduled_group_notice"
PREVIOUS = "0034_run_event_seq_watermark"
TABLE = "login_throttle"
COLUMNS = {"ip", "fail_count", "locked_at", "lock_duration_seconds", "updated_at"}

View File

@ -19,7 +19,8 @@ from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
REVISION = "0037_project_document_summaries"
PREVIOUS = "0036_run_idempotency_request"
CHANNEL_BINDINGS = "0038_channel_thread_bindings"
CURRENT_HEAD = "0039_user_disabled"
USER_DISABLED = "0039_user_disabled"
CURRENT_HEAD = "0040_scheduled_group_notice"
pytestmark = pytest.mark.asyncio
@ -27,7 +28,8 @@ async def test_0037_chains_after_0036_and_is_single_head():
script = ScriptDirectory(str(bootstrap._MIGRATIONS_DIR))
assert script.get_heads() == [CURRENT_HEAD]
assert script.get_revision(CHANNEL_BINDINGS).down_revision == REVISION
assert script.get_revision(CURRENT_HEAD).down_revision == CHANNEL_BINDINGS
assert script.get_revision(USER_DISABLED).down_revision == CHANNEL_BINDINGS
assert script.get_revision(CURRENT_HEAD).down_revision == USER_DISABLED
assert script.get_revision(REVISION).down_revision == PREVIOUS
# alembic_version.version_num is VARCHAR(32).
assert len(REVISION) <= 32

View File

@ -22,7 +22,7 @@ from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
REVISION = "0038_channel_thread_bindings"
PREVIOUS = "0037_project_document_summaries"
CURRENT_HEAD = "0039_user_disabled"
CURRENT_HEAD = "0040_scheduled_group_notice"
TABLE = "channel_thread_bindings"
COLUMNS = {"key", "channel_name", "chat_id", "topic_id", "thread_id", "user_id", "created_at", "updated_at"}
INDEX = "ix_channel_thread_bindings_channel_chat"

View File

@ -0,0 +1,85 @@
"""The explicit group destination is additive and retains disconnected targets."""
import asyncio
import os
import uuid
import pytest
import sqlalchemy as sa
from alembic import command
from alembic.script import ScriptDirectory
from sqlalchemy.ext.asyncio import create_async_engine
from support.postgres import asyncpg_test_url
from deerflow.persistence import bootstrap
from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
REVISION = "0040_scheduled_group_notice"
PREVIOUS = "0039_user_disabled"
COLUMN = "notification_group_connection_id"
def test_group_notification_revision_has_one_head_after_0039():
script = ScriptDirectory(str(bootstrap._MIGRATIONS_DIR))
assert script.get_heads() == [REVISION]
assert script.get_revision(REVISION).down_revision == PREVIOUS
assert len(REVISION) <= 32
@pytest.mark.asyncio
@pytest.mark.parametrize("backend", ["sqlite", "postgres"])
async def test_group_notification_migration_preserves_legacy_rows_and_retries(tmp_path, backend):
schema = None
if backend == "sqlite":
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'migration.db'}")
else:
uri = os.environ.get("TEST_POSTGRES_URI")
if not uri:
pytest.skip("requires TEST_POSTGRES_URI (real Postgres migration)")
schema = f"group_notice_{uuid.uuid4().hex}"
engine = create_async_engine(asyncpg_test_url(uri), connect_args=build_asyncpg_connect_args(schema))
cfg = bootstrap._get_alembic_config(engine, postgres_schema=schema or "")
try:
if schema:
async with engine.begin() as conn:
await conn.execute(sa.text(f'CREATE SCHEMA "{schema}"'))
await asyncio.to_thread(command.upgrade, cfg, PREVIOUS)
async with engine.begin() as conn:
await conn.execute(
sa.text(
"INSERT INTO scheduled_tasks (id,user_id,context_mode,title,prompt,schedule_type,schedule_spec,timezone,status,overlap_policy,run_count,last_occurrence_seq,goal_objective,created_at,updated_at) "
"VALUES ('existing','owner','fresh_thread_per_run','Report','Write report','cron','{}','UTC','paused','enqueue',4,4,'report ready',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)"
)
)
await asyncio.to_thread(command.upgrade, cfg, REVISION)
async with engine.begin() as conn:
columns = await conn.run_sync(lambda sync: {col["name"]: col for col in sa.inspect(sync).get_columns("scheduled_tasks")})
assert columns[COLUMN]["nullable"] and columns[COLUMN]["default"] is None
foreign_keys = await conn.run_sync(lambda sync: sa.inspect(sync).get_foreign_keys("scheduled_tasks"))
assert all(COLUMN not in fk["constrained_columns"] for fk in foreign_keys)
row = (await conn.execute(sa.text(f"SELECT run_count,goal_objective,{COLUMN} FROM scheduled_tasks WHERE id='existing'"))).one()
assert tuple(row) == (4, "report ready", None)
await conn.execute(sa.text(f"UPDATE scheduled_tasks SET {COLUMN}='disconnected-group' WHERE id='existing'"))
# Retry upgrade over an already-added column without changing its value.
await asyncio.to_thread(command.stamp, cfg, PREVIOUS)
await asyncio.to_thread(command.upgrade, cfg, REVISION)
async with engine.connect() as conn:
assert (await conn.execute(sa.text(f"SELECT {COLUMN} FROM scheduled_tasks WHERE id='existing'"))).scalar_one() == "disconnected-group"
await asyncio.to_thread(command.downgrade, cfg, PREVIOUS)
async with engine.connect() as conn:
names = await conn.run_sync(lambda sync: {col["name"] for col in sa.inspect(sync).get_columns("scheduled_tasks")})
assert COLUMN not in names
assert (await conn.execute(sa.text("SELECT run_count,goal_objective FROM scheduled_tasks WHERE id='existing'"))).one() == (4, "report ready")
# Retry downgrade after the column is gone, then upgrade once more.
await asyncio.to_thread(command.stamp, cfg, REVISION)
await asyncio.to_thread(command.downgrade, cfg, PREVIOUS)
await asyncio.to_thread(command.upgrade, cfg, REVISION)
async with engine.connect() as conn:
assert (await conn.execute(sa.text(f"SELECT {COLUMN} FROM scheduled_tasks WHERE id='existing'"))).scalar_one() is None
finally:
if schema:
async with engine.begin() as conn:
await conn.execute(sa.text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
await engine.dispose()

View File

@ -56,6 +56,10 @@ class FakeChannel:
self.unavailable = unavailable
self.is_running = running
self.owners = []
self.group_sent = []
async def send_group_notification_for_owner(self, *, owner_user_id, target, text_markdown):
self.group_sent.append((owner_user_id, target, text_markdown))
async def send_notification_for_owner(self, *, owner_user_id, target, text_markdown):
self.owners.append(owner_user_id)
@ -114,6 +118,78 @@ def _connection(*, provider="wecom", external_account_id="GaoZhiChao", status="c
}
def _group_connection(**updates):
return {**_connection(provider="feishu", external_account_id="ou_owner"), "id": "group-binding", "workspace_id": "oc_group", "metadata": {"chat_id": "oc_group", "chat_type": "group"}, **updates}
@pytest.mark.asyncio
async def test_group_notice_uses_selected_binding_and_only_group_hook():
repo = FakeDeliveryRepo([_delivery_row(provider="feishu", target="group-binding", payload={"target_type": "group", "run_status": "success"})])
channel = FakeChannel()
worker = NotificationDeliveryWorker(delivery_repo=repo, resolve_channel=lambda provider: channel, resolve_connections=lambda owner: [_group_connection()])
await worker.run_once(now=datetime.now(UTC))
assert repo.sent == ["delivery-1"]
assert channel.group_sent[0][:2] == ("user-1", "group-binding")
assert channel.sent == []
assert channel.owners == []
@pytest.mark.asyncio
@pytest.mark.parametrize(
"updates",
[
None,
{"id": "another-binding"},
{"owner_user_id": "another-owner"},
{"status": "revoked"},
{"provider": "wecom"},
{"workspace_id": "oc_other"},
{"metadata": {"chat_id": "oc_group", "chat_type": "p2p"}},
{"metadata": {"chat_id": "oc_group"}},
],
)
async def test_group_notice_requires_current_exact_group_binding(updates):
repo = FakeDeliveryRepo([_delivery_row(provider="feishu", target="group-binding", payload={"target_type": "group"})])
channel = FakeChannel()
rows = [] if updates is None else [_group_connection(**updates)]
worker = NotificationDeliveryWorker(delivery_repo=repo, resolve_channel=lambda provider: channel, resolve_connections=lambda owner: rows)
await worker.run_once(now=datetime.now(UTC))
assert repo.sent == []
assert len(repo.terminal_failures) == 1
assert channel.sent == []
assert channel.group_sent == []
@pytest.mark.asyncio
@pytest.mark.parametrize("provider,target_type", [("wecom", "group"), ("feishu", "unknown"), ("feishu", "private")])
async def test_invalid_group_target_type_never_falls_back_to_personal_notice(provider, target_type):
repo = FakeDeliveryRepo([_delivery_row(provider=provider, target="ou_owner", payload={"target_type": target_type})])
channel = FakeChannel()
worker = NotificationDeliveryWorker(delivery_repo=repo, resolve_channel=lambda provider: channel, resolve_connections=lambda owner: [_connection(provider=provider, external_account_id="ou_owner")])
await worker.run_once(now=datetime.now(UTC))
assert repo.sent == []
assert len(repo.terminal_failures) == 1
assert channel.sent == []
assert channel.group_sent == []
@pytest.mark.asyncio
@pytest.mark.parametrize("resolver_missing", [False, True])
async def test_group_notice_parks_when_binding_cannot_be_checked(resolver_missing):
repo = FakeDeliveryRepo([_delivery_row(provider="feishu", target="group-binding", payload={"target_type": "group"})])
channel = FakeChannel()
def unavailable(owner):
raise OSError("lookup unavailable")
worker = NotificationDeliveryWorker(delivery_repo=repo, resolve_channel=lambda provider: channel, resolve_connections=None if resolver_missing else unavailable)
await worker.run_once(now=datetime.now(UTC))
assert repo.sent == []
assert repo.failed[0][2] is False
assert channel.sent == []
assert channel.group_sent == []
def test_render_run_completed_text_has_no_raw_ids():
text = render_notification_text(_delivery_row(payload={"run_status": "success", "error": None, "task_id": "task-1", "task_title": "Daily digest"}))

View File

@ -0,0 +1,28 @@
"""The group predicate accepts only a recorded connected Feishu group."""
import pytest
@pytest.mark.parametrize(
"overrides,expected",
[
({}, True),
({"provider": "wecom"}, False),
({"status": "revoked"}, False),
({"workspace_id": None}, False),
({"workspace_id": ""}, False),
({"workspace_id": " ", "metadata": {"chat_id": " ", "chat_type": "group"}}, False),
({"workspace_id": "oc_other"}, False),
({"metadata": None}, False),
({"metadata": []}, False),
({"metadata": {"chat_id": "oc_group"}}, False),
({"metadata": {"chat_id": "oc_group", "chat_type": "p2p"}}, False),
({"metadata": {"chat_id": "", "chat_type": "group"}}, False),
({"metadata": {"chat_id": ["oc_group"], "chat_type": "group"}}, False),
],
)
def test_feishu_group_predicate(overrides, expected):
from app.channels.notification_groups import is_feishu_notification_group
value = {"provider": "feishu", "status": "connected", "workspace_id": "oc_group", "metadata": {"chat_id": "oc_group", "chat_type": "group"}, **overrides}
assert is_feishu_notification_group(**value) is expected

View File

@ -0,0 +1,185 @@
"""Owner-selected Feishu groups round-trip through the scheduled-task API."""
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import UUID
import httpx
import pytest
import pytest_asyncio
from _router_auth_helpers import make_authed_test_app
from _scheduled_rows import occurrence
from app.gateway.auth.models import User
from app.gateway.auth_disabled import AUTH_SOURCE_SESSION
from app.gateway.routers import scheduled_tasks
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
from deerflow.config.database_config import DatabaseConfig
from deerflow.persistence.channel_connections import ChannelConnectionRepository
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
from deerflow.persistence.scheduled_tasks import ScheduledTaskRepository
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskRow
OWNER = "00000000-0000-0000-0000-000000000111"
OTHER = "00000000-0000-0000-0000-000000000222"
FIELD = "notification_group_connection_id"
@pytest_asyncio.fixture
async def env(tmp_path, monkeypatch):
await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path)))
sf = get_session_factory()
tasks, connections = ScheduledTaskRepository(sf), ChannelConnectionRepository(sf)
user = User(email="groups@example.com", password_hash="test", system_role="user", id=UUID(OWNER))
app = make_authed_test_app(user_factory=lambda: user, bind_current_user=True)
@app.middleware("http")
async def session_source(request, call_next):
request.state.auth_source = AUTH_SOURCE_SESSION
return await call_next(request)
app.state.scheduled_task_repo = tasks
app.state.channel_connection_repo = connections
app.state.channel_connections_config = ChannelConnectionsConfig(enabled=True)
app.include_router(scheduled_tasks.router)
monkeypatch.setattr(scheduled_tasks, "is_scheduler_running", lambda _request: True)
monkeypatch.setattr(scheduled_tasks, "get_config", lambda: SimpleNamespace(scheduler=SimpleNamespace(min_once_delay_seconds=60)))
try:
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, raise_app_exceptions=False), base_url="http://groups.test") as client:
yield SimpleNamespace(client=client, app=app, tasks=tasks, connections=connections, sf=sf)
finally:
await close_engine()
def _body(**extra):
return {"title": "Weekly report", "prompt": "Prepare the report", "schedule_type": "cron", "schedule_spec": {"cron": "0 9 * * 1"}, "timezone": "UTC", **extra}
async def _bind(env, **extra):
fields = {"owner_user_id": OWNER, "provider": "feishu", "external_account_id": "ou_member", "workspace_id": "oc_group", "metadata": {"chat_type": "group", "chat_id": "oc_group"}, **extra}
return await env.connections.upsert_connection(**fields)
async def _create(env, **extra):
response = await env.client.post("/api/scheduled-tasks", json=_body(**extra))
assert response.status_code == 200, response.text
return response.json()
@pytest.mark.asyncio
@pytest.mark.parametrize("selected", [False, True])
async def test_group_destination_round_trips_create_get_and_list(env, selected):
group = await _bind(env)
target = group["id"] if selected else None
task = await _create(env, **({FIELD: target} if selected else {}))
assert task[FIELD] == target
assert (await env.client.get(f"/api/scheduled-tasks/{task['id']}")).json()[FIELD] == target
assert (await env.client.get("/api/scheduled-tasks")).json()[0][FIELD] == target
assert (await env.tasks.get(task["id"], user_id=OWNER))[FIELD] == target
@pytest.mark.asyncio
@pytest.mark.parametrize("method", ["post", "patch"])
@pytest.mark.parametrize(
"overrides",
[
{"owner_user_id": OTHER},
{"provider": "wecom"},
{"status": "revoked"},
{"metadata": {"chat_type": "p2p", "chat_id": "oc_group"}},
{"metadata": {"chat_id": "oc_group"}},
{"metadata": {"chat_type": "group", "chat_id": "oc_different"}},
{"workspace_id": "", "metadata": {"chat_type": "group", "chat_id": ""}},
],
)
async def test_group_destination_rejects_unowned_or_unverified_bindings(env, method, overrides):
group = await _bind(env, **overrides)
existing = await _create(env) if method == "patch" else None
url = f"/api/scheduled-tasks/{existing['id']}" if existing else "/api/scheduled-tasks"
payload = {FIELD: group["id"]} if existing else _body(**{FIELD: group["id"]})
response = await env.client.request(method, url, json=payload)
assert response.status_code == 422, response.text
assert response.json()["detail"]["code"] == "invalid_notification_group"
if existing:
assert (await env.tasks.get(existing["id"], user_id=OWNER))[FIELD] is None
else:
assert await env.tasks.list_by_user(OWNER) == []
@pytest.mark.asyncio
@pytest.mark.parametrize("target", ["unknown", "", " ", "x" * 65])
async def test_invalid_group_identifier_is_coded_and_does_not_create_task(env, target):
response = await env.client.post("/api/scheduled-tasks", json=_body(**{FIELD: target}))
assert response.status_code == 422, response.text
assert response.json()["detail"]["code"] == "invalid_notification_group"
assert await env.tasks.list_by_user(OWNER) == []
@pytest.mark.asyncio
async def test_title_patch_keeps_a_revoked_selection_and_null_clears_without_lookup(env, monkeypatch):
group = await _bind(env)
task = await _create(env, **{FIELD: group["id"]})
assert await env.connections.disconnect_connection(connection_id=group["id"], owner_user_id=OWNER)
renamed = await env.client.patch(f"/api/scheduled-tasks/{task['id']}", json={"title": "Renamed"})
assert renamed.status_code == 200, renamed.text
assert renamed.json()[FIELD] == group["id"]
lookup = AsyncMock(side_effect=OSError("lookup should not run"))
monkeypatch.setattr(env.connections, "list_connections", lookup)
cleared = await env.client.patch(f"/api/scheduled-tasks/{task['id']}", json={FIELD: None})
assert cleared.status_code == 200, cleared.text
assert cleared.json()[FIELD] is None
lookup.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize("method", ["post", "patch"])
async def test_group_lookup_failure_is_503_not_invalid_target(env, monkeypatch, method):
existing = await _create(env) if method == "patch" else None
monkeypatch.setattr(env.connections, "list_connections", AsyncMock(side_effect=OSError("private connection failure")))
url = f"/api/scheduled-tasks/{existing['id']}" if existing else "/api/scheduled-tasks"
payload = {FIELD: "conn-group"} if existing else _body(**{FIELD: "conn-group"})
response = await env.client.request(method, url, json=payload)
assert response.status_code == 503, response.text
assert response.json()["detail"]["code"] == "scheduler_unavailable"
assert "private connection failure" not in response.text
@pytest.mark.asyncio
@pytest.mark.parametrize("status", ["completed", "paused", "enabled"])
async def test_group_only_edit_preserves_schedule_goal_history_and_used_budget(env, status):
group = await _bind(env)
task = await _create(env, goal_objective="Finish the report", max_runs=1)
async with env.sf() as session:
row = await session.get(ScheduledTaskRow, task["id"])
row.status = status
row.run_count = 7
row.last_occurrence_seq = 9
row.unmet_streak_after_seq = 4
row.standing_notes = ["Preserve the report format"]
row.end_at = datetime.now(UTC) - timedelta(days=1)
if status == "completed":
row.next_run_at = None
session.add(occurrence("prior-run", task["id"], seq=9))
await session.commit()
before = (await env.client.get(f"/api/scheduled-tasks/{task['id']}")).json()
response = await env.client.patch(f"/api/scheduled-tasks/{task['id']}", json={FIELD: group["id"]})
assert response.status_code == 200, response.text
after = response.json()
assert after[FIELD] == group["id"]
for key in ("status", "next_run_at", "schedule_type", "schedule_spec", "timezone", "goal_objective", "max_runs", "end_at", "run_count", "automatic_runs_used", "standing_notes"):
assert after[key] == before[key], key
assert after["automatic_runs_used"] == 1
async with env.sf() as session:
row = await session.get(ScheduledTaskRow, task["id"])
assert row.last_occurrence_seq == 9
assert row.unmet_streak_after_seq == 4
@pytest.mark.asyncio
async def test_group_selection_requires_enabled_channel_connections(env):
group = await _bind(env)
env.app.state.channel_connections_config.enabled = False
response = await env.client.post("/api/scheduled-tasks", json=_body(**{FIELD: group["id"]}))
assert response.status_code == 422, response.text
assert response.json()["detail"]["code"] == "invalid_notification_group"

View File

@ -1373,6 +1373,78 @@ async def test_only_push_capable_connected_providers_get_outbox_rows(outbox):
assert len(await _notices(outbox)) == 2
@pytest.mark.asyncio
async def test_selected_group_notice_is_additional_and_deduplicated(outbox):
from deerflow.persistence.channel_connections.model import ChannelConnectionRow
await _task(outbox, notification_group_connection_id="selected-group")
await _bind(outbox, "feishu", "ou_feishu")
await _bind(outbox, "wecom", "wecom-user")
async with outbox.sf() as session:
session.add(
ChannelConnectionRow(id="selected-group", owner_user_id=NOTICE_OWNER, provider="feishu", status="connected", external_account_id="ou_feishu", workspace_id="oc_group", metadata_json={"chat_id": "oc_group", "chat_type": "group"})
)
await session.commit()
occurrence_id, run_id = await _occurrence(outbox)
assert await _complete(outbox, occurrence_id, run_id) is True
assert await _complete(outbox, occurrence_id, run_id) is False
notices = await _notices(outbox)
assert sorted((row["provider"], row["target"]) for row in notices) == [("feishu", "ou_feishu"), ("feishu", "selected-group"), ("wecom", "wecom-user")]
(group,) = [row for row in notices if row["target"] == "selected-group"]
assert group["payload"]["target_type"] == "group"
assert all("target_type" not in row["payload"] for row in notices if row is not group)
@pytest.mark.asyncio
@pytest.mark.parametrize("revoked_binding", [False, True])
async def test_selected_group_still_enqueues_if_binding_was_removed(outbox, revoked_binding):
from types import SimpleNamespace
from sqlalchemy import select
from app.scheduler.notification_delivery import NotificationDeliveryWorker
from deerflow.persistence.channel_connections.model import ChannelConnectionRow
from deerflow.persistence.notification_deliveries import NotificationDeliveryRow
await _task(outbox, notification_group_connection_id="selected-group")
if revoked_binding:
async with outbox.sf() as session:
session.add(
ChannelConnectionRow(
id="selected-group", owner_user_id=NOTICE_OWNER, provider="feishu", status="revoked", external_account_id="ou_feishu", workspace_id="oc_group", metadata_json={"chat_id": "oc_group", "chat_type": "group"}
)
)
await session.commit()
occurrence_id, run_id = await _occurrence(outbox)
await _complete(outbox, occurrence_id, run_id)
notices = await _notices(outbox)
assert [(row["provider"], row["target"], row["payload"]["target_type"]) for row in notices] == [("feishu", "selected-group", "group")]
worker = NotificationDeliveryWorker(delivery_repo=outbox.deliveries, resolve_channel=lambda provider: SimpleNamespace(is_running=True), resolve_connections=outbox.service._connection_repo.list_connections)
await worker.run_once(now=datetime.now(UTC) + timedelta(days=1))
async with outbox.sf() as session:
row = await session.scalar(select(NotificationDeliveryRow))
assert row.status == "failed"
@pytest.mark.asyncio
async def test_group_binding_without_task_selection_keeps_personal_notice_only(outbox):
from deerflow.persistence.channel_connections.model import ChannelConnectionRow
await _task(outbox)
async with outbox.sf() as session:
session.add(
ChannelConnectionRow(
id="group-not-selected", owner_user_id=NOTICE_OWNER, provider="feishu", status="connected", external_account_id="ou_feishu", workspace_id="oc_group", metadata_json={"chat_id": "oc_group", "chat_type": "group"}
)
)
await session.commit()
occurrence_id, run_id = await _occurrence(outbox)
await _complete(outbox, occurrence_id, run_id)
notices = await _notices(outbox)
assert [(row["provider"], row["target"]) for row in notices] == [("feishu", "ou_feishu")]
assert "target_type" not in notices[0]["payload"]
@pytest.mark.asyncio
async def test_auto_pause_sends_one_merged_notice(outbox):
await _task(outbox, goal_objective="every item is checked")

View File

@ -19,6 +19,7 @@
"invalid_goal",
"goal_requires_fresh_thread",
"invalid_stop_condition",
"invalid_notification_group",
"invalid_max_runs",
"end_at_in_past",
"end_at_before_first_run",

View File

@ -373,6 +373,33 @@ across a service restart. A changed asset revision or removed action fails expli
and requires reload. Disabled/unloaded pages have no navigation entry after refresh;
visiting an unavailable page directly does not mount a plugin.
## Frontend implementation invariants
`core/extensions/` loads authenticated deployment-installed ES modules from `/api/plugins`.
Inline modules use authenticated fetch plus a released Blob URL. Manifest assets use
native credentialed module scripts, preserving relative imports and resource URLs.
Both honor the backend base and prefixes; transport and cache semantics are documented in
`docs/full-stack-plugins.md`. Host copy belongs in the typed locale dictionaries.
Conversation action factories, shapes and availability callbacks are guarded per plugin;
only validated value snapshots reach the toolbar/sidebar render paths.
`PluginNavigation` and the dynamic workspace extension route consume page declarations;
Capability Center defaults to the repository examples in `core/extensions/catalog.ts`,
merged by explicit namespace with runtime descriptors. Catalog-only entries are discovery
metadata, never module-loader inputs or proof of installation. Backend-only examples may
have no plugin descriptor; keep their runtime status unasserted. Details link to package
installation instructions. Keep the catalog aligned with `examples/deerflow-extension-*`.
Agent teams uses `community.agent-teams`; merge its installed descriptor into the
localized catalog entry without asserting runtime status for a catalog-only row.
Conversation action slots augment normal/custom-agent toolbars and sidebar menus without replacing native export or notification.
Plugin views use mount/dispose and abort signals; Shadow DOM is CSS isolation, not a sandbox.
Descriptors are user-keyed page snapshots, refreshed manually. Backend calls bind the plugin's
namespace, action allowlist and expected viewer identity. See `docs/full-stack-plugins.md`.
Plugin page `openConversation(threadId)` resolves authenticated thread metadata
with `pathOfThread`; do not let plugins hardcode default-agent routes. The page's
abort signal fences late navigation after unmount/account changes. Synchronous
conversation-action callbacks reject Promise returns while consuming rejections.
## Validation scope
The bookmark E2E test runs the production frontend against real Python action handlers

View File

@ -57,6 +57,17 @@ Run status `unmet` identifies a finished occurrence whose scheduled goal was not
`core/scheduled-tasks/goal-outcome.ts` maps goal verdicts and host reason codes for run history; show known codes as localized labels; unknown codes, raw run errors and the evaluator's reason stay behind the run row's Details. Check-failure codes are "unchecked", not a miss. `contracts/scheduled_goal_notes_contract.json` pins the host strings it and `run-error.ts` match.
Scheduled-task views read state through the pure `core/scheduled-tasks` helpers (`status.ts`, `actions.ts`, `format.ts`, `describeTaskSchedule`, `errors.ts`) so list, detail and chat card agree. "Is a run active?" is `status === "running"` or `active_run_status` (a recurring task stays `enabled` while it runs). Default views show no IDs, ISO times, cron strings or enum names; errors localize by `detail.code` (`contracts/scheduled_task_errors_contract.json`) with raw text only behind Details. `tests/e2e/utils/readable.ts` and `tests/unit/helpers/readable.ts` assert this.
The tasks page (`app/workspace/scheduled-tasks/page.tsx`) only composes `components/workspace/scheduled-tasks/*`: list with status tabs, detail (Runs / Stops when / goal / Does / notes / History), outcome notice, create/edit/duplicate dialog (PATCH sends only changed fields, `null` clears) and the renew dialog that a `limits_exhausted` Resume opens. `?task_id=` selects a task; `?thread_id=` scopes the list to one chat.
`notification_group_connection_id` adds one Feishu group without changing personal
notifications. The selector requires `features.scheduled_tasks.group_notifications`
(absent = false); otherwise a saved target is preserved but explicitly clearable.
While the capability query is pending, show loading without a clear control.
Reuse `useChannelConnections`: offer connected Feishu `group` bindings with matching
nonempty metadata chat/workspace IDs. Show workspace names; disable unnamed groups
and never display raw IDs. Loading/errors preserve selections. PATCH omits unchanged
groups, clears with `null`, and leaves schedules untouched; Duplicate copies the group.
`BindFeishuGroup` uses the connect API for a persistent, copyable `/connect` command
and explicitly refreshes the shared query. The general connection poll stops early
when a private binding already exists, so cannot wait for a new group.
In chat, a `schedule_task` result becomes an `assistant:scheduled-task` group (`core/scheduled-tasks/tool-result.ts`, last card per task per turn; not a turn boundary) rendered by `ScheduledTaskCard`, which polls the task only while on screen. A human message with `additional_kwargs.deerflow_scheduled_origin` stays a `human` group with `scheduledOrigin`: `ScheduledRunPrompt` renders the origin's user-language parts, never the launched text, and it is not editable.
Server-created threads: `core/threads/origin.ts` reads `metadata.deerflow_origin` (`contracts/thread_origin_contract.json`), then `channel_source`, then a legacy `scheduled_task_id`; provider names come from `threads.origin.providers`. `core/threads/activity.ts` polls `/api/thread-activity` (mount `useThreadActivity` once; unmounting resets its cursor; first poll only seeds; invalidate lists only on server-origin threads, truncation or a higher `read_version`) and `useMarkThreadRead` posts reads. `core/scheduled-tasks/events.ts` loads a chat's lifecycle events, not gated on its task list; `placeTaskEvents` puts each at the end of its `after_run_id` turn. The UI language is saved as the `locale` preference once per session and on each switch, only when the Gateway's preferences include `locale`.
Thread rows (sidebar, `/workspace/chats`) show `ThreadOriginIcon` and, if `unread === true` and not the open thread, `ThreadUnreadDot` with `threads.unreadLabel` as row label. `WorkspaceSidebar` mounts `useThreadActivity`. Chat pages call `useMarkOpenThreadRead` once metadata loads (marks read on load, on an unread flip, on tab visible, and via its callback from `onFinish`).
@ -363,30 +374,10 @@ a model switch without changing the backend's direct legacy-request behavior.
## Full-stack plugin UI
`core/extensions/` loads authenticated deployment-installed ES modules from `/api/plugins`.
Inline modules use authenticated fetch plus a released Blob URL. Manifest assets use
native credentialed module scripts, preserving relative imports and resource URLs.
Both honor the backend base and prefixes; transport and cache semantics are documented in
`docs/full-stack-plugins.md`. Host copy belongs in the typed locale dictionaries.
Conversation action factories, shapes and availability callbacks are guarded per plugin;
only validated value snapshots reach the toolbar/sidebar render paths.
`PluginNavigation` and the dynamic workspace extension route consume page declarations;
Capability Center defaults to the repository examples in `core/extensions/catalog.ts`,
merged by explicit namespace with runtime descriptors. Catalog-only entries are discovery
metadata, never module-loader inputs or proof of installation. Backend-only examples may
have no plugin descriptor; keep their runtime status unasserted. Details link to package
installation instructions. Keep the catalog aligned with `examples/deerflow-extension-*`.
Agent teams uses `community.agent-teams`; merge its installed descriptor into the
localized catalog entry without asserting runtime status for a catalog-only row.
Conversation action slots augment normal/custom-agent toolbars and sidebar menus without replacing native export or notification.
Plugin views use mount/dispose and abort signals; Shadow DOM is CSS isolation, not a sandbox.
Descriptors are user-keyed page snapshots, refreshed manually. Backend calls bind the plugin's
namespace, action allowlist and expected viewer identity. See `docs/full-stack-plugins.md`.
Plugin page `openConversation(threadId)` resolves authenticated thread metadata
with `pathOfThread`; do not let plugins hardcode default-agent routes. The page's
abort signal fences late navigation after unmount/account changes. Synchronous
conversation-action callbacks reject Promise returns while consuming rejections.
Before changing plugin loaders, pages or conversation actions, read the mandatory
[Frontend implementation invariants](../docs/full-stack-plugins.md#frontend-implementation-invariants).
Preserve authenticated, viewer-bound loading, native actions, localization and
mount/dispose plus abort fencing; Shadow DOM is not a security sandbox.
### Composer references
@ -404,5 +395,7 @@ sends, with retry on failure. Polish restores whole labels longest-first in one
pass. Confirmed thread creation seeds metadata before migration; references stay
locked during attachment. Deleting an object removes its context; undo restores
references already attached to the draft. Labels grant no read authority. IME,
caret, thread changes and attachment fences apply to editor and picker. The `@`
caret, thread changes and attachment fences apply to editor and picker. Commit
mention text before restoring focus synchronously; a delayed callback must not
overwrite subsequent typing or caret movement. The `@`
and attachment buttons share the picker.

View File

@ -1895,14 +1895,12 @@ export function InputBox({
[],
);
const focusComposerAt = useCallback((offset: number) => {
requestAnimationFrame(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.focus();
textarea.setSelectionRange(offset, offset);
} else if (inlineSkillTextRef.current)
focusReferenceAt(inlineSkillTextRef.current, offset);
});
const textarea = textareaRef.current;
if (textarea) {
textarea.focus();
textarea.setSelectionRange(offset, offset);
} else if (inlineSkillTextRef.current)
focusReferenceAt(inlineSkillTextRef.current, offset);
}, []);
const selectMention = useCallback(
async (selection: MentionSelection) => {
@ -1938,9 +1936,11 @@ export function InputBox({
setConversationReferences((previous) =>
previous.filter((ref) => ref.threadId !== selectionId),
);
textInput.setInput(next);
setMentionQuery(null);
setMentionButtonOpen(false);
flushSync(() => {
textInput.setInput(next);
setMentionQuery(null);
setMentionButtonOpen(false);
});
focusComposerAt(Math.min(caret, next.length));
return;
}
@ -2074,10 +2074,14 @@ export function InputBox({
latestDraftRef.current = null;
clearComposerDraft(storage, draftKey);
}
textInput.setInput(nextText);
setMentionQuery(null);
setMentionButtonOpen(false);
setMentionError(null);
// Commit the replacement editor before restoring its selection. Deferring
// to the next frame can misplace typing or overwrite a later caret move.
flushSync(() => {
textInput.setInput(nextText);
setMentionQuery(null);
setMentionButtonOpen(false);
setMentionError(null);
});
focusComposerAt(caret + inserted.length);
if (selection.kind === "file") onReferenceFileAttached?.();
},

View File

@ -0,0 +1,146 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { connectChannelProvider } from "@/core/channels/api";
import { channelConnectionsQueryKey } from "@/core/channels/hooks";
import { useI18n } from "@/core/i18n/hooks";
import { fill } from "./shared";
/** Group binding needs its own command; a connected private chat is not completion. */
export function BindFeishuGroup() {
const { t, locale } = useI18n();
const labels = t.scheduledTasks.notificationGroup;
const queryClient = useQueryClient();
const [visible, setVisible] = useState(false);
const [copied, setCopied] = useState(false);
const [copyFailed, setCopyFailed] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [expiresAt, setExpiresAt] = useState<number | null>(null);
const binding = useMutation({
mutationFn: () => connectChannelProvider("feishu"),
onSuccess: (result) => {
setCopied(false);
setCopyFailed(false);
setExpiresAt(Date.now() + result.expires_in * 1000);
},
});
const command = binding.data ? `/connect ${binding.data.code}` : "";
const copy = async () => {
try {
await navigator.clipboard.writeText(command);
setCopied(true);
setCopyFailed(false);
} catch {
setCopyFailed(true);
}
};
const refresh = async () => {
setRefreshing(true);
try {
await queryClient.invalidateQueries({
queryKey: channelConnectionsQueryKey,
});
} finally {
setRefreshing(false);
}
};
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={binding.isPending}
onClick={() => {
setVisible(true);
if (!binding.data) binding.mutate();
}}
>
{labels.bind}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={refreshing}
onClick={() => void refresh()}
>
{labels.refresh}
</Button>
</div>
{visible && (
<div
className="bg-muted/30 flex flex-col gap-2 rounded-md border p-3"
data-testid="feishu-group-binding"
>
<p className="text-sm">{labels.bindInstructions}</p>
{binding.isPending && (
<p role="status" className="text-muted-foreground text-xs">
{labels.generating}
</p>
)}
{binding.error && (
<p role="alert" className="text-destructive text-xs">
{labels.bindFailed}
</p>
)}
{command && (
<>
<Input aria-label={labels.command} value={command} readOnly />
{expiresAt !== null && (
<p className="text-muted-foreground text-xs">
{fill(labels.expiresAt, {
time: new Intl.DateTimeFormat(locale, {
hour: "2-digit",
minute: "2-digit",
}).format(expiresAt),
})}
</p>
)}
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
onClick={() => void copy()}
>
{copied ? labels.copied : labels.copy}
</Button>
{copyFailed && (
<p role="alert" className="text-destructive text-xs">
{labels.copyFailed}
</p>
)}
</>
)}
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={binding.isPending}
onClick={() => binding.mutate()}
>
{labels.newCode}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setVisible(false)}
>
{labels.close}
</Button>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,161 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useChannelConnections } from "@/core/channels/hooks";
import { useScheduledTasksFeature } from "@/core/features/hooks";
import { useI18n } from "@/core/i18n/hooks";
import {
feishuNotificationGroups,
notificationGroupName,
} from "@/core/scheduled-tasks/notification-groups";
import { BindFeishuGroup } from "./bind-feishu-group";
const NO_GROUP = "__no_additional_group__";
type NotificationGroupSelectProps = {
id: string;
value: string;
onChange: (value: string) => void;
};
/** Older Gateways ignore unknown fields: do not offer a destination they cannot save. */
export function NotificationGroupSelect(props: NotificationGroupSelectProps) {
const { groupNotifications, isLoading } = useScheduledTasksFeature();
const { t } = useI18n();
if (isLoading) {
return (
<p role="status" className="text-muted-foreground text-sm">
{t.scheduledTasks.notificationGroup.loading}
</p>
);
}
if (!groupNotifications && props.value) {
return (
<div className="flex flex-col gap-2">
<p className="text-muted-foreground text-sm">
{t.scheduledTasks.notificationGroup.savedUnavailable}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
onClick={() => props.onChange("")}
>
{t.scheduledTasks.notificationGroup.clearSaved}
</Button>
</div>
);
}
return groupNotifications ? (
<EnabledNotificationGroupSelect {...props} />
) : null;
}
/** Loading or losing the list must never reset the form's saved selection. */
function EnabledNotificationGroupSelect({
id,
value,
onChange,
}: NotificationGroupSelectProps) {
const { t } = useI18n();
const labels = t.scheduledTasks.notificationGroup;
const { connections, isLoading, error } = useChannelConnections();
const groups = feishuNotificationGroups(connections);
const selected = groups.find((group) => group.id === value);
const selectedMissing = Boolean(value) && !selected;
const unnamed = groups.some((group) => !notificationGroupName(group));
const hint = error
? labels.loadFailed
: isLoading
? labels.loading
: selectedMissing
? labels.unavailableHint
: unnamed
? labels.unnamedHint
: groups.length === 0
? labels.emptyHint
: null;
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={id} className="text-sm font-medium">
{labels.label}
</label>
<Select
value={value || NO_GROUP}
onValueChange={(next) => onChange(next === NO_GROUP ? "" : next)}
>
<SelectTrigger
id={id}
className="w-full"
aria-label={labels.label}
aria-describedby={`${id}-hint`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_GROUP}>{labels.none}</SelectItem>
{selectedMissing && (
<SelectItem value={value} disabled>
{isLoading ? labels.loading : labels.unavailable}
</SelectItem>
)}
{groups.map((group) => (
<SelectItem
key={group.id}
value={group.id}
disabled={!notificationGroupName(group) || Boolean(error)}
>
{notificationGroupName(group) ?? labels.unnamed}
</SelectItem>
))}
</SelectContent>
</Select>
<div id={`${id}-hint`} className="text-muted-foreground text-xs">
<p>{labels.personalHint}</p>
{hint && <p role="status">{hint}</p>}
</div>
<BindFeishuGroup />
</div>
);
}
/** Shares the existing connections query cache, without adding a polling loop. */
export function NotificationGroupSummary({
connectionId,
}: {
connectionId: string;
}) {
const { t } = useI18n();
const labels = t.scheduledTasks.notificationGroup;
const { connections, isLoading, error } = useChannelConnections();
const group = feishuNotificationGroups(connections).find(
(connection) => connection.id === connectionId,
);
const name = notificationGroupName(group);
return (
<div data-testid="scheduled-task-notification-group">
<p>
{name ??
(isLoading
? labels.loading
: group
? labels.unnamed
: labels.unavailable)}
</p>
<p className="text-muted-foreground text-xs">{labels.personalHint}</p>
{error && (
<p className="text-muted-foreground text-xs">{labels.loadFailed}</p>
)}
</div>
);
}

View File

@ -70,6 +70,7 @@ import { isTaskBusy, type ScheduledTask } from "@/core/scheduled-tasks/types";
import { cn } from "@/lib/utils";
import { DeleteTaskDialog } from "./delete-task-dialog";
import { NotificationGroupSummary } from "./notification-group";
import { RenewLimitsDialog } from "./renew-limits-dialog";
import { RunRow } from "./run-row";
import {
@ -530,6 +531,14 @@ export function TaskDetail({
)}
</Section>
{task.notification_group_connection_id && (
<Section label={st.notificationGroup.detailLabel}>
<NotificationGroupSummary
connectionId={task.notification_group_connection_id}
/>
</Section>
)}
<Section label={st.detail.stopsWhen}>
<p data-testid="scheduled-task-stops-when">
{stops.primary}

View File

@ -57,6 +57,7 @@ import { RECIPES, type Recipe } from "@/core/scheduled-tasks/recipes";
import type { ScheduledTask } from "@/core/scheduled-tasks/types";
import { cn } from "@/lib/utils";
import { NotificationGroupSelect } from "./notification-group";
import { DEFAULT_ASSISTANT_ID, fill, isFrequentSchedule } from "./shared";
export type TaskFormMode = "create" | "edit" | "duplicate";
@ -83,6 +84,7 @@ export type TaskFormState = {
assistantId: string;
contextMode: ContextMode;
chatId: string;
notificationGroupConnectionId: string;
};
const DEFAULT_SCHEDULE: ScheduleValue = {
@ -133,6 +135,7 @@ export function initialFormState(
assistantId: DEFAULT_ASSISTANT_ID,
contextMode: "fresh_thread_per_run",
chatId: "",
notificationGroupConnectionId: "",
},
endAtDropped: false,
};
@ -160,6 +163,8 @@ export function initialFormState(
assistantId: task.assistant_id ?? DEFAULT_ASSISTANT_ID,
contextMode: task.context_mode,
chatId: task.thread_id ?? "",
notificationGroupConnectionId:
task.notification_group_connection_id ?? "",
},
endAtDropped: endAtPassed,
};
@ -269,6 +274,10 @@ export function createPayload(
if (valid.endAt) payload.end_at = valid.endAt;
const stop = normalizeText(state.stopCondition);
if (stop) payload.stop_condition = stop;
if (state.notificationGroupConnectionId) {
payload.notification_group_connection_id =
state.notificationGroupConnectionId;
}
return payload;
}
@ -318,6 +327,10 @@ export function updatePayload(
? task.end_at != null
: !task.end_at || minute(valid.endAt) !== minute(task.end_at);
if (endChanged) updates.end_at = valid.endAt;
const notificationGroup = state.notificationGroupConnectionId || null;
if (notificationGroup !== (task.notification_group_connection_id ?? null)) {
updates.notification_group_connection_id = notificationGroup;
}
return updates;
}
@ -670,6 +683,12 @@ function TaskForm({
</p>
)}
<NotificationGroupSelect
id={id("notification-group")}
value={state.notificationGroupConnectionId}
onChange={(value) => set("notificationGroupConnectionId", value)}
/>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button

View File

@ -22,6 +22,7 @@ export interface FeaturesResponse {
available?: boolean;
running?: boolean;
tool_enabled?: boolean;
group_notifications?: boolean;
min_interval_seconds?: number;
};
thread_activity?: {
@ -36,6 +37,8 @@ export interface ScheduledTasksFeature {
running: boolean;
/** Chats can create and manage tasks, and runs can pause their own schedule. */
toolEnabled: boolean;
/** Explicit support is required before submitting an additional Feishu group. */
groupNotifications: boolean;
/** Shortest interval and earliest one-time delay, in seconds. */
minIntervalSeconds: number;
}
@ -49,6 +52,7 @@ export const DEFAULT_SCHEDULED_TASKS_FEATURE: ScheduledTasksFeature = {
available: true,
running: true,
toolEnabled: false,
groupNotifications: false,
minIntervalSeconds: 60,
};
@ -147,6 +151,7 @@ export async function fetchScheduledTasksFeature(): Promise<ScheduledTasksFeatur
typeof feature?.tool_enabled === "boolean"
? feature.tool_enabled
: defaults.toolEnabled,
groupNotifications: feature?.group_notifications === true,
minIntervalSeconds:
typeof minInterval === "number" &&
Number.isInteger(minInterval) &&

View File

@ -108,6 +108,7 @@ export function useScheduledTasksFeature() {
available: feature.available,
running: feature.running,
toolEnabled: feature.toolEnabled,
groupNotifications: feature.groupNotifications,
minIntervalSeconds: feature.minIntervalSeconds,
isLoading: isPending,
};

View File

@ -1019,6 +1019,39 @@ export const enUS: Translations = {
"Tasks created in chat that run more often than hourly need a safety cap.",
submit: "Resume",
},
notificationGroup: {
savedUnavailable:
"This task has a saved group destination, but group notifications are unavailable on this server.",
clearSaved: "Clear group notification",
bind: "Bind a Feishu group",
refresh: "Refresh group list",
bindInstructions:
"Add the bot to the group, then mention the bot and send this command in that group. Refresh the group list after binding.",
generating: "Generating a binding code…",
bindFailed: "Could not create a binding code. Try again.",
command: "Group binding command",
expiresAt: "Expires at {time}.",
copy: "Copy command",
copied: "Copied",
copyFailed: "Could not copy. Select and copy the command above.",
newCode: "Generate a new code",
close: "Close binding instructions",
label: "Also notify a Feishu group (optional)",
detailLabel: "Additional Feishu group",
none: "No additional group",
personalHint:
"Your personal notifications stay unchanged. The selected group also receives task updates.",
emptyHint: "Bind a Feishu group below, then refresh the list.",
loading: "Loading groups…",
unavailable: "Selected group unavailable",
unavailableHint:
"The selected group is no longer available. Keep it, remove it, or choose another group.",
unnamed: "Group name unavailable",
unnamedHint:
"Check the app's group-information permission, then bind the group again to load its name.",
loadFailed:
"Could not load groups. Your selection is kept; you can save other changes or remove it.",
},
form: {
createTitle: "New scheduled task",
editTitle: "Edit task",
@ -1190,6 +1223,8 @@ export const enUS: Translations = {
goalRequiresFreshThread: "A goal needs each run to start a new chat.",
invalidStopCondition:
"The stop condition must be at most {max_chars} characters.",
invalidNotificationGroup:
"This Feishu group is no longer available. Select another group or remove the additional notification.",
invalidMaxRuns: "The run limit must be a whole number of at least 1.",
endAtInPast: "Pick an end time in the future.",
endAtBeforeFirstRun: "The end time must leave room for at least one run.",

View File

@ -835,6 +835,33 @@ export interface Translations {
removeCapBlocked: string;
submit: string;
};
notificationGroup: {
savedUnavailable: string;
clearSaved: string;
bind: string;
refresh: string;
bindInstructions: string;
generating: string;
bindFailed: string;
command: string;
expiresAt: string;
copy: string;
copied: string;
copyFailed: string;
newCode: string;
close: string;
label: string;
detailLabel: string;
none: string;
personalHint: string;
emptyHint: string;
loading: string;
unavailable: string;
unavailableHint: string;
unnamed: string;
unnamedHint: string;
loadFailed: string;
};
form: {
createTitle: string;
editTitle: string;
@ -995,6 +1022,7 @@ export interface Translations {
invalidGoal: string;
goalRequiresFreshThread: string;
invalidStopCondition: string;
invalidNotificationGroup: string;
invalidMaxRuns: string;
endAtInPast: string;
endAtBeforeFirstRun: string;

View File

@ -956,6 +956,35 @@ export const zhCN: Translations = {
"在对话中创建、且比每小时更频繁的任务需要设置保险上限。",
submit: "恢复",
},
notificationGroup: {
savedUnavailable: "已有群通知配置,当前服务未启用群通知。",
clearSaved: "清除群通知",
bind: "绑定飞书群",
refresh: "刷新群列表",
bindInstructions:
"先把机器人加入群聊,再在群里 @机器人 并发送这条命令。完成绑定后刷新群列表。",
generating: "正在生成绑定码…",
bindFailed: "无法生成绑定码,请重试。",
command: "群绑定命令",
expiresAt: "有效期至 {time}。",
copy: "复制命令",
copied: "已复制",
copyFailed: "复制失败,请选中上方命令手动复制。",
newCode: "生成新绑定码",
close: "关闭绑定说明",
label: "额外通知到飞书群(可选)",
detailLabel: "额外通知到飞书群",
none: "仅通知本人",
personalHint: "你现有的个人通知保持不变,所选群也会收到任务更新。",
emptyHint: "先在下方绑定飞书群,再刷新群列表。",
loading: "正在加载群列表…",
unavailable: "已选群暂不可用",
unavailableHint: "已选群当前不可用。可以保留、移除或选择其他群。",
unnamed: "群名称不可用",
unnamedHint: "请检查应用的群信息读取权限,再重新绑定该群以获取群名称。",
loadFailed:
"无法加载群列表,已保留当前选择。仍可保存其他修改或移除所选群。",
},
form: {
createTitle: "新建定时任务",
editTitle: "编辑任务",
@ -1120,6 +1149,8 @@ export const zhCN: Translations = {
invalidGoal: "目标为空,或超过了 {max_chars} 个字符。",
goalRequiresFreshThread: "设置目标时,每次运行都必须新建对话。",
invalidStopCondition: "停止条件不能超过 {max_chars} 个字符。",
invalidNotificationGroup:
"此飞书群当前不可用,请选择其他群或移除额外群通知。",
invalidMaxRuns: "运行次数上限必须是不小于 1 的整数。",
endAtInPast: "请选择一个未来的结束时间。",
endAtBeforeFirstRun: "结束时间至少要留出一次运行。",

View File

@ -97,11 +97,13 @@ export type ScheduledTaskPayload = {
max_runs?: number | null;
end_at?: string | null;
stop_condition?: string | null;
notification_group_connection_id?: string | null;
};
/**
* PATCH body: send only changed fields. `null` clears `goal_objective`,
* `max_runs`, `end_at` and `stop_condition`; other fields ignore `null`.
* `max_runs`, `end_at`, `stop_condition` and `notification_group_connection_id`;
* other fields ignore `null`.
* `schedule_type` is accepted (with `schedule_spec`), though the page keeps it
* locked when editing.
*/

View File

@ -30,6 +30,7 @@ export const SCHEDULED_TASK_ERROR_KEYS: Readonly<
invalid_goal: "invalidGoal",
goal_requires_fresh_thread: "goalRequiresFreshThread",
invalid_stop_condition: "invalidStopCondition",
invalid_notification_group: "invalidNotificationGroup",
invalid_max_runs: "invalidMaxRuns",
end_at_in_past: "endAtInPast",
end_at_before_first_run: "endAtBeforeFirstRun",

View File

@ -0,0 +1,24 @@
import type { ChannelConnection } from "@/core/channels/types";
/** The Gateway still validates ownership and the binding when the task is saved. */
export function feishuNotificationGroups(
connections: readonly ChannelConnection[],
): ChannelConnection[] {
return connections.filter(
(connection) =>
connection.provider === "feishu" &&
connection.status === "connected" &&
connection.metadata?.chat_type === "group" &&
typeof connection.workspace_id === "string" &&
connection.workspace_id.trim().length > 0 &&
connection.metadata?.chat_id === connection.workspace_id,
);
}
export function notificationGroupName(
connection: ChannelConnection | undefined,
): string | null {
const name = connection?.workspace_name?.trim();
if (!name) return null;
return name;
}

View File

@ -35,6 +35,8 @@ export type ScheduledTask = {
standing_notes?: string[] | null;
/** The user's "stop when …" rule, stored in its own column. */
stop_condition?: string | null;
/** One additional owner-bound Feishu group; personal notifications are unchanged. */
notification_group_connection_id?: string | null;
/** Launched scheduled runs counted against `max_runs` (trial runs excluded). */
automatic_runs_used?: number;
/**

View File

@ -0,0 +1,30 @@
import { expect, test } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
test("the first project file reference keeps subsequent typing after the chip across thread handoff", async ({
page,
}) => {
mockLangGraphAPI(page, {
skills: [{ name: "research", description: "Research", enabled: true }],
projects: [{ id: "proj-1", name: "Research project" }],
projectDocuments: [
{ id: "doc-1", project_id: "proj-1", name: "report.pdf", size_bytes: 10 },
],
});
await page.goto("/workspace/chats/new?project=proj-1");
const input = page.getByRole("textbox", { name: /how can i assist you/i });
await input.fill("Read @report");
await page.getByRole("option", { name: "report.pdf", exact: true }).click();
await expect(page.getByTestId("project-attachment-chip")).toContainText(
"report.pdf",
);
await input.pressSequentially("summarize");
await expect(input).toHaveText("Read ▤report.pdf summarize");
await expect(page).not.toHaveURL(/\/new/);
await page.reload();
await expect(input).toHaveText("Read ▤report.pdf summarize");
await expect(page.getByTestId("project-attachment-chip")).toContainText(
"report.pdf",
);
});

View File

@ -0,0 +1,285 @@
import { expect, test, type Page } from "@playwright/test";
import { mockLangGraphAPI, type MockScheduledTask } from "./utils/mock-api";
const group = {
id: "feishu-group-binding",
provider: "feishu",
status: "connected",
workspace_id: "oc_hidden_group",
workspace_name: "Planning team",
scopes: [],
metadata: { chat_id: "oc_hidden_group", chat_type: "group" },
};
function task(overrides: Partial<MockScheduledTask> = {}): MockScheduledTask {
return {
id: "group-notification-task",
thread_id: null,
context_mode: "fresh_thread_per_run",
title: "Team summary",
prompt: "Summarize this week's decisions",
schedule_type: "cron",
schedule_spec: { cron: "0 9 * * *" },
timezone: "UTC",
status: "enabled",
next_run_at: "2099-01-01T09:00:00Z",
last_run_at: null,
last_run_id: null,
last_error: null,
run_count: 0,
created_at: "2026-10-01T00:00:00Z",
updated_at: "2026-10-01T00:00:00Z",
...overrides,
};
}
async function open(
page: Page,
source: MockScheduledTask,
failConnections = false,
supportsGroups = true,
) {
mockLangGraphAPI(page, {
threads: [],
scheduledTasks: [source],
features: { scheduledTasks: { groupNotifications: supportsGroups } },
});
await page.route("**/api/channels/connections", (route) =>
route.fulfill({
status: failConnections ? 503 : 200,
contentType: "application/json",
body: JSON.stringify(
failConnections
? { detail: "Unavailable" }
: {
connections: [
group,
{ ...group, id: "unnamed-binding", workspace_name: null },
{
...group,
id: "personal-binding",
workspace_name: "Private chat",
metadata: { ...group.metadata, chat_type: "p2p" },
},
],
},
),
}),
);
await page.goto(`/workspace/scheduled-tasks?task_id=${source.id}`);
await expect(page.getByTestId("scheduled-task-detail")).toBeVisible();
}
test("selecting and clearing an additional group preserves the schedule and displays its name", async ({
page,
}) => {
const source = task();
await open(page, source);
const requests: unknown[] = [];
page.on("request", (request) => {
if (
request.method() === "PATCH" &&
new URL(request.url()).pathname === `/api/scheduled-tasks/${source.id}`
)
requests.push(request.postDataJSON());
});
await page.getByRole("button", { name: "Edit", exact: true }).click();
const form = page.getByTestId("scheduled-task-form");
const select = form.getByRole("combobox", {
name: "Also notify a Feishu group (optional)",
});
await expect(select).toHaveText("No additional group");
await select.click();
await expect(page.getByRole("option", { name: "Private chat" })).toHaveCount(
0,
);
await expect(
page.getByRole("option", { name: "Group name unavailable" }),
).toBeDisabled();
await page
.getByRole("option", { name: "Planning team", exact: true })
.click();
await form.getByRole("button", { name: "Save changes" }).click();
await expect(form).toHaveCount(0);
expect(requests).toEqual([{ notification_group_connection_id: group.id }]);
await expect(
page.getByTestId("scheduled-task-notification-group"),
).toContainText("Planning team");
await page.reload();
await expect(
page.getByTestId("scheduled-task-notification-group"),
).toContainText("Planning team");
await page.getByRole("button", { name: "Edit", exact: true }).click();
await form
.getByRole("combobox", { name: "Also notify a Feishu group (optional)" })
.click();
await page
.getByRole("option", { name: "No additional group", exact: true })
.click();
await form.getByRole("button", { name: "Save changes" }).click();
await expect(form).toHaveCount(0);
expect(requests).toEqual([
{ notification_group_connection_id: group.id },
{ notification_group_connection_id: null },
]);
await expect(
page.getByTestId("scheduled-task-notification-group"),
).toHaveCount(0);
});
test("a connection-list failure does not clear an existing group when saving another field", async ({
page,
}) => {
const source = task({
notification_group_connection_id: "missing-group-binding",
});
await open(page, source, true);
await page.getByRole("button", { name: "Edit", exact: true }).click();
const form = page.getByTestId("scheduled-task-form");
// The real connections query retries 503s before exposing its error state.
// Wait through that backoff; preserving a selection while merely loading
// would not exercise the failed-query behavior this case is meant to pin.
await expect(
form.getByText(
"Could not load groups. Your selection is kept; you can save other changes or remove it.",
),
).toBeVisible({ timeout: 15_000 });
await expect(
form.getByRole("combobox", {
name: "Also notify a Feishu group (optional)",
}),
).toHaveText("Selected group unavailable");
await expect(form).not.toContainText("missing-group-binding");
await form
.getByRole("textbox", { name: "Title", exact: true })
.fill("Updated team summary");
const request = page.waitForRequest(
(r) =>
r.method() === "PATCH" &&
new URL(r.url()).pathname === `/api/scheduled-tasks/${source.id}`,
);
await form.getByRole("button", { name: "Save changes" }).click();
expect((await request).postDataJSON()).toEqual({
title: "Updated team summary",
});
});
test("duplicating a task copies its selected group", async ({ page }) => {
const source = task({ notification_group_connection_id: group.id });
await open(page, source);
await page
.getByTestId("scheduled-task-detail")
.getByRole("button", { name: "More actions" })
.click();
await page.getByRole("menuitem", { name: "Duplicate" }).click();
const form = page.getByTestId("scheduled-task-form");
await expect(
form.getByRole("combobox", {
name: "Also notify a Feishu group (optional)",
}),
).toHaveText("Planning team");
const request = page.waitForRequest(
(r) =>
r.method() === "POST" &&
new URL(r.url()).pathname === "/api/scheduled-tasks",
);
await form.getByRole("button", { name: "Create", exact: true }).click();
expect((await request).postDataJSON()).toMatchObject({
notification_group_connection_id: group.id,
schedule_spec: source.schedule_spec,
});
});
test("the task form provides a persistent group binding command and refreshes the resulting group", async ({
page,
}) => {
await page.addInitScript(() => {
let copied = "";
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
writeText: async (text: string) => {
copied = text;
},
readText: async () => copied,
},
});
});
let groupBound = false;
let codesCreated = 0;
await open(page, task());
await page.route("**/api/channels/connections", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ connections: groupBound ? [group] : [] }),
}),
);
await page.route("**/api/channels/feishu/connect", (route) => {
codesCreated++;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
provider: "feishu",
mode: "binding_code",
code: "test-group-binding-code",
expires_in: 600,
instruction: "Send /connect test-group-binding-code",
url: null,
}),
});
});
await page.getByRole("button", { name: "Edit", exact: true }).click();
const form = page.getByTestId("scheduled-task-form");
await form
.getByRole("button", { name: "Bind a Feishu group", exact: true })
.click();
const binding = form.getByTestId("feishu-group-binding");
await expect(
binding.getByRole("textbox", { name: "Group binding command" }),
).toHaveValue("/connect test-group-binding-code");
await binding
.getByRole("button", { name: "Copy command", exact: true })
.click();
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(
"/connect test-group-binding-code",
);
await binding
.getByRole("button", { name: "Close binding instructions" })
.click();
await form
.getByRole("button", { name: "Bind a Feishu group", exact: true })
.click();
await expect(
binding.getByRole("textbox", { name: "Group binding command" }),
).toHaveValue("/connect test-group-binding-code");
expect(codesCreated).toBe(1);
groupBound = true;
await form.getByRole("button", { name: "Refresh group list" }).click();
const selector = form.getByRole("combobox", {
name: "Also notify a Feishu group (optional)",
});
await expect(selector).toHaveText("No additional group");
await selector.click();
await expect(
page.getByRole("option", { name: "Planning team" }),
).toBeVisible();
});
test("a backend without the group capability does not offer a group destination", async ({
page,
}) => {
await open(page, task(), false, false);
await page.getByRole("button", { name: "Edit", exact: true }).click();
const form = page.getByTestId("scheduled-task-form");
await expect(
form.getByRole("button", { name: "Bind a Feishu group" }),
).toHaveCount(0);
await expect(
form.getByRole("combobox", {
name: "Also notify a Feishu group (optional)",
}),
).toHaveCount(0);
});

View File

@ -166,11 +166,12 @@ export type MockAPIOptions = {
browserControlEnabled?: boolean;
mcpTasksEnabled?: boolean;
knowledgeScopeSelectionEnabled?: boolean;
/** Emitted as `scheduled_tasks`; each flag defaults to true. */
/** Existing task flags default to true; optional group notifications default to false. */
scheduledTasks?: {
available?: boolean;
running?: boolean;
toolEnabled?: boolean;
groupNotifications?: boolean;
};
/** Emitted as `thread_activity`; unavailable unless enabled, so nothing polls by default. */
threadActivity?: {
@ -288,6 +289,7 @@ export type MockScheduledTask = {
origin_thread_id?: string | null;
standing_notes?: string[] | null;
stop_condition?: string | null;
notification_group_connection_id?: string | null;
automatic_runs_used?: number;
active_run_status?: "queued" | "launching" | "running" | null;
created_at: string;
@ -357,6 +359,7 @@ const CLEARABLE_TASK_FIELDS = [
"max_runs",
"end_at",
"stop_condition",
"notification_group_connection_id",
] as const;
/** PATCH/resume semantics: a present key replaces the value; `null` clears the clearable fields. */
@ -654,6 +657,8 @@ export function mockLangGraphAPI(
available: options?.features?.scheduledTasks?.available ?? true,
running: options?.features?.scheduledTasks?.running ?? true,
toolEnabled: options?.features?.scheduledTasks?.toolEnabled ?? true,
groupNotifications:
options?.features?.scheduledTasks?.groupNotifications ?? false,
},
threadActivity: {
available: options?.features?.threadActivity?.available ?? false,
@ -2373,6 +2378,7 @@ export function mockLangGraphAPI(
available: featureFlags.scheduledTasks.available,
running: featureFlags.scheduledTasks.running,
tool_enabled: featureFlags.scheduledTasks.toolEnabled,
group_notifications: featureFlags.scheduledTasks.groupNotifications,
min_interval_seconds: 60,
},
thread_activity: {

View File

@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
act,
cleanup,
fireEvent,
render,
@ -13,6 +14,8 @@ import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { InputBox } from "@/components/workspace/input-box";
import {
focusReferenceAt,
readReferenceEditor,
referenceCaret,
referenceToken,
} from "@/components/workspace/mentions/inline-references";
import { ThreadContext } from "@/components/workspace/messages/context";
@ -256,6 +259,100 @@ function enterMention(
return input;
}
function holdAnimationFrames() {
let nextId = 0;
const pending = new Map<number, FrameRequestCallback>();
rs.spyOn(globalThis, "requestAnimationFrame").mockImplementation(
(callback) => {
const id = ++nextId;
pending.set(id, callback);
return id;
},
);
rs.spyOn(globalThis, "cancelAnimationFrame").mockImplementation((id) => {
pending.delete(id);
});
return {
flush() {
const callbacks = [...pending.values()];
pending.clear();
for (const callback of callbacks) callback(performance.now());
},
};
}
describe("mention selection focus", () => {
it("focuses the replacement editor at the canonical insertion caret before the next animation frame", () => {
const { container } = renderComposer("mention-focus-before-frame");
const input = enterMention(container, "Use @res carefully", 8);
act(() => input.focus());
holdAnimationFrames();
fireEvent.keyDown(input, { key: "Enter" });
const editor = container.querySelector<HTMLElement>(
'[contenteditable="true"]',
)!;
const token = referenceToken("skill", "research", "research");
expect({
focused: document.activeElement === editor,
caret: referenceCaret(editor),
text: readReferenceEditor(editor),
}).toEqual({
focused: true,
caret: "Use ".length + token.length + 1,
text: `Use ${token} carefully`,
});
});
it("does not overwrite a user's subsequent caret move with a late mention callback", () => {
const { container } = renderComposer("mention-late-frame-caret");
const input = enterMention(container, "Use @res carefully", 8);
act(() => input.focus());
const frames = holdAnimationFrames();
fireEvent.keyDown(input, { key: "Enter" });
const editor = container.querySelector<HTMLElement>(
'[contenteditable="true"]',
)!;
act(() => focusReferenceAt(editor, 1));
fireEvent.keyUp(editor, { key: "ArrowLeft" });
expect(referenceCaret(editor)).toBe(1);
act(() => frames.flush());
expect(document.activeElement).toBe(editor);
expect(referenceCaret(editor)).toBe(1);
});
it("keeps focus and the canonical caret when the last selected reference is toggled off", () => {
const token = referenceToken("skill", "research", "research");
saveDraft("last-reference-focus", `Start ${token} end`);
const { container } = renderComposer("last-reference-focus");
const editor = container.querySelector<HTMLElement>(
'[contenteditable="true"]',
)!;
act(() => focusReferenceAt(editor, 2));
const picker = screen.getByTestId("add-attachments-button");
act(() => picker.focus());
holdAnimationFrames();
fireEvent.click(picker);
fireEvent.click(
screen.getByRole("option", { name: "research Research a topic" }),
);
// The composer intentionally retains its inline editor after the last
// reference; no textarea remount is required to preserve native undo.
expect(screen.queryByTestId("inline-skill-reference")).toBeNull();
expect(container.querySelector('[contenteditable="true"]')).toBe(editor);
expect({
focused: document.activeElement === editor,
caret: referenceCaret(editor),
text: readReferenceEditor(editor),
}).toEqual({ focused: true, caret: 2, text: "Start end" });
});
});
describe("unified composer mentions", () => {
it("submits a plugin selection as readable text and structured metadata", async () => {
pluginSearch.mockResolvedValue([{ id: "alice", label: "Alice" }]);

View File

@ -11,6 +11,7 @@ import {
import type { ReactNode } from "react";
import { TaskDetail } from "@/components/workspace/scheduled-tasks/task-detail";
import type { ChannelConnection } from "@/core/channels/types";
import { I18nProvider } from "@/core/i18n/context";
import type {
ScheduledTask,
@ -20,7 +21,14 @@ import type {
import { expectNoRawIdentifiers } from "../../../helpers/readable";
import { loadLiveMinuteTask } from "../../../helpers/scheduled-fixtures";
const { fetchRuns } = rs.hoisted(() => ({ fetchRuns: rs.fn() }));
const { fetchRuns, listConnections } = rs.hoisted(() => ({
fetchRuns: rs.fn(),
listConnections: rs.fn(async (): Promise<ChannelConnection[]> => []),
}));
rs.mock("@/core/channels/api", () => ({
listChannelConnections: listConnections,
}));
rs.mock("@/core/scheduled-tasks/api", () => ({
fetchScheduledTaskRuns: fetchRuns,
@ -55,6 +63,8 @@ afterEach(() => {
cleanup();
clients.splice(0).forEach((client) => client.clear());
fetchRuns.mockReset();
listConnections.mockReset();
listConnections.mockResolvedValue([]);
document.cookie = "locale=; max-age=0; path=/";
});
@ -154,6 +164,40 @@ function renderDetail(
}
describe("TaskDetail", () => {
test("shows the additional group's name, never its binding or chat identifier", async () => {
listConnections.mockResolvedValue([
{
id: "connection-secret-id",
provider: "feishu",
status: "connected",
workspace_id: "oc_secret_chat",
workspace_name: "Planning team",
scopes: [],
metadata: { chat_id: "oc_secret_chat", chat_type: "group" },
},
]);
renderDetail(
task({ notification_group_connection_id: "connection-secret-id" }),
);
await screen.findByText("Planning team");
const section = screen.getByTestId("scheduled-task-notification-group");
expect(section.textContent).toContain(
"Your personal notifications stay unchanged",
);
expect(section.textContent).not.toContain("connection-secret-id");
expect(section.textContent).not.toContain("oc_secret_chat");
});
test("retains an unavailable group indication when its connection is missing", async () => {
renderDetail(
task({ notification_group_connection_id: "missing-connection-id" }),
);
await screen.findByText("Selected group unavailable");
expect(
screen.getByTestId("scheduled-task-notification-group").textContent,
).not.toContain("missing-connection-id");
});
test("an active task shows Runs, Stops when, goal, Does, notes and history without raw identifiers", async () => {
const view = renderDetail(
task({

View File

@ -16,12 +16,35 @@ import {
initialFormState,
type TaskFormRequest,
} from "@/components/workspace/scheduled-tasks/task-form-dialog";
import type { ChannelConnection } from "@/core/channels/types";
import { I18nProvider } from "@/core/i18n/context";
import type { ScheduledTask } from "@/core/scheduled-tasks/types";
const { createTask, updateTask } = rs.hoisted(() => ({
function group(overrides: Partial<ChannelConnection> = {}): ChannelConnection {
return {
id: "group-binding",
provider: "feishu",
status: "connected",
workspace_id: "oc_private_group_id",
workspace_name: "Planning team",
metadata: { chat_id: "oc_private_group_id", chat_type: "group" },
scopes: [],
...overrides,
};
}
const {
createTask,
updateTask,
listConnections,
connectProvider,
groupNotifications,
} = rs.hoisted(() => ({
createTask: rs.fn(),
updateTask: rs.fn(),
listConnections: rs.fn(async (): Promise<ChannelConnection[]> => []),
connectProvider: rs.fn(),
groupNotifications: { value: true as boolean | undefined, isLoading: false },
}));
rs.mock("@/core/scheduled-tasks/api", () => ({
@ -32,6 +55,16 @@ rs.mock("@/core/agents/api", () => ({
fetchAgentsApiEnabled: async () => false,
listAgents: async () => [],
}));
rs.mock("@/core/channels/api", () => ({
listChannelConnections: listConnections,
connectChannelProvider: connectProvider,
}));
rs.mock("@/core/features/hooks", () => ({
useScheduledTasksFeature: () => ({
groupNotifications: groupNotifications.value,
isLoading: groupNotifications.isLoading,
}),
}));
rs.mock("sonner", () => ({
toast: { success: rs.fn(), error: rs.fn(), info: rs.fn() },
}));
@ -42,6 +75,11 @@ afterEach(() => {
clients.splice(0).forEach((client) => client.clear());
createTask.mockReset();
updateTask.mockReset();
listConnections.mockReset();
listConnections.mockResolvedValue([]);
connectProvider.mockReset();
groupNotifications.value = true;
groupNotifications.isLoading = false;
document.cookie = "locale=; max-age=0; path=/";
});
@ -86,7 +124,7 @@ function renderForm(
defaultOptions: { queries: { retry: false } },
});
clients.push(client);
return render(
const tree = () => (
<QueryClientProvider client={client}>
<I18nProvider initialLocale={locale}>
<TaskFormDialog
@ -95,11 +133,365 @@ function renderForm(
onOpenChange={rs.fn()}
/>
</I18nProvider>
</QueryClientProvider>,
</QueryClientProvider>
);
const view = render(tree());
return { ...view, rerenderForm: () => view.rerender(tree()) };
}
describe("TaskFormDialog", () => {
test("a pending capability preserves the saved group through a title edit and then shows the enabled selector", async () => {
groupNotifications.value = false;
groupNotifications.isLoading = true;
listConnections.mockResolvedValue([group()]);
updateTask.mockResolvedValue(task());
const view = renderForm({
mode: "edit",
task: task({ notification_group_connection_id: "group-binding" }),
});
expect(screen.getByText("Loading groups…")).toBeTruthy();
expect(
screen.queryByText(
"This task has a saved group destination, but group notifications are unavailable on this server.",
),
).toBeNull();
expect(
screen.queryByRole("button", { name: "Clear group notification" }),
).toBeNull();
expect(
screen.queryByRole("combobox", {
name: "Also notify a Feishu group (optional)",
}),
).toBeNull();
expect(
screen.queryByRole("button", { name: "Bind a Feishu group" }),
).toBeNull();
expect(listConnections).not.toHaveBeenCalled();
fireEvent.change(screen.getByRole("textbox", { name: "Title" }), {
target: { value: "Updated title" },
});
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() =>
expect(updateTask).toHaveBeenCalledWith("task-1", {
title: "Updated title",
}),
);
groupNotifications.value = true;
groupNotifications.isLoading = false;
view.rerenderForm();
await waitFor(() =>
expect(
screen.getByRole("combobox", {
name: "Also notify a Feishu group (optional)",
}).textContent,
).toBe("Planning team"),
);
expect(
screen.queryByRole("button", { name: "Clear group notification" }),
).toBeNull();
});
test("a saved group can be explicitly cleared only after the capability resolves as disabled", async () => {
groupNotifications.value = false;
groupNotifications.isLoading = true;
updateTask.mockResolvedValue(task());
const view = renderForm({
mode: "edit",
task: task({ notification_group_connection_id: "group-binding" }),
});
expect(screen.getByText("Loading groups…")).toBeTruthy();
expect(
screen.queryByRole("button", { name: "Clear group notification" }),
).toBeNull();
groupNotifications.isLoading = false;
view.rerenderForm();
expect(
screen.getByText(
"This task has a saved group destination, but group notifications are unavailable on this server.",
),
).toBeTruthy();
fireEvent.click(
screen.getByRole("button", { name: "Clear group notification" }),
);
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() =>
expect(updateTask).toHaveBeenCalledWith("task-1", {
notification_group_connection_id: null,
}),
);
expect(listConnections).not.toHaveBeenCalled();
});
test.each([
[
"en-US",
"This task has a saved group destination, but group notifications are unavailable on this server.",
"Clear group notification",
"Create",
],
["zh-CN", "已有群通知配置,当前服务未启用群通知。", "清除群通知", "创建"],
] as const)(
"a duplicate can explicitly drop an old group when the capability is off (%s)",
async (locale, notice, clear, create) => {
groupNotifications.value = false;
createTask.mockResolvedValue(task({ id: "task-copy" }));
renderForm(
{
mode: "duplicate",
task: task({ notification_group_connection_id: "existing-binding" }),
},
{ locale },
);
expect(screen.getByText(notice)).toBeTruthy();
expect(document.body.textContent).not.toContain("existing-binding");
expect(listConnections).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: clear }));
fireEvent.click(screen.getByRole("button", { name: create }));
await waitFor(() => expect(createTask).toHaveBeenCalledTimes(1));
expect(createTask.mock.calls[0]![0]).not.toHaveProperty(
"notification_group_connection_id",
);
expect(createTask.mock.calls[0]![0]).toMatchObject({
schedule_spec: { cron: "0 9 * * *" },
});
},
);
test("clearing a saved group with the capability off sends only null in an edit", async () => {
groupNotifications.value = false;
updateTask.mockResolvedValue(task());
renderForm({
mode: "edit",
task: task({ notification_group_connection_id: "existing-binding" }),
});
expect(
screen.queryByRole("button", { name: "Bind a Feishu group" }),
).toBeNull();
expect(
screen.queryByRole("combobox", {
name: "Also notify a Feishu group (optional)",
}),
).toBeNull();
fireEvent.click(
screen.getByRole("button", { name: "Clear group notification" }),
);
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() =>
expect(updateTask).toHaveBeenCalledWith("task-1", {
notification_group_connection_id: null,
}),
);
});
test("an older backend without the group capability hides selection and binding while preserving the saved field", async () => {
groupNotifications.value = undefined;
updateTask.mockResolvedValue(task());
renderForm({
mode: "edit",
task: task({ notification_group_connection_id: "existing-binding" }),
});
expect(
screen.queryByRole("combobox", {
name: "Also notify a Feishu group (optional)",
}),
).toBeNull();
expect(
screen.queryByRole("button", { name: "Bind a Feishu group" }),
).toBeNull();
expect(listConnections).not.toHaveBeenCalled();
fireEvent.change(screen.getByRole("textbox", { name: "Title" }), {
target: { value: "Updated title" },
});
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() =>
expect(updateTask).toHaveBeenCalledWith("task-1", {
title: "Updated title",
}),
);
});
test("group binding keeps a copyable command visible without changing the saved destination", async () => {
connectProvider.mockResolvedValue({
provider: "feishu",
mode: "binding_code",
code: "test-group-code",
instruction: "unused",
expires_in: 600,
});
const writeText = rs.fn(async () => undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
updateTask.mockResolvedValue(task());
renderForm({
mode: "edit",
task: task({ notification_group_connection_id: "saved-group-binding" }),
});
fireEvent.click(
screen.getByRole("button", { name: "Bind a Feishu group" }),
);
const command = await screen.findByRole("textbox", {
name: "Group binding command",
});
expect(command).toHaveProperty("value", "/connect test-group-code");
expect(command).toHaveProperty("readOnly", true);
expect(connectProvider).toHaveBeenCalledWith("feishu");
expect(
screen.getByText(
"Add the bot to the group, then mention the bot and send this command in that group. Refresh the group list after binding.",
),
).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Copy command" }));
await waitFor(() =>
expect(writeText).toHaveBeenCalledWith("/connect test-group-code"),
);
fireEvent.click(
screen.getByRole("button", { name: "Close binding instructions" }),
);
fireEvent.change(screen.getByRole("textbox", { name: "Title" }), {
target: { value: "Checklist v2" },
});
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() =>
expect(updateTask).toHaveBeenCalledWith("task-1", {
title: "Checklist v2",
}),
);
});
test("refreshing after binding discovers the group without changing the task automatically", async () => {
listConnections.mockResolvedValueOnce([]).mockResolvedValue([group()]);
renderForm({ mode: "create" });
await screen.findByText(
"Bind a Feishu group below, then refresh the list.",
);
fireEvent.click(screen.getByRole("button", { name: "Refresh group list" }));
await waitFor(() => expect(listConnections).toHaveBeenCalledTimes(2));
fireEvent.keyDown(
screen.getByRole("combobox", {
name: "Also notify a Feishu group (optional)",
}),
{ key: "ArrowDown" },
);
expect(
await screen.findByRole("option", { name: "Planning team" }),
).toBeTruthy();
expect(createTask).not.toHaveBeenCalled();
expect(updateTask).not.toHaveBeenCalled();
});
test("choosing a named Feishu group sends only the new destination", async () => {
listConnections.mockResolvedValue([group()]);
updateTask.mockResolvedValue(task());
renderForm({ mode: "edit", task: task() });
await waitFor(() => expect(listConnections).toHaveBeenCalled());
fireEvent.keyDown(
screen.getByRole("combobox", {
name: "Also notify a Feishu group (optional)",
}),
{ key: "ArrowDown" },
);
fireEvent.click(
await screen.findByRole("option", { name: "Planning team" }),
);
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(updateTask).toHaveBeenCalledTimes(1));
expect(updateTask).toHaveBeenCalledWith("task-1", {
notification_group_connection_id: "group-binding",
});
});
test("a failed groups query preserves the stored destination when editing another field", async () => {
listConnections.mockRejectedValue(new Error("offline"));
updateTask.mockResolvedValue(task());
renderForm({
mode: "edit",
task: task({ notification_group_connection_id: "saved-group-binding" }),
});
await screen.findByText(
"Could not load groups. Your selection is kept; you can save other changes or remove it.",
);
expect(
screen.getByRole("combobox", {
name: "Also notify a Feishu group (optional)",
}).textContent,
).toBe("Selected group unavailable");
expect(document.body.textContent).not.toContain("saved-group-binding");
fireEvent.change(screen.getByRole("textbox", { name: "Title" }), {
target: { value: "Checklist v2" },
});
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(updateTask).toHaveBeenCalledTimes(1));
expect(updateTask).toHaveBeenCalledWith("task-1", {
title: "Checklist v2",
});
});
test("an unavailable selected group can be explicitly cleared even while the list fails", async () => {
listConnections.mockRejectedValue(new Error("offline"));
updateTask.mockResolvedValue(task());
renderForm({
mode: "edit",
task: task({ notification_group_connection_id: "saved-group-binding" }),
});
await screen.findByText(
"Could not load groups. Your selection is kept; you can save other changes or remove it.",
);
fireEvent.keyDown(
screen.getByRole("combobox", {
name: "Also notify a Feishu group (optional)",
}),
{ key: "ArrowDown" },
);
fireEvent.click(
await screen.findByRole("option", { name: "No additional group" }),
);
fireEvent.click(screen.getByRole("button", { name: "Save changes" }));
await waitFor(() => expect(updateTask).toHaveBeenCalledTimes(1));
expect(updateTask).toHaveBeenCalledWith("task-1", {
notification_group_connection_id: null,
});
});
test("unnamed groups are disabled without exposing identifiers", async () => {
listConnections.mockResolvedValue([group({ workspace_name: null })]);
renderForm({ mode: "create" });
await screen.findByText(
"Check the app's group-information permission, then bind the group again to load its name.",
);
fireEvent.keyDown(
screen.getByRole("combobox", {
name: "Also notify a Feishu group (optional)",
}),
{ key: "ArrowDown" },
);
expect(
(
await screen.findByRole("option", { name: "Group name unavailable" })
).getAttribute("aria-disabled"),
).toBe("true");
expect(document.body.textContent).not.toContain("oc_private_group_id");
expect(document.body.textContent).not.toContain("group-binding");
});
test("the Chinese group field explains that personal notifications stay unchanged", async () => {
renderForm({ mode: "create" }, { locale: "zh-CN" });
expect(
screen.getByRole("combobox", { name: "额外通知到飞书群(可选)" })
.textContent,
).toBe("仅通知本人");
expect(
screen.getByText("你现有的个人通知保持不变,所选群也会收到任务更新。"),
).toBeTruthy();
await screen.findByText("先在下方绑定飞书群,再刷新群列表。");
});
test("every field resolves by its label", () => {
renderForm({ mode: "edit", task: task() });
expect(screen.getByRole("textbox", { name: "Title" })).toHaveProperty(
@ -316,6 +708,62 @@ describe("TaskFormDialog", () => {
});
describe("form payload helpers", () => {
test("duplicate retains an additional group, while an untouched edit omits it", () => {
const source = task({ notification_group_connection_id: "group-binding" });
const { state } = initialFormState(
{ mode: "duplicate", task: source },
"(copy)",
);
const valid = validateForm(state, source.end_at);
expect(valid.ok).toBe(true);
if (!valid.ok) return;
expect(createPayload(state, valid).notification_group_connection_id).toBe(
"group-binding",
);
expect(
updatePayload(source, { ...state, title: source.title }, valid, {
includeStopCondition: true,
}),
).toEqual({});
});
test("changing only a notification group never sends the schedule; clearing sends null", () => {
const source = task({ notification_group_connection_id: "old-binding" });
const { state } = initialFormState({ mode: "edit", task: source }, "");
const valid = validateForm(state, source.end_at);
expect(valid.ok).toBe(true);
if (!valid.ok) return;
expect(
updatePayload(
source,
{ ...state, notificationGroupConnectionId: "new-binding" },
valid,
{
includeStopCondition: true,
},
),
).toEqual({ notification_group_connection_id: "new-binding" });
expect(
updatePayload(
source,
{ ...state, notificationGroupConnectionId: "" },
valid,
{
includeStopCondition: true,
},
),
).toEqual({ notification_group_connection_id: null });
});
test("new tasks have no additional group by default", () => {
const { state } = initialFormState({ mode: "create" }, "");
expect(state.notificationGroupConnectionId).toBe("");
expect(
createPayload(state, { maxRuns: null, endAt: null })
.notification_group_connection_id,
).toBeUndefined();
});
test("an untouched form produces an empty update", () => {
const source = task({ end_at: "2099-12-31T10:00:30Z" });
const { state } = initialFormState(

View File

@ -110,6 +110,7 @@ describe("scheduled tasks feature", () => {
available: true,
running: false,
tool_enabled: false,
group_notifications: true,
min_interval_seconds: 30,
},
}),
@ -118,6 +119,7 @@ describe("scheduled tasks feature", () => {
available: true,
running: false,
toolEnabled: false,
groupNotifications: true,
minIntervalSeconds: 30,
});
});
@ -130,6 +132,7 @@ describe("scheduled tasks feature", () => {
available: true,
running: true,
toolEnabled: false,
groupNotifications: false,
minIntervalSeconds: 60,
});
});
@ -138,13 +141,18 @@ describe("scheduled tasks feature", () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse({
agents_api: { enabled: true },
scheduled_tasks: { available: false, min_interval_seconds: "10" },
scheduled_tasks: {
available: false,
min_interval_seconds: "10",
group_notifications: "true",
},
}),
);
await expect(fetchScheduledTasksFeature()).resolves.toEqual({
available: false,
running: true,
toolEnabled: false,
groupNotifications: false,
minIntervalSeconds: 60,
});
});

View File

@ -0,0 +1,47 @@
import { describe, expect, test } from "@rstest/core";
import type { ChannelConnection } from "@/core/channels/types";
import {
feishuNotificationGroups,
notificationGroupName,
} from "@/core/scheduled-tasks/notification-groups";
const group: ChannelConnection = {
id: "binding",
provider: "feishu",
status: "connected",
workspace_id: "oc_group",
workspace_name: "Project review",
scopes: [],
metadata: { chat_id: "oc_group", chat_type: "group" },
};
describe("additional Feishu notification groups", () => {
test.each([
{ provider: "slack" },
{ status: "revoked" },
{ status: "pending" },
{ metadata: { chat_id: "oc_group", chat_type: "p2p" } },
{ metadata: { chat_id: "different-chat", chat_type: "group" } },
{ metadata: { chat_type: "group" } },
{ workspace_id: "" },
{ workspace_id: null },
])(
"excludes bindings outside the trusted connected group route: %j",
(overrides) => {
expect(
feishuNotificationGroups([{ ...group, ...overrides }, group]),
).toEqual([group]);
},
);
test("keeps an unnamed connected group distinguishable from a missing binding without inventing a name", () => {
const unnamed = { ...group, workspace_name: " " };
expect(feishuNotificationGroups([unnamed])).toEqual([unnamed]);
expect(notificationGroupName(unnamed)).toBeNull();
expect(notificationGroupName(undefined)).toBeNull();
expect(
notificationGroupName({ ...group, workspace_name: " 项目会议 " }),
).toBe("项目会议");
});
});