mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Tasks let agents ask a person for input. > - The reply composer needs a useful default recipient. > - A recent comment does not identify the agent that assigned the task. > - The old fallback can select the person who is writing the reply. > - This change uses the latest agent assignment and keeps manual choices. ## Linked Issues or Issue Description **What happened?** When an agent assigns a task to a person, the reply composer can select that person or a different commenter. A data refresh can also replace a manual selection. **Expected behavior** For a task assigned to the current person, select the latest agent that assigned it to them. If no such assignment exists, use the latest other commenter. If neither exists, leave the recipient empty. Keep explicit choices during refreshes. **Steps to reproduce** 1. Have agent Alpha assign a task to a person. 2. Assign it to agent Beta, then have Beta assign it to the same person. 3. Open the task as that person. 4. Check that the reply composer selects Beta. 5. Select another recipient. Refresh task data. Check that the selection stays. Related work: #15091 shares task composer controls. This change fixes recipient defaults. ## What Changed - Read real assignment changes from REST and native runner task activity, in time order. - Record human assignee fields in native create and reassign activity. - Ignore unchanged assignment fields and assignments to other people. - Use the assigning agent before comment hints for human-owned tasks. - Connect the suggestion to current and classic task composers and pipeline replies. - Keep manual selections when suggestions change. - Add regression tests and document the fallback. - Use tasks with no assignee in the attachment receipt tests. This keeps the tests on the comment route. Dismiss announcements through the real UI. - Record human and agent assignees in REST creation receipts. Include child creation in the assignment history resolver. ## Verification - Focused tests cover assignment order, creation, other recipients, unchanged fields, fallback, and manual selections. - Browser fixture uses the real composer and assignment resolver. It selects Beta and keeps a manual human selection after history refresh. - Passed: 223 focused tests across four suites. - Passed: `pnpm --filter @paperclipai/ui typecheck`. - Passed: `pnpm --filter @paperclipai/ui build`. - Passed: `pnpm check:token-gates`. - The initial CI run passed full build, typecheck, Rust, unit tests and seven E2E shards. The attachment shard exposed tests that relied on the old self-selection default. The tests now select the recipient explicitly. - Native assignment regression tests and resolver tests pass: 40 tests. - Attachment E2E suite: 10 passed; one page-load timing failure passed on an isolated rerun. All 11 cases passed across the suite and rerun. - Previous full CI passed atcc7ed05. New CI must pass on the current head. - Latest local checks: 16 resolver tests and 7 workspace policy tests pass. REST creation audit tests cover both creation routes. - The latest browser check could not start. The UI build exceeded the local memory limit (exit 137). Full typecheck, tests, build, and browser checks will run in CI. ## Risks - Old activity rows without previous assignment values cannot prove a reassignment. They use the comment fallback. - An empty fallback requires the person to choose a recipient or use the existing no-assignee flow. - No database or API schema changes. ## Model Used OpenAI Codex, GPT-6. The session does not expose an exact model build or context window. Used code editing, shell tools, tests, and browser verification. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Final validation atf83f2f6: 55 checks passed. Two optional Storybook checks were skipped. All browser shards passed. Both review threads are resolved. Classic attachment tests use the real no-assignee confirmation.
470 lines
17 KiB
TypeScript
470 lines
17 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import {
|
|
expect,
|
|
test,
|
|
type APIRequestContext,
|
|
type Locator,
|
|
type Page,
|
|
} from "@playwright/test";
|
|
|
|
// Real local Board UI, upload storage, comment HTTP routes, and disposable DB.
|
|
// Only the interface flag and explicit transport/upload rejection faults are mocked.
|
|
// Tasks have no recipient: no agent, runner, or provider is contacted.
|
|
type Attachment = {
|
|
id: string;
|
|
issueCommentId: string | null;
|
|
contentPath: string;
|
|
};
|
|
type Comment = { id: string; body: string; clientRequestId?: string | null };
|
|
|
|
async function body<T>(
|
|
response: Awaited<ReturnType<APIRequestContext["get"]>>,
|
|
): Promise<T> {
|
|
expect(response.ok(), `${response.status()}: ${await response.text()}`).toBe(
|
|
true,
|
|
);
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
async function setup(page: Page, request: APIRequestContext, classic: boolean) {
|
|
// Announcements can appear after navigation or reload and cover the classic
|
|
// composer's attachment button. Dismiss through the same UI as a Board user.
|
|
await page.addLocatorHandler(
|
|
page.getByRole("complementary", { name: "Paperclip announcements", exact: true })
|
|
.getByRole("button", { name: "Dismiss announcement", exact: true }),
|
|
async (dismiss) => { await dismiss.click(); },
|
|
);
|
|
const company = await body<{ id: string; issuePrefix: string }>(
|
|
await request.post("/api/companies", {
|
|
data: { name: `Board receipt browser ${randomUUID()}` },
|
|
}),
|
|
);
|
|
const issue = await body<{ id: string; identifier: string }>(
|
|
await request.post(`/api/companies/${company.id}/issues`, {
|
|
data: {
|
|
title: "Inspect the newly uploaded Board files",
|
|
status: "todo",
|
|
// Keep attachment sends on the comment route. A task assigned to the
|
|
// current human now suggests an empty recipient and sends a reassignment.
|
|
assigneeUserId: null,
|
|
},
|
|
}),
|
|
);
|
|
await page.route("**/api/instance/settings/experimental", (route) =>
|
|
route.fulfill({
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ enableClassicTaskInterface: classic }),
|
|
}),
|
|
);
|
|
await page.goto(`/${company.issuePrefix}/issues/${issue.identifier}`);
|
|
await expect(
|
|
page.getByRole("heading", {
|
|
name: "Inspect the newly uploaded Board files",
|
|
exact: true,
|
|
}),
|
|
).toBeVisible();
|
|
const composer = classic
|
|
? page.getByTestId("issue-chat-composer")
|
|
: page.locator(".paperclip-task-chat-composer");
|
|
const editor = composer.getByRole("textbox", {
|
|
name: "editable markdown",
|
|
exact: true,
|
|
});
|
|
const send = classic
|
|
? composer.getByRole("button", { name: /Send|Reply/ }).last()
|
|
: page.getByTestId("task-chat-composer-send");
|
|
await expect(editor).toBeVisible();
|
|
return {
|
|
company,
|
|
issue,
|
|
composer,
|
|
editor,
|
|
send,
|
|
sendComment: async () => {
|
|
await send.click();
|
|
if (classic) {
|
|
const confirm = page.getByTestId("issue-chat-no-assignee-send-anyway");
|
|
await expect(confirm).toBeVisible();
|
|
await confirm.click();
|
|
}
|
|
},
|
|
attachments: () =>
|
|
request
|
|
.get(`/api/issues/${issue.id}/attachments`)
|
|
.then(body<Attachment[]>),
|
|
comments: () =>
|
|
request.get(`/api/issues/${issue.id}/comments`).then(body<Comment[]>),
|
|
};
|
|
}
|
|
|
|
const files = [
|
|
{
|
|
name: "board-fresh.txt",
|
|
mimeType: "text/plain",
|
|
buffer: Buffer.from("Object: cat\nAccent: teal\nCount: 3\n"),
|
|
},
|
|
{
|
|
name: "board-fresh.png",
|
|
mimeType: "image/png",
|
|
buffer: Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jfFoAAAAASUVORK5CYII=",
|
|
"base64",
|
|
),
|
|
},
|
|
];
|
|
|
|
async function openAttachmentChooser(page: Page, composer: Locator) {
|
|
await composer.getByRole("button", { name: "Add to composer" }).click();
|
|
const chooser = page.waitForEvent("filechooser");
|
|
await page.getByRole("menuitem", { name: "Files and images", exact: true }).click();
|
|
return chooser;
|
|
}
|
|
|
|
async function upload(
|
|
page: Page,
|
|
fixture: Awaited<ReturnType<typeof setup>>,
|
|
file: (typeof files)[number],
|
|
) {
|
|
const chooser = await openAttachmentChooser(page, fixture.composer);
|
|
const response = page.waitForResponse(
|
|
(res) =>
|
|
res.request().method() === "POST" &&
|
|
new URL(res.url()).pathname.endsWith(
|
|
`/issues/${fixture.issue.id}/attachments`,
|
|
),
|
|
);
|
|
await chooser.setFiles(file);
|
|
const receipt = await body<Attachment>(await response);
|
|
await expect
|
|
.poll(async () =>
|
|
(await fixture.attachments()).some((item) => item.id === receipt.id),
|
|
)
|
|
.toBe(true);
|
|
return receipt;
|
|
}
|
|
|
|
for (const classic of [false, true]) {
|
|
test(`uploaded file and image receipts survive reload and bind exactly once (classic=${classic})`, async ({
|
|
page,
|
|
request,
|
|
}, testInfo) => {
|
|
const fixture = await setup(page, request, classic);
|
|
await fixture.editor.fill(
|
|
"Inspect only these fresh Board attachments. Keep this internal.",
|
|
);
|
|
const receipts = [];
|
|
for (const file of files) receipts.push(await upload(page, fixture, file));
|
|
const draftKey = `paperclip:issue-comment-draft:${fixture.issue.id}`;
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
(key) =>
|
|
JSON.parse(localStorage.getItem(`${key}:attachments:v1`) ?? "null")
|
|
?.attachments?.length,
|
|
draftKey,
|
|
),
|
|
)
|
|
.toBe(2);
|
|
await expect
|
|
.poll(() => page.evaluate((key) => localStorage.getItem(key), draftKey))
|
|
.toContain("Inspect only these fresh Board attachments.");
|
|
await testInfo.attach("before-reload-draft", {
|
|
body: JSON.stringify(
|
|
await page.evaluate(
|
|
(key) => ({
|
|
text: localStorage.getItem(key),
|
|
receipts: localStorage.getItem(`${key}:attachments:v1`),
|
|
}),
|
|
draftKey,
|
|
),
|
|
),
|
|
contentType: "application/json",
|
|
});
|
|
await page.reload();
|
|
await expect(fixture.editor).toContainText(
|
|
"Inspect only these fresh Board attachments.",
|
|
);
|
|
await expect(
|
|
fixture.composer.getByText("board-fresh.txt", { exact: true }),
|
|
).toBeVisible();
|
|
await expect(fixture.composer.locator("img")).toHaveCount(1);
|
|
const outbound = page.waitForRequest(
|
|
(req) =>
|
|
req.method() === "POST" &&
|
|
new URL(req.url()).pathname.endsWith("/comments"),
|
|
);
|
|
await fixture.sendComment();
|
|
expect((await outbound).postDataJSON().attachmentIds.sort()).toEqual(
|
|
receipts.map((row) => row.id).sort(),
|
|
);
|
|
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
|
|
const [comment] = await fixture.comments();
|
|
const savedBubble = classic
|
|
? page.locator(`[id="comment-${comment!.id}"]`)
|
|
: page.getByTestId("task-chat-human-bubble").filter({
|
|
hasText:
|
|
"Inspect only these fresh Board attachments. Keep this internal.",
|
|
});
|
|
await expect(savedBubble).toHaveCount(1);
|
|
expect(comment.body).toContain(receipts[0]!.contentPath);
|
|
expect(comment.body).toContain(receipts[1]!.contentPath);
|
|
expect(
|
|
(await fixture.attachments()).map((item) => item.issueCommentId),
|
|
).toEqual([comment.id, comment.id]);
|
|
for (let index = 0; index < receipts.length; index++) {
|
|
const downloaded = await request.get(receipts[index]!.contentPath);
|
|
expect(downloaded.ok()).toBe(true);
|
|
expect(await downloaded.body()).toEqual(files[index]!.buffer);
|
|
}
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
(key) => localStorage.getItem(`${key}:attachments:v1`),
|
|
draftKey,
|
|
),
|
|
)
|
|
.toBeNull();
|
|
await page.reload();
|
|
await expect(fixture.editor).toBeEmpty();
|
|
expect(await fixture.comments()).toHaveLength(1);
|
|
await expect(savedBubble).toHaveCount(1);
|
|
await page.screenshot({
|
|
path: testInfo.outputPath("bound-board-attachments.png"),
|
|
fullPage: true,
|
|
});
|
|
});
|
|
|
|
test(`lost accepted response settles from its exact receipt without replay (classic=${classic})`, async ({
|
|
page,
|
|
request,
|
|
}, testInfo) => {
|
|
const fixture = await setup(page, request, classic);
|
|
await fixture.editor.fill("Accepted once: inspect this exact file.");
|
|
const receipt = await upload(page, fixture, files[0]!);
|
|
let accepted = false;
|
|
let attempts = 0;
|
|
let acceptedRequestId: string | undefined;
|
|
await page.route("**/api/issues/*/comments", async (route) => {
|
|
if (route.request().method() !== "POST") return route.continue();
|
|
attempts++;
|
|
if (accepted) return route.continue();
|
|
acceptedRequestId = route.request().postDataJSON().clientRequestId;
|
|
const response = await route.fetch();
|
|
expect(response.status()).toBe(201);
|
|
accepted = true;
|
|
await route.abort("connectionreset");
|
|
});
|
|
await fixture.sendComment();
|
|
await expect.poll(() => accepted).toBe(true);
|
|
expect(await fixture.comments()).toHaveLength(1);
|
|
expect(acceptedRequestId).toEqual(expect.any(String));
|
|
expect((await fixture.comments())[0]!.clientRequestId).toBe(acceptedRequestId);
|
|
expect(
|
|
(await fixture.attachments()).find((row) => row.id === receipt.id)
|
|
?.issueCommentId,
|
|
).toBeTruthy();
|
|
await page.reload();
|
|
// The durable request receipt proves delivery even though the POST reply
|
|
// was lost. No manual Review/Discard bookkeeping or blind replay remains.
|
|
await expect(fixture.editor).toBeEmpty();
|
|
await expect(fixture.composer.getByRole("alert")).toHaveCount(0);
|
|
await expect(fixture.composer.getByText("board-fresh.txt", { exact: true })).toHaveCount(0);
|
|
await expect(fixture.send).toBeDisabled();
|
|
await fixture.editor.press("Control+Enter");
|
|
expect(attempts).toBe(1);
|
|
expect(await fixture.comments()).toHaveLength(1);
|
|
await page.screenshot({
|
|
path: testInfo.outputPath("accepted-response-lost.png"),
|
|
fullPage: true,
|
|
});
|
|
expect(
|
|
(await fixture.attachments()).find((row) => row.id === receipt.id)
|
|
?.issueCommentId,
|
|
).toBeTruthy();
|
|
await page.reload();
|
|
await expect(fixture.composer.getByRole("alert")).toHaveCount(0);
|
|
await fixture.editor.fill(
|
|
"A deliberately new comment after the original receipt settled.",
|
|
);
|
|
await fixture.sendComment();
|
|
await expect.poll(async () => (await fixture.comments()).length).toBe(2);
|
|
});
|
|
|
|
test(`known rejection retains the same uploaded receipt for explicit retry (classic=${classic})`, async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
const fixture = await setup(page, request, classic);
|
|
await fixture.editor.fill("Known rejection, then retry the same file.");
|
|
const receipt = await upload(page, fixture, files[0]!);
|
|
let rejected = false;
|
|
await page.route("**/api/issues/*/comments", async (route) => {
|
|
if (route.request().method() !== "POST" || rejected)
|
|
return route.continue();
|
|
rejected = true;
|
|
await route.fulfill({
|
|
status: 422,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ error: "Fixture policy rejected this attempt" }),
|
|
});
|
|
});
|
|
await fixture.sendComment();
|
|
await expect(fixture.editor).toContainText("Known rejection,");
|
|
await expect(fixture.send).toBeEnabled();
|
|
expect(await fixture.comments()).toHaveLength(0);
|
|
await page.reload();
|
|
await expect(fixture.editor).toContainText("Known rejection,");
|
|
await expect(fixture.composer.getByRole("alert")).toHaveCount(0);
|
|
await fixture.sendComment();
|
|
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
|
|
expect(
|
|
(await fixture.attachments()).find((row) => row.id === receipt.id)
|
|
?.issueCommentId,
|
|
).toBeTruthy();
|
|
});
|
|
|
|
test(`reload during a pending save settles its receipt and preserves a newer draft (classic=${classic})`, async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
const fixture = await setup(page, request, classic);
|
|
await fixture.editor.fill("One text-only save interrupted by reload.");
|
|
let release!: () => void;
|
|
const held = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
let accepted = false;
|
|
let attempts = 0;
|
|
let acceptedRequestId: string | undefined;
|
|
await page.route("**/api/issues/*/comments", async (route) => {
|
|
if (route.request().method() !== "POST") return route.continue();
|
|
attempts++;
|
|
acceptedRequestId ??= route.request().postDataJSON().clientRequestId;
|
|
const response = await route.fetch();
|
|
expect(response.status()).toBe(201);
|
|
accepted = true;
|
|
await held;
|
|
// Reload cancels the original browser request; the owned server response
|
|
// has already been observed and must never be replayed by the fixture.
|
|
await route.fulfill({ response }).catch(() => {});
|
|
});
|
|
try {
|
|
await fixture.sendComment();
|
|
await expect.poll(() => accepted).toBe(true);
|
|
expect(await fixture.comments()).toHaveLength(1);
|
|
await fixture.editor.fill("A newer draft written while delivery was pending.");
|
|
await page.reload();
|
|
release();
|
|
await expect(fixture.editor).toHaveText(
|
|
"A newer draft written while delivery was pending.",
|
|
);
|
|
await expect(fixture.composer.getByRole("alert")).toHaveCount(0);
|
|
await expect(fixture.send).toBeEnabled();
|
|
expect(attempts).toBe(1);
|
|
expect(await fixture.comments()).toHaveLength(1);
|
|
expect(acceptedRequestId).toEqual(expect.any(String));
|
|
expect((await fixture.comments())[0]!.clientRequestId).toBe(acceptedRequestId);
|
|
await fixture.sendComment();
|
|
await expect.poll(async () => (await fixture.comments()).length).toBe(2);
|
|
expect(attempts).toBe(2);
|
|
expect((await fixture.comments()).map((comment) => comment.body).sort()).toEqual([
|
|
"A newer draft written while delivery was pending.",
|
|
"One text-only save interrupted by reload.",
|
|
]);
|
|
} finally {
|
|
release();
|
|
}
|
|
});
|
|
}
|
|
|
|
for (const classic of [false, true])
|
|
test(`removed pending inline upload cannot reappear or submit after its HTTP response (classic=${classic})`, async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
const fixture = await setup(page, request, classic);
|
|
await fixture.editor.fill("Send without the removed image.");
|
|
let release!: () => void;
|
|
const held = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
let arrived = false;
|
|
await page.route(
|
|
`**/api/companies/${fixture.company.id}/issues/${fixture.issue.id}/attachments`,
|
|
async (route) => {
|
|
const response = await route.fetch();
|
|
arrived = true;
|
|
await held;
|
|
await route.fulfill({ response });
|
|
},
|
|
);
|
|
const chooser = await openAttachmentChooser(page, fixture.composer);
|
|
await chooser.setFiles(files[1]!);
|
|
await expect.poll(() => arrived).toBe(true);
|
|
await expect(fixture.send).toBeDisabled();
|
|
try {
|
|
await expect(
|
|
fixture.composer.getByText(
|
|
classic ? "Uploading to task" : "Uploading…",
|
|
{ exact: true },
|
|
),
|
|
).toBeVisible();
|
|
const remove = fixture.composer.getByRole("button", {
|
|
name: "Remove board-fresh.png",
|
|
});
|
|
await expect(remove).toBeVisible();
|
|
await remove.click();
|
|
release();
|
|
await expect(fixture.send).toBeEnabled();
|
|
await expect(fixture.composer.locator("img")).toHaveCount(0);
|
|
const outbound = page.waitForRequest(
|
|
(req) =>
|
|
req.method() === "POST" &&
|
|
new URL(req.url()).pathname.endsWith("/comments"),
|
|
);
|
|
await fixture.sendComment();
|
|
expect((await outbound).postDataJSON().attachmentIds).toBeUndefined();
|
|
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
|
|
expect((await fixture.comments())[0]!.body).not.toContain(
|
|
"/api/attachments/",
|
|
);
|
|
expect((await fixture.attachments())[0]!.issueCommentId).toBeNull();
|
|
} finally {
|
|
release();
|
|
}
|
|
});
|
|
|
|
test("legacy failed upload can be removed before sending the retained text", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
const fixture = await setup(page, request, true);
|
|
await fixture.editor.fill("Keep this text after removing the failed file.");
|
|
await page.route(
|
|
`**/api/companies/${fixture.company.id}/issues/${fixture.issue.id}/attachments`,
|
|
(route) =>
|
|
route.fulfill({
|
|
status: 500,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ error: "Fixture upload rejected" }),
|
|
}),
|
|
);
|
|
const chooser = await openAttachmentChooser(page, fixture.composer);
|
|
await chooser.setFiles(files[0]!);
|
|
await expect(
|
|
fixture.composer.getByText("Fixture upload rejected", { exact: true }),
|
|
).toBeVisible();
|
|
await expect(fixture.send).toBeDisabled();
|
|
const remove = fixture.composer.getByRole("button", {
|
|
name: "Remove board-fresh.txt",
|
|
});
|
|
await expect(remove).toBeVisible();
|
|
await remove.click();
|
|
await fixture.sendComment();
|
|
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
|
|
expect((await fixture.comments())[0]!.body).toBe(
|
|
"Keep this text after removing the failed file.",
|
|
);
|
|
expect(await fixture.attachments()).toHaveLength(0);
|
|
});
|