Skip to content
This repository was archived by the owner on Jul 27, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions __tests__/components/features/home/home-chat-launcher.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { AxiosError } from "axios";

import { HomeChatLauncher } from "#/components/features/home/home-chat-launcher";
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
Expand Down Expand Up @@ -257,6 +258,26 @@ function makeConversationResponse(
} as never;
}

function makeLimitError(limit = 3): AxiosError {
return new AxiosError(
"Request failed with status code 429",
"ERR_BAD_REQUEST",
undefined,
undefined,
{
status: 429,
data: {
detail: {
error: "CONCURRENCY_LIMIT_REACHED",
message: `You have reached your limit of ${limit} concurrent conversations.`,
limit,
current: limit,
},
},
} as never,
);
}

const localBackend = {
backend: {
id: "local-id",
Expand Down Expand Up @@ -620,4 +641,22 @@ describe("HomeChatLauncher", () => {
undefined,
);
});

it("suppresses the generic error toast when creation hits the cloud conversation limit", async () => {
mockUseActiveBackend.mockReturnValue(cloudBackend);
const dismissSpy = vi.spyOn(toast, "dismiss");
vi.spyOn(
AgentServerConversationService,
"createConversation",
).mockRejectedValue(makeLimitError());

renderLauncher();
const user = userEvent.setup();
await user.click(screen.getByTestId("stub-chat-submit"));

// onError dismisses the loading toast first; once that has run, the
// suppression branch (skip displayErrorToast) has executed too.
await waitFor(() => expect(dismissSpy).toHaveBeenCalled());
expect(mockDisplayErrorToast).not.toHaveBeenCalled();
});
});
38 changes: 38 additions & 0 deletions __tests__/query-client-config.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { AxiosError } from "axios";
import { MutationObserver } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createAgentServerQueryClient } from "#/query-client-config";
import { __resetActiveStoreForTests } from "#/api/backend-registry/active-store";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
import { useConversationLimitStore } from "#/stores/conversation-limit-store";

afterEach(() => {
window.localStorage.clear();
Expand Down Expand Up @@ -89,4 +91,40 @@ describe("createAgentServerQueryClient", () => {

expect(toastSpy).not.toHaveBeenCalled();
});

it("opens the conversation-limit modal instead of toasting on a cloud limit error", async () => {
const toastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
const client = createAgentServerQueryClient();
const limitError = new AxiosError(
"Request failed with status code 429",
"ERR_BAD_REQUEST",
undefined,
undefined,
{
status: 429,
data: {
detail: {
error: "CONCURRENCY_LIMIT_REACHED",
message:
"You have reached your limit of 3 concurrent conversations.",
limit: 3,
current: 3,
},
},
} as never,
);

const observer = new MutationObserver(client, {
mutationFn: async () => {
throw limitError;
},
});
await observer.mutate().catch(() => {});

expect(useConversationLimitStore.getState()).toMatchObject({
isOpen: true,
limit: 3,
});
expect(toastSpy).not.toHaveBeenCalled();
});
});
72 changes: 72 additions & 0 deletions __tests__/utils/concurrency-limit-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { AxiosError } from "axios";
import { describe, expect, it } from "vitest";
import {
isConcurrencyLimitError,
getConcurrencyLimit,
} from "#/utils/concurrency-limit-error";
import { DEFAULT_CONCURRENT_SANDBOX_LIMIT } from "#/utils/constants";

function makeAxiosError(status: number, data: unknown): AxiosError {
return new AxiosError(
`Request failed with status code ${status}`,
"ERR_BAD_REQUEST",
undefined,
undefined,
{ status, data } as never,
);
}

// limit deliberately differs from DEFAULT_CONCURRENT_SANDBOX_LIMIT so the
// "reads the detail" case can't pass by accidentally hitting the fallback.
const limitResponse = {
detail: {
error: "CONCURRENCY_LIMIT_REACHED",
message: "You have reached your limit of 5 concurrent conversations.",
limit: 5,
current: 5,
},
};

