mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-06 23:20:53 +01:00
## Thinking Path > - Paperclip is the open source control plane for agent companies. > - People choose a harness when creating an agent. > - The server should resolve that harness to a qualified runner. > - Codex is the first harness in the staged default rollout. > - Saved agents must retain their execution choices. > - New Codex agents therefore use Paperclip Runner on qualified macOS/Linux targets, with an explicit Legacy override. ## Linked Issues or Issue Description Refs #16028, #15555, #15673, #15422, #15631, #15926, #15932, #16149. The runtime/install prerequisite and release repair are merged. This slice owns Codex creation, setup, configuration and preservation. Other harness defaults remain unchanged. It adds no database migration; incoming master migrations, required maps and regression coverage are retained. ## What Changed - Resolve `runner: auto | paperclip | legacy` at the common creation boundary across onboarding, API/CLI, hires, built-ins, plugins and imports. Freeze pending hires and export resolved execution choices. - Preserve existing runners on upgrades, unrelated edits and import updates, including compatible accounts, workspace settings, Codex effort and intentional clears. - Use the underlying harness picker and branding; put the runner override in Advanced. Use the shared dropdown and update production-component stories and creation contracts. - Support qualified Apple Silicon, Intel Mac, Linux x64 and Linux ARM64. Linux requires GNU libc 2.39+. Disabled, external, unsupported and unqualified combinations retain their applicable Legacy execution. - Test the selected runtime/account/model/environment. Ordinary Connect probes eligible immutable managed targets and refreshes scoped discovery. Creation/discovery do not provision. Auth, dependency, incompatible-setting and cleanup failures remain actionable without changing runners. - Keep setup probes read-only, preserve local/SSH isolation and resolve packaged assets before staging credentials. Bound cleanup reads by the remaining absolute deadline; physically close every owned ingress socket before endpoint release. Unconfirmed cleanup retains recovery state. - Preserve validated completed-onboarding callbacks through startup recovery. First file-producing work belongs to the child task and uses the existing dependency/block handoff and task-scoped registration. - Reuse existing E2E fixtures, artifact checks, reporting and controls. Keep Docker/Cargo/native compilation remote. Paperclip tarballs contain no Codex payloads; official optional npm platform dependencies remain. ## Verification Current tested head: `1c03a8815672d82c264a9b08d4ea1984e8fae107`. **Merge-ready: required CI, fresh review, package qualification, Mac core acceptance and Test task/follow-up acceptance pass.** The existing attachment and native-continuity oracles independently verify both Test downloads, run attribution and unchanged native/provider/workspace identities. The maintainer authorized a code-owner-review exception for this PR only. The first Test setup failure and normal Board completion-review friction are retained below. Actual post-merge packages and private Cloud distribution remain separate qualification gates. - [Current CI](https://github.com/paperclipai/paperclip/actions/runs/38564241625): all 48 ordinary jobs and required verify/E2E pass. Attempt 1 retained six simultaneous host-shutdown cancellations. One failed-only retry passes those six lanes and two aggregates, preserving 40 successes. Actual integration checkout `53104c1` combines this head with master `c80`; it is not relabeled as the branch tree. Fresh exact-head Greptile 5/5, 30 resolved threads, no actionable findings. - [Exact-source package qualification](https://github.com/paperclipai/paperclip/actions/runs/38565287239): nine applicable jobs pass, nine conditional jobs skip. Four remote native producers; Linux x64 clean install; Intel Mac, Apple Silicon and Linux ARM64 native startup/physical cleanup controls. All 18 tarballs/four daemon source/hash/manifest bindings and 343 migrations through 0344 verify. No Codex payload is packed. These hosted tarballs are not a published npm canary. - Normal Apple Silicon installation and credential-free native startup/cleanup pass using the packaged daemon, with no source build or provider turn. Fresh installed DB initialization passes. Upgrade 651a→1c03 preserves all three complete execution configs, accounts, ten recorded run profiles, paused budgets, active incidents, cost summaries and two historical unpriced events. Historical recovery state is retained. - Cleanup regressions: four delayed-read baseline failures become 15 passing deadline/authority controls. No-acknowledgement and prior-error socket ownership regressions become 12 passing controls. Direct runtime/server types, maps: 35 areas / 176 IDs, diff checks and independent reviews pass. Permission/auth/reconnect policy and the 10-second setup budget remain intact. - Existing source coverage includes creation/selection/imports, pending approvals, recovery, account/lifecycle handling, mounted forms, dropdowns and E2E support. Full typechecks/tests/build/token and applicable Storybook checks pass in the recorded current CI; no duplicate suite or weakened grader. - Literal 9546 live Mac proof: ordinary Auto Codex onboarding, useful document, real completed-child callback/reply, seven successful native runs, manual follow-up and independently downloaded1,101-byte child file. Existing Legacy title edit/reload preserves execution; two real Legacy runs produce independently checked15/34-byte files. These remain historical source records. - Current literal 1c03 Mac core acceptance passes independent checks: ordinary Auto native Codex onboarding without runner override; only timeout 180 seconds/concurrency 1 saved; six successful native runs and six serialized turns (maximum overlap 1); both same-task continuity checks and five mechanical callback checks; actual rendered parent reply and exact child downloads. The useful handoff advances from revision 1 to 2. Both files are 48 bytes, SHA-256 `8b742bd4e8123a7c078f0a8d9f3449fbfa8a320d7d90be641a2818db3c53d92c` and `f42db9774fa06e804ab7d596fc8b922a2b638921c418b87d0111151d290a6afe`; exactly one byte changes and old history remains. The agent is paused with zero active runs/errors. Both artifact work products have `isPrimary=false`, and descriptive titles fail a stricter title-helper assumption; these retained results are scoped editorial follow-up, not new merge gates. Six source-bound screenshots are uploaded and verified in the private primary QA report. - [Literal 9546 managed proof](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-38485488621-1/index.html): original 19 assertions and completed judge pass, four native turns, child-bound240-byte file and session/workspace continuity. Checkout reports a verified generated target-lock mutation; full final porcelain was not retained. Signed execution-image identity remains separate from Core source. - Actual 651a Test deployment succeeds on one owned/pinned target. One ordinary Auto API-key Connect then fails native cleanup before hire. Controller turn/drain/suspend completion and service-recorded environment cleanup are observed; they do not certify physical deletion or native authority retirement. Failure/recovery/unknown holds are retained. No Legacy switch or unchanged paid retry. The two source repairs above require actual treatment. - [Current immutable Core preview](https://github.com/paperclipai/paperclip/actions/runs/38570365725) and matching DB/shared artifacts pass. One owned, pinned Test target serves exact `1c03`; identical migration history correctly skips the migrator. Ordinary Auto Codex Connect succeeds and hires native; saved native selection and QA bounds survive reload. The initial child preparation attempt fails before provider work, with actual stdout identifying `renewal_deadline`; its trigger remains unknown and the failure is retained. Normal Board **Try again** and **Resume** recover the child to Done, producing a browser-verified 44-byte file, SHA-256 `0a8458b1efcc2d7a0d815689458c4caab039a46c7c7a3e5805de6bc8b1a96ecc`. The actual parent callback reports the completed child document/file. The existing validator requires a parent-owned attachment, so recovery enters normal Review; after byte checking, normal **Approve completion** reaches parent Done. No forced status or duplicate parent file is used. The validator is unchanged from integrated and current master. Autonomous initial callback-to-Done is not proven. The same-child follow-up completes document revision 2 and publishes a second independently downloaded 44-byte file, SHA-256 `22022257300fae762784632d08d3bbe0bab61e2ce81f91864a14dcd7aa464591`; only the revision digit changes. The unchanged existing native-continuity and attachment oracles pass. All ten recorded runs are terminal: nine native successes and the retained pre-provider setup failure. The QA agent is paused with zero active runs. The pre-existing parent evidence scope mismatch is a separate follow-up, with ordinary completion review exercised rather than a forced status or relaxed validation. This unsigned Core preview remains distinct from post-merge private Cloud composition. ## Risks - Saved agents are not migrated. Explicit runner changes use normal configuration/session rules; historical runs retain their recorded execution. - Native readiness depends on verified assets and server-owned target qualification. A Legacy probe, HTTP200 or successful account save cannot prove native readiness. - Recovery exceptions remain restricted to validated completed-onboarding reports; process-loss, ownership and terminal-state safeguards remain active. Unexecuted live journeys stay open. - Linux ARM paid tasks, Windows, Alpine and 32-bit ARM remain deferred. Cold Cargo cache misses are recorded; no warm-cache speedup is claimed. - Historical invoices/physical cleanup remain unconfirmed and held. After merge, exact published npm canary, signed standard image/migrator, private composition and serving/live acceptance still require qualification. Production deployment/default promotion is not authorized. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, tools, code execution and parallel agents. Exact serving snapshot and context-window size are not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used with available 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 linked public issues or PRs above - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] I have run the stated focused tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation - [x] I have considered and documented risks - [x] All Paperclip CI gates are green on the current candidate - [x] Greptile is 5/5 with no open findings on the current candidate - [x] I will address all reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
435 lines
26 KiB
TypeScript
435 lines
26 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { act, createElement } 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 dotApi = vi.hoisted(() => ({ create: vi.fn(), connection: vi.fn(), pair: vi.fn(), retry: vi.fn() }));
|
|
vi.mock("@/api/dotInvitations", () => ({ dotInvitationsApi: dotApi }));
|
|
const state = vi.hoisted(() => ({
|
|
adapters: [] as object[],
|
|
navigate: vi.fn(),
|
|
close: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/router", () => ({ useNavigate: () => state.navigate, Link: ({ to, children }: any) => createElement("a", { href: to }, children) }));
|
|
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();
|
|
Object.values(dotApi).forEach(mock => mock.mockReset());
|
|
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, staleTime: Infinity } } });
|
|
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 one Codex harness without a runner/provider tile", async () => {
|
|
await name();
|
|
expect(document.querySelector('input[value="paperclip_runner"]')).toBeNull();
|
|
expect(document.querySelectorAll('input[value="codex_local"]')).toHaveLength(1);
|
|
expect(document.querySelector("select")).toBeNull();
|
|
});
|
|
it.each(["claude", "grok", "opencode"])("preserves explicit experimental %s creation in Advanced", async provider => {
|
|
await name();
|
|
await act(async () => (document.querySelector("summary") as HTMLElement).click());
|
|
await act(async () => document.querySelector<HTMLButtonElement>('[aria-label="Experimental harness"]')!.click());
|
|
await act(async () => document.querySelector<HTMLButtonElement>(`[role="option"][data-value="${provider}"]`)!.click());
|
|
await click("Configure agent");
|
|
const query = new URL(state.navigate.mock.calls[0][0], "http://local").searchParams;
|
|
expect(query.get("adapterType")).toBe("paperclip_runner");
|
|
expect(query.get("runnerProvider")).toBe(provider);
|
|
});
|
|
it.each(["gate off", "disabled adapter"])("hides experimental native creation when %s", async reason => {
|
|
await act(async () => {
|
|
if (reason === "gate off") cache.setQueryData(queryKeys.instance.experimentalSettings, { enableNativeRunner: false });
|
|
else cache.setQueryData(queryKeys.adapters.all, state.adapters.map((entry: any) => entry.type === "paperclip_runner" ? { ...entry, disabled: true } : entry));
|
|
});
|
|
await name();
|
|
expect(document.querySelector('[aria-label="Experimental harness"]')).toBeNull();
|
|
expect(document.querySelectorAll('input[value="codex_local"]')).toHaveLength(1);
|
|
});
|
|
|
|
it("moves Dot out of the harness picker into experimental external invitations", async () => {
|
|
await act(async () => cache.setQueryData(queryKeys.instance.experimentalSettings, { enableNativeRunner: false, enableOpenAiDot: true, enablePublicMcp: true }));
|
|
await name();
|
|
expect(document.querySelector('input[value="openai_dot"]')).toBeNull();
|
|
await click("Back");
|
|
await click("Invite an external agent");
|
|
const dot = [...document.querySelectorAll("button")].find(b => b.textContent?.startsWith("Dot"));
|
|
expect(dot?.disabled).toBe(false);
|
|
});
|
|
|
|
it.each(["Hermes", "Other"])("keeps the existing agent invitation for %s and reuses it after Back", async (kind) => {
|
|
await click("Invite an external agent");
|
|
const select = async () => act(async () => [...document.querySelectorAll("button")].find(b => b.textContent?.startsWith(kind))!.click());
|
|
await select();
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); });
|
|
expect(invites.createCompanyInvite).toHaveBeenCalledWith("company-1", { allowedJoinTypes: "agent", humanRole: null, agentMessage: null });
|
|
expect(invites.copy).not.toHaveBeenCalled();
|
|
await click("Copy invitation prompt");
|
|
expect(invites.copy.mock.calls[0][0]).toContain("/api/invites/one-time-token/onboarding.txt");
|
|
await click("Back");
|
|
await select();
|
|
expect(invites.createCompanyInvite).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("provides a readable prompt and copy retry when the clipboard fails", async () => {
|
|
invites.copy.mockRejectedValueOnce(new Error("Clipboard unavailable"));
|
|
await click("Invite an external agent");
|
|
await act(async () => [...document.querySelectorAll("button")].find(b => b.textContent?.startsWith("Other"))!.click());
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); });
|
|
await click("Copy invitation prompt");
|
|
expect(document.body.textContent).toContain("Could not copy automatically");
|
|
expect(document.querySelector<HTMLTextAreaElement>('textarea[aria-label="Setup prompt"]')?.value).toContain("onboarding.txt");
|
|
await click("Try copying again");
|
|
expect(document.body.textContent).not.toContain("Could not copy automatically");
|
|
expect(invites.createCompanyInvite).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each([{ enableOpenAiDot: false, enablePublicMcp: true }, { enableOpenAiDot: true, enablePublicMcp: false }])("requires both Dot experimental prerequisites: %j", async flags => {
|
|
await act(async () => cache.setQueryData(queryKeys.instance.experimentalSettings, flags));
|
|
await click("Invite an external agent");
|
|
const dot = [...document.querySelectorAll("button")].find(b => b.textContent?.startsWith("Dot"));
|
|
expect(dot?.disabled).toBe(true);
|
|
expect(document.body.textContent).toContain("Assistant connections (MCP)");
|
|
expect(dotApi.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("copies one scoped Dot prompt, resumes on Back, and trusts only server readiness", async () => {
|
|
const connection = { enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", agentLifecycleState: "ready", canConfigureConnection: true, binding: null as any };
|
|
dotApi.create.mockResolvedValue({ agent: { id: "dot-agent", status: "idle" }, approvalId: null, binding: null });
|
|
dotApi.connection.mockImplementation(async () => ({ ...connection }));
|
|
dotApi.pair.mockImplementation(async () => {
|
|
connection.binding = { id: "binding", status: "pairing", connected: false, subscriptionVerified: false, hasPendingChallenge: false };
|
|
return { bindingId: "binding", pairingCode: "test-one-use-code", expiresAt: new Date(Date.now() + 900000).toISOString() };
|
|
});
|
|
await act(async () => cache.setQueryData(queryKeys.instance.experimentalSettings, { enableOpenAiDot: true, enablePublicMcp: true }));
|
|
await click("Invite an external agent");
|
|
const select = async () => act(async () => [...document.querySelectorAll("button")].find(b => b.textContent?.startsWith("Dot"))!.click());
|
|
await select();
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 50)); });
|
|
await click("Copy setup prompt");
|
|
expect(invites.copy.mock.calls[0][0]).toContain("test-one-use-code");
|
|
expect(document.body.textContent).toContain("Watching for your Dot");
|
|
expect(document.body.textContent).not.toContain("Your Dot is connected");
|
|
expect(JSON.stringify(cache.getMutationCache().getAll().map(m => m.state.data))).not.toContain("test-one-use-code");
|
|
await click("Back"); await select();
|
|
expect(dotApi.create).toHaveBeenCalledTimes(1);
|
|
expect(dotApi.pair).toHaveBeenCalledTimes(1);
|
|
await click("Copy setup prompt");
|
|
expect(document.querySelector('button[aria-label="Close agent setup"]')).not.toBeNull();
|
|
connection.binding = { ...connection.binding, status: "connected", connected: true };
|
|
await act(async () => cache.invalidateQueries({ queryKey: ["dot-binding", "company-1", "dot-agent"] }));
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
|
expect(document.querySelector('button[aria-label="Close agent setup"]')).toBeNull();
|
|
expect(document.body.textContent).not.toContain("Copy setup prompt");
|
|
expect([...document.querySelectorAll("button")].find(b => b.textContent === "Connecting…")?.disabled).toBe(true);
|
|
connection.binding = { ...connection.binding, subscriptionVerified: true, hasPendingChallenge: true };
|
|
await act(async () => cache.invalidateQueries({ queryKey: ["dot-binding", "company-1", "dot-agent"] }));
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
|
expect([...document.querySelectorAll("button")].find(b => b.textContent === "Confirming connection…")?.disabled).toBe(true);
|
|
expect(document.body.textContent).not.toContain("Done");
|
|
connection.binding = { ...connection.binding, status: "ready", connected: true, subscriptionVerified: true, hasPendingChallenge: false };
|
|
await act(async () => cache.invalidateQueries({ queryKey: ["dot-binding", "company-1", "dot-agent"] }));
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
|
expect(document.body.textContent).toContain("Your Dot is connected");
|
|
expect(document.body.textContent).toContain("Test event confirmed");
|
|
await click("Done");
|
|
expect(state.close).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("waits for company approval before issuing a Dot capability", async () => {
|
|
dotApi.create.mockResolvedValue({ agent: { id: "pending-dot", status: "pending_approval" }, approvalId: "approval-id", binding: null });
|
|
dotApi.connection.mockResolvedValue({ enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "pending_approval", agentLifecycleState: "pending_approval", canConfigureConnection: false, binding: null });
|
|
await act(async () => cache.setQueryData(queryKeys.instance.experimentalSettings, { enableOpenAiDot: true, enablePublicMcp: true }));
|
|
await click("Invite an external agent");
|
|
await act(async () => [...document.querySelectorAll("button")].find(b => b.textContent?.startsWith("Dot"))!.click());
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); });
|
|
expect(document.querySelector('a[href="/approvals/approval-id"]')?.textContent).toBe("Review approval");
|
|
expect(dotApi.pair).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows the scoped Dot invitation on cloud when its prerequisites are enabled", async () => {
|
|
await act(async () => {
|
|
cache.setQueryData(queryKeys.instance.experimentalSettings, { enableOpenAiDot: true, enablePublicMcp: true });
|
|
cache.setQueryData(queryKeys.health, { status: "ok", cloud: { managed: true } });
|
|
});
|
|
await click("Invite an external agent");
|
|
const dot = [...document.querySelectorAll("button")].find(b => b.textContent?.startsWith("Dot"))!;
|
|
expect(dot.disabled).toBe(false);
|
|
await act(async () => dot.click());
|
|
expect(dotApi.create).toHaveBeenCalledWith("company-1");
|
|
});
|
|
|
|
async function openDotSetup() {
|
|
await act(async () => cache.setQueryData(queryKeys.instance.experimentalSettings, { enableOpenAiDot: true, enablePublicMcp: true }));
|
|
await click("Invite an external agent");
|
|
await act(async () => [...document.querySelectorAll("button")].find(b => b.textContent?.startsWith("Dot"))!.click());
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 50)); });
|
|
}
|
|
|
|
const pendingDotBinding = (id: string, expiresAt: string) => ({
|
|
id, status: "pairing", connected: false, subscriptionVerified: false, hasPendingChallenge: false, pairingExpiresAt: expiresAt,
|
|
});
|
|
|
|
it.each(["expired", "unavailable"])("automatically replaces an %s saved Dot prompt on opening", async condition => {
|
|
const connection = { enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", agentLifecycleState: "ready", canConfigureConnection: true,
|
|
binding: pendingDotBinding("saved-binding", new Date(Date.now() + (condition === "expired" ? -60000 : 900000)).toISOString()) };
|
|
dotApi.create.mockResolvedValue({ agent: { id: "dot-agent", status: "idle" }, approvalId: null, binding: connection.binding });
|
|
dotApi.connection.mockImplementation(async () => ({ ...connection }));
|
|
dotApi.pair.mockImplementation(async () => {
|
|
const expiresAt = new Date(Date.now() + 900000).toISOString();
|
|
connection.binding = pendingDotBinding("fresh-binding", expiresAt);
|
|
return { bindingId: "fresh-binding", pairingCode: "fresh-test-code", expiresAt };
|
|
});
|
|
await openDotSetup();
|
|
expect(dotApi.pair).toHaveBeenCalledExactlyOnceWith("company-1", "dot-agent", "saved-binding");
|
|
expect(document.body.textContent).not.toContain("expired");
|
|
expect(document.body.textContent).not.toContain("Create a new prompt");
|
|
await click("Copy setup prompt");
|
|
expect(invites.copy.mock.calls[0][0]).toContain("fresh-test-code");
|
|
await act(async () => cache.invalidateQueries({ queryKey: ["dot-binding", "company-1", "dot-agent"] }));
|
|
expect(dotApi.pair).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("renews a Dot prompt that expires while setup stays open", async () => {
|
|
const connection = { enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", agentLifecycleState: "ready", canConfigureConnection: true, binding: null as any };
|
|
dotApi.create.mockResolvedValue({ agent: { id: "dot-agent", status: "idle" }, approvalId: null, binding: null });
|
|
dotApi.connection.mockImplementation(async () => ({ ...connection }));
|
|
let generation = 0;
|
|
dotApi.pair.mockImplementation(async () => {
|
|
generation++;
|
|
const expiresAt = new Date(Date.now() + (generation === 1 ? 350 : 900000)).toISOString();
|
|
connection.binding = pendingDotBinding(`binding-${generation}`, expiresAt);
|
|
return { bindingId: connection.binding.id, pairingCode: `code-${generation}`, expiresAt };
|
|
});
|
|
await openDotSetup();
|
|
expect(dotApi.pair).toHaveBeenCalledTimes(1);
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 400)); });
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); });
|
|
expect(dotApi.pair).toHaveBeenCalledTimes(2);
|
|
expect(dotApi.pair).toHaveBeenLastCalledWith("company-1", "dot-agent", "binding-1");
|
|
expect(document.body.textContent).not.toContain("expired");
|
|
await click("Copy setup prompt");
|
|
expect(invites.copy.mock.calls[0][0]).toContain("code-2");
|
|
});
|
|
|
|
it("checks fresh connection state before renewing a cached pending Dot invitation", async () => {
|
|
const staleBinding = pendingDotBinding("saved-binding", new Date(Date.now() - 60000).toISOString());
|
|
cache.setQueryData(["dot-binding", "company-1", "dot-agent"], {
|
|
enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", agentLifecycleState: "ready", canConfigureConnection: true, binding: staleBinding,
|
|
});
|
|
dotApi.create.mockResolvedValue({ agent: { id: "dot-agent", status: "idle" }, approvalId: null, binding: staleBinding });
|
|
dotApi.connection.mockResolvedValue({ enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", agentLifecycleState: "ready", canConfigureConnection: true,
|
|
binding: { ...staleBinding, status: "ready", connected: true, subscriptionVerified: true } });
|
|
await openDotSetup();
|
|
expect(dotApi.connection).toHaveBeenCalled();
|
|
expect(dotApi.pair).not.toHaveBeenCalled();
|
|
expect(document.body.textContent).toContain("Your Dot is connected");
|
|
});
|
|
|
|
it("offers a retry after automatic renewal fails without rotating on every poll", async () => {
|
|
const connection = { enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", agentLifecycleState: "ready", canConfigureConnection: true,
|
|
binding: pendingDotBinding("saved-binding", new Date(Date.now() - 60000).toISOString()) };
|
|
dotApi.create.mockResolvedValue({ agent: { id: "dot-agent", status: "idle" }, approvalId: null, binding: connection.binding });
|
|
dotApi.connection.mockImplementation(async () => ({ ...connection }));
|
|
dotApi.pair.mockRejectedValueOnce(new Error("Connection interrupted. Try again."));
|
|
await openDotSetup();
|
|
expect(dotApi.pair).toHaveBeenCalledTimes(1);
|
|
expect(document.body.textContent).toContain("Connection interrupted. Try again.");
|
|
await act(async () => cache.invalidateQueries({ queryKey: ["dot-binding", "company-1", "dot-agent"] }));
|
|
expect(dotApi.pair).toHaveBeenCalledTimes(1);
|
|
dotApi.pair.mockImplementation(async () => {
|
|
const expiresAt = new Date(Date.now() + 900000).toISOString();
|
|
connection.binding = pendingDotBinding("retried-binding", expiresAt);
|
|
return { bindingId: "retried-binding", pairingCode: "retried-code", expiresAt };
|
|
});
|
|
await click("Try again");
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); });
|
|
expect(dotApi.pair).toHaveBeenCalledTimes(2);
|
|
await click("Copy setup prompt");
|
|
expect(invites.copy.mock.calls[0][0]).toContain("retried-code");
|
|
});
|
|
|
|
|
|
it("does not let an older window renew another window's fresh Dot prompt", async () => {
|
|
const connection = { enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", agentLifecycleState: "ready", canConfigureConnection: true, binding: null as any };
|
|
dotApi.create.mockResolvedValue({ agent: { id: "dot-agent", status: "idle" }, approvalId: null, binding: null });
|
|
dotApi.connection.mockImplementation(async () => ({ ...connection }));
|
|
dotApi.pair.mockImplementation(async () => {
|
|
const expiresAt = new Date(Date.now() + 350).toISOString();
|
|
connection.binding = pendingDotBinding("old-window-binding", expiresAt);
|
|
return { bindingId: connection.binding.id, pairingCode: "old-code", expiresAt };
|
|
});
|
|
await openDotSetup();
|
|
connection.binding = pendingDotBinding("other-window-binding", new Date(Date.now() + 900000).toISOString());
|
|
await act(async () => cache.invalidateQueries({ queryKey: ["dot-binding", "company-1", "dot-agent"] }));
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 420)); });
|
|
expect(dotApi.pair).toHaveBeenCalledTimes(1);
|
|
expect(document.body.textContent).toContain("Create a new prompt");
|
|
expect(document.body.textContent).not.toContain("Copy setup prompt");
|
|
});
|
|
|
|
it("retries the event test after Dot connects during a failed prompt renewal", async () => {
|
|
const connection = { enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "idle", agentLifecycleState: "ready", canConfigureConnection: true,
|
|
binding: pendingDotBinding("saved-binding", new Date(Date.now() - 60000).toISOString()) as any };
|
|
dotApi.create.mockResolvedValue({ agent: { id: "dot-agent", status: "idle" }, approvalId: null, binding: connection.binding });
|
|
dotApi.connection.mockImplementation(async () => ({ ...connection }));
|
|
dotApi.pair.mockRejectedValueOnce(new Error("Dot already connected."));
|
|
await openDotSetup();
|
|
connection.binding = { ...connection.binding, status: "connected", connected: true, subscriptionVerified: true,
|
|
challengeExpiresAt: new Date(Date.now() - 1000).toISOString() };
|
|
await act(async () => cache.invalidateQueries({ queryKey: ["dot-binding", "company-1", "dot-agent"] }));
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
|
expect(document.body.textContent).not.toContain("Dot already connected.");
|
|
await click("Retry test event");
|
|
expect(dotApi.retry).toHaveBeenCalledExactlyOnceWith("company-1", "dot-agent", "saved-binding");
|
|
expect(dotApi.pair).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
|
|
it.each(["preparing", "verifying"])("prepares the Dot prompt while the hire is %s", async agentLifecycleState => {
|
|
const connection = { enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "paused",
|
|
agentLifecycleState, canConfigureConnection: true, binding: null as any };
|
|
dotApi.create.mockResolvedValue({ agent: { id: "dot-agent", status: "paused" }, approvalId: null, binding: null });
|
|
dotApi.connection.mockImplementation(async () => ({ ...connection }));
|
|
dotApi.pair.mockImplementation(async () => {
|
|
connection.binding = pendingDotBinding("binding", new Date(Date.now() + 900000).toISOString());
|
|
return { bindingId: "binding", pairingCode: "setup-hire-code", expiresAt: connection.binding.pairingExpiresAt };
|
|
});
|
|
await openDotSetup();
|
|
expect(dotApi.pair).toHaveBeenCalledTimes(1);
|
|
expect(document.body.textContent).not.toContain("Resume this agent");
|
|
await click("Copy setup prompt");
|
|
expect(invites.copy.mock.calls[0][0]).toContain("setup-hire-code");
|
|
});
|
|
|
|
it("does not prepare a Dot capability for a real paused agent", async () => {
|
|
dotApi.create.mockResolvedValue({ agent: { id: "dot-agent", status: "paused" }, approvalId: null, binding: null });
|
|
dotApi.connection.mockResolvedValue({ enabled: true, resourceUrl: "https://paperclip.example/mcp/runner",
|
|
agentStatus: "paused", agentLifecycleState: "paused", canConfigureConnection: false, binding: null });
|
|
await openDotSetup();
|
|
expect(dotApi.pair).not.toHaveBeenCalled();
|
|
expect(document.body.textContent).toContain("Resume this agent before connecting Dot.");
|
|
});
|
|
|
|
it("keeps watching after the event check until hire verification completes", async () => {
|
|
const connection = { enabled: true, resourceUrl: "https://paperclip.example/mcp/runner", agentStatus: "paused",
|
|
agentLifecycleState: "verifying", canConfigureConnection: true, binding: null as any };
|
|
dotApi.create.mockResolvedValue({ agent: { id: "dot-agent", status: "paused" }, approvalId: null, binding: null });
|
|
dotApi.connection.mockImplementation(async () => ({ ...connection }));
|
|
dotApi.pair.mockImplementation(async () => {
|
|
connection.binding = pendingDotBinding("binding", new Date(Date.now() + 900000).toISOString());
|
|
return { bindingId: "binding", pairingCode: "setup-hire-code", expiresAt: connection.binding.pairingExpiresAt };
|
|
});
|
|
await openDotSetup();
|
|
connection.binding = { ...connection.binding, status: "ready", connected: true, subscriptionVerified: true, hasPendingChallenge: false };
|
|
await act(async () => cache.invalidateQueries({ queryKey: ["dot-binding", "company-1", "dot-agent"] }));
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
|
expect(document.body.textContent).not.toContain("Your Dot is connected");
|
|
expect(document.body.textContent).toContain("Test event confirmed: complete");
|
|
expect(document.body.textContent).toContain("Your Dot confirmed the test event. Paperclip is finishing agent setup.");
|
|
const finishing = Array.from(document.querySelectorAll("button")).find(button => button.textContent?.includes("Finishing setup…"));
|
|
expect(finishing?.disabled).toBe(true);
|
|
connection.agentLifecycleState = "ready";
|
|
connection.agentStatus = "idle";
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 2750)); });
|
|
expect(document.body.textContent).toContain("Your Dot is connected");
|
|
await click("Done");
|
|
expect(state.close).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps completed checks but shows a pause blocker for a paired paused Dot", async () => {
|
|
dotApi.create.mockResolvedValue({ agent: { id: "dot-agent", status: "paused" }, approvalId: null, binding: null });
|
|
dotApi.connection.mockResolvedValue({ enabled: true, resourceUrl: "https://paperclip.example/mcp/runner",
|
|
agentStatus: "paused", agentLifecycleState: "paused", canConfigureConnection: false,
|
|
binding: { ...pendingDotBinding("binding", new Date(Date.now() + 900000).toISOString()),
|
|
status: "ready", connected: true, subscriptionVerified: true, hasPendingChallenge: false } });
|
|
await openDotSetup();
|
|
expect(document.body.textContent).toContain("Test event confirmed: complete");
|
|
expect(document.body.textContent).toContain("Resume this agent before connecting Dot.");
|
|
expect(document.body.textContent).not.toContain("Finishing setup…");
|
|
expect(document.body.textContent).not.toContain("Paperclip is finishing agent setup.");
|
|
const blocked = Array.from(document.querySelectorAll("button")).find(button => button.textContent?.includes("Agent unavailable"));
|
|
expect(blocked?.disabled).toBe(true);
|
|
expect(dotApi.pair).not.toHaveBeenCalled();
|
|
});
|