feat(grokbuild): add first-class Grok Build support (#5453)

* feat(grokbuild): add backend integration

* feat(grokbuild): add provider and management UI

* test(grokbuild): cover configuration and integrations

* fix(grokbuild): address backend review feedback

* fix(grokbuild): complete UI review feedback

* feat(grokbuild): add CLI lifecycle management

* fix(grokbuild): align provider icon fallback

* test(grokbuild): cover provider icon persistence
This commit is contained in:
Thefool
2026-07-17 15:50:50 +08:00
committed by GitHub
parent f6e37ed994
commit 1c0ee0c58a
112 changed files with 4530 additions and 246 deletions
@@ -125,4 +125,45 @@ describe("AddProviderDialog", () => {
},
});
});
it("新建 Grok Build 自定义供应商时不补默认 Grok 图标", async () => {
const handleSubmit = vi.fn().mockResolvedValue(undefined);
mockFormValues = {
name: "tes 1",
websiteUrl: "",
icon: "",
iconColor: "",
settingsConfig: JSON.stringify({
config: `[models]
default = "grok-4.5"
[model."grok-4.5"]
model = "grok-4.5"
base_url = "https://grok.example.com/v1"
name = "tes 1"
api_key = "secret"
api_backend = "responses"
context_window = 500000
`,
}),
};
render(
<AddProviderDialog
open
onOpenChange={vi.fn()}
appId="grokbuild"
onSubmit={handleSubmit}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "common.add" }));
await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1));
const submitted = handleSubmit.mock.calls[0][0];
expect(submitted.icon).toBeUndefined();
expect(submitted.iconColor).toBeUndefined();
});
});
@@ -0,0 +1,178 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { parse as parseToml } from "smol-toml";
import { describe, expect, it, vi } from "vitest";
import { GrokBuildProviderForm } from "@/components/providers/forms/GrokBuildProviderForm";
vi.mock("@/components/JsonEditor", () => ({
default: ({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) => (
<textarea
aria-label="raw-config"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
),
}));
describe("GrokBuildProviderForm", () => {
it("offers Codex-compatible provider presets and applies one", async () => {
const user = userEvent.setup();
const { container } = render(
<GrokBuildProviderForm
submitLabel="Save"
onSubmit={() => {}}
onCancel={() => {}}
/>,
);
await user.click(screen.getByRole("button", { name: /PatewayAI/ }));
const baseUrlInput =
container.querySelector<HTMLInputElement>("#codexBaseUrl");
const nameInput =
container.querySelector<HTMLInputElement>('input[name="name"]');
expect(baseUrlInput?.value).toBe("https://api.pateway.ai/v1");
expect(nameInput?.value).toBe("PatewayAI");
});
it("submits a complete config.toml payload with Grok defaults", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
const { container } = render(
<GrokBuildProviderForm
submitLabel="Save"
onSubmit={onSubmit}
onCancel={() => {}}
/>,
);
const nameInput =
container.querySelector<HTMLInputElement>('input[name="name"]');
const baseUrlInput =
container.querySelector<HTMLInputElement>("#codexBaseUrl");
expect(nameInput).not.toBeNull();
expect(baseUrlInput).not.toBeNull();
fireEvent.change(nameInput!, { target: { value: "Example Relay" } });
fireEvent.change(baseUrlInput!, {
target: { value: "https://relay.example.com/v1" },
});
fireEvent.change(screen.getByLabelText("API Key"), {
target: { value: "secret-key" },
});
await user.click(screen.getByRole("button", { name: "Save" }));
expect(onSubmit).toHaveBeenCalledTimes(1);
const submitted = onSubmit.mock.calls[0][0];
expect(submitted.icon).toBe("");
const settings = JSON.parse(submitted.settingsConfig);
const config = parseToml(settings.config) as any;
expect(config.models.default).toBe("grok-4.5");
expect(config.model["grok-4.5"]).toEqual({
model: "grok-4.5",
base_url: "https://relay.example.com/v1",
name: "Example Relay",
api_key: "secret-key",
api_backend: "responses",
context_window: 500000,
});
});
it("maps Chat Completions presets into Grok api_backend", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(
<GrokBuildProviderForm
submitLabel="Save"
onSubmit={onSubmit}
onCancel={() => {}}
/>,
);
await user.click(screen.getByRole("button", { name: /BytePlus/ }));
await user.type(screen.getByLabelText("API Key"), "secret-key");
await user.click(screen.getByRole("button", { name: "Save" }));
expect(onSubmit).toHaveBeenCalledTimes(1);
const submitted = onSubmit.mock.calls[0][0];
const settings = JSON.parse(submitted.settingsConfig);
const config = parseToml(settings.config) as any;
expect(submitted.meta.apiFormat).toBe("openai_chat");
expect(config.model[config.models.default].api_backend).toBe(
"chat_completions",
);
});
it("renders localized validation feedback for malformed TOML", async () => {
const onSubmit = vi.fn();
render(
<GrokBuildProviderForm
submitLabel="Save"
onSubmit={onSubmit}
onCancel={() => {}}
/>,
);
fireEvent.change(screen.getByLabelText("raw-config"), {
target: { value: "[models" },
});
expect(screen.getByText(/Invalid config\.toml:/)).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
it("loads edit-mode values and does not resubmit stale custom endpoints", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
const config = `[models]
default = "existing-profile"
[model."existing-profile"]
model = "grok-upstream"
base_url = "https://existing.example.com/v1"
name = "Existing Relay"
api_key = "existing-key"
api_backend = "responses"
context_window = 250000
`;
const { container } = render(
<GrokBuildProviderForm
providerId="existing-provider"
submitLabel="Save"
onSubmit={onSubmit}
onCancel={() => {}}
initialData={{
name: "Existing Relay",
settingsConfig: { config },
meta: {
custom_endpoints: {
"https://deleted.example.com/v1": {
url: "https://deleted.example.com/v1",
addedAt: 1,
},
},
},
}}
/>,
);
expect(
container.querySelector<HTMLInputElement>("#grokbuild-profile")?.value,
).toBe("existing-profile");
expect(
container.querySelector<HTMLInputElement>("#codexBaseUrl")?.value,
).toBe("https://existing.example.com/v1");
await user.click(screen.getByRole("button", { name: "Save" }));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0].meta.custom_endpoints).toBeUndefined();
});
});
+9
View File
@@ -245,6 +245,7 @@ describe("McpFormModal", () => {
claude: true,
codex: true,
gemini: true,
grokbuild: true,
},
});
expect(onSave).toHaveBeenCalledTimes(1);
@@ -390,6 +391,7 @@ type = "stdio"
claude: true,
codex: false,
gemini: false,
grokbuild: false,
});
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave).toHaveBeenCalledWith();
@@ -423,6 +425,12 @@ type = "stdio"
expect(geminiCheckbox.checked).toBe(true);
fireEvent.click(geminiCheckbox);
const grokbuildCheckbox = screen.getByLabelText(
"mcp.unifiedPanel.apps.grokbuild",
) as HTMLInputElement;
expect(grokbuildCheckbox.checked).toBe(true);
fireEvent.click(grokbuildCheckbox);
fireEvent.click(screen.getByText("common.add"));
await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1));
@@ -432,6 +440,7 @@ type = "stdio"
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
openclaw: false,
hermes: false,
+17 -4
View File
@@ -51,8 +51,8 @@ vi.mock("@/hooks/useSkills", () => ({
{
directory: "shared-skill",
name: "Shared Skill",
description: "Imported from Claude",
foundIn: ["claude"],
description: "Imported from Grok Build",
foundIn: ["grokbuild"],
path: "/tmp/shared-skill",
},
],
@@ -82,8 +82,8 @@ describe("UnifiedSkillsPanel", () => {
{
directory: "shared-skill",
name: "Shared Skill",
description: "Imported from Claude",
foundIn: ["claude"],
description: "Imported from Grok Build",
foundIn: ["grokbuild"],
path: "/tmp/shared-skill",
},
],
@@ -116,5 +116,18 @@ describe("UnifiedSkillsPanel", () => {
expect(screen.getByText("Shared Skill")).toBeInTheDocument();
expect(screen.getByText("/tmp/shared-skill")).toBeInTheDocument();
});
await act(async () => {
screen.getByText("skills.importSelected").click();
});
await waitFor(() => {
expect(importSkillsMock).toHaveBeenCalledWith([
{
directory: "shared-skill",
apps: expect.objectContaining({ grokbuild: true }),
},
]);
});
});
});
@@ -48,6 +48,7 @@ const createSettings = (
enableClaudePluginIntegration: false,
claudeConfigDir: "/claude/custom",
codexConfigDir: "/codex/custom",
grokConfigDir: "/grok/custom",
language: "zh",
...overrides,
});
@@ -68,6 +69,7 @@ describe("useDirectorySettings", () => {
if (app === "claude") return "/remote/claude";
if (app === "codex") return "/remote/codex";
if (app === "gemini") return "/remote/gemini";
if (app === "grokbuild") return "/remote/grok";
if (app === "opencode") return "/remote/opencode";
if (app === "openclaw") return "/remote/openclaw";
return "/remote/hermes";
@@ -90,6 +92,7 @@ describe("useDirectorySettings", () => {
claude: "/remote/claude",
codex: "/remote/codex",
gemini: "/remote/gemini",
grokbuild: "/remote/grok",
opencode: "/remote/opencode",
openclaw: "/remote/openclaw",
hermes: "/remote/hermes",
@@ -249,6 +252,7 @@ describe("useDirectorySettings", () => {
claude: "/server/claude",
codex: "/server/codex",
gemini: "/server/gemini",
grokbuild: "/server/grok",
opencode: "/server/opencode",
openclaw: "/server/openclaw",
});
@@ -257,6 +261,7 @@ describe("useDirectorySettings", () => {
expect(result.current.resolvedDirs.claude).toBe("/server/claude");
expect(result.current.resolvedDirs.codex).toBe("/server/codex");
expect(result.current.resolvedDirs.gemini).toBe("/server/gemini");
expect(result.current.resolvedDirs.grokbuild).toBe("/server/grok");
expect(result.current.resolvedDirs.opencode).toBe("/server/opencode");
expect(result.current.resolvedDirs.openclaw).toBe("/server/openclaw");
});
+36
View File
@@ -264,6 +264,42 @@ describe("useProviderActions", () => {
expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);
});
it("warns for Grok providers that require the Responses router", async () => {
switchProviderMutateAsync.mockResolvedValue(undefined);
const { wrapper } = createWrapper();
const providers = [
createProvider({
id: "grok-chat",
category: "custom",
meta: { apiFormat: "openai_chat" },
}),
createProvider({
id: "grok-anthropic",
category: "custom",
meta: { apiFormat: "anthropic" },
}),
createProvider({
id: "grok-full-url",
category: "custom",
meta: { isFullUrl: true },
}),
];
const { result } = renderHook(
() => useProviderActions("grokbuild", false),
{ wrapper },
);
for (const provider of providers) {
await act(async () => {
await result.current.switchProvider(provider);
});
}
expect(toastWarningMock).toHaveBeenCalledTimes(3);
expect(switchProviderMutateAsync).toHaveBeenCalledTimes(3);
});
it("allows the built-in Codex official provider during takeover", async () => {
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
const { wrapper } = createWrapper();
+2 -1
View File
@@ -81,6 +81,7 @@ describe("useProxyStatus", () => {
claude: false,
codex: false,
gemini: false,
grokbuild: false,
opencode: false,
openclaw: false,
});
@@ -115,4 +116,4 @@ describe("useProxyStatus", () => {
{ closeButton: true },
);
});
});
});
+1
View File
@@ -337,6 +337,7 @@ export const handlers = [
claude: false,
codex: false,
gemini: false,
grokbuild: false,
}),
),
+4
View File
@@ -69,6 +69,7 @@ const createDefaultProviders = (): ProvidersByApp => ({
createdAt: Date.now(),
},
},
grokbuild: {},
opencode: {},
openclaw: {},
hermes: {},
@@ -79,6 +80,7 @@ const createDefaultCurrent = (): CurrentProviderState => ({
"claude-desktop": "",
codex: "codex-1",
gemini: "gemini-1",
grokbuild: "",
opencode: "",
openclaw: "",
hermes: "",
@@ -191,6 +193,7 @@ let mcpConfigs: McpConfigState = {
},
},
gemini: {},
grokbuild: {},
opencode: {},
openclaw: {},
hermes: {},
@@ -259,6 +262,7 @@ export const resetProviderState = () => {
},
},
gemini: {},
grokbuild: {},
opencode: {},
openclaw: {},
hermes: {},