mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
Add a personal primary agent per company and user. Initialize it from the first human-created agent, expose profile-only switching with confirmation, and use it after recent choices for task and Chat defaults. Persist authenticated preferences, preserve lifecycle and membership rules, keep selections out of shared audit events, and document the API contract. Include the reviewed Storybook surfaces and regression coverage for concurrent choices, onboarding, cross-device updates, and browser journeys. Co-Authored-By: Paperclip <noreply@paperclip.ing>
180 lines
7.4 KiB
TypeScript
180 lines
7.4 KiB
TypeScript
// @vitest-environment jsdom
|
||
import { act } from "react";
|
||
import { createRoot, type Root } from "react-dom/client";
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import type { Agent } from "@paperclipai/shared";
|
||
import { recordAgentChatVisit } from "@/lib/recent-agent-chats";
|
||
import { AgentChats } from "./AgentChats";
|
||
|
||
const state = vi.hoisted(() => ({
|
||
primaryAgentId: null as string | null,
|
||
primaryLoading: false,
|
||
companyId: "company-a",
|
||
userId: "user-a",
|
||
agents: [] as Agent[],
|
||
chats: [] as { id: string; conversationAgentId: string }[],
|
||
chatsFetched: true,
|
||
chatsError: null as Error | null,
|
||
sessionError: null as Error | null,
|
||
navigate: vi.fn(),
|
||
}));
|
||
|
||
vi.mock("@/components/primary-agent/PrimaryAgentPresentation", () => ({ usePrimaryAgentPresentation: () => ({ primaryAgentId: state.primaryAgentId, loading: state.primaryLoading }) }));
|
||
|
||
vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }) }));
|
||
vi.mock("@/hooks/useAgentChatNavigation", () => ({
|
||
useAgentChatNavigation: () => ({
|
||
companyId: state.companyId,
|
||
userId: state.userId,
|
||
enabled: true,
|
||
loaded: true,
|
||
agents: { data: state.agents, isFetched: true, isPending: false, error: null },
|
||
chats: { data: state.chats, isFetched: state.chatsFetched, isSuccess: state.chatsFetched && !state.chatsError, error: state.chatsError },
|
||
session: { isFetched: !state.sessionError, isPending: false, error: state.sessionError },
|
||
}),
|
||
useOpenAgentChat: () => vi.fn(),
|
||
}));
|
||
vi.mock("@/lib/router", () => ({
|
||
useNavigate: () => state.navigate,
|
||
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
|
||
}));
|
||
vi.mock("@/components/AgentAvatar", () => ({ AgentAvatar: () => null }));
|
||
|
||
let root: Root;
|
||
let container: HTMLDivElement;
|
||
const agent = (id: string, companyId = "company-a") => ({
|
||
id, companyId, name: id, urlKey: `${id}-slug`, role: "engineer", status: "idle",
|
||
} as Agent);
|
||
|
||
async function render() {
|
||
await act(async () => root.render(<AgentChats />));
|
||
}
|
||
|
||
beforeEach(() => {
|
||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||
state.primaryAgentId = null;
|
||
state.primaryLoading = false;
|
||
state.companyId = "company-a";
|
||
state.userId = "user-a";
|
||
state.agents = [agent("alice"), agent("bob")];
|
||
state.chats = [];
|
||
state.chatsFetched = true;
|
||
state.chatsError = null;
|
||
state.sessionError = null;
|
||
state.navigate.mockReset();
|
||
for (const id of ["alice", "bob", "removed", "retired-id"])
|
||
recordAgentChatVisit("company-a", "user-a", id, null);
|
||
localStorage.setItem("paperclip.recentAgentChats:company-a:user-a", "[]");
|
||
localStorage.setItem("paperclip.recentAgentChats:company-b:user-a", "[]");
|
||
localStorage.setItem("paperclip.recentAgentChats:company-a:user-b", "[]");
|
||
container = document.createElement("div");
|
||
document.body.append(container);
|
||
root = createRoot(container);
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await act(async () => root.unmount());
|
||
container.remove();
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
describe("Chat landing", () => {
|
||
it("uses the primary only when there is no valid recent conversation", async () => {
|
||
state.primaryAgentId = "alice";
|
||
recordAgentChatVisit("company-a", "user-a", "removed");
|
||
await render();
|
||
expect(state.navigate).toHaveBeenCalledWith("/chats/alice-slug", { replace: true });
|
||
state.navigate.mockClear();
|
||
recordAgentChatVisit("company-a", "user-a", "bob");
|
||
await render();
|
||
expect(state.navigate).toHaveBeenCalledWith("/chats/bob-slug", { replace: true });
|
||
});
|
||
|
||
it("waits for the primary without sending anything and preserves the chooser for unavailable primaries", async () => {
|
||
state.primaryLoading = true;
|
||
await render();
|
||
expect(state.navigate).not.toHaveBeenCalled();
|
||
expect(container.textContent).toContain("Opening chat");
|
||
state.primaryLoading = false;
|
||
state.primaryAgentId = "removed";
|
||
await render();
|
||
expect(state.navigate).not.toHaveBeenCalled();
|
||
expect(container.textContent).toContain("Who would you like to talk to?");
|
||
});
|
||
|
||
it("opens the most recently visited agent and replaces the landing history entry", async () => {
|
||
recordAgentChatVisit("company-a", "user-a", "alice");
|
||
recordAgentChatVisit("company-a", "user-a", "bob");
|
||
await render();
|
||
expect(state.navigate).toHaveBeenCalledWith("/chats/bob-slug", { replace: true });
|
||
expect(container.textContent).toContain("Opening chat");
|
||
});
|
||
|
||
it("skips unavailable saved agents and preserves the company and user scope", async () => {
|
||
recordAgentChatVisit("company-a", "user-a", "alice");
|
||
recordAgentChatVisit("company-a", "user-a", "removed");
|
||
recordAgentChatVisit("company-b", "user-a", "other-company-agent");
|
||
recordAgentChatVisit("company-a", "user-b", "other-user-agent");
|
||
await render();
|
||
expect(state.navigate).toHaveBeenCalledWith("/chats/alice-slug", { replace: true });
|
||
});
|
||
|
||
it("reopens historical chats for agents missing from the active roster", async () => {
|
||
recordAgentChatVisit("company-a", "user-a", "retired-id");
|
||
state.chats = [{ id: "chat-retired", conversationAgentId: "retired-id" }];
|
||
await render();
|
||
expect(state.navigate).toHaveBeenCalledWith("/chats/retired-id", { replace: true });
|
||
});
|
||
|
||
it("waits for history before skipping a recent historical chat", async () => {
|
||
recordAgentChatVisit("company-a", "user-a", "alice");
|
||
recordAgentChatVisit("company-a", "user-a", "retired-id");
|
||
state.chatsFetched = false;
|
||
await render();
|
||
expect(state.navigate).not.toHaveBeenCalled();
|
||
state.chatsFetched = true;
|
||
state.chats = [{ id: "chat-retired", conversationAgentId: "retired-id" }];
|
||
await render();
|
||
expect(state.navigate).toHaveBeenCalledWith("/chats/retired-id", { replace: true });
|
||
});
|
||
|
||
it("shows the chooser when no saved chat remains available", async () => {
|
||
recordAgentChatVisit("company-a", "user-a", "removed");
|
||
await render();
|
||
expect(state.navigate).not.toHaveBeenCalled();
|
||
expect(container.textContent).toContain("Who would you like to talk to?");
|
||
});
|
||
|
||
it("skips a saved conversation that is no longer accessible even when its agent remains available", async () => {
|
||
recordAgentChatVisit("company-a", "user-a", "alice");
|
||
recordAgentChatVisit("company-a", "user-a", "bob", "deleted-chat");
|
||
state.chats = [{ id: "chat-alice", conversationAgentId: "alice" }];
|
||
await render();
|
||
expect(state.navigate).toHaveBeenCalledWith("/chats/alice-slug", { replace: true });
|
||
});
|
||
|
||
it("reopens an active agent when history fails", async () => {
|
||
recordAgentChatVisit("company-a", "user-a", "alice", "chat-alice");
|
||
state.chatsFetched = false;
|
||
state.chatsError = new Error("History unavailable");
|
||
await render();
|
||
expect(state.navigate).toHaveBeenCalledWith("/chats/alice-slug", { replace: true });
|
||
});
|
||
|
||
it("reopens a visited empty chat without waiting for history", async () => {
|
||
recordAgentChatVisit("company-a", "user-a", "alice", null);
|
||
state.chatsFetched = false;
|
||
await render();
|
||
expect(state.navigate).toHaveBeenCalledWith("/chats/alice-slug", { replace: true });
|
||
});
|
||
|
||
it("shows a retry state when identity fails instead of waiting for disabled history", async () => {
|
||
recordAgentChatVisit("company-a", "user-a", "alice");
|
||
state.sessionError = new Error("Session unavailable");
|
||
state.chatsFetched = false;
|
||
await render();
|
||
expect(state.navigate).not.toHaveBeenCalled();
|
||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Couldn’t load your chats");
|
||
});
|
||
});
|