describe("isConcurrencyLimitError", () => {
it("is true for a 429 whose detail.error is CONCURRENCY_LIMIT_REACHED", () => {
expect(isConcurrencyLimitError(makeAxiosError(429, limitResponse))).toBe(
true,
);
});

it("is false for a 429 carrying a different detail.error", () => {
expect(
isConcurrencyLimitError(
makeAxiosError(429, { detail: { error: "RATE_LIMITED" } }),
),
).toBe(false);
});

it("is false for the limit error code on a non-429 status", () => {
expect(isConcurrencyLimitError(makeAxiosError(403, limitResponse))).toBe(
false,
);
});

it("is false for a non-Axios error", () => {
expect(isConcurrencyLimitError(new Error("boom"))).toBe(false);
});
});

describe("getConcurrencyLimit", () => {
it("returns the limit carried in the error detail", () => {
const error = makeAxiosError(429, limitResponse);
if (!isConcurrencyLimitError(error))
throw new Error("expected limit error");
expect(getConcurrencyLimit(error)).toBe(5);
});

it("falls back to the default when the detail omits a limit", () => {
const error = makeAxiosError(429, {
detail: { error: "CONCURRENCY_LIMIT_REACHED" },
});
if (!isConcurrencyLimitError(error))
throw new Error("expected limit error");
expect(getConcurrencyLimit(error)).toBe(DEFAULT_CONCURRENT_SANDBOX_LIMIT);
});
});
44 changes: 44 additions & 0 deletions src/components/features/conversation/conversation-limit-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { modalTitleClassName } from "#/utils/modal-classes";
import { DEFAULT_CONCURRENT_SANDBOX_LIMIT } from "#/utils/constants";

interface ConversationLimitModalProps {
onClose: () => void;
limit?: number;
}

