mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
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:
parent
8cec469738
commit
5ae95e6a34
21
README.md
21
README.md
@ -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.
|
||||
|
||||
@ -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`)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
6
backend/app/channels/notification_groups.py
Normal file
6
backend/app/channels/notification_groups.py
Normal 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
|
||||
@ -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)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
85
backend/tests/test_migration_0040_scheduled_group_notice.py
Normal file
85
backend/tests/test_migration_0040_scheduled_group_notice.py
Normal 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()
|
||||
@ -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"}))
|
||||
|
||||
|
||||
28
backend/tests/test_notification_groups.py
Normal file
28
backend/tests/test_notification_groups.py
Normal 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
|
||||
185
backend/tests/test_scheduled_task_group_targets.py
Normal file
185
backend/tests/test_scheduled_task_group_targets.py
Normal 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"
|
||||
@ -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")
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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?.();
|
||||
},
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) &&
|
||||
|
||||
@ -108,6 +108,7 @@ export function useScheduledTasksFeature() {
|
||||
available: feature.available,
|
||||
running: feature.running,
|
||||
toolEnabled: feature.toolEnabled,
|
||||
groupNotifications: feature.groupNotifications,
|
||||
minIntervalSeconds: feature.minIntervalSeconds,
|
||||
isLoading: isPending,
|
||||
};
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: "结束时间至少要留出一次运行。",
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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",
|
||||
|
||||
24
frontend/src/core/scheduled-tasks/notification-groups.ts
Normal file
24
frontend/src/core/scheduled-tasks/notification-groups.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
/**
|
||||
|
||||
30
frontend/tests/e2e/mention-caret-handoff.spec.ts
Normal file
30
frontend/tests/e2e/mention-caret-handoff.spec.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
285
frontend/tests/e2e/scheduled-notification-groups.spec.ts
Normal file
285
frontend/tests/e2e/scheduled-notification-groups.spec.ts
Normal 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);
|
||||
});
|
||||
@ -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: {
|
||||
|
||||
@ -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" }]);
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@ -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("项目会议");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user