Files
PaperClipAI/ui/src/components/NewAgentDialog.test.tsx
T
Nicky LeachandPaperclip 79b2bf78dd Create agent records when onboarding names are submitted (#17005)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Core onboarding selects an agent and connects its provider account.
> - The current flow creates the agent record after authentication.
> - Setup needs a stable agent identity as soon as the user submits its
name.
> - This PR saves a paused setup record on name submission and finishes
the hire on that same record.
> - The existing sandbox authentication and approval paths continue to
apply.

## Linked Issues or Issue Description

**Subsystem affected**

Core agent lifecycle, shared validators, and onboarding UI.

**Current behavior**

Onboarding creates the database record at the final hire step. Earlier
authentication and saved setup have no agent record.

**Proposed behavior**

Create a paused agent with a setup hold when the user submits its name.
Do this before adapter selection or authentication. Update that same
record after adapter selection. Reuse its ID on retries and saved setup.
Finish that record through the existing hire path.

**Reason and benefit**

Give setup a stable identity without coupling this refactor to computer
provisioning or a new execution backend.

**Breaking changes**

Incomplete setup now leaves an inactive agent record. Existing clients
can still create and hire agents without the new optional fields. The
sandbox authentication flow and API key support remain in place.

**Additional context**

Requested Core-only refactor. Related work: #15422 changes runner
defaults. This PR preserves those choices. No duplicate early-record
refactor was found in the issue and PR search.

## What Changed

- Save the agent when the user submits its name in both onboarding entry
points. Use a provisional adapter until the user chooses one. Reopen
name-only drafts at adapter selection.
- Add retry-safe setup creation and same-record hire completion. Keep
approval checks at final hire.
- Save and restore setup configuration in both onboarding entry points.
Show Finish setup for paused setup agents.
- Restrict setup changes to authorized board users. Block generic resume
until setup finishes.
- Add lifecycle, authorization, connection, and UI coverage. Update the
specification and feature maps.

## Verification

- Latest focused server run: 126 lifecycle and AI-connection tests
passed after the Apex fixes. An earlier broader run passed 301
lifecycle, instruction, adapter-validation, and AI-connection tests.
Earlier authentication, retry, cross-company, and direct-create
regressions also passed.
- Name-save verification: 336 tests across the wizard, naming dialog,
New agent, action-button, and wizard-step suites passed. Coverage
includes retry identity, repeated submission, closing during a save,
saved-model restoration, and a disabled process adapter.
- Shared, server, and UI TypeScript checks passed. The latest UI package
check and direct server TypeScript check passed. The server package
preparation command could not finish locally because Cargo is
unavailable; full build preparation runs in CI. Map checks, token gates,
and `git diff --check` passed; each pushed diff was scanned for
credentials and PII.
- All six onboarding browser tests passed together with installed Chrome
against a fresh throwaway instance after the Apex fixes. They include
numbered duplicate names, first-time Copilot without a connected
account, and Grok’s saved model. They verify early persistence in both
entry points, closing and reopening a name-only draft, same-record
adapter selection, sandbox sign-in, login reload, and failed-connection
gating. Provider responses are simulated; Core persistence is real.
- Regression coverage includes incomplete Copilot creation versus final
validation, Kimi settings through redacted responses, stale local
drafts, company switches during creation, saved environments, and
provider changes with personal/shared old bindings.
- Apex found five name-only setup gaps: numbered names, early Copilot
account validation, the Grok model default, same-name draft ownership,
and provisional runner restoration. The fixes preserve server names,
defer implicit account binding until hire, set Grok’s model, avoid
name-based reuse of unfinished records, and restore Auto for pending
adapter choices. UI, database, and browser regressions cover these
cases.
- Standard Greptile found a Grok provider mismatch in the new adapter
save. The save now uses ACPX with the Grok profile. Both the 129-test
dialog/setup run and a real-API browser regression passed after the fix.
The first browser attempt stopped on an ambiguous empty-roster button
selector; it now scopes the click to the toolbar.
- Moving the save point exposed a saved-model reset and timing
assumptions in the same-name and portal fixtures. The source choice now
preserves the restored model, and tests cover same-name recovery without
a second hire.
- Initial CI exposed direct-create compatibility, concurrent setup
effects, fixture assumptions, and a missing-fields assumption in the
provider-validation guard. These were fixed and verified locally. Review
findings were addressed with authorization, recovery,
credential-persistence, and restored-configuration regressions.
- The final desktop capture passed after host resource cleanup. A repeat
first failed before Core started because macOS had exhausted
shared-memory IDs. Standalone PostgreSQL initialization reproduced the
same error. Releasing only unattached control segments from this task’s
stopped test databases allowed the unchanged revision to pass. No Core
or system setting changed.
- Local database tests required port access outside the sandbox. Missing
declared development dependencies were supplied outside the branch. No
live provider sign-in was tested locally.
- [Full monorepo
CI](https://github.com/paperclipai/paperclip/actions/runs/39522434236)
is green on `82b82fec189a172a885d4dd565ae2f4a18c73efc`. Standard
Greptile scored 5/5 with zero new comments (check `118969424903`). The
subsequent Apex review also scored 5/5 with zero new comments (check
`118972866331`) on that same commit. All review threads are resolved.
- The separate Superagent Security scan returned neutral/inconclusive
because its pinned merge-base checkout failed or timed out. GitHub
returned 404 for check and suite rerun requests. This is not a
successful security scan; the CI workflow and other security checks
passed.

## Risks

- Users can leave inactive setup records behind, including after name
submission alone. These records cannot run until hire completes.
- Setup completion touches hiring and approval boundaries. Tests cover
retries, company isolation, creator permissions, and approval snapshots.
- An uncertain credential-save result retains its new secret reference
for retry. A confirmed client rejection removes only credentials staged
by that attempt.
- No schema migration or VM provisioning change is required.
Agent-computer integration is out of scope.

## Model Used

OpenAI Codex, based on GPT-6, with repository search, code editing, and
tool execution. The exact deployment ID and context window are not
exposed in this session.

## 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] Apex Greptile is also 5/5 on the same commit as standard review
and green CI
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-20 18:08:57 -07:00

563 lines
34 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 agents = vi.hoisted(() => ({ create: vi.fn(), update: vi.fn(), get: vi.fn(), list: vi.fn() }));
vi.mock("@/api/agents", () => ({ agentsApi: agents }));
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();
const rows = new Map();
Object.values(agents).forEach(mock => mock.mockReset());
agents.list.mockResolvedValue([]);
agents.create.mockImplementation(async (_company, input) => {
const record = { ...input, id: input.setupId, pauseReason: "setup" };
rows.set(record.id, record);
return record;
});
agents.get.mockImplementation(async id => rows.get(id));
agents.update.mockImplementation(async (id, input) => {
const record = { ...rows.get(id), ...input, id };
rows.set(id, record);
return record;
});
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(value = "Ada & Co") {
const input = document.querySelector("input")!;
await act(async () => {
Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)!.set!.call(input, value);
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("saves identity on name submission before an adapter is selected", async () => {
expect(agents.create).not.toHaveBeenCalled();
await name();
expect(agents.create).toHaveBeenCalledTimes(1);
const input = agents.create.mock.calls[0][1];
expect(input).toMatchObject({ name: "Ada & Co", adapterType: "process", metadata: { setupAdapterPending: true }, runtimeConfig: { heartbeat: { enabled: false } } });
expect(document.body.textContent).toContain("Choose an adapter");
expect(document.querySelector('input[name="new-agent-adapter"]:checked')).toBeNull();
expect(state.navigate).not.toHaveBeenCalled();
await act(async () => document.querySelector<HTMLInputElement>('input[value="codex_local"]')!.click());
await click("Configure agent");
expect(agents.create).toHaveBeenCalledTimes(1);
expect(agents.update).toHaveBeenCalledWith(input.setupId, expect.objectContaining({ adapterType: "codex_local", metadata: { setupAdapterPending: false } }), "company-1");
const query = new URL(state.navigate.mock.calls[0][0], "http://local").searchParams;
expect(query.get("setupAgentId")).toBe(input.setupId);
expect(query.get("runner")).toBe("auto");
});
it("retries a failed name save with the same identity", async () => {
agents.create.mockRejectedValueOnce(new Error("Creation unavailable"));
await name();
expect(document.body.textContent).toContain("Creation unavailable");
expect(document.body.textContent).not.toContain("Choose an adapter");
await click("Choose adapter");
expect(agents.create).toHaveBeenCalledTimes(2);
expect(agents.create.mock.calls[1][1].setupId).toBe(agents.create.mock.calls[0][1].setupId);
expect(document.body.textContent).toContain("Choose an adapter");
});
it("keeps the draft on adapter save failure and updates it on retry", async () => {
await name();
await act(async () => document.querySelector<HTMLInputElement>('input[value="codex_local"]')!.click());
agents.update.mockRejectedValueOnce(new Error("Save unavailable"));
await click("Configure agent");
expect(document.body.textContent).toContain("Save unavailable");
expect(state.navigate).not.toHaveBeenCalled();
await click("Configure agent");
expect(agents.update.mock.calls[1][0]).toBe(agents.create.mock.calls[0][1].setupId);
expect(state.navigate).toHaveBeenCalledTimes(1);
});
it("keeps the same record after returning to the name step", async () => {
await name();
await click("Back");
await name();
expect(agents.create).toHaveBeenCalledTimes(1);
});
it("ignores repeated name submission while the record is being saved", async () => {
let finish!: (value: object) => void;
agents.create.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
await name();
const input = agents.create.mock.calls[0][1];
await act(async () => {
const form = document.querySelector("form")!;
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
expect(agents.create).toHaveBeenCalledTimes(1);
await act(async () => finish({ ...input, id: input.setupId }));
expect(document.body.textContent).toContain("Choose an adapter");
});
it("does not navigate after closing while an adapter save is pending", async () => {
await name();
await act(async () => document.querySelector<HTMLInputElement>('input[value="codex_local"]')!.click());
let finish!: (value: object) => void;
agents.update.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
await click("Configure agent");
await act(async () => root.render(null));
await act(async () => finish({ id: agents.create.mock.calls[0][1].setupId, name: "Ada & Co" }));
expect(state.navigate).not.toHaveBeenCalled();
});
it("can save the name when the process adapter is disabled", async () => {
await act(async () => cache.setQueryData(queryKeys.adapters.all, [...state.adapters, { type: "process", loaded: true, disabled: true }]));
await name();
expect(agents.create.mock.calls[0][1]).toMatchObject({ adapterType: "codex_local", metadata: { setupAdapterPending: true } });
expect(document.querySelector('input[name="new-agent-adapter"]:checked')).toBeNull();
});
it("keeps the server-assigned name when a duplicate is numbered", async () => {
agents.create.mockImplementationOnce(async (_company, input) => ({ ...input, id: input.setupId, name: "Ada & Co 2" }));
await name();
expect(document.body.textContent).toContain("How should Ada & Co 2 work?");
expect(agents.update).not.toHaveBeenCalled();
await act(async () => document.querySelector<HTMLInputElement>('input[value="codex_local"]')!.click());
await click("Configure agent");
expect(agents.update.mock.calls[0][1].name).toBe("Ada & Co 2");
const query = new URL(state.navigate.mock.calls[0][0], "http://local").searchParams;
expect(query.get("name")).toBe("Ada & Co 2");
});
it("renames a numbered draft only after the user edits its name", async () => {
agents.create.mockImplementationOnce(async (_company, input) => ({ ...input, id: input.setupId, name: "Ada & Co 2" }));
await name();
expect(agents.update).not.toHaveBeenCalled();
await click("Back");
await name("Grace");
expect(agents.create).toHaveBeenCalledTimes(1);
expect(agents.update).toHaveBeenCalledWith(agents.create.mock.calls[0][1].setupId, { name: "Grace" }, "company-1");
expect(document.body.textContent).toContain("How should Grace work?");
});
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([true, false, undefined])("opens saved-token Copilot setup without an experimental override (%s)", async enableNativeRunner => {
await act(async () => cache.setQueryData(queryKeys.instance.experimentalSettings, { enableNativeRunner }));
await name();
await act(async () => document.querySelector<HTMLInputElement>('input[value="copilot_runtime"]')!.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("copilot");
});
it("hides Copilot when its runtime adapter is disabled", async () => {
await act(async () => cache.setQueryData(queryKeys.adapters.all, state.adapters.map((entry: any) => entry.type === "paperclip_runner" ? { ...entry, disabled: true } : entry)));
await name();
expect(document.querySelector('input[value="copilot_runtime"]')).toBeNull();
});
it.each(["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);
expect(agents.update).toHaveBeenCalledWith(agents.create.mock.calls[0][1].setupId, expect.objectContaining({
adapterType: "paperclip_runner", adapterConfig: provider === "grok" ? { provider: "acpx", acpxAgent: "grok", model: "grok-4.7" } : { provider: "opencode" },
}), "company-1");
});
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();
});