export function ConversationLimitModal({
onClose,
limit = DEFAULT_CONCURRENT_SANDBOX_LIMIT,
}: ConversationLimitModalProps) {
const { t } = useTranslation("openhands");

return (
<ModalBackdrop onClose={onClose}>
<div
data-testid="conversation-limit-modal"
className="bg-base-secondary p-4 rounded-xl flex flex-col gap-4 border border-[var(--oh-border)] max-w-[460px]"
>
<h3 className={modalTitleClassName}>
{t(I18nKey.CONVERSATION_LIMIT$TITLE)}
</h3>
<p className="text-sm leading-5 text-[var(--oh-muted)]">
{t(I18nKey.CONVERSATION_LIMIT$DESCRIPTION, { limit })}
</p>
<div className="w-full flex justify-end">
<BrandButton
testId="conversation-limit-close-button"
type="button"
variant="primary"
onClick={onClose}
>
{t(I18nKey.BUTTON$CLOSE)}
</BrandButton>
</div>
</div>
</ModalBackdrop>
);
}
3 changes: 3 additions & 0 deletions src/components/features/home/home-chat-launcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
displayErrorToast,
TOAST_OPTIONS,
} from "#/utils/custom-toast-handlers";
import { isConcurrencyLimitError } from "#/utils/concurrency-limit-error";
import { getWorkspacesUnsupportedMessage } from "#/utils/workspaces-compatibility";
import type { PluginSpec } from "#/api/conversation-service/agent-server-conversation-service.types";
import { PluginPickerModal } from "#/components/features/plugins/plugin-picker-modal";
Expand Down Expand Up @@ -203,6 +204,8 @@ export function HomeChatLauncher() {
navigate(`/conversations/${targetConversationId}`);
} catch (error) {
toast.dismiss(toastId);
// The conversation-limit modal already explains this failure.
if (isConcurrencyLimitError(error)) return;
displayErrorToast(error instanceof Error ? error.message : null);
}
})();
Expand Down
34 changes: 34 additions & 0 deletions src/i18n/translation.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,38 @@
{
"CONVERSATION_LIMIT$TITLE": {
"en": "Conversation limit reached",
"ja": "会話数の上限に達しました",
"zh-CN": "已达到对话数量上限",
"zh-TW": "已達到對話數量上限",
"ko-KR": "대화 한도에 도달했습니다",
"no": "Samtalegrense nådd",
"it": "Limite di conversazioni raggiunto",
"pt": "Limite de conversas atingido",
"es": "Límite de conversaciones alcanzado",
"ar": "تم الوصول إلى حد المحادثات",
"fr": "Limite de conversations atteinte",
"tr": "Konuşma sınırına ulaşıldı",
"de": "Konversationslimit erreicht",
"uk": "Досягнуто ліміту розмов",
"ca": "S'ha assolit el límit de converses"
},
"CONVERSATION_LIMIT$DESCRIPTION": {
"en": "You've reached the maximum number of concurrent conversations ({{limit}}) for your OpenHands Cloud plan. To start a new one, stop the runtime of an existing conversation.",
"ja": "OpenHands Cloud プランで同時に実行できる会話の上限({{limit}})に達しました。新しい会話を開始するには、既存の会話のランタイムを停止してください。",
"zh-CN": "您已达到 OpenHands Cloud 套餐允许的并发对话数量上限({{limit}})。若要开始新的对话,请先停止某个现有对话的运行时。",
"zh-TW": "您已達到 OpenHands Cloud 方案允許的並行對話數量上限({{limit}})。若要開始新的對話,請先停止某個現有對話的執行階段。",
"ko-KR": "OpenHands Cloud 플랜에서 동시에 실행할 수 있는 대화 수({{limit}})의 한도에 도달했습니다. 새 대화를 시작하려면 기존 대화의 런타임을 중지하세요.",
"no": "Du har nådd det maksimale antallet samtidige samtaler ({{limit}}) for OpenHands Cloud-abonnementet ditt. For å starte en ny, stopp kjøretiden til en eksisterende samtale.",
"it": "Hai raggiunto il numero massimo di conversazioni simultanee ({{limit}}) consentito dal tuo piano OpenHands Cloud. Per avviarne una nuova, arresta il runtime di una conversazione esistente.",
"pt": "Você atingiu o número máximo de conversas simultâneas ({{limit}}) do seu plano OpenHands Cloud. Para iniciar uma nova, pare o runtime de uma conversa existente.",
"es": "Has alcanzado el número máximo de conversaciones simultáneas ({{limit}}) de tu plan de OpenHands Cloud. Para iniciar una nueva, detén el runtime de una conversación existente.",
"ar": "لقد وصلت إلى الحد الأقصى لعدد المحادثات المتزامنة ({{limit}}) في خطة OpenHands Cloud الخاصة بك. لبدء محادثة جديدة، أوقف وقت تشغيل إحدى المحادثات الحالية.",
"fr": "Vous avez atteint le nombre maximal de conversations simultanées ({{limit}}) de votre forfait OpenHands Cloud. Pour en démarrer une nouvelle, arrêtez le runtime d'une conversation existante.",
"tr": "OpenHands Cloud planınız için eşzamanlı konuşma sayısının üst sınırına ({{limit}}) ulaştınız. Yeni bir konuşma başlatmak için mevcut bir konuşmanın çalışma zamanını durdurun.",
"de": "Sie haben die maximale Anzahl gleichzeitiger Konversationen ({{limit}}) Ihres OpenHands Cloud-Tarifs erreicht. Um eine neue zu starten, stoppen Sie die Laufzeitumgebung einer bestehenden Konversation.",
"uk": "Ви досягли максимальної кількості одночасних розмов ({{limit}}) у вашому плані OpenHands Cloud. Щоб розпочати нову, зупиніть середовище виконання наявної розмови.",
"ca": "Heu assolit el nombre màxim de converses simultànies ({{limit}}) del vostre pla d'OpenHands Cloud. Per iniciar-ne una de nova, atureu el temps d'execució d'una conversa existent."
},
"COMMON$PATTERN": {
"ar": "النمط:",
"ca": "Patró:",
Expand Down
14 changes: 14 additions & 0 deletions src/query-client-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import { retrieveAxiosErrorMessage } from "./utils/retrieve-axios-error-message"
import { displayErrorToast } from "./utils/custom-toast-handlers";
import { getActiveBackend } from "#/api/backend-registry/active-store";
import { recordBackendSuccess } from "#/api/backend-registry/health-store";
import {
isConcurrencyLimitError,
getConcurrencyLimit,
} from "#/utils/concurrency-limit-error";
import { useConversationLimitStore } from "#/stores/conversation-limit-store";

const handle401Error = (error: AxiosError, client: QueryClient) => {
if (error?.response?.status === 401 || error?.status === 401) {
Expand Down Expand Up @@ -66,6 +71,15 @@ export const createAgentServerQueryClient = () => {
onError: (error, _, __, mutation) => {
handle401Error(error, client);

// A cloud backend rejecting creation for too many concurrent
// conversations gets a dedicated modal instead of a generic toast.
if (isConcurrencyLimitError(error)) {
useConversationLimitStore
.getState()
.showLimitModal(getConcurrencyLimit(error));
return;
}

const disableToast =
mutation?.meta?.disableToast ?? mutation?.options.meta?.disableToast;

Expand Down
20 changes: 20 additions & 0 deletions src/routes/root-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useAppTitle } from "#/hooks/use-app-title";
import { ReactRouterNavigationProvider } from "./react-router-navigation-provider";
import { OnboardingHost } from "#/components/features/onboarding";
import { isOnboardingPreviewActive } from "#/components/features/onboarding/onboarding-preview";
import { useConversationLimitStore } from "#/stores/conversation-limit-store";

const EnvironmentSwitchOverlay = React.lazy(
() => import("#/components/features/backends/environment-switch-overlay"),
Expand All @@ -37,6 +38,11 @@ const CommandMenu = React.lazy(() =>
default: m.CommandMenu,
})),
);
const ConversationLimitModal = React.lazy(() =>
import("#/components/features/conversation/conversation-limit-modal").then(
(m) => ({ default: m.ConversationLimitModal }),
),
);

export function ErrorBoundary() {
const error = useRouteError();
Expand Down Expand Up @@ -76,6 +82,11 @@ export default function MainApp() {
const appTitle = useAppTitle();
const { data: settings } = useSettings();
const config = useConfig();
const {
isOpen: isConversationLimitModalOpen,
limit: conversationLimit,
closeLimitModal,
} = useConversationLimitStore();

useSyncAutomationTelemetryConsent();

Expand Down Expand Up @@ -138,6 +149,15 @@ export default function MainApp() {
<Outlet />
</div>
</div>

{isConversationLimitModalOpen && (
<React.Suspense fallback={null}>
<ConversationLimitModal
limit={conversationLimit ?? undefined}
onClose={closeLimitModal}
/>
</React.Suspense>
)}
</div>
<React.Suspense fallback={null}>
<EnvironmentSwitchOverlay />
Expand Down
34 changes: 34 additions & 0 deletions src/stores/conversation-limit-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { create } from "zustand";

/**
* Drives the "conversation limit reached" modal. Opened when a cloud backend
* rejects conversation creation for exceeding the user's concurrent-conversation
* limit (see `isConcurrencyLimitError`). `limit` is the cap reported by the
* backend, shown in the modal copy.
*/
interface ConversationLimitState {
isOpen: boolean;
limit: number | null;
}

interface ConversationLimitActions {
showLimitModal: (limit: number) => void;
closeLimitModal: () => void;
}

type ConversationLimitStore = ConversationLimitState & ConversationLimitActions;

const initialState: ConversationLimitState = {
isOpen: false,
limit: null,
};

export const useConversationLimitStore = create<ConversationLimitStore>(
(set) => ({
...initialState,

showLimitModal: (limit: number) => set(() => ({ isOpen: true, limit })),

closeLimitModal: () => set(() => ({ ...initialState })),
}),
);
Loading
Loading