mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Conversations must use the same tasks, controls, and execution history. > - Users need an ongoing chat with an agent without managing task properties. > - Agents should clarify and plan work, then hand execution to assigned project tasks. > - This pull request combines the reviewed Agent Chat stack for one squash merge. > - The benefit is persistent conversation with normal task governance and shared UI. ## Linked Issues or Issue Description **Subsystem affected** Task lifecycle, agent runtime tools, shared task UI, and browser/paid runner tests. **Problem or motivation** Users need one persistent conversation with each agent. A separate chat store or renderer would duplicate task behavior and bypass existing controls. **Proposed solution** Use a task-backed chat per company, user, and agent. Reuse the task composer and transcript. Clarify and plan in chat, then create assigned project tasks with the relevant plan. Keep Agent Chat behind its own disabled-by-default experimental setting. **Roadmap alignment** This implements the task-backed direction in [CEO Chat](https://github.com/paperclipai/paperclip/blob/master/ROADMAP.md#-ceo-chat). Related proposals: #2504 and #9693. Related request: #7981. The maintainer requested one squash merge of the complete stack. Consolidates the reviewed runtime [#13281](https://github.com/paperclipai/paperclip/pull/13281), backend [#13282](https://github.com/paperclipai/paperclip/pull/13282), and UI [#13283](https://github.com/paperclipai/paperclip/pull/13283) layers with this PR's E2E coverage. All four layers passed CI and received Greptile 5/5 before consolidation. This PR targets master and includes the complete feature. ## What Changed - Add personal canonical chat tasks with ordinary company visibility, immutable identity, idempotent first sends, and an idle waiting state. - Process `/new` in queue order. Preserve history, release a chat pause, and fence old provider context and delayed writes. - Keep chat lifecycle rules across recovery, finalization, assignment, task lists, and rollups. - Support research and plan revision in chat. Hand plans to ordinary assigned project tasks before execution starts. Reject new chat subtasks. - Add repository-aware project creation and discovery tools, including multiple repository IDs and GitHub URLs, authorization, idempotency, and durable project-created cards. - Reuse task UI components for chat, with starred/recent agent navigation and a separate `enableAgentChat` experimental flag. - Add deterministic browser tests and 24 paid chat cells across four Codex/Claude profiles, with validated reports and screenshots. - Integrate current master recovery, controller lease, queued-message, and task UI changes. Gate chat interruption and deferred promotion on ownership/feature policy. Guarantee lease renewal and active controls are stopped even if teardown fails. - Preserve master's migration 0273 and generate chat migration 0274 with idempotent replay for development databases. ## Verification - Prior exact heads of all four PRs passed Linux CI, including build, typecheck, general/serialized tests, and browser E2E. Each had Greptile 5/5 and no unresolved findings. - Integrated local verification passed: full repository typecheck and production build, Storybook build, token gates, 340 focused UI tests, all 20 deterministic chat browser tests, two migration replay tests, 88 focused chat/queue/native/controller tests, and provider/session regressions including real lease expiry. These include the three lifecycle regressions for the final admission/teardown fixes; server typecheck also passes. Current head `1268eda16cc2af892055917e7292f068820be135` has Greptile 5/5 with no unresolved findings and passing security scans. All final-head CI gates passed: build, full Runner verification, typecheck/release registry, canary, all general/serialized test shards, and all browser E2E shards ([CI run](https://github.com/paperclipai/paperclip/actions/runs/34696739927)). Local PostgreSQL startup contention required serialized retries; skipped fixtures do not count as passing coverage. - The earlier paid campaign passed all 24 chat cells and retained 32 screenshots: [report](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-34648511170-1/index.html?report=agent-chat#suite-agent-chat). It tested `abacbdfd2f660709ec37312cdb758284c8399d04`; it is prior evidence, not a paid run of this integrated head. - Manual check: enable Agent Chat in Experimental settings, open an agent, clarify and revise a plan, then hand off to an assigned project task. Stop a reply, send `/new`, and verify fresh context with retained history. Disable the setting and verify agent shortcuts/new chat turns are blocked. ## Risks - Queue/session integration can affect retries and delayed writes. Tests cover ownership, cancellation, reset boundaries, idle recovery, and ordinary task behavior. - Migration 0274 adds conversation fields and constraints. Replay is idempotent and preserves existing development chat history. - This combines the previously reviewed stack at the maintainer's request. Agent Chat remains off by default and is separate from Conference Room. ## Model Used OpenAI Codex, GPT-6 Astra (`gpt-6-astra`), with reasoning, code execution, browser tools, and parallel review. The exact context-window size is not exposed in this session. Codex and Claude also ran as test subjects in the linked paid campaign. ## 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>
1055 lines
33 KiB
TypeScript
1055 lines
33 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 stars, recent agents, configuration links, and drafts survive switching", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
const f = await setup(request);
|
|
try {
|
|
for (const agent of f.agents) {
|
|
await page.goto(`/${f.company.issuePrefix}/chats/${agent.id}`);
|
|
await expect(page.getByTestId("task-chat-composer-input")).toBeVisible();
|
|
}
|
|
const nav = page.getByRole("navigation");
|
|
await expect(nav.locator('a[href*="/chats/"]')).toHaveCount(4);
|
|
await expect(nav.locator('a[href*="/chats/"]').first()).toHaveText("Zeta");
|
|
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 page
|
|
.getByRole("link", { name: "See all agents", exact: true })
|
|
.click();
|
|
await expect(page).toHaveURL(
|
|
new RegExp(`/${f.company.issuePrefix}/agents/all$`),
|
|
);
|
|
} 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();
|
|
}
|
|
});
|