From 48d4ab54d6dd8bb507ca99df9fdb16c38985f5cb Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 12 Oct 2026 20:34:26 -0500 Subject: [PATCH] chore: reconcile contribution rules and onboarding fixtures Extract the guided setup test fixture without dropping cases. Supply current settings/discovery contracts and Opus 5.5 in the isolated Storybook boundary. Co-Authored-By: Paperclip --- .github/workflows/docker-runner-check.yml | 2 - doc/plans/2026-10-12-claude-runner-default.md | 48 ++++ packages/adapter-utils/src/types.ts | 1 - .../claude-local/src/ui/build-config.ts | 2 - packages/shared/src/agent-runner.ts | 2 - ...issue-update-comment-wakeup-routes.test.ts | 2 - ...teams-catalog-install-no-overrides.test.ts | 2 - server/src/services/ai-connection-router.ts | 1 - server/src/services/anthropic-pricing.ts | 3 - .../native-runtime/provider-profile.ts | 1 - tests/runner-e2e/installed-cli.test.ts | 2 - ui/src/components/AgentRunnerSelect.tsx | 1 - .../components/OnboardingWizard.step.test.tsx | 1 - ui/src/components/OnboardingWizard.test.tsx | 1 - ui/src/components/OnboardingWizard.tsx | 3 - ui/src/pages/CompanyImport.tsx | 2 - ui/src/pages/NewAgent.test-support.tsx | 203 +++++++++++++++++ ui/src/pages/NewAgent.test.tsx | 212 +----------------- ui/storybook/.storybook/preview.tsx | 20 +- 19 files changed, 277 insertions(+), 232 deletions(-) create mode 100644 ui/src/pages/NewAgent.test-support.tsx diff --git a/.github/workflows/docker-runner-check.yml b/.github/workflows/docker-runner-check.yml index 7bcc6fe5b7b..ae00c65df35 100644 --- a/.github/workflows/docker-runner-check.yml +++ b/.github/workflows/docker-runner-check.yml @@ -177,8 +177,6 @@ jobs: run: | set -uo pipefail pnpm install --frozen-lockfile --ignore-scripts - # Historical evaluation fixtures read these exact public trees. - # Fetch only their objects; keep the selected checkout unchanged. git fetch --no-tags --depth=1 origin 2a8a99e4a5f69aa803b3f10b982f583e75a87042 16ef1a5744b23bb043a8b286e6288921da1de98f status=0 for check in 'pnpm -r typecheck' 'pnpm test:run' 'pnpm check:token-gates' 'pnpm test:e2e:runner:typecheck' 'pnpm test:e2e:runner:unit' 'pnpm build' 'if [ -f scripts/verify-grok-npm-install.mjs ]; then node scripts/verify-grok-npm-install.mjs; fi'; do diff --git a/doc/plans/2026-10-12-claude-runner-default.md b/doc/plans/2026-10-12-claude-runner-default.md index c0cb8ec9e8e..90f2e4a330a 100644 --- a/doc/plans/2026-10-12-claude-runner-default.md +++ b/doc/plans/2026-10-12-claude-runner-default.md @@ -696,3 +696,51 @@ Report attachment: `e6206748-a785-4c02-9bbf-f4db8a3b22f9`, work product Local receipts: `/private/tmp/claude-responsive-preview-evidence.json`, `/private/tmp/claude-responsive-evidence-upload.json`, and `/private/tmp/claude-responsive-evidence-independent-check.json`. + +## Canonical composition and current contribution rules — 2026-10-12 + +The source owner committed the stronger shared startup repair as +`ce23716503a2c9158b5a7ab74ab61571ff4ee7a3`. Local additive merges preserve +that parent and this chat's Claude effort/resume changes. Eight controls that do +not need native executables and runner noemit pass in the combined protocol. +The ninth selected readiness-cycle case timed out while waiting for readiness; +read-only resolution confirms both the default runner artifact and fake provider +fixture are absent, with no private runner override. That native case remains a +hosted CI gate, not a pass. No local native build was performed. Logs: +`/private/tmp/claude-canonical-startup-composition-controls.log`, +`/private/tmp/claude-canonical-startup-no-process-controls.log`, and +`/private/tmp/claude-canonical-startup-composition-types.log`. + +The source owner then reconciled current master in `9f59ff36cf`, adding the +Storybook catalog and contribution rules against new code comments and code files +crossing 1000 lines. The guided-creation fixture is extracted to +`ui/src/pages/NewAgent.test-support.tsx`; all 65 cases remain and pass, with UI +noemit and token gates passing. Fixture component rendering stays inside its +mock-owning module, including the saved-confirmation reload case. The active test +file no longer crosses the threshold. Our added explanatory comments are removed; +the source owner handles the common transport's comment cleanup. Semantic code +and internal deadlines stay unchanged. + +Removing this chat's added sandbox comments changes its raw source attestation to +`144d62b3415cfedf272b5b052e14af1e41fe50a644af84247fc9c20c7ef7edde`. +Immutable Pi profiles 23 and 24 remain retained. New Pi profile 25 binds only that +source revision plus its profile version, digest +`sha256:dd67387487dadb08da76534c057dc0aa059f7698d1779cbd5fc452813828167a`. +SDK/executable closures, platforms, model/effort contract and other providers' +qualification boundaries do not change. Copilot remains pending profile 18. +Generated TS/Rust descriptors, Rust current/prior fixtures, Docker context and +current documentation are synchronized. All 119 profile/factory/admission/effort +controls pass with one existing historical skip, including corrupt-byte rejection +before spawn/bridge; runner noemit and generation checks pass. The first restricted +attempt stopped at loopback lease admission and is retained, not counted as byte +validation. Native/Rust execution and final package qualification remain cloud gates. + +The maintain-storybook source review found concrete fixture incompatibilities: +the general-settings endpoint and scoped runner-discovery defaults were absent. +The shared preview now returns that settings contract and explicitly simulated +Legacy discovery for Claude/Codex, exposes the loaded native adapter, and includes +Opus 5.5 while retaining explicit older-model examples. These fixture defaults are +not rollout qualification. Catalog fingerprints/classification and affected story +rendering must be reconciled after the final parent is frozen; source-only review +is not an executed Storybook or provider pass. Published draft heads remain fixed +while the source owner finishes the canonical contribution/catalog cleanup. diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index bad24a8ba1d..2818fd3292e 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -725,7 +725,6 @@ export interface CLIAdapterModule { export interface CreateConfigValues { runner?: "auto" | "paperclip" | "legacy"; - /** Transient form resolution; never an execution setting or API override. */ resolvedRunner?: "paperclip" | "legacy"; adapterType: string; codexPermissionMode?: "never" | "on-request" | "untrusted"; diff --git a/packages/adapters/claude-local/src/ui/build-config.ts b/packages/adapters/claude-local/src/ui/build-config.ts index 072efac710e..c3d452a8e92 100644 --- a/packages/adapters/claude-local/src/ui/build-config.ts +++ b/packages/adapters/claude-local/src/ui/build-config.ts @@ -45,8 +45,6 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record 0) ac.env = env; const nativeSelected = v.runner === "paperclip" || ((v.runner === undefined || v.runner === "auto") && v.resolvedRunner === "paperclip"); - // Only a resolved native form omits the Legacy default. Explicit limits - // still reach the server's compatibility check. if (!nativeSelected || v.maxTurnsPerRun !== 1000) ac.maxTurnsPerRun = v.maxTurnsPerRun; if (nativeSelected && v.adapterSchemaValues?.acpxPermissionMode !== undefined) { ac.acpxPermissionMode = v.adapterSchemaValues.acpxPermissionMode; diff --git a/packages/shared/src/agent-runner.ts b/packages/shared/src/agent-runner.ts index c49fba46896..5b494c59a1d 100644 --- a/packages/shared/src/agent-runner.ts +++ b/packages/shared/src/agent-runner.ts @@ -7,7 +7,6 @@ export interface AgentRunnerAvailability { defaultRunner: AgentRunner; } -/** Only qualified harnesses graduate their creation default. */ export function paperclipRunnerProfileForHarness(harness: string): { provider: "codex" } | { provider: "acpx"; acpxAgent: "claude" } | undefined { if (harness === "codex_local") return { provider: "codex" }; if (harness === "claude_local") return { provider: "acpx", acpxAgent: "claude" }; @@ -31,7 +30,6 @@ function supportsLinuxLibc(libc?: "glibc" | "musl" | "unknown", version?: string /** Creation defaults follow GNU/Linux and macOS artifacts; Alpine/musl is deferred. */ export function paperclipRunnerSupportsPlatform(harness: string, platform: string, architecture: string, libc?: "glibc" | "musl" | "unknown", glibcVersion?: string): boolean { - // Claude graduates on Apple Silicon first; other targets await package and live qualification. if (harness === "claude_local") return platform === "darwin" && architecture === "arm64"; return !!paperclipRunnerProfileForHarness(harness) // Omitted libc preserves platform-only callers; detected unknown Linux is not qualified. diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts index 5bf63139673..8c566598e82 100644 --- a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -276,8 +276,6 @@ describe("issue update comment wakeups", () => { mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); mockIssueService.getCurrentScheduledRetry.mockResolvedValue(null); mockIssueService.listReviewAttention.mockResolvedValue(new Map()); - // A fresh route graph is fixture preparation, not request execution. Keep - // per-case module resets while charging the cold transform to hookTimeout. routeFixture = await loadRouteFixture(); }); diff --git a/server/src/__tests__/teams-catalog-install-no-overrides.test.ts b/server/src/__tests__/teams-catalog-install-no-overrides.test.ts index 5d49e08d246..205cc105dc0 100644 --- a/server/src/__tests__/teams-catalog-install-no-overrides.test.ts +++ b/server/src/__tests__/teams-catalog-install-no-overrides.test.ts @@ -11,8 +11,6 @@ import { } from "./helpers/embedded-postgres.js"; import { teamsCatalogService } from "../services/teams-catalog.js"; -// Bind the real creation resolver to the release target under test so the -// catalog contract is stable across the machines running this fixture. vi.mock("../services/agent-runner-selection.js", async importOriginal => { const actual = await importOriginal(); return { ...actual, resolveNewAgentRunnerForCompany: async (_db: unknown, _companyId: string, input: Parameters[2]) => diff --git a/server/src/services/ai-connection-router.ts b/server/src/services/ai-connection-router.ts index 1e235ef391b..5bea58456d2 100644 --- a/server/src/services/ai-connection-router.ts +++ b/server/src/services/ai-connection-router.ts @@ -55,7 +55,6 @@ export function poolMemberRuntimeConfig(member: AiConnectionPoolMember, adapterT const efforts = provider === "openai" ? codexLocalReasoningEffortsForModel(model) : provider === "anthropic" ? claudeLocalReasoningEffortsForModel(model) : provider === "xai" ? grokLocalReasoningEffortsForModel(model) : []; - // Claude and Codex carry effort through native admission; other ACPX profiles keep their existing limit. const effortKey = adapterType === "paperclip_runner" ? profile.provider === "codex" || (profile.provider === "acpx" && profile.acpxAgent === "claude") ? "modelReasoningEffort" : null : provider === "anthropic" ? "effort" : provider === "xai" ? "reasoningEffort" : provider === "openai" ? "modelReasoningEffort" : "variant"; const requested = overrides.modelReasoningEffort ?? overrides.reasoningEffort ?? overrides.effort ?? overrides.variant; diff --git a/server/src/services/anthropic-pricing.ts b/server/src/services/anthropic-pricing.ts index 63348e36cf5..1823a0adbc2 100644 --- a/server/src/services/anthropic-pricing.ts +++ b/server/src/services/anthropic-pricing.ts @@ -1,9 +1,6 @@ import { centsToUsd, unitsToCents, usdToUnits } from "@paperclipai/shared"; import type { AdapterUsageCheckpoint } from "@paperclipai/adapter-utils"; -// Direct Anthropic API list prices, verified per model below: -// https://platform.claude.com/docs/en/about-claude/pricing -// Keep this bounded to exact qualified models; aliases and other billers stay unknown. const RATE_CARDS: Record = { "claude-sonnet-5": { rates: ["2", "0.2", "4", "10"], version: "anthropic-standard-2026-10-07" }, "claude-opus-5-5": { rates: ["4", "0.2", "8", "20"], version: "anthropic-standard-2026-10-12" }, diff --git a/server/src/services/native-runtime/provider-profile.ts b/server/src/services/native-runtime/provider-profile.ts index 750cb8802cf..f13a246508c 100644 --- a/server/src/services/native-runtime/provider-profile.ts +++ b/server/src/services/native-runtime/provider-profile.ts @@ -167,7 +167,6 @@ export function validatePaperclipRunnerDotConfig(config: Record return bindingId; } -/** A task may change a local Runner model and supported effort, but not provider identity. */ export function projectPaperclipRunnerTaskConfig( backend: "codex_app_server" | "opencode_server" | "acpx_runtime", agentConfig: unknown, diff --git a/tests/runner-e2e/installed-cli.test.ts b/tests/runner-e2e/installed-cli.test.ts index e902e66a67d..d14b65d8df0 100644 --- a/tests/runner-e2e/installed-cli.test.ts +++ b/tests/runner-e2e/installed-cli.test.ts @@ -10,8 +10,6 @@ import { buildPaperclipServerEnvironment, buildRunnerE2EProcessEnvironment } fro const cells = runnerMatrix.filter(e => e.profile.id === "runner-acpx-pi"); const roots: string[] = []; beforeEach(() => { - // pnpm's test shim can inject NODE_PATH. Installed-startup fixtures model - // the production launcher's clean environment, not that test shim. vi.stubEnv("NODE_PATH", undefined); vi.stubEnv("NODE_OPTIONS", undefined); }); diff --git a/ui/src/components/AgentRunnerSelect.tsx b/ui/src/components/AgentRunnerSelect.tsx index d1cf2db832a..f2f72e2d6ff 100644 --- a/ui/src/components/AgentRunnerSelect.tsx +++ b/ui/src/components/AgentRunnerSelect.tsx @@ -3,7 +3,6 @@ import { Field } from "./agent-config-primitives"; import { SelectPopover } from "./ui/select"; import { Button } from "./ui/button"; -/** The override is request-level. Automatic selection is resolved by the server. */ export function AgentRunnerSelect({ value, onChange, diff --git a/ui/src/components/OnboardingWizard.step.test.tsx b/ui/src/components/OnboardingWizard.step.test.tsx index 54b53bda774..a759b55f961 100644 --- a/ui/src/components/OnboardingWizard.step.test.tsx +++ b/ui/src/components/OnboardingWizard.step.test.tsx @@ -178,7 +178,6 @@ describe("OnboardingWizard — which step it lands on", () => { dialogState.onboardingOpen = false; dialogState.onboardingOptions = {}; dialogState.onboardingRouteDismissed = false; - // The scoped discovery contract must resolve before the hire seam runs. mockAdaptersApi.list.mockResolvedValue(["claude_local", "codex_local"].map(type => ({ type, loaded: true, disabled: false, defaultRunner: "legacy", supportedRunners: ["legacy"], }))); diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index b6112c59617..b7ab3dfc3a8 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -3180,7 +3180,6 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( const { root, queryClient } = await openStep4({ adapterType: "claude_local" }); try { await act(async () => { - // A warm value remains successful while its current fetch is pending. if (pending === "environments") queryClient.setQueryData(queryKeys.environments.list("company-new"), [LOCAL_ENVIRONMENT]); else if (pending === "settings") queryClient.setQueryData(queryKeys.instance.settings, { defaultEnvironmentId: null }); else queryClient.setQueryData(queryKeys.instance.experimentalSettings, { enableManagedSandboxOnly: false }); diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 10d9b7bcbf4..931787e0423 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -1060,9 +1060,6 @@ function OnboardingWizardInner({ }, effectiveOnboardingOpen && step === 4 && canUseLocalLogin && credentialMode !== "api" && Boolean(managedProvider) && !savedSubscription && !savedKeys.storedLogin.data && !managedBindingForStep(), ); - // A result from a previous selection must not hire or advance this wizard. - // Discovery starts after environment settings resolve. A later target or - // runner change invalidates the configuration an in-flight test was built from. useEffect(() => { autoConnectStartedRef.current = false; hiringAgentRef.current = null; diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index 57d1004bd55..e539e9b59af 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -1775,8 +1775,6 @@ export function CompanyImport() { applyChanges(original.adapterSchemaValues ?? {}, configVals.adapterSchemaValues ?? {}); applyChanges(baseline, edited); if (selectedType === "claude_local") { - // The native builder preserves source schema fields. Project common - // form edits separately so a removed binding cannot be restored there. const commonBuild = getUIAdapter("claude_local").buildAdapterConfig; const commonBefore = commonBuild(original); const commonAfter = commonBuild(configVals); diff --git a/ui/src/pages/NewAgent.test-support.tsx b/ui/src/pages/NewAgent.test-support.tsx new file mode 100644 index 00000000000..bbd53fd9493 --- /dev/null +++ b/ui/src/pages/NewAgent.test-support.tsx @@ -0,0 +1,203 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, expect, vi } from "vitest"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { queryKeys } from "@/lib/queryKeys"; +import { NewAgent } from "./NewAgent"; +import { ApiError } from "@/api/client"; + +const api = vi.hoisted(() => ({ + get: vi.fn(), + adapterModels: vi.fn(), + list: vi.fn(), + hire: vi.fn(), + testEnvironment: vi.fn(), + getAdapterAuthSignal: vi.fn(), + getClaudeOAuthTokenStatus: vi.fn(), +})); +const envApi = vi.hoisted(() => ({ list: vi.fn(), capabilities: vi.fn() })); +const settings = vi.hoisted(() => ({ + get: vi.fn(), + getExperimental: vi.fn(), + getGeneral: vi.fn(), +})); +const secrets = vi.hoisted(() => ({ + create: vi.fn(), + remove: vi.fn(), + list: vi.fn(), + listMyUserSecrets: vi.fn(), + createUserSecretDefinition: vi.fn(), + createMyUserSecret: vi.fn(), + rotateMyUserSecret: vi.fn(), + removeUserSecretDefinition: vi.fn(), +})); +const state = vi.hoisted(() => ({ + params: new URLSearchParams(), + adapters: [] as object[], + navigate: vi.fn(), + openNewIssue: vi.fn(), +})); +const managedApi = vi.hoisted(() => ({ + list: vi.fn(async () => ({ currentUserId: "user-1", canManageConnections: true, connections: [] })), + create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })), + setDefault: vi.fn(async () => ({})), +})); +vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: managedApi })); +vi.mock("@/api/agents", () => ({ agentsApi: api })); +vi.mock("@/api/environments", () => ({ environmentsApi: envApi })); +vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: settings })); +vi.mock("@/api/secrets", () => ({ secretsApi: secrets })); +const adapterApi = vi.hoisted(() => ({ list: vi.fn() })); +vi.mock("@/api/adapters", () => ({ adaptersApi: adapterApi })); +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompanyId: "company-1" }), +})); +vi.mock("../context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }), +})); +vi.mock("@/context/DialogContext", () => ({ + useDialogActions: () => ({ openNewIssue: state.openNewIssue }), +})); +vi.mock("@/lib/router", () => ({ + useNavigate: () => state.navigate, + useSearchParams: () => [state.params], +})); +vi.mock("@/components/AgentConfigForm", () => ({ + ModelDropdown: ({ + value, + onChange, + }: { + value: string; + onChange: (value: string) => void; + }) => ( + onChange(e.target.value)} + /> + ), + AdapterLoginPanel: ({ onConnected }: { onConnected: (id: string) => void }) => ( + + ), +})); +vi.mock("@/components/onboarding/PillGuy", () => ({ PillGuy: () => null })); +vi.mock("motion/react", () => ({ + AnimatePresence: ({ children }: any) => children, + MotionConfig: ({ children }: any) => children, + motion: { + span: ({ children }: any) => {children}, + div: ({ children, initial, animate, exit, transition, ...rest }: any) => ( +
{children}
+ ), + }, +})); +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +let root: Root; +let container: HTMLDivElement; +let cache: QueryClient; +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); +} +async function click(text: string) { + const button = [...container.querySelectorAll("button")].find( + (b) => b.textContent?.trim() === text, + ); + expect(button, `Missing button ${text}`).toBeTruthy(); + await act(async () => button!.click()); + await settle(); +} +async function fill(label: string, value: string) { + const input = container.querySelector( + `[aria-label="${label}"]`, + ) as HTMLInputElement; + expect(input).toBeTruthy(); + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} +async function render(adapter = "pi_local", runnerProvider = "codex") { + state.params = new URLSearchParams({ + name: "Atlas", + adapterType: adapter, + runnerProvider, + }); + await renderCurrentParams(); +} +async function renderCurrentParams() { + await act(async () => + root.render( + + + , + ), + ); + await settle(); +} +async function connect(provider: string) { + await click(provider + "Subscription"); + await click("Connect"); +} +const pass = { + adapterType: "pi_local", + status: "pass", + checks: [ + { code: "hello_probe_passed", level: "info", message: "Model replied" }, + ], + testedAt: "2026-09-07T00:00:00Z", +}; +beforeEach(() => { + vi.clearAllMocks(); + adapterApi.list.mockImplementation(async () => state.adapters); + managedApi.list.mockResolvedValue({ currentUserId: "user-1", canManageConnections: true, connections: [] }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + cache = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + state.adapters = [ + "claude_local", + "codex_local", + "opencode_local", + "pi_local", + "paperclip_runner", "cursor_cloud", "cursor", "gemini_local", "kimi_local", "grok_local", "hermes_local", "hermes_gateway", + ].map((type) => ({ type, loaded: true, disabled: false })); + api.adapterModels.mockResolvedValue([]); + api.list.mockResolvedValue([{ id: "ceo", role: "ceo", status: "idle" }]); + api.getAdapterAuthSignal.mockResolvedValue({ status: "present" }); + api.getClaudeOAuthTokenStatus.mockRejectedValue(new ApiError("Not found", 404, null)); + api.testEnvironment.mockResolvedValue(pass); + api.hire.mockImplementation(async (_company, input) => ({ + agent: { ...input, id: "new-agent", status: "idle", urlKey: "atlas" }, + })); + envApi.list.mockResolvedValue([ + { id: "local-1", name: "Local", driver: "local", config: {} }, + ]); + envApi.capabilities.mockResolvedValue({ sandboxProviders: {} }); + settings.get.mockResolvedValue({ defaultEnvironmentId: "local-1" }); + settings.getExperimental.mockResolvedValue({ enableNativeRunner: true }); + settings.getGeneral.mockResolvedValue({ executionMode: "any" }); + secrets.list.mockResolvedValue([]); + secrets.create.mockResolvedValue({ id: "org-secret-1" }); + secrets.remove.mockResolvedValue({ ok: true }); + secrets.listMyUserSecrets.mockResolvedValue([]); + secrets.createUserSecretDefinition.mockResolvedValue({ id: "definition-1" }); + secrets.createMyUserSecret.mockResolvedValue({ id: "secret-1" }); + secrets.removeUserSecretDefinition.mockResolvedValue({ ok: true }); +}); +afterEach(async () => { + await act(async () => root.unmount()); + cache.clear(); + container.remove(); +}); + +export { api, envApi, settings, secrets, state, managedApi, adapterApi, root, container, cache, settle, click, fill, render, connect, pass, renderCurrentParams }; diff --git a/ui/src/pages/NewAgent.test.tsx b/ui/src/pages/NewAgent.test.tsx index 80765ed25e5..983924ef71c 100644 --- a/ui/src/pages/NewAgent.test.tsx +++ b/ui/src/pages/NewAgent.test.tsx @@ -1,204 +1,13 @@ // @vitest-environment jsdom import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { TooltipProvider } from "@/components/ui/tooltip"; +import { describe, expect, it, vi } from "vitest"; import { queryKeys } from "@/lib/queryKeys"; -import { NewAgent } from "./NewAgent"; -import { ApiError } from "@/api/client"; +import { + api, envApi, settings, secrets, state, managedApi, adapterApi, + root, container, cache, settle, click, fill, render, connect, pass, + renderCurrentParams, +} from "./NewAgent.test-support"; -const api = vi.hoisted(() => ({ - get: vi.fn(), - adapterModels: vi.fn(), - list: vi.fn(), - hire: vi.fn(), - testEnvironment: vi.fn(), - getAdapterAuthSignal: vi.fn(), - getClaudeOAuthTokenStatus: vi.fn(), -})); -const envApi = vi.hoisted(() => ({ list: vi.fn(), capabilities: vi.fn() })); -const settings = vi.hoisted(() => ({ - get: vi.fn(), - getExperimental: vi.fn(), - getGeneral: vi.fn(), -})); -const secrets = vi.hoisted(() => ({ - create: vi.fn(), - remove: vi.fn(), - list: vi.fn(), - listMyUserSecrets: vi.fn(), - createUserSecretDefinition: vi.fn(), - createMyUserSecret: vi.fn(), - rotateMyUserSecret: vi.fn(), - removeUserSecretDefinition: vi.fn(), -})); -const state = vi.hoisted(() => ({ - params: new URLSearchParams(), - adapters: [] as object[], - navigate: vi.fn(), - openNewIssue: vi.fn(), -})); -const managedApi = vi.hoisted(() => ({ - list: vi.fn(async () => ({ currentUserId: "user-1", canManageConnections: true, connections: [] })), - create: vi.fn(async () => ({ connectionId: "managed-connection", grantId: "managed-grant" })), - setDefault: vi.fn(async () => ({})), -})); -vi.mock("@/api/ai-connections", () => ({ aiConnectionsApi: managedApi })); -vi.mock("@/api/agents", () => ({ agentsApi: api })); -vi.mock("@/api/environments", () => ({ environmentsApi: envApi })); -vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: settings })); -vi.mock("@/api/secrets", () => ({ secretsApi: secrets })); -const adapterApi = vi.hoisted(() => ({ list: vi.fn() })); -vi.mock("@/api/adapters", () => ({ adaptersApi: adapterApi })); -vi.mock("@/context/CompanyContext", () => ({ - useCompany: () => ({ selectedCompanyId: "company-1" }), -})); -vi.mock("../context/BreadcrumbContext", () => ({ - useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }), -})); -vi.mock("@/context/DialogContext", () => ({ - useDialogActions: () => ({ openNewIssue: state.openNewIssue }), -})); -vi.mock("@/lib/router", () => ({ - useNavigate: () => state.navigate, - useSearchParams: () => [state.params], -})); -// Exercise API/persistence contracts with deterministic presentation primitives. -// The actual searchable dropdown and login panel are covered by their tests and browser verification. -vi.mock("@/components/AgentConfigForm", () => ({ - ModelDropdown: ({ - value, - onChange, - }: { - value: string; - onChange: (value: string) => void; - }) => ( - onChange(e.target.value)} - /> - ), - AdapterLoginPanel: ({ onConnected }: { onConnected: (id: string) => void }) => ( - - ), -})); -vi.mock("@/components/onboarding/PillGuy", () => ({ PillGuy: () => null })); -vi.mock("motion/react", () => ({ - AnimatePresence: ({ children }: any) => children, - MotionConfig: ({ children }: any) => children, - motion: { - span: ({ children }: any) => {children}, - div: ({ children, initial, animate, exit, transition, ...rest }: any) => ( -
{children}
- ), - }, -})); -(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -let root: Root; -let container: HTMLDivElement; -let cache: QueryClient; -async function settle() { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 20)); - }); -} -async function click(text: string) { - const button = [...container.querySelectorAll("button")].find( - (b) => b.textContent?.trim() === text, - ); - expect(button, `Missing button ${text}`).toBeTruthy(); - await act(async () => button!.click()); - await settle(); -} -async function fill(label: string, value: string) { - const input = container.querySelector( - `[aria-label="${label}"]`, - ) as HTMLInputElement; - expect(input).toBeTruthy(); - await act(async () => { - Object.getOwnPropertyDescriptor( - HTMLInputElement.prototype, - "value", - )!.set!.call(input, value); - input.dispatchEvent(new Event("input", { bubbles: true })); - }); -} -async function render(adapter = "pi_local", runnerProvider = "codex") { - state.params = new URLSearchParams({ - name: "Atlas", - adapterType: adapter, - runnerProvider, - }); - await act(async () => - root.render( - - - , - ), - ); - await settle(); -} -async function connect(provider: string) { - await click(provider + "Subscription"); - await click("Connect"); -} -const pass = { - adapterType: "pi_local", - status: "pass", - checks: [ - { code: "hello_probe_passed", level: "info", message: "Model replied" }, - ], - testedAt: "2026-09-07T00:00:00Z", -}; -beforeEach(() => { - vi.clearAllMocks(); - adapterApi.list.mockImplementation(async () => state.adapters); - managedApi.list.mockResolvedValue({ currentUserId: "user-1", canManageConnections: true, connections: [] }); - container = document.createElement("div"); - document.body.append(container); - root = createRoot(container); - cache = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, - }); - state.adapters = [ - "claude_local", - "codex_local", - "opencode_local", - "pi_local", - "paperclip_runner", "cursor_cloud", "cursor", "gemini_local", "kimi_local", "grok_local", "hermes_local", "hermes_gateway", - ].map((type) => ({ type, loaded: true, disabled: false })); - api.adapterModels.mockResolvedValue([]); - api.list.mockResolvedValue([{ id: "ceo", role: "ceo", status: "idle" }]); - api.getAdapterAuthSignal.mockResolvedValue({ status: "present" }); - api.getClaudeOAuthTokenStatus.mockRejectedValue(new ApiError("Not found", 404, null)); - api.testEnvironment.mockResolvedValue(pass); - api.hire.mockImplementation(async (_company, input) => ({ - agent: { ...input, id: "new-agent", status: "idle", urlKey: "atlas" }, - })); - envApi.list.mockResolvedValue([ - { id: "local-1", name: "Local", driver: "local", config: {} }, - ]); - envApi.capabilities.mockResolvedValue({ sandboxProviders: {} }); - settings.get.mockResolvedValue({ defaultEnvironmentId: "local-1" }); - settings.getExperimental.mockResolvedValue({ enableNativeRunner: true }); - settings.getGeneral.mockResolvedValue({ executionMode: "any" }); - secrets.list.mockResolvedValue([]); - secrets.create.mockResolvedValue({ id: "org-secret-1" }); - secrets.remove.mockResolvedValue({ ok: true }); - secrets.listMyUserSecrets.mockResolvedValue([]); - secrets.createUserSecretDefinition.mockResolvedValue({ id: "definition-1" }); - secrets.createMyUserSecret.mockResolvedValue({ id: "secret-1" }); - secrets.removeUserSecretDefinition.mockResolvedValue({ ok: true }); -}); -afterEach(async () => { - await act(async () => root.unmount()); - cache.clear(); - container.remove(); -}); describe("New agent setup", () => { it("waits for the current instance default instead of testing cached SSH discovery", async () => { let finish!: (value: object) => void; @@ -663,14 +472,7 @@ describe("New agent setup", () => { adapterType: "pi_local", createdAgentId: "saved-agent", }); - await act(async () => - root.render( - - - , - ), - ); - await settle(); + await renderCurrentParams(); expect(container.textContent).toContain("Your agent is ready"); expect(container.textContent).not.toContain("Finish setup"); expect(api.hire).not.toHaveBeenCalled(); diff --git a/ui/storybook/.storybook/preview.tsx b/ui/storybook/.storybook/preview.tsx index cd548b4f2c5..73ebe65ff4b 100644 --- a/ui/storybook/.storybook/preview.tsx +++ b/ui/storybook/.storybook/preview.tsx @@ -162,6 +162,9 @@ function installStorybookApiFixtures() { if (url.pathname === "/api/instance/settings/experimental") { return Response.json(experimentalSettings()); } + if (url.pathname === "/api/instance/settings/general") { + return Response.json(instanceGeneralSettingsSchema.parse({})); + } if (url.pathname === "/api/health") { return Response.json({ deploymentMode: "authenticated", localAiLoginSupported: onboardingFixtureState.environments === "local" }); @@ -482,9 +485,11 @@ function installStorybookApiFixtures() { type: "claude_local", label: "Claude Code", source: "builtin", - modelsCount: 2, + modelsCount: 4, loaded: true, disabled: false, + supportedRunners: ["paperclip", "legacy"], + defaultRunner: "legacy", capabilities: { supportsInstructionsBundle: true, supportsSkills: true, @@ -510,6 +515,8 @@ function installStorybookApiFixtures() { modelsCount: 3, loaded: true, disabled: false, + supportedRunners: ["paperclip", "legacy"], + defaultRunner: "legacy", capabilities: { supportsInstructionsBundle: true, supportsSkills: true, @@ -522,6 +529,16 @@ function installStorybookApiFixtures() { }, }, }, + { + type: "paperclip_runner", + label: "Paperclip Runner", + source: "builtin", + modelsCount: 0, + loaded: true, + disabled: false, + supportedRunners: ["paperclip"], + defaultRunner: "paperclip", + }, ]); } @@ -532,6 +549,7 @@ function installStorybookApiFixtures() { const [, , resource] = adapterModelsMatch; if (resource === "models") { return Response.json([ + { id: "claude-opus-5-5", label: "Claude Opus 5.5" }, { id: "claude-opus-4-7", label: "Claude Opus 4.7" }, { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, { id: "claude-haiku-4-5", label: "Claude Haiku 4.5" },