fix(web-shell): refine scheduled delivery states and coverage

This commit is contained in:
jkxiao committed 2026-11-07 12:31:29 +00:00
1 parent d24088fcf8
commit d3cbd7c242
10 files changed
+615 -86

No files matched your search

@@ -8,13 +8,13 @@ Issue #16516 exposes an existing daemon capability: delivering the final respons
## Design
Reuse the SDK `DaemonChannelDelivery` contract on task reads and create/update requests. An omitted update preserves delivery; `null` explicitly clears it. Only actual user changes send the field, matching the existing gate field. Configuring a Channel never enables task delivery automatically.
Reuse the SDK `DaemonChannelDelivery` contract on task reads and create/update requests. An omitted update preserves delivery; `null` explicitly clears it. Only actual user changes send the field, matching the existing gate field. Configuring a Channel never enables task delivery automatically. Destination IDs remain opaque: the form warns about leading or trailing whitespace and offers an explicit action to remove it, without changing IDs automatically.
The form offers delivery off or a Channel instance with a user/chat target ID. Observed contacts supply optional suggestions; labels are accompanied by Channel name, target type, and raw ID to distinguish similar names. Manual IDs remain possible because discovery is not authorization. Cards show the saved Channel, type, and ID even when discovery fails or the daemon no longer advertises delivery.
The form offers delivery off or a Channel instance with a user/chat target ID. Observed contacts supply optional suggestions and load only when a delivery target is selected; labels are accompanied by Channel name, target type, and raw ID to distinguish similar names. Manual IDs remain possible because discovery is not authorization. Cards show the saved Channel, type, and ID even when discovery fails or the daemon no longer advertises delivery. Cards without a saved target omit the delivery line.
Configured Channels come from the existing workspace-aware Channel hook. Add a Web Shell action for the existing observed-contacts route, scoped with the same explicit workspace ID as task mutations. Only top-level users and groups become suggestions; threads/topics are not delivery target types. A form workspace change clears its draft destination and invalidates old discovery results. Editing stays pinned to the task's workspace. Missing or untrusted workspaces cannot silently fall back to another workspace.
Gate editing on `channel_delivery` and discovery on `workspace_channel_observed_contacts`. Preserve saved values across capability loss, missing Channels, stale contacts, and request failures. Show understandable loading, failure, empty, and unavailable states. A changed draft cannot be silently ignored after capability loss.
Gate editing on `channel_delivery` and discovery on `workspace_channel_observed_contacts`. Preserve saved values across capability loss, missing Channels, stale contacts, and request failures. Show understandable loading, failure, empty, and unavailable states. Only a successfully loaded Channel list establishes that a saved Channel is missing; an initial, loading, or failed request does not. A changed draft cannot be silently ignored after capability loss. The user can discard the unsaved delivery change, restoring the original target (or no target when creating a task), and save other form changes without sending a delivery update.
Delivery requires `persistent`, including the current-session option. Selecting `per_run` never silently clears delivery or switches the chosen mode. Explain the restriction and reject submission until the user explicitly clears delivery or chooses a persistent mode. An unchanged existing destination can be saved without rediscovery.
@@ -28,8 +28,10 @@ Focused action tests verify route scoping and error handling. Form and browser c
## Open questions and follow-up
There are no blocking implementation questions. Raw IDs are displayed to resolve the identity ambiguity raised in triage. Maintainer issue housekeeping and actual Channel delivery/retry enhancements are separate work. Stop before publishing the prepared PR as requested.
There are no blocking implementation questions. Delivery guarantees, durable retries, and failure alerts remain separate work.
## Browser evidence
Verified with the repository Playwright harness and intercepted daemon responses: [before](assets/scheduled-task-channel-delivery/before.png), [delivery form](assets/scheduled-task-channel-delivery/after-details.png), [saved task](assets/scheduled-task-channel-delivery/after-card.png), and [mobile restriction and long ID](assets/scheduled-task-channel-delivery/mobile.png). These screenshots demonstrate the UI, not real provider receipt.
The built CLI was also verified with all six delivery scenarios selected by `@smoke`, including [explicit whitespace removal](assets/scheduled-task-channel-delivery/after-whitespace.png) and [a card after delivery is cleared](assets/scheduled-task-channel-delivery/after-cleared-card.png).
@@ -8,15 +8,15 @@ Issue #16516 要求开放 daemon 已有能力:将成功完成的 persistent
## 设计
任务读取和创建/更新请求复用 SDK 的 `DaemonChannelDelivery` 契约。更新时省略字段表示保留,`null` 表示明确清除。与现有 gate 字段一致,仅在用户实际修改后提交该字段。配置 Channel 不会自动启用任务投递。
任务读取和创建/更新请求复用 SDK 的 `DaemonChannelDelivery` 契约。更新时省略字段表示保留,`null` 表示明确清除。与现有 gate 字段一致,仅在用户实际修改后提交该字段。配置 Channel 不会自动启用任务投递。目标 ID 保持原样:表单会提示首尾空白,并提供由用户明确选择的去除操作,不会自动修改 ID。
表单提供关闭投递,或选择 Channel 实例及用户/群目标 ID。已观察联系人仅提供可选候选;标签同时展示 Channel 名、目标类型和原始 ID,以区分同名目标。允许手工填写 ID,因为发现不是授权。即使发现失败或 daemon 不再声明投递能力,卡片仍显示保存的 Channel、类型和 ID。
表单提供关闭投递,或选择频道实例及用户/群目标 ID。已观察联系人仅提供可选候选,并且仅在选择投递目标后加载;标签同时展示频道名、目标类型和原始 ID,以区分同名目标。允许手工填写 ID,因为发现不是授权。即使发现失败或 daemon 不再声明投递能力,卡片仍显示保存的频道、类型和 ID。没有保存目标的卡片不显示投递行。
已配置 Channel 复用支持工作区的现有 Channel hook。为现有 observed-contacts 路由增加 Web Shell 动作,与任务变更使用同一个显式工作区 ID。仅将顶层用户和群作为候选,线程/话题不是投递目标类型。表单切换工作区时清除草稿目标,并使旧发现结果失效。编辑固定在任务所属工作区。工作区缺失或不受信任时不能悄悄回退至其他工作区。
以 `channel_delivery` 控制编辑,以 `workspace_channel_observed_contacts` 控制发现。能力丢失、Channel 缺失、联系人过期或请求失败均保留已保存值,显示易懂的加载、失败、空列表和不可用状态。能力丢失后不能静默忽略已修改的草稿。
以 `channel_delivery` 控制编辑,以 `workspace_channel_observed_contacts` 控制发现。能力丢失、频道缺失、联系人过期或请求失败均保留已保存值,显示易懂的加载、失败、空列表和不可用状态。只有成功加载的频道列表才能确认已保存的频道不存在;初始状态、加载中或请求失败均不代表频道缺失。能力丢失后不能静默忽略已修改的草稿。用户可以放弃未保存的投递修改,恢复原目标(新建任务时恢复为不投递),然后保存表单中的其他修改,而不发送投递更新。
投递要求 `persistent`,包括当前会话选项。选择 `per_run` 不会自动清除投递或改变用户选择的模式。解释限制并阻止提交,直到用户主动清除投递或选择持久会话模式。保存未修改的已有目标不依赖再次发现。
投递要求 `persistent`,包括当前会话选项。选择 `per_run` 不会自动清除投递或改变用户选择的模式。解释限制并阻止提交,直到用户主动清除投递或选择固定会话模式。保存未修改的已有目标不依赖再次发现。
## 影响面与风险
@@ -28,8 +28,10 @@ Issue #16516 要求开放 daemon 已有能力:将成功完成的 persistent
## 待定事项与后续工作
没有阻塞实现的问题。通过展示原始 ID 解决分诊提出的身份歧义。维护者的 issue 整理及实际 Channel 投递/重试增强属于独立工作。按用户要求停止在发布准备好的 PR 之前。
没有阻塞实现的问题。送达保证、持久化重试和失败告警仍属于独立工作。
## 浏览器证据
通过仓库 Playwright 测试环境及拦截的 daemon 响应验证:[修改前](assets/scheduled-task-channel-delivery/before.png)、[投递表单](assets/scheduled-task-channel-delivery/after-details.png)、[保存后的任务](assets/scheduled-task-channel-delivery/after-card.png)及[移动端限制和长 ID](assets/scheduled-task-channel-delivery/mobile.png)。这些截图展示 UI,不代表真实平台收件。
还针对打包后的 CLI 验证了由 `@smoke` 选中的全部 6 个投递场景,包括[明确去除首尾空白](assets/scheduled-task-channel-delivery/after-whitespace.png)及[清除投递后的卡片](assets/scheduled-task-channel-delivery/after-cleared-card.png)。
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

