feat(pi): expose first-class desktop workflows

This commit is contained in:
SaladDay
2026-08-02 22:52:35 +00:00
parent 7609ff799e
commit a88a58b516
64 changed files with 2954 additions and 218 deletions
@@ -112,4 +112,21 @@ describe("ImportExportSection Component", () => {
expect(screen.getByText("settings.importFailed")).toBeInTheDocument();
expect(screen.getByText("Parse failed")).toBeInTheDocument();
});
it("shows the Pi portability boundary and the concrete partial-sync failure", () => {
render(
<ImportExportSection
{...baseProps}
status="partial-success"
errorMessage="unclaimed native key 'occupied' already exists"
/>,
);
expect(
screen.getByText("settings.piImportExportBoundary"),
).toBeInTheDocument();
expect(
screen.getByText("unclaimed native key 'occupied' already exists"),
).toBeInTheDocument();
});
});
@@ -0,0 +1,144 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PiNativePromptResources } from "@/components/prompts/PiNativePromptResources";
import { promptsApi, type PiPromptFileKind } from "@/lib/api/prompts";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
const renderResources = () => {
const client = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={client}>
<PiNativePromptResources />
</QueryClientProvider>,
);
};
describe("PiNativePromptResources", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(promptsApi, "getPiPromptFile").mockImplementation(
async (kind: PiPromptFileKind) => ({
kind,
path:
kind === "system_override"
? "/agent/SYSTEM.md"
: "/agent/APPEND_SYSTEM.md",
exists: kind === "system_append",
revision: kind === "system_append" ? "append-revision" : "missing",
content: kind === "system_append" ? "append" : "",
}),
);
vi.spyOn(promptsApi, "listPiPromptTemplates").mockResolvedValue([
{
slug: "empty",
content: "",
revision: "empty-revision",
},
]);
vi.spyOn(promptsApi, "upsertPiPromptTemplate").mockResolvedValue({
slug: "new-empty",
content: "",
revision: "created-revision",
});
vi.spyOn(promptsApi, "replacePiPromptFile").mockImplementation(
async (kind, _revision, content) => ({
kind,
path: "/agent/SYSTEM.md",
exists: true,
revision: "saved-empty",
content,
}),
);
});
it("uses file presence as state, rejects blank direct saves, and permits empty templates", async () => {
renderResources();
await waitFor(() => expect(screen.getByText("/empty")).toBeInTheDocument());
expect(screen.getByText("pi.prompts.active")).toBeInTheDocument();
expect(screen.getByText("pi.prompts.inactive")).toBeInTheDocument();
const instructionEditors = screen.getAllByPlaceholderText(
"pi.prompts.instructionPlaceholder",
);
fireEvent.change(instructionEditors[1], { target: { value: "" } });
const saveButtons = screen.getAllByRole("button", { name: "common.save" });
expect(saveButtons[1]).toBeDisabled();
fireEvent.change(instructionEditors[1], {
target: { value: "new append" },
});
expect(saveButtons[1]).toBeEnabled();
fireEvent.click(saveButtons[1]);
await waitFor(() =>
expect(promptsApi.replacePiPromptFile).toHaveBeenCalledWith(
"system_append",
"append-revision",
"new append",
),
);
fireEvent.change(screen.getByPlaceholderText("pi.prompts.templateSlug"), {
target: { value: "new-empty" },
});
const create = screen.getByRole("button", {
name: "pi.prompts.createTemplate",
});
expect(create).toBeEnabled();
fireEvent.click(create);
await waitFor(() =>
expect(promptsApi.upsertPiPromptTemplate).toHaveBeenCalledWith(
"new-empty",
"missing",
"",
),
);
});
it("requires confirmation before creating the dangerous SYSTEM override", async () => {
renderResources();
const instructionEditors = await screen.findAllByPlaceholderText(
"pi.prompts.instructionPlaceholder",
);
fireEvent.change(instructionEditors[0], {
target: { value: "replace the system prompt" },
});
fireEvent.click(
screen.getAllByRole("button", { name: "common.save" })[0],
);
expect(promptsApi.replacePiPromptFile).not.toHaveBeenCalled();
expect(
screen.getByText("pi.prompts.activateOverrideTitle"),
).toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", { name: "pi.prompts.activateOverride" }),
);
await waitFor(() =>
expect(promptsApi.replacePiPromptFile).toHaveBeenCalledWith(
"system_override",
"missing",
"replace the system prompt",
),
);
});
});
+162
View File
@@ -0,0 +1,162 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { PiProviderForm } from "@/components/providers/forms/PiProviderForm";
import composerOracle from "../fixtures/pi/native-oracle/composer-oracle-v1.json";
describe("PiProviderForm", () => {
it("submits only explicit model fields and leaves pinned defaults to Pi", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
<PiProviderForm
appId="pi"
submitLabel="Save Pi provider"
onSubmit={onSubmit}
onCancel={() => {}}
/>,
);
fireEvent.change(screen.getByPlaceholderText("my-provider"), {
target: { value: "verified-provider" },
});
fireEvent.change(screen.getByPlaceholderText("My Pi provider"), {
target: { value: "Verified provider" },
});
fireEvent.change(screen.getByPlaceholderText("openai-responses"), {
target: { value: "openai-responses" },
});
fireEvent.change(
screen.getByPlaceholderText("https://api.example.com/v1"),
{
target: { value: "https://api.example.com/v1" },
},
);
fireEvent.change(screen.getByPlaceholderText("model-id"), {
target: { value: "opaque-model" },
});
fireEvent.click(screen.getByRole("button", { name: "Save Pi provider" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const submitted = onSubmit.mock.calls[0][0];
expect(submitted.providerKey).toBe("verified-provider");
expect(JSON.parse(submitted.settingsConfig)).toEqual({
name: "Verified provider",
api: "openai-responses",
baseUrl: "https://api.example.com/v1",
models: [{ id: "opaque-model" }],
});
});
it("round-trips every field in the pinned all-fields composer vector", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
const vector = composerOracle.cases.find(
(candidate) => candidate.id === "combined-all-fields-precedence",
);
expect(vector).toBeDefined();
const input = JSON.parse(JSON.stringify(vector?.input)) as Record<
string,
unknown
>;
render(
<PiProviderForm
appId="pi"
providerId="all-fields"
submitLabel="Save all fields"
onSubmit={onSubmit}
onCancel={() => {}}
initialData={{
name: String(input.name),
settingsConfig: input,
}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Save all fields" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(JSON.parse(onSubmit.mock.calls[0][0].settingsConfig)).toEqual(input);
});
it("preserves an explicitly false authHeader instead of erasing it", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
const input = {
name: "Explicit false",
api: "openai-responses",
baseUrl: "https://api.example.com/v1",
authHeader: false,
models: [{ id: "model" }],
};
render(
<PiProviderForm
appId="pi"
providerId="explicit-false"
submitLabel="Save explicit false"
onSubmit={onSubmit}
onCancel={() => {}}
initialData={{ name: input.name, settingsConfig: input }}
/>,
);
fireEvent.click(
screen.getByRole("button", { name: "Save explicit false" }),
);
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(JSON.parse(onSubmit.mock.calls[0][0].settingsConfig)).toEqual(input);
});
it("creates and submits typed failover endpoints from the real form entry", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
<PiProviderForm
appId="pi"
submitLabel="Save endpoint provider"
onSubmit={onSubmit}
onCancel={() => {}}
/>,
);
fireEvent.change(screen.getByPlaceholderText("my-provider"), {
target: { value: "endpoint-provider" },
});
fireEvent.change(screen.getByPlaceholderText("My Pi provider"), {
target: { value: "Endpoint provider" },
});
fireEvent.change(screen.getByPlaceholderText("openai-responses"), {
target: { value: "openai-responses" },
});
fireEvent.change(screen.getByPlaceholderText("model-id"), {
target: { value: "endpoint-model" },
});
fireEvent.click(
screen.getByRole("button", { name: "pi.form.manageEndpoints" }),
);
const endpointInput = await screen.findByPlaceholderText(
"endpointTest.addEndpointPlaceholder",
);
fireEvent.change(endpointInput, {
target: { value: "https://failover.example/v1" },
});
fireEvent.keyDown(endpointInput, { key: "Enter" });
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
fireEvent.click(
screen.getByRole("button", { name: "Save endpoint provider" }),
);
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(JSON.parse(onSubmit.mock.calls[0][0].settingsConfig)).toMatchObject({
baseUrl: "https://failover.example/v1",
models: [{ id: "endpoint-model" }],
});
expect(onSubmit.mock.calls[0][0].meta.custom_endpoints).toEqual({
"https://failover.example/v1": expect.objectContaining({
url: "https://failover.example/v1",
}),
});
});
});
@@ -2,12 +2,13 @@ import { describe, expect, it } from "vitest";
import { FAILOVER_APPS } from "@/components/settings/ProxyTabContent";
describe("ProxyTabContent failover apps", () => {
it("exposes Grok Build alongside the existing failover applications", () => {
it("exposes Pi alongside the existing failover applications", () => {
expect(FAILOVER_APPS.map(({ id }) => id)).toEqual([
"claude",
"codex",
"gemini",
"grokbuild",
"pi",
]);
});
});
@@ -10,6 +10,7 @@ import {
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SessionManagerPage } from "@/components/sessions/SessionManagerPage";
import { piApi } from "@/lib/api/pi";
import { sessionsApi } from "@/lib/api/sessions";
import type { SessionMessage, SessionMeta } from "@/types";
import { setSessionFixtures } from "../msw/state";
@@ -221,6 +222,23 @@ describe("SessionManagerPage", () => {
setSessionFixtures(sessions, messages);
});
it("surfaces a relative Pi sessionDir instead of presenting an empty scan as authoritative", async () => {
const discovery = vi
.spyOn(piApi, "getSessionDiscovery")
.mockResolvedValue({
status: "requires_project_context",
configuredPath: ".pi/sessions",
source: "settings",
});
renderPage("pi");
const notice = await screen.findByRole("status");
expect(notice).toHaveTextContent(".pi/sessions");
expect(discovery).toHaveBeenCalledTimes(1);
discovery.mockRestore();
});
it("deletes the selected session and selects the next visible session", async () => {
renderPage();
+73 -1
View File
@@ -13,6 +13,12 @@ const importSkillsMock = vi.fn();
const installFromZipMock = vi.fn();
const deleteSkillBackupMock = vi.fn();
const restoreSkillBackupMock = vi.fn();
const skillsHookState = vi.hoisted(() => ({
installed: [] as unknown[],
piStatuses: {} as Record<string, unknown>,
piStatusesLoading: false,
piStatusesError: false,
}));
vi.mock("sonner", () => ({
toast: {
@@ -24,9 +30,14 @@ vi.mock("sonner", () => ({
vi.mock("@/hooks/useSkills", () => ({
useInstalledSkills: () => ({
data: [],
data: skillsHookState.installed,
isLoading: false,
}),
usePiSkillStatuses: () => ({
data: skillsHookState.piStatuses,
isLoading: skillsHookState.piStatusesLoading,
isError: skillsHookState.piStatusesError,
}),
useSkillBackups: () => ({
data: [],
refetch: vi.fn(),
@@ -94,6 +105,10 @@ describe("UnifiedSkillsPanel", () => {
installFromZipMock.mockReset();
deleteSkillBackupMock.mockReset();
restoreSkillBackupMock.mockReset();
skillsHookState.installed = [];
skillsHookState.piStatuses = {};
skillsHookState.piStatusesLoading = false;
skillsHookState.piStatusesError = false;
});
it("opens the import dialog without crashing when app toggles render", async () => {
@@ -130,4 +145,61 @@ describe("UnifiedSkillsPanel", () => {
]);
});
});
it("renders Pi active state from inspection and toggles the desired state", async () => {
skillsHookState.installed = [
{
id: "skill-1",
name: "Pi Skill",
directory: "pi-skill",
apps: {
claude: false,
codex: false,
gemini: false,
opencode: false,
openclaw: false,
hermes: false,
pi: true,
},
installedAt: 1,
updatedAt: 1,
},
];
skillsHookState.piStatuses = {
"skill-1": {
desiredEnabled: true,
ownedDeployment: false,
effectivelyDiscovered: false,
ownership: "foreign",
discovery: "absent",
issue: "collision",
},
};
toggleSkillAppMock.mockResolvedValue(true);
render(
<UnifiedSkillsPanel
onOpenDiscovery={() => {}}
currentApp="pi"
/>,
);
expect(
screen.getByText("Pi: skills.piStatus.foreignConflict"),
).toBeInTheDocument();
const piToggle = screen.getByRole("button", { name: "Pi" });
expect(piToggle).toHaveAttribute("aria-pressed", "false");
await act(async () => {
piToggle.click();
});
await waitFor(() => {
expect(toggleSkillAppMock).toHaveBeenCalledWith({
id: "skill-1",
app: "pi",
enabled: false,
});
});
});
});
@@ -72,6 +72,7 @@ describe("useDirectorySettings", () => {
if (app === "grokbuild") return "/remote/grok";
if (app === "opencode") return "/remote/opencode";
if (app === "openclaw") return "/remote/openclaw";
if (app === "pi") return "/remote/pi";
return "/remote/hermes";
});
selectConfigDirectoryMock.mockReset();
@@ -96,6 +97,7 @@ describe("useDirectorySettings", () => {
opencode: "/remote/opencode",
openclaw: "/remote/openclaw",
hermes: "/remote/hermes",
pi: "/remote/pi",
});
});
@@ -14,6 +14,7 @@ function makeSkill(overrides: Partial<InstalledSkill> = {}): InstalledSkill {
opencode: false,
openclaw: false,
hermes: false,
pi: false,
},
installedAt: 0,
updatedAt: 0,
+28 -1
View File
@@ -92,6 +92,8 @@ const createSettingsFormMock = (overrides: Record<string, unknown> = {}) => ({
geminiConfigDir: "/gemini",
opencodeConfigDir: "/opencode",
openclawConfigDir: "/openclaw",
hermesConfigDir: "/hermes",
piConfigDir: "/pi",
language: "zh",
},
isLoading: false,
@@ -113,6 +115,8 @@ const createDirectorySettingsMock = (
gemini: "/default/gemini",
opencode: "/default/opencode",
openclaw: "/default/openclaw",
hermes: "/default/hermes",
pi: "/default/pi",
},
isLoading: false,
initialAppConfigDir: undefined,
@@ -161,6 +165,8 @@ describe("useSettings hook", () => {
geminiConfigDir: "/server/gemini",
opencodeConfigDir: "/server/opencode",
openclawConfigDir: "/server/openclaw",
hermesConfigDir: "/server/hermes",
piConfigDir: "/server/pi",
language: "zh",
};
@@ -348,6 +354,25 @@ describe("useSettings hook", () => {
expect(syncCurrentProvidersLiveMock).not.toHaveBeenCalled();
});
it("sanitizes and republishes when the Pi native directory changes", async () => {
settingsFormMock = createSettingsFormMock({
settings: {
...serverSettings,
piConfigDir: " /custom/pi ",
},
});
const { result } = renderHook(() => useSettings());
await act(async () => {
await result.current.saveSettings(undefined, { silent: true });
});
const payload = mutateAsyncMock.mock.calls[0][0] as Settings;
expect(payload.piConfigDir).toBe("/custom/pi");
expect(syncCurrentProvidersLiveMock).toHaveBeenCalledTimes(1);
});
it("shows toast when Claude plugin sync fails but continues flow", async () => {
// 设置服务器状态为 false,本地状态为 true,触发状态变化
serverSettings = {
@@ -459,9 +484,11 @@ describe("useSettings hook", () => {
claude: "/server/claude",
codex: undefined,
gemini: "/server/gemini",
grokbuild: undefined,
opencode: "/server/opencode",
openclaw: "/server/openclaw",
hermes: undefined,
hermes: "/server/hermes",
pi: "/server/pi",
});
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false);
});
+4
View File
@@ -170,6 +170,8 @@ describe("useSettingsForm Hook", () => {
enableClaudePluginIntegration: true,
claudeConfigDir: " /reset ",
codexConfigDir: " ",
hermesConfigDir: " /hermes-reset ",
piConfigDir: " /pi-reset ",
language: "zh",
});
});
@@ -180,6 +182,8 @@ describe("useSettingsForm Hook", () => {
expect(settings.enableClaudePluginIntegration).toBe(true);
expect(settings.claudeConfigDir).toBe("/reset");
expect(settings.codexConfigDir).toBeUndefined();
expect(settings.hermesConfigDir).toBe("/hermes-reset");
expect(settings.piConfigDir).toBe("/pi-reset");
expect(settings.language).toBe("zh");
expect(result.current.initialLanguage).toBe("en");
expect(changeLanguageSpy).toHaveBeenCalledWith("en");
+4
View File
@@ -73,6 +73,7 @@ const createDefaultProviders = (): ProvidersByApp => ({
opencode: {},
openclaw: {},
hermes: {},
pi: {},
});
const createDefaultCurrent = (): CurrentProviderState => ({
@@ -84,6 +85,7 @@ const createDefaultCurrent = (): CurrentProviderState => ({
opencode: "",
openclaw: "",
hermes: "",
pi: "",
});
let providers = createDefaultProviders();
@@ -197,6 +199,7 @@ let mcpConfigs: McpConfigState = {
opencode: {},
openclaw: {},
hermes: {},
pi: {},
};
const cloneProviders = (value: ProvidersByApp) =>
@@ -266,6 +269,7 @@ export const resetProviderState = () => {
opencode: {},
openclaw: {},
hermes: {},
pi: {},
};
};
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { getCacheWriteAvailability } from "@/types/usage";
describe("getCacheWriteAvailability", () => {
it("does not present Pi mixed-protocol cache creation as an authoritative zero", () => {
expect(getCacheWriteAvailability(["pi"])).toBe("partial");
expect(getCacheWriteAvailability(["pi", "codex"])).toBe("partial");
expect(getCacheWriteAvailability(["pi", "claude"])).toBe("partial");
});
it("preserves fixed-protocol and cross-app availability states", () => {
expect(getCacheWriteAvailability(["claude"])).toBe("ok");
expect(getCacheWriteAvailability(["codex", "gemini"])).toBe("na");
expect(getCacheWriteAvailability(["claude", "codex"])).toBe("partial");
expect(getCacheWriteAvailability([])).toBe("ok");
});
});