fix(web-shell): support primary delivery during daemon startup

This commit is contained in:
jkxiao committed 2026-11-07 14:40:46 +00:00
1 parent d3cbd7c242
commit 33d8d8b3d4
6 files changed
+235 -30

No files matched your search

@@ -12,7 +12,11 @@ Reuse the SDK `DaemonChannelDelivery` contract on task reads and create/update r
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.
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.
Selecting another workspace in the form clears the draft delivery target and model/session-group choices, invalidates old discovery results, and preserves the name, prompt, and schedule. Editing stays pinned to the task's workspace. If the externally supplied locked workspace or primary workspace identity changes, close the form and discard the entire draft to prevent saving it to the wrong workspace.
Fully initialized daemon capabilities include the primary entry in `workspaces`, even for a single project. A separately hosted WebShellWithProviders can receive bootstrap capabilities with `workspaceCwd` but no `workspaces` array. Pass that explicit primary cwd to the form as a fallback for the primary target only. Missing or untrusted secondary workspaces cannot fall back to the primary 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. 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.
@@ -24,7 +28,7 @@ The main surfaces are the Web Shell workspace action/types, `ScheduledTasksDialo
## Validation and acceptance
Focused action tests verify route scoping and error handling. Form and browser coverage verify create, edit, clear, saved-target round-trip, same-name identity display, manual IDs, missing contacts/Channels, failures, workspace switches and stale responses, capability gating, and the per-run restriction. Tasks without delivery keep their existing payload behavior. Run formatting, lint, build, typecheck, and focused unit/browser tests. Record actual results and environment limits in the local E2E report and PR draft; do not claim provider receipt based on mocked browser tests.
Focused action tests verify route scoping and error handling. Form and browser coverage verify create, edit, clear, saved-target round-trip, same-name identity display, candidate isolation between Channels, manual IDs, missing contacts/Channels, failures, workspace switches and stale responses, primary-only bootstrap capabilities, capability gating, and the per-run restriction. Tasks without delivery keep their existing payload behavior and omit the delivery row. Run formatting, lint, build, typecheck, and focused unit/browser tests. Record actual results and environment limits in the local E2E report and PR draft; do not claim provider receipt based on mocked browser tests.
## Open questions and follow-up
@@ -34,4 +38,4 @@ There are no blocking implementation questions. Delivery guarantees, durable ret
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).
Browser verification against the built CLI also covered [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).
@@ -12,7 +12,11 @@ Issue #16516 要求开放 daemon 已有能力:将成功完成的 persistent
表单提供关闭投递,或选择频道实例及用户/群目标 ID。已观察联系人仅提供可选候选,并且仅在选择投递目标后加载;标签同时展示频道名、目标类型和原始 ID,以区分同名目标。允许手工填写 ID,因为发现不是授权。即使发现失败或 daemon 不再声明投递能力,卡片仍显示保存的频道、类型和 ID。没有保存目标的卡片不显示投递行。
已配置 Channel 复用支持工作区的现有 Channel hook。为现有 observed-contacts 路由增加 Web Shell 动作,与任务变更使用同一个显式工作区 ID。仅将顶层用户和群作为候选,线程/话题不是投递目标类型。表单切换工作区时清除草稿目标,并使旧发现结果失效。编辑固定在任务所属工作区。工作区缺失或不受信任时不能悄悄回退至其他工作区。
已配置 Channel 复用支持工作区的现有 Channel hook。为现有 observed-contacts 路由增加 Web Shell 动作,与任务变更使用同一个显式工作区 ID。仅将顶层用户和群作为候选,线程/话题不是投递目标类型。
在表单内选择其他工作区时,清除草稿投递目标及模型/会话分组选择,使旧发现结果失效,并保留名称、提示词和调度配置。编辑固定在任务所属工作区。如果外部传入的锁定工作区或主工作区身份发生变化,则关闭表单并丢弃整个草稿,避免将其保存到错误的工作区。
完成初始化的 daemon 能力响应始终在 `workspaces` 中包含主工作区,即使只有一个项目。独立托管的 WebShellWithProviders 可能收到含 `workspaceCwd`、但没有 `workspaces` 数组的启动期能力响应。将该显式主工作区 cwd 传给表单,仅用于主工作区目标的回退。其他工作区缺失或不受信任时,不能回退至主工作区。
以 `channel_delivery` 控制编辑,以 `workspace_channel_observed_contacts` 控制发现。能力丢失、频道缺失、联系人过期或请求失败均保留已保存值,显示易懂的加载、失败、空列表和不可用状态。只有成功加载的频道列表才能确认已保存的频道不存在;初始状态、加载中或请求失败均不代表频道缺失。能力丢失后不能静默忽略已修改的草稿。用户可以放弃未保存的投递修改,恢复原目标(新建任务时恢复为不投递),然后保存表单中的其他修改,而不发送投递更新。
@@ -24,7 +28,7 @@ Issue #16516 要求开放 daemon 已有能力:将成功完成的 persistent
## 验证与验收
聚焦动作测试验证路由作用域和错误处理。表单及浏览器覆盖创建、编辑、清除、已存目标往返、同名身份显示、手工 ID、联系人/Channel 缺失、失败、工作区切换及过期响应、能力开关和 per-run 限制。无投递任务保持原有请求行为。运行格式、lint、构建、类型检查及相关单元/浏览器测试。实际结果和环境限制写入本地 E2E 报告与 PR 草稿;不根据模拟浏览器测试宣称平台真实收到消息。
聚焦动作测试验证路由作用域和错误处理。表单及浏览器覆盖创建、编辑、清除、已存目标往返、同名身份显示、不同频道的候选隔离、手工 ID、联系人/Channel 缺失、失败、工作区切换及过期响应、仅含主工作区 cwd 的启动期能力响应、能力开关和 per-run 限制。无投递任务保持原有请求行为,且不显示投递行。运行格式、lint、构建、类型检查及相关单元/浏览器测试。实际结果和环境限制写入本地 E2E 报告与 PR 草稿;不根据模拟浏览器测试宣称平台真实收到消息。
## 待定事项与后续工作
@@ -34,4 +38,4 @@ Issue #16516 要求开放 daemon 已有能力:将成功完成的 persistent
通过仓库 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)。
针对打包后的 CLI 的浏览器验证还覆盖了[明确去除首尾空白](assets/scheduled-task-channel-delivery/after-whitespace.png)及[清除投递后的卡片](assets/scheduled-task-channel-delivery/after-cleared-card.png)。
+4 -3
View File
@@ -23814,14 +23814,15 @@ export function App({
<div className={styles.fullPageBody}>
<ScheduledTasksDialog
onRunPrompt={runTaskManually}
// Registered workspaces (multi-workspace daemons only) so
// the page aggregates every project's schedule and the New
// form can target one; absent/single → primary-only view.
// Registered workspaces let the page aggregate schedules
// and the New form target a project; absent/single →
// primary-only view.
workspaces={
lockedWorkspaceCwd
? visibleWorkspaces
: ordinaryWorkspaces
}
primaryWorkspaceCwd={workspace.capabilities?.workspaceCwd}
lockedWorkspace={lockedWorkspaceCapability}
currentSession={
currentSessionSummary
@@ -107,6 +107,7 @@ async function mount(
gateAvailable?: boolean;
deliveryAvailable?: boolean;
observedContactsAvailable?: boolean;
primaryWorkspaceCwd?: string;
autofixPrWatchesAvailable?: boolean;
workspaces?: Array<{
id: string;
@@ -161,6 +162,7 @@ async function mount(
gateAvailable={nextOpts.gateAvailable}
deliveryAvailable={nextOpts.deliveryAvailable}
observedContactsAvailable={nextOpts.observedContactsAvailable}
primaryWorkspaceCwd={nextOpts.primaryWorkspaceCwd}
autofixPrWatchesAvailable={nextOpts.autofixPrWatchesAvailable}
workspaces={nextOpts.workspaces}
lockedWorkspace={nextOpts.lockedWorkspace}
@@ -2784,6 +2786,106 @@ describe('ScheduledTasksDialog Channel delivery', () => {
enterPrompt();
}
it('discovers and creates delivery with a primary cwd and no workspace list', async () => {
const { rerender } = await mount([], {
...options,
workspaces: [],
primaryWorkspaceCwd: '/repo/main',
});
await openCreate();
expect(channelSelect().textContent).toContain('team-bot');
expect(useDaemonChannels).toHaveBeenLastCalledWith({
workspaceCwd: '/repo/main',
enabled: true,
autoLoad: true,
});
expect(document.body.textContent).not.toContain(
'The selected workspace is unavailable or untrusted',
);
select(findSessionModeSelect()!, 'dedicated');
select(channelSelect(), 'team-bot');
await flush();
select(suggestionSelect(), JSON.stringify(delivery.target));
const originalInput = targetInput();
await rerender({ ...options, primaryWorkspaceCwd: '/repo/main' });
expect(targetInput()).toBe(originalInput);
expect(targetInput().value).toBe('group-1');
click(findButton('Create'));
await flush();
expect(actions.listChannelObservedContacts).toHaveBeenCalledWith(undefined);
expect(actions.createScheduledTask).toHaveBeenCalledWith(
expect.objectContaining({ sessionMode: 'persistent', delivery }),
undefined,
);
});
it('closes a primary-only draft when the primary cwd changes', async () => {
const primaryOptions = {
...options,
workspaces: [],
primaryWorkspaceCwd: '/repo/main',
};
const { rerender } = await mount([], primaryOptions);
await openCreate();
await rerender({
...primaryOptions,
primaryWorkspaceCwd: '/repo/replaced',
});
expect(channelSelect()).toBeNull();
expect(actions.createScheduledTask).not.toHaveBeenCalled();
await openCreate();
expect(channelSelect().value).toBe('');
expect(useDaemonChannels).toHaveBeenLastCalledWith({
workspaceCwd: '/repo/replaced',
enabled: true,
autoLoad: true,
});
});
it.each(['removed', 'untrusted'])(
'does not fall back to the primary when a selected secondary becomes %s',
async (change) => {
const secondaryOptions = {
...options,
workspaces,
primaryWorkspaceCwd: '/repo/main',
};
const { rerender } = await mount([], secondaryOptions);
await openCreate();
const workspaceSelect = Array.from(
document.querySelectorAll('select'),
).find((element) => element.querySelector('option[value="id-other"]'))!;
select(workspaceSelect, 'id-other');
select(findSessionModeSelect()!, 'dedicated');
select(channelSelect(), 'team-bot');
input(targetInput(), 'secondary-group');
await flush();
expect(actions.listChannelObservedContacts).toHaveBeenCalledWith(
'id-other',
);
actions.listChannelObservedContacts.mockClear();
await rerender({
...secondaryOptions,
workspaces:
change === 'removed'
? [workspaces[0]!]
: [workspaces[0]!, { ...workspaces[1]!, trusted: false }],
});
expect(useDaemonChannels).toHaveBeenLastCalledWith({
workspaceCwd: undefined,
enabled: false,
autoLoad: true,
});
expect(actions.listChannelObservedContacts).not.toHaveBeenCalled();
click(findButton('Create'));
await flush();
expect(actions.createScheduledTask).not.toHaveBeenCalled();
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
'The selected workspace is unavailable or untrusted',
);
},
);
it.each([
{ label: 'not started', loading: false, error: undefined },
{ label: 'loading', loading: true, error: undefined },
@@ -2836,6 +2938,9 @@ describe('ScheduledTasksDialog Channel delivery', () => {
'omits the delivery row on an unconfigured card with capability %s',
async (deliveryAvailable) => {
await mount([baseTask({})], { ...options, deliveryAvailable });
expect(
container?.querySelector('[data-testid="scheduled-task-delivery"]'),
).toBeNull();
expect(container?.textContent).not.toContain('Delivery off');
},
);
@@ -2977,8 +3082,12 @@ describe('ScheduledTasksDialog Channel delivery', () => {
return stored;
},
);
expect(container?.textContent).toContain('team-bot');
expect(container?.textContent).toContain('old-group');
const deliveryRow = container?.querySelector(
'[data-testid="scheduled-task-delivery"]',
);
expect(deliveryRow).not.toBeNull();
expect(deliveryRow?.textContent).toContain('team-bot');
expect(deliveryRow?.textContent).toContain('old-group');
click(document.querySelector('[aria-label="Edit"]'));
await flush();
expect(channelSelect().value).toBe('team-bot');
@@ -3117,13 +3226,17 @@ describe('ScheduledTasksDialog Channel delivery', () => {
);
});
it('clears a draft target on workspace switch and ignores old contact responses', async () => {
it('keeps new workspace suggestions when old contacts arrive', async () => {
const oldContacts = deferred<typeof contacts>();
const otherContacts = {
users: [],
groups: [{ ...contacts.groups[0]!, id: 'other-group' }],
};
actions.listChannelObservedContacts.mockImplementation(
(workspaceId?: string) =>
workspaceId === undefined
? oldContacts.promise
: Promise.resolve({ users: [], groups: [] }),
: Promise.resolve(otherContacts),
);
await mount([], { ...options, workspaces });
await openCreate();
@@ -3136,11 +3249,13 @@ describe('ScheduledTasksDialog Channel delivery', () => {
select(workspaceSelect, 'id-other');
await flush();
expect(channelSelect().value).toBe('');
await act(async () => oldContacts.resolve(contacts));
await flush();
select(channelSelect(), 'team-bot');
await flush();
expect(suggestionSelect()).not.toBeNull();
expect(suggestionSelect().textContent).toContain('other-group');
await act(async () => oldContacts.resolve(contacts));
await flush();
expect(suggestionSelect().textContent).toContain('other-group');
expect(suggestionSelect().textContent).not.toContain('group-1');
select(channelSelect(), '');
click(findButton('Create'));
@@ -3260,23 +3375,42 @@ describe('ScheduledTasksDialog Channel delivery', () => {
},
);
it('resets the destination type and ID when switching Channels', async () => {
it('resets the destination and scopes loaded suggestions when switching Channels', async () => {
const otherContact = {
...contacts.groups[0]!,
channelName: 'ops-bot',
id: 'ops-group',
};
actions.listChannelObservedContacts.mockResolvedValue({
...contacts,
groups: [...contacts.groups, otherContact],
});
await mount([], options);
await openCreate();
select(findSessionModeSelect()!, 'dedicated');
select(channelSelect(), 'team-bot');
await flush();
expect(suggestionSelect().textContent).toContain('group-1');
expect(suggestionSelect().textContent).not.toContain('ops-group');
const typeSelect = document.querySelector<HTMLSelectElement>(
'#scheduled-task-delivery-type',
)!;
select(typeSelect, 'user');
input(targetInput(), 'user-1');
select(channelSelect(), 'ops-bot');
await flush();
expect(typeSelect.value).toBe('chat');
expect(targetInput().value).toBe('');
expect(suggestionSelect().textContent).toContain('ops-group');
expect(suggestionSelect().textContent).not.toContain('group-1');
expect(suggestionSelect().textContent).not.toContain('user-1');
click(findButton('Create'));
await flush();
expect(actions.createScheduledTask).not.toHaveBeenCalled();
input(targetInput(), 'ops-group');
select(
suggestionSelect(),
JSON.stringify({ channelName: 'ops-bot', type: 'chat', id: 'ops-group' }),
);
click(findButton('Create'));
await flush();
expect(actions.createScheduledTask.mock.calls[0]?.[0]).toMatchObject({
@@ -180,11 +180,13 @@ interface ScheduledTasksDialogProps {
/** Open a task's bound session — its transcript IS the task's run history.
* When absent, tasks fall back to the inline fire-timestamp list. */
onOpenSession?: (sessionId: string, workspaceCwd?: string) => void;
/** Registered workspaces on a multi-workspace daemon (from capabilities).
/** Registered workspaces from daemon capabilities.
* When more than one is present the page aggregates every trusted workspace's
* tasks (each card tagged with its workspace) and the New-task form offers a
* workspace picker. Absent or a single entry → the plain primary-only view. */
workspaces?: DaemonWorkspaceCapability[];
/** Explicit primary cwd for capabilities without a workspace list. */
primaryWorkspaceCwd?: string;
/** Forces all task operations through this workspace's route. */
lockedWorkspace?: DaemonWorkspaceCapability;
currentSession?: DaemonSessionSummary;
@@ -648,6 +650,7 @@ export function ScheduledTasksDialog({
onCreateViaChat,
onOpenSession,
workspaces,
primaryWorkspaceCwd,
lockedWorkspace,
currentSession,
currentSessionSchedulingAvailable,
@@ -772,7 +775,9 @@ export function ScheduledTasksDialog({
const formWorkspaceMissing =
formWorkspaceId !== undefined &&
(!formWorkspace || (!formWorkspace.primary && !formWorkspace.trusted));
const formWorkspaceCwd = formWorkspace?.cwd;
const formWorkspaceCwd =
formWorkspace?.cwd ??
(formWorkspaceId === undefined ? primaryWorkspaceCwd : undefined);
const [builder, setBuilder] = useState<BuilderState>(DEFAULT_BUILDER);
useEffect(() => {
if (!currentSessionSchedulingAvailable) {
@@ -1218,7 +1223,8 @@ export function ScheduledTasksDialog({
const workspaceIdentity = JSON.stringify([
lockedWorkspace?.cwd,
workspaceList.find((workspace) => workspace.primary)?.cwd,
workspaceList.find((workspace) => workspace.primary)?.cwd ??
primaryWorkspaceCwd,
]);
const previousWorkspaceIdentity = useRef(workspaceIdentity);
useEffect(() => {
@@ -2253,7 +2259,11 @@ export function ScheduledTasksDialog({
</div>
)}
{task.delivery && (
<div className={styles.cardPrompt} title={deliveryLabel}>
<div
className={styles.cardPrompt}
data-testid="scheduled-task-delivery"
title={deliveryLabel}
>
{deliveryLabel}
</div>
)}
@@ -47,6 +47,7 @@ async function install(
tasks?: DaemonScheduledTask[];
capable?: boolean;
multiWorkspace?: boolean;
omitWorkspaces?: boolean;
channelsFail?: boolean;
contactsFail?: boolean;
deferPrimaryContacts?: boolean;
@@ -93,6 +94,7 @@ async function install(
},
],
});
if (options.omitWorkspaces) delete scenario.capabilities.workspaces;
await installMockDaemon(page, scenario, { baseURL });
const tasks = new Map<string, DaemonScheduledTask[]>([
['primary', options.tasks ?? []],
@@ -154,14 +156,28 @@ async function install(
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: [],
}))
? [
...['chat-1', 'chat-2'].map((id) => ({
channelName,
id: secondary ? `secondary-${id}` : id,
label: 'Release team',
lastObservedAt: '2026-11-07T00:00:00Z',
users: [],
topics: [],
})),
...(!secondary
? [
{
channelName: 'ops-bot',
id: 'ops-chat',
label: 'Operations team',
lastObservedAt: '2026-11-07T00:00:00Z',
users: [],
topics: [],
},
]
: []),
]
: [],
},
});
@@ -244,6 +260,9 @@ test('creates, preserves, changes and explicitly clears scheduled delivery @smok
await expect(
page.getByRole('combobox', { name: 'Suggested destination', exact: true }),
).toContainText('chat-2');
await expect(
page.getByRole('combobox', { name: 'Suggested destination', exact: true }),
).not.toContainText('ops-bot');
await page.screenshot({ path: info.outputPath('delivery-create.png') });
await page
.getByRole('button', { name: 'Create', exact: true })
@@ -266,6 +285,12 @@ test('creates, preserves, changes and explicitly clears scheduled delivery @smok
await expect(
page.getByRole('combobox', { name: 'Destination type', exact: true }),
).toHaveValue('chat');
await expect(
page.getByRole('combobox', { name: 'Suggested destination', exact: true }),
).toContainText('ops-chat');
await expect(
page.getByRole('combobox', { name: 'Suggested destination', exact: true }),
).not.toContainText('release-bot');
await page.getByRole('button', { name: 'Create', exact: true }).click();
await expect(page.getByRole('alert')).toHaveText(
'Choose a Channel and enter a destination ID.',
@@ -279,7 +304,7 @@ test('creates, preserves, changes and explicitly clears scheduled delivery @smok
path: '/scheduled-tasks',
body: { delivery, sessionMode: 'persistent' },
});
await expect(page.getByTestId('scheduled-tasks-page')).toContainText(
await expect(page.getByTestId('scheduled-task-delivery')).toContainText(
'chat-1',
);
await page.screenshot({ path: info.outputPath('delivery-saved-card.png') });
@@ -334,6 +359,7 @@ test('creates, preserves, changes and explicitly clears scheduled delivery @smok
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-task-delivery')).toHaveCount(0);
await expect(page.getByTestId('scheduled-tasks-page')).not.toContainText(
'Delivery off',
);
@@ -341,6 +367,32 @@ test('creates, preserves, changes and explicitly clears scheduled delivery @smok
expect(errors).toEqual([]);
});
test('uses the primary cwd when bootstrap capabilities omit workspaces @smoke', async ({
page,
baseURL,
}, info) => {
const state = await install(page, baseURL!, { omitWorkspaces: true });
await createForm(page);
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({
method: 'POST',
path: '/scheduled-tasks',
body: { delivery, sessionMode: 'persistent' },
});
expect(state.reads).toContain(
`/workspaces/${encodeURIComponent(primaryCwd)}/channels`,
);
expect(state.reads).toContain('/workspace/channel/observed-contacts');
await expect(page.getByTestId('scheduled-task-delivery')).toContainText(
'release-bot · Chat · chat-1',
);
await page.screenshot({
path: info.outputPath('delivery-bootstrap-primary.png'),
});
});
test('requires an explicit resolution of per-run delivery and supports mobile layout @smoke', async ({
page,
baseURL,