mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-02 02:07:25 +08:00
fix(agentmail): preserve explicit account selection during recovery
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -35,9 +35,10 @@ export function AgentMailIntentSetup({ companyId, agentId, requestId, savedCrede
|
||||
const [setupRequestId, setSetupRequestId] = useState(draft?.setupRequestId ?? requestId);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
// A refresh can interrupt the response after the server has saved the key.
|
||||
// Recover that account unless the user deliberately started a different setup.
|
||||
// Recover that account unless the user selected another key or started a
|
||||
// different setup after the interrupted request.
|
||||
const [credentialId, setCredentialId] = useState(draft?.credentialId
|
||||
?? (!draft || draft.setupRequestId === requestId ? savedCredentialId ?? null : null));
|
||||
?? (!draft || (draft.setupRequestId === requestId && !draft.selectedCredentialId) ? savedCredentialId ?? null : null));
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState<string | null>(draft?.selectedCredentialId ?? null);
|
||||
const [inboxConnectionId, setInboxConnectionId] = useState(readyConnectionId ?? draft?.inboxConnectionId ?? null);
|
||||
useEffect(() => {
|
||||
@@ -45,8 +46,8 @@ export function AgentMailIntentSetup({ companyId, agentId, requestId, savedCrede
|
||||
catch { /* Keep setup usable without browser storage. */ }
|
||||
}, [draftKey, setupRequestId, credentialId, inboxConnectionId, selectedCredentialId]);
|
||||
const changeKey = useMutation({
|
||||
mutationFn: async () => {
|
||||
const pending = (await emailApi.list(companyId)).find(inbox => inbox.id === setupRequestId && inbox.status !== "archived");
|
||||
mutationFn: async (pendingRequestId: string) => {
|
||||
const pending = (await emailApi.list(companyId)).find(inbox => inbox.id === pendingRequestId && inbox.status !== "archived");
|
||||
if (pending?.address) throw new Error(`The address ${pending.address} is already reserved. Finish setup with its saved key.`);
|
||||
if (pending) await emailApi.control(pending.id, "remove");
|
||||
},
|
||||
@@ -59,14 +60,14 @@ export function AgentMailIntentSetup({ companyId, agentId, requestId, savedCrede
|
||||
},
|
||||
});
|
||||
const setup = useMutation({
|
||||
mutationFn: async () => {
|
||||
let connectionId = inboxConnectionId;
|
||||
mutationFn: async (submitted: { accountId: string | null; apiKey: string; requestId: string; inboxId: string | null }) => {
|
||||
let connectionId = submitted.inboxId;
|
||||
if (!connectionId) {
|
||||
let accountId = credentialId || selectedCredentialId;
|
||||
let accountId = submitted.accountId;
|
||||
if (!accountId) {
|
||||
const account = await emailApi.connect(companyId, {
|
||||
apiKey: apiKey.trim(), grantKind: "organization", allAgents: false,
|
||||
agentIds: [agentId], idempotencyKey: setupRequestId,
|
||||
apiKey: submitted.apiKey, grantKind: "organization", allAgents: false,
|
||||
agentIds: [agentId], idempotencyKey: submitted.requestId,
|
||||
});
|
||||
accountId = account.id;
|
||||
setCredentialId(accountId);
|
||||
@@ -75,7 +76,7 @@ export function AgentMailIntentSetup({ companyId, agentId, requestId, savedCrede
|
||||
setCredentialId(accountId);
|
||||
const inbox = await emailApi.setup(companyId, {
|
||||
assignedAgentId: agentId, credentialConnectionId: accountId,
|
||||
receiveMode: "websocket", idempotencyKey: setupRequestId,
|
||||
receiveMode: "websocket", idempotencyKey: submitted.requestId,
|
||||
});
|
||||
connectionId = inbox.connectionId;
|
||||
setInboxConnectionId(connectionId);
|
||||
@@ -88,7 +89,10 @@ export function AgentMailIntentSetup({ companyId, agentId, requestId, savedCrede
|
||||
});
|
||||
return <form className="mt-4 space-y-4" data-testid="agentmail-inline-setup" onSubmit={event => {
|
||||
event.preventDefault();
|
||||
if (!setup.isPending && !changeKey.isPending && !declining) setup.mutate();
|
||||
const accountId = credentialId || selectedCredentialId;
|
||||
if (!setup.isPending && !changeKey.isPending && !declining && (accountId || inboxConnectionId || apiKey.trim())) {
|
||||
setup.mutate({ accountId, apiKey: apiKey.trim(), requestId: setupRequestId, inboxId: inboxConnectionId });
|
||||
}
|
||||
}}>
|
||||
{credentialId || inboxConnectionId
|
||||
? <p className="text-sm text-muted-foreground">{inboxConnectionId ? "Your inbox is ready. Continue to resume the chat." : "API key saved. Finish creating the inbox."}</p>
|
||||
@@ -96,7 +100,7 @@ export function AgentMailIntentSetup({ companyId, agentId, requestId, savedCrede
|
||||
onConnectionChange={id => { setSelectedCredentialId(id); setApiKey(""); }}
|
||||
value={apiKey} onChange={setApiKey} disabled={setup.isPending || changeKey.isPending || declining} />}
|
||||
{credentialId && !inboxConnectionId && <Button type="button" variant="link" size="sm" className="h-auto p-0"
|
||||
disabled={setup.isPending || changeKey.isPending || declining} onClick={() => changeKey.mutate()}>
|
||||
disabled={setup.isPending || changeKey.isPending || declining} onClick={() => changeKey.mutate(setupRequestId)}>
|
||||
{changeKey.isPending ? "Checking setup…" : "Change API key"}
|
||||
</Button>}
|
||||
{changeKey.error && <p className="text-sm text-destructive" role="alert">{changeKey.error.message}</p>}
|
||||
|
||||
@@ -126,12 +126,12 @@ async function flush() {
|
||||
async function waitForAssertion(assertion: () => void, attempts = 20) {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
await flush();
|
||||
try {
|
||||
assertion();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await flush();
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
@@ -215,6 +215,8 @@ beforeEach(() => {
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) await act(() => root?.unmount());
|
||||
await queryClient.cancelQueries();
|
||||
queryClient.clear();
|
||||
host?.remove();
|
||||
document.body
|
||||
.querySelectorAll("[data-radix-focus-guard]")
|
||||
@@ -607,6 +609,7 @@ describe("AgentMail inline setup", () => {
|
||||
payload: { ...pendingConnectionIntentInteraction.payload, purpose: "channel", serviceSlug: "agentmail", serviceName: "AgentMail" },
|
||||
};
|
||||
async function enterKey() {
|
||||
await waitForAssertion(() => expect(document.querySelector('input[type="password"]')).not.toBeNull());
|
||||
const input = document.querySelector('input[type="password"]') as HTMLInputElement;
|
||||
await act(() => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "fixture-api-key");
|
||||
@@ -662,6 +665,8 @@ describe("AgentMail inline setup", () => {
|
||||
emailListMock.mockResolvedValue([{ id: interaction.id, address: null, status: "draft" }]);
|
||||
renderBody(interaction); await flush();
|
||||
await waitForAssertion(() => expect(button("Connect AgentMail")?.disabled).toBe(false));
|
||||
expect((document.querySelector("select") as HTMLSelectElement)?.value).toBe("saved-account");
|
||||
expect(emailConnectMock).not.toHaveBeenCalled();
|
||||
await act(() => button("Connect AgentMail")!.click()); await flush();
|
||||
expect(emailConnectMock).not.toHaveBeenCalled();
|
||||
await act(() => button("Change API key")!.click()); await flush();
|
||||
@@ -702,6 +707,21 @@ describe("AgentMail inline setup", () => {
|
||||
credentialConnectionId: "server-saved-account", idempotencyKey: resumedInteraction.id,
|
||||
}));
|
||||
});
|
||||
it("keeps a different saved-key selection when recovering an interrupted save", async () => {
|
||||
const resumedInteraction = { ...interaction, id: "a381e91e-7127-427d-9d2d-519d4deba89f" };
|
||||
sessionStorage.setItem(`paperclip.agentmail-inline:${interaction.companyId}:${resumedInteraction.id}`, JSON.stringify({
|
||||
setupRequestId: resumedInteraction.id, credentialId: null, inboxConnectionId: null, selectedCredentialId: "selected-account",
|
||||
}));
|
||||
emailCredentialsMock.mockResolvedValue([{ id: "selected-account", label: "My selected key", scope: "organization", createdAt: "2026-10-01T14:00:00Z" }]);
|
||||
setupOptionsMock.mockResolvedValue({ existingConnections: [], emailSetup: { credentialConnectionId: "earlier-account", readyConnectionId: null } });
|
||||
renderBody(resumedInteraction); await flush();
|
||||
await waitForAssertion(() => expect((document.querySelector('select') as HTMLSelectElement)?.value).toBe("selected-account"));
|
||||
await act(() => button("Connect AgentMail")!.click()); await flush();
|
||||
expect(emailConnectMock).not.toHaveBeenCalled();
|
||||
expect(emailSetupMock).toHaveBeenCalledWith(interaction.companyId, expect.objectContaining({
|
||||
credentialConnectionId: "selected-account", idempotencyKey: resumedInteraction.id,
|
||||
}));
|
||||
});
|
||||
it("does not restore an abandoned server account after the user changes keys", async () => {
|
||||
const replacementRequestId = "a381e91e-7127-427d-9d2d-519d4deba89f";
|
||||
sessionStorage.setItem(`paperclip.agentmail-inline:${interaction.companyId}:${interaction.id}`, JSON.stringify({
|
||||
|
||||
Reference in New Issue
Block a user