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:
2026-08-06 19:35:24 +08:00
parent 0a47534ddb
commit 0dd521d8c9
2 changed files with 67 additions and 10 deletions
+46 -1
View File
@@ -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);
});
}); });
+21 -9
View File
@@ -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;