Files
PaperClipAI/tests/e2e/chat-adapters-ui-messaging.spec.ts
T
DottaandClaude Opus 5.5 2e6850e745 perf(task-chat): open tasks from a preloaded cache and cold tasks in one request (#20592)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - People open tasks from task lists, the Inbox and the sidebar many
times each hour
> - A task's chat shows its first frame only after about seven separate
reads finish
> - #20355 added one batched `chat-bootstrap` read that returns all of
these reads. #20450 used it only for the sidebar's Recents
> - The hosted database is CPU-bound, so the UI must open tasks faster
and must also send fewer requests
> - This pull request uses the bootstrap for task lists, for open intent
on any task link, for cold opens and for a hard load of a task URL. It
shares one coordination module between all of these paths
> - The benefit is that a task opens from cache more often, a cold open
sends one chat request instead of about seven, and the first content
paint comes sooner

## Linked Issues or Issue Description

Refs #20355 (server read that this pull request consumes). Refs #20450
(Recents preload, now on the shared path).

**Is your feature request related to a problem?**
A click on a task in a task list waited for about seven separate chat
reads. The default task list (Coordinator) had no preload at all. A
press or a resting hover on a task link sent the same seven reads, and
the page then sent a second set. A hard load of a task URL started its
chat reads only after the JavaScript bundle and the company list loaded.
Sidebar project task links had no open-intent prefetch.

**Describe the solution you'd like**
Use the single `chat-bootstrap` read everywhere a task is about to open.
Let the page's gating reads share a bootstrap that is in flight instead
of sending their own requests. Keep the core issue read separate so the
header does not wait for the larger read.

**Describe alternatives you've considered**
Preload 30 rows per list in three requests (the first prototype). Each
task costs the server about fifty queries, and a resting hover warms any
other row before the click, so this change preloads 10 rows in one
request.

## What Changed

- `ui/src/lib/issue-chat-bootstrap.ts`: one module decides when to send
a bootstrap and lets page reads share it (`readFromIssueChatBootstrap`).
Seeding uses the existing `issueChatBootstrapCache` (replace-style reads
are refreshed when newer, merge-style reads are filled only when empty,
other companies are ignored). It skips tasks that are loading, loaded in
the last 30 seconds, or already fresh in cache, under the id or the
identifier. It never aborts a request in flight, so a click that
unmounts the list still uses the answer. Each request is bound to the
account that sent it; a request sent before the first session read is
bound to the first account the session reports, and a sign-out revokes
it. A page read shares a bootstrap only while its key holds no data (a
refresh after a write always reads the server), and waits at most 5
seconds for it. Every ref of a task (id and identifier) is tracked at
request start. Entries expire after 30 seconds.
- Task lists preload the chat of their top 10 rows in one request, after
the list's own reads settle and the browser is idle
(`usePreloadTaskChats`, `when-page-settles.ts`): the Coordinator task
list (`TaskList` with `preloadCompanyId`), the streamlined and legacy
issues lists and the streamlined Inbox. Recents now uses the same path,
so a task in both places loads once. A list that stays open preloads
each task at most once in five minutes: live events (#20563's lanes)
keep a preloaded task's cache current or mark it stale, and a stale key
is read from the server, never from an older bootstrap. All seeded keys
are per-task keys on the fast live lane, not the ambient lane.
- Open intent (press, click, resting hover or focus) on a task with no
chat history in cache warms it with one bootstrap request instead of six
separate reads (`prefetchIssueDetailForNavigation`). A task seen before
still refreshes its cached reads one by one.
- A cold open starts one bootstrap. The gating reads (recent comments,
activity, runs, input facts, interactions, attachments, live runs,
active run) wait for it instead of sending their own requests. The core
issue read stays independent.
- A hard load of a task URL starts `GET /api/issues/:ref/chat-bootstrap`
from the inline preload script, in parallel with the JavaScript bundle.
The page adopts that answer.
- Sidebar project task links (Coordinator sidebar) get the same hover,
press and focus warming as Recents and `IssueLinkQuicklook`.
- Tests: unit tests for the coordination module, Recents, `TaskList`
preload, sidebar warming and the cold-intent path. A new layout journey
checks that a task the Inbox preloaded reveals once at its latest
message without reading its chat again. Journeys that hold one separate
read now also hold or fail the bootstrap. The Inbox reveal journey held
a read that the chat no longer sends on open (it was flaky on master
too), so it now holds the recent comment window.
- The perf harness gains `PAPERCLIP_ISSUE_PERF_LIST_DWELL_MS` and the
README documents it.

## Verification

Measurement setup: production UI builds of master (`e01fe7f84a` and the
earlier base `8d2bf78323`) and of this branch, served by one local
server with `PAPERCLIP_UI_DEV_MIDDLEWARE=false` and embedded PostgreSQL.
Only `index.html` changed between variants (both asset sets were
present), so the server, database and seeded task were the same. Four
rounds of the issue-detail harness, with master and branch alternated in
each round: 20 pairs per S1 cell, 40 per S2 cell. The host was shared
with other agents (load average 16 to 43), so the spread is large.

Content paint (the task is readable), median paired delta, branch minus
master:

| Scenario | Profile | Pairs | Content paint | Branch faster in | API
requests before content (master / branch) |
|---|---|---:|---:|---:|---:|
| S1 preloaded click (dwell 1500 ms) | unthrottled | 20 | -45 ms | 15/20
| 25 / 18 |
| S1 preloaded click (dwell 1500 ms) | Fast 4G, 4x CPU | 20 | -23 ms |
13/20 | 26 / 18 |
| S1 click, no dwell | unthrottled | 20 | -51 ms | 15/20 | 25 / 18 |
| S1 click, no dwell | Fast 4G, 4x CPU | 20 | -21 ms | 12/20 | 26 / 18 |
| S2 cold deep link | unthrottled | 40 | -61 ms | 28/40 | 54 / 40 |
| S2 cold deep link | Fast 4G, 4x CPU | 40 | -129 ms | 29/40 | 47 / 39 |

Medians (p25 to p75), content paint in ms: S1 dwell unthrottled 295
(283-315) → 248 (217-276). S1 dwell Fast 4G 1133 (1114-1226) → 1093
(853-1176). S1 no dwell unthrottled 295 (277-328) → 260 (233-332). S1 no
dwell Fast 4G 984 (842-1196) → 923 (816-1110). S2 unthrottled 1002
(677-1235) → 896 (619-1280). S2 Fast 4G 4643 (4155-5061) → 4516
(4298-4894).

Header paint is neutral to slightly later in two cells: S1 dwell
unthrottled +39 ms (3/20 faster) and S2 Fast 4G +56 ms (17/40). In those
cells the header now paints in the same frame as the content. In the
first two rounds, the median gap from header to content fell from
105-157 ms to 29-74 ms unthrottled and from 557 ms to 298 ms for S2 on
Fast 4G. Other header cells are -27 to +1 ms.

The local server answers each read in a few milliseconds, so the network
wait that the preload removes is small here. The request count is the
stable result: a cold open sends one chat request instead of about
seven.

List page: the preload request starts after the first task row is in the
DOM (64 ms after it unthrottled, 200-280 ms after it on Fast 4G, page
clock). Time to the first visible row, 30-task list: unthrottled master
1863 ms vs branch 1563 ms (6 pairs, branch faster in 5). Fast 4G master
3626 ms vs branch 3727 ms (14 pairs, branch faster in 6, mixed signs).
The preload starts after the row paints, so it cannot delay it; the Fast
4G difference is noise. One 10-task bootstrap took 78-162 ms on the
server.

Checks run:
- `pnpm exec vitest run` for the bootstrap, cache, prefetch, Recents,
sidebar, quicklook, `TaskList`, issues list, Inbox, issue detail and
Coordinator tests: 18 files, 646 tests pass.
- `pnpm exec tsc -p ui/tsconfig.json --noEmit`: clean, except missing
generated Storybook catalog files that this change does not touch.
- `pnpm check:token-gates`: clean.
- `tests/e2e/task-loading-frame.spec.ts`,
`disconnect-resilience.spec.ts` and `connection-reviews.spec.ts`
(production build): 21 passed, 1 skipped.
- `tests/perf/issue-detail/layout-stability.spec.ts` and
`plan-startup.spec.ts` against the production build: all pass. The Inbox
reveal journey and the new preloaded journey passed 4 of 4 repeats each.
- `pnpm verify` asks for broader CI coverage because an e2e spec
changed.

## Risks

- More server work for list views: one bootstrap of up to 10 tasks per
list visit (about fifty queries per task), skipped for tasks already
loaded in the last 30 seconds. It runs only after the page's own reads
settle and the browser is idle, and only while the tab is visible.
- A resting hover on a never-opened task now sends one bootstrap instead
of about six reads. The server work per task is about the same; the
request count is lower.
- If a bootstrap fails, each page read falls back to its own request.
The fallback is slower than before for that one open (the reads start
when the page mounts, not at the press).
- The header can paint a few tens of milliseconds later in the same
frame as the content when everything is cached.
- An answer that arrives after a sign-out or account change is dropped.
An answer for another company is ignored.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (shell, file edits, Playwright runs).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 14:26:27 -06:00

1625 lines
59 KiB
TypeScript

import { expect, test, type Page, type Route } from "@playwright/test";
import {
fulfill,
installChatControlPlaneMock,
json,
seedCompanyAndAgent,
PROVIDERS,
bodyOf,
type Seed,
} from "./chat-adapters-ui.shared";
/**
* Native chat-connector messaging coverage: destination partial updates,
* board send delivery refresh, and exact failed-run retry. Shared fixtures
* and the chat-control-plane mock live in ./chat-adapters-ui.shared.ts; the
* provider-setup describes run in chat-adapters-ui-providers.spec.ts.
*/
test.describe("chat destination partial updates", () => {
let seed: Seed;
test.beforeAll(async ({ request }) => {
seed = await seedCompanyAndAgent(request);
});
for (const initiallyEnabled of [false, true]) {
test(`a stale Settings view preserves another view's ${initiallyEnabled ? "revocation" : "grant"}`, async ({
context,
page,
}) => {
const provider = PROVIDERS.find((item) => item.provider === "discord")!;
// Both real pages share one server fixture, but have separate query caches.
const mock = await installChatControlPlaneMock(context, provider, seed, {
enableChatConnectors: true,
});
mock.setStatus("active");
const settingsUrl = `/${seed.prefix}/apps/chat/endpoint-discord/settings`;
const primarySwitch = (view: Page) =>
view.getByRole("switch", { name: `Enable ${provider.resourceLabel}` });
const secondarySwitch = (view: Page) =>
view.getByRole("switch", {
name: `Enable ${provider.secondaryResourceLabel}`,
});
await page.goto(settingsUrl);
await expect(primarySwitch(page)).not.toBeChecked();
if (initiallyEnabled) {
await primarySwitch(page).click();
await expect(primarySwitch(page)).toBeChecked();
}
const staleView = await context.newPage();
await staleView.goto(settingsUrl);
await expect(primarySwitch(staleView)).toBeChecked({
checked: initiallyEnabled,
});
await expect(secondarySwitch(staleView)).not.toBeChecked();
await primarySwitch(page).click();
await expect(primarySwitch(page)).toBeChecked({
checked: !initiallyEnabled,
});
// Assert the stale prerequisite instead of assuming browser focus/refetch.
await expect(primarySwitch(staleView)).toBeChecked({
checked: initiallyEnabled,
});
await secondarySwitch(staleView).click();
await expect(secondarySwitch(staleView)).toBeChecked();
await expect(primarySwitch(staleView)).toBeChecked({
checked: !initiallyEnabled,
});
expect(mock.resourceUpdates.at(-1)).toEqual([
{ id: "resource-discord-secondary", enabled: true },
]);
// The full response refreshes this page, and persisted state survives reload.
await page.reload();
await expect(primarySwitch(page)).toBeChecked({
checked: !initiallyEnabled,
});
await expect(secondarySwitch(page)).toBeChecked();
await staleView.close();
});
}
test("a pending destination change stays truthful on rejection and explicit retry", async ({
page,
}, testInfo) => {
const provider = PROVIDERS.find((item) => item.provider === "discord")!;
const mock = await installChatControlPlaneMock(page, provider, seed, {
enableChatConnectors: true,
});
mock.setStatus("active");
const attempts: unknown[] = [];
let holdFirstPut!: (route: Route) => void;
const firstPut = new Promise<Route>((resolve) => {
holdFirstPut = resolve;
});
await page.route(
"**/api/chat-endpoints/endpoint-discord/resources",
async (route) => {
if (route.request().method() === "PUT") {
attempts.push(bodyOf(route));
if (attempts.length === 1) {
holdFirstPut(route);
return;
}
}
await route.fallback();
},
);
await page.goto(`/${seed.prefix}/apps/chat/endpoint-discord/settings`);
const primary = page.getByRole("switch", { name: "Enable #general" });
const secondary = page.getByRole("switch", { name: "Enable #support" });
await expect(primary).not.toBeChecked();
await primary.click();
const held = await firstPut;
try {
await expect(primary).toBeDisabled();
await expect(secondary).toBeDisabled();
await expect(primary).not.toBeChecked();
await expect(secondary).not.toBeChecked();
expect(mock.resourceUpdates).toEqual([]);
await page.screenshot({
path: testInfo.outputPath("destination-pending.png"),
});
} finally {
await fulfill(
held,
{ error: "Destination is no longer available. Refresh and try again." },
409,
);
}
await expect(
page.getByText("Couldn't update destination", { exact: true }),
).toBeVisible();
await expect(
page.getByText(
"Destination is no longer available. Refresh and try again.",
{ exact: true },
),
).toBeVisible();
await expect(primary).toBeEnabled();
await expect(secondary).toBeEnabled();
await expect(primary).not.toBeChecked();
await expect(secondary).not.toBeChecked();
expect(mock.resourceUpdates).toEqual([]);
expect(attempts).toEqual([
{ resources: [{ id: "resource-discord", enabled: true }] },
]);
// Visibility alone also matches the toast's initial transparent animation frame.
await expect(
page
.getByRole("listitem")
.filter({ hasText: "Couldn't update destination" }),
).toHaveCSS("opacity", "1");
await page.screenshot({
path: testInfo.outputPath("destination-rejected.png"),
});
await primary.click();
await expect(primary).toBeChecked();
await expect(secondary).not.toBeChecked();
expect(attempts).toEqual([
{ resources: [{ id: "resource-discord", enabled: true }] },
{ resources: [{ id: "resource-discord", enabled: true }] },
]);
expect(mock.resourceUpdates).toEqual([
[{ id: "resource-discord", enabled: true }],
]);
await page.reload();
await expect(primary).toBeChecked();
await expect(secondary).not.toBeChecked();
await page.screenshot({
path: testInfo.outputPath("destination-retry-saved.png"),
});
});
test("one toggle succeeds with more than 500 discovered destinations", async ({
page,
}) => {
const provider = PROVIDERS.find((item) => item.provider === "discord")!;
const mock = await installChatControlPlaneMock(page, provider, seed, {
enableChatConnectors: true,
resourceCount: 501,
});
mock.setStatus("active");
await page.goto(`/${seed.prefix}/apps/chat/endpoint-discord/settings`);
await expect(page.getByRole("switch", { name: /^Enable / })).toHaveCount(
501,
);
const target = page.getByRole("switch", {
name: "Enable Extra destination 499",
});
await expect(target).not.toBeChecked();
await target.click();
await expect(target).toBeChecked();
expect(mock.resourceUpdates).toEqual([
[{ id: "resource-discord-extra-498", enabled: true }],
]);
await expect(
page.getByRole("switch", { name: "Enable #general" }),
).not.toBeChecked();
await page.reload();
await expect(target).toBeChecked();
await expect(page.getByRole("switch", { name: /^Enable / })).toHaveCount(
501,
);
});
});
test.describe("Board send delivery refresh", () => {
for (const classic of [false, true]) {
for (const entry of [
"uuid",
"uppercase-uuid",
"wrong-prefix-identifier",
] as const) {
test(`opens external task links in their own organization and uploads safely (${entry}, classic=${classic})`, async ({
page,
request,
}) => {
await page.addLocatorHandler(
page.getByRole("complementary", { name: "Paperclip announcements", exact: true })
.getByRole("button", { name: "Dismiss announcement", exact: true }),
async (dismiss) => {
const menuOpen = await page.getByRole("menu", { name: "Add to composer", exact: true }).isVisible();
if (menuOpen) await page.keyboard.press("Escape");
await dismiss.click();
if (menuOpen) await page.getByRole("button", { name: "Add to composer", exact: true }).click();
},
);
const selected = await seedCompanyAndAgent(request);
const target = await seedCompanyAndAgent(request);
const issue = await json<{ id: string; identifier: string }>(
await request.post(`/api/companies/${target.companyId}/issues`, {
data: { title: "Organization-bound task link", status: "backlog" },
}),
"create linked task in another organization",
);
await page.route("**/api/workspace-preferences/me", route => fulfill(route, {
userId: "local-board", coordinatorWorkspaceEnabled: !classic, updatedAt: null,
}));
await page.route("**/api/instance/settings/experimental", (route) =>
fulfill(route, {
enableChatConnectors: true,
enableClassicTaskInterface: classic,
}),
);
await page.goto(`/${selected.prefix}/issues`);
await expect
.poll(() =>
page.evaluate(() =>
localStorage.getItem("paperclip.selectedCompanyId"),
),
)
.toBe(selected.companyId);
await page.goto(
entry === "uuid"
? `/issues/${issue.id}`
: entry === "uppercase-uuid"
? `/issues/${issue.id.toUpperCase()}`
: `/${selected.prefix}/issues/${issue.identifier}?external=chat#files`,
);
if (!classic) {
await expect(page.getByTestId("task-chat-history-content")).toBeVisible({ timeout: 30_000 });
}
await expect(
page.getByRole("heading", {
name: "Organization-bound task link",
exact: true,
}),
).toBeVisible();
await expect
.soft(page)
.toHaveURL(
new RegExp(
`/${target.prefix}/issues/${issue.identifier}${
entry === "wrong-prefix-identifier"
? "\\?external=chat#files"
: ""
}$`,
),
{ timeout: 4_000 },
);
const file = {
name: classic ? "task-link-report.txt" : "task-link-image.png",
mimeType: classic ? "text/plain" : "image/png",
buffer: classic
? Buffer.from("Synthetic cross-organization upload proof.\n")
: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jfFoAAAAASUVORK5CYII=",
"base64",
),
};
if (!classic) {
await page.getByRole("button", { name: "Add to composer" }).click();
}
const chooserPromise = page.waitForEvent("filechooser");
await (classic
? page.getByRole("button", { name: "Upload attachment", exact: true })
: page.getByRole("menuitem", { name: "Files and images", exact: true })
).click();
const responsePromise = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
/\/api\/companies\/[^/]+\/issues\/[^/]+\/attachments$/.test(
new URL(response.url()).pathname,
),
);
await (await chooserPromise).setFiles(file);
const response = await responsePromise;
expect.soft(response.status(), await response.text()).toBe(201);
expect
.soft(new URL(response.url()).pathname)
.toBe(
`/api/companies/${target.companyId}/issues/${issue.id}/attachments`,
);
const attachments = await json<
{
originalFilename: string;
contentPath: string;
issueCommentId: string | null;
}[]
>(
await request.get(`/api/issues/${issue.id}/attachments`),
"read linked task attachments",
);
expect(attachments).toHaveLength(1);
expect(attachments[0]).toMatchObject({
originalFilename: file.name,
issueCommentId: null,
});
expect(
await (await request.get(attachments[0].contentPath)).body(),
).toEqual(file.buffer);
expect(
await json<unknown[]>(
await request.get(`/api/issues/${issue.id}/comments`),
"read linked task comments",
),
).toHaveLength(0);
await expect
.poll(() =>
page.evaluate(() =>
localStorage.getItem("paperclip.selectedCompanyId"),
),
)
.toBe(target.companyId);
});
}
}
test("keeps the connected-task banner readable in narrow task panes", async ({
page,
request,
}) => {
const seed = await seedCompanyAndAgent(request);
const issue = await json<{ id: string; identifier: string }>(
await request.post(`/api/companies/${seed.companyId}/issues`, {
data: { title: "Connected banner layout", status: "backlog" },
}),
"create connected banner task",
);
await page.route("**/api/instance/settings/experimental", (route) =>
fulfill(route, { enableChatConnectors: true }),
);
await page.route(`**/api/issues/${issue.id}/chat-binding`, (route) =>
fulfill(route, {
endpointId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
conversationId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
provider: "discord",
externalLabel: "#a-long-but-readable-qualification-channel-name",
externalUrl: "https://discord.com/channels/test-server/test-thread",
assignedAgentLocked: true,
}),
);
await page.goto(`/${seed.prefix}/issues/${issue.identifier}`);
const banner = page.getByRole("region", {
name: "External conversation",
exact: true,
});
const heading = banner.getByText("Connected to Discord", { exact: true });
await expect(heading).toBeVisible();
// Exercise container widths independently of the operator's sidebar and
// Properties preferences. These are test constraints, not product styles.
for (const width of [340, 500, 760]) {
await banner.evaluate((element, value) => {
element.style.width = `${value}px`;
}, width);
await expect
.poll(() =>
heading
.evaluate((element) => ({
height: element.getBoundingClientRect().height,
lineHeight: Number.parseFloat(
getComputedStyle(element).lineHeight,
),
}))
.then(({ height, lineHeight }) => height <= lineHeight * 1.5),
)
.toBe(true);
const bounds = await banner.boundingBox();
expect(bounds).not.toBeNull();
for (const action of [
banner.getByRole("link", { name: "Open Discord", exact: true }),
banner.getByRole("button", { name: "Send to channel", exact: true }),
banner.getByRole("link", { name: "Connection", exact: true }),
]) {
await expect(action).toBeVisible();
const box = await action.boundingBox();
expect(box).not.toBeNull();
expect(box!.x).toBeGreaterThanOrEqual(bounds!.x);
expect(box!.x + box!.width).toBeLessThanOrEqual(
bounds!.x + bounds!.width,
);
expect(box!.y + box!.height).toBeLessThanOrEqual(
bounds!.y + bounds!.height,
);
}
}
});
test("uploads images and files directly from an empty channel composer without publishing early", async ({
page,
request,
}) => {
const seed = await seedCompanyAndAgent(request);
const issue = await json<{ id: string; identifier: string }>(
await request.post(`/api/companies/${seed.companyId}/issues`, {
data: { title: "Upload channel files", status: "backlog" },
}),
"create upload task",
);
const endpointId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const conversationId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const publicationId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
const sends: Record<string, unknown>[] = [];
let releaseSend!: () => void;
const sendResponse = new Promise<void>((resolve) => {
releaseSend = resolve;
});
await page.route("**/api/instance/settings/experimental", (route) =>
fulfill(route, { enableChatConnectors: true }),
);
await page.route(`**/api/issues/${issue.id}/chat-binding`, (route) =>
fulfill(route, {
endpointId,
conversationId,
provider: "discord",
externalLabel: "#upload-test",
assignedAgentLocked: true,
}),
);
await page.route(
`**/api/chat-endpoints/${endpointId}/conversations/${conversationId}/publications`,
async (route) => {
sends.push(bodyOf(route));
await sendResponse;
return fulfill(
route,
{ id: publicationId, state: "streaming", attempts: 1 },
201,
);
},
);
await page.route(
`**/api/chat-endpoints/${endpointId}/conversations/${conversationId}/publications/${publicationId}/status`,
(route) =>
fulfill(route, {
publication: { id: publicationId, state: "pending", attempts: 0 },
total: 3,
published: 0,
}),
);
await page.goto(`/${seed.prefix}/issues/${issue.identifier}`);
await page
.getByRole("button", { name: "Send to channel", exact: true })
.click();
const input = page.getByLabel("Attach file to channel update", {
exact: true,
});
const files = [
{
name: "channel-image.png",
mimeType: "image/png",
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jfFoAAAAASUVORK5CYII=",
"base64",
),
},
{
name: "channel-report.txt",
mimeType: "text/plain",
buffer: Buffer.from("Synthetic channel upload proof.\n"),
},
];
for (const file of files) {
await input.setInputFiles(file);
await expect(
page
.getByRole("group", { name: "Include task files", exact: true })
.getByRole("checkbox", { name: file.name, exact: true }),
).toBeChecked();
await expect(input).toBeEnabled();
}
expect(sends).toHaveLength(0);
const stored = await json<
{
id: string;
originalFilename: string;
contentPath: string;
issueCommentId: string | null;
}[]
>(
await request.get(`/api/issues/${issue.id}/attachments`),
"read uploaded files",
);
expect(stored).toHaveLength(2);
for (const file of files) {
const attachment = stored.find(
(item) => item.originalFilename === file.name,
)!;
expect(attachment.issueCommentId).toBeNull();
const content = await request.get(attachment.contentPath);
expect(content.ok()).toBe(true);
expect(await content.body()).toEqual(file.buffer);
}
expect(
await json<unknown[]>(
await request.get(`/api/issues/${issue.id}/comments`),
"read internal comments",
),
).toHaveLength(0);
await page
.getByRole("textbox", { name: "Board update", exact: true })
.fill("Publish these two synthetic files.");
await page
.getByRole("button", { name: "Send to channel", exact: true })
.last()
.click();
await expect.poll(() => sends.length).toBe(1);
await expect(
page.getByRole("button", { name: "Sending…", exact: true }),
).toBeDisabled();
await expect(
page.getByText("Delivery result not confirmed", { exact: true }),
).toHaveCount(0);
releaseSend();
expect(sends[0].attachmentIds).toEqual(
files.map(
(file) =>
stored.find((item) => item.originalFilename === file.name)!.id,
),
);
await expect(input).toBeDisabled();
await page.reload();
const retained = page.getByRole("group", {
name: "Files in this send",
exact: true,
});
for (const file of files) {
await expect(
retained.getByRole("checkbox", { name: file.name, exact: true }),
).toBeChecked();
await expect(
retained.getByRole("checkbox", { name: file.name, exact: true }),
).toBeDisabled();
}
expect(sends).toHaveLength(1);
});
test("recovers an uncertain channel send into a durable rejection before an explicit correction", async ({
page,
request,
}, testInfo) => {
const seed = await seedCompanyAndAgent(request);
const issue = await json<{ id: string; identifier: string }>(
await request.post(`/api/companies/${seed.companyId}/issues`, {
data: { title: "Rejected channel selection", status: "backlog" },
}),
"create rejected-send task",
);
const endpointId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const conversationId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
await page.route("**/api/instance/settings/experimental", (route) =>
fulfill(route, { enableChatConnectors: false, enableGitHubReviewBots: true }),
);
await page.route(`**/api/issues/${issue.id}/chat-binding`, (route) =>
fulfill(route, {
endpointId,
conversationId,
provider: "github",
externalLabel: "test/rejected-send",
assignedAgentLocked: true,
}),
);
const sends: Record<string, unknown>[] = [];
let attachmentId = "";
let privateComment: { id: string } | undefined;
// The lost send resends on its own; hold that resend until the waiting
// state has been checked.
let releaseResend!: () => void;
const resendReleased = new Promise<void>((resolve) => {
releaseResend = resolve;
});
await page.route(
`**/api/chat-endpoints/${endpointId}/conversations/${conversationId}/publications`,
async (route) => {
const input = bodyOf(route);
sends.push(input);
if (sends.length === 1) {
expect(input.attachmentIds).toEqual([attachmentId]);
// Consume the file only after the browser has captured its send.
// Binding it before Send lets live refresh remove it from the draft,
// making the mocked rejection inconsistent with the actual request.
privateComment = await json<{ id: string }>(
await request.post(`/api/issues/${issue.id}/comments`, {
data: { body: "Private Board file", attachmentIds: [attachmentId] },
}),
"bind selected file to private comment",
);
return route.abort("connectionreset");
}
if (sends.length === 2) {
await resendReleased;
return fulfill(
route,
{
error: "A selected file already belongs to another comment",
details: {
code: "chat_board_send_attachments_already_bound",
endpointId,
conversationId,
idempotencyKey: input.idempotencyKey,
attachmentIds: [attachmentId],
},
},
409,
);
}
return fulfill(
route,
{
id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
state: "published",
attempts: 1,
},
201,
);
},
);
await page.goto(`/${seed.prefix}/issues/${issue.identifier}`);
const banner = page.getByRole("region", { name: "External conversation" });
await banner
.getByRole("button", { name: "Send to channel", exact: true })
.click();
await banner
.getByLabel("Attach file to channel update", { exact: true })
.setInputFiles({
name: "rejected-send.txt",
mimeType: "text/plain",
buffer: Buffer.from("Synthetic rejected send proof.\n"),
});
await expect(
banner.getByRole("checkbox", { name: "rejected-send.txt" }),
).toBeChecked();
const [file] = await json<{ id: string }[]>(
await request.get(`/api/issues/${issue.id}/attachments`),
"read selected file",
);
attachmentId = file!.id;
// The first intercepted send binds the file to a concurrent Board comment.
// The real TX/idempotency and delete/retry behavior are covered in PostgreSQL.
const draft =
"Share the verified result, without the private Board comment.";
await banner.getByRole("textbox", { name: "Board update" }).fill(draft);
await banner
.getByRole("button", { name: "Send to channel", exact: true })
.last()
.click();
// A lost response waits and resends on its own with the same identity.
await expect(
banner.getByText("Sending when reconnected…", { exact: true }),
).toBeVisible();
await expect(
banner.getByRole("textbox", { name: "Board update" }),
).toBeDisabled();
await expect.poll(() => sends.length).toBe(2);
releaseResend();
await expect(
banner.getByText("Update was not sent", { exact: true }),
).toBeVisible();
expect(sends[1]).toEqual(sends[0]);
await page.reload();
await expect(
banner.getByText("Update was not sent", { exact: true }),
).toBeVisible();
await expect(
banner.getByRole("textbox", { name: "Board update" }),
).toHaveValue(draft);
expect(sends).toHaveLength(2);
await banner.screenshot({
path: testInfo.outputPath("durable-rejected-channel-send.png"),
});
await banner
.getByRole("button", { name: "Edit rejected send", exact: true })
.click();
await expect(
banner.getByRole("textbox", { name: "Board update" }),
).toBeEnabled();
await expect(
banner.getByText(/Attach a new copy or share the task link/),
).toBeVisible();
await expect(banner.getByRole("checkbox")).toHaveCount(0);
await banner
.getByRole("button", { name: "Send to channel", exact: true })
.last()
.click();
await expect(
banner.getByRole("textbox", { name: "Board update" }),
).toHaveCount(0);
expect(sends).toHaveLength(3);
expect(sends[2]!.body).toBe(draft);
expect(sends[2]!.attachmentIds ?? []).toEqual([]);
expect(sends[2]!.idempotencyKey).not.toBe(sends[0]!.idempotencyKey);
const attachments = await json<{ id: string; issueCommentId: string }[]>(
await request.get(`/api/issues/${issue.id}/attachments`),
"verify immutable original binding",
);
expect(attachments).toEqual([
expect.objectContaining({
id: attachmentId,
issueCommentId: privateComment!.id,
}),
]);
});
for (const cancelledHead of [false, true]) {
test(`keeps Teams file consent anchored across reload (${cancelledHead ? "cancelled head with waiting tail" : "mixed terminal receipt and explicit dismiss"})`, async ({
page,
request,
}, testInfo) => {
const seed = await seedCompanyAndAgent(request);
const issue = await json<{ id: string; identifier: string }>(
await request.post(`/api/companies/${seed.companyId}/issues`, {
data: { title: "Teams consent receipt", status: "backlog" },
}),
"create consent receipt task",
);
const endpointId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const conversationId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const anchor = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
const nextAnchor = "ffffffff-ffff-4fff-8fff-ffffffffffff";
const filePublications = [
"dddddddd-dddd-4ddd-8ddd-dddddddddddd",
"eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
];
const filenames = ["consent-report.txt", "consent-notes.txt"];
const posts: Record<string, unknown>[] = [];
const statusReads: string[] = [];
let terminal = false;
const publicationsPath = `/api/chat-endpoints/${endpointId}/conversations/${conversationId}/publications`;
const session = await json<{ user: { id: string } }>(
await request.get("/api/auth/get-session"),
"read receipt owner",
);
const storageKey = `paperclip:board-send:v2:${JSON.stringify([session.user.id, seed.companyId, issue.id, endpointId, conversationId])}`;
await page.route("**/api/instance/settings/experimental", (route) =>
fulfill(route, { enableChatConnectors: true }),
);
await page.route(`**/api/issues/${issue.id}/chat-binding`, (route) =>
fulfill(route, {
endpointId,
conversationId,
provider: "microsoft-teams",
externalLabel: "Personal file conversation",
assignedAgentLocked: true,
}),
);
// Only the publication API is simulated. Task creation and safe file
// uploads use this test's isolated Paperclip instance, never Teams.
await page.route(`**${publicationsPath}`, (route) => {
expect(route.request().method()).toBe("POST");
posts.push(bodyOf(route));
return fulfill(
route,
{
id: posts.length === 1 ? anchor : nextAnchor,
state: "pending",
attempts: 0,
},
201,
);
});
await page.route(`**${publicationsPath}/*/status`, (route) => {
expect(route.request().method()).toBe("GET");
const path = new URL(route.request().url()).pathname;
statusReads.push(path);
if (path.endsWith(`/${nextAnchor}/status`)) {
return fulfill(route, {
publication: { id: nextAnchor, state: "pending", attempts: 0 },
total: 1,
published: 0,
awaitingConsent: 0,
declined: 0,
expired: 0,
cancelled: 0,
settled: 0,
canDismiss: false,
});
}
expect(path).toBe(`${publicationsPath}/${anchor}/status`);
const parts = [
{ id: anchor, state: "published", attempts: 1 },
...filePublications.map((id, index) => ({
id,
state:
terminal || (cancelledHead && index === 0)
? "cancelled"
: "awaiting_consent",
attempts: 1,
fileTransfer: {
provider: "microsoft-teams",
filename: filenames[index],
phase: terminal
? index === 0
? "declined"
: "expired"
: cancelledHead && index === 0
? "cancelled"
: "awaiting_consent",
version: terminal ? 3 : 2,
},
})),
];
return fulfill(route, {
publication: parts[1],
parts,
total: 3,
published: 1,
awaitingConsent: terminal ? 0 : cancelledHead ? 1 : 2,
declined: terminal ? 1 : 0,
expired: terminal ? 1 : 0,
cancelled: !terminal && cancelledHead ? 1 : 0,
settled: terminal ? 3 : cancelledHead ? 2 : 1,
canDismiss: terminal,
});
});
await page.goto(`/${seed.prefix}/issues/${issue.identifier}`);
const banner = page.getByRole("region", {
name: "External conversation",
exact: true,
});
await banner
.getByRole("button", { name: "Send to channel", exact: true })
.click();
for (const name of filenames) {
await banner
.getByLabel("Attach file to channel update", { exact: true })
.setInputFiles({
name,
mimeType: "text/plain",
buffer: Buffer.from(`Synthetic consent fixture: ${name}.\n`),
});
await expect(
banner.getByRole("checkbox", { name, exact: true }),
).toBeChecked();
await expect(
banner.getByLabel("Attach file to channel update", { exact: true }),
).toBeEnabled();
}
await expect(
banner.getByRole("group", { name: "Include task files", exact: true }),
).toContainText(
"In personal Teams chats, recipients accept each file before upload. Channels and group chats receive supported images directly; other files stay on the task, with a task link or private-task notice.",
);
await banner.screenshot({
path: testInfo.outputPath("teams-file-guidance.png"),
});
expect(posts).toHaveLength(0);
await banner
.getByRole("textbox", { name: "Board update", exact: true })
.fill("Please review these two files.");
await banner
.getByRole("button", { name: "Send to channel", exact: true })
.last()
.click();
await expect.poll(() => posts.length).toBe(1);
expect(posts[0].attachmentIds).toHaveLength(2);
await expect(
banner.getByText(
cancelledHead
? "1 published · 1 awaiting consent · 1 cancelled"
: "1 published · 2 awaiting consent",
{ exact: true },
),
).toBeVisible();
const retainedBeforeReload = await page.evaluate(
(key) => sessionStorage.getItem(key),
storageKey,
);
expect(JSON.parse(retainedBeforeReload!).publication.id).toBe(anchor);
const readsBeforeReload = statusReads.length;
await page.reload();
await expect
.poll(() => statusReads.length)
.toBeGreaterThan(readsBeforeReload);
expect(
await page.evaluate((key) => sessionStorage.getItem(key), storageKey),
).toBe(retainedBeforeReload);
expect(
statusReads.every(
(path) => path === `${publicationsPath}/${anchor}/status`,
),
).toBe(true);
await expect(
banner.getByRole("textbox", { name: "Board update", exact: true }),
).toBeDisabled();
await expect(
banner.getByLabel("Attach file to channel update", { exact: true }),
).toBeDisabled();
await expect(
banner
.getByRole("button", { name: "Send to channel", exact: true })
.last(),
).toBeDisabled();
await expect(
banner.getByRole("button", {
name: "Dismiss delivery receipt",
exact: true,
}),
).toHaveCount(0);
await expect(
banner.getByText(
cancelledHead
? "Waiting for remaining file consent"
: "Waiting for file consent",
{ exact: true },
),
).toBeVisible();
await expect(
banner.getByText("Channel delivery cancelled", { exact: true }),
).toHaveCount(0);
await expect(
banner.getByText("Sent to channel", { exact: true }),
).toHaveCount(0);
for (const name of filenames) {
await expect(
banner.getByRole("checkbox", { name, exact: true }),
).toBeChecked();
await expect(
banner.getByRole("checkbox", { name, exact: true }),
).toBeDisabled();
}
expect(posts).toHaveLength(1);
await banner.screenshot({
path: testInfo.outputPath("consent-waiting.png"),
});
if (cancelledHead) return;
terminal = true;
await expect(
banner.getByText("Delivery settled with mixed outcomes", {
exact: true,
}),
).toBeVisible({ timeout: 8_000 });
await expect(
banner.getByText("1 published · 1 declined · 1 expired", {
exact: true,
}),
).toBeVisible();
await expect(
banner.getByText("consent-report.txt — Declined", { exact: true }),
).toBeVisible();
await expect(
banner.getByText("consent-notes.txt — Consent expired", {
exact: true,
}),
).toBeVisible();
await expect(
banner
.getByRole("button", { name: "Send to channel", exact: true })
.last(),
).toBeDisabled();
await banner.screenshot({
path: testInfo.outputPath("consent-mixed.png"),
});
expect(posts).toHaveLength(1);
await banner
.getByRole("button", { name: "Dismiss delivery receipt", exact: true })
.click();
await expect(
banner.getByRole("textbox", { name: "Board update", exact: true }),
).toHaveValue("");
await expect(
banner.getByRole("textbox", { name: "Board update", exact: true }),
).toBeEnabled();
await expect(
banner
.getByRole("button", { name: "Send to channel", exact: true })
.last(),
).toBeDisabled();
expect(
await page.evaluate((key) => sessionStorage.getItem(key), storageKey),
).toBeNull();
expect(posts).toHaveLength(1);
await expect(
page.getByText("Sent to channel", { exact: true }),
).toHaveCount(0);
for (const name of filenames) {
await expect(
banner.getByRole("checkbox", { name, exact: true }),
).not.toBeChecked();
}
await banner
.getByRole("textbox", { name: "Board update", exact: true })
.fill("A separate text-only update.");
expect(posts).toHaveLength(1);
await banner
.getByRole("button", { name: "Send to channel", exact: true })
.last()
.click();
await expect.poll(() => posts.length).toBe(2);
expect(posts[1]).toMatchObject({
body: "A separate text-only update.",
});
expect(posts[1]).not.toHaveProperty("attachmentIds");
expect(posts[1].idempotencyKey).not.toBe(posts[0].idempotencyKey);
});
}
for (const outcome of [
"published",
"failed",
"delivery_unknown",
"response_lost",
] as const) {
test(`tracks the whole file batch across reload and ${outcome} without a new send identity`, async ({
page,
request,
}) => {
const seed = await seedCompanyAndAgent(request);
const issue = await json<{ id: string; identifier: string }>(
await request.post(`/api/companies/${seed.companyId}/issues`, {
data: { title: "Board delivery refresh", status: "backlog" },
}),
"create board-send task",
);
const endpointId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const conversationId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const publicationId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
const attachmentId = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
const statusPath = `/api/chat-endpoints/${endpointId}/conversations/${conversationId}/publications/${publicationId}/status`;
let sends = 0;
const submittedPayloads: Record<string, unknown>[] = [];
let reads = 0;
let canonicalAttachmentReadsAfterSend = 0;
let canonicalCommentReadsAfterSend = 0;
let status = "streaming";
let releaseResend!: () => void;
const resendReleased = new Promise<void>((resolve) => {
releaseResend = resolve;
});
await page.route("**/api/instance/settings/experimental", (route) =>
fulfill(route, { enableChatConnectors: true }),
);
await page.route(`**/api/issues/${issue.id}/chat-binding`, (route) =>
fulfill(route, {
endpointId,
conversationId,
provider: "slack",
externalLabel: "#board-send-test",
assignedAgentLocked: true,
}),
);
// The comment and attachment reads below are faked. The chat bootstrap
// would answer them from the server, so make the page read them itself.
await page.route(/\/chat-bootstrap(?:\?|$)/, (route) => route.abort());
await page.route(
new RegExp(
`/api/issues/(${issue.id}|${issue.identifier})/(?:recent-comments|comments)(?:\\?|$)`,
),
async (route) => {
if (
sends &&
route.request().url().includes(`/issues/${issue.identifier}/`)
)
canonicalCommentReadsAfterSend += 1;
const comments = sends
? [
{
id: publicationId,
companyId: seed.companyId,
issueId: issue.id,
authorAgentId: null,
authorUserId: "local-board",
authorType: "user",
body: "Board batch must finish all files.",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
]
: [];
await fulfill(route, route.request().url().includes("/recent-comments")
? { comments, latestCommentId: comments.at(-1)?.id ?? null, hasMore: false }
: comments);
},
);
await page.route(
new RegExp(
`/api/issues/(${issue.id}|${issue.identifier})/attachments$`,
),
async (route) => {
if (
sends &&
route.request().url().includes(`/issues/${issue.identifier}/`)
)
canonicalAttachmentReadsAfterSend += 1;
await fulfill(route, [
{
id: attachmentId,
companyId: seed.companyId,
issueId: issue.id,
issueCommentId: sends ? publicationId : null,
assetId: attachmentId,
provider: "local_disk",
objectKey: "board-send-test.txt",
contentType: "text/plain",
byteSize: 12,
sha256: "a".repeat(64),
originalFilename: "board-send-test.txt",
createdByAgentId: null,
createdByUserId: "local-board",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
contentPath: `/api/attachments/${attachmentId}/content`,
},
{
id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
companyId: seed.companyId,
issueId: issue.id,
issueCommentId: null,
assetId: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
provider: "local_disk",
objectKey: "internal-only.txt",
contentType: "text/plain",
byteSize: 8,
sha256: "b".repeat(64),
originalFilename: "internal-only.txt",
createdByAgentId: null,
createdByUserId: "local-board",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
contentPath:
"/api/attachments/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee/content",
},
]);
},
);
await page.route(
`**/api/chat-endpoints/${endpointId}/conversations/${conversationId}/publications`,
async (route) => {
expect(route.request().method()).toBe("POST");
expect(bodyOf(route).attachmentIds).toEqual([attachmentId]);
sends += 1;
submittedPayloads.push(bodyOf(route));
if (outcome === "response_lost" && sends === 1) {
await route.abort("failed");
return;
}
if (outcome === "response_lost" && sends === 2) {
// The automatic resend is still in flight when the page reloads;
// the browser drops it.
await resendReleased;
await route.abort("failed").catch(() => {});
return;
}
await fulfill(
route,
{ id: publicationId, state: "streaming", attempts: 1 },
201,
);
},
);
await page.route(`**${statusPath}`, async (route) => {
expect(route.request().method()).toBe("GET");
reads += 1;
await fulfill(route, {
publication: {
id: status === "streaming" ? publicationId : attachmentId,
state: status,
attempts: 1,
},
total: 2,
published: status === "published" ? 2 : 1,
});
});
await page.goto(`/${seed.prefix}/issues/${issue.identifier}`);
await page
.getByRole("button", { name: "Send to channel", exact: true })
.click();
await page
.getByRole("textbox", { name: "Board update", exact: true })
.fill("Board batch must finish all files.");
await page.getByRole("checkbox", { name: "board-send-test.txt" }).check();
await page
.getByRole("button", { name: "Send to channel", exact: true })
.last()
.click();
const expectRetainedFiles = async () => {
const files = page.getByRole("group", {
name: "Files in this send",
exact: true,
});
await expect(files).toBeVisible();
await expect(files.getByRole("checkbox")).toHaveCount(1);
await expect(
files.getByRole("checkbox", { name: "board-send-test.txt" }),
).toBeChecked();
await expect(
files.getByRole("checkbox", { name: "board-send-test.txt" }),
).toBeDisabled();
await expect(
files.getByText("internal-only.txt", { exact: true }),
).toHaveCount(0);
};
if (outcome === "response_lost") {
// The lost send resends on its own with the same identity.
await expect(
page.getByText("Sending when reconnected…", { exact: true }),
).toBeVisible();
await expect.poll(() => submittedPayloads.length).toBe(2);
expect(submittedPayloads[1]).toEqual(submittedPayloads[0]);
// A send restored after reload has an unknown outcome and waits for
// an explicit, same-identity retry.
await page.reload();
releaseResend();
await expect(
page.getByText("Delivery result not confirmed", { exact: true }),
).toBeVisible();
await expect(
page.getByRole("textbox", { name: "Board update", exact: true }),
).toBeDisabled();
await expectRetainedFiles();
expect(sends).toBe(2);
expect(reads).toBe(0);
await page
.getByRole("button", { name: "Retry safely", exact: true })
.click();
await expect.poll(() => submittedPayloads.length).toBe(3);
expect(submittedPayloads[2]).toEqual(submittedPayloads[0]);
} else {
await expect(
page.getByText("Publishing to channel", { exact: true }).first(),
).toBeVisible();
// The canonical task view must refresh its comment/files before any reload.
await expect
.poll(() => canonicalCommentReadsAfterSend)
.toBeGreaterThan(0);
await expect
.poll(() => canonicalAttachmentReadsAfterSend)
.toBeGreaterThan(0);
await expectRetainedFiles();
const readsBeforeReload = reads;
await page.reload();
await expect
.poll(() => reads, { timeout: 8_000 })
.toBeGreaterThan(readsBeforeReload);
expect(sends).toBe(1);
}
await expect.poll(() => reads, { timeout: 8_000 }).toBeGreaterThan(0);
await expectRetainedFiles();
await expect(
page.getByRole("textbox", { name: "Board update", exact: true }),
).toHaveValue("Board batch must finish all files.");
await expect(
page.getByRole("textbox", { name: "Board update", exact: true }),
).toBeDisabled();
status = outcome === "response_lost" ? "published" : outcome;
if (status === "published") {
await expect(
page.getByRole("textbox", { name: "Board update", exact: true }),
).toHaveCount(0, { timeout: 8_000 });
await expect(
page.getByText("Sent to channel", { exact: true }),
).toBeVisible();
} else {
await expect(
page.getByText(
outcome === "failed"
? "Channel delivery failed"
: "Delivery not confirmed",
{ exact: true },
),
).toBeVisible({ timeout: 8_000 });
await expect(
page.getByRole("textbox", { name: "Board update", exact: true }),
).toHaveValue("Board batch must finish all files.");
await expect(
page
.getByRole("button", { name: "Send to channel", exact: true })
.last(),
).toBeDisabled();
await expect(
page.getByRole("link", { name: "Open Activity", exact: true }),
).toBeVisible();
// An explicit resolution elsewhere may complete the batch; this surface only reads it.
status = "published";
await expect(
page.getByRole("textbox", { name: "Board update", exact: true }),
).toHaveCount(0, { timeout: 8_000 });
}
// response_lost: the lost send, its automatic resend, and the explicit
// retry after reload, all with one identity.
const expectedSends = outcome === "response_lost" ? 3 : 1;
expect(sends).toBe(expectedSends);
expect(canonicalCommentReadsAfterSend).toBeGreaterThan(0);
expect(canonicalAttachmentReadsAfterSend).toBeGreaterThan(0);
await expect(
page.getByText("Board batch must finish all files.", { exact: true }),
).toBeVisible();
await page
.getByRole("button", { name: "Send to channel", exact: true })
.click();
const newFiles = page.getByRole("group", {
name: "Include task files",
exact: true,
});
await expect(
newFiles.getByRole("checkbox", { name: "board-send-test.txt" }),
).toHaveCount(0);
await expect(
newFiles.getByRole("checkbox", { name: "internal-only.txt" }),
).not.toBeChecked();
await expect(
newFiles.getByRole("checkbox", { name: "internal-only.txt" }),
).toBeEnabled();
expect(sends).toBe(expectedSends);
});
}
});
test.describe("Failed chat task and exact run retry", () => {
let seed: Seed;
let issue: { id: string; identifier: string; title: string };
const failedRunId = "11111111-aaaa-4aaa-8aaa-111111111111";
const actionId = "22222222-bbbb-4bbb-8bbb-222222222222";
const denial =
"This chat source is no longer authorized. Open the task to review its current channel access before retrying.";
test.beforeAll(async ({ request }) => {
seed = await seedCompanyAndAgent(request);
issue = await json<typeof issue>(
await request.post(`/api/companies/${seed.companyId}/issues`, {
data: { title: "Exact chat retry destination", status: "backlog" },
}),
"create exact-retry task",
);
});
for (const surface of ["agent run", "Inbox", "Coordinator Tasks", "Legacy Inbox"] as const) {
for (const outcome of ["queued", "deferred", "denied"] as const) {
test(`${surface}: selected run ${outcome} preserves exact retry authority and truthful feedback`, async ({
page,
}, testInfo) => {
const usesTaskRetry = surface === "Inbox" || surface === "Coordinator Tasks";
const now = Date.now();
const run = {
id: failedRunId,
companyId: seed.companyId,
agentId: seed.agentId,
status: "failed",
invocationSource: "assignment",
triggerDetail: "system",
startedAt: new Date(now - 60_000).toISOString(),
finishedAt: new Date(now - 30_000).toISOString(),
createdAt: new Date(now - 60_000).toISOString(),
updatedAt: new Date(now - 30_000).toISOString(),
error: "The fixture provider turn failed.",
errorCode: "adapter_failed",
exitCode: 1,
signal: null,
responsibleUserId: null,
runtimeMode: "native",
driverKind: "codex_app_server",
nativeIssueId: issue.id,
usageJson: null,
resultJson: null,
sessionIdBefore: null,
sessionIdAfter: null,
logStore: null,
logRef: null,
logBytes: 0,
retryOfRunId: null,
scheduledRetryAt: null,
scheduledRetryAttempt: 0,
scheduledRetryReason: null,
contextSnapshot: {
source: "chat:slack",
issueId: issue.id,
taskId: "33333333-cccc-4ccc-8ccc-333333333333",
taskKey: "untrusted-copy-of-another-task",
wakeCommentId: "44444444-dddd-4ddd-8ddd-444444444444",
wakeCommentIds: ["44444444-dddd-4ddd-8ddd-444444444444"],
chatFailedRunRetry: { actionId: "untrusted-client-action" },
},
};
const requests: Array<{
companyId: string | null;
body: Record<string, unknown>;
}> = [];
const destinations: string[] = [];
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) destinations.push(frame.url());
});
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url());
const pathname = url.pathname;
if (pathname === "/api/workspace-preferences/me") {
await fulfill(route, {
userId: "local-board",
coordinatorWorkspaceEnabled: surface === "Coordinator Tasks",
updatedAt: null,
});
return;
}
if (pathname === "/api/instance/settings/experimental") {
await fulfill(route, {
enableChatConnectors: true,
enableStreamlinedUi: surface !== "Legacy Inbox",
});
return;
}
if (pathname === `/api/companies/${seed.companyId}/heartbeat-runs`) {
await fulfill(route, [run]);
return;
}
if (pathname === `/api/heartbeat-runs/${failedRunId}`) {
await fulfill(route, run);
return;
}
if (
pathname === `/api/heartbeat-runs/${failedRunId}/events` ||
pathname ===
`/api/heartbeat-runs/${failedRunId}/workspace-operations` ||
pathname === `/api/companies/${seed.companyId}/provider-traces`
) {
await fulfill(route, []);
return;
}
if (pathname === `/api/heartbeat-runs/${failedRunId}/issues`) {
await fulfill(route, [
{
issueId: issue.id,
identifier: issue.identifier,
title: issue.title,
status: "backlog",
priority: "medium",
},
]);
return;
}
if (pathname === `/api/heartbeat-runs/${failedRunId}/log`) {
await fulfill(route, {
runId: failedRunId,
content: "",
nextOffset: 0,
});
return;
}
const retryPath = usesTaskRetry
? `/api/issues/${issue.id}/retry`
: `/api/agents/${seed.agentId}/wakeup`;
if (pathname === retryPath) {
expect(route.request().method()).toBe("POST");
requests.push({
companyId: url.searchParams.get("companyId"),
body: bodyOf(route),
});
if (outcome === "denied") {
await fulfill(
route,
{
error: denial,
details: { code: "chat_failed_run_retry_source_denied" },
},
409,
);
} else {
// The durable action exists, but no run has been admitted yet.
await fulfill(
route,
usesTaskRetry
? {
requestId: actionId,
accepted: true,
stale: false,
observedRunId: failedRunId,
presentation: {
intentId: actionId,
responsibility: "system",
state: outcome === "queued" ? "working" : "retry_scheduled",
nextRetryAt: null,
canRetry: false,
userAction: null,
sourceRunIds: [failedRunId],
observedRunId: failedRunId,
},
}
: { actionId, issueId: issue.id, runId: null, status: outcome },
202,
);
}
return;
}
await route.continue();
});
const startPath =
surface === "agent run"
? `/${seed.prefix}/agents/maya/runs/${failedRunId}`
: `/${seed.prefix}/inbox/all`;
const canonicalAgentResponse =
surface === "agent run"
? page.waitForResponse((response) => {
const url = new URL(response.url());
return (
response.request().method() === "GET" &&
url.pathname === "/api/agents/maya" &&
url.searchParams.get("companyId") === seed.companyId
);
})
: null;
await page.goto(startPath);
if (canonicalAgentResponse) {
const response = await canonicalAgentResponse;
expect(response.ok()).toBe(true);
expect(await response.json()).toMatchObject({
id: seed.agentId,
companyId: seed.companyId,
urlKey: "maya",
});
await expect(page).toHaveURL(
new RegExp(`/${seed.prefix}/agents/maya/runs/${failedRunId}$`),
);
}
const retry = page
.getByRole("button", { name: "Retry", exact: true })
.filter({ visible: true });
await expect(retry).toHaveCount(1);
await retry.click();
await expect.poll(() => requests.length).toBe(1);
expect(requests[0]).toEqual(usesTaskRetry
? {
companyId: null,
body: {
idempotencyKey: expect.stringMatching(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
),
observedRunId: failedRunId,
},
}
: {
companyId: seed.companyId,
body: {
source: "on_demand",
triggerDetail: "manual",
reason: "retry_failed_run",
failedRunId,
},
});
if (outcome === "denied") {
await expect(page.getByText(denial, { exact: true })).toBeVisible();
if (surface !== "agent run") {
await expect(
page.getByText("Run retry failed", { exact: true }),
).toBeVisible();
const toast = page.getByRole("listitem").filter({
has: page.getByText("Run retry failed", { exact: true }),
});
// Visibility alone accepts opacity:0 during the toast entrance.
// The operator must actually be able to read the denial.
await expect(toast).toHaveCSS("opacity", "1");
await expect(toast).toBeInViewport();
}
// Agent routes canonicalize the UUID to its human-readable URL key.
// The selected failed run must remain unchanged across that redirect.
await expect(page).toHaveURL(
surface === "agent run"
? new RegExp(
`/${seed.prefix}/agents/(${seed.agentId}|maya)/runs/${failedRunId}$`,
)
: surface === "Coordinator Tasks"
? new RegExp(`/${seed.prefix}/issues\\?view=everything$`)
: new RegExp(`${startPath}$`),
);
await expect(retry).toBeEnabled();
await testInfo.attach(`${surface}-retry-denied`, {
body: await page.screenshot(),
contentType: "image/png",
});
} else {
await expect(page).toHaveURL(
new RegExp(
`/${seed.prefix}/issues/(${issue.id}|${issue.identifier})$`,
),
);
await expect(
page.getByText(issue.title, { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText("Run retry failed", { exact: true }),
).toHaveCount(0);
}
expect(requests).toHaveLength(1);
expect(
destinations.some((url) =>
/\/runs\/(null|undefined)(?:[/?#]|$)/.test(url),
),
).toBe(false);
});
}
}
});