Skip to content
Merged
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
8 changes: 5 additions & 3 deletions ui/src/app/models/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ export default function ModelsPage() {
try {
setLoading(true);
const response = await getModelConfigs();
if (response.error || !response.data) {
throw new Error(response.error || "Failed to fetch models");
if (response.error) {
throw new Error(response.error);
}
setModels(response.data);
// An empty list is valid (no ModelConfigs deployed). The backend omits
// `data` for empty collections, so treat missing data as an empty list.
setModels(response.data ?? []);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to fetch models";
setError(errorMessage);
Expand Down
9 changes: 6 additions & 3 deletions ui/src/components/AgentsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,14 @@ export function AgentsProvider({ children }: AgentsProviderProps) {
const fetchModels = useCallback(async () => {
try {
const response = await getModelConfigs();
if (!response.data || response.error) {
throw new Error(response.error || "Failed to fetch models");
if (response.error) {
throw new Error(response.error);
}

setModels(response.data);
// An empty list is a valid result (e.g. no ModelConfigs deployed). The
// backend omits `data` for empty collections (json omitempty), so treat
// missing data as an empty list rather than a fetch failure.
setModels(response.data ?? []);
setError("");
} catch (err) {
console.error("Error fetching models:", err);
Expand Down
68 changes: 66 additions & 2 deletions ui/src/lib/__tests__/AgentsProvider.namespace.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { render, screen, waitFor } from "@testing-library/react";
import { AgentsProvider } from "@/components/AgentsProvider";
import { act, render, screen, waitFor } from "@testing-library/react";
import { AgentsProvider, useAgents } from "@/components/AgentsProvider";
import { getAgents } from "@/app/actions/agents";
import { getTools } from "@/app/actions/tools";
import { getModelConfigs } from "@/app/actions/modelConfigs";
Expand All @@ -22,6 +22,22 @@ const mockGetAgents = getAgents as jest.MockedFunction<typeof getAgents>;
const mockGetTools = getTools as jest.MockedFunction<typeof getTools>;
const mockGetModelConfigs = getModelConfigs as jest.MockedFunction<typeof getModelConfigs>;

// A tiny consumer that surfaces the two pieces of provider state we assert on:
// the shared `error` string and the list of model configs.
function ModelsConsumer() {
const { error, models } = useAgents();
return (
<div>
<p data-testid="model-error">{error}</p>
<ul data-testid="model-list">
{models.map((m) => (
<li key={m.ref}>{m.ref}</li>
))}
</ul>
</div>
);
}

describe("AgentsProvider list fetching", () => {
beforeEach(() => {
jest.clearAllMocks();
Expand All @@ -43,4 +59,52 @@ describe("AgentsProvider list fetching", () => {
await waitFor(() => expect(mockGetTools).toHaveBeenCalled());
expect(mockGetAgents).not.toHaveBeenCalled();
});

// Regression for #1930.
//
// When no ModelConfigs are deployed, the backend responds 200 OK but with the
// `data` field omitted entirely (Go json omitempty), e.g.:
// { "error": false, "message": "Successfully listed ModelConfigs" }
// The provider must read that as "zero models", NOT as a fetch failure.
// Before the fix, the missing `data` was treated as an error and the UI showed
// "Failed to fetch models".
it("shows no error when there are no model configs", async () => {
// The response below has no `data` key on purpose — that is exactly what an
// empty list looks like on the wire.
mockGetModelConfigs.mockResolvedValue({ message: "Successfully listed ModelConfigs" });

render(
<AgentsProvider>
<ModelsConsumer />
</AgentsProvider>,
);

// Wait for the fetch to be made, then let its promise + setState calls flush
// so we assert on the state *after* fetchModels has run (not the initial state,
// which also happens to be "no error / no models").
await waitFor(() => expect(mockGetModelConfigs).toHaveBeenCalled());
await act(async () => {});

expect(screen.getByTestId("model-error")).toBeEmptyDOMElement();
expect(screen.getByTestId("model-list").children).toHaveLength(0);
});

// The fix must not hide genuine failures: a real backend error should still be
// surfaced via the provider's `error` state.
it("surfaces a real error returned by the backend", async () => {
mockGetModelConfigs.mockResolvedValue({
message: "Failed",
error: "model configs unavailable",
});

render(
<AgentsProvider>
<ModelsConsumer />
</AgentsProvider>,
);

await waitFor(() =>
expect(screen.getByTestId("model-error")).toHaveTextContent("model configs unavailable"),
);
});
});
Loading