mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 21:49:18 +01:00
## Thinking Path > - Paperclip manages agents and controls when their work starts. > - Onboarding saves an unfinished agent before account connection. > - The setup hold keeps that record paused until Connect completes. > - The published canary smoke still required an empty agent list at that point. > - That old assertion rejected the documented setup state and blocked release work. > - These tests now check the setup hold and the absence of early work. > - The nightly smoke also checks that Connect completes the same record. ## Linked Issues or Issue Description **What happened?** Published canary onboarding failed at the empty-agent-list assertion after reaching Connect a model. The API returned the expected paused setup record. The same failure affected recent canaries and prevented their promotion to a nightly. **Expected behavior** Require exactly one named setup record before Connect, paused with the `setup` hold and heartbeat disabled. Require no tasks or runs. After successful Connect, complete that same agent record. **Steps to reproduce** Run the published canary onboarding smoke with `PAPERCLIPAI_VERSION=2026.1028.0-canary.15`. Enter the organization and agent names, then open Connect a model. The old assertion fails on the held setup record. **Paperclip version or commit** The published artifact is `2026.1028.0-canary.15` at `743698e700638cddc4d3fc4c0ad89e6c132fab40`. The test branch starts at `65fa1cd617b43a45c9d22e10480f4fb6e52dbd21`. **Deployment mode** The canary check installs the exact public npm package in a fresh local-trusted instance. The authenticated nightly smoke uses the existing Docker harness and its provider mock. Refs #17976 and #12667. The existing agent setup contract is in `doc/SPEC-implementation.md` section 10.3. ## What Changed - Replace the stale empty-agent assertion with a bounded check for one paused setup record, disabled heartbeats and no tasks or runs. - Check the same setup state in the authenticated nightly smoke. Require Connect to complete that record without creating a second agent. - Update the onboarding verification recipe with the scope of both release checks. ## Verification - Red: the unchanged smoke failed locally against the exact published `.15` artifact at the empty-agent assertion. This matches the [release failure](https://github.com/paperclipai/paperclip/actions/runs/40761030399/job/123112508486). - Green: the revised smoke passed against the same `.15` artifact, with a real browser and fresh data. No model credential was required. - Full `pnpm build` and `node scripts/check-maps.mjs` pass. - Full recursive `pnpm -r typecheck` passes. - Full local `pnpm test:run` hit embedded PostgreSQL initialization failures. A standalone probe of the same bundled `initdb` reproduced `could not create shared memory segment: No space left on device`; this Mac had exhausted its shared-memory IDs. The local suite was stopped after those failures. No full local-suite pass is claimed. - The additional old-nightly artifact probe stopped before the browser journey with the same PostgreSQL bootstrap failure. Its failure is retained separately from the canary red/green result. - All 54 current-head checks pass; two optional checks are skipped. [CI run](https://github.com/paperclipai/paperclip/actions/runs/40779358071). - [Apex review is 5/5](https://github.com/paperclipai/paperclip/pull/18102#issuecomment-6482273653) for `40c63db4e9bf66e915843a9f325763d449aee049`, with no unresolved review threads. - The first [authenticated Linux canary smoke](https://github.com/paperclipai/paperclip/actions/runs/40779405110) stopped before the browser because the canary tag moved to `.16` before its matching server dependency was published. That dependency is now available and the failed job has been retried. Both the canary and [nightly smoke](https://github.com/paperclipai/paperclip/actions/runs/40780591068) passed background-service installation. Their authenticated Docker browser jobs are still queued for GitHub-hosted Ubuntu runners. Full authenticated browser acceptance remains unverified pending runner allocation. ## Risks These are test and verification-document changes. Unexpected agent states, extra records, early tasks and early execution still fail the checks. The authenticated smoke uses the existing mocked provider acceptance; it does not qualify real provider authentication. ## Model Used OpenAI GPT-6 through Codex, with reasoning, tool use and code execution. The session does not expose a more specific model ID or the context-window size. ## 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 — targeted canary smoke; full local-suite limitation is recorded above - [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>
304 lines
13 KiB
TypeScript
304 lines
13 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
const ADMIN_EMAIL =
|
|
process.env.PAPERCLIP_RELEASE_SMOKE_EMAIL ??
|
|
process.env.SMOKE_ADMIN_EMAIL ??
|
|
"smoke-admin@paperclip.local";
|
|
const ADMIN_PASSWORD =
|
|
process.env.PAPERCLIP_RELEASE_SMOKE_PASSWORD ??
|
|
process.env.SMOKE_ADMIN_PASSWORD ??
|
|
"paperclip-smoke-password";
|
|
|
|
// A hire needs a live-verified credential since #13344 — the subscription
|
|
// path now dead-ends on a `claude auth login` no CI machine can finish — so
|
|
// the wizard is driven through "Use API key instead". The server verifies the
|
|
// key against api.anthropic.com, which the docker-onboard-smoke harness
|
|
// serves from its own mock inside the container's network, so the placeholder
|
|
// below passes without any real credential in CI.
|
|
//
|
|
// The placeholder is offered only to a loopback target — where the mocked
|
|
// harness lives. Any other target reaches the real provider, which would
|
|
// reject the placeholder late inside the wizard, so those runs must set
|
|
// PAPERCLIP_RELEASE_SMOKE_ANTHROPIC_API_KEY and fail up front without it.
|
|
// A real key entered here also lands in Playwright's failure traces and DOM
|
|
// snapshots (the field is masked on screen, not in the DOM) — those artifacts
|
|
// stay on the machine running the suite, and CI never uses a real key.
|
|
const BASE_URL =
|
|
process.env.PAPERCLIP_RELEASE_SMOKE_BASE_URL ?? "http://127.0.0.1:3232";
|
|
const TARGET_IS_LOOPBACK = /^https?:\/\/(localhost|127\.0\.0\.1)([:/]|$)/i.test(
|
|
BASE_URL
|
|
);
|
|
const ANTHROPIC_API_KEY =
|
|
process.env.PAPERCLIP_RELEASE_SMOKE_ANTHROPIC_API_KEY ??
|
|
(TARGET_IS_LOOPBACK ? "sk-ant-release-smoke-placeholder" : "");
|
|
|
|
const COMPANY_NAME = `Release-Smoke-${Date.now()}`;
|
|
const AGENT_NAME = "Release Smoke Lead";
|
|
// The arc asks for a name, not a role, so every onboarding hire is filed under
|
|
// the neutral role (DEFAULT_AGENT_ROLE in ui/src/lib/onboarding-agent-role.ts).
|
|
const AGENT_ROLE = "general";
|
|
// Seeded by the wizard's launch step (DEFAULT_TASK_TITLE in
|
|
// ui/src/components/OnboardingWizard.tsx).
|
|
const FIRST_TASK_TITLE = "Paperclip onboarding";
|
|
|
|
async function signIn(page: Page) {
|
|
await page.goto("/");
|
|
await expect(page).toHaveURL(/\/auth/);
|
|
|
|
await page.locator('input[type="email"]').fill(ADMIN_EMAIL);
|
|
await page.locator('input[type="password"]').fill(ADMIN_PASSWORD);
|
|
await page.getByRole("button", { name: "Sign In" }).click();
|
|
|
|
await expect(page).not.toHaveURL(/\/auth/, { timeout: 20_000 });
|
|
}
|
|
|
|
async function getJson<T>(page: Page, url: string): Promise<T> {
|
|
const response = await page.request.get(url);
|
|
expect(response.ok()).toBe(true);
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
// ONBOARDING_STORAGE_KEY in ui/src/components/OnboardingWizard.tsx.
|
|
const ONBOARDING_DRAFT_STORAGE_KEY = "paperclip-onboarding-state";
|
|
|
|
/**
|
|
* Open the wizard on its first step and hand back the organization-name field.
|
|
*
|
|
* `/onboarding` resolves to `{ initialStep: 1 }` on a self-hosted instance
|
|
* (`resolveRouteOnboardingOptions`) and the route keeps the wizard open, so
|
|
* this lands on "name your organization" whether or not the instance already
|
|
* holds a company. Navigating explicitly is what keeps the spec re-runnable:
|
|
* the release-smoke config retries once in CI, and by the second attempt the
|
|
* instance is no longer company-less, so sign-in lands on a dashboard instead.
|
|
*
|
|
* The saved draft is dropped first. Sign-in on an instance that already holds
|
|
* an agentless company redirects into *that* company's onboarding, which
|
|
* persists its id into the draft; the restored draft then makes step 1 skip
|
|
* creating a company and hire into the old one instead. That is an artifact of
|
|
* re-running against a re-used instance, not behaviour this spec is asserting,
|
|
* and a fresh release-smoke container never has it.
|
|
*
|
|
* The field is located by role. Step 1 has no id and its `<label>` is not
|
|
* associated with the input, so the alternative is its placeholder copy — the
|
|
* exact coupling that let this spec drift. The wizard's first screen has
|
|
* exactly one text box, and a second one appearing there would fail Playwright's
|
|
* strict mode loudly rather than silently matching the wrong control.
|
|
*/
|
|
async function openOnboarding(page: Page) {
|
|
await page.evaluate((key) => {
|
|
window.localStorage.removeItem(key);
|
|
}, ONBOARDING_DRAFT_STORAGE_KEY);
|
|
await page.goto("/onboarding");
|
|
|
|
const orgNameField = page.getByRole("textbox");
|
|
await expect(orgNameField).toBeVisible({ timeout: 20_000 });
|
|
return orgNameField;
|
|
}
|
|
|
|
test.describe("Docker authenticated onboarding smoke", () => {
|
|
test("logs in, completes onboarding, and hires the lead agent", async ({
|
|
page,
|
|
}) => {
|
|
// Only bites off-loopback: fail on arrival rather than submitting the
|
|
// placeholder to the real provider and timing out deep in the wizard.
|
|
expect(
|
|
ANTHROPIC_API_KEY,
|
|
"This target reaches the real provider — set PAPERCLIP_RELEASE_SMOKE_ANTHROPIC_API_KEY to a key it accepts"
|
|
).toBeTruthy();
|
|
|
|
await signIn(page);
|
|
|
|
const baseUrl = new URL(page.url()).origin;
|
|
|
|
// A board with no company routes sign-in straight into onboarding rather
|
|
// than a dashboard — the first-run experience this suite exists to guard.
|
|
// Asserted only when the instance really is company-less, because a retry
|
|
// (or a re-used smoke container) runs against one that is not.
|
|
const companiesBeforeOnboarding = await getJson<Array<{ id: string }>>(
|
|
page,
|
|
`${baseUrl}/api/companies`
|
|
);
|
|
if (companiesBeforeOnboarding.length === 0) {
|
|
await expect(page).toHaveURL(/\/onboarding$/, { timeout: 20_000 });
|
|
}
|
|
|
|
// Step 1: name the organization. "Continue" creates the company itself and
|
|
// routes straight to the agent step — onboarding no longer asks for the
|
|
// mission (it is collected later, in the app), so step 2 is skipped.
|
|
const orgNameField = await openOnboarding(page);
|
|
await orgNameField.fill(COMPANY_NAME);
|
|
await page.getByRole("button", { name: "Continue", exact: true }).click();
|
|
|
|
// Step 3: name the team lead. The name is the step's only question and it
|
|
// gates the CTA; the role picker is gone, so the hire is filed as `general`.
|
|
const agentNameField = page.locator("#onboarding-agent-name");
|
|
await expect(agentNameField).toBeVisible({ timeout: 20_000 });
|
|
await agentNameField.fill(AGENT_NAME);
|
|
|
|
const nextButton = page.getByRole("button", { name: "Next", exact: true });
|
|
await expect(nextButton).toBeEnabled({ timeout: 10_000 });
|
|
await nextButton.click();
|
|
|
|
// Step 4: answer the model-source question, then connect (hire) the lead.
|
|
// The step now opens as a row of source tiles and the footer button has
|
|
// nothing to do until one is picked (#12796/#12801 rebuilt the step around
|
|
// that question); picking Claude collapses the row.
|
|
//
|
|
// Since #13344, a hire requires a verified credential: the subscription
|
|
// path opens an isolated `claude auth login` attempt that only a human at
|
|
// a terminal on the server can finish, and Connect refuses to proceed
|
|
// until it has. The clean-machine path this suite guards is therefore the
|
|
// API key: switch modes, pick Claude, paste a key, Connect — the server
|
|
// verifies it against the provider endpoint (the harness's mock, here)
|
|
// and then hires. A genuine failure here means the published artifact
|
|
// cannot hire on a clean machine even when the provider accepts the
|
|
// credential. Allow generous time for the validation + hire +
|
|
// auto-approval.
|
|
//
|
|
// The mode switch comes before the tile: picking a tile starts the step's
|
|
// collapse sequence and the "Use API key instead" link only offers itself
|
|
// while the row is still a question (`connectLinkVisible` in
|
|
// OnboardingWizard.tsx).
|
|
await expect(
|
|
page.getByRole("heading", { name: "Connect a model" })
|
|
).toBeVisible({ timeout: 20_000 });
|
|
|
|
const companies = await getJson<Array<{ id: string; name: string }>>(
|
|
page,
|
|
`${baseUrl}/api/companies`
|
|
);
|
|
const company = companies.find((entry) => entry.name === COMPANY_NAME);
|
|
expect(company).toBeTruthy();
|
|
const agentsUrl = `${baseUrl}/api/companies/${company!.id}/agents`;
|
|
await expect.poll(
|
|
() => getJson<Array<{ id: string }>>(page, agentsUrl),
|
|
{ message: "Connect a model must retain one paused setup record" },
|
|
).toEqual([
|
|
expect.objectContaining({
|
|
name: AGENT_NAME,
|
|
status: "paused",
|
|
pauseReason: "setup",
|
|
metadata: expect.objectContaining({ setupAdapterPending: true }),
|
|
runtimeConfig: expect.objectContaining({
|
|
heartbeat: expect.objectContaining({ enabled: false }),
|
|
}),
|
|
}),
|
|
]);
|
|
const [setupAgent] = await getJson<Array<{ id: string }>>(page, agentsUrl);
|
|
for (const resource of ["issues", "heartbeat-runs"]) {
|
|
expect(
|
|
await getJson<Array<{ id: string }>>(
|
|
page,
|
|
`${baseUrl}/api/companies/${company!.id}/${resource}`,
|
|
),
|
|
).toEqual([]);
|
|
}
|
|
|
|
await page
|
|
.getByRole("button", { name: "Use API key instead", exact: true })
|
|
.click();
|
|
|
|
// "Claude API" once the mode has swapped the tile's tag — matched on the
|
|
// stable half.
|
|
const claudeSourceTile = page
|
|
.getByRole("radiogroup", { name: "Model source" })
|
|
.getByRole("radio", { name: /Claude/ });
|
|
await expect(claudeSourceTile).toBeVisible({ timeout: 10_000 });
|
|
await claudeSourceTile.click();
|
|
|
|
// OnboardingCardField carries the accessible name via aria-label.
|
|
const apiKeyField = page.getByLabel("API key");
|
|
await expect(apiKeyField).toBeVisible({ timeout: 10_000 });
|
|
await apiKeyField.fill(ANTHROPIC_API_KEY);
|
|
|
|
const connectButton = page.getByRole("button", {
|
|
name: "Connect",
|
|
exact: true,
|
|
});
|
|
await expect(connectButton).toBeVisible({ timeout: 10_000 });
|
|
await expect(connectButton).toBeEnabled({ timeout: 30_000 });
|
|
await connectButton.click();
|
|
|
|
// Step 5: review, then launch. "Get started" provisions the onboarding
|
|
// project and first task and, only on success, drops the user into the
|
|
// seeded first task's thread (not the dashboard).
|
|
const getStartedButton = page.getByRole("button", {
|
|
name: "Get started",
|
|
exact: true,
|
|
});
|
|
await expect(getStartedButton).toBeVisible({ timeout: 60_000 });
|
|
await expect(getStartedButton).toBeEnabled({ timeout: 10_000 });
|
|
await getStartedButton.click();
|
|
await expect(page).toHaveURL(/\/issues\//, { timeout: 30_000 });
|
|
|
|
const agents = await getJson<
|
|
Array<{ id: string; name: string; role: string; adapterType: string; pauseReason: string | null }>
|
|
>(page, `${baseUrl}/api/companies/${company!.id}/agents`);
|
|
expect(agents).toHaveLength(1);
|
|
const leadAgent = agents.find((entry) => entry.name === AGENT_NAME);
|
|
expect(leadAgent).toBeTruthy();
|
|
expect(leadAgent!.id).toBe(setupAgent!.id);
|
|
expect(leadAgent!.pauseReason).not.toBe("setup");
|
|
expect(leadAgent!.role).toBe(AGENT_ROLE);
|
|
expect(leadAgent!.adapterType).not.toBe("process");
|
|
|
|
// Onboarding deliberately writes no goal: the mission is collected later in
|
|
// the app, so a fresh company must come out of the wizard with an empty
|
|
// goal list rather than an unchosen one.
|
|
const goals = await getJson<Array<{ id: string }>>(
|
|
page,
|
|
`${baseUrl}/api/companies/${company!.id}/goals`
|
|
);
|
|
expect(goals).toEqual([]);
|
|
|
|
const issues = await getJson<
|
|
Array<{
|
|
id: string;
|
|
identifier: string | null;
|
|
title: string;
|
|
assigneeAgentId: string | null;
|
|
}>
|
|
>(page, `${baseUrl}/api/companies/${company!.id}/issues`);
|
|
const seededIssue = issues.find((entry) => entry.title === FIRST_TASK_TITLE);
|
|
expect(seededIssue).toBeTruthy();
|
|
expect(seededIssue!.assigneeAgentId).toBe(leadAgent!.id);
|
|
|
|
// The launch must have landed on the seeded task itself, not merely on
|
|
// some issue route.
|
|
const seededRef = seededIssue!.identifier ?? seededIssue!.id;
|
|
expect(new URL(page.url()).pathname.endsWith(`/issues/${seededRef}`)).toBe(
|
|
true
|
|
);
|
|
|
|
// #13068 rebuilt the seeded first task as a chat with the lead: launch
|
|
// posts a deterministic, server-owned greeting plus an opening question
|
|
// card, and deliberately does not wake the assignee — "no run until the
|
|
// user answers". Assert the chat actually opened (the greeting and the
|
|
// card are seeded without an LLM, so their absence means the launch
|
|
// half-finished) …
|
|
await expect(
|
|
page.getByText("Welcome to Paperclip!").first()
|
|
).toBeVisible({ timeout: 20_000 });
|
|
// The timeline also summarizes the unanswered question. Check the open
|
|
// question card so a summary alone cannot satisfy this assertion.
|
|
await expect(
|
|
page
|
|
.getByTestId("task-chat-interaction")
|
|
.getByText("What would you like to do?", { exact: true })
|
|
).toBeVisible();
|
|
|
|
// … and that the no-run contract holds. This spec used to poll for an
|
|
// assignment-triggered heartbeat run here; a run appearing before the
|
|
// user's first answer is now the regression, not the success. Wake
|
|
// dispatch is asynchronous, so watch the endpoint over a bounded window
|
|
// rather than sampling it once — a launch-time wake that slips through
|
|
// lands well within this window.
|
|
const runsUrl = `${baseUrl}/api/companies/${company!.id}/heartbeat-runs?agentId=${leadAgent!.id}`;
|
|
const noRunDeadline = Date.now() + 15_000;
|
|
while (Date.now() < noRunDeadline) {
|
|
expect(await getJson<Array<{ id: string }>>(page, runsUrl)).toEqual([]);
|
|
await page.waitForTimeout(1_000);
|
|
}
|
|
});
|
|
});
|