mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 23:43:16 +01:00
Merge master into qualified test lifecycle fixes
Preserve master retention I/O settlement and held-I/O coverage alongside the controlled fairness clock and exact run completion barriers. Retain the delegated-completion fixture without changing production code. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
ab0b15d234
465 files changed
+33126
-1543
No files matched your search
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { idle, json, send, setup } from "./agent-chat.shared";
|
||||
|
||||
test.use({ trace: "retain-on-failure" });
|
||||
test.setTimeout(120_000);
|
||||
|
||||
test("a routine delivers a first digest, unread state, source link and replyable history", async ({ page, request }) => {
|
||||
const f = await setup(request);
|
||||
try {
|
||||
await page.goto(`/${f.company.issuePrefix}/chats/${f.agents[1].id}`);
|
||||
await expect(page.getByTestId("task-chat-composer-input")).toBeVisible();
|
||||
const routine = await json(await request.post(`/api/companies/${f.company.id}/routines`, { data: {
|
||||
title: "Morning PR review", description: "proactive-digest-fixture", assigneeAgentId: f.agent.id,
|
||||
} }));
|
||||
await json(await request.post(`/api/routines/${routine.id}/run`, { data: { idempotencyKey: "browser-morning" } }));
|
||||
await expect(page.getByRole("img", { name: "Unread messages from Alpha", exact: true })).toBeVisible({ timeout: 45_000 });
|
||||
await expect(page.getByLabel(/unread chats/)).toBeVisible();
|
||||
let chat: any;
|
||||
await expect.poll(async () => {
|
||||
chat = await json(await request.get(f.chatPath));
|
||||
return chat?.isUnreadForMe;
|
||||
}).toBe(true);
|
||||
const comments = await json(await request.get(`/api/issues/${chat.id}/comments`));
|
||||
const digest = comments.find((c: any) => c.metadata?.chatMessage);
|
||||
expect(digest.authorAgentId).toBe(f.agent.id);
|
||||
const sourceId = digest.metadata.chatMessage.sourceTaskId;
|
||||
const source = await json(await request.get(`/api/issues/${sourceId}`));
|
||||
expect(source.originKind).toBe("routine_execution");
|
||||
expect(await json(await request.get(`/api/issues/${chat.id}/runs`))).toEqual([]);
|
||||
await page.reload();
|
||||
await expect(page.getByRole("img", { name: "Unread messages from Alpha", exact: true })).toBeVisible();
|
||||
await page.goto(f.route);
|
||||
await expect(page.getByText("Morning PR digest", { exact: true })).toBeVisible();
|
||||
const sourceLink = page.getByRole("link", { name: `From ${source.identifier}`, exact: true });
|
||||
await expect(sourceLink).toBeVisible();
|
||||
await expect.poll(async () => (await json(await request.get(f.chatPath))).isUnreadForMe).toBe(false);
|
||||
await sourceLink.click();
|
||||
await expect(page).toHaveURL(new RegExp(`/issues/${source.identifier}`));
|
||||
await page.goto(f.route);
|
||||
await send(page, { action: "digest-followup", text: "Which was the first PR?" });
|
||||
await idle(request, f.chatPath, 2);
|
||||
await expect(page.getByText("The first PR in your morning digest is PR 123.", { exact: true })).toBeVisible({ timeout: 45_000 });
|
||||
} finally { await f.restore(); }
|
||||
});
|
||||
@@ -264,7 +264,7 @@ test("secondary chat navigation preserves layout, unique conversations, history,
|
||||
data: { enableStreamlinedUi: true },
|
||||
}));
|
||||
await page.goto(`/${f.company.issuePrefix}/dashboard`);
|
||||
await page.getByRole("link", { name: "Chat", exact: true }).click();
|
||||
await page.getByRole("link", { name: /^Chat(?:\s|$)/ }).click();
|
||||
// With no recent conversation, Chat opens the first human-created primary.
|
||||
// Navigation alone must not create a conversation or start execution.
|
||||
expect(await json(await request.get(`/api/companies/${f.company.id}/primary-agent/me`))).toMatchObject({
|
||||
@@ -333,7 +333,7 @@ test("secondary chat navigation preserves layout, unique conversations, history,
|
||||
await expect(editor).toContainText("Unsent draft for Alpha");
|
||||
await expect(nav.getByRole("link")).toHaveCount(6);
|
||||
|
||||
await page.getByRole("link", { name: "Chat", exact: true }).click();
|
||||
await page.getByRole("link", { name: /^Chat(?:\s|$)/ }).click();
|
||||
await expect(page.getByRole("link", { name: "Configure Alpha", exact: true })).toBeVisible();
|
||||
await expect(editor).toContainText("Unsent draft for Alpha");
|
||||
expect(await sidebar.boundingBox()).toEqual(landingBounds);
|
||||
@@ -346,7 +346,7 @@ test("secondary chat navigation preserves layout, unique conversations, history,
|
||||
},
|
||||
}));
|
||||
await idle(request, betaPath);
|
||||
await page.getByRole("link", { name: "Chat", exact: true }).click();
|
||||
await page.getByRole("link", { name: /^Chat(?:\s|$)/ }).click();
|
||||
await expect(page.getByRole("link", { name: "Configure Alpha", exact: true })).toBeVisible();
|
||||
await compose.click();
|
||||
await picker.getByRole("combobox").fill("Beta");
|
||||
|
||||
@@ -56,7 +56,9 @@ for (const mobile of [false, true]) {
|
||||
else await expect(artifacts).toHaveAttribute("aria-selected", "false");
|
||||
await artifacts.click();
|
||||
await expectTaskPanelTab(panel, mobile, "Artifacts");
|
||||
await expect(panel.getByRole("heading", { name: "Arriving report", level: 2, exact: true })).toBeVisible();
|
||||
const document = panel.locator("#document-report");
|
||||
await expect(document.getByRole("heading", { name: "Arriving report", level: 1, exact: true })).toBeVisible();
|
||||
await expect(document.locator("p").filter({ hasText: /^Durable output\.$/ })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("artifact-opened-by-user.png"), fullPage: true });
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { expect, test, type APIRequestContext, type APIResponse, type Locator, type Page } from "@playwright/test";
|
||||
import { expectTaskPanelTab } from "./helpers/task-panel-tabs";
|
||||
|
||||
@@ -52,18 +53,26 @@ async function assertStillPaused(request: APIRequestContext, issueId: string) {
|
||||
for (const mobile of [false, true]) {
|
||||
test(`upload documents and files while a task remains paused (mobile=${mobile})`, async ({ page, request }, testInfo) => {
|
||||
const { issue, panel } = await setup(page, request, mobile);
|
||||
const markdown = "# Engineering handoff\n\nStart with the loop inventory and preserve current task ownership.";
|
||||
const csv = "path,pattern\ndelivery,event-driven\nroutines,clock-driven";
|
||||
await chooseFiles(page, panel, [
|
||||
{ name: "core-handoff.md", mimeType: "text/markdown", buffer: Buffer.from("# Engineering handoff\n\nStart with the loop inventory and preserve current task ownership.") },
|
||||
{ name: "loop-inventory.csv", mimeType: "text/csv", buffer: Buffer.from("path,pattern\ndelivery,event-driven\nroutines,clock-driven") },
|
||||
{ name: "core-handoff.md", mimeType: "text/markdown", buffer: Buffer.from(markdown) },
|
||||
{ name: "loop-inventory.csv", mimeType: "text/csv", buffer: Buffer.from(csv) },
|
||||
]);
|
||||
await expect(panel.getByRole("button", { name: "Read document", exact: true })).toBeVisible();
|
||||
const document = panel.locator("#document-core-handoff");
|
||||
await expect(document.getByRole("heading", { name: "Engineering handoff", exact: true })).toBeVisible();
|
||||
await expect(document.getByText("Start with the loop inventory and preserve current task ownership.", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByRole("link", { name: "Download file", exact: true })).toBeVisible();
|
||||
await expectTaskPanelTab(panel, mobile, "Artifacts");
|
||||
const download = page.waitForEvent("download");
|
||||
await panel.getByRole("link", { name: "Download file", exact: true }).click();
|
||||
expect((await download).suggestedFilename()).toBe("loop-inventory.csv");
|
||||
await panel.getByRole("button", { name: "Read document", exact: true }).click();
|
||||
await expect(panel.getByText("Start with the loop inventory and preserve current task ownership.", { exact: true })).toBeVisible();
|
||||
const downloaded = await download;
|
||||
expect(downloaded.suggestedFilename()).toBe("loop-inventory.csv");
|
||||
expect(await readFile((await downloaded.path())!, "utf8")).toBe(csv);
|
||||
await document.getByRole("button", { name: "Collapse core-handoff document", exact: true }).click();
|
||||
await expect(document.getByText("Start with the loop inventory and preserve current task ownership.", { exact: true })).toBeHidden();
|
||||
await document.getByRole("button", { name: "Expand core-handoff document", exact: true }).click();
|
||||
await expect(document.getByText("Start with the loop inventory and preserve current task ownership.", { exact: true })).toBeVisible();
|
||||
await assertStillPaused(request, issue.id);
|
||||
await page.reload();
|
||||
await expect(page.getByRole("heading", { name: issue.title, exact: true })).toBeVisible();
|
||||
@@ -71,8 +80,15 @@ for (const mobile of [false, true]) {
|
||||
const attachments = await json(await request.get(`/api/issues/${issue.id}/attachments`));
|
||||
expect(documents).toHaveLength(1);
|
||||
expect(documents[0].key).toBe("core-handoff");
|
||||
expect(documents[0].body).toBe(markdown);
|
||||
expect(attachments).toHaveLength(1);
|
||||
expect(attachments[0].originalFilename).toBe("loop-inventory.csv");
|
||||
if (mobile) {
|
||||
await page.getByRole("button", { name: "More actions", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Properties", exact: true }).click();
|
||||
}
|
||||
await expect(document.getByRole("heading", { name: "Engineering handoff", exact: true })).toBeVisible();
|
||||
await expect(document.getByText("Start with the loop inventory and preserve current task ownership.", { exact: true })).toBeVisible();
|
||||
await assertStillPaused(request, issue.id);
|
||||
await page.screenshot({ path: testInfo.outputPath("uploaded-handoff-persisted.png"), fullPage: true });
|
||||
});
|
||||
@@ -80,8 +96,11 @@ for (const mobile of [false, true]) {
|
||||
|
||||
test("the entire populated panel accepts a drop and preserves an existing document", async ({ page, request }, testInfo) => {
|
||||
const { issue, panel } = await setup(page, request);
|
||||
await chooseFiles(page, panel, [{ name: "handoff.md", mimeType: "text/markdown", buffer: Buffer.from("# Original handoff\n\nPreserve this version.") }]);
|
||||
await expect(panel.getByRole("button", { name: "Read document", exact: true })).toBeVisible();
|
||||
const original = "# Original handoff\n\nPreserve this version.";
|
||||
await chooseFiles(page, panel, [{ name: "handoff.md", mimeType: "text/markdown", buffer: Buffer.from(original) }]);
|
||||
const document = panel.locator("#document-handoff");
|
||||
await expect(document.getByRole("heading", { name: "Original handoff", exact: true })).toBeVisible();
|
||||
const originalDocument = await json(await request.get(`/api/issues/${issue.id}/documents/handoff`));
|
||||
const transfer = await page.evaluateHandle(() => {
|
||||
const data = new DataTransfer();
|
||||
data.items.add(new File(["# Additional handoff\n\nNew reference, separate document."], "handoff.md", { type: "text/markdown" }));
|
||||
@@ -99,12 +118,17 @@ test("the entire populated panel accepts a drop and preserves an existing docume
|
||||
await header.dispatchEvent("dragleave", { dataTransfer: transfer });
|
||||
await expect(overlay).toBeHidden();
|
||||
await header.dispatchEvent("dragenter", { dataTransfer: transfer });
|
||||
await panel.locator("article").first().dispatchEvent("drop", { dataTransfer: transfer });
|
||||
await expect(panel.getByRole("button", { name: "Read document", exact: true })).toHaveCount(2);
|
||||
await document.dispatchEvent("drop", { dataTransfer: transfer });
|
||||
await expect(panel.locator("#document-handoff, #document-handoff-2")).toHaveCount(2);
|
||||
await expect(document.getByText("Preserve this version.", { exact: true })).toBeVisible();
|
||||
await expect(panel.locator("#document-handoff-2").getByText("New reference, separate document.", { exact: true })).toBeVisible();
|
||||
await expect(panel.getByText("verification.txt", { exact: true }).first()).toBeVisible();
|
||||
const documents = await json(await request.get(`/api/issues/${issue.id}/documents`));
|
||||
expect(documents.map((row: { key: string }) => row.key).sort()).toEqual(["handoff", "handoff-2"]);
|
||||
expect(documents.find((row: { key: string }) => row.key === "handoff").body).toContain("Preserve this version.");
|
||||
const preserved = documents.find((row: { key: string }) => row.key === "handoff");
|
||||
expect(preserved.body).toBe(original);
|
||||
expect(preserved.latestRevisionId).toBe(originalDocument.latestRevisionId);
|
||||
expect(documents.find((row: { key: string }) => row.key === "handoff-2").body).toBe("# Additional handoff\n\nNew reference, separate document.");
|
||||
await assertStillPaused(request, issue.id);
|
||||
});
|
||||
|
||||
@@ -138,7 +162,12 @@ test("reserved filenames create visible documents without replacing internal rev
|
||||
originals.set(key, document.latestRevisionId);
|
||||
}
|
||||
await chooseFiles(page, panel, reserved.map(key => ({ name: `${key}.md`, mimeType: "text/markdown", buffer: Buffer.from(`# Uploaded ${key}\n\nThis reference must stay visible.`) })));
|
||||
await expect(panel.getByRole("button", { name: "Read document", exact: true })).toHaveCount(3);
|
||||
await expect(panel.locator('[id^="document-upload-"]')).toHaveCount(3);
|
||||
for (const key of reserved) {
|
||||
const document = panel.locator(`#document-upload-${key}`);
|
||||
await expect(document.getByRole("heading", { name: `Uploaded ${key}`, exact: true })).toBeVisible();
|
||||
await expect(document.getByText("This reference must stay visible.", { exact: true })).toBeVisible();
|
||||
}
|
||||
const documents = await json(await request.get(`/api/issues/${issue.id}/documents`));
|
||||
expect(documents.filter((row: { key: string }) => row.key.startsWith("upload-")).map((row: { key: string }) => row.key).sort()).toEqual(reserved.map(key => `upload-${key}`).sort());
|
||||
for (const key of reserved) {
|
||||
|
||||
@@ -24,6 +24,14 @@ const task = await api(`/issues/${ctx.issueId}`);
|
||||
const comment = async (body) =>
|
||||
api(`/issues/${task.id}/comments`, "POST", { body });
|
||||
if (!task.conversationAgentId) {
|
||||
if (task.description?.includes("proactive-digest-fixture")) {
|
||||
await api("/agents/me/chat/messages", "POST", {
|
||||
body: "## Morning PR digest\n\nPlease review [PR 123](https://github.com/paperclipai/paperclip/pull/123), [PR 456](https://github.com/paperclipai/paperclip/pull/456), and [PR 789](https://github.com/paperclipai/paperclip/pull/789).",
|
||||
idempotencyKey: "morning-digest",
|
||||
});
|
||||
await api(`/issues/${task.id}`, "PATCH", { status: "done", comment: "Digest delivered." });
|
||||
process.exit(0);
|
||||
}
|
||||
const plan = await api(`/issues/${task.id}/documents/plan`);
|
||||
await api(`/issues/${task.id}/documents/output`, "PUT", {
|
||||
title: "Output",
|
||||
@@ -182,6 +190,10 @@ if (command.action === "hold") {
|
||||
},
|
||||
});
|
||||
await comment("Please choose an audience.");
|
||||
} else if (command.action === "digest-followup") {
|
||||
const digest = comments.find(c => c.metadata?.chatMessage && c.body.includes("Morning PR digest"));
|
||||
if (!digest) throw new Error("Proactive digest is missing from the next human turn's history");
|
||||
await comment("The first PR in your morning digest is PR 123.");
|
||||
} else if (command.action === "history") {
|
||||
for (let index = 0; index < 65; index++)
|
||||
await comment(`History message ${String(index).padStart(2, "0")}`);
|
||||
|
||||
Reference in new issue
Block a user