Files
PaperClipAI/tests/e2e/agent-chat.spec.ts
T
DottaandPaperclip 5282cabde8 fix(ui): make every agent reachable from Chats (#13420)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Experimental Agent Chat gives each person a persistent conversation
with each agent.
> - The sidebar only showed starred or previously visited agents.
> - A person could not start a conversation with an agent absent from
that list.
> - This pull request adds a searchable agent picker and a default
sidebar entry.
> - People can now reach every company agent while keeping their
frequent conversations close.

## Linked Issues or Issue Description

Refs #13283, which introduced the existing task-backed agent chat
surface. This change adds discovery to that surface after maintainer
review of the Storybook design.

**What happened?**

With Agent Chat enabled, a person with no starred or recent agents had
no agent shortcut. The sidebar also lacked a direct way to start a chat
with another agent.

**Expected behavior**

Show the first-created company agent by default. Let the person search
all company agents and open a persistent conversation.

**Steps to reproduce**

1. Enable Agent Chat in a company with agents.
2. Use an account with no starred agents or recent chats.
3. Look for a chat entry in the sidebar, or try to chat with an agent
absent from its shortcuts.

**Paperclip version or commit**

The existing behavior is present on master at 728f7185f.

## What Changed

- Add a compact picker that searches every company agent by name or
role. Include loading, retry, empty, and no-match states.
- Keep the first-created agent in Chats alongside stars and up to four
other recent conversations.
- Align the compose button with stars. Show it on hover or keyboard
focus, and keep it visible on touch devices.
- Use the existing company-aware chat route. Close the mobile sidebar on
selection and reset the picker when the company or user changes.
- Connect the approved Storybook scenarios to the production sidebar and
picker. Update product and design-guide documentation.

## Verification

- Ten focused tests pass for ordering, duplicate names, role search,
keyboard selection, search reset, retry states, full-roster navigation,
mobile dismissal, and company/user changes.
- The agent-chat sidebar end-to-end test passes against an isolated
PostgreSQL container. It verifies the first-use default, picker
navigation, lazy creation, stars, draft preservation, and reopening
existing history. The old four-row and See all link expectations now
match the approved behavior.
- Browser review of the production components in Storybook: open the
sidebar picker, search for an agent absent from the shortcuts, select
with Enter, and send a message through the existing composer.
- `pnpm build`, `pnpm -r typecheck`, `pnpm check:token-gates`, and `pnpm
build-storybook` pass on the rebased branch. The full local test run
reached 12,020 passing server tests, then stopped because an unrelated
environment-custom-images suite could not allocate PostgreSQL shared
memory on this Mac. All corresponding CI test shards pass on isolated
hosts.
- All CI checks pass on commit
`c324568324a6a8b65adfc6447ef19bcc710c61df`. Greptile reviewed that
commit at 5/5 with no open findings.
- Reviewer path: enable Agent Chat, open the Chats compose button,
search by name or role, and select an agent. Existing conversation
history must remain available.

## Risks

- Sidebar ordering changes: the earliest-created agent remains visible
even without a visit or star.
- The hover control remains available to keyboard and touch users.
- This change uses the existing feature gate, APIs, membership
preferences, and persistent conversation behavior. It adds no migration
or server contract.
- Storybook uses fixture responses. Its first-use landing page is a
review fixture and is not added as a product route.

## Model Used

OpenAI GPT-6 Astra (`gpt-6-astra`) through Codex, with reasoning, code
execution, and browser tools. The session does not expose its context
window size.

## 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-14 13:19:13 -05:00

1067 lines
34 KiB
TypeScript

import path from "node:path";
import { createLocalAgentJwt } from "../../server/src/agent-auth-jwt";
import {
test,
expect,
type APIRequestContext,
type Page,
} from "@playwright/test";
test.use({ trace: "retain-on-failure" });
test.setTimeout(120_000);
async function json(response: Awaited<ReturnType<APIRequestContext["get"]>>) {
expect(response.ok(), `${response.status()} ${await response.text()}`).toBe(
true,
);
return response.json();
}
async function setup(request: APIRequestContext) {
const company = await json(
await request.post("/api/companies", {
data: { name: `Agent Chat ${Date.now()}` },
}),
);
const original = await json(
await request.get("/api/instance/settings/experimental"),
);
await json(
await request.patch("/api/instance/settings/experimental", {
data: { enableAgentChat: true, enableClassicTaskInterface: false },
}),
);
const agents = [];
for (const name of ["Alpha", "Beta", "Gamma", "Delta", "Epsilon", "Zeta"])
agents.push(
await json(
await request.post(`/api/companies/${company.id}/agents`, {
data: {
name,
adapterType: "process",
adapterConfig: {
command: process.execPath,
args: [path.resolve("tests/e2e/fixtures/agent-chat.mjs")],
graceSec: 1,
},
runtimeConfig: {
heartbeat: { enabled: false, wakeOnDemand: true },
},
},
}),
),
);
const agent = agents[0];
const chatPath = `/api/companies/${company.id}/chats/${agent.id}`;
const route = `/${company.issuePrefix}/chats/${agent.id}`;
return {
company,
agents,
agent,
chatPath,
route,
restore: async () => {
try {
const chat = await request.get(chatPath);
const history = chat.ok() ? await chat.json() : null;
const tasks = await json(
await request.get(`/api/companies/${company.id}/issues`),
);
const projects = await json(
await request.get(`/api/companies/${company.id}/projects`),
);
const ledger = await json(
await request.get(`/api/companies/${company.id}/heartbeat-runs`),
);
const documents = await Promise.all(
[...(history ? [history] : []), ...tasks].map(async (task: any) => ({
taskId: task.id,
documents: await json(
await request.get(`/api/issues/${task.id}/documents`),
),
})),
);
await test.info().attach("chat-persisted-state", {
contentType: "application/json",
body: Buffer.from(
JSON.stringify(
{
chat: history,
tasks,
projects,
documents,
comments: history
? await json(
await request.get(`/api/issues/${history.id}/comments`),
)
: [],
runs: ledger.map((run: any) => ({
id: run.id,
status: run.status,
startedAt: run.startedAt,
finishedAt: run.finishedAt,
sessionIdBefore: run.sessionIdBefore,
sessionIdAfter: run.sessionIdAfter,
issueId: run.contextSnapshot?.issueId,
generation:
run.contextSnapshot?.conversationSessionGeneration,
reset: run.contextSnapshot?.conversationReset,
})),
},
null,
2,
),
),
});
} finally {
const runs = await json(
await request.get(`/api/companies/${company.id}/live-runs`),
);
for (const run of runs)
await json(
await request.post(`/api/heartbeat-runs/${run.id}/cancel`),
);
await json(
await request.patch("/api/instance/settings/experimental", {
data: {
enableAgentChat: original.enableAgentChat,
enableClassicTaskInterface: original.enableClassicTaskInterface,
},
}),
);
}
},
};
}
async function send(page: Page, value: unknown) {
await page
.getByTestId("task-chat-composer-input")
.last()
.locator('[contenteditable="true"],textarea')
.first()
.fill(
typeof value === "string"
? value
: `fixture:${Buffer.from(JSON.stringify(value)).toString("base64url")}`,
);
await page.getByTestId("task-chat-composer-send").last().click();
}
async function idle(
request: APIRequestContext,
chatPath: string,
minimumReplies = 1,
) {
let issue: any;
await expect
.poll(
async () => {
issue = await json(await request.get(chatPath));
if (!issue) return false;
const replies = await json(
await request.get(`/api/issues/${issue.id}/comments`),
);
const live = await json(
await request.get(`/api/issues/${issue.id}/live-runs`),
);
return (
live.length === 0 &&
issue.conversationState === "waiting" &&
issue.status === "in_review" &&
replies.filter((c: any) => c.authorAgentId).length >= minimumReplies
);
},
{ timeout: 60_000, intervals: [100, 250, 500] },
)
.toBe(true);
return issue;
}
test("chat first open is read-only; concurrent first sends and retries share one task", async ({
page,
context,
request,
}) => {
const f = await setup(request);
try {
await page.goto(f.route);
await expect(page.getByTestId("task-chat-composer-input")).toBeVisible();
expect(await json(await request.get(f.chatPath))).toBeNull();
expect(
await json(
await request.get(`/api/companies/${f.company.id}/heartbeat-runs`),
),
).toHaveLength(0);
const other = await context.newPage();
await other.goto(f.route);
await Promise.all([send(page, "Same first message"), send(other, "Same first message")]);
const issue = await idle(request, f.chatPath, 2);
const initialComments = await json(await request.get(`/api/issues/${issue.id}/comments`));
expect(initialComments.filter((comment: any) => !comment.authorAgentId && comment.body === "Same first message")).toHaveLength(2);
const resolved = await Promise.all(
Array.from({ length: 4 }, () =>
request.post(f.chatPath, { data: {} }).then(json),
),
);
expect(new Set(resolved.map((row) => row.id))).toEqual(new Set([issue.id]));
const body = {
body: "Retry once",
clientRequestId: "00000000-0000-4000-8000-000000000001",
};
const replies = await Promise.all([
request
.post(`/api/issues/${issue.id}/comments`, { data: body })
.then(json),
request
.post(`/api/issues/${issue.id}/comments`, { data: body })
.then(json),
]);
expect(replies[0].id).toBe(replies[1].id);
await idle(request, f.chatPath, 3);
await page.reload();
await expect(
page.getByText("Reply generation 0: Retry once", { exact: true }),
).toBeVisible();
expect(
await json(await request.get(`/api/companies/${f.company.id}/issues`)),
).toHaveLength(0);
const dashboard = await json(
await request.get(`/api/companies/${f.company.id}/dashboard`),
);
expect(dashboard.tasks).toEqual({
open: 0,
inProgress: 0,
blocked: 0,
done: 0,
});
const count = (
await json(
await request.get(`/api/companies/${f.company.id}/heartbeat-runs`),
)
).length;
await page.goto(`/${f.company.issuePrefix}/issues/${issue.identifier}`);
await page.goto(f.route);
expect(
await json(
await request.get(`/api/companies/${f.company.id}/heartbeat-runs`),
),
).toHaveLength(count);
await other.close();
} finally {
await f.restore();
}
});
test("feature flag blocks new sends and resets while preserving existing history", async ({
page,
request,
}) => {
const f = await setup(request);
try {
await page.goto(f.route);
await send(page, "Visible history");
const issue = await idle(request, f.chatPath);
await json(
await request.patch("/api/instance/settings/experimental", {
data: { enableAgentChat: false },
}),
);
for (const body of ["blocked message", "/new"])
expect(
(
await request.post(`/api/issues/${issue.id}/comments`, {
data: { body },
})
).status(),
).toBe(404);
await page.reload();
await expect(page.getByText(/Agent Chat is disabled/)).toBeVisible();
expect(
(await json(await request.get(`/api/issues/${issue.id}/comments`))).some(
(c: any) => c.body.includes("Visible history"),
),
).toBe(true);
expect((await request.post(f.chatPath, { data: {} })).status()).toBe(404);
} finally {
await f.restore();
}
});
test("Stop then queued /new resets unpause the conversation without losing history", async ({
page,
request,
}) => {
const f = await setup(request);
try {
await page.goto(f.route);
await send(page, { action: "hold" });
await expect
.poll(async () => {
const chat = await json(await request.get(f.chatPath));
return (
chat &&
(
await json(await request.get(`/api/issues/${chat.id}/comments`))
).some(
(c: any) => c.body === "Provider is streaming and ready to stop.",
)
);
})
.toBe(true);
const issue = await json(await request.get(f.chatPath));
const active = (
await json(await request.get(`/api/issues/${issue.id}/live-runs`))
)[0];
await page.getByTestId("task-chat-composer-stop").click();
await expect
.poll(
async () =>
(await json(await request.get(`/api/heartbeat-runs/${active.id}`)))
.status,
)
.toBe("cancelled");
const staleToken = createLocalAgentJwt(
f.agent.id,
f.company.id,
"process",
active.id,
);
expect(staleToken).toBeTruthy();
const late = await request.post(`/api/issues/${issue.id}/comments`, {
headers: { Authorization: `Bearer ${staleToken}` },
data: { body: "Forbidden late response" },
});
expect([403, 409]).toContain(late.status());
const mutation = await request.post(
`/api/companies/${f.company.id}/projects`,
{
headers: { Authorization: `Bearer ${staleToken}` },
data: { name: "Cancelled project" },
},
);
expect([403, 409]).toContain(mutation.status());
expect(
await json(await request.get(`/api/companies/${f.company.id}/projects`)),
).toHaveLength(0);
await send(page, "/new");
await send(page, "/new");
await send(page, "Fresh followup");
await idle(request, f.chatPath, 2);
const fresh = await json(await request.get(f.chatPath));
expect(fresh.id).toBe(issue.id);
expect(fresh.conversationSessionGeneration).toBe(2);
await expect(
page.getByText("Reply generation 2: Fresh followup", { exact: true }),
).toBeVisible();
await page.reload();
await expect(page.getByText("New session", { exact: true })).toHaveCount(2);
await expect(
page.getByRole("separator", { name: "Run completed", exact: true }),
).toHaveCount(0);
const runs = await json(
await request.get(`/api/companies/${f.company.id}/heartbeat-runs`),
);
const detailed = await Promise.all(
runs.map((run: any) =>
request.get(`/api/heartbeat-runs/${run.id}`).then(json),
),
);
expect(
detailed.filter((run) => run.contextSnapshot?.conversationReset),
).toHaveLength(2);
expect(
detailed
.filter((run) => run.contextSnapshot?.conversationReset)
.every((run) => !run.sessionIdAfter),
).toBe(true);
} finally {
await f.restore();
}
});
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("sidebar discovery, stars, recent agents, configuration links, and drafts survive switching", async ({
page,
request,
}) => {
const f = await setup(request);
try {
await page.goto(`/${f.company.issuePrefix}/dashboard`);
const nav = page.getByRole("navigation");
const chatLinks = nav.locator('a[href*="/chats/"]');
await expect(chatLinks).toHaveText(["Alpha"]);
const compose = nav.getByRole("button", { name: "Chat with an agent", exact: true });
await compose.click();
const picker = page.getByRole("dialog", { name: "Chat with an agent", exact: true });
await expect(picker.getByRole("option")).toHaveCount(6);
await picker.getByRole("combobox").fill("Beta");
await picker.getByRole("combobox").press("Enter");
await expect(picker).not.toBeVisible();
await expect(page.getByRole("link", { name: "Configure Beta", exact: true })).toBeVisible();
expect(await json(await request.get(`/api/companies/${f.company.id}/chats/${f.agents[1].id}`))).toBeNull();
for (const agent of f.agents) {
await page.goto(`/${f.company.issuePrefix}/chats/${agent.id}`);
await expect(page.getByTestId("task-chat-composer-input")).toBeVisible();
}
await expect(chatLinks).toHaveText(["Alpha", "Zeta", "Epsilon", "Delta", "Gamma"]);
const star = page.getByRole("button", { name: "Star Zeta", exact: true });
await page.getByTestId("task-chat-composer-input").click();
await expect(star).toHaveCSS("opacity", "0");
await star.focus();
await page.keyboard.press("Tab");
await page.keyboard.press("Shift+Tab");
await expect(star).toHaveCSS("opacity", "1");
await star.click();
await page.goto(f.route);
await page.getByRole("button", { name: "Star Alpha", exact: true }).click();
await expect(nav.locator('a[href*="/chats/"]').first()).toHaveText("Alpha");
await expect(nav.locator('a[href*="/chats/"]').nth(1)).toHaveText("Zeta");
const editor = page
.getByTestId("task-chat-composer-input")
.locator('[contenteditable="true"]');
await editor.fill("Unsent draft for Alpha");
await nav.getByRole("link", { name: "Zeta", exact: true }).click();
await expect(editor).toHaveText("");
await nav.getByRole("link", { name: "Alpha", exact: true }).click();
await expect(editor).toContainText("Unsent draft for Alpha");
const recent = await page.evaluate(() =>
Object.fromEntries(
Object.entries(localStorage).filter(([key]) =>
key.startsWith("paperclip.recentAgentChats:"),
),
),
);
await page.getByRole("link", { name: /Configure Alpha/ }).click();
await expect(page).toHaveURL(/\/agents\/.*\/runtime/);
const backgroundPath = `/api/companies/${f.company.id}/chats/${f.agents[1].id}`;
const background = await json(
await request.post(backgroundPath, { data: {} }),
);
await json(
await request.post(`/api/issues/${background.id}/comments`, {
data: {
body: "Background activity",
clientRequestId: "00000000-0000-4000-8000-000000000099",
},
}),
);
await idle(request, backgroundPath);
expect(
await page.evaluate(() =>
Object.fromEntries(
Object.entries(localStorage).filter(([key]) =>
key.startsWith("paperclip.recentAgentChats:"),
),
),
),
).toEqual(recent);
await compose.click();
await expect(picker.getByRole("combobox")).toHaveValue("");
await picker.getByRole("combobox").fill("Beta");
await picker.getByRole("combobox").press("Enter");
await expect(picker).not.toBeVisible();
await expect(page.getByRole("link", { name: "Configure Beta", exact: true })).toBeVisible();
await expect(page.getByText("Background activity", { exact: true })).toBeVisible();
} 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();
}
});