diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index ac1ebdc2a9f..2ea4f268145 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -42,7 +42,9 @@ mode's form is shown below. Advanced opens the existing compatible-connection picker and provider setup. Execution environment selection lives in Configure. Back from subscription sign-in cancels that attempt and pending prompt or status -reads. A session returned after Back is released instead of reopening sign-in. +reads. A session returned after Back is released instead of reopening sign-in. If an +aborted read hides the session ID, a separate owner lookup releases the attempt +before a replacement can start. Selecting a subscription again starts a fresh attempt. Reloading the page can still resume a sign-in that was not cancelled. diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 0de5f60a0d5..f192d65bc4c 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -346,7 +346,7 @@ export const agentsApi = { // Reads the caller's active login session for one company and adapter, with // no session id, so the browser rediscovers its own session after a reload // with no local state. A 404 means no active session for the caller. - getActiveAdapterAuthLoginSession: (companyId: string, type: string, options?: { signal?: AbortSignal }) => + getActiveAdapterAuthLoginSession: (companyId: string, type: string, options?: { signal?: AbortSignal; fresh?: boolean }) => api.get( `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions/active`, { cache: "no-store", ...options }, @@ -386,7 +386,7 @@ export const agentsApi = { // Reads the caller's active Claude setup-token login session, with no // session id, so the browser rediscovers its own session after a reload with // no local state. A 404 means no active session for the caller. - getActiveClaudeSetupTokenLoginSession: (companyId: string, options?: { signal?: AbortSignal }) => + getActiveClaudeSetupTokenLoginSession: (companyId: string, options?: { signal?: AbortSignal; fresh?: boolean }) => api.get( `/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/active`, { cache: "no-store", ...options }, diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index 9dc2e1d9648..a8cd96d090f 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -4014,6 +4014,68 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( } finally { await act(async () => root.unmount()); } }); + it.each([ + ["claude_local", false], ["codex_local", false], ["claude_local", true], ["codex_local", true], + ] as const)("discovers and releases %s after its resume read rejects on abort (panel mounted: %s)", async (adapterType, panelMounted) => { + const claude = adapterType === "claude_local"; + const activeApi = claude ? mockAgentsApi.getActiveClaudeSetupTokenLoginSession : mockAgentsApi.getActiveAdapterAuthLoginSession; + const cancelApi = claude ? mockAgentsApi.cancelClaudeSetupTokenLogin : mockAgentsApi.cancelAdapterAuthLogin; + const startApi = claude ? mockAgentsApi.startClaudeSetupTokenLogin : mockAgentsApi.startAdapterAuthLogin; + const session = { sessionId: "abandoned-session", environmentId: "env-sandbox-1", status: "waiting_for_user", expiresAt: null, + panelMode: claude ? "submitted_browser_code" : "displayed_code", + aiConnection: { provider: claude ? "anthropic" : "openai", method: "subscription", ownership: "personal", agentIds: [], allAgents: true } }; + let serverActive = true; + let aborts = 0; + let finishDiscovery!: () => void; + let finishCancellation = () => {}; + const discovery = new Promise(resolve => { finishDiscovery = resolve; }); + activeApi.mockImplementation((...args: unknown[]) => { + const options = args.at(-1) as { signal?: AbortSignal; fresh?: boolean }; + if (options?.fresh) return discovery.then(() => serverActive ? session : null); + if (!serverActive) return Promise.resolve(null); + return new Promise((_resolve, reject) => options?.signal?.addEventListener("abort", () => { + aborts++; + reject(new DOMException("The request was aborted", "AbortError")); + }, { once: true })); + }); + cancelApi.mockImplementation(() => new Promise(resolve => { finishCancellation = () => { serverActive = false; resolve({}); }; })); + mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" }); + const { root } = await openStep4({ adapterType }); + const matchMedia = window.matchMedia; + const restoreMotion = () => Object.defineProperty(window, "matchMedia", { configurable: true, writable: true, value: matchMedia }); + try { + if (panelMounted) await pickSource(claude ? /Claude/ : /OpenAI/); + else { + Object.defineProperty(window, "matchMedia", { configurable: true, writable: true, + value: (media: string) => ({ matches: false, media, onchange: null, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {}, dispatchEvent: () => false }) }); + const tile = [...document.body.querySelectorAll('[role="radio"]')].find(tile => (claude ? /Claude/ : /OpenAI/).test(tile.textContent ?? ""))!; + await act(async () => (tile as HTMLElement).click()); + expect(activeApi).toHaveBeenCalledTimes(1); + restoreMotion(); + } + await act(async () => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim().startsWith("Back"))!.click()); + for (let i = 0; i < 12; i++) await flushReact(); + expect(aborts).toBeGreaterThan(0); + await vi.waitFor(() => expect((activeApi.mock.calls as unknown[][]).some(call => (call.at(-1) as { fresh?: boolean })?.fresh)).toBe(true)); + expect(document.querySelectorAll('[role="radio"][aria-checked="true"]')).toHaveLength(0); + await pickSource(claude ? /Claude/ : /OpenAI/); + expect(startApi).not.toHaveBeenCalled(); + await act(async () => finishDiscovery()); + await vi.waitFor(() => expect(cancelApi).toHaveBeenCalled()); + expect(startApi).not.toHaveBeenCalled(); + await act(async () => finishCancellation()); + for (let i = 0; i < 12; i++) await flushReact(); + expect(serverActive).toBe(false); + expect(startApi).toHaveBeenCalledTimes(1); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + } finally { + restoreMotion(); + finishDiscovery(); + finishCancellation(); + await act(async () => root.unmount()); + } + }); + it("cancels claude_local on Back and starts a fresh sign-in when selected again", async () => { const session = { sessionId: "claude-session-1", diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 29a147f8903..312e373a16a 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -1,3 +1,4 @@ +import { cancelAdapterLoginSession, cancelPendingAdapterLogin } from "./useAdapterLoginCancellation"; import { useQueryView } from "@/components/QueryView"; import type { AgentRunner, AgentRunnerChoice } from "@paperclipai/shared"; import { needsClaudeSetupQualification, qualifyClaudeSetup } from "../lib/qualify-claude-setup"; @@ -1161,10 +1162,8 @@ function OnboardingWizardInner({ ? await agentsApi.getActiveClaudeSetupTokenLoginSession(createdCompanyId!, { signal }) : await agentsApi.getActiveAdapterAuthLoginSession(createdCompanyId!, adapterType, { signal }); if (session && dismissedLoginResumes.current.has(loginResumeKey)) { - const cancel = adapterCaps.login?.panelMode === "submitted_browser_code" - ? agentsApi.cancelClaudeSetupTokenLogin(createdCompanyId!, session.sessionId) - : agentsApi.cancelAdapterAuthLogin(createdCompanyId!, adapterType, session.sessionId); - await cancel.catch(() => {}); + await cancelAdapterLoginSession(createdCompanyId!, adapterType, + adapterCaps.login?.panelMode === "submitted_browser_code", session.sessionId); return null; } return session; @@ -1567,7 +1566,11 @@ function OnboardingWizardInner({ function unwindConnectStep() { localLogin.cancel(); dismissedLoginResumes.current.add(loginResumeKey); - if (createdCompanyId) void queryClient.cancelQueries({ queryKey: queryKeys.agents.activeLoginSession(createdCompanyId, adapterType) }); + if (createdCompanyId) { + void queryClient.cancelQueries({ queryKey: queryKeys.agents.activeLoginSession(createdCompanyId, adapterType) }); + if (canShowAdapterLogin && credentialMode !== "api") void cancelPendingAdapterLogin(createdCompanyId, adapterType, + adapterCaps.login?.panelMode === "submitted_browser_code"); + } connectAttemptRef.current++; hiringAgentRef.current = null; setLoading(false); diff --git a/ui/src/components/useAdapterLoginCancellation.ts b/ui/src/components/useAdapterLoginCancellation.ts index b385a9cf635..90286a6932b 100644 --- a/ui/src/components/useAdapterLoginCancellation.ts +++ b/ui/src/components/useAdapterLoginCancellation.ts @@ -3,6 +3,34 @@ import { useQueryClient } from "@tanstack/react-query"; import { agentsApi } from "../api/agents"; const pendingCancellations = new Map>(); +const sessionCancellations = new Map>(); + +export function cancelAdapterLoginSession(companyId: string, adapterType: string, submittedCode: boolean, sessionId: string) { + const key = JSON.stringify([companyId, adapterType, sessionId]); + const previous = sessionCancellations.get(key); + if (previous) return previous; + const request = (submittedCode + ? agentsApi.cancelClaudeSetupTokenLogin(companyId, sessionId) + : agentsApi.cancelAdapterAuthLogin(companyId, adapterType, sessionId)).catch(() => {}); + sessionCancellations.set(key, request); + void request.then(() => { if (sessionCancellations.get(key) === request) sessionCancellations.delete(key); }); + return request; +} + +export function cancelPendingAdapterLogin(companyId: string, adapterType: string, submittedCode: boolean, pending: Promise[] = [], discover = true) { + const key = JSON.stringify([companyId, adapterType]); + const previous = pendingCancellations.get(key); + const cleanup = Promise.allSettled([...(previous ? [previous] : []), ...pending]).then(async () => { + if (!discover) return; + const session = submittedCode + ? await agentsApi.getActiveClaudeSetupTokenLoginSession(companyId, { fresh: true }) + : await agentsApi.getActiveAdapterAuthLoginSession(companyId, adapterType, { fresh: true }); + if (session) await cancelAdapterLoginSession(companyId, adapterType, submittedCode, session.sessionId); + }).catch(() => {}); + pendingCancellations.set(key, cleanup); + void cleanup.then(() => { if (pendingCancellations.get(key) === cleanup) pendingCancellations.delete(key); }); + return cleanup; +} export function useAdapterLoginCancellation(active: boolean, companyId: string, adapterType: string, submittedCode: boolean, sessionId: string | null) { const queryClient = useQueryClient(); @@ -14,9 +42,7 @@ export function useAdapterLoginCancellation(active: boolean, companyId: string, const cancel = useCallback((id: string) => { const previous = cancelled.current.get(id); if (previous) return previous; - const request = (submittedCode - ? agentsApi.cancelClaudeSetupTokenLogin(companyId, id) - : agentsApi.cancelAdapterAuthLogin(companyId, adapterType, id)).catch(() => {}); + const request = cancelAdapterLoginSession(companyId, adapterType, submittedCode, id); cancelled.current.set(id, request); return request; }, [companyId, adapterType, submittedCode]); @@ -45,11 +71,8 @@ export function useAdapterLoginCancellation(active: boolean, companyId: string, ? [["claude-setup-token-active-session", companyId], ["claude-setup-token-status", companyId], ["claude-setup-token-prompt", companyId]] : [["adapter-login-active-session", companyId, adapterType], ["adapter-login-status", companyId, adapterType]]; for (const queryKey of keys) void queryClient.cancelQueries({ queryKey }); - const cleanup = Promise.allSettled([...requests.current, ...(sessionId ? [cancel(sessionId)] : [])]); - pendingCancellations.set(ownerKey, cleanup); - void cleanup.then(() => { - if (pendingCancellations.get(ownerKey) === cleanup) pendingCancellations.delete(ownerKey); - }); + void cancelPendingAdapterLogin(companyId, adapterType, submittedCode, + [...requests.current, ...(sessionId ? [cancel(sessionId)] : [])], !sessionId); }, [active, companyId, adapterType, submittedCode, sessionId, queryClient, cancel, ownerKey]); return { accept, isActive: () => scope.current.active };