Files
PaperClipAI/tests/runner-e2e/first-task-flow.ts
T
Devin FoleyandPaperclip fd4b197f83 fix(ui): hide runner selection during agent setup (#19517)
## Thinking Path

> - Paperclip lets people manage AI agents for work.
> - Agent setup connects a harness, model, account, and computer.
> - The system already selects a qualified runner for a new agent.
> - Setup still asks users to select that internal runner.
> - The runner dropdown can be the only field in Advanced.
> - This PR removes the dropdown and hides an empty Advanced section.
> - Users keep their saved execution settings and see fewer setup controls.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Agent creation, onboarding, built-in agent setup, company import, and agent configuration.

**Subsystem affected**

The UI, setup error messages, tests, and Storybook fixtures.

**Current behavior**

Users can select a runner from a dropdown. Some setup screens place only that dropdown in Advanced. Errors can direct users to that control.

**Proposed behavior**

Remove runner dropdowns from setup and configuration. Use the existing automatic selection for new agents. Retain saved runner settings. Show Advanced only when other fields remain.

**Reason and benefit**

Users can configure agents without an extra internal execution choice. Setup also avoids an empty Advanced section.

**Breaking changes**

Users can no longer select a runner through these UI dropdowns. The API and CLI retain their runner options. Existing agents keep their saved execution settings.

**Additional context**

Related: [Default new agents to the qualified runner for their harness](https://github.com/paperclipai/paperclip/pull/15422). This PR changes the existing setup UI. It adds no harness or runtime default. The master merge retains the custom gateway placement from [#19510](https://github.com/paperclipai/paperclip/pull/19510).

## What Changed

- Remove the shared runner dropdown and its setup/configuration callers.
- Hide Advanced when it has no remaining fields. Keep command and argument settings where supported. Preserve custom gateway setup under Advanced on the Claude and OpenAI API key paths.
- Keep setup failures visible and keep the retry action outside the removed control.
- Update errors and documentation that referred to the removed dropdown.
- Update component tests and Storybook fixtures for the new UI states.
- Make Legacy first-task evals resume an API-configured setup record. Record that path separately from fresh automatic onboarding.

## Verification

- Latest master integration (`187c0f6d6e5`): 276 onboarding/provider-connection tests passed across six files. UI typecheck and build passed. Token gates, query-error rendering checks, and the Storybook catalog check passed. The conflict resolution retains master's bounded connection tests and cancellation while removing the obsolete runner instruction. The first test command used the wrong working directory and failed before collecting tests; the corrected invocation from `ui/` passed.
- Earlier branch verification passed: full workspace build and typecheck, 1,037 focused UI/service tests, 49 heartbeat provider contract tests, eval typecheck, and 30 map/catalog tests.
- The full local `pnpm test:run` run passed all six chat integration shards (1,931 tests). It was stopped for the master merge before the complete suite finished. The [complete CI run](https://github.com/paperclipai/paperclip/actions/runs/42264662642) passed on the previous head `72e1699`.
- Current head `a097c8369d079c839e78661a2206cfa62c5210e0`: [full CI](https://github.com/paperclipai/paperclip/actions/runs/42273168268) passed. PR checks total 55 passed and 3 skipped, with none failed or pending. Greptile reviewed this head at 5/5 with no actionable findings or unresolved review threads.
- Live browser/provider evals have not run. The Legacy eval change has no live qualification claim.

## Risks

- Removing the dropdown changes the setup UI. Existing API and CLI runner choices remain available.
- Conditional Advanced sections must retain valid command and argument fields.
- Legacy first-task evals now prove setup resumption. They do not prove the fresh-agent automatic default.
- No schema change or data migration is included.

## Model Used

OpenAI `gpt-6.1-sol`, through Codex in T3 Code. Reasoning effort: `xhigh`. The model used file editing, shell commands, and test tools. The harness does not expose 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
- [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>
2026-11-06 10:58:42 -08:00

935 lines
44 KiB
TypeScript

import { runsCompletionUpdateProbe } from "./completion-quality.js";
import { gradeConfirmationReply, assertConfirmationReceipt } from "./confirmation-replies.js";
import { firstTaskUserRequest } from "./first-task-transcript.js";
import { isBlockedUnstartedWake, isTerminalUnstartedWake } from "./non-execution-wake.js";
import { firstTaskRejectionReplyRecorded, isFirstTaskRejectionCancellation } from "./first-task-rejection.js";
import { answerableRuntimeRunIds } from "./runtime-question-readiness.js";
import { captureFirstTaskAttachments } from "./first-task-attachments.js";
import { waitForFirstTaskReply } from "./first-task-replies.js";
import { APPROVAL_HISTORY_CASE, readApprovalHistoryRevision } from "./first-task-approval-history.js";
import { runApprovalHistoryDecisions } from "./first-task-approval-history-flow.js";
import { observeCompletionUpdate } from "./completion-update-flow.js";
import {
provisionFirstTaskFixtures,
isManagedFirstTask,
startFirstTaskWithRuntimeBound,
type FirstTaskFixtureValues,
} from "./first-task-fixtures.js";
import { execFileSync } from "node:child_process";
import { expect, type Page, type Request, type Response } from "@playwright/test";
import { readFile } from "node:fs/promises";
import { pollUntil, type RunnerApi } from "./api.js";
import {
sendChatMessage,
chatQuestionPresentation,
collectChatRunEvidence,
} from "./chat-flow.js";
import type { LiveFixtureValues } from "./live-fixtures.js";
import type { CredentialName, MatrixExecution, RunnerE2EResult } from "./types.js";
import { firstTaskScenario } from "./first-task-cases.js";
import {
activeRuns,
digestText,
snapshotInstruction,
gradeFirstTask,
firstTaskCompletionSettled,
type FirstTaskEvidence,
type FirstTaskCheckpoint,
type Row,
} from "./first-task-scoring.js";
export function projectFirstTaskResult(result: RunnerE2EResult): RunnerE2EResult {
const e = result.firstTask;
if (!e) return result;
const pick = (value: unknown, fields: readonly string[]): Row => {
const row = value && typeof value === "object" ? value as Record<string, unknown> : {};
return Object.fromEntries(fields.flatMap(key => {
const item = row[key];
return item === null || ["string", "number", "boolean"].includes(typeof item)
? [[key, item]] : [];
})) as Row;
};
const rows = (value: unknown): Row[] => Array.isArray(value)
? value.filter(row => row && typeof row === "object") : [];
const strings = (value: unknown): string[] => Array.isArray(value)
? value.filter((item): item is string => typeof item === "string") : [];
const usage = (value: unknown) => {
const u = value as Row | undefined;
const measurements = ["inputTokens", "outputTokens", "cachedInputTokens", "cacheWriteTokens", "cacheReadTokens", "cachedReadTokens", "input", "output", "promptTokens", "completionTokens", "cacheAdjustedCostUsd", "costUsd", "providerCostUsd", "costUsdExact"];
return {
...pick(u, [...measurements, "model", "provider", "biller", "billingType", "costStatus", "usageSource", "accountingReceiptReady"]),
...Object.fromEntries(["runDelta", "total", "cumulative"].filter(key => u?.[key]).map(key => [key, pick(u?.[key], measurements)])),
...(u?.cost ? { cost: pick(u.cost, ["currency", "amount", "total"]) } : {}),
...(u?.pricingProvenance ? { pricingProvenance: pick(u.pricingProvenance, ["source", "version", "contextTier", "serviceTier", "inputCentsPerMillion", "outputCentsPerMillion", "cacheWriteCentsPerMillion", "cachedInputCentsPerMillion"]) } : {}),
};
};
const questionSet = (value: unknown) => {
const set = value as Row | undefined;
return { ...pick(set, ["title", "description", "submitLabel"]), questions: rows(set?.questions).map(q => ({
...pick(q, ["id", "prompt", "header", "helpText", "required", "answerMode", "selectionMode", "allowOther"]),
options: rows(q.options).map(option => pick(option, ["id", "label", "description", "recommended", "freeText"])),
...(q.customAnswer ? { customAnswer: pick(q.customAnswer, ["enabled", "label", "placeholder"]) } : {}),
})) };
};
const interaction = (row: Row): Row => {
const payload = row.payload as Row | undefined;
const answers = row.result?.answers;
const answerRows: Row[] = Array.isArray(answers) ? rows(answers) : answers && typeof answers === "object"
? Object.entries(answers).map(([questionId, answer]) => ({ ...answer as Row, questionId })) : [];
return {
...pick(row, ["id", "issueId", "kind", "status", "createdAt", "resolvedAt", "resolvedByAgentId", "resolvedByRunId", "sourceRunId"]),
...(row.resolvedByUserId ? { resolvedByUserId: "fixture-board" } : {}),
payload: {
...pick(payload, ["version", "title", "prompt", "detailsMarkdown", "acceptLabel", "rejectLabel", "rejectRequiresReason", "rejectReasonLabel", "submitLabel"]),
...(payload?.questions ? questionSet(payload) : {}),
...(payload?.questionSet ? { questionSet: questionSet(payload.questionSet) } : {}),
...(payload?.target ? { target: pick(payload.target, ["type", "key", "label", "revisionNumber", "issueId", "documentId", "revisionId"]) } : {}),
...(payload?.options ? { options: rows(payload.options).map(option => pick(option, ["id", "label", "description"])) } : {}),
...(payload?.defaultSelectedOptionIds ? { defaultSelectedOptionIds: strings(payload.defaultSelectedOptionIds) } : {}),
},
...(row.result ? { result: {
...pick(row.result, ["outcome", "reason", "commentId"]),
...(row.result.selectedOptionIds ? { selectedOptionIds: strings(row.result.selectedOptionIds) } : {}),
answers: answerRows.map(answer => ({
...pick(answer, ["id", "questionId", "text", "otherText", "customText"]),
optionIds: strings(answer.optionIds ?? answer.selectedOptionIds),
})),
} } : {}),
};
};
const privateInstructions = "Full instructions are retained in the access-controlled first-task snapshot.";
const settings = e.runtimeSettings as Record<string, any> | undefined;
const checkpointIds = new Set(e.checkpoints.map(c => c.id));
const firstTask: FirstTaskEvidence = {
caseId: e.caseId, nonce: e.nonce, onboardingIssueId: e.onboardingIssueId,
agentId: e.agentId, initialTaskIds: [...e.initialTaskIds],
...(e.source ? { source: { sha: e.source.sha, ref: e.source.ref, dirty: e.source.dirty } } : {}),
configuredModel: e.configuredModel, observedModels: [...e.observedModels],
instructions: e.instructions.map(i => ({
path: /^(?:\/|[A-Za-z]:)|(?:^|\/)\.\.(?:\/|$)/.test(i.path) ? "private-instruction-path" : i.path,
sha256: i.sha256, content: privateInstructions, contentSha256: digestText(privateInstructions), redacted: true,
})),
runtimeSettings: {
...pick(settings, ["adapterType", "completionDeliveryProbe"]),
adapterConfig: pick(settings?.adapterConfig, ["provider", "model", "modelReasoningEffort", "lifecycleMode", "codexPermissionMode"]),
...(settings?.onboardingRuntime ? { onboardingRuntime: pick(settings.onboardingRuntime, ["mode", "originalAdapterType", "testedAdapterType", "originalModel"]) } : {}),
},
checkpoints: e.checkpoints.map(c => ({
id: c.id, at: c.at, phase: c.phase, issueId: c.issueId,
tasks: c.tasks.map(t => pick(t, ["id", "identifier", "title", "description", "status", "parentId", "assigneeAgentId", "createdAt", "completedAt"])),
agents: c.agents.map(a => ({ ...pick(a, ["id", "name", "adapterType", "createdAt"]),
adapterConfig: pick(a.adapterConfig, ["provider", "model", "modelReasoningEffort", "lifecycleMode", "codexPermissionMode"]) })),
comments: c.comments.map(comment => ({ ...pick(comment, ["id", "issueId", "body", "authorAgentId", "createdAt"]),
...(comment.authorUserId ? { authorUserId: "fixture-board" } : {}) })),
interactions: c.interactions.map(interaction),
documents: c.documents.map(d => ({ ...pick(d, ["id", "issueId", "key", "title", "body", "format", "latestRevisionId", "latestRevisionNumber", "createdAt", "updatedAt"]),
...(d.savedRevision ? { savedRevision: { ...pick(d.savedRevision, ["id", "documentId", "issueId", "key", "body", "createdByAgentId", "createdByRunId"]),
...(d.savedRevision.createdByUserId ? { createdByUserId: "fixture-board" } : {}) } } : {}),
})),
attachments: (c.attachments ?? []).map(a => pick(a, ["id", "issueId", "title", "originalFilename", "filename", "body", "contentVerified", "contentSha256", "byteSize", "createdAt"])),
runs: c.runs.map(run => ({ ...pick(run, ["id", "issueId", "nativeIssueId", "agentId", "status", "startedAt", "finishedAt", "runtimeMode", "driverKind"]),
...pick({ model: run.resultJson?.model ?? run.usageJson?.model }, ["model"]) })),
})),
checks: e.checks.map(check => ({
id: check.id, passed: check.passed,
...(check.notReached ? { notReached: "The journey did not reach this check; private evidence retains the reason." } : {}),
evidence: check.evidence.filter(ref => checkpointIds.has(ref)),
detail: `${check.id}: ${check.notReached ? "not reached" : check.passed ? "passed" : "failed"}. Full diagnostic evidence is retained in the access-controlled snapshot.`,
})),
};
return {
...result, firstTask,
...(result.usage ? { usage: Array.isArray(result.usage.runs)
? { runs: rows(result.usage.runs).map(run => ({ runId: run.runId, usage: run.usage ? usage(run.usage) : null })) }
: usage(result.usage) } : {}),
...(result.error ? { error: "First-task journey failed; inspect the recorded checks and access-controlled evidence for the original error." } : {}),
matcherResults: (result.matcherResults ?? []).map(matcher => ({
...matcher,
detail: matcher.matcher.kind === "json_path"
? firstTask.checks.find(check => matcher.matcher.kind === "json_path" && matcher.matcher.path === `firstTask.checks.${check.id}`)?.detail
?? `${matcher.matcher.kind}: ${matcher.passed ? "passed" : "failed"}. Private evidence retains diagnostics.`
: `${matcher.matcher.kind}: ${matcher.passed ? "passed" : "failed"}. Private evidence retains diagnostics.`,
})),
};
}
export function managedFirstTaskFileCheck(e: FirstTaskEvidence) {
const last = e.checkpoints.at(-1);
const children = last?.tasks.filter(task => task.parentId === e.onboardingIssueId && task.status === "done") ?? [];
const marker = firstTaskScenario(e.caseId, e.nonce).marker;
const passed = children.length === 1 && (last?.attachments ?? []).some(attachment =>
attachment.issueId === children[0]!.id && attachment.originalFilename === "garden-welcome.txt"
&& /^text\/(?:plain|markdown)(?:;|$)/i.test(String(attachment.contentType))
&& attachment.contentVerified === true && typeof attachment.body === "string"
&& attachment.contentSha256 === digestText(attachment.body) && attachment.body.includes(marker));
return { id: "managed-onboarding-physical-artifact", passed, evidence: last ? [last.id] : [],
detail: "The completed child has a downloaded, size/hash-verified garden-welcome.txt attachment with the requested marker." };
}
export async function observeManagedFirstTaskSetup(page: Page, api: RunnerApi, fixtures: LiveFixtureValues, deadlineAt: number) {
const before = new Set((await api.get<Row[]>(`/api/environments/${fixtures.environment.id}/leases`)).map(lease => lease.id));
const startedAt = Date.now();
const qualification = fixtures.setupQualification!;
const operations: Promise<void>[] = [];
let rejectFailure!: (error: unknown) => void;
const failure = new Promise<never>((_resolve, reject) => { rejectFailure = reject; });
void failure.catch(() => undefined);
const requestStarts = new WeakMap<Request, number>();
const pendingSetupRequests = new Set<Request>();
let platformEndedAt = Infinity;
let qualifiedDefault = false;
let automaticRequest = false;
function phase(request: Request): "platform" | "adapter" | "discovery" | null {
const url = new URL(request.url());
if (request.method() === "POST" && url.pathname === `/api/environments/${fixtures.environment.id}/probe`
&& url.searchParams.get("companyId") === fixtures.company.id) return "platform";
if (request.method() === "POST" && url.pathname === `/api/companies/${fixtures.company.id}/adapters/codex_local/test-environment`) return "adapter";
if (request.method() === "GET" && url.pathname === "/api/adapters" && url.searchParams.get("companyId") === fixtures.company.id
&& url.searchParams.get("environmentId") === fixtures.environment.id) return "discovery";
return null;
}
const onRequest = (request: Request) => {
const kind = phase(request);
if (kind) requestStarts.set(request, Date.now());
if (kind === "platform" || kind === "adapter") pendingSetupRequests.add(request);
};
const onRequestFailed = (request: Request) => {
if (pendingSetupRequests.has(request)) fixtures.setupLeaseAccountingIncomplete = true;
};
const onResponse = (response: Response) => {
const request = response.request();
const kind = phase(request);
if (!kind) return;
const responseAt = Date.now();
if (kind === "platform") { qualification.platformResponses++; platformEndedAt = responseAt; }
if (kind === "adapter") qualification.adapterResponses++;
if (qualification.platformResponses > 1 || qualification.adapterResponses > 1) rejectFailure(new Error("The UI repeated a managed setup request"));
const operation = (async () => {
const body = await response.json() as Row | Row[];
if (kind === "discovery") {
if (Array.isArray(body) && responseAt >= platformEndedAt) {
const codex = body.find(adapter => adapter.type === "codex_local");
qualifiedDefault = codex?.defaultRunner === "paperclip" && codex?.supportedRunners?.includes("paperclip") === true;
if (!qualifiedDefault) rejectFailure(new Error("The selected image is not qualified for native Codex onboarding"));
}
return;
}
const data = body as Row;
if (kind === "platform") {
qualification.recorded = response.ok() && data.ok === true && data.details?.platformQualificationRecorded === true;
if (!qualification.recorded) rejectFailure(new Error("The UI environment qualification did not pass"));
}
else {
qualification.adapterType = response.ok() && data.status === "pass" ? data.adapterType : null;
if (qualification.adapterType !== "paperclip_runner") rejectFailure(new Error("The UI native runtime setup did not pass"));
automaticRequest = request.postDataJSON()?.runner === undefined && (requestStarts.get(request) ?? 0) >= platformEndedAt;
}
const leaseId = kind === "platform" ? data.details?.leaseId
: /(?:^|;\s*)paperclipLeaseId=([a-zA-Z0-9-]+)(?:;|$)/.exec(String(data.checks?.find((check: Row) => check.code === "sandbox_test_identity")?.detail ?? ""))?.[1];
if (typeof leaseId !== "string" || !leaseId) {
fixtures.setupLeaseAccountingIncomplete = true;
return;
}
const lease = await api.get<Row>(`/api/environment-leases/${encodeURIComponent(leaseId)}`, { timeout: 10_000 });
if (before.has(leaseId) || lease.companyId !== fixtures.company.id || lease.environmentId !== fixtures.environment.id
|| lease.provider !== "daytona" || lease.heartbeatRunId || lease.issueId) throw new Error("Setup response references a foreign or non-setup lease");
pendingSetupRequests.delete(request);
if (!fixtures.setupLeases!.some(entry => entry.leaseId === leaseId)) fixtures.setupLeases!.push({ leaseId, phase: kind,
durationMs: responseAt - (requestStarts.get(request) ?? startedAt) });
})();
void operation.catch(error => { fixtures.setupLeaseAccountingIncomplete = true; rejectFailure(error); });
operations.push(operation);
};
page.on("request", onRequest);
page.on("response", onResponse);
page.on("requestfailed", onRequestFailed);
const stop = async (requireSuccess: boolean) => {
page.off("request", onRequest);
page.off("response", onResponse);
page.off("requestfailed", onRequestFailed);
const settled = await Promise.allSettled(operations);
if (pendingSetupRequests.size > 0) fixtures.setupLeaseAccountingIncomplete = true;
const failures = settled.flatMap(result => result.status === "rejected" ? [result.reason] : []);
let leases: Row[];
try { leases = await api.get<Row[]>(`/api/environments/${fixtures.environment.id}/leases`, { timeout: 10_000 }); }
catch (error) { fixtures.setupLeaseAccountingIncomplete = true; throw error; }
for (const lease of leases) {
if (before.has(lease.id) || lease.companyId !== fixtures.company.id || lease.environmentId !== fixtures.environment.id
|| lease.provider !== "daytona" || lease.heartbeatRunId || lease.issueId) continue;
if (!fixtures.setupLeases!.some(entry => entry.leaseId === lease.id)) fixtures.setupLeaseAccountingIncomplete = true;
}
if (failures.length) throw new AggregateError(failures, "Managed onboarding setup evidence could not be verified");
if (!requireSuccess) return;
if (!qualification.recorded || qualification.platformResponses !== 1 || qualification.adapterResponses !== 1
|| qualification.adapterType !== "paperclip_runner" || !automaticRequest || !qualifiedDefault
|| fixtures.setupLeaseAccountingIncomplete || fixtures.setupLeases!.length !== 2 || !["platform", "adapter"].every(kind => fixtures.setupLeases!.some(lease => lease.phase === kind))) {
throw new Error("The UI did not prove one qualified automatic native setup with both owned leases");
}
await pollUntil({ label: "managed onboarding setup lease cleanup", deadlineAt: Math.min(deadlineAt, Date.now() + 120_000),
load: () => api.get<Row[]>(`/api/environments/${fixtures.environment.id}/leases`),
accept: rows => fixtures.setupLeases!.every(entry => rows.some(row => row.id === entry.leaseId
&& row.companyId === fixtures.company.id && row.environmentId === fixtures.environment.id
&& row.status === "released" && row.cleanupStatus === "success")),
});
};
return Object.assign(stop, { failure });
}
export async function setupFirstTaskFixtures(input: {
page: Page;
api: RunnerApi;
execution: MatrixExecution;
nonce: string;
credentials: Partial<Record<CredentialName, string>>;
daytonaImage?: string;
deadlineAt?: number;
observe: (fixtures: LiveFixtureValues) => void;
onCompanyCreated?: (companyId: string) => Promise<void>;
}): Promise<FirstTaskFixtureValues> {
const { page, api, execution, nonce } = input;
const legacySetup = ["legacy-codex", "legacy-claude"].includes(execution.profile.id);
await api.patch("/api/instance/settings/experimental", {
enableClassicTaskInterface: false,
});
await page.goto("/onboarding", { waitUntil: "domcontentloaded" });
const launcher = page.getByRole("button", {
name: /Start Onboarding|New Organization|Add Agent/,
});
if (await launcher.count()) await launcher.first().click();
const create = page.getByRole("button", { name: /Build a new organization/ });
if (await create.count()) await create.first().click();
await page
.getByPlaceholder("e.g. Northwind Labs")
.fill(`First task ${nonce}`);
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page.locator("#onboarding-agent-name")).toBeVisible();
const companies = await api.get<Row[]>("/api/companies");
const company = companies.find((c) => c.name === `First task ${nonce}`);
if (!company) throw new Error("Onboarding fixture company missing");
await input.onCompanyCreated?.(company.id);
const fixtures = await provisionFirstTaskFixtures({
api,
execution,
nonce,
credentials: input.credentials,
daytonaImage: input.daytonaImage,
company: {
id: company.id,
name: company.name,
issuePrefix: company.issuePrefix,
},
});
const secret = fixtures.secretRefs[execution.profile.credential]!;
input.observe(fixtures);
await page.locator("#onboarding-agent-name").fill(fixtures.agent.name);
await page.getByRole("button", { name: "Next", exact: true }).click();
await expect(
page.getByRole("heading", { name: "Connect a model" }),
).toBeVisible();
await page
.getByRole("radio", {
name:
execution.profile.credential === "OPENAI_API_KEY"
? /^OpenAI/
: /^Claude/,
})
.click();
if (legacySetup) {
const agents = await pollUntil({
label: "first-task agent setup configuration",
deadlineAt: Date.now() + 30_000,
load: () => api.get<Row[]>(`/api/companies/${company.id}/agents`),
accept: rows => rows.some(agent => agent.name === fixtures.agent.name
&& agent.pauseReason === "setup" && agent.metadata?.setupAdapterPending === false),
});
const agent = agents.find(agent => agent.name === fixtures.agent.name && agent.pauseReason === "setup")!;
await api.patch(`/api/agents/${agent.id}`, { runner: "legacy" });
await page.reload({ waitUntil: "domcontentloaded" });
await page.getByRole("radio", {
name: execution.profile.credential === "OPENAI_API_KEY" ? /^OpenAI/ : /^Claude/,
}).click();
}
await expect(page.getByRole("button", { name: "Runner", exact: true })).toHaveCount(0);
const useKey = page.getByRole("button", {
name: "Use API key instead",
exact: true,
});
const savedKey = page.getByRole("combobox", { name: "Saved API key" });
// Credential mode depends on the selected provider and its asynchronous key lookup.
await expect(savedKey.or(useKey).first()).toBeVisible();
if (await useKey.isVisible()) await useKey.click();
await page
.getByRole("combobox", { name: "Saved API key" })
.selectOption(`company:${secret.secretId}`);
const managed = isManagedFirstTask(execution);
if (managed && (!input.deadlineAt || input.deadlineAt - Date.now() <= 120_000)) throw new Error("Managed onboarding has no bounded setup deadline");
const stopSetup = managed ? await observeManagedFirstTaskSetup(page, api, fixtures, input.deadlineAt! - 60_000) : null;
let setupError: unknown;
try {
await page.getByRole("button", { name: /^(Connect|Next)$/, exact: true }).last().click();
const visible = expect(page.getByRole("button", { name: "Get started", exact: true })).toBeVisible({
timeout: managed ? input.deadlineAt! - Date.now() - 120_000 : 120_000,
});
if (stopSetup) await Promise.race([visible, stopSetup.failure]); else await visible;
} catch (error) { setupError = error; }
try { await stopSetup?.(!setupError); }
catch (error) { setupError = setupError ? new AggregateError([setupError, error], "Managed onboarding and setup evidence failed") : error; }
if (setupError) throw setupError;
const agents = await api.get<Row[]>(`/api/companies/${company.id}/agents`);
expect(agents).toHaveLength(1);
const wizardAdapter =
execution.profile.credential === "OPENAI_API_KEY"
? execution.profile.id === "runner-codex" ? "paperclip_runner" : "codex_local"
: execution.profile.id === "runner-acpx-claude" ? "paperclip_runner" : "claude_local";
expect(agents[0].adapterType).toBe(wizardAdapter);
if (execution.profile.id === "runner-codex") {
expect(agents[0].adapterConfig?.provider).toBe("codex");
}
fixtures.onboardingRuntime = {
mode: legacySetup ? "api-configured-legacy-setup" : "production-wizard",
originalAdapterType: wizardAdapter,
testedAdapterType: wizardAdapter,
originalModel: agents[0].adapterConfig?.model ?? null,
};
if (execution.profile.id === "runner-acpx-claude") {
expect(agents[0].adapterConfig?.provider).toBe("acpx");
expect(agents[0].adapterConfig?.acpxAgent).toBe("claude");
expect(agents[0].adapterConfig?.model).toBe("claude-opus-5-5");
}
fixtures.agent = {
id: agents[0].id,
companyId: company.id,
name: agents[0].name,
};
input.observe(fixtures);
if (managed) {
await page.reload({ waitUntil: "domcontentloaded" });
const persisted = await api.get<Row>(`/api/agents/${fixtures.agent.id}`);
expect(persisted.adapterType).toBe("paperclip_runner");
expect(persisted.adapterConfig?.provider).toBe("codex");
}
await startFirstTaskWithRuntimeBound({
api, execution, agent: agents[0],
observe: bound => { fixtures.onboardingRuntimeBound = bound; input.observe(fixtures); },
start: () => page.getByRole("button", { name: "Get started", exact: true }).click(),
});
return fixtures;
}
export async function runFirstTaskFlow(input: {
page: Page;
api: RunnerApi;
fixtures: FirstTaskFixtureValues;
execution: MatrixExecution;
nonce: string;
deadlineAt?: number;
observe: (issue: any, runs: any[], evidence: FirstTaskEvidence) => void;
secrets: readonly string[];
createOrdinary: (title: string, prompt: string) => Promise<Row>;
capture: (id: string, label: string, file: string) => Promise<void>;
evidence: (name: string, value: unknown) => Promise<void>;
}) {
const { page, api, fixtures, execution, nonce } = input;
const scenario = firstTaskScenario(execution.task.id, nonce);
const managed = isManagedFirstTask(execution);
if (managed) {
const attachment = "Attach the finished note as a UTF-8 text file named garden-welcome.txt.";
scenario.prompt += ` ${attachment}`;
scenario.facts += ` ${attachment}`;
}
const deadlineAt = managed ? (input.deadlineAt ?? Date.now()) - 120_000
: Date.now() + execution.task.attemptTimeoutMs.local - 120_000;
const tasksPath = `/api/companies/${fixtures.company.id}/issues?limit=100`;
const allRuns = async () => {
const runs = await api.get<Row[]>(
`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`,
);
return Promise.all(
runs.map((r) => api.get<Row>(`/api/heartbeat-runs/${r.id}`)),
);
};
const initial = await pollUntil({
label: "onboarding first task",
deadlineAt,
load: () => api.get<Row[]>(tasksPath),
accept: (rows) => rows.length === 1,
});
let issue = initial[0];
expect(issue.assigneeAgentId).toBe(fixtures.agent.id);
expect(issue.description).toContain("/first-task");
expect(await allRuns()).toHaveLength(0);
const e: FirstTaskEvidence = {
caseId: scenario.id,
nonce,
onboardingIssueId: issue.id,
agentId: fixtures.agent.id,
initialTaskIds: initial.map((t) => t.id),
instructions: [],
configuredModel: null,
observedModels: [],
checkpoints: [],
checks: [],
};
input.observe(issue, [], e);
const snapshot = async (
phase: FirstTaskCheckpoint["phase"],
at = new Date().toISOString(),
) => {
const [tasks, agents, comments, interactions, runs] = await Promise.all([
api.get<Row[]>(tasksPath),
api.get<Row[]>(`/api/companies/${fixtures.company.id}/agents`),
api.get<Row[]>(`/api/issues/${issue.id}/comments?order=asc`),
api.get<Row[]>(`/api/issues/${issue.id}/interactions`),
allRuns(),
]);
const documents = (
await Promise.all(
tasks.map(async (t) => {
const summaries = await api.get<Row[]>(
`/api/issues/${t.id}/documents`,
);
return Promise.all(
summaries.map(async (d) => {
const document = { ...(await api.get<Row>(
`/api/issues/${t.id}/documents/${encodeURIComponent(d.key)}`,
)),
key: d.key,
issueId: t.id,
};
return scenario.id === APPROVAL_HISTORY_CASE ? readApprovalHistoryRevision(api, document) : document;
}),
);
}),
)
).flat();
issue = tasks.find((t) => t.id === issue.id) ?? issue;
e.observedModels = [
...new Set(
runs
.map((r) => r.resultJson?.model ?? r.usageJson?.model)
.filter((m): m is string => typeof m === "string"),
),
];
const checkpoint: FirstTaskCheckpoint = {
id: `${phase}-${e.checkpoints.length}`,
at,
phase,
issueId: issue.id,
tasks,
agents,
comments,
interactions,
documents,
attachments: await captureFirstTaskAttachments(api, tasks, input.secrets),
runs,
};
e.checkpoints.push(checkpoint);
e.checks = gradeFirstTask(e);
if (managed && e.checkpoints.at(-1)?.phase === "finished") e.checks.push(managedFirstTaskFileCheck(e));
if (execution.suite.id === "confirmation-replies" && scenario.id !== "task-card-accept") e.checks.push(...gradeConfirmationReply(e));
input.observe(issue, runs, e);
await input.evidence("first-task.json", e);
await input.evidence("api-state.json", checkpoint);
return checkpoint;
};
let pausedRuntimeRunIds = new Set<string>();
const settle = async (priorRunIds: Set<string>, completion = false, rejection = false) => {
const previousPaused = pausedRuntimeRunIds;
let stable = 0;
await pollUntil({
label: "first-task response and durable outcome",
deadlineAt: Math.min(deadlineAt, Date.now() + 300_000),
intervalMs: 1000,
load: async () => ({
runs: await allRuns(),
tasks: await api.get<Row[]>(tasksPath),
interactions: await api.get<Row[]>(`/api/issues/${issue.id}/interactions`),
comments: rejection ? await api.get<Row[]>(`/api/issues/${issue.id}/comments?order=asc`) : [],
}),
reject: ({ runs, tasks }) => {
const bad = runs.find((r) =>
["failed", "timed_out", "cancelled"].includes(r.status) && !isBlockedUnstartedWake(r) &&
!isTerminalUnstartedWake(r, tasks) && !(rejection && isFirstTaskRejectionCancellation(r, e, tasks)),
);
if (bad)
return `run status ${bad.status}: ${bad.errorCode ?? ""} ${bad.error ?? ""}`;
if (runs.length > 12) return "first-task run count exceeded 12";
},
accept: ({ runs, tasks, interactions, comments }) => {
const paused = answerableRuntimeRunIds(interactions);
const active = activeRuns(runs);
const waitingForAnswer = !completion && active.length > 0 && active.every((r) => paused.has(r.id));
const progressed = runs.some((r) => !priorRunIds.has(r.id) || previousPaused.has(r.id));
const settled = progressed && (active.length === 0 || waitingForAnswer);
pausedRuntimeRunIds = waitingForAnswer ? paused : new Set();
const done =
!completion ||
firstTaskCompletionSettled(
tasks,
e.initialTaskIds,
e.onboardingIssueId,
);
const replied = !rejection || firstTaskRejectionReplyRecorded(e, comments, runs);
stable = settled && done && replied ? stable + 1 : 0;
return stable >= 3;
},
});
};
const turn = async (
message: string,
phase: FirstTaskCheckpoint["phase"],
complete = false,
) => {
const before = new Set((await allRuns()).map((r) => r.id));
const loadComments = () =>
api.get<Row[]>(`/api/issues/${issue.id}/comments?order=asc`);
const previousIds = new Set(
(await loadComments()).map((comment) => comment.id),
);
const at = new Date().toISOString();
await sendChatMessage(page, message);
await waitForFirstTaskReply({
load: loadComments,
previousIds,
message,
deadlineAt: Math.min(deadlineAt, Date.now() + 30_000),
});
const decision = phase === "accepted" || phase === "rejected";
if (decision) await snapshot(phase, at);
await settle(before, complete, phase === "rejected");
return snapshot(decision ? "finished" : phase);
};
let failure: unknown;
try {
const git = (args: string[]) =>
execFileSync("git", args, {
cwd: new URL("../../", import.meta.url),
encoding: "utf8",
}).trim();
e.source = {
sha: git(["rev-parse", "HEAD"]),
ref: git(["branch", "--show-current"]),
dirty: Boolean(git(["status", "--porcelain"])),
};
const agent = await api.get<Row>(`/api/agents/${fixtures.agent.id}`);
e.configuredModel = agent.adapterConfig?.model ?? null;
e.runtimeSettings = {
completionDeliveryProbe: runsCompletionUpdateProbe(execution),
onboardingRuntime: fixtures.onboardingRuntime,
...(fixtures.onboardingRuntimeBound ? { onboardingRuntimeBound: fixtures.onboardingRuntimeBound } : {}),
adapterType: agent.adapterType,
adapterConfig: agent.adapterConfig,
runtimeConfig: agent.runtimeConfig,
permissions: agent.permissions,
};
const desired =
agent.adapterConfig?.paperclipSkillSync?.desiredSkills ?? [];
expect(
desired.map((s: string | { key: string }) =>
typeof s === "string" ? s : s.key,
),
).toContain("paperclipai/paperclip/first-task");
const bundle = await api.get<{ files: Array<{ path: string }> }>(
`/api/agents/${fixtures.agent.id}/instructions-bundle`,
);
for (const file of bundle.files) {
const detail = await api.get<{ content: string }>(
`/api/agents/${fixtures.agent.id}/instructions-bundle/file?path=${encodeURIComponent(file.path)}`,
);
e.instructions.push(
snapshotInstruction(file.path, detail.content, input.secrets),
);
}
const skills = await api.get<Row[]>(
`/api/companies/${fixtures.company.id}/skills`,
);
for (const selection of desired) {
const key = typeof selection === "string" ? selection : selection.key;
const skill = skills.find((s) => s.key === key);
if (!skill) throw new Error(`Missing assigned skill ${key}`);
const file = await api.get<{ content: string }>(
`/api/companies/${fixtures.company.id}/skills/${skill.id}/files?path=SKILL.md`,
);
e.instructions.push(
snapshotInstruction(
`${skill.slug}/SKILL.md`,
file.content,
input.secrets,
),
);
}
// The execution contract is appended by production runners outside the managed persona.
const contract = await readFile(
new URL(
"../../server/src/onboarding-assets/default/AGENTS.md",
import.meta.url,
),
"utf8",
);
e.instructions.push(
snapshotInstruction("runtime/default/AGENTS.md", contract, input.secrets),
);
await snapshot("opening");
await page.goto(
`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`,
);
if (scenario.opening === "ordinary") {
const before = new Set((await allRuns()).map((r) => r.id));
issue = await input.createOrdinary(
execution.task.buildTitle(nonce),
scenario.prompt,
);
e.initialTaskIds.push(issue.id);
input.observe(issue, [], e);
expect(issue.description).not.toContain("/first-task");
await page.goto(
`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`,
);
await settle(before);
await snapshot("response");
} else if (scenario.opening === "message") {
await turn(scenario.prompt, "response");
} else {
const opening = e.checkpoints[0].interactions.find(
(i) => i.kind === "ask_user_questions" && i.status === "pending",
);
expect(opening, "deterministic opening card").toBeTruthy();
const option = opening!.payload.questions[0].options.find(
(o: any) =>
o.id === (scenario.opening === "interview" ? "interview" : "task"),
);
await page
// Paperclip includes the option description in the accessible name.
.getByRole("radio", { name: option.label })
.last()
.click();
if (scenario.opening !== "interview")
await page
.getByTestId("question-other-answer-composer")
.last()
.locator('[contenteditable="true"],textarea')
.first()
.fill(scenario.prompt);
const before = new Set((await allRuns()).map((r) => r.id));
await page
.getByRole("button", {
name: opening!.payload.submitLabel ?? "Continue",
exact: true,
})
.last()
.click();
if (scenario.id === "accept-while-running") {
await pollUntil({
label: "approval card published before the source run finishes",
deadlineAt: Math.min(deadlineAt, Date.now() + 300_000), intervalMs: 100,
load: async () => ({ interactions: await api.get<Row[]>(`/api/issues/${issue.id}/interactions`), runs: await allRuns() }),
accept: ({ interactions, runs }) => interactions.some(i => i.status === "pending" &&
["request_confirmation", "request_checkbox_confirmation"].includes(i.kind) &&
runs.some(r => r.id === i.sourceRunId && r.status === "running")),
reject: ({ runs }) => runs.some(r => !before.has(r.id)) && !activeRuns(runs).length
? "Acceptance overlap was not exercised: source run finished before a live approval card was observed" : undefined,
});
} else await settle(before);
await snapshot("response");
}
// Screenshots can take longer than the source turn's final handoff. In the
// overlap case, accept first and retain the response checkpoint as evidence.
if (scenario.id !== "accept-while-running") await input.capture(
"first-task-response",
"First onboarding response",
"first-task-response.png",
);
const assertBeforeAcceptance = () => {
const check = gradeFirstTask(e).find((c) => c.id === "no-premature-work");
expect(
check?.passed ?? true,
"Behavior failure: work executed before acceptance",
).toBe(true);
};
assertBeforeAcceptance();
if (!scenario.firstResponseOnly) {
if (["interview", "ambiguous"].includes(scenario.opening)) {
const facts =
["interview-plan-accept", APPROVAL_HISTORY_CASE].includes(scenario.id)
? `${scenario.facts} Please write a plan for me to review, before doing the work.`
: scenario.facts;
const pending = (
await api.get<Row[]>(`/api/issues/${issue.id}/interactions`)
).find(
(i) => i.status === "pending" && i.kind === "ask_user_questions",
);
if (!pending) await turn(facts, "clarified");
else {
const set = chatQuestionPresentation(pending.payload);
const before = new Set((await allRuns()).map((r) => r.id));
for (const [index, question] of set.questions.entries()) {
const text = page
.getByTestId("question-text-answer-composer")
.last();
if (await text.isVisible())
await text
.locator('[contenteditable="true"],textarea')
.first()
.fill(facts);
else {
await page
.getByRole(
question.answerMode === "multi_select" ? "checkbox" : "radio",
{
name: question.customAnswer?.label ?? "Other",
exact: true,
},
)
.last()
.click();
await page
.getByTestId("question-other-answer-composer")
.last()
.locator('[contenteditable="true"],textarea')
.first()
.fill(facts);
}
await page
.getByRole("button", {
name:
index === set.questions.length - 1
? (set.submitLabel ?? "Submit answers")
: "Next",
exact: true,
})
.last()
.click();
}
await settle(before);
await snapshot("clarified");
}
}
if (scenario.id === "revise-accept")
await turn(scenario.revision, "revised");
assertBeforeAcceptance();
if (scenario.id === APPROVAL_HISTORY_CASE) {
await runApprovalHistoryDecisions({ page, api, issueId: issue.id, marker: scenario.marker, deadlineAt,
allRuns, snapshot, settle, capture: input.capture });
} else if (scenario.id === "reject-no-execution")
await turn(scenario.rejection, "rejected");
else if (["task-card-accept", "accept-while-running"].includes(scenario.id)) {
const pending = (
await api.get<Row[]>(`/api/issues/${issue.id}/interactions`)
).find(
(i) =>
i.status === "pending" &&
["request_confirmation", "request_checkbox_confirmation"].includes(
i.kind,
),
);
expect(
pending,
"proposal must offer an acceptance card in this case",
).toBeTruthy();
const before = new Set((await allRuns()).map((r) => r.id));
const at = new Date().toISOString();
if (pending!.kind === "request_checkbox_confirmation") {
for (const item of pending!.payload.options ?? [])
await page
.getByRole("checkbox", { name: item.label, exact: true })
.last()
.check();
}
await page
.getByRole("button", {
name:
pending!.payload.acceptLabel ??
(pending!.kind === "request_confirmation"
? "Approve"
: "Confirm selection"),
exact: true,
})
.last()
.click();
await pollUntil({
label: "first-task confirmation acceptance",
deadlineAt: Math.min(deadlineAt, Date.now() + 30_000),
intervalMs: 250,
load: () => api.get<Row[]>(`/api/issues/${issue.id}/interactions`),
accept: (interactions) =>
interactions.find((i) => i.id === pending!.id)?.status ===
"accepted",
reject: (interactions) => {
const status = interactions.find(
(i) => i.id === pending!.id,
)?.status;
return status && !["pending", "accepted"].includes(status)
? `Confirmation ended as ${status}`
: undefined;
},
});
await snapshot("accepted", at);
await settle(before, true);
await snapshot("finished");
} else
await turn(
scenario.acceptance,
"accepted",
scenario.id !== "interview-plan-accept",
);
}
if (execution.suite.id === "confirmation-replies" && scenario.id !== "task-card-accept") {
const last = e.checkpoints.at(-1)!;
const decisionCard = last.interactions.find(card => card.result?.commentId && ["accepted", "rejected"].includes(card.status));
if (decisionCard) {
await page.reload({ waitUntil: "domcontentloaded" });
await assertConfirmationReceipt(page, decisionCard);
}
await input.evidence("confirmation-audit.json", await api.get(`/api/issues/${issue.id}/activity`));
}
if (runsCompletionUpdateProbe(execution)) {
const children = (await api.get<Row[]>(tasksPath)).filter(t => t.parentId === issue.id);
expect(children).toHaveLength(1);
const completion = await observeCompletionUpdate({ ...input, sourceId: issue.id, workerId: children[0]!.id,
marker: scenario.marker, fixtureRequest: firstTaskUserRequest(e), allRuns });
e.runtimeSettings!.completionRenderedLinks = completion.renderedLinks ?? [];
await snapshot("finished");
}
e.checks = gradeFirstTask(e);
if (managed && e.checkpoints.at(-1)?.phase === "finished") e.checks.push(managedFirstTaskFileCheck(e));
if (execution.suite.id === "confirmation-replies" && scenario.id !== "task-card-accept") e.checks.push(...gradeConfirmationReply(e));
await input.evidence("first-task.json", e);
await input.capture(
"final-state",
"First-task final state",
"final-state.png",
);
const failures = e.checks.filter((c) => !c.passed);
expect(
failures,
failures.map((c) => `${c.id}: ${c.detail}`).join("\n"),
).toEqual([]);
return { issue, runs: e.checkpoints.at(-1)!.runs, evidence: e };
} catch (error) {
failure = error;
throw error;
} finally {
// Save failed journeys without replacing the original assertion/transport error.
try {
await snapshot("finished");
const runs = await allRuns();
await input.evidence(
"first-task-run-evidence.json",
await Promise.all(
runs.map((r) =>
collectChatRunEvidence(
api,
r as Parameters<typeof collectChatRunEvidence>[1],
),
),
),
);
} catch (error) {
if (!failure) throw error;
await input.evidence("first-task-evidence-error.json", {
captureFailed: true,
});
}
}
}