+4 -2
View File
@@ -12,12 +12,14 @@ Messaging channels use a separate persistent scheduler so results can be pushed
## Deliver Web Shell task results to a Channel
In `qwen serve`, open **Scheduled Tasks** and create or edit a task in its workspace. Choose a persistent run destination (a dedicated session, or the current session when available), then choose a **Delivery Channel**, a user or chat destination type, and a destination ID. Recent observed contacts are optional suggestions; check the Channel name, type, and raw ID before selecting one. You can also enter an ID directly. The configured Channel must support sending to that destination.
In `qwen serve`, open **Scheduled Tasks** and create or edit a task in its workspace. Choose a persistent run destination (a dedicated session, or the current session when available), then choose a **Delivery Channel**, a user or chat destination type, and a destination ID. Recent observed contacts are optional suggestions; check the Channel name, type, and raw ID before selecting one. You can also enter an ID directly. If an ID has leading or trailing whitespace, the form warns you and offers **Remove surrounding whitespace**; it never changes the ID automatically. The configured Channel must support sending to that destination.
Delivery is off by default. Configuring a Channel alone does not enable delivery for any task. When enabled, a successfully completed scheduled run's final response is sent through the selected Channel; this does not guarantee receipt or add durable retries or failure alerts. The task card displays its saved destination.
Delivery is off by default. Configuring a Channel alone does not enable delivery for any task. When enabled, a successfully completed scheduled run's final response is sent through the selected Channel; this does not guarantee receipt or add durable retries or failure alerts. The task card displays a delivery line only when it has a saved destination.
To remove delivery, edit the task and select **Delivery off** under **Delivery Channel**. Editing other fields preserves the saved target, including a target no longer found in recent contacts. Loading failures or missing Channels do not clear it. Changing the workspace while creating a task resets the draft destination; an existing task remains in its original workspace.
If delivery support becomes unavailable after you change a destination, choose **Discard delivery changes** to restore the original destination and keep editing the task's other fields. On a new task, this removes the draft destination. Saving then leaves any existing delivery configuration unchanged.
Tasks that create a fresh session for each run (`per_run`) do not support delivery. Switching to that mode keeps your draft destination visible and asks you to explicitly turn delivery off or return to a persistent mode before saving. Delivery controls require the daemon's `channel_delivery` capability; existing destinations remain visible with older daemons, but cannot be changed there.
## Schedule a recurring prompt with /loop
@@ -11,6 +11,7 @@ import {
useWorkspaceActions,
} from '@qwen-code/web-shell/daemon-react-sdk';
import { useI18n } from '../../i18n';
import { Button } from '../ui/button';
import { Field, FieldDescription, FieldGroup, FieldLabel } from '../ui/field';
import { Input } from '../ui/input';
import styles from './ScheduledTasksDialog.module.css';
@@ -64,12 +65,13 @@ export function ScheduledTaskDeliveryFields({
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [contactsLoading, setContactsLoading] = useState(false);
const [contactsError, setContactsError] = useState(false);
const hasTarget = delivery !== null;
useEffect(() => {
let cancelled = false;
setSuggestions([]);
setContactsError(false);
setContactsLoading(false);
if (!canDiscover || !observedContactsAvailable) return;
if (!canDiscover || !observedContactsAvailable || !hasTarget) return;
setContactsLoading(true);
void actions.listChannelObservedContacts(workspaceId).then(
(contacts) => {
@@ -95,11 +97,18 @@ export function ScheduledTaskDeliveryFields({
return () => {
cancelled = true;
};
}, [actions, canDiscover, observedContactsAvailable, workspaceId]);
}, [actions, canDiscover, observedContactsAvailable, workspaceId, hasTarget]);
const names = canDiscover ? Object.keys(channels.channels) : [];
const channelsReady =
canDiscover &&
channels.data !== undefined &&
!channels.loading &&
!channels.error;
const target = delivery?.target;
const unavailableTarget = !!target && !names.includes(target.channelName);
const unavailableTarget =
!!target && channelsReady && !names.includes(target.channelName);
const hasSurroundingWhitespace = !!target && target.id !== target.id.trim();
const matchingSuggestions = suggestions.filter(
(suggestion) => suggestion.channelName === target?.channelName,
);
@@ -150,7 +159,7 @@ export function ScheduledTaskDeliveryFields({
}
>
<option value="">{t('scheduledTasks.delivery.off')}</option>
{unavailableTarget && (
{target && !names.includes(target.channelName) && (
<option value={target.channelName}>{target.channelName}</option>
)}
{names.map((name) => (
@@ -185,21 +194,18 @@ export function ScheduledTaskDeliveryFields({
{t('scheduledTasks.delivery.channelsFailed')}
</FieldDescription>
)}
{canDiscover &&
!channels.loading &&
!channels.error &&
names.length === 0 && (
<FieldDescription>
{t('scheduledTasks.delivery.noChannels')}
</FieldDescription>
)}
{channelsReady && names.length === 0 && (
<FieldDescription>
{t('scheduledTasks.delivery.noChannels')}
</FieldDescription>
)}
</Field>
{target && (
<>
<FieldDescription className="min-w-0 break-all">
{identity(target)}
</FieldDescription>
{unavailableTarget && !channels.loading && (
{unavailableTarget && (
<FieldDescription>
{t('scheduledTasks.delivery.targetUnavailable')}
</FieldDescription>
@@ -286,8 +292,29 @@ export function ScheduledTaskDeliveryFields({
id="scheduled-task-delivery-id"
value={target.id}
disabled={disabled || !available || unavailableTarget}
aria-describedby={
hasSurroundingWhitespace
? 'scheduled-task-delivery-id-whitespace'
: undefined
}
onChange={(event) => updateTarget({ id: event.target.value })}
/>
{hasSurroundingWhitespace && (
<>
<FieldDescription id="scheduled-task-delivery-id-whitespace">
{t('scheduledTasks.delivery.whitespace')}
</FieldDescription>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled || !available || unavailableTarget}
onClick={() => updateTarget({ id: target.id.trim() })}
>
{t('scheduledTasks.delivery.trimWhitespace')}
</Button>
</>
)}
</Field>
</>
)}
@@ -2723,9 +2723,26 @@ describe('ScheduledTasksDialog Channel delivery', () => {
],
};
const channelInstances = Object.fromEntries(
['team-bot', 'ops-bot'].map((name) => [
name,
{
name,
config: { type: 'dingtalk' },
secrets: {},
startsWithServe: false,
runtime: { state: 'stopped' as const },
},
]),
);
beforeEach(() => {
useDaemonChannels.mockReturnValue({
channels: { 'team-bot': {} },
data: {
catalog: [],
snapshot: { revision: '1', instances: channelInstances },
},
channels: channelInstances,
loading: false,
error: undefined,
reload: vi.fn(),
@@ -2767,6 +2784,62 @@ describe('ScheduledTasksDialog Channel delivery', () => {
enterPrompt();
}
it.each([
{ label: 'not started', loading: false, error: undefined },
{ label: 'loading', loading: true, error: undefined },
{ label: 'failed', loading: false, error: new Error('load failed') },
])(
'does not mark an undiscovered saved Channel missing when $label',
async ({ loading, error }) => {
useDaemonChannels.mockReturnValue({
data: undefined,
channels: {},
loading,
error,
reload: vi.fn(),
});
await mount([baseTask({ delivery })], options);
click(document.querySelector('[aria-label="Edit"]'));
await flush();
expect(document.body.textContent).not.toContain(
'No Channels are configured',
);
expect(document.body.textContent).not.toContain(
'This Channel is unavailable',
);
expect(channelSelect().value).toBe('team-bot');
expect(targetInput().disabled).toBe(false);
},
);
it('does not discover contacts before delivery is enabled', async () => {
await mount([], options);
await openCreate();
expect(actions.listChannelObservedContacts).not.toHaveBeenCalled();
select(channelSelect(), 'team-bot');
await flush();
expect(actions.listChannelObservedContacts).toHaveBeenCalledTimes(1);
input(targetInput(), 'first');
input(targetInput(), 'second');
await flush();
expect(actions.listChannelObservedContacts).toHaveBeenCalledTimes(1);
select(channelSelect(), '');
await flush();
expect(actions.listChannelObservedContacts).toHaveBeenCalledTimes(1);
expect(suggestionSelect()).toBeNull();
select(channelSelect(), 'team-bot');
await flush();
expect(actions.listChannelObservedContacts).toHaveBeenCalledTimes(2);
});
it.each([true, false])(
'omits the delivery row on an unconfigured card with capability %s',
async (deliveryAvailable) => {
await mount([baseTask({})], { ...options, deliveryAvailable });
expect(container?.textContent).not.toContain('Delivery off');
},
);
it('keeps delivery off by default even with configured Channels', async () => {
await mount([], options);
await openCreate();
@@ -2810,11 +2883,15 @@ describe('ScheduledTasksDialog Channel delivery', () => {
);
expect(actions.listChannelObservedContacts).toHaveBeenCalledWith(undefined);
expect(useDaemonChannels).toHaveBeenCalledWith(
expect.objectContaining({ workspaceCwd: '/repo/main', enabled: true }),
expect.objectContaining({
workspaceCwd: '/repo/main',
enabled: true,
autoLoad: true,
}),
);
});
it('permits a manual target without observed-contact capability', async () => {
it('preserves a manually entered raw ID without observed-contact capability', async () => {
await mount([], { ...options, observedContactsAvailable: false });
await openCreate();
select(findSessionModeSelect()!, 'dedicated');
@@ -2825,14 +2902,15 @@ describe('ScheduledTasksDialog Channel delivery', () => {
)!,
'user',
);
input(targetInput(), 'manual-user');
input(targetInput(), ' manual-user ');
expect(findButton('Remove surrounding whitespace')?.disabled).toBe(false);
click(findButton('Create'));
await flush();
expect(actions.listChannelObservedContacts).not.toHaveBeenCalled();
expect(actions.createScheduledTask.mock.calls[0]?.[0]).toMatchObject({
delivery: {
kind: 'channel',
target: { channelName: 'team-bot', type: 'user', id: 'manual-user' },
target: { channelName: 'team-bot', type: 'user', id: ' manual-user ' },
},
});
});
@@ -2868,6 +2946,7 @@ describe('ScheduledTasksDialog Channel delivery', () => {
it('explains an empty Channel list without opting the task into delivery', async () => {
useDaemonChannels.mockReturnValue({
data: { catalog: [], snapshot: { revision: '1', instances: {} } },
channels: {},
loading: false,
error: undefined,
@@ -2887,15 +2966,23 @@ describe('ScheduledTasksDialog Channel delivery', () => {
it('round-trips a saved target absent from discovery and omits unchanged delivery', async () => {
const saved = {
...delivery,
target: { ...delivery.target, id: 'old-group' },
target: { ...delivery.target, id: ' old-group ' },
};
await mount([baseTask({ delivery: saved })], options);
let stored = baseTask({ delivery: saved });
await mount([stored], options);
actions.listScheduledTasks.mockImplementation(async () => [stored]);
actions.updateScheduledTask.mockImplementation(
async (_id: string, patch: Partial<MockTask>) => {
stored = { ...stored, ...patch };
return stored;
},
);
expect(container?.textContent).toContain('team-bot');
expect(container?.textContent).toContain('old-group');
click(document.querySelector('[aria-label="Edit"]'));
await flush();
expect(channelSelect().value).toBe('team-bot');
expect(targetInput().value).toBe('old-group');
expect(targetInput().value).toBe(' old-group ');
input(
document.querySelector<HTMLInputElement>('input[maxlength="200"]')!,
'New name',
@@ -2905,9 +2992,13 @@ describe('ScheduledTasksDialog Channel delivery', () => {
expect(actions.updateScheduledTask.mock.calls[0]?.[1]).not.toHaveProperty(
'delivery',
);
expect(stored).toMatchObject({ name: 'New name', delivery: saved });
click(document.querySelector('[aria-label="Edit"]'));
await flush();
expect(targetInput().value).toBe('old-group');
expect(targetInput().value).toBe(' old-group ');
expect(
document.querySelector<HTMLInputElement>('input[maxlength="200"]')!.value,
).toBe('New name');
});
it('updates a saved target and explicitly clears delivery with null', async () => {
@@ -2940,7 +3031,11 @@ describe('ScheduledTasksDialog Channel delivery', () => {
});
it('preserves saved delivery without capability and offers no discovery', async () => {
await mount([baseTask({ delivery })], {
const saved = {
...delivery,
target: { ...delivery.target, id: ' group-1 ' },
};
await mount([baseTask({ delivery: saved })], {
...options,
deliveryAvailable: false,
});
@@ -2948,6 +3043,8 @@ describe('ScheduledTasksDialog Channel delivery', () => {
click(document.querySelector('[aria-label="Edit"]'));
await flush();
expect(actions.listChannelObservedContacts).not.toHaveBeenCalled();
expect(targetInput().value).toBe(' group-1 ');
expect(findButton('Remove surrounding whitespace')?.disabled).toBe(true);
click(findButton('Save'));
await flush();
expect(actions.updateScheduledTask.mock.calls[0]?.[1]).not.toHaveProperty(
@@ -2963,14 +3060,37 @@ describe('ScheduledTasksDialog Channel delivery', () => {
await flush();
if (operation === 'clear') select(channelSelect(), '');
else input(targetInput(), 'group-2');
input(
document.querySelector<HTMLInputElement>('input[maxlength="200"]')!,
'Keep this name',
);
enterPrompt();
await rerender({ ...options, deliveryAvailable: false });
expect(document.querySelector('[role="alert"]')).toBeNull();
click(findButton('Save'));
await flush();
expect(actions.updateScheduledTask).not.toHaveBeenCalled();
expect(document.body.textContent).toContain(
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
'does not support Channel delivery changes',
);
expect(document.body.textContent).toContain('delivery');
const discard = findButton('Discard delivery changes');
expect(discard).toBeDefined();
click(discard);
expect(targetInput().value).toBe('group-1');
expect(document.querySelector('[role="alert"]')).toBeNull();
click(findButton('Save'));
await flush();
expect(actions.updateScheduledTask).toHaveBeenCalledWith(
't1',
expect.objectContaining({
name: 'Keep this name',
prompt: 'Summarize the project',
}),
undefined,
);
expect(actions.updateScheduledTask.mock.calls[0]?.[1]).not.toHaveProperty(
'delivery',
);
},
);
@@ -2984,6 +3104,9 @@ describe('ScheduledTasksDialog Channel delivery', () => {
click(findButton('Save'));
await flush();
expect(actions.updateScheduledTask).not.toHaveBeenCalled();
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
'A new session every run does not support Channel delivery.',
);
select(channelSelect(), '');
click(findButton('Save'));
await flush();
@@ -3016,7 +3139,9 @@ describe('ScheduledTasksDialog Channel delivery', () => {
await act(async () => oldContacts.resolve(contacts));
await flush();
select(channelSelect(), 'team-bot');
expect(suggestionSelect()?.textContent ?? '').not.toContain('group-1');
await flush();
expect(suggestionSelect()).not.toBeNull();
expect(suggestionSelect().textContent).not.toContain('group-1');
select(channelSelect(), '');
click(findButton('Create'));
await flush();
@@ -3075,7 +3200,183 @@ describe('ScheduledTasksDialog Channel delivery', () => {
click(document.querySelector('[aria-label="Edit"]'));
await flush();
expect(targetInput().value).toBe('group-1');
expect(document.body.textContent).toContain('unavailable');
expect(document.body.textContent).toContain('Could not load Channels.');
expect(document.body.textContent).toContain('Could not load suggestions.');
expect(document.body.textContent).not.toContain(
'This Channel is unavailable',
);
expect(targetInput().disabled).toBe(false);
click(findButton('Save'));
await flush();
expect(actions.updateScheduledTask.mock.calls[0]?.[1]).not.toHaveProperty(
'delivery',
);
});
it('allows a create draft to discard delivery after capability loss without losing other fields', async () => {
const { rerender } = await mount([], options);
await openCreate();
select(findSessionModeSelect()!, 'dedicated');
select(channelSelect(), 'team-bot');
input(targetInput(), 'group-2');
await rerender({ ...options, deliveryAvailable: false });
click(findButton('Create'));
await flush();
expect(actions.createScheduledTask).not.toHaveBeenCalled();
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
'does not support Channel delivery changes',
);
click(findButton('Discard delivery changes'));
expect(channelSelect()).toBeNull();
expect(document.querySelector('[role="alert"]')).toBeNull();
click(findButton('Create'));
await flush();
expect(actions.createScheduledTask).toHaveBeenCalledWith(
expect.objectContaining({
prompt: 'Summarize the project',
sessionMode: 'persistent',
}),
undefined,
);
expect(actions.createScheduledTask.mock.calls[0]?.[0]).not.toHaveProperty(
'delivery',
);
});
it.each(['', ' '])(
'refuses an empty destination ID %j before creating a task',
async (id) => {
await mount([], options);
await openCreate();
select(findSessionModeSelect()!, 'dedicated');
select(channelSelect(), 'team-bot');
input(targetInput(), id);
click(findButton('Create'));
await flush();
expect(actions.createScheduledTask).not.toHaveBeenCalled();
expect(document.querySelector('[role="alert"]')?.textContent).toBe(
'Choose a Channel and enter a destination ID.',
);
},
);
it('resets the destination type and ID when switching Channels', async () => {
await mount([], options);
await openCreate();
select(findSessionModeSelect()!, 'dedicated');
select(channelSelect(), 'team-bot');
const typeSelect = document.querySelector<HTMLSelectElement>(
'#scheduled-task-delivery-type',
)!;
select(typeSelect, 'user');
input(targetInput(), 'user-1');
select(channelSelect(), 'ops-bot');
expect(typeSelect.value).toBe('chat');
expect(targetInput().value).toBe('');
click(findButton('Create'));
await flush();
expect(actions.createScheduledTask).not.toHaveBeenCalled();
input(targetInput(), 'ops-group');
click(findButton('Create'));
await flush();
expect(actions.createScheduledTask.mock.calls[0]?.[0]).toMatchObject({
delivery: {
kind: 'channel',
target: { channelName: 'ops-bot', type: 'chat', id: 'ops-group' },
},
});
});
it('ignores an old contact response after discovery capability changes without remounting the form', async () => {
const oldContacts = deferred<typeof contacts>();
const currentContacts = {
users: [],
groups: [{ ...contacts.groups[0]!, id: 'current-group' }],
};
actions.listChannelObservedContacts
.mockReturnValueOnce(oldContacts.promise)
.mockResolvedValue(currentContacts);
const { rerender } = await mount([baseTask({ delivery })], options);
click(document.querySelector('[aria-label="Edit"]'));
await flush();
const originalInput = targetInput();
await rerender({ ...options, observedContactsAvailable: false });
expect(targetInput()).toBe(originalInput);
await rerender(options);
expect(actions.listChannelObservedContacts).toHaveBeenCalledTimes(2);
expect(suggestionSelect()).not.toBeNull();
expect(suggestionSelect().textContent).toContain('current-group');
await act(async () => oldContacts.resolve(contacts));
await flush();
expect(targetInput()).toBe(originalInput);
expect(suggestionSelect().textContent).toContain('current-group');
expect(suggestionSelect().textContent).not.toContain('group-1');
});
it.each(['create', 'update'])(
'offers explicit surrounding whitespace removal on %s',
async (operation) => {
const rawId = ' group with spaces ';
await mount(
operation === 'create' ? [] : [baseTask({ delivery })],
options,
);
if (operation === 'create') {
await openCreate();
select(findSessionModeSelect()!, 'dedicated');
select(channelSelect(), 'team-bot');
} else {
click(document.querySelector('[aria-label="Edit"]'));
await flush();
}
input(targetInput(), rawId);
expect(targetInput().value).toBe(rawId);
expect(document.body.textContent).toContain(
'This ID has leading or trailing whitespace, which will be sent as part of the destination.',
);
const removeWhitespace = findButton('Remove surrounding whitespace');
expect(removeWhitespace).toBeDefined();
click(removeWhitespace);
expect(targetInput().value).toBe('group with spaces');
expect(findButton('Remove surrounding whitespace')).toBeUndefined();
click(findButton(operation === 'create' ? 'Create' : 'Save'));
await flush();
const payload =
operation === 'create'
? actions.createScheduledTask.mock.calls[0]?.[0]
: actions.updateScheduledTask.mock.calls[0]?.[1];
expect(payload).toMatchObject({
delivery: {
kind: 'channel',
target: {
channelName: 'team-bot',
type: 'chat',
id: 'group with spaces',
},
},
});
},
);
it('marks a saved Channel unavailable only after discovery confirms it is missing', async () => {
useDaemonChannels.mockReturnValue({
data: { catalog: [], snapshot: { revision: '1', instances: {} } },
channels: {},
loading: false,
error: undefined,
});
const saved = {
...delivery,
target: { ...delivery.target, id: ' group-1 ' },
};
await mount([baseTask({ delivery: saved })], options);
click(document.querySelector('[aria-label="Edit"]'));
await flush();
expect(channelSelect().value).toBe('team-bot');
expect(targetInput().value).toBe(' group-1 ');
expect(findButton('Remove surrounding whitespace')?.disabled).toBe(true);
expect(targetInput().disabled).toBe(true);
expect(document.body.textContent).toContain('This Channel is unavailable');
click(findButton('Save'));
await flush();
expect(actions.updateScheduledTask.mock.calls[0]?.[1]).not.toHaveProperty(
@@ -1879,6 +1879,19 @@ export function ScheduledTasksDialog({
disabled={submitting}
/>
)}
{!deliveryAvailable && !sameDelivery(delivery, editedDelivery) && (
<button
type="button"
className={styles.secondaryButton}
disabled={submitting}
onClick={() => {
setDelivery(editedDelivery);
setFormError(null);
}}
>
{t('scheduledTasks.delivery.discardChanges')}
</button>
)}
{runDestination === 'per_run' && (
<div className={styles.routingFields}>
@@ -2124,7 +2137,11 @@ export function ScheduledTasksDialog({
)}
</div>
{formError && <div className={styles.formError}>{formError}</div>}
{formError && (
<div className={styles.formError} role="alert">
{formError}
</div>
)}
<div className={styles.formActions}>
<button
@@ -2170,7 +2187,7 @@ export function ScheduledTasksDialog({
const title = task.name || task.prompt;
const deliveryLabel = task.delivery
? `${t('scheduledTasks.delivery.cardPrefix')} ${task.delivery.target.channelName} · ${t(`scheduledTasks.delivery.${task.delivery.target.type}`)} · ${task.delivery.target.id}`
: t('scheduledTasks.delivery.off');
: undefined;
const busy = busyId === taskKey(task);
return (
<div
@@ -2235,9 +2252,11 @@ export function ScheduledTasksDialog({
{task.prompt}
</div>
)}
<div className={styles.cardPrompt} title={deliveryLabel}>
{deliveryLabel}
</div>
{task.delivery && (
<div className={styles.cardPrompt} title={deliveryLabel}>
{deliveryLabel}
</div>
)}
{task.gate && (
<div className={styles.cardPrompt} title={task.gate}>
{t('scheduledTasks.gate.cardPrefix')} {task.gate}
@@ -47,6 +47,9 @@ async function install(
tasks?: DaemonScheduledTask[];
capable?: boolean;
multiWorkspace?: boolean;
channelsFail?: boolean;
contactsFail?: boolean;
deferPrimaryContacts?: boolean;
} = {},
) {
const scenario = createWebShellDaemonScenario({
@@ -101,6 +104,7 @@ async function install(
body: Record<string, unknown>;
}> = [];
const reads: string[] = [];
const pendingContacts: Array<() => Promise<void>> = [];
let contactsVisible = true;
await page.route('**/*', async (route) => {
if (route.request().isNavigationRequest()) return route.continue();
@@ -113,38 +117,59 @@ async function install(
const channelName = secondary ? 'secondary-bot' : 'release-bot';
if (path.endsWith('/channels')) {
reads.push(path);
if (options.channelsFail)
return route.fulfill({
status: 500,
json: { error: 'Channels temporarily unavailable' },
});
return route.fulfill({
json: {
revision: '1',
instances: {
[channelName]: {
name: channelName,
config: { type: 'dingtalk' },
secrets: {},
startsWithServe: false,
runtime: { state: 'connected' },
},
},
instances: Object.fromEntries(
(secondary ? [channelName] : [channelName, 'ops-bot']).map(
(name) => [
name,
{
name,
config: { type: 'dingtalk' },
secrets: {},
startsWithServe: false,
runtime: { state: 'connected' },
},
],
),
),
},
});
}
if (path.endsWith('/channel/observed-contacts')) {
reads.push(path);
return route.fulfill({
json: {
users: [],
groups: contactsVisible
? ['chat-1', 'chat-2'].map((id) => ({
channelName,
id: secondary ? `secondary-${id}` : id,
label: 'Release team',
lastObservedAt: '2026-11-07T00:00:00Z',
users: [],
topics: [],
}))
: [],
},
});
if (options.contactsFail)
return route.fulfill({
status: 500,
json: { error: 'Contacts temporarily unavailable' },
});
const respond = () =>
route.fulfill({
json: {
users: [],
groups: contactsVisible
? ['chat-1', 'chat-2'].map((id) => ({
channelName,
id: secondary ? `secondary-${id}` : id,
label: 'Release team',
lastObservedAt: '2026-11-07T00:00:00Z',
users: [],
topics: [],
}))
: [],
},
});
if (options.deferPrimaryContacts && !secondary) {
pendingContacts.push(respond);
return;
}
return respond();
}
const match = path.match(/\/scheduled-tasks(?:\/([^/]+))?$/);
if (!match) return route.fallback();
@@ -172,6 +197,8 @@ async function install(
return {
writes,
reads,
releaseContacts: () =>
Promise.all(pendingContacts.splice(0).map((respond) => respond())),
hideContacts: () => {
contactsVisible = false;
},
@@ -199,7 +226,7 @@ async function selectDelivery(page: Page) {
.selectOption(JSON.stringify(target));
}
test('creates, preserves, changes and explicitly clears scheduled delivery', async ({
test('creates, preserves, changes and explicitly clears scheduled delivery @smoke', async ({
page,
baseURL,
}, info) => {
@@ -224,6 +251,27 @@ test('creates, preserves, changes and explicitly clears scheduled delivery', asy
await page.screenshot({
path: info.outputPath('delivery-create-details.png'),
});
await page
.getByRole('combobox', { name: 'Destination type', exact: true })
.selectOption('user');
await page
.getByRole('textbox', { name: 'Destination ID', exact: true })
.fill('previous-user');
await page
.getByRole('combobox', { name: 'Delivery Channel', exact: true })
.selectOption('ops-bot');
await expect(
page.getByRole('textbox', { name: 'Destination ID', exact: true }),
).toHaveValue('');
await expect(
page.getByRole('combobox', { name: 'Destination type', exact: true }),
).toHaveValue('chat');
await page.getByRole('button', { name: 'Create', exact: true }).click();
await expect(page.getByRole('alert')).toHaveText(
'Choose a Channel and enter a destination ID.',
);
expect(state.writes).toHaveLength(0);
await selectDelivery(page);
await page.getByRole('button', { name: 'Create', exact: true }).click();
await expect.poll(() => state.writes.length).toBe(1);
expect(state.writes[0]).toMatchObject({
@@ -252,7 +300,23 @@ test('creates, preserves, changes and explicitly clears scheduled delivery', asy
.selectOption('user');
await page
.getByRole('textbox', { name: 'Destination ID', exact: true })
.fill('api-only-user');
.fill(' api-only-user ');
await expect(
page.getByText(
'This ID has leading or trailing whitespace, which will be sent as part of the destination.',
{ exact: true },
),
).toBeVisible();
await page
.getByRole('button', { name: 'Remove surrounding whitespace', exact: true })
.scrollIntoViewIfNeeded();
await page.screenshot({ path: info.outputPath('delivery-whitespace.png') });
await page
.getByRole('button', { name: 'Remove surrounding whitespace', exact: true })
.click();
await expect(
page.getByRole('textbox', { name: 'Destination ID', exact: true }),
).toHaveValue('api-only-user');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => state.writes.length).toBe(3);
expect(state.writes[2]!.body.delivery).toEqual({
@@ -269,10 +333,15 @@ test('creates, preserves, changes and explicitly clears scheduled delivery', asy
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => state.writes.length).toBe(4);
expect(state.writes[3]!.body.delivery).toBeNull();
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByTestId('scheduled-tasks-page')).not.toContainText(
'Delivery off',
);
await page.screenshot({ path: info.outputPath('delivery-cleared-card.png') });
expect(errors).toEqual([]);
});
test('requires an explicit resolution of per-run delivery and supports mobile layout', async ({
test('requires an explicit resolution of per-run delivery and supports mobile layout @smoke', async ({
page,
baseURL,
}, info) => {
@@ -319,7 +388,7 @@ test('requires an explicit resolution of per-run delivery and supports mobile la
expect(state.writes[0]!.body).not.toHaveProperty('delivery');
});
test('preserves saved targets without delivery capability', async ({
test('preserves saved targets without delivery capability @smoke', async ({
page,
baseURL,
}) => {
@@ -343,13 +412,32 @@ test('preserves saved targets without delivery capability', async ({
expect(state.reads).toEqual([]);
});
test('resets selection and scopes discovery and creation when workspace changes', async ({
test('ignores late contacts and scopes discovery and creation when workspace changes @smoke', async ({
page,
baseURL,
}) => {
const state = await install(page, baseURL!, { multiWorkspace: true });
const state = await install(page, baseURL!, {
multiWorkspace: true,
deferPrimaryContacts: true,
});
await createForm(page);
await selectDelivery(page);
await page
.getByRole('combobox', { name: 'Delivery Channel', exact: true })
.selectOption('release-bot');
await expect
.poll(
() =>
state.reads.filter((path) =>
path.endsWith('/channel/observed-contacts'),
).length,
)
.toBe(1);
await expect(
page.getByText('Loading suggested destinations…', { exact: true }),
).toBeVisible();
await page
.getByRole('textbox', { name: 'Destination ID', exact: true })
.fill('primary-manual');
await page
.getByRole('combobox', { name: 'Workspace', exact: true })
.selectOption('secondary');
@@ -367,6 +455,16 @@ test('resets selection and scopes discovery and creation when workspace changes'
type: 'chat',
id: 'secondary-chat-2',
};
await expect(
page.getByRole('combobox', { name: 'Suggested destination', exact: true }),
).toContainText('secondary-chat-2');
const lateResponse = page.waitForResponse(
(response) =>
new URL(response.url()).pathname ===
'/workspace/channel/observed-contacts',
);
await state.releaseContacts();
await (await lateResponse).finished();
await page
.getByRole('combobox', { name: 'Suggested destination', exact: true })
.selectOption(JSON.stringify(secondaryTarget));
@@ -383,3 +481,74 @@ test('resets selection and scopes discovery and creation when workspace changes'
`/workspaces/${encodeURIComponent(secondaryCwd)}/channels`,
);
});
test('loads contacts only for delivery and accepts manual IDs when discovery fails @smoke', async ({
page,
baseURL,
}) => {
const state = await install(page, baseURL!, { contactsFail: true });
await createForm(page);
const channels = page.getByRole('combobox', {
name: 'Delivery Channel',
exact: true,
});
await expect(channels).toContainText('release-bot');
expect(
state.reads.filter((path) => path.endsWith('/channel/observed-contacts')),
).toEqual([]);
await channels.selectOption('release-bot');
await expect(
page.getByText(
'Could not load suggestions. You can still enter a destination ID; existing delivery is preserved.',
{ exact: true },
),
).toBeVisible();
await expect(
page.getByText('Loading suggested destinations…', { exact: true }),
).not.toBeVisible();
const id = page.getByRole('textbox', { name: 'Destination ID', exact: true });
await expect(id).toBeEnabled();
await id.fill('manual-chat');
await page.getByRole('button', { name: 'Create', exact: true }).click();
await expect.poll(() => state.writes.length).toBe(1);
expect(state.writes[0]!.body.delivery).toEqual({
kind: 'channel',
target: { ...target, id: 'manual-chat' },
});
});
test('preserves saved delivery when Channels and contact discovery fail @smoke', async ({
page,
baseURL,
}) => {
const state = await install(page, baseURL!, {
tasks: [task({ delivery })],
channelsFail: true,
contactsFail: true,
});
await page.getByRole('button', { name: 'Edit', exact: true }).click();
await expect(
page.getByText(
'Could not load Channels. Close and reopen the form to retry; existing delivery is preserved.',
{ exact: true },
),
).toBeVisible();
await expect(
page.getByText('This Channel is unavailable in this workspace.', {
exact: false,
}),
).not.toBeVisible();
const id = page.getByRole('textbox', { name: 'Destination ID', exact: true });
await expect(id).toBeEnabled();
await expect(id).toHaveValue('chat-1');
await page
.getByRole('textbox', { name: 'Name', exact: true })
.fill('Renamed after discovery failed');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(() => state.writes.length).toBe(1);
expect(state.writes[0]!.body).not.toHaveProperty('delivery');
await page.getByRole('button', { name: 'Edit', exact: true }).click();
await expect(
page.getByRole('textbox', { name: 'Destination ID', exact: true }),
).toHaveValue('chat-1');
});
+19 -12
View File
@@ -2118,11 +2118,15 @@ const EN: Messages = {
'scheduledTasks.delivery.hint':
'Send the final response of a successful scheduled run to this destination. Delivery depends on the Channel and does not guarantee receipt.',
'scheduledTasks.delivery.unavailable':
'This daemon does not support Channel delivery changes. Existing delivery is preserved.',
'This daemon does not support Channel delivery changes.',
'scheduledTasks.delivery.discardChanges': 'Discard delivery changes',
'scheduledTasks.delivery.perRun':
'A new session every run does not support Channel delivery. Choose a persistent session or turn delivery off before saving.',
'scheduledTasks.delivery.required':
'Choose a Channel and enter a destination ID.',
'scheduledTasks.delivery.whitespace':
'This ID has leading or trailing whitespace, which will be sent as part of the destination.',
'scheduledTasks.delivery.trimWhitespace': 'Remove surrounding whitespace',
'scheduledTasks.delivery.loading': 'Loading Channels…',
'scheduledTasks.delivery.loadingContacts': 'Loading suggested destinations…',
'scheduledTasks.delivery.channelsFailed':
@@ -7023,23 +7027,26 @@ const ZH: Messages = {
'scheduledTasks.group.hint': '每次运行产生的会话都会放入这个分组。',
'scheduledTasks.group.nameRequired': '请输入新分组名称。',
'scheduledTasks.routing.loading': '正在加载模型和分组…',
'scheduledTasks.delivery.channel': '投递 Channel',
'scheduledTasks.delivery.channel': '投递频道',
'scheduledTasks.delivery.off': '不投递',
'scheduledTasks.delivery.hint':
'将成功完成的定时任务的最终回复发送到此目标。投递依赖 Channel,不保证送达。',
'scheduledTasks.delivery.unavailable':
'此 daemon 不支持修改 Channel 投递配置。已有配置会保留。',
'将成功完成的定时任务的最终回复发送到此目标。投递依赖频道,不保证送达。',
'scheduledTasks.delivery.unavailable': '此 daemon 不支持修改频道投递配置。',
'scheduledTasks.delivery.discardChanges': '放弃投递修改',
'scheduledTasks.delivery.perRun':
'每次运行新建会话不支持 Channel 投递。请在保存前选择持久会话,或关闭投递。',
'scheduledTasks.delivery.required': '请选择 Channel 并输入目标 ID。',
'scheduledTasks.delivery.loading': '正在加载 Channel…',
'每次新会话不支持频道投递。请在保存前选择固定会话,或关闭投递。',
'scheduledTasks.delivery.required': '请选择频道并输入目标 ID。',
'scheduledTasks.delivery.whitespace':
'此 ID 的首尾含有空白字符,它们会作为目标 ID 的一部分原样发送。',
'scheduledTasks.delivery.trimWhitespace': '去除首尾空白',
'scheduledTasks.delivery.loading': '正在加载频道…',
'scheduledTasks.delivery.loadingContacts': '正在加载目标候选…',
'scheduledTasks.delivery.channelsFailed':
'无法加载 Channel。请关闭并重新打开表单重试;已有投递配置会保留。',
'无法加载频道。请关闭并重新打开表单重试;已有投递配置会保留。',
'scheduledTasks.delivery.noChannels':
'此工作区尚未配置 Channel。请先在 Channel 页面中配置。',
'此工作区尚未配置频道。请先在频道页面中配置。',
'scheduledTasks.delivery.targetUnavailable':
'此 Channel 在当前工作区不可用。已保存的目标会保留;你可以选择其他 Channel 或关闭投递。',
'此频道在当前工作区不可用。已保存的目标会保留;你可以选择其他频道或关闭投递。',
'scheduledTasks.delivery.notObserved':
'此目标不在最近的候选中。其 ID 会保留;联系人发现不是投递的必要条件。',
'scheduledTasks.delivery.suggestion': '目标候选',
@@ -7047,7 +7054,7 @@ const ZH: Messages = {
'scheduledTasks.delivery.contactsFailed':
'无法加载候选。你仍可输入目标 ID;已有投递配置会保留。',
'scheduledTasks.delivery.suggestionsHint':
'候选来自最近观察到的联系人。你也可以输入 Channel 支持的已知用户或群 ID。',
'候选来自最近观察到的联系人。你也可以输入频道支持的已知用户或群 ID。',
'scheduledTasks.delivery.type': '目标类型',
'scheduledTasks.delivery.id': '目标 ID',
'scheduledTasks.delivery.user': '用户',