fix(chat): distinguish auth outages from model failures
Runtime model checks previously mapped every authenticated endpoint failure to models_unavailable. Treat request failures as service outages and retry failed checks after five seconds; empty model configurations remain distinct.
This commit is contained in:
@@ -6,6 +6,7 @@ import { GlobalChatbox } from "./GlobalChatbox";
|
|||||||
|
|
||||||
const createSession = jest.fn();
|
const createSession = jest.fn();
|
||||||
const mockFetchAgentRuntimeHealth = jest.fn();
|
const mockFetchAgentRuntimeHealth = jest.fn();
|
||||||
|
const mockFetchAgentModels = jest.fn();
|
||||||
let mockCurrentProjectId = "project-1";
|
let mockCurrentProjectId = "project-1";
|
||||||
|
|
||||||
jest.mock("@refinedev/core", () => ({
|
jest.mock("@refinedev/core", () => ({
|
||||||
@@ -13,7 +14,7 @@ jest.mock("@refinedev/core", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("@/lib/chatModels", () => ({
|
jest.mock("@/lib/chatModels", () => ({
|
||||||
fetchAgentModels: jest.fn(() => new Promise(() => {})),
|
fetchAgentModels: (...args: unknown[]) => mockFetchAgentModels(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("@/lib/agentRuntime", () => ({
|
jest.mock("@/lib/agentRuntime", () => ({
|
||||||
@@ -100,12 +101,15 @@ describe("GlobalChatbox lifecycle", () => {
|
|||||||
createSession.mockClear();
|
createSession.mockClear();
|
||||||
mockFetchAgentRuntimeHealth.mockReset();
|
mockFetchAgentRuntimeHealth.mockReset();
|
||||||
mockFetchAgentRuntimeHealth.mockImplementation(() => new Promise(() => {}));
|
mockFetchAgentRuntimeHealth.mockImplementation(() => new Promise(() => {}));
|
||||||
|
mockFetchAgentModels.mockReset();
|
||||||
|
mockFetchAgentModels.mockImplementation(() => new Promise(() => {}));
|
||||||
mockCurrentProjectId = "project-1";
|
mockCurrentProjectId = "project-1";
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.runOnlyPendingTimers();
|
jest.runOnlyPendingTimers();
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
|
jest.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps content mounted and preserves the session across close and reopen", async () => {
|
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();
|
expect(screen.getByText("Workspace state: unavailable")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reserves models unavailable for an empty model configuration", async () => {
|
||||||
|
mockFetchAgentRuntimeHealth.mockResolvedValueOnce(true);
|
||||||
|
mockFetchAgentModels.mockResolvedValueOnce({ models: [] });
|
||||||
|
render(<GlobalChatbox open onClose={jest.fn()} />);
|
||||||
|
|
||||||
|
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(<GlobalChatbox open onClose={jest.fn()} />);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { useAgentToolActions } from "./hooks/useAgentToolActions";
|
|||||||
const STREAMING_BOTTOM_RESERVE_PX = 180;
|
const STREAMING_BOTTOM_RESERVE_PX = 180;
|
||||||
const STREAMING_SCROLL_RESTORE_AT_PX = STREAMING_BOTTOM_RESERVE_PX - 36;
|
const STREAMING_SCROLL_RESTORE_AT_PX = STREAMING_BOTTOM_RESERVE_PX - 36;
|
||||||
const AGENT_RUNTIME_POLL_MS = 30_000;
|
const AGENT_RUNTIME_POLL_MS = 30_000;
|
||||||
|
const AGENT_RUNTIME_RETRY_MS = 5_000;
|
||||||
const AGENT_RUNTIME_TIMEOUT_MS = 8_000;
|
const AGENT_RUNTIME_TIMEOUT_MS = 8_000;
|
||||||
|
|
||||||
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||||
@@ -97,7 +98,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
setRuntimeState("unavailable");
|
setRuntimeState("unavailable");
|
||||||
setModelOptions([]);
|
setModelOptions([]);
|
||||||
setSelectedModel(undefined);
|
setSelectedModel(undefined);
|
||||||
return;
|
return "unavailable" as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelConfig = await fetchAgentModels(controller.signal);
|
const modelConfig = await fetchAgentModels(controller.signal);
|
||||||
@@ -106,7 +107,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
setRuntimeState("models_unavailable");
|
setRuntimeState("models_unavailable");
|
||||||
setModelOptions([]);
|
setModelOptions([]);
|
||||||
setSelectedModel(undefined);
|
setSelectedModel(undefined);
|
||||||
return;
|
return "models_unavailable" as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
setModelOptions(modelConfig.models);
|
setModelOptions(modelConfig.models);
|
||||||
@@ -117,12 +118,14 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
return modelConfig.defaultModel;
|
return modelConfig.defaultModel;
|
||||||
});
|
});
|
||||||
setRuntimeState("ready");
|
setRuntimeState("ready");
|
||||||
|
return "ready" as const;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestId !== runtimeRequestIdRef.current) return;
|
if (requestId !== runtimeRequestIdRef.current) return;
|
||||||
console.error("[GlobalChatbox] Failed to check agent runtime:", error);
|
console.error("[GlobalChatbox] Failed to check agent runtime:", error);
|
||||||
setRuntimeState(runtimeHealthy ? "models_unavailable" : "unavailable");
|
setRuntimeState("unavailable");
|
||||||
setModelOptions([]);
|
setModelOptions([]);
|
||||||
setSelectedModel(undefined);
|
setSelectedModel(undefined);
|
||||||
|
return "unavailable" as const;
|
||||||
} finally {
|
} finally {
|
||||||
window.clearTimeout(timeoutId);
|
window.clearTimeout(timeoutId);
|
||||||
if (runtimeAbortRef.current === controller) {
|
if (runtimeAbortRef.current === controller) {
|
||||||
@@ -134,14 +137,23 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
void refreshAgentRuntime(true);
|
let cancelled = false;
|
||||||
const intervalId = window.setInterval(
|
let pollTimerId: number | undefined;
|
||||||
() => void refreshAgentRuntime(false),
|
|
||||||
AGENT_RUNTIME_POLL_MS,
|
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 () => {
|
return () => {
|
||||||
window.clearInterval(intervalId);
|
cancelled = true;
|
||||||
|
if (pollTimerId !== undefined) window.clearTimeout(pollTimerId);
|
||||||
runtimeRequestIdRef.current += 1;
|
runtimeRequestIdRef.current += 1;
|
||||||
runtimeAbortRef.current?.abort();
|
runtimeAbortRef.current?.abort();
|
||||||
runtimeAbortRef.current = null;
|
runtimeAbortRef.current = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user