Files
PaperClipAI/tests/e2e/agent-chat-projects.spec.ts
0be2afcca6 feat(ui): improve task composer controls and pending input (#14322)
## Thinking Path

> - Paperclip lets operators assign tasks to AI agents and review their
work.
> - The task composer controls the next message and its assigned agent.
> - Operators needed a way to choose that agent's model and effort
without leaving the composer.
> - The old mode selector, upload button, and input cards made the
mobile composer crowded and hid normal messaging during a pending
decision.
> - Harnesses publish different model and effort capabilities, so the
picker must follow the selected agent.
> - This pull request adds one responsive composer flow, keeps pending
cards visible above it, and protects Codex ACP authentication in the
local test path.
> - Operators can choose run settings, send a message, and answer a
pending card as separate actions.

## Linked Issues or Issue Description

**Subsystem affected**

Task composer UI, issue thread interactions, Codex ACP credential
handling, and Storybook.

**Problem or motivation**

The composer did not expose model or effort for the selected agent.
Mobile actions wrapped poorly. Pending questions and confirmations
replaced the composer. A local Codex ACP test could also reuse host
authentication after the managed key was removed.

**Proposed solution**

Put assignee search, model search, exact model IDs, effort, and fast
mode in one picker. Use a mobile dialog. Replace the direct-upload plus
action and separate mode selector with an Add menu and removable Plan or
Ask chips. Place pending interaction cards above the usable composer.
Keep these cards pending after an ordinary message unless their creator
asks for comment superseding. Replace managed ACP auth files atomically
and isolate the test key from host credentials.

**Roadmap alignment**

ROADMAP.md does not list an overlapping composer milestone. This change
improves the existing task and review flows.

## What Changed

- Added the combined assignee, model, and effort picker to both task
composers. Search matches agent name, role, and harness. The server uses
a curated Codex list by default and honors instance-declared models.
Manual IDs remain available.
- Added an effort slider for known model capabilities, a conditional
Codex fast control, and reset. The picker opens in a modal on mobile.
- Added the Add menu for files, supported goals, Plan mode, and Ask
mode. Plan and Ask are exclusive removable chips. Keyboard mode cycling
remains available.
- Adjusted mobile spacing, avatars, wrapping, and Send placement.
Removed the composer divider.
- Moved pending question, confirmation, review, and related cards above
the composer. Ordinary comments now leave question and confirmation
cards pending by default. The onboarding prompt retains explicit comment
superseding.
- Updated the Storybook composer group with responsive states and the
production picker. Added UI, service, route, and browser regression
coverage.
- Isolated Codex ACP API-key authentication, skipped subscription auth
merge and shared-home copy-back for remote API-key runs, and replaced
the managed auth file atomically.

## Verification

- `pnpm -r typecheck` — passed on the final local head.
- `pnpm check:token-gates` — passed on the final local head.
- `pnpm exec vitest run server/src/__tests__/adapter-models.test.ts
ui/src/components/task-chat/ComposerRunSettingsPicker.test.tsx` — 31
tests passed, including role and harness search, declared Codex models,
and filtering general OpenAI models.
- `pnpm exec vitest run
server/src/__tests__/issue-thread-interactions-service.test.ts` — 74
tests passed.
- `pnpm exec vitest run
packages/adapters/codex-local/src/server/acp.test.ts` — 42 tests passed,
including remote API-key copy-back isolation.
- `pnpm test:run` — attempted locally; the embedded PostgreSQL test
database could not initialize on macOS. The isolated
`heartbeat-run-event-sequencing` suite reproduced that environment
failure. GitHub CI runs the full test matrix for this head.
- `pnpm build` — passed on the final head. `pnpm build-storybook` passed
after the last UI change; only server code, tests, and docs changed
afterward.
- Live local test drive — Codex ACP ran a task with a managed API key.
The test agent was restored to its default ACP configuration afterward.
- Review the interactive stories under the top-level Composer group with
`pnpm storybook`. Check a narrow desktop width and mobile Plan, Ask,
picker, and pending-question states.

## Risks

- A pending card stays open when an ordinary comment changes the
discussion. Its creator can set `supersedeOnUserComment: true` when a
new comment should replace it.
- Model and effort overrides persist on the task until reset or changed.
An unlisted manual model ID may fail when the provider runs it.
- Some harness catalogs do not report effort support. The picker hides
effort for those models.
- No database migration is required.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI GPT-6 via Codex. This runtime does not expose the exact model ID
or context window to the task. The model used code execution and browser
tools.

## 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
- [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>
Co-authored-by: OpenAI Codex <codex@openai.com>
2026-09-28 23:02:39 +00:00

609 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-add").click();
await page.getByTestId(mode === "Plan" ? "composer-add-plan" : "composer-add-ask").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-add").click();
await page.getByTestId("composer-add-plan").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();
}
});