Files
PaperClipAI/tests/e2e/agent-chat-projects.spec.ts
T
Devin Foley d08abcba15 ci: cut PR wall clock from ~16 to ~6 minutes (#13521)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Every pull request runs the Trusted PR CI workflow before merge
> - The test suites roughly tripled in six weeks, and shard balance did
not keep up, so PR runs crept from ~4 to ~17 minutes
> - Slow CI delays every merge and every contributor
> - This pull request rebalances the shards from fresh measurements,
splits the largest test files, reuses the Rust build cache in three more
jobs, and takes the policy job off the critical path
> - The benefit is a PR wall clock near 6 minutes with the same coverage

## Linked Issues or Issue Description

**What existing behavior does this improve?**

PR CI wall clock. A typical green run took 16-17 minutes. Two months ago
it took about 4 minutes.

**Subsystem affected**

The Trusted PR CI workflow (`.github/workflows/pr-trusted.yml`), the
shard-duration manifests, the vitest shard runner scripts, the
`paperclip-runner` package scripts, and the dry-run branch of
`release.sh`.

**Current behavior**

The shard-duration manifests were stale. The general-server manifest had
durations for ~400 of 649 suites. The e2e manifest was missing 14 of 29
specs. Stale median weights made shard steps range 417s-806s (server)
and 277s-745s (e2e). Three jobs each paid a ~3m40s cold cargo release
build. Every test lane waited ~60s for the policy job before it could
start.

**Proposed behavior**

All lanes finish in a narrow ~200-290s band. The manifests carry fresh
measured durations for every suite. The three largest test files are
split so no single file caps a shard. The Rust cache restore runs in
every job that builds the Runner binary. Test lanes start as soon as the
gate resolves.

**Reason and benefit**

Merges stop waiting on CI. The projected wall clock is ~6 minutes for
the same test coverage.

## What Changed

- Rebuild `scripts/general-server-shard-durations.json` (646 suites) and
`scripts/e2e-shard-durations.json` (all specs) from per-suite completion
timestamps in runs 35036001734 and 35024948947.
- Move the PR server lane to the release-verify shape:
`general-server-without-chat` across twelve duration-balanced shards,
plus the chat integration suite split by collected test location across
three dedicated lanes.
- Split `tests/e2e/chat-adapters-ui.spec.ts` into `-providers` and
`-messaging` specs, and `tests/e2e/agent-chat.spec.ts` into `-sessions`
and `-projects` specs. Each pair shares fixtures through a `.shared.ts`
module. Playwright collects the same test sets (39 and 20 tests).
- Raise e2e shards to eight and serialized shards to nine.
- Run the runner package's `check:all` as four matrix lanes:
`check:static`, `check:runner`, and two native vitest `--shard` halves.
The union is exactly `check:all`.
- Add the read-only Rust cache restore (toolchain pin, `save-if: false`)
to the Canary Dry Run, Build, and Typecheck jobs.
- Make release.sh preview publish payloads concurrently in batches of
eight during `--dry-run`. The real publish path stays strictly serial.
- Drop the policy-job lockfile artifact chain. Each lane installs with
`--frozen-lockfile` and falls back to an inline `--resolution-only`
regeneration. The policy job stays a required check through the `verify`
and `e2e` aggregates.
- Update the shard-count mirrors and workflow assertions in the
partition and gate tests.

## Verification

- `node --test scripts/__tests__/run-vitest-stable-shard.test.mjs
scripts/__tests__/e2e-shard.test.mjs` — 30 pass.
- `node --test '.github/scripts/tests/'*.test.mjs` — 410 pass.
- `node --test scripts/__tests__/release-verify-workflow.test.mjs
scripts/cloud-source-verification.test.mjs
scripts/__tests__/release-dry-run-notes.test.mjs` — 42 pass.
- `playwright test --list` collects 39 tests across the chat-adapters
split and 20 across the agent-chat split, equal to the original files.
- A local vitest collection of the chat suite partitions 995 tests into
498/497 line shards.
- Projected shard weights: server 230s x12, chat ~143s x3, e2e 207-242s
x8, serialized ~216s x9.

## Risks

- The split spec files reorder tests relative to the original files.
Every describe seeds its own company, so the specs stay independent; a
hidden cross-describe dependency would surface as a deterministic
failure in one shard.
- The inline lockfile fallback changes install behavior for
manifest-changing and stacked PRs. The policy job still validates
resolution as a required check.
- `release.sh` changes are confined to the `--dry-run` preview branch.
The publish loop is untouched. `bash -n` passes and the release dry-run
tests pass.
- One PR now schedules ~44 fleet runners. If the RunsOn fleet caps
concurrency, queueing may absorb part of the gain; watch the first runs.

## Model Used

- Claude Fable 5 (`claude-fable-5`, Anthropic), extended thinking, with
tool use (shell, file edits) in Claude Code.

## 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
2026-09-16 11:45:14 -07:00

615 lines
19 KiB
TypeScript

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);
/**
* Agent chat project and content coverage: project cards, split handoff,
* uploads, Ask/Plan modes, repository selection, plan approval, and shared
* history. Shared fixtures live in ./agent-chat.shared.ts; session lifecycle
* flows run in agent-chat-sessions.spec.ts.
*/
for (const direct of [false, true])
test(`project card through ${direct ? "direct API" : "dedicated tool"} persists and deduplicates retries`, async ({
page,
request,
}) => {
const f = await setup(request);
try {
await page.goto(f.route);
await send(page, {
action: "project",
name: "Browser repositories",
direct,
urls: [
"https://github.com/octocat/Hello-World.git",
"https://github.com/octocat/Spoon-Knife",
"https://github.com/octocat/Hello-World",
],
});
await idle(request, f.chatPath);
const card = page.getByRole("article", {
name: "Project created: Browser repositories",
});
await expect(card).toHaveCount(1);
await expect(
card.getByRole("link", { name: "octocat/Hello-World" }),
).toHaveAttribute("href", "https://github.com/octocat/Hello-World");
await expect(
card.getByRole("link", { name: "octocat/Spoon-Knife" }),
).toBeVisible();
const projects = await json(
await request.get(`/api/companies/${f.company.id}/projects`),
);
expect(projects).toHaveLength(1);
expect(projects[0].workspaces).toHaveLength(2);
if (direct) {
await json(await request.post(`/api/projects/${projects[0].id}/workspaces`, {
data: { name: "Additional repository", repoUrl: "https://github.com/octocat/git-consortium" },
}));
await expect(card.getByRole("link", { name: "Additional repository" }))
.toHaveAttribute("href", "https://github.com/octocat/git-consortium");
await expect(card).toHaveCount(1);
}
await send(page, "/new");
await expect
.poll(
async () =>
(await json(await request.get(f.chatPath)))
.conversationSessionGeneration,
)
.toBe(1);
await page.reload();
await expect(card).toHaveCount(1);
if (direct) await expect(card.getByRole("link", { name: "Additional repository" })).toBeVisible();
await card
.getByRole("link", { name: "Browser repositories", exact: true })
.click();
await expect(page).toHaveURL(/\/projects\/.*\/issues/);
await page
.getByRole("tab", { name: "Configuration", exact: true })
.click();
await expect(
page.getByRole("region", { name: "Repositories" }),
).toContainText("octocat/Hello-World");
} finally {
await f.restore();
}
});
test("split handoff commits relevant plans before execution and never creates chat children", async ({
page,
request,
}) => {
const f = await setup(request);
try {
await page.goto(f.route);
await send(page, {
action: "handoff",
name: "Welcome project",
split: true,
plan: "# Welcome plan\nWrite a friendly welcome.",
});
const chat = await idle(request, f.chatPath);
await expect
.poll(
async () => {
const tasks = await json(
await request.get(`/api/companies/${f.company.id}/issues`),
);
return (
tasks.length === 2 &&
tasks.every((task: any) => task.status === "done")
);
},
{ timeout: 60_000 },
)
.toBe(true);
const tasks = await json(
await request.get(`/api/companies/${f.company.id}/issues`),
);
for (const task of tasks) {
expect(task.parentId).toBeNull();
expect(task.projectId).toBeTruthy();
expect(task.assigneeAgentId).toBe(f.agent.id);
const plan = await json(
await request.get(`/api/issues/${task.id}/documents/plan`),
);
const output = await json(
await request.get(`/api/issues/${task.id}/documents/output`),
);
expect(output.body).toContain(plan.body);
const runs = await json(
await request.get(`/api/companies/${f.company.id}/heartbeat-runs`),
);
const run = runs.find(
(run: any) => run.contextSnapshot?.issueId === task.id,
);
expect(run).toBeTruthy();
expect(Date.parse(plan.updatedAt)).toBeLessThanOrEqual(
Date.parse(run.startedAt),
);
}
expect(
(await json(await request.get(`/api/issues/${chat.id}/documents/plan`)))
.body,
).toContain("Welcome plan");
expect(
(
await request.post(`/api/companies/${f.company.id}/issues`, {
data: { title: "Invalid child", parentId: chat.id },
})
).status(),
).toBe(422);
expect(
(
await request.patch(`/api/issues/${tasks[0].id}`, {
data: { parentId: chat.id },
})
).status(),
).toBe(422);
} finally {
await f.restore();
}
});
for (const bad of [
{ ids: ["987654321"] },
{ urls: ["https://user:password@github.com/org/repo"] },
{
urls: ["https://github.com/org/repo"],
workspace: { name: "Conflicting", repoUrl: "https://github.com/org/repo" },
},
])
test(`failed project creation has no success card or partial state: ${JSON.stringify(bad)}`, async ({
page,
request,
}) => {
const f = await setup(request);
try {
await page.goto(f.route);
await send(page, { action: "project", ...bad });
await idle(request, f.chatPath);
await expect(page.getByText(/Expected tool result:/)).toBeVisible();
await expect(
page.getByRole("article", { name: /Project created:/ }),
).toHaveCount(0);
expect(
await json(
await request.get(`/api/companies/${f.company.id}/projects`),
),
).toHaveLength(0);
expect(
await json(await request.get(`/api/companies/${f.company.id}/issues`)),
).toHaveLength(0);
} finally {
await f.restore();
}
});
test("first upload creates the chat without invoking its agent; shared attachments persist", async ({
page,
request,
}) => {
const f = await setup(request);
try {
await page.goto(f.route);
await page
.locator('input[type="file"]')
.last()
.setInputFiles({
name: "chat-notes.txt",
mimeType: "text/plain",
buffer: Buffer.from("Attachment acceptance content"),
});
await expect(
page.getByTestId("task-chat-composer-attachments"),
).toContainText("chat-notes.txt");
await expect
.poll(async () => Boolean(await json(await request.get(f.chatPath))))
.toBe(true);
const chat = await json(await request.get(f.chatPath));
expect(chat.id).toBeTruthy();
expect(
await json(
await request.get(`/api/companies/${f.company.id}/heartbeat-runs`),
),
).toHaveLength(0);
await expect
.poll(
async () =>
(await json(await request.get(`/api/issues/${chat.id}/attachments`)))
.length,
)
.toBe(1);
await send(page, "Read these notes later; just acknowledge.");
await idle(request, f.chatPath);
await page.reload();
await expect(
page.getByRole("tab", { name: "Properties", exact: true }),
).toHaveCount(0);
expect(
await json(await request.get(`/api/issues/${chat.id}/attachments`)),
).toHaveLength(1);
} finally {
await f.restore();
}
});
for (const mode of ["Ask", "Plan"])
test(`${mode} denies project mutations; Plan can draft and revise without execution`, async ({
page,
request,
}) => {
const f = await setup(request);
try {
await page.goto(f.route);
await page.getByTestId("task-chat-composer-mode").click();
await page
.getByTestId("task-chat-composer-mode-menu")
.getByText(`${mode} mode`, { exact: true })
.click();
await send(page, { action: "project", name: "Forbidden mutation" });
await idle(request, f.chatPath);
expect(
await json(
await request.get(`/api/companies/${f.company.id}/projects`),
),
).toHaveLength(0);
await expect(
page.getByRole("article", { name: /Project created:/ }),
).toHaveCount(0);
if (mode === "Plan") {
await send(page, {
action: "plan",
text: "# Draft plan\nDiscuss the goal.",
});
const chat = await idle(request, f.chatPath, 2);
const first = await json(
await request.get(`/api/issues/${chat.id}/documents/plan`),
);
await send(page, {
action: "plan",
text: "# Revised plan\nDiscuss the revised goal.",
});
await idle(request, f.chatPath, 3);
const revised = await json(
await request.get(`/api/issues/${chat.id}/documents/plan`),
);
expect(revised.latestRevisionId).not.toBe(first.latestRevisionId);
expect(revised.body).toContain("Revised plan");
await expect(
page.getByRole("tab", { name: "Plan", exact: true }),
).toBeVisible();
}
expect(
await json(await request.get(`/api/companies/${f.company.id}/issues`)),
).toHaveLength(0);
} finally {
await f.restore();
}
});
for (const selection of [
{ ids: ["101"] },
{ ids: ["101", "102"] },
{
ids: ["101"],
urls: [
"https://github.com/chat-fixture/frontend",
"https://github.com/octocat/Hello-World",
],
},
])
test(`authorized repository discovery and selection: ${JSON.stringify(selection)}`, async ({
page,
request,
}) => {
const f = await setup(request);
try {
const secret = await json(
await request.post(`/api/companies/${f.company.id}/secrets`, {
data: {
name: "Deterministic GitHub credential",
value: "paperclip-e2e-repository-fixture",
},
}),
);
await json(
await request.post(`/api/companies/${f.company.id}/tools/connections`, {
data: {
name: "Fixture GitHub",
applicationName: "Fixture GitHub",
transport: "rest_api",
authKind: "api_key",
credentialPolicy: "shared",
status: "active",
enabled: true,
config: {
sourceTemplateKey: "github",
baseUrl: "https://api.github.com",
},
credentialSecretRefs: [
{
configPath: "headers.Authorization",
secretId: secret.id,
versionSelector: "latest",
},
],
},
}),
);
const repos = await json(
await request.get(
`/api/companies/${f.company.id}/project-repositories`,
),
);
expect(repos.repositories.map((repo: any) => repo.id).sort()).toEqual([
"101",
"102",
]);
await page.goto(f.route);
await send(page, {
action: "project",
name: "Selected repositories",
...selection,
});
await idle(request, f.chatPath);
const project = (
await json(await request.get(`/api/companies/${f.company.id}/projects`))
)[0];
expect(project).toBeTruthy();
expect(
project.workspaces
.map((w: any) => w.metadata?.githubRepositoryId)
.filter(Boolean)
.sort(),
).toEqual(selection.ids);
expect(new Set(project.workspaces.map((w: any) => w.repoUrl)).size).toBe(
project.workspaces.length,
);
await expect(
page.getByRole("article", {
name: "Project created: Selected repositories",
}),
).toHaveCount(1);
} finally {
await f.restore();
}
});
test("plan approval hands the preserved revision to an assigned project task", async ({
page,
request,
}) => {
const f = await setup(request);
try {
await page.goto(f.route);
await page.getByTestId("task-chat-composer-mode").click();
await page
.getByTestId("task-chat-composer-mode-menu")
.getByText("Plan mode", { exact: true })
.click();
await send(page, {
action: "plan",
text: "# Approved welcome\nWrite two friendly sentences.",
approval: true,
});
const chat = await idle(request, f.chatPath);
const original = await json(
await request.get(`/api/issues/${chat.id}/documents/plan`),
);
expect(
await json(await request.get(`/api/companies/${f.company.id}/issues`)),
).toHaveLength(0);
await page
.getByRole("button", { name: "Approve handoff", exact: true })
.last()
.click();
await idle(request, f.chatPath, 2);
await expect
.poll(
async () =>
(
await json(
await request.get(`/api/companies/${f.company.id}/issues`),
)
).filter((task: any) => task.status === "done").length,
{ timeout: 60_000 },
)
.toBe(1);
const task = (
await json(await request.get(`/api/companies/${f.company.id}/issues`))
)[0];
expect(task.parentId).toBeNull();
expect(task.projectId).toBeTruthy();
expect(task.assigneeAgentId).toBe(f.agent.id);
const plan = await json(
await request.get(`/api/issues/${task.id}/documents/plan`),
);
const output = await json(
await request.get(`/api/issues/${task.id}/documents/output`),
);
expect(plan.body).toContain(original.body);
expect(output.body).toContain(plan.body);
expect(
(await json(await request.get(`/api/issues/${chat.id}/documents/plan`)))
.latestRevisionId,
).toBe(original.latestRevisionId);
await expect(
page.getByRole("article", {
name: "Project created: Approved plan project",
}),
).toHaveCount(1);
} finally {
await f.restore();
}
});
test("shared questions resume and existing project reuse creates no project card", async ({
page,
request,
}) => {
const f = await setup(request);
try {
const project = await json(
await request.post(`/api/companies/${f.company.id}/projects`, {
data: { name: "Garden club" },
}),
);
await page.goto(f.route);
await send(page, { action: "question" });
await expect(
page.getByRole("radio", { name: "Garden club", exact: true }).last(),
).toBeVisible();
await page
.getByRole("radio", { name: "Garden club", exact: true })
.last()
.click();
await page
.getByRole("button", { name: "Submit answers", exact: true })
.last()
.click();
const chat = await idle(request, f.chatPath, 2);
await expect(
page.getByText("Reply generation 0: Clarification received.", {
exact: true,
}),
).toBeVisible();
await send(page, { action: "handoff", projectId: project.id });
await idle(request, f.chatPath, 3);
await expect
.poll(
async () =>
(
await json(
await request.get(`/api/companies/${f.company.id}/issues`),
)
).length,
)
.toBe(1);
const task = (
await json(await request.get(`/api/companies/${f.company.id}/issues`))
)[0];
expect(task.projectId).toBe(project.id);
expect(task.parentId).toBeNull();
expect(
await json(await request.get(`/api/companies/${f.company.id}/projects`)),
).toHaveLength(1);
await expect(
page.getByRole("article", { name: /Project created:/ }),
).toHaveCount(0);
const ordinaryChild = await json(
await request.post(`/api/companies/${f.company.id}/issues`, {
data: { title: "Ordinary delegation still works", parentId: task.id },
}),
);
expect(ordinaryChild.parentId).toBe(task.id);
expect(
(await json(await request.get(`/api/issues/${chat.id}`))).status,
).toBe("in_review");
} finally {
await f.restore();
}
});
test("shared history loads older messages without replacing the latest turn", async ({
page,
request,
}) => {
const f = await setup(request);
try {
await page.goto(f.route);
await send(page, { action: "history" });
await idle(request, f.chatPath, 65);
await page.reload();
await expect(
page.getByText("History message 64", { exact: true }),
).toBeVisible();
// Scroll the shared transcript, including its older-page sentinel.
await page.getByText("History message 64", { exact: true }).hover();
for (
let attempt = 0;
attempt < 5 &&
!(await page.getByText("History message 00", { exact: true }).count());
attempt++
) {
await page.mouse.wheel(0, -12000);
await expect
.poll(async () => page.getByText(/History message/).count())
.toBeGreaterThan(40);
}
await expect(
page.getByText("History message 00", { exact: true }),
).toBeAttached();
await expect(
page.getByText("History message 64", { exact: true }),
).toBeAttached();
} finally {
await f.restore();
}
});
test("disabling the experiment lets an active turn settle and keeps idle history", async ({
page,
request,
}) => {
const original = await json(
await request.get("/api/instance/settings/experimental"),
);
expect(original.enableAgentChat).toBe(false);
const f = await setup(request);
try {
await page.goto(f.route);
await send(page, { action: "delayed" });
await expect(
page.getByText("Turn started before feature disable.", { exact: true }),
).toBeVisible();
const chat = await json(await request.get(f.chatPath));
await json(
await request.patch("/api/instance/settings/experimental", {
data: { enableAgentChat: false },
}),
);
await expect
.poll(async () =>
(await json(await request.get(`/api/issues/${chat.id}/comments`))).some(
(c: any) => c.body === "Active turn settled after feature disable.",
),
)
.toBe(true);
await expect
.poll(
async () =>
(await json(await request.get(`/api/issues/${chat.id}`)))
.conversationState,
)
.toBe("waiting");
expect(
(
await request.post(`/api/issues/${chat.id}/comments`, {
data: { body: "/new" },
})
).status(),
).toBe(404);
const before = (
await json(
await request.get(`/api/companies/${f.company.id}/heartbeat-runs`),
)
).length;
await page.reload();
await expect(page.getByText(/Agent Chat is disabled/)).toBeVisible();
expect(
(
await json(
await request.get(`/api/companies/${f.company.id}/heartbeat-runs`),
)
).length,
).toBe(before);
} finally {
await f.restore();
}
});