diff --git a/control-plane/web/client/src/services/api.ts b/control-plane/web/client/src/services/api.ts index b9f555c77..6feaf4903 100644 --- a/control-plane/web/client/src/services/api.ts +++ b/control-plane/web/client/src/services/api.ts @@ -11,6 +11,7 @@ import type { MCPHealthResponseModeAware, MCPServerMetrics, } from '../types/agentfield'; +import { getErrorMessage } from './errorMessage'; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/ui/v1'; const STORAGE_KEY = "af_api_key"; @@ -97,11 +98,11 @@ async function fetchWrapper(url: string, options?: RequestInit & { timeout?: clearTimeout(timeoutId); if (!response.ok) { - const errorData = await response.json().catch(() => ({ - message: 'Request failed with status ' + response.status - })); + const errorData: unknown = await response.json().catch(() => null); - throw new Error(errorData.message || `HTTP error! status: ${response.status}`); + throw new Error( + getErrorMessage(errorData, `HTTP error! status: ${response.status}`), + ); } return response.json() as Promise; diff --git a/control-plane/web/client/src/services/errorMessage.ts b/control-plane/web/client/src/services/errorMessage.ts new file mode 100644 index 000000000..505891d8e --- /dev/null +++ b/control-plane/web/client/src/services/errorMessage.ts @@ -0,0 +1,34 @@ +const ERROR_MESSAGE_KEYS = ["message", "detail", "error", "reason", "msg"] as const; +const MAX_ERROR_DEPTH = 4; + +function messageFromValue(value: unknown, depth = 0): string | null { + if (depth > MAX_ERROR_DEPTH || value == null) return null; + + if (typeof value === "string") { + const message = value.trim(); + return message || null; + } + + if (value instanceof Error) return messageFromValue(value.message, depth + 1); + + if (Array.isArray(value)) { + const messages = value + .map((entry) => messageFromValue(entry, depth + 1)) + .filter((entry): entry is string => Boolean(entry)); + return messages.length > 0 ? messages.join("; ") : null; + } + + if (typeof value !== "object") return String(value); + + const record = value as Record; + for (const key of ERROR_MESSAGE_KEYS) { + const message = messageFromValue(record[key], depth + 1); + if (message) return message; + } + + return null; +} + +export function getErrorMessage(value: unknown, fallback: string): string { + return messageFromValue(value) ?? fallback; +} diff --git a/control-plane/web/client/src/services/sessionsApi.ts b/control-plane/web/client/src/services/sessionsApi.ts index 2b8b2e351..53162f297 100644 --- a/control-plane/web/client/src/services/sessionsApi.ts +++ b/control-plane/web/client/src/services/sessionsApi.ts @@ -1,4 +1,5 @@ import { getGlobalApiKey } from "./api"; +import { getErrorMessage } from "./errorMessage"; const API_BASE = "/api/v1"; @@ -56,8 +57,10 @@ async function fetchJson(url: string, options: RequestInit = {}): Promise }), }); if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || errorData.error || `Request failed with status ${response.status}`); + const errorData: unknown = await response.json().catch(() => null); + throw new Error( + getErrorMessage(errorData, `Request failed with status ${response.status}`), + ); } return response.json(); } diff --git a/control-plane/web/client/src/test/services/errorMessage.test.ts b/control-plane/web/client/src/test/services/errorMessage.test.ts new file mode 100644 index 000000000..f5c18e40a --- /dev/null +++ b/control-plane/web/client/src/test/services/errorMessage.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { getErrorMessage } from "@/services/errorMessage"; + +describe("getErrorMessage", () => { + it("extracts a nested backend error message", () => { + expect( + getErrorMessage( + { + error: { + code: "session_start_failed", + message: "No active node can start this session", + }, + }, + "fallback", + ), + ).toBe("No active node can start this session"); + }); + + it("joins FastAPI validation messages", () => { + expect( + getErrorMessage( + { + detail: [ + { loc: ["body", "target"], msg: "Field required" }, + { loc: ["body", "model"], msg: "Unsupported model" }, + ], + }, + "fallback", + ), + ).toBe("Field required; Unsupported model"); + }); + + it("uses the fallback for unknown structured errors instead of coercing them", () => { + expect(getErrorMessage({ code: "node_offline" }, "fallback")).toBe("fallback"); + }); + + it("uses the fallback for empty and circular values", () => { + const circular: Record = {}; + circular.self = circular; + + expect(getErrorMessage({}, "fallback")).toBe("fallback"); + expect(getErrorMessage(circular, "fallback")).toBe("fallback"); + }); +}); diff --git a/control-plane/web/client/src/test/services/sessionsApi.test.ts b/control-plane/web/client/src/test/services/sessionsApi.test.ts index 0ef4717ee..1a49ce44c 100644 --- a/control-plane/web/client/src/test/services/sessionsApi.test.ts +++ b/control-plane/web/client/src/test/services/sessionsApi.test.ts @@ -74,4 +74,35 @@ describe("sessionsApi", () => { sessionsApi.invokeTool("sess-1", "missing", { input: {} }), ).rejects.toThrow("bad tool"); }); + + it("extracts a message from a structured session error", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + json: async () => ({ + error: { + code: "node_offline", + message: "No active node can start this session", + }, + }), + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(sessionsApi.startSession("support.voice")).rejects.toThrow( + "No active node can start this session", + ); + }); + + it("falls back to the HTTP status when the response has no safe message", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + json: async () => ({ code: "node_offline" }), + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(sessionsApi.startSession("support.voice")).rejects.toThrow( + "Request failed with status 503", + ); + }); });