mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
## Thinking Path > - Paperclip manages AI agents and their execution settings. > - The new-agent flow selects an adapter before configuring credentials and a model. > - Cloud uses one adapter policy for the picker and direct setup links. > - That policy excludes Grok despite its existing adapter and xAI connection support. > - This change adds Grok to the Cloud policy. > - Cloud users can configure Grok with the existing subscription or API-key flow. ## Linked Issues or Issue Description **What existing behavior does this improve?** Agent creation on Cloud. **Current behavior** The Cloud picker offers Claude, Codex, and OpenCode. Direct Grok setup links also fail the shared adapter check. **Proposed behavior** Offer Grok alongside the existing choices. Use the existing xAI connection and managed sandbox setup. **Reason and benefit** Users can select an already-supported adapter through the Cloud creation flow. **Breaking changes** None. Loaded and enabled checks still apply. Other excluded adapters remain excluded. ## What Changed - Add `grok_local` to the shared Cloud creation policy. - Display four adapter choices in a 2×2 grid on desktop and mobile, and reuse the theme-aware provider mark on the connection step so Grok is visible in dark mode. - Verify picker navigation and both Grok authentication methods through sandbox setup, model testing, and agent creation. - Verify that probe and hire payloads carry the xAI connection binding without the entered API key. - Update the agent configuration specification. ## Verification - `cd ui && pnpm exec vitest run src/components/NewAgentDialog.test.tsx src/pages/NewAgent.test.tsx src/components/new-agent/AgentProviderConnection.test.tsx` — 69 tests passed. - Chromium checks against the production components and built stylesheet — four cards occupy two rows and two columns at 1280px and 390px; the connection step loads and displays the white Grok logo in dark mode and the black logo in light mode. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm --filter @paperclipai/ui build` — passed. - `pnpm check:token-gates` — passed. - `git diff origin/master...HEAD | gitleaks stdin --redact --no-banner` — no leaks found; manual diff review found no private identifiers or user data. - `pnpm -r typecheck` and `pnpm build` — blocked at the existing runner package because `cargo` is not installed locally. - `pnpm test:run` — started locally, then stopped after the equivalent CI suites passed. - Initial [PR CI](https://github.com/paperclipai/paperclip/actions/runs/35681351752) passed, including full typecheck/build, general and serialized tests, Rust checks, and all eight browser-test shards. One unchanged Cursor test timed out on the first attempt; its five-test file passed locally and the failed CI shard passed on retry. - The [latest CI run](https://github.com/paperclipai/paperclip/actions/runs/35683642812) passed build, typecheck, all general and serialized tests, Rust checks, and all eight browser-test shards. One unchanged local-service-supervisor test failed its HTTP readiness check on the first attempt; its six-test file passed locally, and the failed server shard passed on retry. - Live xAI login and model execution were not run; the setup tests mock provider calls. ## Risks Small UI policy change. The existing Grok adapter, authentication, and secret storage paths remain in use. No schema or control-plane change is required. Cloud must deploy a tenant-app release containing this change. Revert the policy entry to hide Grok from new-agent setup again. ## Model Used - OpenAI GPT-6 (Codex), with repository inspection, code editing, and shell-based verification. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
200 lines
7.4 KiB
TypeScript
200 lines
7.4 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
import { NewAgentDialog } from "./NewAgentDialog";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
vi.mock("@/api/instanceSettings", () => ({
|
|
instanceSettingsApi: {
|
|
getExperimental: async () => ({ enableNativeRunner: true }),
|
|
},
|
|
}));
|
|
const invites = vi.hoisted(() => ({ createCompanyInvite: vi.fn(), getInviteOnboarding: vi.fn(), copy: vi.fn() }));
|
|
vi.mock("../api/access", () => ({ accessApi: invites }));
|
|
vi.mock("../lib/clipboard", () => ({ copyTextToClipboard: invites.copy }));
|
|
vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1" }) }));
|
|
const state = vi.hoisted(() => ({
|
|
adapters: [] as object[],
|
|
navigate: vi.fn(),
|
|
close: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/router", () => ({ useNavigate: () => state.navigate }));
|
|
vi.mock("../context/DialogContext", () => ({
|
|
useDialog: () => ({ newAgentOpen: true, closeNewAgent: state.close }),
|
|
}));
|
|
vi.mock("@/api/adapters", () => ({
|
|
adaptersApi: { list: async () => state.adapters },
|
|
}));
|
|
vi.mock("./onboarding/PillGuy", () => ({ PillGuy: () => null }));
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
let root: Root;
|
|
let container: HTMLDivElement;
|
|
let cache: QueryClient;
|
|
async function click(label: string) {
|
|
const button = [...document.querySelectorAll("button")].find(
|
|
(b) => b.textContent?.trim() === label,
|
|
);
|
|
expect(button).toBeTruthy();
|
|
await act(async () => button!.click());
|
|
}
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
invites.createCompanyInvite.mockResolvedValue({ token: "one-time-token", onboardingTextPath: "/api/invites/one-time-token/onboarding.txt" });
|
|
invites.getInviteOnboarding.mockResolvedValue({ onboarding: { connectivity: {} } });
|
|
invites.copy.mockResolvedValue(undefined);
|
|
state.adapters = [
|
|
{ type: "codex_local", loaded: true },
|
|
{ type: "paperclip_runner", loaded: true },
|
|
{ type: "claude_local", loaded: true, disabled: true },
|
|
];
|
|
container = document.createElement("div");
|
|
document.body.append(container);
|
|
root = createRoot(container);
|
|
cache = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
await act(async () =>
|
|
root.render(
|
|
<QueryClientProvider client={cache}>
|
|
<NewAgentDialog />
|
|
</QueryClientProvider>,
|
|
),
|
|
);
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
});
|
|
});
|
|
afterEach(async () => {
|
|
await act(async () => root.unmount());
|
|
cache.clear();
|
|
container.remove();
|
|
});
|
|
async function name() {
|
|
const input = document.querySelector("input")!;
|
|
await act(async () => {
|
|
Object.getOwnPropertyDescriptor(
|
|
HTMLInputElement.prototype,
|
|
"value",
|
|
)!.set!.call(input, "Ada & Co");
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
await click("Choose adapter");
|
|
}
|
|
it("requires a name and an enabled adapter before navigating", async () => {
|
|
await click("Choose adapter");
|
|
expect(document.body.textContent).toContain("Agent name");
|
|
await name();
|
|
expect(document.querySelector('input[value="claude_local"]')).toBeNull();
|
|
await click("Configure agent");
|
|
expect(state.navigate).not.toHaveBeenCalled();
|
|
await act(async () =>
|
|
(
|
|
document.querySelector('input[value="codex_local"]') as HTMLInputElement
|
|
).click(),
|
|
);
|
|
await click("Configure agent");
|
|
expect(state.close).toHaveBeenCalledTimes(1);
|
|
const query = new URL(state.navigate.mock.calls[0][0], "http://local")
|
|
.searchParams;
|
|
expect(query.get("name")).toBe("Ada & Co");
|
|
expect(query.get("adapterType")).toBe("codex_local");
|
|
});
|
|
it("offers native Codex, Claude ACPX, and OpenCode runners", async () => {
|
|
await name();
|
|
await act(async () =>
|
|
(
|
|
document.querySelector(
|
|
'input[value="paperclip_runner"]',
|
|
) as HTMLInputElement
|
|
).click(),
|
|
);
|
|
const options = [...document.querySelectorAll("option")].map(
|
|
(option) => option.textContent,
|
|
);
|
|
expect(options).toContain("Codex (app server)");
|
|
expect(options).toContain("Claude (ACPX)");
|
|
expect(options).toContain("OpenCode");
|
|
expect(options.join(" ")).not.toContain("ACPX Codex");
|
|
});
|
|
|
|
it.each([false, undefined])(
|
|
"hides the runner unless explicitly enabled (%s)",
|
|
async (enableNativeRunner) => {
|
|
await act(async () => {
|
|
cache.setQueryData(queryKeys.instance.experimentalSettings, {
|
|
enableNativeRunner,
|
|
});
|
|
});
|
|
await name();
|
|
expect(
|
|
document.querySelector('input[value="paperclip_runner"]'),
|
|
).toBeNull();
|
|
expect(document.querySelector('input[value="codex_local"]')).not.toBeNull();
|
|
},
|
|
);
|
|
|
|
it("offers Claude, Codex, OpenCode, and Grok on Cloud, even with the runner enabled", async () => {
|
|
await act(async () => {
|
|
cache.setQueryData(queryKeys.health, {
|
|
status: "ok",
|
|
cloud: { managed: true },
|
|
});
|
|
cache.setQueryData(
|
|
queryKeys.adapters.all,
|
|
[
|
|
"claude_local",
|
|
"codex_local",
|
|
"opencode_local",
|
|
"cursor",
|
|
"cursor_cloud",
|
|
"gemini_local",
|
|
"grok_local",
|
|
"kimi_local",
|
|
"pi_local",
|
|
"hermes_local",
|
|
"paperclip_runner",
|
|
].map((type) => ({ type, loaded: true })),
|
|
);
|
|
});
|
|
await name();
|
|
expect(
|
|
[...document.querySelectorAll<HTMLInputElement>('input[type="radio"]')].map(
|
|
(input) => input.value,
|
|
),
|
|
).toEqual(["claude_local", "codex_local", "opencode_local", "grok_local"]);
|
|
expect(document.body.textContent).not.toContain("CLI harness");
|
|
await act(async () =>
|
|
document.querySelector<HTMLInputElement>('input[value="grok_local"]')!.click(),
|
|
);
|
|
await click("Configure agent");
|
|
const query = new URL(state.navigate.mock.calls[0][0], "http://local").searchParams;
|
|
expect(query.get("adapterType")).toBe("grok_local");
|
|
expect(query.get("name")).toBe("Ada & Co");
|
|
});
|
|
|
|
it("keeps agent-only invitations reachable from the new-agent flow", async () => {
|
|
await click("Invite an external agent");
|
|
const message = document.querySelector("textarea")!;
|
|
await act(async () => {
|
|
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(message, "Help with research");
|
|
message.dispatchEvent(new Event("input", { bubbles: true }));
|
|
});
|
|
await click("Generate onboarding prompt");
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
|
expect(invites.createCompanyInvite).toHaveBeenCalledWith("company-1", {
|
|
allowedJoinTypes: "agent", humanRole: null, agentMessage: "Help with research",
|
|
});
|
|
expect(document.querySelector<HTMLTextAreaElement>('textarea[readonly]')?.value).toContain("/api/invites/one-time-token/onboarding.txt");
|
|
expect(invites.copy).toHaveBeenCalled();
|
|
expect(state.navigate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps the generated invitation readable when clipboard access fails", async () => {
|
|
invites.copy.mockRejectedValue(new Error("Clipboard unavailable"));
|
|
invites.getInviteOnboarding.mockRejectedValue(new Error("Manifest unavailable"));
|
|
await click("Invite an external agent");
|
|
await click("Generate onboarding prompt");
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
|
expect(document.body.textContent).toContain("Copy the prompt manually");
|
|
expect(document.querySelector<HTMLTextAreaElement>('textarea[readonly]')?.value).toContain("/api/invites/one-time-token/onboarding.txt");
|
|
});
|