Thanks to visit codestin.com
Credit goes to github.com

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
12 changes: 12 additions & 0 deletions site/src/api/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,18 @@ describe("api.ts", () => {
expect(result).toStrictEqual(responseData);
});

it("rejects chat model responses without a models array", async () => {
vi.spyOn(axiosInstance, "get").mockResolvedValueOnce({
data: { providers: [], unsupported_providers: [] },
});

await expect(
API.experimental.getChatModels(organizationId),
).rejects.toThrow(
"Invalid chat models response: models must be an array.",
);
});

it.each<[string, () => Promise<unknown>]>([
[
"/api/experimental/organizations/organization%2Fid/chats/models",
Expand Down
3 changes: 3 additions & 0 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3903,6 +3903,9 @@ class ExperimentalApiMethods {
await this.axios.get<TypesGen.OrganizationChatModelsResponse>(
chatModelsPath(organizationId),
);
if (!Array.isArray(response.data?.models)) {
throw new Error("Invalid chat models response: models must be an array.");
}
return response.data;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,24 @@ export const PermissionLoadErrorShowsAlert: Story = {
},
};

export const ModelLoadErrorShowsAlert: Story = {
beforeEach: () => {
spyOn(API.experimental, "getChatModels").mockRejectedValue(
new Error("Invalid chat models response: models must be an array."),
);
spyOn(API, "checkAuthorization").mockResolvedValue({});
},
parameters: { queries: [] },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
await canvas.findByText(
"Invalid chat models response: models must be an array.",
),
).toBeVisible();
},
};

export const Loading: Story = {
beforeEach: () => {
spyOn(API.experimental, "getChatModels").mockImplementation(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const useAccessibleModelOrganizations = (
);
const accessibleOrganizations = organizations.filter(
(organization, index) =>
(queries[index]?.data?.models.length ?? 0) > 0 ||
(queries[index]?.data?.models?.length ?? 0) > 0 ||
canAccessOrganizationChatModelConfig(
permissionsQuery.data?.[organization.id],
),
Expand Down
135 changes: 123 additions & 12 deletions site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Decorator, Meta, StoryObj } from "@storybook/react-vite";
import { delay } from "msw";
import { useEffect, useState } from "react";
import { type ComponentProps, useEffect, useState } from "react";
import { QueryClient, QueryClientProvider, useQueryClient } from "react-query";
import {
expect,
Expand All @@ -26,6 +26,7 @@ import {
MockChatModel,
MockChatModelProviderDescriptor,
} from "#/testHelpers/chatModels";
import { createDeferred, type Deferred } from "#/testHelpers/deferred";
import {
MockDefaultOrganization,
MockOrganization2,
Expand All @@ -37,8 +38,15 @@ import {
getReasoningEffortForModel,
saveReasoningEffortForModel,
} from "../utils/reasoningEffort";
import { AgentCreateForm, emptyInputStorageKey } from "./AgentCreateForm";

import {
AgentCreateForm,
emptyInputStorageKey,
selectedOrganizationIdStorageKey,
} from "./AgentCreateForm";

let pendingOrganizationAuthorization: Deferred<
Awaited<ReturnType<typeof API.checkAuthorization>>
>;
let capturedQueryClient: QueryClient | undefined;

const permittedOrgsKey = permittedOrganizationsKey({
Expand Down Expand Up @@ -298,6 +306,20 @@ type Story = StoryObj<typeof AgentCreateForm>;

const defaultArgs = meta.args;

const RemountAgentCreateForm = (
props: ComponentProps<typeof AgentCreateForm>,
) => {
const [key, setKey] = useState(0);
return (
<>
<button type="button" onClick={() => setKey((current) => current + 1)}>
Remount form
</button>
<AgentCreateForm key={key} {...props} />
</>
);
};

const mockPermittedOrganizations = (
permissions: Record<string, boolean>,
delayMs = 0,
Expand Down Expand Up @@ -1644,6 +1666,7 @@ export const OrganizationAuthorizationFailure: Story = {
parameters: {
showOrganizations: true,
organizations: [MockDefaultOrganization, MockOrganization2],
queries: [],
},
beforeEach: () => {
localStorage.clear();
Expand All @@ -1655,10 +1678,18 @@ export const OrganizationAuthorizationFailure: Story = {
spyOn(API, "checkAuthorization").mockRejectedValue(
new Error("authorization check failed"),
);
spyOn(API.experimental, "getChatModels").mockResolvedValue(
defaultModelCatalog,
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findAllByText(/authorization check failed/i);
expect(API.experimental.getChatModels).not.toHaveBeenCalled();
expect(API.experimental.getMCPServerConfigs).not.toHaveBeenCalled();
expect(
API.experimental.getUserChatPersonalModelOverrides,
).not.toHaveBeenCalled();
expect(canvas.getByRole("button", { name: "Send" })).toBeDisabled();
},
};
Expand Down Expand Up @@ -1695,15 +1726,23 @@ export const DelayedOrganizationAuthorization: Story = {
parameters: {
showOrganizations: true,
organizations: [MockDefaultOrganization, MockOrganization2],
queries: [],
},
beforeEach: () => {
localStorage.setItem(emptyInputStorageKey, "draft message");
mockPermittedOrganizations(
{
[MockDefaultOrganization.id]: true,
[MockOrganization2.id]: true,
},
1_500,
pendingOrganizationAuthorization = createDeferred();
spyOn(API, "getOrganizations").mockResolvedValue([
MockDefaultOrganization,
MockOrganization2,
]);
spyOn(API, "checkAuthorization").mockImplementation(
() => pendingOrganizationAuthorization.promise,
);
spyOn(API.experimental, "getChatModels").mockImplementation(
async (organizationId) =>
organizationId === MockOrganization2.id
? organization2LocalCatalog
: { ...defaultModelCatalog, models: [] },
);
},
args: {
Expand All @@ -1724,6 +1763,15 @@ export const DelayedOrganizationAuthorization: Story = {
await expect(
canvas.getByRole("button", { name: "More options" }),
).toBeDisabled();
expect(API.experimental.getChatModels).not.toHaveBeenCalled();
expect(API.experimental.getMCPServerConfigs).not.toHaveBeenCalled();
expect(
API.experimental.getUserChatPersonalModelOverrides,
).not.toHaveBeenCalled();
expect(
canvas.queryByText(/AI models aren't available yet/i),
).not.toBeInTheDocument();
expect(canvas.queryByText("No model is available")).not.toBeInTheDocument();
// dispatchEvent returns false when a handler accepted the drop
// via preventDefault, giving a race-free accepted/ignored signal.
const dropFile = (name: string): boolean => {
Expand All @@ -1741,12 +1789,28 @@ export const DelayedOrganizationAuthorization: Story = {
// must be ignored.
expect(dropFile("drop.txt")).toBe(true);
expect(canvas.queryByLabelText("Remove drop.txt")).not.toBeInTheDocument();
// The pending option list is unfiltered, so the picker must stay hidden.
expect(
canvas.queryByRole("button", { name: /organization/i }),
).not.toBeInTheDocument();
await waitFor(() => expect(sendButton).toBeEnabled(), { timeout: 3_000 });
await canvas.findByRole("button", { name: /organization/i });

pendingOrganizationAuthorization.resolve({
[MockDefaultOrganization.id]: false,
[MockOrganization2.id]: true,
});

await waitFor(() => expect(sendButton).toBeEnabled());
expect(API.experimental.getChatModels).toHaveBeenCalledWith(
MockOrganization2.id,
);
expect(API.experimental.getChatModels).not.toHaveBeenCalledWith(
MockDefaultOrganization.id,
);
expect(API.experimental.getMCPServerConfigs).toHaveBeenCalledWith(
MockOrganization2.id,
);
expect(
API.experimental.getUserChatPersonalModelOverrides,
).toHaveBeenCalledWith(MockOrganization2.id, "me");
// Positive control: once settled the same drop is accepted and
// attaches, so the pending-state assertions exercised a real path.
expect(dropFile("after.txt")).toBe(false);
Expand All @@ -1756,6 +1820,53 @@ export const DelayedOrganizationAuthorization: Story = {
},
};

export const SelectedOrganizationSurvivesRemount: Story = {
parameters: {
showOrganizations: true,
organizations: [MockDefaultOrganization, MockOrganization2],
queries: [],
},
args: { ...defaultArgs },
render: (args) => <RemountAgentCreateForm {...args} />,
beforeEach: () => {
mockPermittedOrganizations({
[MockDefaultOrganization.id]: true,
[MockOrganization2.id]: true,
});
spyOn(API.experimental, "getChatModels").mockImplementation(
async (organizationId) =>
organizationId === MockOrganization2.id
? organization2LocalCatalog
: defaultModelCatalog,
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByRole("button", {
name: `Organization: ${MockDefaultOrganization.display_name}`,
}),
);
await userEvent.click(
await screen.findByRole("option", {
name: MockOrganization2.display_name,
}),
);
await waitFor(() => {
expect(localStorage.getItem(selectedOrganizationIdStorageKey)).toBe(
MockOrganization2.id,
);
});

await userEvent.click(canvas.getByRole("button", { name: "Remount form" }));
expect(
await canvas.findByRole("button", {
name: `Organization: ${MockOrganization2.display_name}`,
}),
).toBeVisible();
},
};

// Mutable permissions let play functions change authorization across refetches.
// The story-local QueryClient exposes those refetches; the preview client's
// instance is inaccessible and uses infinite stale time.
Expand Down
41 changes: 29 additions & 12 deletions site/src/pages/AgentsPage/components/AgentCreateForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ import { getModelSelectorHelp } from "./ModelSelectorHelp";

/** @internal Exported for testing. */
export const emptyInputStorageKey = "agents.empty-input";
/** @internal Exported for testing. */
export const selectedOrganizationIdStorageKey =
"agents.selected-organization-id";
const selectedWorkspaceIdStorageKey = "agents.selected-workspace-id";
const lastModelConfigIDStorageKey = "agents.last-model-config-id";

Expand Down Expand Up @@ -165,8 +168,6 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
const [initialLastModelConfigID] = useState(() => {
return localStorage.getItem(lastModelConfigIDStorageKey) ?? "";
});
const initialOrg =
organizations.find((o) => o.is_default) ?? organizations[0];
// effectiveWorkspaceId nulls a stored selection outside the effective org's
// filtered workspace list without deleting it. Preserve the stored value
// because the permitted-organizations query may resolve after mount and
Expand All @@ -175,7 +176,16 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
() => localStorage.getItem(selectedWorkspaceIdStorageKey),
);
const [selectedOrg, setSelectedOrg] = useState<TypesGen.Organization | null>(
null,
() => {
const storedOrganizationId = localStorage.getItem(
selectedOrganizationIdStorageKey,
);
return (
organizations.find(
(organization) => organization.id === storedOrganizationId,
) ?? null
);
},
);
const [pendingOrgChange, setPendingOrgChange] =
useState<TypesGen.Organization | null>(null);
Expand All @@ -195,14 +205,13 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
// selection, its organization list is authoritative so a removed org cannot
// remain selected for submission.
const permittedOrgs = showOrganizations
? (permittedOrgsQuery.data ?? organizations)
? (permittedOrgsQuery.data ?? [])
: organizations;
// Treat the dashboard org as provisional until permissions resolve so
// sends and persisted attachments cannot use an unpermitted org.
const orgSelectionSettled =
!showOrganizations || permittedOrgsQuery.data !== undefined;
// Prevent effectiveOrg's dashboard fallback from bypassing an empty
// permitted set.
// Keep an authoritative empty permission set distinct from pending data.
const noPermittedOrgs =
showOrganizations && permittedOrgsQuery.data?.length === 0;
const selectedOrgIsPermitted =
Expand All @@ -228,7 +237,6 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
? selectedOrg
: (permittedOrgs.find((org) => org.is_default) ??
permittedOrgs[0] ??
initialOrg ??
null);
const organizationId = effectiveOrg?.id ?? "";
const mcpServersQuery = useQuery({
Expand Down Expand Up @@ -266,6 +274,16 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
setUserMCPServerIds(null);
}
}
useEffect(() => {
if (!orgSelectionSettled) {
return;
}
if (selectedOrg) {
localStorage.setItem(selectedOrganizationIdStorageKey, selectedOrg.id);
} else {
localStorage.removeItem(selectedOrganizationIdStorageKey);
}
}, [orgSelectionSettled, selectedOrg]);
useEffect(() => {
if (selectedWorkspaceId === null) {
localStorage.removeItem(selectedWorkspaceIdStorageKey);
Expand Down Expand Up @@ -453,10 +471,11 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
// guarantees completeness. If workspace counts grow large
// enough to warrant pagination, this should switch to a
// server-side organization:<name> query filter.
const filteredWorkspaces =
showOrganizations && effectiveOrg
const filteredWorkspaces = showOrganizations
? effectiveOrg
? workspaceOptions.filter((ws) => ws.organization_id === effectiveOrg.id)
: workspaceOptions;
: []
: workspaceOptions;

const effectiveWorkspaceId =
selectedWorkspaceId !== null &&
Expand Down Expand Up @@ -609,8 +628,6 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
</AlertDescription>
</Alert>
)}
{/* The pre-settlement list is the unfiltered dashboard fallback;
selecting from it could destroy existing workspace state. */}
{showOrganizations &&
orgSelectionSettled &&
permittedOrgs.length > 1 && (
Expand Down
Loading