mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 21:05:21 +02:00
## Thinking Path > - Paperclip helps people manage AI agents and their work. > - Agent Chat keeps one conversation for each agent and board user. > - The Chat sidebar entry opens the agent chooser each time. > - A user must then find and reopen the chat they just used. > - The browser already records recent agent chat visits by company and user. > - This pull request uses that record to reopen the last available chat. > - The chooser still serves users who have no available saved chat. ## Linked Issues or Issue Description Related: #14706 added the secondary Agent Chat navigation. **What happened?** The Chat sidebar entry opened the agent chooser, even after a user opened an agent chat. **Expected behavior** The Chat entry should reopen the last agent chat visited by the current user in the current company. **Steps to reproduce** 1. Enable Agent Chat and open a chat with an agent. 2. Open another page. 3. Select Chat in the sidebar. 4. Observe the agent chooser instead of the chat. **Paperclip version or commit** Reproduced on master at `0829d94af`. **Deployment mode** Local development, browser UI. The change also uses the same browser storage path in authenticated mode. ## What Changed - Use the existing recent chat record when the Chat landing route opens. - Check saved agents against the current roster and chat history before redirecting. - Keep the chooser when no saved chat is available, and show a retry state for load errors. - Add route tests and update the Agent Chat implementation spec. ## Verification - `pnpm exec vitest run ui/src/pages/AgentChats.test.tsx ui/src/lib/recent-agent-chats.test.ts` — 16 tests passed. - `pnpm check:token-gates` — passed. - `pnpm exec playwright test --config tests/e2e/playwright.config.ts tests/e2e/agent-chat-sessions.spec.ts --grep 'secondary chat navigation preserves layout'` — passed. - `pnpm --filter @paperclipai/ui typecheck` — passed on the final commit. - `pnpm -r typecheck` and `pnpm build` — passed earlier in this branch; latest-head CI completed all 47 jobs successfully. - `pnpm test:run` reported an unrelated native runtime test failure before it was stopped. That test and an unrelated external object refresh test passed in isolation. CI runs the same suites on the PR. - To check in the UI: open an agent chat, leave it, and select Chat. The same chat should open. Clear the recent chat record or use another company to see the chooser. ## Risks - The recent order is stored in the browser. Clearing browser storage returns the user to the chooser. - An existing chat ID is stored with its visit. If the chat is removed, the landing route skips that visit when history loads. Cross-tab storage removal clears the identity; failed writes retain an in-tab fallback. - The landing route waits for the agent roster and validates saved issue IDs against chat history when available. If history fails, an active agent chat can still open; roster or session failures show a retry action. - No database or API contract changes are 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 Codex, GPT-6 family. The runtime did not expose an exact API model ID or context window. It used reasoning, repository tools, shell commands, and code execution. ## 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>
362 lines
14 KiB
TypeScript
362 lines
14 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
import { createLocalAgentJwt } from "../../server/src/agent-auth-jwt";
|
|
|
|
import { idle, json, send, setup } from "./agent-chat.shared";
|
|
|
|
test.use({ trace: "retain-on-failure" });
|
|
test.setTimeout(120_000);
|
|
|
|
/**
|
|
* Agent chat session lifecycle coverage: first-open semantics, the feature
|
|
* flag, Stop + queued /new resets, and sidebar discovery. Shared fixtures
|
|
* live in ./agent-chat.shared.ts; project, attachment, and history flows run
|
|
* in agent-chat-projects.spec.ts.
|
|
*/
|
|
test("built chat initializes after service worker takeover and reload with a slow CPU", async ({
|
|
page,
|
|
context,
|
|
request,
|
|
}) => {
|
|
const f = await setup(request);
|
|
try {
|
|
const cdp = await context.newCDPSession(page);
|
|
try {
|
|
await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 });
|
|
await page.goto(f.route);
|
|
await expect(page.getByTestId("task-chat-composer-input")).toBeVisible();
|
|
// The failed CI traces stopped before React evaluated, while a service
|
|
// worker forwarded the Vite module graph. Keep this test on shipped assets
|
|
// and cover both first takeover and subsequent controlled navigations.
|
|
const scripts = await page.locator('script[type="module"][src]').evaluateAll(
|
|
(elements) => elements.map((element) => element.getAttribute("src")),
|
|
);
|
|
expect(scripts.length).toBeGreaterThan(0);
|
|
expect(scripts.every((src) => src?.startsWith("/assets/"))).toBe(true);
|
|
await page.evaluate(async () => { await navigator.serviceWorker.ready; });
|
|
for (let reload = 0; reload < 3; reload += 1) {
|
|
await page.reload();
|
|
await expect(page.getByTestId("task-chat-composer-input")).toBeVisible();
|
|
expect(await page.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true);
|
|
}
|
|
expect(await json(await request.get(f.chatPath))).toBeNull();
|
|
} finally {
|
|
await cdp.detach();
|
|
}
|
|
} finally {
|
|
await f.restore();
|
|
}
|
|
});
|
|
|
|
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();
|
|
}
|
|
});
|
|
|
|
test("secondary chat navigation preserves layout, unique conversations, history, and drafts", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
const originalExperimental = await json(await request.get("/api/instance/settings/experimental"));
|
|
const f = await setup(request);
|
|
try {
|
|
await json(await request.patch("/api/instance/settings/experimental", {
|
|
data: { enableStreamlinedUi: true },
|
|
}));
|
|
await page.goto(`/${f.company.issuePrefix}/dashboard`);
|
|
await page.getByRole("link", { name: "Chat", exact: true }).click();
|
|
await expect(page.getByRole("heading", { name: "Who would you like to talk to?" })).toBeVisible();
|
|
const sidebar = page.getByRole("complementary", { name: "Chat", exact: true });
|
|
await expect(sidebar.getByText("No chats yet", { exact: true })).toBeVisible();
|
|
const landingBounds = await sidebar.boundingBox();
|
|
const compose = sidebar.getByRole("button", { name: "Add chat", exact: true });
|
|
const picker = page.getByRole("dialog", { name: "Chat with an agent", exact: true });
|
|
const chatListPath = `/api/companies/${f.company.id}/chats`;
|
|
const betaPath = `${chatListPath}/${f.agents[1].id}`;
|
|
await compose.click();
|
|
await expect(picker.getByRole("option")).toHaveCount(6);
|
|
await picker.getByRole("combobox").fill("Beta");
|
|
await expect(picker.getByRole("option", { name: /^Beta / })).toContainText("New chat");
|
|
await picker.getByRole("combobox").press("Enter");
|
|
await expect(picker).not.toBeVisible();
|
|
await expect(page.getByRole("link", { name: "Configure Beta", exact: true })).toBeVisible();
|
|
expect(await sidebar.boundingBox()).toEqual(landingBounds);
|
|
const beta = await json(await request.get(betaPath));
|
|
expect(beta.conversationAgentId).toBe(f.agents[1].id);
|
|
expect(await json(await request.get(chatListPath))).toHaveLength(1);
|
|
expect(await json(await request.get(`/api/companies/${f.company.id}/heartbeat-runs`))).toHaveLength(0);
|
|
|
|
// Explicitly adding an existing agent must reopen the same conversation.
|
|
await compose.click();
|
|
await expect(picker.getByRole("combobox")).toHaveValue("");
|
|
await picker.getByRole("combobox").fill("Beta");
|
|
await expect(picker.getByRole("option", { name: /^Beta / })).toContainText("Open chat");
|
|
await picker.getByRole("combobox").press("Enter");
|
|
await expect(picker).not.toBeVisible();
|
|
expect((await json(await request.get(betaPath))).id).toBe(beta.id);
|
|
expect(await json(await request.get(chatListPath))).toHaveLength(1);
|
|
|
|
await compose.click();
|
|
await picker.getByRole("combobox").fill("Alpha");
|
|
await picker.getByRole("combobox").press("Enter");
|
|
await expect(picker).not.toBeVisible();
|
|
await expect(page.getByRole("link", { name: "Configure Alpha", exact: true })).toBeVisible();
|
|
const nav = sidebar.getByRole("navigation", { name: "Agent conversations" });
|
|
const alphaLink = nav.getByRole("link", { name: /^Alpha / });
|
|
const betaLink = nav.getByRole("link", { name: /^Beta / });
|
|
await expect(nav.getByRole("link")).toHaveCount(2);
|
|
const editor = page.getByTestId("task-chat-composer-input").locator('[contenteditable="true"]');
|
|
await editor.fill("Unsent draft for Alpha");
|
|
await betaLink.click();
|
|
await expect(editor).toHaveText("");
|
|
await alphaLink.click();
|
|
await expect(editor).toContainText("Unsent draft for Alpha");
|
|
|
|
const search = sidebar.getByRole("textbox", { name: "Search agents", exact: true });
|
|
await search.fill("No matching agent");
|
|
await expect(sidebar.getByText("No agents found", { exact: true })).toBeVisible();
|
|
await search.fill("Beta");
|
|
await expect(nav.getByRole("link")).toHaveCount(1);
|
|
await expect(betaLink).toBeVisible();
|
|
await search.press("Escape");
|
|
await expect(nav.getByRole("link")).toHaveCount(2);
|
|
await page.reload();
|
|
await expect(editor).toContainText("Unsent draft for Alpha");
|
|
await expect(nav.getByRole("link")).toHaveCount(2);
|
|
|
|
await page.getByRole("link", { name: "Chat", exact: true }).click();
|
|
await expect(page.getByRole("link", { name: "Configure Alpha", exact: true })).toBeVisible();
|
|
await expect(editor).toContainText("Unsent draft for Alpha");
|
|
expect(await sidebar.boundingBox()).toEqual(landingBounds);
|
|
await page.getByRole("link", { name: "Configure Alpha", exact: true }).click();
|
|
await expect(page).toHaveURL(/\/agents\/.*\/runtime/);
|
|
await json(await request.post(`/api/issues/${beta.id}/comments`, {
|
|
data: {
|
|
body: "Background activity",
|
|
clientRequestId: "00000000-0000-4000-8000-000000000099",
|
|
},
|
|
}));
|
|
await idle(request, betaPath);
|
|
await page.getByRole("link", { name: "Chat", exact: true }).click();
|
|
await expect(page.getByRole("link", { name: "Configure Alpha", exact: true })).toBeVisible();
|
|
await compose.click();
|
|
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();
|
|
expect((await json(await request.get(betaPath))).id).toBe(beta.id);
|
|
expect(await json(await request.get(chatListPath))).toHaveLength(2);
|
|
await json(await request.post(`/api/agents/${f.agents[1].id}/terminate`));
|
|
await page.goto(`/${f.company.issuePrefix}/chats`);
|
|
await expect(betaLink).toContainText("Terminated");
|
|
await betaLink.click();
|
|
await expect(page).toHaveURL(new RegExp(`/chats/${f.agents[1].id}$`));
|
|
await expect(page.getByText("Background activity", { exact: true })).toBeVisible();
|
|
await page.reload();
|
|
await expect(page.getByText("Background activity", { exact: true })).toBeVisible();
|
|
} finally {
|
|
await f.restore();
|
|
await json(await request.patch("/api/instance/settings/experimental", {
|
|
data: { enableStreamlinedUi: originalExperimental.enableStreamlinedUi },
|
|
}));
|
|
}
|
|
});
|