diff --git a/src/components/chat/GlobalChatbox.test.tsx b/src/components/chat/GlobalChatbox.test.tsx index f2a3d51..9fd71e3 100644 --- a/src/components/chat/GlobalChatbox.test.tsx +++ b/src/components/chat/GlobalChatbox.test.tsx @@ -6,6 +6,7 @@ import { GlobalChatbox } from "./GlobalChatbox"; const createSession = jest.fn(); const mockFetchAgentRuntimeHealth = jest.fn(); +const mockFetchAgentModels = jest.fn(); let mockCurrentProjectId = "project-1"; jest.mock("@refinedev/core", () => ({ @@ -13,7 +14,7 @@ jest.mock("@refinedev/core", () => ({ })); jest.mock("@/lib/chatModels", () => ({ - fetchAgentModels: jest.fn(() => new Promise(() => {})), + fetchAgentModels: (...args: unknown[]) => mockFetchAgentModels(...args), })); jest.mock("@/lib/agentRuntime", () => ({ @@ -100,12 +101,15 @@ describe("GlobalChatbox lifecycle", () => { createSession.mockClear(); mockFetchAgentRuntimeHealth.mockReset(); mockFetchAgentRuntimeHealth.mockImplementation(() => new Promise(() => {})); + mockFetchAgentModels.mockReset(); + mockFetchAgentModels.mockImplementation(() => new Promise(() => {})); mockCurrentProjectId = "project-1"; }); afterEach(() => { jest.runOnlyPendingTimers(); jest.useRealTimers(); + jest.restoreAllMocks(); }); it("keeps content mounted and preserves the session across close and reopen", async () => { @@ -146,4 +150,45 @@ describe("GlobalChatbox lifecycle", () => { expect(screen.getByText("Workspace state: unavailable")).toBeInTheDocument(); }); + + it("reserves models unavailable for an empty model configuration", async () => { + mockFetchAgentRuntimeHealth.mockResolvedValueOnce(true); + mockFetchAgentModels.mockResolvedValueOnce({ models: [] }); + render(); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(screen.getByText("Workspace state: models_unavailable")).toBeInTheDocument(); + }); + + it("reports an auth dependency failure as unavailable and retries quickly", async () => { + jest.spyOn(console, "error").mockImplementation(() => undefined); + mockFetchAgentRuntimeHealth.mockResolvedValue(true); + mockFetchAgentModels + .mockRejectedValueOnce(new Error("authentication service unavailable")) + .mockResolvedValueOnce({ + defaultModel: "provider/model", + models: [{ id: "provider/model", label: "Model" }], + }); + + render(); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByText("Workspace state: unavailable")).toBeInTheDocument(); + expect(mockFetchAgentModels).toHaveBeenCalledTimes(1); + + await act(async () => { + jest.advanceTimersByTime(5_000); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByText("Workspace state: ready")).toBeInTheDocument(); + expect(mockFetchAgentModels).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index cf19fb7..ef308fc 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -31,6 +31,7 @@ import { useAgentToolActions } from "./hooks/useAgentToolActions"; const STREAMING_BOTTOM_RESERVE_PX = 180; const STREAMING_SCROLL_RESTORE_AT_PX = STREAMING_BOTTOM_RESERVE_PX - 36; const AGENT_RUNTIME_POLL_MS = 30_000; +const AGENT_RUNTIME_RETRY_MS = 5_000; const AGENT_RUNTIME_TIMEOUT_MS = 8_000; export const GlobalChatbox: React.FC = ({ open, onClose }) => { @@ -97,7 +98,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { setRuntimeState("unavailable"); setModelOptions([]); setSelectedModel(undefined); - return; + return "unavailable" as const; } const modelConfig = await fetchAgentModels(controller.signal); @@ -106,7 +107,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { setRuntimeState("models_unavailable"); setModelOptions([]); setSelectedModel(undefined); - return; + return "models_unavailable" as const; } setModelOptions(modelConfig.models); @@ -117,12 +118,14 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { return modelConfig.defaultModel; }); setRuntimeState("ready"); + return "ready" as const; } catch (error) { if (requestId !== runtimeRequestIdRef.current) return; console.error("[GlobalChatbox] Failed to check agent runtime:", error); - setRuntimeState(runtimeHealthy ? "models_unavailable" : "unavailable"); + setRuntimeState("unavailable"); setModelOptions([]); setSelectedModel(undefined); + return "unavailable" as const; } finally { window.clearTimeout(timeoutId); if (runtimeAbortRef.current === controller) { @@ -134,14 +137,23 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { useEffect(() => { if (!open) return; - void refreshAgentRuntime(true); - const intervalId = window.setInterval( - () => void refreshAgentRuntime(false), - AGENT_RUNTIME_POLL_MS, - ); + let cancelled = false; + let pollTimerId: number | undefined; + + const pollRuntime = async (showChecking: boolean) => { + const nextState = await refreshAgentRuntime(showChecking); + if (cancelled || !nextState) return; + pollTimerId = window.setTimeout( + () => void pollRuntime(false), + nextState === "ready" ? AGENT_RUNTIME_POLL_MS : AGENT_RUNTIME_RETRY_MS, + ); + }; + + void pollRuntime(true); return () => { - window.clearInterval(intervalId); + cancelled = true; + if (pollTimerId !== undefined) window.clearTimeout(pollTimerId); runtimeRequestIdRef.current += 1; runtimeAbortRef.current?.abort(); runtimeAbortRef.current = null;