diff --git a/tests/e2e/chat-adapters-ui-messaging.spec.ts b/tests/e2e/chat-adapters-ui-messaging.spec.ts index a542eff6465..d169b4031e4 100644 --- a/tests/e2e/chat-adapters-ui-messaging.spec.ts +++ b/tests/e2e/chat-adapters-ui-messaging.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page, type Route } from "@playwright/test"; +import { writeFile } from "node:fs/promises"; import { fulfill, @@ -1304,6 +1305,85 @@ test.describe("Exact failed chat run retry", () => { ); }); + test("a cross-company UUID redirect keeps same-name agent caches isolated", async ({ + page, + request, + }, testInfo) => { + const other = await seedCompanyAndAgent(request); + const foreignTitle = "Foreign UUID agent cache sentinel"; + const scopedTitle = "Company-scoped Maya cache sentinel"; + const [foreignCompany, scopedCompany] = await Promise.all([ + json<{ id: string; name: string }>(await request.get(`/api/companies/${other.companyId}`), "read foreign company"), + json<{ id: string; name: string }>(await request.get(`/api/companies/${seed.companyId}`), "read scoped company"), + ]); + expect(foreignCompany.id).toBe(other.companyId); + expect(scopedCompany.id).toBe(seed.companyId); + expect(foreignCompany.name).not.toBe(scopedCompany.name); + await json(await request.patch(`/api/agents/${other.agentId}`, { + data: { title: foreignTitle }, + }), "set foreign agent title"); + await json(await request.patch(`/api/agents/${seed.agentId}`, { + data: { title: scopedTitle }, + }), "set scoped agent title"); + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + let observed!: () => void; + const canonicalRead = new Promise((resolve) => { observed = resolve; }); + page.once("close", release); + await page.route("**/api/agents/maya?*", async (route) => { + const url = new URL(route.request().url()); + if (url.searchParams.get("companyId") !== seed.companyId) { + await route.fallback(); + return; + } + const response = await route.fetch(); + const agent = await response.json(); + expect(agent.id).toBe(seed.agentId); + observed(); + await held; + await route.fulfill({ response }); + }); + try { + await page.goto(`/${seed.prefix}/agents/${other.agentId}/overview`); + await expect(page).toHaveURL(new RegExp(`/${other.prefix}/agents/maya/overview$`)); + await expect(page.getByText(foreignTitle, { exact: true }).first()).toBeVisible(); + await expect.poll(() => page.evaluate(() => localStorage.getItem("paperclip.selectedCompanyId"))) + .toBe(other.companyId); + await page.getByRole("button", { name: `Open ${foreignCompany.name} organization switcher`, exact: true }).click(); + const menuItems = await page.getByRole("menuitem").allTextContents(); + const scopedOption = page.getByRole("menuitem").filter({ hasText: scopedCompany.name }); + const chosenOption = await scopedOption.innerText(); + const routeHistory: string[] = []; + page.on("framenavigated", (frame) => { if (frame === page.mainFrame()) routeHistory.push(frame.url()); }); + await scopedOption.click(); + await writeFile(testInfo.outputPath("cross-company-menu-context.json"), JSON.stringify({ + foreignCompany, scopedCompany, seed, other, menuItems, chosenOption, routeHistory, + afterSwitchUrl: page.url(), + afterSwitchSelectedCompany: await page.evaluate(() => localStorage.getItem("paperclip.selectedCompanyId")), + }, null, 2)); + await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/dashboard$`)); + await page.getByRole("link", { name: "Agents", exact: true }).first().click(); + await page.getByRole("link", { name: "Maya", exact: true }).first().click(); + await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/agents/maya(?:/overview)?$`)); + await expect(page.getByText(foreignTitle, { exact: true })).toHaveCount(0); + await Promise.race([ + canonicalRead, + page.getByText("Paperclip hit an error", { exact: true }).waitFor({ timeout: 15_000 }).then(() => { + throw new Error("Cross-company canonical navigation reached the app error boundary"); + }), + ]); + release(); + await expect(page.getByText(scopedTitle, { exact: true }).first()).toBeVisible(); + await expect(page.getByText(foreignTitle, { exact: true })).toHaveCount(0); + await expect(page.getByText("Paperclip hit an error", { exact: true })).toHaveCount(0); + await expect.poll(() => page.evaluate(() => localStorage.getItem("paperclip.selectedCompanyId"))) + .toBe(seed.companyId); + await page.screenshot({ path: testInfo.outputPath("cross-company-canonical-agent.png") }); + } finally { + release(); + } + }); + for (const surface of ["agent run", "Inbox", "Legacy Inbox"] as const) { for (const outcome of ["queued", "deferred", "denied"] as const) { test(`${surface}: selected run ${outcome} preserves exact retry authority and truthful feedback`, async ({ diff --git a/ui/src/hooks/useCompanyPageMemory.test.ts b/ui/src/hooks/useCompanyPageMemory.test.ts index 4f669015218..be925ebaa5d 100644 --- a/ui/src/hooks/useCompanyPageMemory.test.ts +++ b/ui/src/hooks/useCompanyPageMemory.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { getRememberedPathOwnerCompanyId, + isRememberableCompanyPath, sanitizeRememberedPathForCompany, } from "../lib/company-page-memory"; @@ -52,6 +53,25 @@ describe("getRememberedPathOwnerCompanyId", () => { }); describe("sanitizeRememberedPathForCompany", () => { + it("does not remember an agent UUID before its company-scoped alias is resolved", () => { + expect(isRememberableCompanyPath("/agents/209d54ec-4f22-4897-b65d-517a49593023/overview")).toBe(false); + expect(isRememberableCompanyPath("/agents/maya/overview")).toBe(true); + }); + + it("falls back to dashboard for a legacy remembered agent UUID with unknown ownership", () => { + expect(sanitizeRememberedPathForCompany({ + path: "/PAP/agents/209d54ec-4f22-4897-b65d-517a49593023/runs/run-1", + companyPrefix: "PAP", + })).toBe("/dashboard"); + }); + + it("keeps canonical agent run paths and their query when switching companies", () => { + expect(sanitizeRememberedPathForCompany({ + path: "/agents/maya/runs/run-1?mode=raw", + companyPrefix: "PAP", + })).toBe("/agents/maya/runs/run-1?mode=raw"); + }); + it("keeps remembered issue paths that belong to the target company", () => { expect( sanitizeRememberedPathForCompany({ diff --git a/ui/src/lib/company-page-memory.ts b/ui/src/lib/company-page-memory.ts index 9ee5b01e9c9..1269c657846 100644 --- a/ui/src/lib/company-page-memory.ts +++ b/ui/src/lib/company-page-memory.ts @@ -1,3 +1,4 @@ +import { isUuidLike } from "@paperclipai/shared"; import { extractCompanyPrefixFromPath, normalizeCompanyPrefix, @@ -10,8 +11,12 @@ export function isRememberableCompanyPath(path: string): boolean { const pathname = path.split("?")[0] ?? ""; const segments = pathname.split("/").filter(Boolean); if (segments.length === 0) return true; - const [root] = segments; + const [root, entityId] = segments; if (GLOBAL_SEGMENTS.has(root!)) return false; + // UUID agent links can resolve to another accessible company. Remember the + // company-scoped alias after resolution, rather than replaying an unverified + // UUID when the operator switches back to the company in the original URL. + if (root === "agents" && isUuidLike(entityId)) return false; return true; } diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index e2f69b200e2..db28428a66e 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -31,6 +31,7 @@ import { useCompany } from "../context/CompanyContext"; import { useToastActions } from "../context/ToastContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; +import { applyCompanyPrefix } from "../lib/company-routes"; import { copyTextToClipboard } from "../lib/clipboard"; import { AgentSkillsTab } from "./agent-skills/AgentSkillsTab"; import { AgentConfigForm } from "../components/AgentConfigForm"; @@ -766,7 +767,7 @@ export function AgentDetail() { tab?: string; runId?: string; }>(); - const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany(); + const { companies, selectedCompanyId, selectionSource, setSelectedCompanyId } = useCompany(); const { closePanel } = usePanel(); const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); @@ -817,6 +818,7 @@ export function AgentDetail() { }); const resolvedCompanyId = agent?.companyId ?? selectedCompanyId; const canonicalAgentRef = agent ? agentRouteRef(agent) : routeAgentRef; + const agentCompany = companies.find((company) => company.id === agent?.companyId); const handleLegacyTabChange = useCallback((next: string) => { if (!prepareAgentNavigation()) return; navigate(`/agents/${canonicalAgentRef || routeAgentRef}/${next}`); @@ -964,38 +966,41 @@ export function AgentDetail() { useEffect(() => { if (!agent) return; + if (!agentCompany) return; + const companyMismatch = companyPrefix?.toUpperCase() !== agentCompany.issuePrefix.toUpperCase(); if (routeAgentRef !== canonicalAgentRef) { // Reuse the authorized response across the alias redirect. An empty // alias query would unmount the selected run and discard its mutations. + // UUID lookups can return another accessible company's agent. Its alias + // belongs only to that company's cache, even if the URL used another scope. queryClient.setQueryData( - [...queryKeys.agents.detail(canonicalAgentRef), lookupCompanyId ?? null], + [...queryKeys.agents.detail(canonicalAgentRef), agent.companyId], agent, ); - if (lookupCompanyId !== agent.companyId) { - queryClient.setQueryData( - [...queryKeys.agents.detail(canonicalAgentRef), agent.companyId], - agent, - ); - } } if (urlRunId) { - if (routeAgentRef !== canonicalAgentRef) { - navigate(`/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true }); + if (routeAgentRef !== canonicalAgentRef || companyMismatch) { + navigate(`/${agentCompany.issuePrefix}/agents/${canonicalAgentRef}/runs/${urlRunId}`, { replace: true }); } return; } if (legacyAuditSection) return; const canonicalTab = activeView === "run-detail" ? "overview" : activeView; - if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) { - navigate(agentDetailHref(canonicalAgentRef, canonicalTab), { replace: true }); + if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab || companyMismatch) { + navigate(`/${agentCompany.issuePrefix}${agentDetailHref(canonicalAgentRef, canonicalTab)}`, { replace: true }); return; } - }, [agent, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, legacyAuditSection, navigate, lookupCompanyId, queryClient]); + }, [agent, agentCompany, companyPrefix, routeAgentRef, canonicalAgentRef, urlRunId, urlTab, activeView, legacyAuditSection, navigate, queryClient]); useEffect(() => { if (!agent?.companyId || agent.companyId === selectedCompanyId) return; + // Let an explicit organization switch finish its navigation first. + if (selectionSource === "manual") return; + // The route owns selection until canonical navigation reaches this company. + // Competing with Layout's old-prefix selection can cause an update loop. + if (routeCompanyId && routeCompanyId !== agent.companyId) return; setSelectedCompanyId(agent.companyId, { source: "route_sync" }); - }, [agent?.companyId, selectedCompanyId, setSelectedCompanyId]); + }, [agent?.companyId, routeCompanyId, selectedCompanyId, selectionSource, setSelectedCompanyId]); // Invoke / pause / resume / terminate / duplicate / reset live in the shared // AgentActionButtons component. The detail header keeps only "approve" here, @@ -1169,10 +1174,10 @@ export function AgentDetail() { if (error) return

{error.message}

; if (!agent) return null; if (!urlRunId && legacyAuditSection) { - return ; + return ; } if (!urlRunId && !urlTab) { - return ; + return ; } const isPendingApproval = agent.status === "pending_approval"; const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain";