fix(ai-connections): recover failed session lookup

Observe the shared session query in account details, expose read errors and retry session verification before account reads. Reuse personal default eligibility in account controls.

Co-Authored-By: Paperclip <[email protected]>
This commit is contained in:
cryppadottaandPaperclip committed 2026-11-07 16:00:59 +00:00
1 parent 8518f06433
commit 1767cce0ab
5 files changed
+81 -12

No files matched your search

+6 -1
View File
@@ -57,6 +57,11 @@ const sessionQueryOptions = {
queryFn: () => authApi.getSession(),
} as const;
/** Observe the shared session read when a surface needs error and retry state. */
export function useAccountSessionQuery() {
return useQuery(sessionQueryOptions);
}
/**
* The signed-in account, as the cache currently understands it.
*
@@ -79,7 +84,7 @@ export function useAccountIdentity(): {
bootstrap: BoardBootstrap | undefined;
updatedAt: number;
} {
const { data: session, isSuccess, isError, dataUpdatedAt } = useQuery(sessionQueryOptions);
const { data: session, isSuccess, isError, dataUpdatedAt } = useAccountSessionQuery();
const userId = session?.user.id ?? null;
return {
userId, settled: isSuccess, failed: isError, updatedAt: dataUpdatedAt,
@@ -7,6 +7,7 @@ import { cn } from "@/lib/utils";
import { AI_PROVIDERS, type AiConnectionSummary } from "./model";
import { describeError } from "@/api/errors";
import { AiDefaultCrown } from "./AiConnectionDefault";
import { isOwnAiAccount } from "./useAiConnectionDefaults";
/** AI-only account controls; identity, access and navigation belong to AppDetail. */
export function AiConnectionAccountControls({
@@ -25,13 +26,13 @@ export function AiConnectionAccountControls({
const [revoking, setRevoking] = useState(false);
const [revokePending, setRevokePending] = useState(false);
const [revokeError, setRevokeError] = useState<string>();
const ownPersonal = account.ownership === "personal" && account.ownerUserId === currentUserId;
const eligibleForDefault = isOwnAiAccount(account, currentUserId);
const available = account.status === "connected";
const activeDefault = account.isDefault && available;
const providerName = AI_PROVIDERS[account.provider].name;
return (
<section className="space-y-4" aria-label="AI account settings">
{ownPersonal && !account.routing && (
{eligibleForDefault && (
<div className={cn(
"flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border p-3",
activeDefault && "border-(--status-task-done)/30 bg-(--status-task-done)/5",
@@ -9,7 +9,7 @@ import { ManagedAiConnectionDetails } from "./ManagedAiConnectionDetails";
import { TooltipProvider } from "@/components/ui/tooltip";
vi.mock("@/lib/router", () => ({ useNavigate: () => vi.fn() }));
vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => ({ userId: "user", settled: true }) }));
vi.mock("@/api/auth", () => ({ authApi: { getSession: vi.fn(async () => ({ user: { id: "user" } })) } }));
const account: AiManagedConnectionSummary = {
id: "connection", grantId: "grant", companyId: "company", provider: "anthropic", method: "subscription",
@@ -5,10 +5,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { AiManagedConnectionSummary, ToolConnection } from "@paperclipai/shared";
import { queryKeys } from "@/lib/queryKeys";
import { authApi } from "@/api/auth";
import { ManagedAiConnectionDetails } from "./ManagedAiConnectionDetails";
vi.mock("@/lib/router", () => ({ useNavigate: () => vi.fn() }));
vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => ({ userId: "user", settled: true }) }));
vi.mock("@/api/auth", () => ({ authApi: { getSession: vi.fn() } }));
vi.mock("./AiConnectionAccountControls", () => ({
AiConnectionAccountControls: ({ account, grant }: { account: AiManagedConnectionSummary; grant: { id: string } }) => (
<section aria-label="AI account settings" data-grant-id={grant.id}>{account.name}</section>
@@ -39,6 +40,7 @@ beforeEach(() => {
blocked = null;
denied = false;
signals = [];
vi.mocked(authApi.getSession).mockReset().mockResolvedValue({ user: { id: "user" } } as Awaited<ReturnType<typeof authApi.getSession>>);
fetchMock.mockReset().mockImplementation((url: string, init: RequestInit) => {
if (url.endsWith("/active-runs")) return Promise.resolve(Response.json([]));
const kind = url.endsWith("/grants") ? "grants" : "accounts";
@@ -50,7 +52,7 @@ beforeEach(() => {
if (denied) return Promise.resolve(Response.json({ error: "Access denied" }, { status: 403 }));
return Promise.resolve(Response.json(kind === "grants" ? grants : accounts));
});
client = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, gcTime: Infinity } } });
client = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, gcTime: Infinity, retry: false } } });
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
@@ -68,7 +70,8 @@ async function render() {
await act(async () => {
root.render(<QueryClientProvider client={client}><ManagedAiConnectionDetails connection={connection} /></QueryClientProvider>);
});
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
await act(async () => { await vi.advanceTimersByTimeAsync(10); });
await act(async () => { await vi.advanceTimersByTimeAsync(10); });
}
it("reuses the grants already loaded by the permissions page", async () => {
@@ -78,6 +81,56 @@ it("reuses the grants already loaded by the permissions page", async () => {
expect(fetchMock.mock.calls.some(([url]) => url.endsWith("/grants"))).toBe(false);
});
it("reports a failed session lookup and retries it before loading account data", async () => {
vi.mocked(authApi.getSession).mockRejectedValueOnce(new Error("Session lookup failed"));
await render();
expect(host.querySelector('[role="alert"]')).not.toBeNull();
expect(host.textContent).not.toContain("Loading AI account…");
expect(host.querySelector("button")?.textContent).toBe("Retry");
const accountReads = () => fetchMock.mock.calls.filter(([url]) => url.endsWith("/ai-connections"));
expect(accountReads()).toHaveLength(0);
expect(client.getQueryData(["ai-connections", "company", undefined, null])).toBeUndefined();
await act(async () => {
host.querySelector("button")!.click();
await vi.advanceTimersByTimeAsync(10);
});
await act(async () => { await vi.advanceTimersByTimeAsync(10); });
expect(authApi.getSession).toHaveBeenCalledTimes(2);
expect(accountReads()).toHaveLength(1);
expect(host.querySelector('[aria-label="AI account settings"]')?.textContent).toBe(account.name);
expect(host.querySelector('[role="alert"]')).toBeNull();
});
it("waits for session recovery before retrying an earlier account error", async () => {
await render();
denied = true;
await act(async () => {
await client.invalidateQueries({ queryKey: ["ai-connections"] });
await vi.advanceTimersByTimeAsync(10);
});
vi.mocked(authApi.getSession).mockRejectedValue(new Error("Session lookup failed"));
await act(async () => {
await client.invalidateQueries({ queryKey: queryKeys.auth.session });
await vi.advanceTimersByTimeAsync(10);
});
const calls = fetchMock.mock.calls.length;
let recover!: (session: Awaited<ReturnType<typeof authApi.getSession>>) => void;
vi.mocked(authApi.getSession).mockImplementation(() => new Promise(resolve => { recover = resolve; }));
await act(async () => {
host.querySelector("button")!.click();
await vi.advanceTimersByTimeAsync(10);
});
expect(fetchMock).toHaveBeenCalledTimes(calls);
expect(host.querySelector('[aria-label="AI account settings"]')).toBeNull();
denied = false;
await act(async () => {
recover({ user: { id: "user" } } as Awaited<ReturnType<typeof authApi.getSession>>);
await vi.advanceTimersByTimeAsync(10);
});
await act(async () => { await vi.advanceTimersByTimeAsync(10); });
expect(host.querySelector('[aria-label="AI account settings"]')?.textContent).toBe(account.name);
});
it.each(["personal", "shared", "missing-first"] as const)("keeps an accessible %s grant visible without a saved personal default", async mode => {
const personal = { ...account, ownerUserId: "user", grantId: "personal-grant", name: "Personal account", isDefault: false };
const shared = { ...account, ownership: "shared" as const, grantId: "shared-grant", name: "Shared account", isDefault: false };
@@ -13,7 +13,7 @@ import type { ToolConnection, AiConnectionMetadata } from "@paperclipai/shared";
import { aiMethodLabel } from "./model";
import { queryKeys } from "@/lib/queryKeys";
import { retryTransientOnly } from "@/lib/query-client";
import { useAccountIdentity } from "@/api/companies-query";
import { useAccountSessionQuery } from "@/api/companies-query";
import { useAiConnectionDefaults } from "./useAiConnectionDefaults";
import { AiDefaultError } from "./AiConnectionDefault";
import { AI_PROVIDERS } from "./model";
@@ -46,7 +46,11 @@ export function ManagedAiConnectionDetails({
queryFn: () =>
aiConnectionsApi.activeRuns(connection.companyId, connection.id),
});
const defaults = useAiConnectionDefaults(connection.companyId, useAccountIdentity());
const session = useAccountSessionQuery();
const defaults = useAiConnectionDefaults(connection.companyId, {
userId: session.data?.user.id ?? null,
settled: session.isSuccess,
});
const accounts = defaults.accounts;
const grants = useQuery({
queryKey: queryKeys.tools.connectionGrants(connection.id),
@@ -54,6 +58,7 @@ export function ManagedAiConnectionDetails({
retry: retryTransientOnly(1),
});
const runsView = useQueryView(runs);
const sessionView = useQueryView(session);
const accountsView = useQueryView(accounts);
const grantsView = useQueryView(grants);
const refresh = () => client.invalidateQueries();
@@ -68,11 +73,12 @@ export function ManagedAiConnectionDetails({
});
const visibleGrants = new Set(grants.data?.grants.map(grant => grant.id));
const candidates = defaults.data?.connections.filter(account => account.id === connection.id && account.companyId === connection.companyId && visibleGrants.has(account.grantId)) ?? [];
// Routed personal accounts also need details; default eligibility excludes them.
const ownPersonal = (account: (typeof candidates)[number]) => account.ownership === "personal" && account.ownerUserId === defaults.data?.currentUserId;
const account = candidates.find(account => ownPersonal(account) && account.isDefault)
?? candidates.find(ownPersonal) ?? candidates[0];
const grant = grants.data?.grants.find((g) => g.id === account?.grantId);
const loadError = [accountsView, grantsView].find((view) => view.kind === "error")?.error;
const loadError = [sessionView, accountsView, grantsView].find((view) => view.kind === "error")?.error;
const error = stop.error;
const loadFailure = loadError ? (
<div role="alert" className="space-y-2">
@@ -80,8 +86,12 @@ export function ManagedAiConnectionDetails({
<Button
variant="outline"
size="sm"
disabled={accounts.isFetching || grants.isFetching}
disabled={sessionView.isFetching || accounts.isFetching || grants.isFetching}
onClick={() => {
if (sessionView.kind === "error") {
sessionView.retry();
return;
}
if (accounts.error) void accounts.refetch();
if (grants.error) void grants.refetch();
}}
@@ -94,7 +104,7 @@ export function ManagedAiConnectionDetails({
if (!account || !grant)
return (
<p role="status" className="text-sm text-muted-foreground">
{accounts.isPending || grants.isPending || accountsView.kind === "reconnecting" || grantsView.kind === "reconnecting"
{accounts.isPending || grants.isPending || sessionView.kind === "reconnecting" || accountsView.kind === "reconnecting" || grantsView.kind === "reconnecting"
? "Loading AI account…"
: "This account is not available to you."}
</p>