diff --git a/docs/docs.json b/docs/docs.json index 31223296a3..8538840176 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -43,6 +43,7 @@ "pages": [ "guides/board-operator/dashboard", "guides/board-operator/creating-a-company", + "guides/board-operator/editing-first-task-texts", "guides/board-operator/managing-agents", "guides/board-operator/org-structure", "guides/board-operator/managing-tasks", diff --git a/docs/guides/board-operator/editing-first-task-texts.md b/docs/guides/board-operator/editing-first-task-texts.md new file mode 100644 index 0000000000..aaf0e4d255 --- /dev/null +++ b/docs/guides/board-operator/editing-first-task-texts.md @@ -0,0 +1,45 @@ +--- +title: Editing the First-Task Texts +summary: Change the welcome, instructions, and proposal style for new organizations +--- + +The text for a new organization's first task lives in `server/src/onboarding-assets/first-task/`. It is plain Markdown, so maintainers can change it without editing TypeScript. + +## Files and placeholders + +| File | Purpose | +| --- | --- | +| `greeting.md` | The welcome the user sees. | +| `brief.md` | The first-task instructions. Contains `{{proposalStep}}`. | +| `proposal-confirmation.md` | The single-card proposal used when the plan toggle is off. | +| `proposal-plan.md` | The plan document and checkbox-card proposal used when the toggle is on. | +| `opening-question.json` | The opening card: its prompt and two options. | +| `chief-of-staff/AGENTS.md` | The first agent's chief-of-staff persona. | +| `README.md` | A maintainer reference for the files, placeholders, toggle, and update behavior. | + +The templates support `{{agentName}}`, `{{organizationName}}`, and `{{proposalStep}}`. Paperclip fills them when it creates the organization, first agent, and first task. + +## How the first-task flow works + +The server posts the greeting and an opening card with two options. Nothing runs until the user answers the card or writes a message. + +- **Interview me:** the agent asks 3–4 questions in one card, then proposes a plan and a team. +- **I have a task in mind:** the typed text is the task. When it is clear enough, the agent proposes right away. Otherwise it asks 2–3 clarifying questions first. +- A plain message instead of an answer counts as a task. + +The agent may create hires or tasks only after the user accepts a confirmation or checkbox card. + +## Apply an edit + +Edit the Markdown with GitHub's web editor or locally, open a pull request, and merge it. A local instance loads the change after its next server restart; Cloud tenants receive it with the next release. + +Only new organizations receive updated text. An existing first task keeps its stored description, and an existing first agent keeps its instruction file. You can edit the task description on the task and the agent's copy in the app under **Instructions**. + +## Choose the proposal form + +Open **Settings > Experimental** and find **First task: propose with a plan document**. Its setting key is `enableFirstTaskPlanProposal`, and it is off by default. + +- **Off:** the chief of staff answers a single-task request with one confirmation card. +- **On:** the chief of staff writes a short plan document and adds a checkbox card. + +Paperclip reads this setting once, when it creates an organization's first task. Changing it later does not alter an existing first task. diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index bd934c1703..1cb97376d8 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -56,7 +56,10 @@ export const INSTANCE_FEATURE_CATALOG: Record; @@ -128,7 +134,7 @@ export const createAgentHireSchema = createAgentSchema.extend({ export type CreateAgentHire = z.infer; export const updateAgentSchema = objectWithoutDefaults( - createAgentSchema.omit({ permissions: true }), + createAgentSchema.omit({ permissions: true, onboardingFirstAgent: true }), ) .partial() .extend({ diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index a23c361574..b6247e3848 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -41,7 +41,7 @@ export const patchInstanceGeneralSettingsSchema = z export const instanceExperimentalSettingsSchema = z.object({ enableEnvironments: z.boolean().default(false), - enableNativeRunner: z.boolean().default(false), + enableNativeRunner: z.boolean().default(true), enableManagedSandboxOnly: z.boolean().default(false), enableIsolatedWorkspaces: z.boolean().default(false), enableStreamlinedLeftNavigation: z.boolean().default(true), @@ -67,6 +67,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableServerInfoDebugView: z.boolean().default(false), enablePaperclipDeveloperMode: z.boolean().default(false), enableSimplifiedEnglishInteractions: z.boolean().default(false), + enableFirstTaskPlanProposal: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), enableWorkspaceBranchReconcileForward: z.boolean().default(true), enableWorkspaceDirtyQuarantineRepair: z.boolean().default(true), diff --git a/server/src/__tests__/agent-hire-idempotency-routes.test.ts b/server/src/__tests__/agent-hire-idempotency-routes.test.ts new file mode 100644 index 0000000000..1f5b06cc9b --- /dev/null +++ b/server/src/__tests__/agent-hire-idempotency-routes.test.ts @@ -0,0 +1,236 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { and, eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agents, + agentRuntimeState, + approvals, + companies, + companyMemberships, + createDb, + heartbeatRuns, + principalPermissionGrants, +} from "@paperclipai/db"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { agentRoutes } from "../routes/agents.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping agent-hire idempotency route tests on this host: ${ + embeddedPostgresSupport.reason ?? "unsupported environment" + }`, + ); +} + +type Db = ReturnType; + +/** + * Seed a company, a hiring agent that carries the standard-trust + * `canCreateAgents` permission, and a running heartbeat run for that agent. The + * hire route authorizes the agent through the legacy `agents:create` path, and + * the run id is what the idempotency guard keys on. + */ +async function seedHiringFixture(db: Db) { + const nonce = randomUUID().slice(0, 8); + const [company] = await db + .insert(companies) + .values({ + name: `Idempotency Co ${nonce}`, + issuePrefix: `ID${nonce.slice(0, 4).toUpperCase()}`, + defaultResponsibleUserId: "board-user", + // Direct hires (no board approval) mirror the reported QA org where the + // duplicate "Sam 2" agent was actually created. + requireBoardApprovalForNewAgents: false, + }) + .returning(); + const [hiringAgent] = await db + .insert(agents) + .values({ + companyId: company!.id, + name: "Chief Of Staff", + role: "general", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + permissions: { canCreateAgents: true }, + }) + .returning(); + const [run] = await db + .insert(heartbeatRuns) + .values({ + companyId: company!.id, + agentId: hiringAgent!.id, + status: "running", + contextSnapshot: {}, + }) + .returning(); + return { company: company!, hiringAgent: hiringAgent!, run: run! }; +} + +function agentActor(companyId: string, agentId: string, runId: string): Express.Request["actor"] { + return { + type: "agent", + agentId, + companyId, + runId, + source: "agent_jwt", + }; +} + +function createApp(db: Db, actor: Express.Request["actor"]) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api", agentRoutes(db)); + app.use(errorHandler); + return app; +} + +describeEmbeddedPostgres("agent hire idempotency within a run", () => { + let db!: Db; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-agent-hire-idempotency-"); + db = createDb(tempDb.connectionString); + // Embedded Postgres cold-starts slowly on a loaded machine. + }, 60_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(approvals); + await db.delete(heartbeatRuns); + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); + await db.delete(agentRuntimeState); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("returns the existing hire when the same run re-posts an identical payload", async () => { + const { company, hiringAgent, run } = await seedHiringFixture(db); + const app = createApp(db, agentActor(company.id, hiringAgent.id, run.id)); + const payload = { name: "Sam", role: "engineer", title: "Store Builder", adapterType: "process" as const }; + + const first = await request(app).post(`/api/companies/${company.id}/agent-hires`).send(payload); + expect(first.status, JSON.stringify(first.body)).toBe(201); + expect(first.body.agent?.name).toBe("Sam"); + const createdId = first.body.agent?.id as string; + + // The agent misreads the wrapped 201 body and re-sends the identical payload. + const second = await request(app).post(`/api/companies/${company.id}/agent-hires`).send(payload); + expect(second.status, JSON.stringify(second.body)).toBe(200); + expect(second.body.idempotent).toBe(true); + expect(second.body.agent?.id).toBe(createdId); + // The retry must not have auto-renamed a duplicate to "Sam 2". + expect(second.body.agent?.name).toBe("Sam"); + + const samAgents = await db + .select({ id: agents.id, name: agents.name }) + .from(agents) + .where(and(eq(agents.companyId, company.id), eq(agents.role, "engineer"))); + expect(samAgents.map((row) => row.name)).toEqual(["Sam"]); + }); + + it("creates one agent when two identical retries overlap in the same run", async () => { + const { company, hiringAgent, run } = await seedHiringFixture(db); + const app = createApp(db, agentActor(company.id, hiringAgent.id, run.id)); + const payload = { name: "Sam", role: "engineer", title: "Store Builder", adapterType: "process" as const }; + + // Both requests are in flight at once, so neither can see the other's + // activity record unless the route serializes them. + const [first, second] = await Promise.all([ + request(app).post(`/api/companies/${company.id}/agent-hires`).send(payload), + request(app).post(`/api/companies/${company.id}/agent-hires`).send(payload), + ]); + const statuses = [first.status, second.status].sort(); + expect(statuses, JSON.stringify([first.body, second.body])).toEqual([200, 201]); + const created = first.status === 201 ? first : second; + const replayed = first.status === 200 ? first : second; + expect(replayed.body.idempotent).toBe(true); + expect(replayed.body.agent?.id).toBe(created.body.agent?.id); + + const samAgents = await db + .select({ name: agents.name }) + .from(agents) + .where(and(eq(agents.companyId, company.id), eq(agents.role, "engineer"))); + expect(samAgents.map((row) => row.name)).toEqual(["Sam"]); + }); + + it("treats a changed payload in the same run as a new hire, not a retry", async () => { + const { company, hiringAgent, run } = await seedHiringFixture(db); + const app = createApp(db, agentActor(company.id, hiringAgent.id, run.id)); + + const first = await request(app) + .post(`/api/companies/${company.id}/agent-hires`) + .send({ name: "Sam", role: "engineer", adapterType: "process" }); + expect(first.status, JSON.stringify(first.body)).toBe(201); + + // Same identity, corrected configuration: the agent meant a different hire. + const corrected = await request(app) + .post(`/api/companies/${company.id}/agent-hires`) + .send({ name: "Sam", role: "engineer", adapterType: "process", budgetMonthlyCents: 5000 }); + expect(corrected.status, JSON.stringify(corrected.body)).toBe(201); + expect(corrected.body.idempotent).toBeUndefined(); + expect(corrected.body.agent?.id).not.toBe(first.body.agent?.id); + expect(corrected.body.agent?.budgetMonthlyCents).toBe(5000); + }); + + it("still creates a distinct agent for a different hire in the same run", async () => { + const { company, hiringAgent, run } = await seedHiringFixture(db); + const app = createApp(db, agentActor(company.id, hiringAgent.id, run.id)); + + const sam = await request(app) + .post(`/api/companies/${company.id}/agent-hires`) + .send({ name: "Sam", role: "engineer", adapterType: "process" }); + expect(sam.status, JSON.stringify(sam.body)).toBe(201); + + const casey = await request(app) + .post(`/api/companies/${company.id}/agent-hires`) + .send({ name: "Casey", role: "designer", adapterType: "process" }); + expect(casey.status, JSON.stringify(casey.body)).toBe(201); + expect(casey.body.agent?.id).not.toBe(sam.body.agent?.id); + + const names = await db + .select({ name: agents.name }) + .from(agents) + .where(eq(agents.companyId, company.id)); + expect(names.map((row) => row.name).sort()).toEqual(["Casey", "Chief Of Staff", "Sam"]); + }); + + it("does not deduplicate identical hires across different runs", async () => { + const { company, hiringAgent, run } = await seedHiringFixture(db); + const [secondRun] = await db + .insert(heartbeatRuns) + .values({ companyId: company.id, agentId: hiringAgent.id, status: "running", contextSnapshot: {} }) + .returning(); + const payload = { name: "Sam", role: "engineer", adapterType: "process" as const }; + + const firstRunApp = createApp(db, agentActor(company.id, hiringAgent.id, run.id)); + const firstRunHire = await request(firstRunApp) + .post(`/api/companies/${company.id}/agent-hires`) + .send(payload); + expect(firstRunHire.status, JSON.stringify(firstRunHire.body)).toBe(201); + + const secondRunApp = createApp(db, agentActor(company.id, hiringAgent.id, secondRun!.id)); + const secondRunHire = await request(secondRunApp).post(`/api/companies/${company.id}/agent-hires`).send(payload); + // A genuinely separate run is not a retry, so the legacy dedup names it "Sam 2". + expect(secondRunHire.status, JSON.stringify(secondRunHire.body)).toBe(201); + expect(secondRunHire.body.idempotent).toBeUndefined(); + expect(secondRunHire.body.agent?.name).toBe("Sam 2"); + }); +}); diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index bc60b20405..fc059ad67b 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -1194,6 +1194,34 @@ describe.sequential("agent skill routes", () => { }); }); + it("seeds the chief-of-staff persona for the onboarding first agent", async () => { + const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl) + .post("/api/companies/company-1/agents") + .send({ + name: "Ada", + role: "general", + adapterType: "claude_local", + adapterConfig: {}, + onboardingFirstAgent: true, + })); + + expect([200, 201], JSON.stringify(res.body)).toContain(res.status); + const createdAgentId = expectResponseId(res.body.id); + await vi.waitFor(() => { + expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith( + expect.objectContaining({ id: createdAgentId, role: "general" }), + expect.objectContaining({ + "AGENTS.md": expect.stringContaining("You are Ada, chief of staff for"), + }), + { entryFile: "AGENTS.md", replaceExisting: false }, + ); + }); + // The generic default persona must NOT be what was seeded over the entry file. + const seededCalls = mockAgentInstructionsService.materializeManagedBundle.mock.calls; + const entrySeed = seededCalls.at(-1)?.[1] as Record | undefined; + expect(entrySeed?.["AGENTS.md"]).toContain("# Hiring and delegation"); + }); + it("includes canonical desired skills in hire approvals", async () => { const db = createDb(true); diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index df9d3515de..c7dbf25140 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -6347,6 +6347,174 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { } }); + it("leaves the onboarding first task idle until the user comments", async () => { + const { companyId, agentId, issueId } = + await seedAssignedTodoNoRunFixture(); + await db + .update(issues) + .set({ originKind: "onboarding_first_task" }) + .where(eq(issues.id, issueId)); + // The server-seeded greeting is agent-authored; it must not count as the + // user having typed. + await db.insert(issueComments).values({ + id: randomUUID(), + companyId, + issueId, + authorAgentId: agentId, + authorType: "agent", + body: "Welcome to Paperclip!", + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.onboardingFirstTaskExempted).toBe(1); + expect(result.assignmentDispatched).toBe(0); + expect(result.issueIds).toEqual([]); + + const wakeups = await db + .select() + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.agentId, agentId)); + expect(wakeups).toHaveLength(0); + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + expect(runs).toHaveLength(0); + }); + + it("keeps the onboarding first task idle while the seeded opening card is unanswered", async () => { + const { companyId, agentId, issueId } = + await seedAssignedTodoNoRunFixture(); + await db + .update(issues) + .set({ originKind: "onboarding_first_task" }) + .where(eq(issues.id, issueId)); + await db.insert(issueThreadInteractions).values({ + id: randomUUID(), + companyId, + issueId, + kind: "ask_user_questions", + status: "pending", + continuationPolicy: "wake_assignee", + createdByAgentId: agentId, + payload: { + version: 1, + questions: [ + { + id: "first-task-opening", + prompt: "What would you like to do?", + selectionMode: "single", + options: [ + { id: "interview", label: "Interview me" }, + { id: "task", label: "I have a task in mind", freeText: true }, + ], + }, + ], + }, + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + // A pending wake-policy card is a durable wait path of its own, so the + // sweep skips the issue before it even reaches the onboarding exemption. + expect(result.assignmentDispatched).toBe(0); + expect(result.continuationRequeued).toBe(0); + expect(result.issueIds).toEqual([]); + expect(result.skipped).toBe(1); + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + expect(runs).toHaveLength(0); + }); + + it("dispatches the onboarding first task once the user answered the opening card", async () => { + const { companyId, agentId, issueId } = + await seedAssignedTodoNoRunFixture(); + await db + .update(issues) + .set({ originKind: "onboarding_first_task" }) + .where(eq(issues.id, issueId)); + await db.insert(issueThreadInteractions).values({ + id: randomUUID(), + companyId, + issueId, + kind: "ask_user_questions", + status: "answered", + continuationPolicy: "wake_assignee", + createdByAgentId: agentId, + resolvedByUserId: "local-board", + resolvedAt: new Date(), + payload: { + version: 1, + questions: [ + { + id: "first-task-opening", + prompt: "What would you like to do?", + selectionMode: "single", + options: [ + { id: "interview", label: "Interview me" }, + { id: "task", label: "I have a task in mind", freeText: true }, + ], + }, + ], + }, + result: { + version: 1, + answers: [{ questionId: "first-task-opening", optionIds: ["interview"] }], + }, + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + // The answered wake-policy card with no run after it is a lost + // continuation: the sweep re-queues the assignee rather than leaving the + // first task idle. The onboarding exemption must not swallow it. + expect(result.onboardingFirstTaskExempted).toBe(0); + expect(result.assignmentDispatched + result.continuationRequeued).toBe(1); + expect(result.issueIds).toEqual([issueId]); + + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + if (runs[0]?.id) { + await waitForRunToSettle(heartbeat, runs[0].id); + } + }); + + it("dispatches the onboarding first task once a user comment exists", async () => { + const { companyId, agentId, issueId } = + await seedAssignedTodoNoRunFixture(); + await db + .update(issues) + .set({ originKind: "onboarding_first_task" }) + .where(eq(issues.id, issueId)); + await db.insert(issueComments).values({ + id: randomUUID(), + companyId, + issueId, + authorUserId: "local-board", + authorType: "user", + body: "Let's start with a landing page.", + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.onboardingFirstTaskExempted).toBe(0); + expect(result.assignmentDispatched).toBe(1); + expect(result.issueIds).toEqual([issueId]); + + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + if (runs[0]?.id) { + await waitForRunToSettle(heartbeat, runs[0].id); + } + }); + it("does not duplicate initial assigned todo dispatch when a queued wake already exists", async () => { const { companyId, agentId, issueId } = await seedAssignedTodoNoRunFixture(); diff --git a/server/src/__tests__/instance-settings-cloud-defaults.test.ts b/server/src/__tests__/instance-settings-cloud-defaults.test.ts new file mode 100644 index 0000000000..84f7bd826c --- /dev/null +++ b/server/src/__tests__/instance-settings-cloud-defaults.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { INSTANCE_FEATURE_CATALOG } from "@paperclipai/shared"; +import { + applyCloudCatalogDefaults, + applyExperimentalSettingsPatch, + applyManagedExperimentalOverlay, + normalizeExperimentalSettings, + stripCloudCatalogDefaultEchoes, +} from "../services/instance-settings.js"; +import type { ManagedInstanceConfig } from "../services/managed-config.js"; + +function managedConfig(features: ManagedInstanceConfig["features"] = {}): ManagedInstanceConfig { + return { + v: 1, + mode: "cloud", + catalogVersion: "test", + features, + plugins: { autoInstall: [] }, + environments: [], + }; +} + +describe("applyCloudCatalogDefaults", () => { + it("pins the catalog so this rule has something to guard", () => { + // The rule exists for flags that default on for self-hosted and off for + // Cloud. If that set ever empties, the helper is dead code and should go. + const guarded = Object.entries(INSTANCE_FEATURE_CATALOG) + .filter(([, entry]) => entry.selfHostedDefault === true && entry.cloudDefault === false) + .map(([key]) => key); + expect(guarded).toContain("enableNativeRunner"); + }); + + it("leaves self-hosted instances on the schema default", () => { + const experimental = applyCloudCatalogDefaults(normalizeExperimentalSettings({}), {}, null); + expect(experimental.enableNativeRunner).toBe(true); + }); + + it("re-asserts the Cloud default when the tenant row and the overlay omit the flag", () => { + const experimental = applyCloudCatalogDefaults( + normalizeExperimentalSettings({}), + {}, + managedConfig(), + ); + expect(experimental.enableNativeRunner).toBe(false); + // Flags with matching defaults are untouched. + expect(experimental.enableStreamlinedUi).toBe(true); + }); + + it("keeps an explicit tenant value", () => { + const raw = { enableNativeRunner: true }; + const experimental = applyCloudCatalogDefaults( + normalizeExperimentalSettings(raw), + raw, + managedConfig(), + ); + expect(experimental.enableNativeRunner).toBe(true); + }); + + it("lets a managed feature value win through the overlay", () => { + const config = managedConfig({ enableNativeRunner: true }); + const { experimental } = applyManagedExperimentalOverlay( + applyCloudCatalogDefaults(normalizeExperimentalSettings({}), {}, config), + config, + ); + expect(experimental.enableNativeRunner).toBe(true); + }); + + it("does not touch flags whose Cloud default is the enabled one", () => { + // enableOwnerInstanceAdmin defaults off for self-hosted and on for Cloud. + // That direction is resolved elsewhere; this helper must not flip it. + const experimental = applyCloudCatalogDefaults( + normalizeExperimentalSettings({}), + {}, + managedConfig(), + ); + expect(experimental.enableOwnerInstanceAdmin).toBe(false); + }); +}); + +describe("stripCloudCatalogDefaultEchoes", () => { + /** What `updateExperimental` would persist for a given row and patch. */ + function persisted(rawStored: unknown, patch: Record, config: ManagedInstanceConfig | null) { + return stripCloudCatalogDefaultEchoes( + rawStored, + patch, + applyExperimentalSettingsPatch(rawStored, patch), + config, + ) as Record; + } + + /** What a later read of that persisted row shows. */ + function readBack(stored: Record, config: ManagedInstanceConfig | null) { + return applyManagedExperimentalOverlay( + applyCloudCatalogDefaults(normalizeExperimentalSettings(stored), stored, config), + config, + ).experimental; + } + + it("does not persist the self-hosted default on Cloud during an unrelated write", () => { + const config = managedConfig(); + const stored = persisted({}, { enablePipelines: true }, config); + expect(stored.enablePipelines).toBe(true); + expect("enableNativeRunner" in stored).toBe(false); + // The Cloud default still applies on the next read. + expect(readBack(stored, config).enableNativeRunner).toBe(false); + }); + + it("treats a full-GET echo of the Cloud default as no choice", () => { + const config = managedConfig(); + const stored = persisted({}, { enableNativeRunner: false, enablePipelines: true }, config); + expect("enableNativeRunner" in stored).toBe(false); + expect(readBack(stored, config).enableNativeRunner).toBe(false); + }); + + it("persists an explicit Cloud opt-in", () => { + const config = managedConfig(); + const stored = persisted({}, { enableNativeRunner: true }, config); + expect(stored.enableNativeRunner).toBe(true); + expect(readBack(stored, config).enableNativeRunner).toBe(true); + }); + + it("keeps a stored tenant value across unrelated writes", () => { + const config = managedConfig(); + const stored = persisted({ enableNativeRunner: true }, { enablePipelines: true }, config); + expect(stored.enableNativeRunner).toBe(true); + expect(readBack(stored, config).enableNativeRunner).toBe(true); + }); + + it("leaves the whole normalized object in place for self-hosted rows", () => { + const stored = persisted({}, { enablePipelines: true }, null); + expect(stored.enableNativeRunner).toBe(true); + expect(stored).toEqual(applyExperimentalSettingsPatch({}, { enablePipelines: true })); + }); +}); diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 475a2fdd5a..0c45717236 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -48,6 +48,7 @@ describe("instance settings service", () => { enableServerInfoDebugView: true, enablePaperclipDeveloperMode: true, enableSimplifiedEnglishInteractions: false, + enableFirstTaskPlanProposal: false, autoRestartDevServerWhenIdle: true, enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: false, diff --git a/server/src/__tests__/issue-onboarding-first-task-routes.test.ts b/server/src/__tests__/issue-onboarding-first-task-routes.test.ts index 094ad03694..b4f814be1e 100644 --- a/server/src/__tests__/issue-onboarding-first-task-routes.test.ts +++ b/server/src/__tests__/issue-onboarding-first-task-routes.test.ts @@ -13,7 +13,9 @@ import { createDb, heartbeatRunEvents, heartbeatRuns, + instanceSettings, issueComments, + issueThreadInteractions, issues, } from "@paperclipai/db"; import { ONBOARDING_FIRST_TASK_ORIGIN_KIND } from "@paperclipai/shared"; @@ -89,6 +91,7 @@ describeEmbeddedPostgres("issue create onboarding first-task routes", () => { // agent_wakeup_requests. heartbeat_runs references both, so a completed run // row blocks a parent delete with a foreign-key violation. await db.delete(activityLog); + await db.delete(issueThreadInteractions); await db.delete(issueComments); await db.delete(heartbeatRunEvents); await db.delete(heartbeatRuns); @@ -98,6 +101,7 @@ describeEmbeddedPostgres("issue create onboarding first-task routes", () => { await db.delete(agents); await db.delete(companySkills); await db.delete(companies); + await db.delete(instanceSettings); }); afterAll(async () => { @@ -169,6 +173,63 @@ describeEmbeddedPostgres("issue create onboarding first-task routes", () => { expect(comments[0]).toMatchObject({ authorType: "agent", authorAgentId: agentId }); }); + it("seeds the two-option opening question card as the assignee on the first task", async () => { + const companyId = await seedCompany(); + const agentId = await seedAgent(companyId); + const app = createApp(); + + const created = await request(app) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Get started", onboardingFirstTask: true, assigneeAgentId: agentId }) + .expect(201); + + const interactions = await db + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.issueId, created.body.id)); + expect(interactions).toHaveLength(1); + expect(interactions[0]).toMatchObject({ + kind: "ask_user_questions", + status: "pending", + createdByAgentId: agentId, + createdByUserId: null, + continuationPolicy: "wake_assignee", + }); + const payload = interactions[0].payload as { + supersedeOnUserComment?: boolean; + questions: Array<{ selectionMode: string; options: Array<{ id: string; label: string; freeText?: boolean }> }>; + }; + expect(payload.supersedeOnUserComment).toBe(true); + expect(payload.questions).toHaveLength(1); + expect(payload.questions[0].selectionMode).toBe("single"); + expect(payload.questions[0].options.map((option) => option.id)).toEqual(["interview", "task"]); + expect(payload.questions[0].options[0].label).toBe( + "Interview me and propose a plan and an agent team to execute it.", + ); + expect(payload.questions[0].options[1]).toMatchObject({ label: "I have a task in mind", freeText: true }); + + // The seeded card is read-only for the thread until the user answers: it + // must not have queued a run by itself. + await drainHeartbeatRunsToQuiescence(db, heartbeatService(db)); + expect(mockAdapterExecute).not.toHaveBeenCalled(); + }); + + it("does not seed the opening card on an unassigned onboarding first task", async () => { + const companyId = await seedCompany(); + const app = createApp(); + + const created = await request(app) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Get started", onboardingFirstTask: true }) + .expect(201); + + const interactions = await db + .select() + .from(issueThreadInteractions) + .where(eq(issueThreadInteractions.issueId, created.body.id)); + expect(interactions).toHaveLength(0); + }); + it("fails closed to an ordinary issue when the onboarding origin is already claimed", async () => { const companyId = await seedCompany(); const agentId = await seedAgent(companyId); @@ -215,4 +276,89 @@ describeEmbeddedPostgres("issue create onboarding first-task routes", () => { for (const response of responses) expect(response.status).toBe(201); expect(await listOnboardingIssues(companyId)).toHaveLength(1); }); + + it("stores the server-assembled brief as the description and ignores the client description", async () => { + const companyId = await seedCompany(); + const agentId = await seedAgent(companyId); + const app = createApp(); + + const created = await request(app) + .post(`/api/companies/${companyId}/issues`) + .send({ + title: "Get started", + description: "client supplied description that must be ignored", + onboardingFirstTask: true, + assigneeAgentId: agentId, + }) + .expect(201); + + expect(created.body.description).toContain("This is the user's first task in Paperclip."); + expect(created.body.description).toContain("Take the path the user picked."); + // Toggle defaults off → the confirmation proposal form is inlined. + expect(created.body.description).toContain("post ONE request_confirmation that says, in a few lines"); + expect(created.body.description).not.toContain("treat it like the plan path"); + expect(created.body.description).not.toContain("client supplied description"); + }); + + it("uses the plan proposal brief when enableFirstTaskPlanProposal is on", async () => { + const companyId = await seedCompany(); + const app = createApp(); + await db + .insert(instanceSettings) + .values({ singletonKey: "default", general: {}, experimental: { enableFirstTaskPlanProposal: true } }) + .onConflictDoUpdate({ + target: [instanceSettings.singletonKey], + set: { experimental: { enableFirstTaskPlanProposal: true } }, + }); + + const created = await request(app) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Get started", onboardingFirstTask: true }) + .expect(201); + + expect(created.body.description).toContain("This is the user's first task in Paperclip."); + expect(created.body.description).toContain("treat it like the plan path"); + expect(created.body.description).not.toContain("post ONE request_confirmation that says, in a few lines"); + + await db.delete(instanceSettings); + }); + + it("does not queue an assignment wake for the onboarding first task", async () => { + const companyId = await seedCompany(); + const agentId = await seedAgent(companyId); + const app = createApp(); + + await request(app) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Get started", onboardingFirstTask: true, assigneeAgentId: agentId }) + .expect(201); + + await drainHeartbeatRunsToQuiescence(db, heartbeatService(db)); + const wakeups = await db + .select() + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.companyId, companyId)); + expect(wakeups).toHaveLength(0); + expect(mockAdapterExecute).not.toHaveBeenCalled(); + }); + + it("still queues an assignment wake for an ordinary assigned issue", async () => { + const companyId = await seedCompany(); + const agentId = await seedAgent(companyId); + const app = createApp(); + + await request(app) + .post(`/api/companies/${companyId}/issues`) + .send({ title: "Ordinary task", assigneeAgentId: agentId }) + .expect(201); + + await drainHeartbeatRunsToQuiescence(db, heartbeatService(db)); + const wakeups = await db + .select() + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.companyId, companyId)); + // An ordinary assigned create still queues the assignment wake for the agent. + expect(wakeups.length).toBeGreaterThan(0); + expect(wakeups.some((row) => row.agentId === agentId)).toBe(true); + }); }); diff --git a/server/src/onboarding-assets/first-task/README.md b/server/src/onboarding-assets/first-task/README.md new file mode 100644 index 0000000000..6ed839cf04 --- /dev/null +++ b/server/src/onboarding-assets/first-task/README.md @@ -0,0 +1,65 @@ +# First-task onboarding assets + +Everything the very first agent is told during onboarding lives here as plain +markdown so the board can edit the wording without touching TypeScript. The +server loads these files (see `server/src/services/onboarding-first-task-assets.ts` +and `server/src/services/onboarding-greeting.ts`) when it creates a new +organization's first task and when it hires the first agent. + +## Files + +| File | Layer | What it is | +| --- | --- | --- | +| `greeting.md` | C | The deterministic greeting the server posts as the agent on the first task, before anything runs. No LLM. | +| `opening-question.json` | C | The deterministic `ask_user_questions` card the server posts as the agent right after the greeting: "Interview me and propose a plan and an agent team to execute it." or "I have a task in mind" (free text). The option ids `interview` and `task` are fixed because `brief.md` refers to them; the `task` option must keep `freeText: true`. No LLM. | +| `brief.md` | A (steps 1, 3, 4) | The first task's description. Contains the `{{proposalStep}}` placeholder and tells the agent what to do with each answer to the opening card. | +| `proposal-confirmation.md` | A (step 2, task path) | The proposal instructions used when the plan-proposal toggle is **off** (default): a one-card `request_confirmation`. | +| `proposal-plan.md` | A (step 2, task path) | The proposal instructions used when the toggle is **on**: a short plan document plus a checkbox card. | +| `chief-of-staff/AGENTS.md` | B | The chief-of-staff persona seeded over the first agent's entry instruction file at hire time. | +| `README.md` | — | This file. | + +## The opening card + +`opening-question.json` is one single-select question. Its `prompt`, optional +`helpText`, optional `submitLabel`, and the two options' `label`/`description` +are free to edit. Picking an option only selects it; nothing happens until the +user presses the primary button (`submitLabel`, "Continue"). That is how every +question card behaves: Next / Submit answers, Skip (optional questions only), +and Cancel, which returns the plain composer and leaves the card pending. The server validates the file when it creates a first task +and refuses (logging a warning, the task is still created) if either option id +changes or the `task` option loses `freeText: true`. When the user answers, the +answer reaches the agent in its wake payload and `brief.md` step 1 tells it +which path to take; when the user types a message instead, the card expires +and the message wakes the agent as before. + +## Placeholders + +- `{{agentName}}` → the agent's chosen name. When the agent has no name the + greeting drops the name gracefully ("I'm your first agent teammate"), matching + the historical behaviour. Used in `greeting.md` and `chief-of-staff/AGENTS.md`. +- `{{organizationName}}` → the organization (company) name. Used in + `chief-of-staff/AGENTS.md`. +- `{{proposalStep}}` (in `brief.md` only) → replaced with the contents of + `proposal-confirmation.md` or `proposal-plan.md`, chosen by the + `enableFirstTaskPlanProposal` toggle. + +## The toggle + +`enableFirstTaskPlanProposal` (Settings → Experimental, tier `preference`, +default **off** on cloud and self-hosted). Title: "First task: propose with a +plan document". When on, the first task's brief uses `proposal-plan.md` for the +single-task path so the chief of staff writes a short plan document and a +checkbox card instead of a one-card confirmation. The create route reads the +toggle **once**, when the first task is created; flipping it later does not +change an existing first task. + +## Two rules + +1. **Edits take effect on the next server restart locally, or the next release + on cloud.** The files are read from disk (bundled into `dist/` at build + time), not baked into TypeScript, so a plain markdown edit + restart/release + is all that is needed. +2. **Only NEW organizations get new text.** An existing first task keeps the + description it was created with, and an existing first agent keeps the + persona it was seeded with (editable per agent under Instructions). Changing + these files never rewrites text an existing organization already received. diff --git a/server/src/onboarding-assets/first-task/brief.md b/server/src/onboarding-assets/first-task/brief.md new file mode 100644 index 0000000000..f423b6ef18 --- /dev/null +++ b/server/src/onboarding-assets/first-task/brief.md @@ -0,0 +1,21 @@ +This is the user's first task in Paperclip. Your job is to understand what they want and propose a path forward. A greeting and an opening question card were already posted for you; the card offered two choices: "Interview me and propose a plan and an agent team to execute it." (option `interview`) or "I have a task in mind" (option `task`, with a text field). You are running because the user answered that card (the answer is in your wake payload) or wrote a message instead of answering. Don't re-introduce yourself and don't post the opening card again. + +Work in this order. + +1. Take the path the user picked. + + - `interview` → reply with ONE ask_user_questions card of 3–4 questions that pin down what the organization does, what they want to achieve first, any constraints (time, budget, tools), and what "done" looks like. Don't guess; ask. Don't post anything else before the card. The answers lead to the plan-and-team path in step 2. + + - `task` → the text they typed is the task. If it is clear enough to propose on, go straight to step 2. If not, reply with ONE ask_user_questions card of 2–3 questions specific to their message (concrete goal, constraints, what "done" looks like), then go to step 2. + + - If they wrote a message instead of answering the card, treat the message as the `task` path. + +2. Propose, don't decide. From what you now know, pick the path: + + - They want a plan and/or a team → write a short `plan` document (goal, approach, team as one line per hire: name, role, responsibility; follow-up tasks). Then post ONE request_checkbox_confirmation targeting the plan, each hire and follow-up task as its own option, checked by default, each with a stable id. Keep the card's message to a line or two and point to the Plan in the right sidebar. + +{{proposalStep}} + +3. Wait. Do nothing until a card is accepted. If they ask for changes, revise and re-confirm. Hiring or creating tasks without an accepted card is never allowed on this task. + +4. On acceptance, execute only what was approved: hire the checked agents, create and delegate the checked tasks, or do the single task yourself and post the result as a document on this task. diff --git a/server/src/onboarding-assets/first-task/chief-of-staff/AGENTS.md b/server/src/onboarding-assets/first-task/chief-of-staff/AGENTS.md new file mode 100644 index 0000000000..d2c8b2044d --- /dev/null +++ b/server/src/onboarding-assets/first-task/chief-of-staff/AGENTS.md @@ -0,0 +1,20 @@ +# Role + +You are {{agentName}}, chief of staff for {{organizationName}}. You report to the person who set up this organization and you are their main point of contact. Understand what they want, propose, and coordinate the work. Do not decide for them. + +# Working with the user + +- Be conversational. Propose, don't decide. +- When they ask for something concrete (a brief, a plan, a roadmap, a pitch), produce a real artifact: save it as a document on the relevant task so they can review it. + +# Chat hygiene + +- Everything you post is read by the user. Keep it terse and written for them. +- Lead with the answer. Never narrate tool calls, API steps, or your own thinking. +- One question card at a time. Don't guess; ask. + +# Hiring and delegation + +You may hire agents and create tasks, but never without first confirming with the user in a request_confirmation or checkbox card that names exactly what will be created. This applies to every task, not only the first one. A proposed hire is one line: name, role, responsibility. + +Send each hire exactly once. A hire request that returns HTTP 201 has succeeded; the body is `{"agent": …, "approval": …}`. If the identical hire is sent again during the same run, the server returns the agent it already created (HTTP 200, `idempotent: true`) instead of a duplicate. That covers exact retries only: a changed payload or a later run creates a new agent, and you cannot pause or remove an agent afterwards. So if a result is unclear, list the organization's agents before doing anything else. Never resend a hire. diff --git a/server/src/onboarding-assets/first-task/greeting.md b/server/src/onboarding-assets/first-task/greeting.md new file mode 100644 index 0000000000..f0fce59980 --- /dev/null +++ b/server/src/onboarding-assets/first-task/greeting.md @@ -0,0 +1 @@ +Welcome to Paperclip! I'm {{agentName}}, your first agent teammate. Pick how you'd like to start and I'll take it from there. diff --git a/server/src/onboarding-assets/first-task/opening-question.json b/server/src/onboarding-assets/first-task/opening-question.json new file mode 100644 index 0000000000..8da2f2b248 --- /dev/null +++ b/server/src/onboarding-assets/first-task/opening-question.json @@ -0,0 +1,17 @@ +{ + "prompt": "What would you like to do?", + "submitLabel": "Continue", + "options": [ + { + "id": "interview", + "label": "Interview me and propose a plan and an agent team to execute it.", + "description": "A few questions about what you're building, then a short plan and the team to carry it out, for you to approve." + }, + { + "id": "task", + "label": "I have a task in mind", + "description": "Describe it and I'll propose how to get it done.", + "freeText": true + } + ] +} diff --git a/server/src/onboarding-assets/first-task/proposal-confirmation.md b/server/src/onboarding-assets/first-task/proposal-confirmation.md new file mode 100644 index 0000000000..5b99b9751e --- /dev/null +++ b/server/src/onboarding-assets/first-task/proposal-confirmation.md @@ -0,0 +1 @@ + - They want one thing done now → post ONE request_confirmation that says, in a few lines, what you will do and what they will get (and by when, if you can say). No plan document. diff --git a/server/src/onboarding-assets/first-task/proposal-plan.md b/server/src/onboarding-assets/first-task/proposal-plan.md new file mode 100644 index 0000000000..6611a11bb9 --- /dev/null +++ b/server/src/onboarding-assets/first-task/proposal-plan.md @@ -0,0 +1 @@ + - They want one thing done now → treat it like the plan path: write the short `plan` document (goal, approach, what you will produce) and post ONE request_checkbox_confirmation targeting it, with the task itself and any optional follow-up as options. diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index ee3d7cf1df..77fe6e07f3 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -4,8 +4,9 @@ import { generateKeyPairSync, randomUUID } from "node:crypto"; import { rm } from "node:fs/promises"; import path from "node:path"; import type { Db } from "@paperclipai/db"; -import { agents as agentsTable, companies, heartbeatRuns, issues as issuesTable, projects as projectsTable } from "@paperclipai/db"; +import { activityLog, agents as agentsTable, companies, heartbeatRuns, issues as issuesTable, projects as projectsTable } from "@paperclipai/db"; import { and, desc, eq, inArray, not, sql } from "drizzle-orm"; +import { sha256Digest } from "../services/feedback-redaction.js"; import { agentSkillSyncSchema, agentMineInboxQuerySchema, @@ -210,6 +211,7 @@ import { loadDefaultAgentInstructionsBundle, resolveDefaultAgentInstructionsBundleRole, } from "../services/default-agent-instructions.js"; +import { buildOnboardingFirstAgentInstructionsBundle } from "../services/onboarding-first-task-assets.js"; import { getTelemetryClient } from "../telemetry.js"; import { assertEnvironmentSelectionForCompany } from "./environment-selection.js"; import { recoveryService } from "../services/recovery/service.js"; @@ -397,6 +399,31 @@ async function anySecretNamesAccountHome( return true; } +// Serializes hire requests that share a company and run, so a retried POST +// cannot race its original past the idempotency lookup: the lookup, the create +// and the activity record all happen inside the held section. In-process is +// the right scope because a Paperclip instance serves its API from one +// process, and the lock is keyed narrowly enough that unrelated hires never +// wait on each other. +const hireRunLocks = new Map>(); + +async function withHireRunLock(key: string, fn: () => Promise): Promise { + const previous = hireRunLocks.get(key) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + const chained = previous.then(() => current); + hireRunLocks.set(key, chained); + await previous; + try { + return await fn(); + } finally { + release(); + if (hireRunLocks.get(key) === chained) hireRunLocks.delete(key); + } +} + export function agentRoutes( db: Db, options: { @@ -2475,6 +2502,27 @@ export function agentRoutes( return (updated as T | null) ?? { ...agent, adapterConfig: nextAdapterConfig }; } + // Resolve the server-owned instruction bundle for the onboarding first agent. + // The marker seeds the chief-of-staff persona (server/src/onboarding-assets/ + // first-task/chief-of-staff/AGENTS.md, placeholders filled) over the agent's + // entry file instead of the generic default. Honored only for board-authored + // requests — the onboarding wizard runs as the board — so a client marker + // alone cannot swap another actor's instructions. The generic execution + // contract (default/AGENTS.md) is still appended on every run, unchanged. + async function resolveOnboardingFirstAgentBundle(params: { + onboardingFirstAgent: unknown; + actorType: string; + agentName: string; + organizationName: string | null; + }): Promise<{ files: Record; entryFile: string } | undefined> { + if (params.onboardingFirstAgent !== true) return undefined; + if (params.actorType !== "board") return undefined; + return buildOnboardingFirstAgentInstructionsBundle({ + agentName: params.agentName, + organizationName: params.organizationName, + }); + } + function assertNoNewAgentLegacyPromptTemplate(adapterType: string, adapterConfig: Record) { if (!adapterSupportsInstructionsBundle(adapterType)) return; if ( @@ -4006,6 +4054,14 @@ export function agentRoutes( res.json(state); }); + // Fingerprint the whole validated hire request so a retried POST inside the + // same run (e.g. an agent that misread the 201 body and re-sent the payload) + // resolves to the hire it already created instead of spawning a "Name 2" + // duplicate, while a corrected payload (a different adapter config, budget, + // manager, skills, instructions, ...) counts as a new hire. Hashed, so no + // adapter-config secret lands in the activity log. + const hireFingerprint = (body: unknown): string => sha256Digest(body); + router.post("/companies/:companyId/agent-hires", validate(createAgentHireSchema), async (req, res) => { const companyId = req.params.companyId as string; await assertCanCreateAgentsForCompany(req, companyId); @@ -4022,6 +4078,9 @@ export function agentRoutes( // The apply-existing flag is not an agent column. The server binds the // fixed reference to the owner stored value with no login round trip. applyStoredClaudeLogin: hireApplyStoredClaudeLogin, + // The onboarding marker is not an agent column. The server consumes it to + // seed the chief-of-staff persona; it never reaches the insert values. + onboardingFirstAgent: hireOnboardingFirstAgent, ...hireInput } = req.body; hireInput.adapterType = await assertSelectableAdapterType(hireInput.adapterType); @@ -4085,122 +4144,135 @@ export function agentRoutes( return; } - const requiresApproval = company.requireBoardApprovalForNewAgents; - const status = requiresApproval ? "pending_approval" : "idle"; - const createdAgent = await svc.create( - companyId, - { - id: hiredAgentId, - ...normalizedHireInput, - status, - spentMonthlyCents: 0, - lastHeartbeatAt: null, - }, - { - claudeLogin: { - storedSessionId: hireStoredSessionId ?? null, - ownerUserId: req.actor.type === "agent" ? null : (req.actor.userId ?? null), - // The apply-existing path runs only for a user actor. The owner comes - // from the actor, so an agent actor never reaches the no-claim bind. - applyExistingWithoutClaim: - req.actor.type !== "agent" && hireApplyStoredClaudeLogin === true, + // Idempotency within a run: if this run already created a hire from this + // exact request, return that hire instead of creating a duplicate. The + // creating agent cannot pause or delete its own hire (board-only), so a + // doubled hire would otherwise strand a phantom teammate the board never + // approved. The lookup, the create and the activity record run under one + // lock per company + run, so two overlapping retries cannot both miss. + const requestFingerprint = hireFingerprint(req.body); + const runId = req.actor.runId && isUuidLike(req.actor.runId) ? req.actor.runId : null; + const performHire = async (): Promise<{ status: 200 | 201; body: Record }> => { + if (runId) { + const priorHires = await db + .select({ entityId: activityLog.entityId, details: activityLog.details }) + .from(activityLog) + .where( + and( + eq(activityLog.companyId, companyId), + eq(activityLog.runId, runId), + eq(activityLog.action, "agent.hire_created"), + ), + ) + .orderBy(desc(activityLog.createdAt)); + const match = priorHires.find( + (row) => (row.details as Record | null)?.hireFingerprint === requestFingerprint, + ); + if (match) { + const existingAgent = await svc.getById(match.entityId); + if (existingAgent && existingAgent.status !== "terminated") { + const priorApprovalId = (match.details as Record | null)?.approvalId; + const existingApproval = + typeof priorApprovalId === "string" ? await approvalsSvc.getById(priorApprovalId) : null; + return { status: 200, body: { agent: existingAgent, approval: existingApproval, idempotent: true } }; + } + } + } + + const requiresApproval = company.requireBoardApprovalForNewAgents; + const status = requiresApproval ? "pending_approval" : "idle"; + const createdAgent = await svc.create( + companyId, + { + id: hiredAgentId, + ...normalizedHireInput, + status, + spentMonthlyCents: 0, + lastHeartbeatAt: null, }, - }, - ); - const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent, instructionsBundle); + { + claudeLogin: { + storedSessionId: hireStoredSessionId ?? null, + ownerUserId: req.actor.type === "agent" ? null : (req.actor.userId ?? null), + // The apply-existing path runs only for a user actor. The owner comes + // from the actor, so an agent actor never reaches the no-claim bind. + applyExistingWithoutClaim: + req.actor.type !== "agent" && hireApplyStoredClaudeLogin === true, + }, + }, + ); + const onboardingFirstAgentBundle = await resolveOnboardingFirstAgentBundle({ + onboardingFirstAgent: hireOnboardingFirstAgent, + actorType: req.actor.type, + agentName: createdAgent.name, + organizationName: company.name ?? null, + }); + const agent = await materializeDefaultInstructionsBundleForNewAgent( + createdAgent, + onboardingFirstAgentBundle ?? instructionsBundle, + ); - let approval: Awaited> | null = null; - const actor = getActorInfo(req); + let approval: Awaited> | null = null; + const actor = getActorInfo(req); - if (requiresApproval) { - const requestedAdapterType = normalizedHireInput.adapterType ?? agent.adapterType; - const requestedAdapterConfig = - redactEventPayload( - (agent.adapterConfig ?? normalizedHireInput.adapterConfig) as Record, - ) ?? {}; - const requestedRuntimeConfig = - redactEventPayload( - (normalizedHireInput.runtimeConfig ?? agent.runtimeConfig) as Record, - ) ?? {}; - const requestedMetadata = - redactEventPayload( - ((normalizedHireInput.metadata ?? agent.metadata ?? {}) as Record), - ) ?? {}; - approval = await approvalsSvc.create(companyId, { - type: "hire_agent", - requestedByAgentId: actor.actorType === "agent" ? actor.actorId : null, - requestedByUserId: actor.actorType === "user" ? actor.actorId : null, - status: "pending", - payload: { - name: normalizedHireInput.name, - role: normalizedHireInput.role, - title: normalizedHireInput.title ?? null, - icon: normalizedHireInput.icon ?? null, - reportsTo: normalizedHireInput.reportsTo ?? null, - capabilities: normalizedHireInput.capabilities ?? null, - adapterType: requestedAdapterType, - adapterConfig: requestedAdapterConfig, - runtimeConfig: requestedRuntimeConfig, - budgetMonthlyCents: - typeof normalizedHireInput.budgetMonthlyCents === "number" - ? normalizedHireInput.budgetMonthlyCents - : agent.budgetMonthlyCents, - desiredSkills: desiredSkillAssignment.desiredSkills, - metadata: requestedMetadata, - agentId: agent.id, + if (requiresApproval) { + const requestedAdapterType = normalizedHireInput.adapterType ?? agent.adapterType; + const requestedAdapterConfig = + redactEventPayload( + (agent.adapterConfig ?? normalizedHireInput.adapterConfig) as Record, + ) ?? {}; + const requestedRuntimeConfig = + redactEventPayload( + (normalizedHireInput.runtimeConfig ?? agent.runtimeConfig) as Record, + ) ?? {}; + const requestedMetadata = + redactEventPayload( + ((normalizedHireInput.metadata ?? agent.metadata ?? {}) as Record), + ) ?? {}; + approval = await approvalsSvc.create(companyId, { + type: "hire_agent", requestedByAgentId: actor.actorType === "agent" ? actor.actorId : null, - requestedConfigurationSnapshot: { + requestedByUserId: actor.actorType === "user" ? actor.actorId : null, + status: "pending", + payload: { + name: normalizedHireInput.name, + role: normalizedHireInput.role, + title: normalizedHireInput.title ?? null, + icon: normalizedHireInput.icon ?? null, + reportsTo: normalizedHireInput.reportsTo ?? null, + capabilities: normalizedHireInput.capabilities ?? null, adapterType: requestedAdapterType, adapterConfig: requestedAdapterConfig, runtimeConfig: requestedRuntimeConfig, + budgetMonthlyCents: + typeof normalizedHireInput.budgetMonthlyCents === "number" + ? normalizedHireInput.budgetMonthlyCents + : agent.budgetMonthlyCents, desiredSkills: desiredSkillAssignment.desiredSkills, + metadata: requestedMetadata, + agentId: agent.id, + requestedByAgentId: actor.actorType === "agent" ? actor.actorId : null, + requestedConfigurationSnapshot: { + adapterType: requestedAdapterType, + adapterConfig: requestedAdapterConfig, + runtimeConfig: requestedRuntimeConfig, + desiredSkills: desiredSkillAssignment.desiredSkills, + }, }, - }, - decisionNote: null, - decidedByUserId: null, - decidedAt: null, - updatedAt: new Date(), - }); - - if (sourceIssueIds.length > 0) { - await issueApprovalsSvc.linkManyForApproval(approval.id, sourceIssueIds, { - agentId: actor.actorType === "agent" ? actor.actorId : null, - userId: actor.actorType === "user" ? actor.actorId : null, + decisionNote: null, + decidedByUserId: null, + decidedAt: null, + updatedAt: new Date(), }); + + if (sourceIssueIds.length > 0) { + await issueApprovalsSvc.linkManyForApproval(approval.id, sourceIssueIds, { + agentId: actor.actorType === "agent" ? actor.actorId : null, + userId: actor.actorType === "user" ? actor.actorId : null, + }); + } } - } - await logActivity(db, { - companyId, - actorType: actor.actorType, - actorId: actor.actorId, - agentId: actor.agentId, - runId: actor.runId, - agentApiKeyId: actor.agentApiKeyId, - action: "agent.hire_created", - entityType: "agent", - entityId: agent.id, - details: { - name: agent.name, - role: agent.role, - requiresApproval, - approvalId: approval?.id ?? null, - issueIds: sourceIssueIds, - desiredSkills: desiredSkillAssignment.desiredSkills, - }, - }); - const telemetryClient = getTelemetryClient(); - if (telemetryClient) { - trackAgentCreated(telemetryClient, { agentRole: agent.role, agentId: agent.id }); - } - - await applyDefaultAgentTaskAssignGrant( - companyId, - agent.id, - actor.actorType === "user" ? actor.actorId : null, - ); - - if (approval) { await logActivity(db, { companyId, actorType: actor.actorType, @@ -4208,14 +4280,52 @@ export function agentRoutes( agentId: actor.agentId, runId: actor.runId, agentApiKeyId: actor.agentApiKeyId, - action: "approval.created", - entityType: "approval", - entityId: approval.id, - details: { type: approval.type, linkedAgentId: agent.id }, + action: "agent.hire_created", + entityType: "agent", + entityId: agent.id, + details: { + name: agent.name, + role: agent.role, + requiresApproval, + approvalId: approval?.id ?? null, + issueIds: sourceIssueIds, + desiredSkills: desiredSkillAssignment.desiredSkills, + hireFingerprint: requestFingerprint, + }, }); - } + const telemetryClient = getTelemetryClient(); + if (telemetryClient) { + trackAgentCreated(telemetryClient, { agentRole: agent.role, agentId: agent.id }); + } - res.status(201).json({ agent, approval }); + await applyDefaultAgentTaskAssignGrant( + companyId, + agent.id, + actor.actorType === "user" ? actor.actorId : null, + ); + + if (approval) { + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + agentApiKeyId: actor.agentApiKeyId, + action: "approval.created", + entityType: "approval", + entityId: approval.id, + details: { type: approval.type, linkedAgentId: agent.id }, + }); + } + + return { status: 201, body: { agent, approval } }; + }; + + const outcome = runId + ? await withHireRunLock(`${companyId}:${runId}`, performHire) + : await performHire(); + res.status(outcome.status).json(outcome.body); }); router.post("/companies/:companyId/agents", validate(createAgentSchema), async (req, res) => { @@ -4247,6 +4357,9 @@ export function agentRoutes( // The apply-existing flag is not an agent column. The server binds the // fixed reference to the owner stored value with no login round trip. applyStoredClaudeLogin: createApplyStoredClaudeLogin, + // The onboarding marker is not an agent column. The server consumes it to + // seed the chief-of-staff persona; it never reaches the insert values. + onboardingFirstAgent: createOnboardingFirstAgent, ...createInput } = req.body; createInput.adapterType = await assertSelectableAdapterType(createInput.adapterType); @@ -4322,7 +4435,16 @@ export function agentRoutes( }, }, ); - const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent, instructionsBundle); + const onboardingFirstAgentBundle = await resolveOnboardingFirstAgentBundle({ + onboardingFirstAgent: createOnboardingFirstAgent, + actorType: req.actor.type, + agentName: createdAgent.name, + organizationName: company.name ?? null, + }); + const agent = await materializeDefaultInstructionsBundleForNewAgent( + createdAgent, + onboardingFirstAgentBundle ?? instructionsBundle, + ); const actor = getActorInfo(req); await logActivity(db, { diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 955974bec8..203c6af59f 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -193,9 +193,13 @@ import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup. import { createSecretProposalsService } from "../services/secret-proposals.js"; import { notifySecretProposalResolution } from "../services/secret-proposal-notifications.js"; import { - buildOnboardingGreeting, + renderOnboardingGreeting, ONBOARDING_GREETING_AUTHORIZATION_REASON, } from "../services/onboarding-greeting.js"; +import { + buildOnboardingFirstTaskBrief, + buildOnboardingFirstTaskOpeningQuestion, +} from "../services/onboarding-first-task-assets.js"; import { ISSUE_BLOCKERS_RESOLVED_WAKE_REASON, buildIssueBlockersResolvedWakeStateKey, @@ -9322,6 +9326,23 @@ export function issueRoutes( const runWorkspaceInheritanceSourceIssueId = hasExplicitIssueWorkspaceCreateSelection(rawCreateBody) ? null : await resolveRunIssueWorkspaceInheritanceSource(companyId, actor); + // When this is genuinely the onboarding first task, the server owns the task + // description: assemble it from brief.md plus the proposal file the + // enableFirstTaskPlanProposal toggle selects, read once here at creation + // time, and ignore any client-supplied description. Flipping the toggle + // later does not change an existing first task. Best-effort: a read failure + // must not fail issue creation. + let onboardingFirstTaskDescription: string | null = null; + if (isOnboardingFirstTask && !watchdogProductBugFollowUp) { + try { + const experimental = await instanceSettings.getExperimental(); + onboardingFirstTaskDescription = await buildOnboardingFirstTaskBrief({ + usePlanProposal: experimental.enableFirstTaskPlanProposal === true, + }); + } catch (err) { + logger.warn({ err, companyId }, "failed to assemble onboarding first-task brief"); + } + } const createBody = { ...rawCreateBody, parentId: effectiveParentId, @@ -9330,7 +9351,12 @@ export function issueRoutes( ? { inheritExecutionWorkspaceFromIssueId: runWorkspaceInheritanceSourceIssueId } : {}), ...(isOnboardingFirstTask && !watchdogProductBugFollowUp - ? { originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND } + ? { + originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND, + ...(onboardingFirstTaskDescription !== null + ? { description: onboardingFirstTaskDescription } + : {}), + } : {}), ...(watchdogProductBugFollowUp ? { @@ -9520,15 +9546,13 @@ export function issueRoutes( // best-effort: a greeting failure must not fail issue creation. if (isOnboardingFirstTask && issue.assigneeAgentId) { try { - const [company, goal, assigneeAgent] = await Promise.all([ + const [company, assigneeAgent] = await Promise.all([ companiesSvc.getById(companyId), - createBody.goalId ? goalsSvc.getById(createBody.goalId) : Promise.resolve(null), agentsSvc.getById(issue.assigneeAgentId), ]); - const greetingBody = buildOnboardingGreeting({ + const greetingBody = await renderOnboardingGreeting({ agentName: assigneeAgent?.name ?? null, - teamName: company?.name ?? null, - goals: goal?.description ?? goal?.title ?? null, + organizationName: company?.name ?? null, }); await svc.addComment( issue.id, @@ -9545,17 +9569,47 @@ export function issueRoutes( "failed to seed onboarding first-task greeting", ); } + + // Seed the opening question card right after the greeting so the first + // task is not open-ended: "Interview me and propose a plan and an agent + // team" or "I have a task in mind" (free text). Posted as the assignee, + // deterministic (no LLM), and best-effort like the greeting. Answering + // the card wakes the assignee through the normal question-response path; + // typing a message instead supersedes the card and wakes on the comment. + try { + await issueThreadInteractionService(db).create( + issue, + { + kind: "ask_user_questions", + idempotencyKey: `onboarding-first-task:${issue.id}:opening-question`, + continuationPolicy: "wake_assignee", + payload: await buildOnboardingFirstTaskOpeningQuestion(), + }, + { agentId: issue.assigneeAgentId }, + ); + } catch (err) { + logger.warn( + { err, issueId: issue.id, companyId }, + "failed to seed onboarding first-task opening question", + ); + } } - void queueIssueAssignmentWakeup({ - heartbeat, - issue, - reason: "issue_assigned", - mutation: "create", - contextSource: "issue.create", - requestedByActorType: actor.actorType, - requestedByActorId: actor.actorId, - }); + // Do not auto-wake the onboarding first task. Nothing should run and no + // token should be spent until the user types: the greeting is posted above + // (deterministic, no LLM) and the user's first comment wakes the assignee + // through the normal comment path. Every other create path keeps its wake. + if (!isOnboardingFirstTask) { + void queueIssueAssignmentWakeup({ + heartbeat, + issue, + reason: "issue_assigned", + mutation: "create", + contextSource: "issue.create", + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + }); + } await queueTaskWatchdogEvaluation(issue, actor.runId); res.status(201).json({ diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index b695455612..37879521cc 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -27,7 +27,11 @@ import { type PatchInstanceSettings, type PatchInstanceExperimentalSettings, } from "@paperclipai/shared"; -import { applyOperatorGeneralDefaults, stripOperatorGeneralEchoes } from "@paperclipai/shared"; +import { + INSTANCE_FEATURE_CATALOG, + applyOperatorGeneralDefaults, + stripOperatorGeneralEchoes, +} from "@paperclipai/shared"; import { eq } from "drizzle-orm"; import { getManagedInstanceConfig, type ManagedInstanceConfig } from "./managed-config.js"; import { getOperatorSettingDefaults } from "./setting-defaults.js"; @@ -220,7 +224,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta if (parsed.success) { return { enableEnvironments: parsed.data.enableEnvironments ?? false, - enableNativeRunner: parsed.data.enableNativeRunner ?? false, + enableNativeRunner: parsed.data.enableNativeRunner ?? true, enableManagedSandboxOnly: parsed.data.enableManagedSandboxOnly ?? false, enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false, enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? true, @@ -245,6 +249,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableServerInfoDebugView: parsed.data.enableServerInfoDebugView ?? false, enablePaperclipDeveloperMode: parsed.data.enablePaperclipDeveloperMode ?? false, enableSimplifiedEnglishInteractions: parsed.data.enableSimplifiedEnglishInteractions ?? false, + enableFirstTaskPlanProposal: parsed.data.enableFirstTaskPlanProposal ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, enableWorkspaceBranchReconcileForward: parsed.data.enableWorkspaceBranchReconcileForward ?? true, enableWorkspaceDirtyQuarantineRepair: parsed.data.enableWorkspaceDirtyQuarantineRepair ?? true, @@ -259,7 +264,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta } return { enableEnvironments: false, - enableNativeRunner: false, + enableNativeRunner: true, enableManagedSandboxOnly: false, enableIsolatedWorkspaces: false, enableStreamlinedLeftNavigation: true, @@ -282,6 +287,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableServerInfoDebugView: false, enablePaperclipDeveloperMode: false, enableSimplifiedEnglishInteractions: false, + enableFirstTaskPlanProposal: false, autoRestartDevServerWhenIdle: false, enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: true, @@ -327,6 +333,82 @@ export function applyManagedExperimentalOverlay( return { experimental: next, managedKeys }; } +/** + * Keep self-hosted-only defaults out of Cloud. + * + * The experimental schema carries one default per flag, and the feature + * catalog pins it to `selfHostedDefault`. A flag that is on by default for + * self-hosted but off by default for Cloud (`selfHostedDefault: true`, + * `cloudDefault: false`) would therefore normalize to "on" for a managed + * instance whose tenant row and managed overlay both leave it unset. Re-assert + * the declared Cloud default for exactly those flags. An explicit tenant value + * or a managed feature value still wins (the overlay is applied afterwards). + */ +export function applyCloudCatalogDefaults( + experimental: InstanceExperimentalSettings, + rawStored: unknown, + managedConfig: ManagedInstanceConfig | null, +): InstanceExperimentalSettings { + if (!managedConfig) return experimental; + const stored = + rawStored && typeof rawStored === "object" && !Array.isArray(rawStored) + ? (rawStored as Record) + : {}; + const next: InstanceExperimentalSettings = { ...experimental }; + for (const [key, entry] of Object.entries(INSTANCE_FEATURE_CATALOG)) { + if (entry.cloudDefault !== false || entry.selfHostedDefault !== true) continue; + if (typeof stored[key] === "boolean") continue; + if (typeof managedConfig.features[key as ManagedExperimentalFeatureKey] === "boolean") continue; + (next as unknown as Record)[key] = false; + } + return next; +} + +/** + * Keep the write path from freezing a self-hosted default into a Cloud row. + * + * `updateExperimental` persists the whole normalized object, and the schema + * normalizes an omitted flag to its self-hosted default. Without this step an + * unrelated experimental write (say, turning on pipelines) would store + * `enableNativeRunner: true` on a managed instance whose tenant row had never + * mentioned the flag; every later read would then treat the stored boolean as + * an explicit tenant choice and stop re-asserting the Cloud default. + * + * For each guarded flag (see `applyCloudCatalogDefaults`), the stored key is + * left absent unless the tenant already stored a boolean or this patch sets + * the flag to something other than the Cloud default. A patch value equal to + * the Cloud default is a full-GET echo of the read-time overlay, not a + * choice, and is stripped the same way `stripOperatorGeneralEchoes` treats + * operator defaults. Self-hosted rows are returned untouched. + */ +export function stripCloudCatalogDefaultEchoes( + rawStored: unknown, + patch: PatchInstanceExperimentalSettings | Record, + next: InstanceExperimentalSettings, + managedConfig: ManagedInstanceConfig | null, +): Partial { + if (!managedConfig) return next; + const stored = + rawStored && typeof rawStored === "object" && !Array.isArray(rawStored) + ? (rawStored as Record) + : {}; + const patchRecord = patch as Record; + const result: Record = { ...next }; + for (const [key, entry] of Object.entries(INSTANCE_FEATURE_CATALOG)) { + if (entry.cloudDefault !== false || entry.selfHostedDefault !== true) continue; + if (typeof stored[key] === "boolean") continue; + if ( + Object.prototype.hasOwnProperty.call(patchRecord, key) && + typeof patchRecord[key] === "boolean" && + patchRecord[key] !== entry.cloudDefault + ) { + continue; + } + delete result[key]; + } + return result as Partial; +} + export function instanceSettingsService(db: Db, options: InstanceSettingsServiceOptions = {}) { // Fail closed: a malformed PAPERCLIP_MANAGED_CONFIG throws here (and at // boot in index.ts) rather than silently running without the overlay. @@ -343,7 +425,7 @@ export function instanceSettingsService(db: Db, options: InstanceSettingsService function toExperimentalView(raw: unknown): InstanceExperimentalSettingsWithManaged { const { experimental, managedKeys } = applyManagedExperimentalOverlay( - normalizeExperimentalSettings(raw), + applyCloudCatalogDefaults(normalizeExperimentalSettings(raw), raw, managedConfig), managedConfig, ); // Self-hosted responses stay byte-identical: no managedKeys field at all. @@ -460,7 +542,14 @@ export function instanceSettingsService(db: Db, options: InstanceSettingsService updateExperimental: async (patch: PatchInstanceExperimentalSettings): Promise => { const current = await getOrCreateRow(); - const nextExperimental = applyExperimentalSettingsPatch(current.experimental, patch, options); + // Guarded Cloud flags stay absent from the row unless chosen, so the + // read-time catalog default keeps applying (see stripCloudCatalogDefaultEchoes). + const nextExperimental = stripCloudCatalogDefaultEchoes( + current.experimental, + patch, + applyExperimentalSettingsPatch(current.experimental, patch, options), + managedConfig, + ); const now = new Date(); const [updated] = await db .update(instanceSettings) diff --git a/server/src/services/onboarding-first-task-assets.test.ts b/server/src/services/onboarding-first-task-assets.test.ts new file mode 100644 index 0000000000..aaa2495f9a --- /dev/null +++ b/server/src/services/onboarding-first-task-assets.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { + ONBOARDING_FIRST_TASK_OPENING_INTERVIEW_OPTION_ID, + ONBOARDING_FIRST_TASK_OPENING_QUESTION_ID, + ONBOARDING_FIRST_TASK_OPENING_TASK_OPTION_ID, + buildOnboardingFirstTaskBrief, + buildOnboardingFirstTaskOpeningQuestion, + buildOnboardingFirstAgentInstructionsBundle, + fillFirstTaskPlaceholders, + renderChiefOfStaffPersona, + renderOnboardingFirstTaskGreeting, +} from "./onboarding-first-task-assets.js"; + +describe("fillFirstTaskPlaceholders", () => { + it("fills the name and organization when present", () => { + const out = fillFirstTaskPlaceholders( + "I'm {{agentName}}, chief of staff for {{organizationName}}.", + { agentName: "Ada", organizationName: "Acme" }, + ); + expect(out).toBe("I'm Ada, chief of staff for Acme."); + }); + + it("drops the name and its trailing separator when no name is set", () => { + const out = fillFirstTaskPlaceholders("I'm {{agentName}}, your first agent teammate.", { + agentName: null, + }); + expect(out).toBe("I'm your first agent teammate."); + }); + + it("falls back to a generic organization label when missing", () => { + const out = fillFirstTaskPlaceholders("for {{organizationName}}.", {}); + expect(out).toBe("for your organization."); + }); +}); + +describe("renderOnboardingFirstTaskGreeting", () => { + it("renders the board-approved greeting with the agent name", async () => { + const greeting = await renderOnboardingFirstTaskGreeting({ agentName: "Ada" }); + expect(greeting).toContain("Welcome to Paperclip! I'm Ada, your first agent teammate."); + // The "what would you like to do" question moved onto the opening card. + expect(greeting).not.toContain("What would you like to do?"); + }); +}); + +describe("buildOnboardingFirstTaskOpeningQuestion", () => { + it("builds the two-option opening card with a free-text task option", async () => { + const payload = await buildOnboardingFirstTaskOpeningQuestion(); + expect(payload.version).toBe(1); + expect(payload.supersedeOnUserComment).toBe(true); + expect(payload.submitLabel).toBe("Continue"); + expect(payload.questions).toHaveLength(1); + const [question] = payload.questions; + expect(question.id).toBe(ONBOARDING_FIRST_TASK_OPENING_QUESTION_ID); + expect(question.selectionMode).toBe("single"); + expect(question.required).toBe(true); + expect(question.prompt).toBe("What would you like to do?"); + expect(question.options.map((option) => option.id)).toEqual([ + ONBOARDING_FIRST_TASK_OPENING_INTERVIEW_OPTION_ID, + ONBOARDING_FIRST_TASK_OPENING_TASK_OPTION_ID, + ]); + expect(question.options[0].label).toBe( + "Interview me and propose a plan and an agent team to execute it.", + ); + expect(question.options[0].freeText).toBeUndefined(); + expect(question.options[1].label).toBe("I have a task in mind"); + expect(question.options[1].freeText).toBe(true); + }); +}); + +describe("buildOnboardingFirstTaskBrief", () => { + it("assembles the brief with the confirmation proposal when the toggle is off", async () => { + const brief = await buildOnboardingFirstTaskBrief({ usePlanProposal: false }); + expect(brief).toContain("This is the user's first task in Paperclip."); + // Step 1 branches on the opening card's two option ids. + expect(brief).toContain("Take the path the user picked."); + expect(brief).toContain(`\`${ONBOARDING_FIRST_TASK_OPENING_INTERVIEW_OPTION_ID}\` →`); + expect(brief).toContain(`\`${ONBOARDING_FIRST_TASK_OPENING_TASK_OPTION_ID}\` →`); + // The confirmation form is inlined at the {{proposalStep}} slot. + expect(brief).toContain("post ONE request_confirmation that says, in a few lines"); + expect(brief).not.toContain("{{proposalStep}}"); + // The plan-form-only wording must not appear. + expect(brief).not.toContain("treat it like the plan path"); + }); + + it("assembles the brief with the plan proposal when the toggle is on", async () => { + const brief = await buildOnboardingFirstTaskBrief({ usePlanProposal: true }); + expect(brief).toContain("treat it like the plan path"); + expect(brief).not.toContain("post ONE request_confirmation that says, in a few lines"); + expect(brief).not.toContain("{{proposalStep}}"); + }); +}); + +describe("chief-of-staff persona", () => { + it("renders the persona with placeholders filled", async () => { + const persona = await renderChiefOfStaffPersona({ + agentName: "Ada", + organizationName: "Acme", + }); + expect(persona).toContain("You are Ada, chief of staff for Acme."); + expect(persona).toContain("# Hiring and delegation"); + expect(persona).not.toContain("{{agentName}}"); + expect(persona).not.toContain("{{organizationName}}"); + }); + + it("returns an AGENTS.md-keyed bundle for the first agent", async () => { + const bundle = await buildOnboardingFirstAgentInstructionsBundle({ + agentName: "Ada", + organizationName: "Acme", + }); + expect(bundle.entryFile).toBe("AGENTS.md"); + expect(bundle.files["AGENTS.md"]).toContain("You are Ada, chief of staff for Acme."); + }); +}); diff --git a/server/src/services/onboarding-first-task-assets.ts b/server/src/services/onboarding-first-task-assets.ts new file mode 100644 index 0000000000..0e4c0a3a0f --- /dev/null +++ b/server/src/services/onboarding-first-task-assets.ts @@ -0,0 +1,155 @@ +import fs from "node:fs/promises"; +import { z } from "zod"; +import { + askUserQuestionsPayloadSchema, + askUserQuestionsQuestionOptionSchema, + type AskUserQuestionsPayload, +} from "@paperclipai/shared"; + +// Everything the onboarding first agent is told lives as plain markdown under +// server/src/onboarding-assets/first-task/ so the board can edit the wording +// without touching TypeScript. These loaders read those files at runtime the +// same way loadDefaultAgentInstructionsBundle reads default/ and ceo/ (the build +// copies src/onboarding-assets/. into dist/onboarding-assets/), and fill the +// {{agentName}} / {{organizationName}} / {{proposalStep}} placeholders. + +export interface OnboardingFirstTaskPlaceholders { + agentName?: string | null; + organizationName?: string | null; +} + +// The opening card seeded on the first task right after the greeting: one +// single-select question with two options, "interview me" or "I have a task in +// mind" (free text). The brief refers to these ids, so they are fixed here; +// only the wording lives in opening-question.json. +export const ONBOARDING_FIRST_TASK_OPENING_QUESTION_ID = "first-task-opening"; +export const ONBOARDING_FIRST_TASK_OPENING_INTERVIEW_OPTION_ID = "interview"; +export const ONBOARDING_FIRST_TASK_OPENING_TASK_OPTION_ID = "task"; + +const openingQuestionFileSchema = z.object({ + prompt: z.string().trim().min(1).max(4000), + helpText: z.string().trim().max(4000).nullable().optional(), + submitLabel: z.string().trim().max(120).nullable().optional(), + options: z.array(askUserQuestionsQuestionOptionSchema).length(2), +}).superRefine((value, ctx) => { + const ids = value.options.map((option) => option.id); + if (!ids.includes(ONBOARDING_FIRST_TASK_OPENING_INTERVIEW_OPTION_ID)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `opening-question.json must keep an option with id "${ONBOARDING_FIRST_TASK_OPENING_INTERVIEW_OPTION_ID}"`, + path: ["options"], + }); + } + const taskOption = value.options.find((option) => option.id === ONBOARDING_FIRST_TASK_OPENING_TASK_OPTION_ID); + if (!taskOption) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `opening-question.json must keep an option with id "${ONBOARDING_FIRST_TASK_OPENING_TASK_OPTION_ID}"`, + path: ["options"], + }); + } else if (taskOption.freeText !== true) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `the "${ONBOARDING_FIRST_TASK_OPENING_TASK_OPTION_ID}" option must set freeText: true so the user can describe their task`, + path: ["options"], + }); + } +}); + +function resolveFirstTaskAssetUrl(relativePath: string) { + return new URL(`../onboarding-assets/first-task/${relativePath}`, import.meta.url); +} + +async function loadFirstTaskAsset(relativePath: string): Promise { + return fs.readFile(resolveFirstTaskAssetUrl(relativePath), "utf8"); +} + +// Fill the shared name/organization placeholders. When the agent has no name the +// greeting must read "I'm your first agent teammate" rather than leaving a gap, +// so we drop the placeholder together with its trailing separator — matching the +// historical buildOnboardingGreeting behaviour. +export function fillFirstTaskPlaceholders( + text: string, + { agentName, organizationName }: OnboardingFirstTaskPlaceholders, +): string { + let out = text; + const name = agentName?.trim(); + if (name) { + out = out.split("{{agentName}}").join(name); + } else { + out = out + .split("{{agentName}}, ").join("") + .split("{{agentName}} ").join("") + .split("{{agentName}}").join(""); + } + const org = organizationName?.trim(); + out = out.split("{{organizationName}}").join(org && org.length > 0 ? org : "your organization"); + return out; +} + +// Layer C — the deterministic greeting posted as the agent on the first task. +export async function renderOnboardingFirstTaskGreeting( + placeholders: OnboardingFirstTaskPlaceholders, +): Promise { + const template = await loadFirstTaskAsset("greeting.md"); + return fillFirstTaskPlaceholders(template, placeholders).trim(); +} + +// Layer C — the opening ask_user_questions card seeded as the agent right after +// the greeting, so the first task is not open-ended: the user either asks to be +// interviewed or types the task they have in mind. Deterministic, no LLM. +export async function buildOnboardingFirstTaskOpeningQuestion(): Promise { + const raw = await loadFirstTaskAsset("opening-question.json"); + const file = openingQuestionFileSchema.parse(JSON.parse(raw)); + return askUserQuestionsPayloadSchema.parse({ + version: 1, + submitLabel: file.submitLabel ?? null, + // A typed message instead of an answer still counts as the user's choice: + // the card expires and the comment wakes the agent through the normal path. + supersedeOnUserComment: true, + questions: [ + { + id: ONBOARDING_FIRST_TASK_OPENING_QUESTION_ID, + prompt: file.prompt, + helpText: file.helpText ?? null, + selectionMode: "single", + required: true, + options: file.options, + }, + ], + }); +} + +// Layer A — the first task's description. brief.md carries {{proposalStep}}, +// which is replaced by the proposal file the toggle selects. +export async function buildOnboardingFirstTaskBrief(options: { + usePlanProposal: boolean; +}): Promise { + const [brief, proposal] = await Promise.all([ + loadFirstTaskAsset("brief.md"), + loadFirstTaskAsset(options.usePlanProposal ? "proposal-plan.md" : "proposal-confirmation.md"), + ]); + const proposalStep = proposal.replace(/\s+$/, ""); + // Use a function replacement so `$` sequences in the proposal text are not + // interpreted as replacement patterns. + return brief.replace("{{proposalStep}}", () => proposalStep).trim(); +} + +// Layer B — the chief-of-staff persona seeded over the first agent's entry +// instruction file at hire time. +export async function renderChiefOfStaffPersona( + placeholders: OnboardingFirstTaskPlaceholders, +): Promise { + const template = await loadFirstTaskAsset("chief-of-staff/AGENTS.md"); + return fillFirstTaskPlaceholders(template, placeholders); +} + +// The instruction bundle for the onboarding first agent: the chief-of-staff +// persona as the entry AGENTS.md. The generic execution contract +// (default/AGENTS.md) is still appended on every run by the runner, unchanged. +export async function buildOnboardingFirstAgentInstructionsBundle( + placeholders: OnboardingFirstTaskPlaceholders, +): Promise<{ files: Record; entryFile: string }> { + const persona = await renderChiefOfStaffPersona(placeholders); + return { files: { "AGENTS.md": persona }, entryFile: "AGENTS.md" }; +} diff --git a/server/src/services/onboarding-greeting.test.ts b/server/src/services/onboarding-greeting.test.ts index 4bb5f2550e..a3a1ff5289 100644 --- a/server/src/services/onboarding-greeting.test.ts +++ b/server/src/services/onboarding-greeting.test.ts @@ -1,43 +1,39 @@ import { describe, expect, it } from "vitest"; -import { buildOnboardingGreeting } from "./onboarding-greeting.js"; +import { renderOnboardingGreeting } from "./onboarding-greeting.js"; -describe("buildOnboardingGreeting", () => { - it("introduces the agent by name as the user's first teammate and reflects the goals", () => { - const greeting = buildOnboardingGreeting({ +describe("renderOnboardingGreeting", () => { + it("introduces the agent by name as the user's first teammate", async () => { + const greeting = await renderOnboardingGreeting({ agentName: "Nova", - teamName: "Acme", - goals: "Launch a marketplace for local makers.", + organizationName: "Acme", }); expect(greeting).toContain( - "Welcome! I'm Nova, your first agent teammate on Paperclip.", + "Welcome to Paperclip! I'm Nova, your first agent teammate.", ); - expect(greeting).toContain("Here's what I understand you're aiming for:"); - expect(greeting).toContain("> Launch a marketplace for local makers."); - expect(greeting).toContain("propose a team of agents"); - expect(greeting).toContain("few focused questions"); - }); - - it("falls back to a generic teammate intro when no agent name is set", () => { - const greeting = buildOnboardingGreeting({ agentName: null, goals: null }); - - expect(greeting).toContain( - "Welcome! I'm your first agent teammate on Paperclip.", - ); - }); - - it("collapses whitespace in the reflected goals", () => { - const greeting = buildOnboardingGreeting({ - goals: " Build\n\n a SaaS product. ", - }); - - expect(greeting).toContain("> Build a SaaS product."); - }); - - it("omits the reflect-back block when no goals are provided", () => { - const greeting = buildOnboardingGreeting({ agentName: "Nova", goals: null }); - + // No goal quote and no "give me one moment" — the agent is not about to run. expect(greeting).not.toContain("aiming for"); - expect(greeting).toContain("propose a team of agents"); + expect(greeting).not.toContain("one moment"); + // The "what would you like to do" ask moved to the opening card; the + // greeting only points at it. + expect(greeting).toContain("Pick how you'd like to start"); + }); + + it("drops the name gracefully when no agent name is set", async () => { + const greeting = await renderOnboardingGreeting({ + agentName: null, + organizationName: "Acme", + }); + + expect(greeting).toContain( + "Welcome to Paperclip! I'm your first agent teammate.", + ); + expect(greeting).not.toContain("{{agentName}}"); + }); + + it("trims whitespace/blank names to the no-name phrasing", async () => { + const greeting = await renderOnboardingGreeting({ agentName: " " }); + + expect(greeting).toContain("I'm your first agent teammate."); }); }); diff --git a/server/src/services/onboarding-greeting.ts b/server/src/services/onboarding-greeting.ts index 4c4ae5dc5e..83dd6a7b13 100644 --- a/server/src/services/onboarding-greeting.ts +++ b/server/src/services/onboarding-greeting.ts @@ -1,39 +1,23 @@ // Deterministic, template-driven greeting seeded as an agent-authored comment on -// the onboarding first task. No LLM call: it reflects back the onboarding context -// (team name + goals) so the user lands on a waiting greeting instead of a -// right-aligned "user" bubble showing the agent's own seeded instructions. +// the onboarding first task. No LLM call: the server posts a fixed welcome from +// greeting.md so the user lands on a waiting greeting instead of a right-aligned +// "user" bubble showing the agent's own seeded instructions. +// +// The wording lives in server/src/onboarding-assets/first-task/greeting.md so the +// board can edit it without touching TypeScript; this module only fills the +// {{agentName}} / {{organizationName}} placeholders (see +// onboarding-first-task-assets.ts). + +import { renderOnboardingFirstTaskGreeting } from "./onboarding-first-task-assets.js"; export const ONBOARDING_GREETING_AUTHORIZATION_REASON = "onboarding first-task greeting"; -export function buildOnboardingGreeting(input: { +export async function renderOnboardingGreeting(input: { agentName?: string | null; - teamName?: string | null; - goals?: string | null; -}): string { - const agentName = input.agentName?.trim(); - const goals = input.goals?.replace(/\s+/g, " ").trim(); - - // Introduce the agent by the name the user chose in onboarding when we have - // it, so the first message reads as coming from *their* first teammate rather - // than a generic agent. Fall back to the generic phrasing otherwise. - const identity = agentName - ? `Welcome! I'm ${agentName}, your first agent teammate on Paperclip.` - : "Welcome! I'm your first agent teammate on Paperclip."; - - const lines: string[] = []; - lines.push(identity); - - if (goals) { - lines.push(""); - lines.push("Here's what I understand you're aiming for:"); - lines.push(""); - lines.push(`> ${goals}`); - } - - lines.push(""); - lines.push( - "I want to gather more context so I can come up with a plan and propose a team of agents to help execute it. I'm putting together a few focused questions so we can settle on a concrete goal to tackle first. Please give me one moment...", - ); - - return lines.join("\n"); + organizationName?: string | null; +}): Promise { + return renderOnboardingFirstTaskGreeting({ + agentName: input.agentName, + organizationName: input.organizationName, + }); } diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 06c1cf2cbe..97cb839c4d 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -1,6 +1,7 @@ import { and, asc, desc, eq, gt, gte, inArray, isNull, notInArray, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { + ONBOARDING_FIRST_TASK_ORIGIN_KIND, PROVIDER_QUOTA_MONITOR_SERVICE_NAME, ISSUE_DISPOSITION_REPAIR_RETRY_REASON, type IssueCommentMetadata, @@ -1101,6 +1102,45 @@ export function recoveryService( }); } + // The onboarding first task (origin `onboarding_first_task`) is created with + // its greeting pre-seeded and *no* assignment wake on purpose: the product + // contract is that nothing runs until the user types. Until a user-authored + // comment exists on it, the issue is intentionally idle rather than stranded. + async function isOnboardingFirstTaskAwaitingUser(issue: typeof issues.$inferSelect) { + if (issue.originKind !== ONBOARDING_FIRST_TASK_ORIGIN_KIND) return false; + const userComment = await db + .select({ id: issueComments.id }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, issue.companyId), + eq(issueComments.issueId, issue.id), + or( + eq(issueComments.authorType, "user"), + and(isNull(issueComments.authorType), sql`${issueComments.authorUserId} is not null`), + ), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + if (userComment !== null) return false; + // Answering the seeded opening card ("interview me" / "I have a task in + // mind") is the user's first input too, even though it is not a comment. + const userResolvedInteraction = await db + .select({ id: issueThreadInteractions.id }) + .from(issueThreadInteractions) + .where( + and( + eq(issueThreadInteractions.companyId, issue.companyId), + eq(issueThreadInteractions.issueId, issue.id), + sql`${issueThreadInteractions.resolvedByUserId} is not null`, + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + return userResolvedInteraction === null; + } + async function isInvocationBudgetBlocked(issue: typeof issues.$inferSelect, agentId: string) { const budgetBlock = await budgets.getInvocationBlock(issue.companyId, agentId, { issueId: issue.id, @@ -2883,6 +2923,7 @@ export function recoveryService( providerQuotaMonitored: 0, recentProgressExempted: 0, operatorCancelExempted: 0, + onboardingFirstTaskExempted: 0, skipped: 0, issueIds: [] as string[], }; @@ -3386,6 +3427,17 @@ export function recoveryService( if (issue.status === "todo") { if (!latestRun) { + // The onboarding first task is deliberately created without a wake: + // nothing runs and no token is spent until the user types. It is not + // stranded work, so liveness dispatch must leave it alone until a + // user comment exists (that comment wakes the assignee through the + // normal comment path, and only then may recovery treat a lost wake + // as stranded). + if (await isOnboardingFirstTaskAwaitingUser(issue)) { + result.onboardingFirstTaskExempted += 1; + continue; + } + if (await hasQueuedIssueWake(issue.companyId, issue.id)) { result.skipped += 1; continue; diff --git a/tests/e2e/nux-phase4-screenshots.spec.ts b/tests/e2e/nux-phase4-screenshots.spec.ts index 6b6f5ec521..1e8994508b 100644 --- a/tests/e2e/nux-phase4-screenshots.spec.ts +++ b/tests/e2e/nux-phase4-screenshots.spec.ts @@ -10,13 +10,15 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); * * Boots a throwaway local_trusted instance (see playwright.config.ts webServer) * and captures screenshots of every surface integrated by NUX Phases 1–3: - * - "Build a new company" step 1 (company name) + step 2 (mission) + * - "Build a new company" step 1 (company name) * - Team-lead hire step (capsule wizard, PAP-125) - * - Onboarding front door (path picker) - * - "Add agents to your org" growth intake * - Conference Room (BoardChat) shell + composer + activity feed * - Artifacts page * + * The onboarding front door and the "Add agents to your org" growth intake + * were removed with the four-step wizard, so the shots that captured them + * are gone too. + * * These are structural/rendering checks — LLM-dependent streaming (CEO chat * responses, hiring-plan generation) is verified separately on an LLM-backed * instance. Screenshots land in ./nux-phase4-shots for upload as evidence. @@ -57,14 +59,8 @@ test.describe("NUX Phase 4 visual QA", () => { const baseUrl = "http://127.0.0.1:" + (process.env.PAPERCLIP_E2E_PORT ?? "3199"); - // ── Section A: create-company path (name → mission → hire) ──────────── + // ── Section A: create-company path (name → hire) ────────────────────── await openWizard(page); - // Front door shows when the wizard doesn't open directly on the create - // path (e.g. another spec already created a company on this instance). - const createCard = page.getByRole("button", { name: /Build a new organization/ }); - if (await createCard.count()) { - await createCard.first().click(); - } await expect( page.getByRole("heading", { name: "What is the name of your organization?" }), ).toBeVisible({ timeout: 15_000 }); @@ -89,46 +85,7 @@ test.describe("NUX Phase 4 visual QA", () => { expect(qaCompany, "wizard should have created QA Robotics").toBeTruthy(); const prefix: string = qaCompany.issuePrefix; - // ── Section B: front door + growth intake ───────────────────────────── - await page.evaluate(() => window.localStorage.clear()); - await openWizard(page); - // Reach the full-screen front door (step 0): either it shows directly or - // the naming step's Back returns to it. - // - // That control used to be a "← Back to start" text link. The naming step now - // wears the same footer pair as the steps after it, so its Back is labelled - // like theirs — it still lands on the front door, because the front door is - // what sits behind step 1. - // - // Exact, because the progress strip's segments are buttons with their own - // labels and an unanchored /Back/ would match more than one. - if (!(await page.getByRole("heading", { name: "Welcome to Paperclip" }).count())) { - await page.getByRole("button", { name: "Back", exact: true }).click(); - } - await expect( - page.getByRole("heading", { name: "Welcome to Paperclip" }), - ).toBeVisible({ timeout: 10_000 }); - await expect( - page.getByRole("heading", { name: "Build a new organization" }), - ).toBeVisible(); - await expect( - page.getByRole("heading", { name: "Add agents to your org" }), - ).toBeVisible(); - await page.screenshot({ path: shot("01-front-door.png") }); - - await page.getByRole("button", { name: /Add agents to your org/ }).click(); - // The grow path shares step 1 (company name) before its step-2 intake. - await expect( - page.getByRole("heading", { name: "What is the name of your organization?" }), - ).toBeVisible({ timeout: 10_000 }); - await page.getByPlaceholder("e.g. Northwind Labs").fill("QA Robotics Grow"); - await page.getByRole("button", { name: /^Continue/ }).click(); - await expect( - page.getByRole("heading", { name: /Tell us about your team/ }), - ).toBeVisible({ timeout: 10_000 }); - await page.screenshot({ path: shot("05-growth-intake.png") }); - - // ── Section C: Conference Room (BoardChat) ──────────────────────────── + // ── Section B: Conference Room (BoardChat) ──────────────────────────── // Visit the company dashboard first so CompanyContext selects the company // from the route before we land on the board-chat surface. await page.evaluate(() => window.localStorage.clear()); @@ -144,7 +101,7 @@ test.describe("NUX Phase 4 visual QA", () => { await page.waitForTimeout(2_000); // let welcome bubble + suggestion chips stage in await page.screenshot({ path: shot("06-board-chat.png") }); - // ── Section D: Artifacts ────────────────────────────────────────────── + // ── Section C: Artifacts ────────────────────────────────────────────── await page.goto(`/${prefix}/artifacts`); await expect(page).toHaveURL(new RegExp(`/${prefix}/artifacts`)); await page.waitForLoadState("networkidle"); @@ -152,10 +109,8 @@ test.describe("NUX Phase 4 visual QA", () => { await page.screenshot({ path: shot("07-artifacts.png") }); for (const f of [ - "01-front-door.png", "02-create-name.png", "04-hire-team-lead.png", - "05-growth-intake.png", "06-board-chat.png", "07-artifacts.png", ]) { diff --git a/tests/e2e/planning-mode-visual-verification.spec.ts b/tests/e2e/planning-mode-visual-verification.spec.ts index 765f370f87..c51a94f834 100644 --- a/tests/e2e/planning-mode-visual-verification.spec.ts +++ b/tests/e2e/planning-mode-visual-verification.spec.ts @@ -8,6 +8,27 @@ import { const AGENT_NAME = "CEO"; const TASK_TITLE = "Paperclip onboarding"; +/** + * The first task opens with the chief of staff's opening card sitting where + * the composer is. Cancel hands the plain composer back (the card stays + * pending), and the composer is where the mode toggle lives. + * + * The card arrives with the interactions fetch, after the composer's first + * paint, so a bare `count()` right after navigation sees no card and skips + * the click; the card then lands on top of the composer and hides the mode + * toggle. Wait for the card (or, if it is already dismissed, the pending + * strip it leaves behind) before deciding, and only return once the plain + * composer is back. + */ +async function dismissOpeningCard(page: import("@playwright/test").Page) { + const takeover = page.getByTestId("task-chat-composer-takeover"); + const pendingStrip = page.getByTestId("task-chat-pending-input-indicator"); + await expect(takeover.or(pendingStrip).first()).toBeVisible({ timeout: 30_000 }); + const cancel = takeover.getByRole("button", { name: "Cancel", exact: true }); + if (await cancel.count()) await cancel.first().click(); + await expect(page.getByTestId("task-chat-composer-mode")).toBeVisible({ timeout: 30_000 }); +} + test("captures planning mode UI for desktop and mobile", async ({ page }) => { const timestamp = Date.now(); const companyName = `PAP-3413-${timestamp}`; @@ -131,6 +152,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => { await setMode("planning"); await page.goto(issuePath); + await dismissOpeningCard(page); await expect(page.getByText("Plan mode").first()).toBeVisible(); const desktopPlanningToggle = page.getByTestId("task-chat-composer-mode"); await expect(desktopPlanningToggle).toBeVisible(); @@ -150,6 +172,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => { }); await page.goto(issuePath); + await dismissOpeningCard(page); await page.getByTestId("task-chat-composer-mode").click(); await page.getByRole("menuitem", { name: /Auto mode/ }).click(); await expect(page.getByTestId("task-chat-composer-mode")).toHaveAttribute("data-pending-work-mode", "standard"); @@ -161,6 +184,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => { await setMode("planning"); await page.setViewportSize({ width: 390, height: 844 }); await page.goto(issuePath); + await dismissOpeningCard(page); await expect(page.getByText("Plan mode").first()).toBeVisible(); const mobilePlanningToggle = page.getByTestId("task-chat-composer-mode"); await expect(mobilePlanningToggle).toBeVisible(); diff --git a/ui/src/components/FrontDoor.tsx b/ui/src/components/FrontDoor.tsx deleted file mode 100644 index 9986ed2bdf..0000000000 --- a/ui/src/components/FrontDoor.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Rocket, Zap } from "lucide-react"; -import { cn } from "../lib/utils"; - -interface FrontDoorProps { - onChoose: (path: "create" | "grow") => void; -} - -export function FrontDoor({ onChoose }: FrontDoorProps) { - return ( -
-
-

- Welcome to Paperclip -

-

- How would you like to get started? -

-
- -
- - - -
-
- ); -} diff --git a/ui/src/components/OnboardingWizard.adapters.test.tsx b/ui/src/components/OnboardingWizard.adapters.test.tsx index 3d60d1ffff..ba56405627 100644 --- a/ui/src/components/OnboardingWizard.adapters.test.tsx +++ b/ui/src/components/OnboardingWizard.adapters.test.tsx @@ -96,7 +96,6 @@ vi.mock("../adapters/use-adapter-capabilities", () => ({ })); // Animation / canvas-ish children that add nothing to the logic under test. vi.mock("./AsciiArtAnimation", () => ({ AsciiArtAnimation: () => null })); -vi.mock("./FrontDoor", () => ({ FrontDoor: () => null })); vi.mock("./AgentCapsule", () => ({ AgentCapsule: () => null })); import { queryKeys } from "../lib/queryKeys"; diff --git a/ui/src/components/OnboardingWizard.step.test.tsx b/ui/src/components/OnboardingWizard.step.test.tsx index c35207b0a5..bc90ada613 100644 --- a/ui/src/components/OnboardingWizard.step.test.tsx +++ b/ui/src/components/OnboardingWizard.step.test.tsx @@ -6,10 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ApiError } from "../api/client"; import { queryKeys } from "../lib/queryKeys"; -import { - ONBOARDING_AGENT_STEP, - ONBOARDING_MISSION_STEP, -} from "../lib/onboarding-route"; +import { ONBOARDING_AGENT_STEP } from "../lib/onboarding-route"; /** * Which step the onboarding wizard *lands on*, and what is allowed to move it @@ -20,8 +17,8 @@ import { * guards lived in that seam rather than in either side of it — the pure * helpers in `onboarding-route.test.ts` passed while the wizard was moving a * customer off the step they were typing on. So the real component is rendered - * here, with the real route resolver and the real mission hook, and only the - * network and the surrounding contexts are stubbed. + * here, with the real route resolver, and only the network and the surrounding + * contexts are stubbed. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -36,6 +33,7 @@ const mockAdaptersApi = vi.hoisted(() => ({ list: vi.fn() })); const mockAgentsApi = vi.hoisted(() => ({ create: vi.fn(), adapterModels: vi.fn(), + list: vi.fn(), hire: vi.fn(), instructionsBundle: vi.fn(), saveInstructionsFile: vi.fn(), @@ -97,34 +95,19 @@ vi.mock("../context/CompanyContext", () => ({ // Canvas/animation leaves — nothing to do with the step machinery. vi.mock("./AsciiArtAnimation", () => ({ AsciiArtAnimation: () => null })); vi.mock("./AgentCapsule", () => ({ AgentCapsule: () => null })); -vi.mock("./FrontDoor", () => ({ FrontDoor: () => null })); const { OnboardingWizard } = await import("./OnboardingWizard"); -/** The mission step renders this heading; the agent step renders this input. */ -function currentStep(): "mission" | "agent" | "closed" | "other" { +/** The agent step renders this input; the org-name step ("other") does not. */ +function currentStep(): "agent" | "closed" | "other" { const body = document.body; if (!body.querySelector("[role='dialog'], .fixed.inset-0")) return "closed"; - const headings = [...body.querySelectorAll("h3")].map((h) => h.textContent); - if (headings.includes("Define your mission")) return "mission"; // Keyed on the name field, which is the agent step's only control now that // the role picker is gone. if (body.querySelector("#onboarding-agent-name")) return "agent"; return "other"; } -function confirmMissionButton(): HTMLButtonElement | null { - return ( - [...document.body.querySelectorAll("button")].find((button) => - button.textContent?.includes("Confirm mission"), - ) ?? null - ); -} - -function missionTextarea(): HTMLTextAreaElement | null { - return document.body.querySelector("textarea"); -} - /** Type into a controlled React input without a full user-event dependency. */ function setControlledValue(el: HTMLTextAreaElement | HTMLInputElement, value: string) { const prototype = @@ -198,6 +181,9 @@ describe("OnboardingWizard — which step it lands on", () => { mockAdaptersApi.list.mockResolvedValue([]); mockGoalsApi.list.mockResolvedValue([]); mockAgentsApi.adapterModels.mockResolvedValue([]); + // The hire step lists the company's agents first so it can adopt one that + // already carries the typed name instead of hiring a duplicate. + mockAgentsApi.list.mockResolvedValue([]); mockAgentsApi.hire.mockResolvedValue({ agent: { id: "agent-1" }, approval: null }); mockAgentsApi.instructionsBundle.mockResolvedValue({ entryFile: "AGENTS.md" }); mockAgentsApi.saveInstructionsFile.mockResolvedValue({}); @@ -338,24 +324,25 @@ describe("OnboardingWizard — which step it lands on", () => { it("does not move an open wizard when the dialog is re-opened with a new step", async () => { // The dashboard's auto-open sits behind queries too, so a refetch can call // `openOnboarding` again with a different step for the same company. The - // wizard belongs to the customer by then. + // wizard belongs to the customer by then, so the sync effect keys on the + // company: the same company re-deciding a fresher step must not move them. dialogState.onboardingOpen = true; - dialogState.onboardingOptions = { - companyId: "company-1", - initialStep: ONBOARDING_MISSION_STEP, - }; - await render(); - await settle(); - expect(currentStep()).toBe("mission"); - dialogState.onboardingOptions = { companyId: "company-1", initialStep: ONBOARDING_AGENT_STEP, }; + await render(); + await settle(); + expect(currentStep()).toBe("agent"); + + dialogState.onboardingOptions = { + companyId: "company-1", + initialStep: 5, + }; await rerender(); await settle(); - expect(currentStep()).toBe("mission"); + expect(currentStep()).toBe("agent"); }); it("re-decides the company when the route names a different one", async () => { @@ -373,368 +360,42 @@ describe("OnboardingWizard — which step it lands on", () => { expect(currentStep()).toBe("agent"); }); - describe("the mission step, reached with a company that already exists", () => { - // Nothing sent an existing company here until the dashboard started - // opening agentless ones on this step. Both defects below were reachable - // the moment it did. + it("withdraws a company the wizard created once the route stops naming it", async () => { + // The route only introduces a company when it names one the wizard is not + // already holding, so a company the wizard *created* was never recorded as + // route-owned and was never withdrawn. Visiting its own onboarding path and + // then `/onboarding` left the wizard showing "create an organization" while + // still holding it — and the next confirmation wrote into the old company. + mockCompaniesApi.create.mockResolvedValue({ id: "company-1", issuePrefix: "PC1" }); + mockGoalsApi.create.mockResolvedValue({ id: "goal-company-1" }); + routerState.pathname = "/onboarding"; + await render(); + await settle(); - async function openOnMissionStepForExistingCompany() { - dialogState.onboardingOpen = true; - dialogState.onboardingOptions = { - companyId: "company-1", - initialStep: ONBOARDING_MISSION_STEP, - }; - await render(); - await settle(); - expect(currentStep()).toBe("mission"); - } - - // The route no longer lands on the mission step — onboarding stopped - // asking — so a test that needs that step opens it the way the tenant app - // will when it collects the mission later: explicitly, naming the company. - // What these tests defend is unchanged: state written for one company must - // not survive into the next. - async function openMissionStepFor(companyId: string) { - dialogState.onboardingOpen = true; - dialogState.onboardingOptions = { - companyId, - initialStep: ONBOARDING_MISSION_STEP, - }; - await render(); - await settle(); - expect(currentStep()).toBe("mission"); - } - - async function click(el: Element) { - await act(async () => { - el.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - } - - it("names the company it is asking about, so the step can be completed", async () => { - // `companyName` is only ever typed on step 1. Without a backfill it is - // empty here, the step's own copy has a blank where the name goes, and - // "Confirm mission" stays disabled — a customer sent to this step could - // not leave it. - await openOnMissionStepForExistingCompany(); - - expect(document.body.textContent).toContain("Acme"); - - const direct = [...document.body.querySelectorAll("button")].find((b) => - b.textContent?.includes("I know my mission"), - )!; - await click(direct); - setControlledValue(missionTextarea()!, "Ship the thing"); - await settle(); - - expect(confirmMissionButton()?.disabled).toBe(false); + const nameInput = document.body.querySelector("input")! as HTMLInputElement; + setControlledValue(nameInput, "Acme"); + await settle(); + await act(async () => { + [...document.body.querySelectorAll("button")] + .find((b) => b.textContent?.trim() === "Continue")! + .dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + await settle(); + await settle(); + expect(mockCompaniesApi.create).toHaveBeenCalled(); + expect(currentStep()).toBe("agent"); - it("saves the mission it asked for", async () => { - // Confirming used to advance to the agent step and write nothing, so the - // company kept no mission — the exact state this change exists to remove. - mockGoalsApi.create.mockResolvedValue({ id: "goal-new" }); - await openOnMissionStepForExistingCompany(); + // Its own onboarding path, then back to the unprefixed one. + routerState.pathname = "/PC1/onboarding"; + await rerender(); + await settle(); + routerState.pathname = "/onboarding"; + await rerender(); + await settle(); - const direct = [...document.body.querySelectorAll("button")].find((b) => - b.textContent?.includes("I know my mission"), - )!; - await click(direct); - setControlledValue(missionTextarea()!, "Ship the thing"); - await settle(); - await click(confirmMissionButton()!); - await settle(); - - expect(mockGoalsApi.create).toHaveBeenCalledWith( - "company-1", - expect.objectContaining({ title: "Ship the thing", level: "company", status: "active" }), - ); - expect(currentStep()).toBe("agent"); - }); - - it("does not write a second mission when Enter is pressed twice", async () => { - // The buttons are all disabled while a request is in flight; the - // keyboard has to be too. A second Enter re-enters the handler before - // the first has set the goal id its own guard reads, so both requests - // see "no mission yet" and the company ends up with two. - let resolveCreate: (goal: { id: string }) => void = () => {}; - mockGoalsApi.create.mockReturnValue( - new Promise<{ id: string }>((resolve) => { - resolveCreate = resolve; - }), - ); - await openOnMissionStepForExistingCompany(); - - const direct = [...document.body.querySelectorAll("button")].find((b) => - b.textContent?.includes("I know my mission"), - )!; - await click(direct); - setControlledValue(missionTextarea()!, "Ship the thing"); - await settle(); - - const surface = document.body.querySelector(".fixed.inset-0.z-50.flex")!; - const submit = () => - act(async () => { - surface.dispatchEvent( - new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }), - ); - }); - await submit(); - await submit(); - await act(async () => resolveCreate({ id: "goal-new" })); - await settle(); - - expect(mockGoalsApi.create).toHaveBeenCalledTimes(1); - }); - - it("updates the mission it could not see, rather than adding a second", async () => { - // The cost of failing open. The lookup could not answer, so the customer - // was asked for a mission the company already had. Adding a goal would - // leave two active company-level goals, and the earlier one would keep - // winning `selectDefaultCompanyGoalId` outside this wizard — so the - // mission the customer just typed would lose. Their answer wins instead. - // The dashboard's lookup failed, which is why this company is on the - // mission step at all. By the time the customer confirms, the goal list - // reads — and it has a mission. - mockGoalsApi.list.mockResolvedValue([COMPANY_GOAL]); - mockGoalsApi.update.mockResolvedValue({ id: COMPANY_GOAL.id }); - await openOnMissionStepForExistingCompany(); - - const direct = [...document.body.querySelectorAll("button")].find((b) => - b.textContent?.includes("I know my mission"), - )!; - await click(direct); - setControlledValue(missionTextarea()!, "The mission they just typed"); - await settle(); - await click(confirmMissionButton()!); - await settle(); - - expect(mockGoalsApi.create).not.toHaveBeenCalled(); - expect(mockGoalsApi.update).toHaveBeenCalledWith( - COMPANY_GOAL.id, - expect.objectContaining({ title: "The mission they just typed" }), - ); - expect(currentStep()).toBe("agent"); - }); - - it("still writes the mission when the pre-write read also fails", async () => { - // Fail-open all the way down. If it cannot tell whether a mission - // exists, an unwritten mission is the worse error. - mockGoalsApi.list.mockRejectedValue(new Error("goals unavailable")); - mockGoalsApi.create.mockResolvedValue({ id: "goal-new" }); - await openOnMissionStepForExistingCompany(); - - const direct = [...document.body.querySelectorAll("button")].find((b) => - b.textContent?.includes("I know my mission"), - )!; - await click(direct); - setControlledValue(missionTextarea()!, "Ship the thing"); - await settle(); - await click(confirmMissionButton()!); - await settle(); - - expect(mockGoalsApi.create).toHaveBeenCalledWith( - "company-1", - expect.objectContaining({ title: "Ship the thing" }), - ); - expect(currentStep()).toBe("agent"); - }); - - it("does not carry a mission across a switch to another company", async () => { - // Confirming for one company sets the goal id that `handleConfirmMission` - // reads as "already written". Carried across a company switch it makes - // the next company skip saving its own mission, and the launch path then - // links that company's project to the previous company's goal. - mockGoalsApi.create.mockResolvedValue({ id: "goal-company-1" }); - await openMissionStepFor("company-1"); - - const direct = [...document.body.querySelectorAll("button")].find((b) => - b.textContent?.includes("I know my mission"), - )!; - await click(direct); - setControlledValue(missionTextarea()!, "Acme's mission"); - await settle(); - await click(confirmMissionButton()!); - await settle(); - expect(currentStep()).toBe("agent"); - - dialogState.onboardingOptions = { - companyId: "company-2", - initialStep: ONBOARDING_MISSION_STEP, - }; - await rerender(); - await settle(); - expect(currentStep()).toBe("mission"); - - const direct2 = [...document.body.querySelectorAll("button")].find((b) => - b.textContent?.includes("I know my mission"), - )!; - await click(direct2); - setControlledValue(missionTextarea()!, "Globex's mission"); - await settle(); - await click(confirmMissionButton()!); - await settle(); - - expect(mockGoalsApi.create).toHaveBeenCalledTimes(2); - expect(mockGoalsApi.create).toHaveBeenLastCalledWith( - "company-2", - expect.objectContaining({ title: "Globex's mission" }), - ); - }); - - it("does not hand a new company the mission written for the old one", async () => { - // A route change can switch companies while the write is in flight, and - // the switch clears exactly the state the write is about to set. The - // goal is written and correct either way — but attributing it to the - // company now in hand would undo the clearing and let that company skip - // its own mission. - let resolveCreate: (goal: { id: string }) => void = () => {}; - mockGoalsApi.create.mockReturnValue( - new Promise<{ id: string }>((resolve) => { - resolveCreate = resolve; - }), - ); - await openMissionStepFor("company-1"); - - const direct = [...document.body.querySelectorAll("button")].find((b) => - b.textContent?.includes("I know my mission"), - )!; - await click(direct); - setControlledValue(missionTextarea()!, "Acme's mission"); - await settle(); - await click(confirmMissionButton()!); - - // Switch companies before the write lands, then let it land. - dialogState.onboardingOptions = { - companyId: "company-2", - initialStep: ONBOARDING_MISSION_STEP, - }; - await rerender(); - await settle(); - await act(async () => resolveCreate({ id: "goal-company-1" })); - await settle(); - - // Globex must still be asked, and must write its own mission. - expect(currentStep()).toBe("mission"); - mockGoalsApi.create.mockResolvedValue({ id: "goal-company-2" }); - const direct2 = [...document.body.querySelectorAll("button")].find((b) => - b.textContent?.includes("I know my mission"), - )!; - await click(direct2); - setControlledValue(missionTextarea()!, "Globex's mission"); - await settle(); - await click(confirmMissionButton()!); - await settle(); - - expect(mockGoalsApi.create).toHaveBeenLastCalledWith( - "company-2", - expect.objectContaining({ title: "Globex's mission" }), - ); - }); - - it("does not carry a mission through a route that withdraws the company", async () => { - // Withdrawing a company and replacing one are the same event: this - // company is no longer the wizard's. Clearing only on replacement leaves - // a goal id behind, and the company created next would read it as - // "mission already written" and never be asked for one. - mockGoalsApi.create.mockResolvedValue({ id: "goal-company-1" }); - // Reached explicitly: the route no longer lands here. The withdrawal this - // defends against is still route-driven, so the route is set too — it takes - // over the moment the explicit open is released. - routerState.pathname = "/PC1/onboarding"; - dialogState.onboardingOpen = true; - dialogState.onboardingOptions = { - companyId: "company-1", - initialStep: ONBOARDING_MISSION_STEP, - }; - await render(); - await settle(); - - const direct = [...document.body.querySelectorAll("button")].find((b) => - b.textContent?.includes("I know my mission"), - )!; - await click(direct); - setControlledValue(missionTextarea()!, "Acme's mission"); - await settle(); - await click(confirmMissionButton()!); - await settle(); - expect(currentStep()).toBe("agent"); - - // Navigate to the unprefixed route, which names no company. - routerState.pathname = "/onboarding"; - await rerender(); - await settle(); - - // The wizard is back at company creation with nothing carried over. - const nameInput = document.body.querySelector("input") as HTMLInputElement | null; - expect(nameInput?.value).toBe(""); - expect(document.body.textContent).not.toContain("Acme's mission"); - }); - - it("withdraws a company the wizard created once the route stops naming it", async () => { - // The route only introduces a company when it names one the wizard is - // not already holding, so a company the wizard *created* was never - // recorded as route-owned and was never withdrawn. Visiting its own - // onboarding path and then `/onboarding` left the wizard showing - // "create a company" while still holding it — and the next confirmation - // wrote that customer's new mission into the old company. - mockCompaniesApi.create.mockResolvedValue({ id: "company-1", issuePrefix: "PC1" }); - mockGoalsApi.create.mockResolvedValue({ id: "goal-company-1" }); - routerState.pathname = "/onboarding"; - await render(); - await settle(); - - const nameInput = document.body.querySelector("input")! as HTMLInputElement; - setControlledValue(nameInput, "Acme"); - await settle(); - await click( - [...document.body.querySelectorAll("button")].find( - (b) => b.textContent?.trim() === "Continue", - )!, - ); - await settle(); - await settle(); - expect(mockCompaniesApi.create).toHaveBeenCalled(); - expect(currentStep()).toBe("agent"); - - // Its own onboarding path, then back to the unprefixed one. - routerState.pathname = "/PC1/onboarding"; - await rerender(); - await settle(); - routerState.pathname = "/onboarding"; - await rerender(); - await settle(); - - const nameAfter = document.body.querySelector("input") as HTMLInputElement | null; - expect(nameAfter?.value).toBe(""); - expect(document.body.textContent).not.toContain("Acme's mission"); - }); - - it("does not write a second mission when the step is confirmed twice", async () => { - mockGoalsApi.create.mockResolvedValue({ id: "goal-new" }); - await openOnMissionStepForExistingCompany(); - - const direct = [...document.body.querySelectorAll("button")].find((b) => - b.textContent?.includes("I know my mission"), - )!; - await click(direct); - setControlledValue(missionTextarea()!, "Ship the thing"); - await settle(); - await click(confirmMissionButton()!); - await settle(); - - // Back to the mission step, then forward again. - const back = [...document.body.querySelectorAll("button")].find((b) => - b.textContent?.includes("Back"), - )!; - await click(back); - await settle(); - await click(confirmMissionButton()!); - await settle(); - - expect(mockGoalsApi.create).toHaveBeenCalledTimes(1); - expect(currentStep()).toBe("agent"); - }); + // Back at the organization-name step with nothing carried over. + const nameAfter = document.body.querySelector("input") as HTMLInputElement | null; + expect(nameAfter?.value).toBe(""); }); it("does not adopt a company it created once a route has supplied one", async () => { @@ -831,15 +492,17 @@ describe("OnboardingWizard — which step it lands on", () => { it("applies the step again when the wizard is re-opened", async () => { // Same guard, from the other side: closing and re-opening is a new - // request, so a freeze that outlived the open would be its own defect. + // request, so a freeze that outlived the open would be its own defect. It + // opens on step 1, closes, then re-opens on the agent step — the re-open + // has to apply the fresh step rather than stay where the first one left it. dialogState.onboardingOpen = true; dialogState.onboardingOptions = { companyId: "company-1", - initialStep: ONBOARDING_MISSION_STEP, + initialStep: 1, }; await render(); await settle(); - expect(currentStep()).toBe("mission"); + expect(currentStep()).toBe("other"); dialogState.onboardingOpen = false; await rerender(); @@ -856,9 +519,9 @@ describe("OnboardingWizard — which step it lands on", () => { expect(currentStep()).toBe("agent"); }); - describe("a company that already has its mission", () => { - // It opens on the agent step, so steps 1 and 2 never run. Everything the - // mission feeds has to come from the company instead of the form. + describe("an existing company opened on the agent step", () => { + // It opens on the agent step, so step 1 never runs. The wizard hires the + // first agent there — the mission it once seeded from is now the server's. const MISSION_GOAL = { ...COMPANY_GOAL, @@ -883,9 +546,7 @@ describe("OnboardingWizard — which step it lands on", () => { const field = document.getElementById("onboarding-agent-name") as HTMLInputElement; expect(field, "the agent step should render its name field").toBeTruthy(); setControlledValue(field, name); - // Settle twice: the hire is guarded on the company's goal lookup - // (`missionUnresolvedForHire`), and a Connect that fires before that - // query resolves is swallowed by the guard rather than failing loudly. + // Settle twice so the connect step's queries resolve before the press. await settle(); await settle(); } @@ -926,70 +587,6 @@ describe("OnboardingWizard — which step it lands on", () => { await press(tiles[0]!); } - it("seeds the lead agent's instructions with the mission it was never asked for", async () => { - // The regression this exists for. The agent step feeds - // `composeCeoInstructions` from the mission field, and a company entered - // here never types one — so the agent was hired knowing nothing of the - // mission the customer gave at signup, and nothing reported it. - await openOnAgentStep(); - await nameAgent(); - - await press(stepCta()); - - await pickModelSource(); - expect(stepCta().hasAttribute("disabled")).toBe(false); - await press(stepCta()); - - expect(mockAgentsApi.saveInstructionsFile).toHaveBeenCalled(); - const [, file] = mockAgentsApi.saveInstructionsFile.mock.calls[0]; - expect(file.content).toContain("Scale the marketplace"); - expect(file.content).toContain("Reach 1000 sellers"); - }); - - it("will not hire while the mission is being re-read", async () => { - // Cached goals plus an in-flight refetch: the field holds the right - // company's mission, but not necessarily its current one. Hiring inside - // that window seeds the agent from a value about to change, and reports - // nothing — the same "retained data is not an answer" rule the draft - // ownership gate follows. - await openOnAgentStep(); - await nameAgent(); - - await press(stepCta()); - await pickModelSource(); - expect(stepCta().hasAttribute("disabled")).toBe(false); - - mockGoalsApi.list.mockReturnValue(new Promise(() => {})); - await act(async () => { - queryClient.invalidateQueries({ - queryKey: queryKeys.goals.list("company-1"), - }); - }); - await settle(2); - - expect(stepCta().hasAttribute("disabled")).toBe(true); - expect(mockAgentsApi.hire).not.toHaveBeenCalled(); - }); - - // Removed: "hydrates again when the same company comes back through - // onboarding". - // - // It closed the wizard with the X and re-opened it, which made `reset()` - // clear `hydratedMissionForRef` and the second pass hydrate again. The arc - // has no X any more — the connect step deliberately has no exit, because - // nothing downstream of it works until a model is connected — so `reset()` - // is now reachable only from a completed launch. - // - // Three substitutes were tried and all three were green against a wizard - // with the behaviour deleted, which is worse than no test: routing to "/" - // never withdraws the company; a swap to another company re-points the - // marker by itself, since it stores which company was hydrated rather than - // a bare flag; and either route dance remounts the inner wizard, so the ref - // does not survive to be tested. What the marker guards is still covered - // from the front by "seeds the lead agent's instructions with the mission it - // was never asked for". Restore a real version of this when the arc gains a - // way out. - it("hires under the neutral role, with the name the customer typed", async () => { // The arc stopped asking for a role, so every onboarding hire is filed // as `general` — and the hire guard returns *silently* when the role is diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index 028ff1e3ce..6b63e02480 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -56,6 +56,10 @@ const mockAgentsApi = vi.hoisted(() => ({ }), ), hire: vi.fn(async () => ({ agent: { id: "agent-1" }, approval: null })), + // The hire step lists the company's agents first and adopts one that already + // carries the typed name on the same source, so a wizard that reopens on the + // agent step cannot hire "Ada 2". Empty by default: the company is new. + list: vi.fn(async () => [] as Array<{ id: string; name: string; adapterType: string }>), instructionsBundle: vi.fn(async () => ({ entryFile: "AGENTS.md" })), saveInstructionsFile: vi.fn(async () => ({})), // No default implementation: the top-level `beforeEach` sets the "no @@ -225,7 +229,6 @@ vi.mock("../adapters/use-adapter-capabilities", () => ({ })); // Animation / canvas-ish children that add nothing to the logic under test. vi.mock("./AsciiArtAnimation", () => ({ AsciiArtAnimation: () => null })); -vi.mock("./FrontDoor", () => ({ FrontDoor: () => null })); vi.mock("./AgentCapsule", () => ({ AgentCapsule: () => null })); import { ApiError } from "../api/client"; @@ -373,16 +376,16 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( vi.clearAllMocks(); }); - describe("step 2, which is two screens wearing one number", () => { - // The create path's step 2 was the mission question and is skipped now. The - // grow path's step 2 is "tell us about your team", whose answers seed the - // lead agent — a different screen that happens to share the number, and one - // nothing covered until skipping the first nearly took it along. + describe("step 1 leads straight to the agent — there is no mission step 2", () => { + // One path now: Name your organization → Name your agent → Connect → Get + // started. The Build / Grow front door and both mission screens are gone, + // so "Continue" on step 1 creates the organization and lands on the agent + // step with no mission question in between. - async function openStepOne(path: "create" | "grow") { + async function openStepOne() { window.localStorage.setItem( ONBOARDING_STORAGE_KEY, - JSON.stringify({ step: 1, onboardingPath: path, companyName: "Initech" }), + JSON.stringify({ step: 1, companyName: "Initech" }), ); mockDialog.onboardingOptions = {}; mockCompany.companies = []; @@ -416,24 +419,15 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( await flushReact(); } - it("keeps the grow path's questionnaire", async () => { - const { root } = await openStepOne("grow"); - await clickByText((t) => t.startsWith("Continue")); - - expect(document.body.textContent).toContain("Tell us about your team"); - expect(mockCompaniesApi.create).not.toHaveBeenCalled(); - - await act(async () => root.unmount()); - }); - - it("skips it on the create path, creating the company on the way", async () => { + it("creates the organization on Continue and lands on the agent step, no mission", async () => { mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" }); - const { root } = await openStepOne("create"); + const { root } = await openStepOne(); await clickByText((t) => t.startsWith("Continue")); expect(mockCompaniesApi.create).toHaveBeenCalledWith({ name: "Initech" }); expect(document.body.textContent).toContain("Create your first agent"); expect(document.body.textContent).not.toContain("Define your mission"); + expect(document.body.textContent).not.toContain("Tell us about your team"); await act(async () => root.unmount()); }); @@ -446,7 +440,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( // render unchecked. Both asserted against positive anchors so an // unrendered step cannot pass as an absence. mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" }); - const { root } = await openStepOne("create"); + const { root } = await openStepOne(); await clickByText((t) => t.startsWith("Continue")); expect(document.body.textContent).toContain("Create your first agent"); @@ -481,6 +475,77 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( await act(async () => root.unmount()); }); + it("adopts an agent the company already has under that name instead of hiring it twice", async () => { + // The wizard can reopen on the agent step for a company that just got + // its first agent — the dashboard's agentless offer on a stale list is + // one way — with nothing in its state to say the hire happened. The + // server numbers a repeat name, so without this the walk produced + // "Ada" and "Ada 2". Same name on the same source is the same agent. + mockDialog.onboardingOptions = {}; + mockCompany.companies = []; + mockCompany.loading = false; + mockCompaniesApi.list.mockResolvedValue([]); + mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" }); + mockAgentsApi.list.mockResolvedValueOnce([ + { id: "agent-existing", name: "Ada", adapterType: "claude_local" }, + ]); + mockAdapterRegistry.list = [{ type: "claude_local" }, { type: "codex_local" }]; + const { root, queryClient } = render(); + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + const clickText = async (match: (t: string) => boolean) => { + const el = [...document.body.querySelectorAll("button")].find((b) => + match(b.textContent?.trim() ?? ""), + )!; + await act(async () => { + el.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + }; + + const nameField = document.body.querySelector( + "#onboarding-company-name", + ) as HTMLInputElement | null; + if (nameField) { + await act(async () => { + setControlledValue(nameField, "Initech"); + }); + await flushReact(); + } else { + const anyName = document.body.querySelector( + 'input[placeholder="e.g. Northwind Labs"]', + ) as HTMLInputElement; + await act(async () => { + setControlledValue(anyName, "Initech"); + }); + await flushReact(); + } + await clickText((t) => t.startsWith("Continue")); + const agentField = document.body.querySelector( + "#onboarding-agent-name", + ) as HTMLInputElement; + await act(async () => { + setControlledValue(agentField, "ada "); + }); + await flushReact(); + await clickText((t) => isArcPrimary(t)); + await pickFirstSource(clickText); + await clickText((t) => isArcPrimary(t)); + + expect(mockAgentsApi.list).toHaveBeenCalledWith("company-new"); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain("ada is ready to work!"); + + await act(async () => root.unmount()); + }); + it("hires from a legacy draft that saved an empty role", async () => { // `agentRole: ""` was this field's default before the arc stopped asking // for a role, so every draft saved by an earlier build carries it. `??` @@ -489,7 +554,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( // through a restored draft instead of a fresh one. window.localStorage.setItem( ONBOARDING_STORAGE_KEY, - JSON.stringify({ step: 1, onboardingPath: "create", companyName: "Initech", agentRole: "" }), + JSON.stringify({ step: 1, companyName: "Initech", agentRole: "" }), ); mockDialog.onboardingOptions = {}; mockCompany.companies = []; @@ -556,7 +621,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( }), ); mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" }); - const { root } = await openStepOne("create"); + const { root } = await openStepOne(); await clickByText((t) => t.startsWith("Continue")); const agentField = document.body.querySelector( "#onboarding-agent-name", @@ -593,7 +658,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( // while the same event is still bubbling — so the second caller reads a // value the first has not written. Two companies, one keystroke. mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" }); - const { root } = await openStepOne("create"); + const { root } = await openStepOne(); const nameInput = document.body.querySelector( 'input[placeholder="e.g. Northwind Labs"]', @@ -624,7 +689,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( resolveCreate = resolve; }), ); - const { root } = await openStepOne("create"); + const { root } = await openStepOne(); const nameInput = document.body.querySelector( 'input[placeholder="e.g. Northwind Labs"]', @@ -650,7 +715,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( // A create run reached the agent step from step 1, so Back owes it step 1 — // not the mission screen it never saw. mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" }); - const { root } = await openStepOne("create"); + const { root } = await openStepOne(); await clickByText((t) => t.startsWith("Continue")); expect(document.body.textContent).toContain("Create your first agent"); @@ -674,7 +739,7 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( mockCompaniesApi.create.mockResolvedValue({ id: "company-new", issuePrefix: "INI" }); window.localStorage.setItem( ONBOARDING_STORAGE_KEY, - JSON.stringify({ step: 1, onboardingPath: "create", companyName: "Initech" }), + JSON.stringify({ step: 1, companyName: "Initech" }), ); mockDialog.onboardingOptions = {}; mockCompany.companies = []; @@ -1912,7 +1977,6 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( ONBOARDING_STORAGE_KEY, JSON.stringify({ step: 4, - onboardingPath: "create", companyName: "Initech", agentName: "Ada", createdCompanyId: "company-new", diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index a40e517bce..8c1dea01c2 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -86,9 +86,7 @@ import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; import { getAdapterDisplay } from "../adapters/adapter-display-registry"; import { buildFixedClaudeOAuthBinding } from "./environment-variables-editor/model"; import { defaultCreateValues } from "./agent-config-defaults"; -import { parseOnboardingGoalInput } from "../lib/onboarding-goal"; import { restoreOnboardingState } from "../lib/onboarding-state"; -import { composeCeoInstructions } from "../lib/ceo-instructions"; import { buildOnboardingIssuePayload, buildOnboardingProjectPayload, @@ -107,14 +105,7 @@ import { companyPrefixFromOnboardingPath, resolveRouteOnboardingOptions, } from "../lib/onboarding-route"; -import { useCompanyMission } from "../hooks/useCompanyMission"; import { useCloudInstance } from "../hooks/useCloudInstance"; -import { - isExistingCompanyMissionUnresolved, - planMissionPersistence, -} from "../lib/onboarding-mission"; -import { AsciiArtAnimation } from "./AsciiArtAnimation"; -import { FrontDoor } from "./FrontDoor"; import { PillGuy } from "./onboarding/PillGuy"; import { SleepingZs } from "./onboarding/SleepingZs"; import { @@ -134,12 +125,8 @@ import { DEFAULT_AGENT_ROLE } from "../lib/onboarding-agent-role"; import { capsuleHeroMotion } from "./onboarding/onboarding-motion"; import { Badge } from "@/components/ui/badge"; import { - Building2, - Bot, - ListTodo, ArrowLeft, ArrowRight, - Sparkles, Check, Loader2, ChevronDown, @@ -150,12 +137,6 @@ type Step = 0 | 1 | 2 | 3 | 4 | 5; // wizard's registry-driven approach rather than a fixed union. type AdapterType = string; -const MISSION_PROMPT_CHIPS = [ - "Build a SaaS product", - "Scale a content business", - "Launch a marketplace" -]; - // First-run onboarding stays on the proven direct adapters even when an // instance administrator has opted into Paperclip Runner elsewhere. The // experimental flag only exposes the runner in explicit agent configuration. @@ -171,15 +152,6 @@ function restoreOnboardingAdapterType(savedAdapterType: unknown): AdapterType { : "claude_local"; } -function buildMissionFromQuestionnaire(q1: string, q2: string, q3: string, q4: string): string { - const parts: string[] = []; - if (q1.trim()) parts.push(q1.trim()); - if (q2.trim()) parts.push(`We serve ${q2.trim().toLowerCase()}.`); - if (q3.trim()) parts.push(`Our biggest challenge is ${q3.trim().toLowerCase()}.`); - if (q4.trim()) parts.push(`Success looks like ${q4.trim().toLowerCase()}.`); - return parts.join(" "); -} - /** * True when an adapter-test result blocks a hire. A `fail` status always * blocks. A `warn` or a `pass` status blocks too when a check reports @@ -293,33 +265,6 @@ function ModelSourceMark({ // duplicating the literal and silently drifting from it if it's ever renamed. export const ONBOARDING_STORAGE_KEY = "paperclip-onboarding-state"; const DEFAULT_TASK_TITLE = "Paperclip onboarding"; -const DEFAULT_TASK_DESCRIPTION = `You are the Paperclip agent. This is your first task. Your job here is to -understand what the user wants and turn it into a concrete plan — not to -start building yet. - -A greeting has already been posted to the user on your behalf, so don't -re-introduce yourself — go straight to the questions. - -This is a user-facing chat. Everything you post here is read by the user, so -keep your messages terse and written for them. Only surface things meant for -the user: the questions, the plan, the team, next-step options, and short -status ("Got your answers — here's the plan."). Never narrate how you work. -Don't post your internal steps or thinking into the chat — no "let me probe -the schema", "schema learned", "building the questions payload", "orienting -myself with the API", or similar play-by-play of your API/tool calls. Do that -work silently and post only the result. - -Work in this order: - -1. Ask a few focused, clarifying questions. Use an ask_user_questions interaction to settle on one concrete goal to tackle first— scope, priorities, constraints, and what "done" looks like. Don't guess; ask. - -2. Propose one plan. Once you understand the goal, write a short approach plan to the \`plan\` document. At the bottom, list the agents you'd hire (with their roles) and any follow-up tasks you'd create. Then present the whole thing as a SINGLE request_checkbox_confirmation that targets the \`plan\` document, with each proposed hire and follow-up task as its own checkable option, checked by default. Give each option a stable id you can act on later. Do NOT use suggest_tasks or a separate request_confirmation — one checkbox card is the plan and its approval. In the card's message keep the summary to a line or two and point the user to the full write-up in the plan on the right sidebar (it opens to the Plan there automatically) — don't paste the whole plan into the card, and never say the write-up is "above" or "in the plan doc above"; it lives in the right sidebar. - -3. Wait for approval. Don't hire anyone or create work until the user approves the plan. They can uncheck anything they don't want before approving, and unchecking simply drops it. If they ask for changes, revise the plan document and re-confirm. - -4. On approval, execute only what they kept. Create exactly the checked options — hire the checked agents and create + delegate the checked follow-up tasks, each in its own task. Skip anything the user unchecked. - -Propose, don't decide. Keep it conversational.`; /** * The onboarding draft in `localStorage`, via a browser that is allowed to say * no. @@ -577,7 +522,10 @@ function OnboardingWizardInner({ const disabledTypes = useDisabledAdaptersSync({ enabled: effectiveOnboardingOpen }); const adapterRegistryLoaded = useAdapterRegistryLoaded({ enabled: effectiveOnboardingOpen }); - const initialStep = effectiveOnboardingOptions.initialStep ?? 0; + // A fresh run opens on step 1 — "Name your organization". The Build / Grow + // front door and its own step 0 are gone: there is one path now, so the + // wizard drops the customer straight onto the first real question. + const initialStep = effectiveOnboardingOptions.initialStep ?? 1; const existingCompanyId = effectiveOnboardingOptions.companyId; const [step, setStep] = useState((saved?.step as Step) ?? initialStep); @@ -587,12 +535,6 @@ function OnboardingWizardInner({ // customer mid-flow — and here that would quietly re-open the "create a // company" step to a run that already holds one. const [entryStep, setEntryStep] = useState((saved?.step as Step) ?? initialStep); - const [onboardingPath, setOnboardingPath] = useState<"create" | "grow" | null>((saved?.onboardingPath as "create" | "grow" | null) ?? null); - - // "Grow existing" questionnaire fields - const [growWorkflows, setGrowWorkflows] = useState((saved?.growWorkflows as string) ?? ""); - const [growPainPoints, setGrowPainPoints] = useState((saved?.growPainPoints as string) ?? ""); - const [growAutomate, setGrowAutomate] = useState((saved?.growAutomate as string) ?? ""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [modelOpen, setModelOpen] = useState(false); @@ -600,14 +542,6 @@ function OnboardingWizardInner({ // Step 1 const [companyName, setCompanyName] = useState((saved?.companyName as string) ?? ""); - const [companyGoal, setCompanyGoal] = useState((saved?.companyGoal as string) ?? ""); - const [missionPath, setMissionPath] = useState<"direct" | "questionnaire" | null>((saved?.missionPath as "direct" | "questionnaire" | null) ?? null); - const [missionConfirmed, setMissionConfirmed] = useState((saved?.missionConfirmed as boolean) ?? false); - // Questionnaire answers - const [q1, setQ1] = useState((saved?.q1 as string) ?? ""); // What do you do? - const [q2, setQ2] = useState((saved?.q2 as string) ?? ""); // Who do you serve? - const [q3, setQ3] = useState((saved?.q3 as string) ?? ""); // Biggest bottleneck? - const [q4, setQ4] = useState((saved?.q4 as string) ?? ""); // What would success look like? // Step 2 // The name is not defaulted: a pre-filled "Chief of staff" is a choice made @@ -775,57 +709,9 @@ function OnboardingWizardInner({ const apiKeySecretRef = useRef<{ key: string } | null>(null); createdCompanyIdRef.current = createdCompanyId; - // The mission of the company actually in hand, which is not always the one - // the route named - the dashboard opens the wizard with a company too. Same - // query key as the route lookup above, so when they agree this is one cache - // entry and no second request. - const { - mission: existingCompanyMission, - settled: existingMissionSettled, - fetching: existingMissionFetching, - } = useCompanyMission(createdCompanyId); - - // Seed the mission field from the company's own goal. - // - // A company that already has its mission opens on the agent step, so steps 1 - // and 2 never run and `companyGoal` stays empty. It is not only a display - // field: the Review checklist reads it, and `composeCeoInstructions` seeds - // the lead agent's instructions from it. Left empty, the agent is hired - // knowing nothing of the mission the customer gave at signup - which is the - // answer this whole flow exists to carry forward. - // - // Only when the field is empty, so a customer editing their mission is never - // overwritten by the stored copy. - const hydratedMissionForRef = useRef(null); - useEffect(() => { - if (!effectiveOnboardingOpen || !createdCompanyId) return; - if (hydratedMissionForRef.current === createdCompanyId) return; - if (!existingMissionSettled || existingMissionFetching) return; - hydratedMissionForRef.current = createdCompanyId; - if (!existingCompanyMission.goalInput) return; - setCompanyGoal((current) => (current.trim() ? current : existingCompanyMission.goalInput)); - setCreatedCompanyGoalId((current) => current ?? existingCompanyMission.goalId); - }, [ - effectiveOnboardingOpen, - createdCompanyId, - existingMissionSettled, - existingMissionFetching, - existingCompanyMission.goalInput, - existingCompanyMission.goalId, - ]); - - // Hiring seeds the agent's instructions from `companyGoal`, so it must not - // run while that field is still waiting to be hydrated - the agent would be - // created with an empty or foreign mission and nothing would report it. - const missionUnresolvedForHire = isExistingCompanyMissionUnresolved({ - existingCompanyId: createdCompanyId, - goalsLoaded: existingMissionSettled, - goalsFetching: existingMissionFetching, - }); // The step the request wants, mirrored for the same reason. `initialStep` is - // *derived* - from the company list, and now from the goal list behind - // `useCompanyMission` - so its value changes whenever one of those queries - // does: a retry, a background refetch, a cache invalidation. An effect that + // *derived* - from the company list - so its value changes whenever that + // query does: a retry, a background refetch, a cache invalidation. An effect that // depended on it would re-run on every such change and call setStep, moving // a customer who is already mid-flow. Reading it through a ref breaks that // dependency, so the effect runs when the wizard *opens* or when the company @@ -854,15 +740,6 @@ function OnboardingWizardInner({ function clearCompanyScopedState() { setCreatedCompanyPrefix(null); setCompanyName(""); - setCompanyGoal(""); - // The marker travels with the field it describes. It means "companyGoal - // holds this company's hydrated mission", so it is cleared wherever that - // field is - here and in `reset()`. Left behind, the next run believes a - // mission it no longer holds was already fetched, and hires the lead agent - // without one. - hydratedMissionForRef.current = null; - setMissionPath(null); - setMissionConfirmed(false); setCreatedCompanyGoalId(null); setCreatedProjectId(null); setCreatedIssueRef(null); @@ -963,22 +840,20 @@ function OnboardingWizardInner({ useEffect(() => { if (!effectiveOnboardingOpen) return; const state = { - step, companyName, companyGoal, missionPath, missionConfirmed, - q1, q2, q3, q4, agentName, agentRole, adapterType, cwd, model, command, args, url, + step, companyName, + agentName, agentRole, adapterType, cwd, model, command, args, url, // The mode, never the key: this blob is localStorage. credentialMode, createdCompanyId, createdCompanyPrefix, createdAgentId, createdCompanyGoalId, createdProjectId, createdIssueRef, - onboardingPath, growWorkflows, growPainPoints, growAutomate, }; onboardingDraftStorage.write(JSON.stringify(state)); }, [ - effectiveOnboardingOpen, step, companyName, companyGoal, missionPath, missionConfirmed, - q1, q2, q3, q4, agentName, agentRole, adapterType, cwd, model, command, args, url, + effectiveOnboardingOpen, step, companyName, + agentName, agentRole, adapterType, cwd, model, command, args, url, credentialMode, createdCompanyId, createdCompanyPrefix, createdAgentId, createdCompanyGoalId, createdProjectId, createdIssueRef, - onboardingPath, growWorkflows, growPainPoints, growAutomate, ]); const { @@ -1214,8 +1089,7 @@ function OnboardingWizardInner({ * Anything that gates this step belongs in here, so the next one is added * once rather than twice. */ - const connectStepReady = - sourceSelected && !adapterEnvLoading && !missionUnresolvedForHire; + const connectStepReady = sourceSelected && !adapterEnvLoading; /** * Whether this step has a sign-in to do before it can hire. @@ -1603,23 +1477,12 @@ function OnboardingWizardInner({ function reset() { onboardingDraftStorage.clear(); - // Cleared with `companyGoal` below - see `clearCompanyScopedState`. - hydratedMissionForRef.current = null; - setStep(0); - setOnboardingPath(null); - setGrowWorkflows(""); - setGrowPainPoints(""); - setGrowAutomate(""); + // Back to the first step — "Name your organization". There is no front + // door before it anymore, so a fresh run opens here. + setStep(1); setLoading(false); setError(null); setCompanyName(""); - setCompanyGoal(""); - setMissionPath(null); - setMissionConfirmed(false); - setQ1(""); - setQ2(""); - setQ3(""); - setQ4(""); // Back to the mount defaults: an empty name (the step's only question, and // what its CTA gates on) and the neutral role every onboarding hire uses. setAgentName(""); @@ -1731,7 +1594,6 @@ function OnboardingWizardInner({ createdCompanyId, buildOnboardingIssuePayload({ title: DEFAULT_TASK_TITLE, - description: DEFAULT_TASK_DESCRIPTION, assigneeAgentId: createdAgentId, projectId, goalId @@ -1980,134 +1842,12 @@ function OnboardingWizardInner({ } } - // Step 2 → 3 ("Confirm mission"): create the company + its company-level - // goal, then advance to naming the team lead. Guarded so revisiting the - // mission step (e.g. via Back) doesn't create a duplicate company. - async function handleConfirmMission() { - if (createdCompanyId) { - // An existing company needs its mission written, not just skipped past. - // This branch used to advance without saving anything, which was - // harmless while nothing sent an existing company to the mission step - - // a company reached step 2 only by creating itself on step 1, one line - // below. The dashboard now opens an agentless company here, so the - // customer types a mission and presses "Confirm mission". Advancing - // without writing it would leave the company with no mission at all, - // which is the state this whole change exists to remove. - // - // A goal already in hand means update it, not skip the write. It used - // to mean skip, which was safe only while the field could not hold an - // unsaved change: the id was set by *writing* the mission, so arriving - // here with one meant nothing had been typed since. Hydration breaks - // that - the id now also arrives from the company's existing goal, with - // the customer's edits sitting in the field beside it - and skipping - // would discard exactly the answer this step asked for. - setLoading(true); - setError(null); - try { - // The company may already have a mission this step could not see. - // `useCompanyMission` fails open, so a goal lookup that exhausted its - // retries sends a company that has one here anyway. Adding a second - // company-level goal would leave two, and the earlier one would keep - // winning `selectDefaultCompanyGoalId` everywhere outside this wizard. - // - // So read once more before writing, and update rather than add. The - // customer just answered the question on a step that asked it, so - // their answer is the mission. A read that fails still writes: an - // unwritten mission is the failure this whole change exists to remove. - let existingGoalId: string | null = createdCompanyGoalId; - try { - const goals = await queryClient.fetchQuery({ - queryKey: queryKeys.goals.list(createdCompanyId), - queryFn: () => goalsApi.list(createdCompanyId) - }); - existingGoalId = existingGoalId ?? selectDefaultCompanyGoalId(goals); - } catch { - // Still cannot tell. Fall through and write. - } - - const plan = planMissionPersistence({ - goalInput: companyGoal, - existingGoalId, - }); - if (plan.kind === "skip") { - setStep(3); - return; - } - const goal = - plan.kind === "update" - ? await goalsApi.update(plan.goalId, plan.payload) - : await goalsApi.create(createdCompanyId, plan.payload); - queryClient.invalidateQueries({ - queryKey: queryKeys.goals.list(createdCompanyId) - }); - if (!stillTheSameCompany(createdCompanyId)) return; - setCreatedCompanyGoalId(goal.id); - setStep(3); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to save the mission"); - } finally { - setLoading(false); - } - return; - } - setLoading(true); - setError(null); - const companyIdAtStart = createdCompanyIdRef.current; - try { - const company = await companiesApi.create({ name: companyName.trim() }); - queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); - // Same guard as the others, from the other end: nothing was in hand when - // this started, so "unchanged" means still nothing. A route that supplied - // a company while the request was open has taken over the wizard, and - // adopting the company just created would fight it — and would leave the - // customer on a company they never navigated to. - if (!canCommitCreatedCompany(companyIdAtStart, company.id)) return; - setCreatedCompanyId(company.id); - // Keep the mirror current here rather than waiting for the next render. - // The goal write below asks `stillTheSameCompany(company.id)`, and a ref - // that still held the pre-create value would answer "no" to the handler - // that just did the creating - so the goal would never be attributed and - // the wizard would sit on the mission step it had just completed. - createdCompanyIdRef.current = company.id; - setCreatedCompanyPrefix(company.issuePrefix); - setSelectedCompanyId(company.id); - - const parsedGoal = parseOnboardingGoalInput(companyGoal); - const goal = await goalsApi.create(company.id, { - title: parsedGoal.title, - ...(parsedGoal.description - ? { description: parsedGoal.description } - : {}), - level: "company", - status: "active" - }); - queryClient.invalidateQueries({ - queryKey: queryKeys.goals.list(company.id) - }); - if (!stillTheSameCompany(company.id)) return; - setCreatedCompanyGoalId(goal.id); - - setStep(3); // → Create your team lead - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to create organization"); - } finally { - setLoading(false); - } - } - - // Step 1 → 3 ("Name your company"): create the company, then go straight to - // the first agent. + // Step 1 → 3 ("Name your organization"): create the organization, then go + // straight to the first agent. There is no mission step between them anymore. // - // This work used to live at the end of `handleConfirmMission`, because step 1 - // led to the mission step and the company was created when that step was - // confirmed. Onboarding no longer asks for the mission, so step 1 has to do - // its own creating — routing 1 → 3 without this left the wizard on the agent - // step with no company to hire into, and nothing said so. - // - // No goal is written here. That is the difference from the path this was - // taken from, and it is deliberate: the mission is collected later, in the - // tenant app, so writing an empty one now would only give the company a goal - // it did not choose. + // No goal is written here: the mission is collected later, in the chat with + // the first agent, so writing an empty one now would only give the + // organization a goal it did not choose. async function handleCreateCompany() { if (createdCompanyId) { setStep(3); @@ -2128,9 +1868,9 @@ function OnboardingWizardInner({ // navigated to. if (!canCommitCreatedCompany(companyIdAtStart, company.id)) return; setCreatedCompanyId(company.id); - // Keep the mirror current rather than waiting for the next render, for - // the same reason the mission path does: anything downstream that asks - // `stillTheSameCompany` in this tick would otherwise be told no. + // Keep the mirror current rather than waiting for the next render: + // anything downstream that asks `stillTheSameCompany` in this tick would + // otherwise be told no. createdCompanyIdRef.current = company.id; setCreatedCompanyPrefix(company.issuePrefix); setSelectedCompanyId(company.id); @@ -2158,11 +1898,6 @@ function OnboardingWizardInner({ setError("Paperclip Runner is not available during onboarding. Choose a legacy adapter."); return; } - // Guarded at the button and the Enter path too; repeated here because this - // seeds the agent's instructions from `companyGoal`, and hiring with an - // unhydrated mission fails silently - the agent exists, and simply never - // learns what the company is for. - if (missionUnresolvedForHire) return; if (createdAgentId) { setStep(5); return; @@ -2290,14 +2025,43 @@ function OnboardingWizardInner({ // path that clears the role must not reach a hire that silently no-ops. if (!agentRole) return; + const hireName = agentName.trim() || AGENT_ROLE_LABELS[agentRole]; + + // The company may already hold this agent. A wizard that reopens on the + // agent step after the hire — the dashboard's agentless offer on a stale + // list, a restored run — has no `createdAgentId` to stop it, and the + // server accepts a repeat name by numbering it, so the customer who + // walks the step twice ends up with "Ada" and "Ada 2". An agent with the + // same name on the same source is that agent: adopt it and move on to + // Review, the way a run that remembers its hire does. + const existingAgents = await agentsApi.list(createdCompanyId).catch(() => null); + const existing = existingAgents?.find( + (agent) => + agent.name.trim().toLowerCase() === hireName.toLowerCase() && + agent.adapterType === adapterType, + ); + if (existing) { + if (!stillTheSameCompany(createdCompanyId)) return; + setCreatedAgentId(existing.id); + queryClient.invalidateQueries({ + queryKey: queryKeys.agents.list(createdCompanyId) + }); + setStep(5); + return; + } + const hire = await agentsApi.hire(createdCompanyId, { // The name is optional; an agent that reaches here without one is // named for the job it was hired to do rather than left blank. - name: agentName.trim() || AGENT_ROLE_LABELS[agentRole], + name: hireName, role: agentRole, adapterType, adapterConfig: hireAdapterConfig, ...(shouldApplyStoredClaudeLogin ? { applyStoredClaudeLogin: true } : {}), + // The server owns what the first agent is told now: this marker seeds + // the chief-of-staff persona over the agent's entry instruction file. + // The wizard no longer composes or overwrites it. + onboardingFirstAgent: true, runtimeConfig: buildNewAgentRuntimeConfig() }); if (hire.approval) { @@ -2313,35 +2077,9 @@ function OnboardingWizardInner({ queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(createdCompanyId) }); - // Seed the CEO's agent instructions file so the agent always has - // company context + a hiring-plan output format rule. Non-fatal on - // failure — the agent can still function with adapter defaults. - // - // Before the ownership check below on purpose. This agent exists now, - // and it needs its instructions whatever this wizard goes on to show. - // Guarding server work rather than attribution would leave a hired agent - // with adapter defaults because the customer changed pages. - try { - const bundle = await agentsApi.instructionsBundle(agent.id, createdCompanyId); - await agentsApi.saveInstructionsFile( - agent.id, - { - path: bundle.entryFile, - content: composeCeoInstructions({ - companyName, - companyGoal, - growPath: onboardingPath === "grow", - growWorkflows, - growPainPoints, - growAutomate, - q1, q2, q3, q4, - }), - }, - createdCompanyId, - ); - } catch (err) { - console.warn("Failed to seed CEO instructions:", err); - } + // The agent's instruction file is seeded server-side from the + // `onboardingFirstAgent` marker above (the chief-of-staff persona). The + // wizard no longer composes or overwrites it. if (!stillTheSameCompany(createdCompanyId)) return; setCreatedAgentId(agent.id); @@ -2419,14 +2157,9 @@ function OnboardingWizardInner({ // Every button below is disabled while a request is in flight. The // keyboard has to honour the same rule, or a second Enter re-enters a // handler whose guard is a piece of state the first one has not set - // yet — two goals for one mission, two agents for one hire. + // yet — two organizations for one name, two agents for one hire. if (loading) return; - if (step === 0) return; // front door requires click - if (step === 1 && companyName.trim()) { - if (skipsMissionStep) void handleCreateCompany(); - else setStep(2); - } - else if (step === 2 && companyName.trim() && companyGoal.trim()) handleConfirmMission(); + if (step === 1 && companyName.trim()) void handleCreateCompany(); else if (step === 3 && agentName.trim()) setStep(4); // `connectStepReady`, the same predicate the step's button uses. Spelling // the condition out here again is what let this path hire against a @@ -2449,23 +2182,15 @@ function OnboardingWizardInner({ if (!effectiveOnboardingOpen) return null; // The arc strip stands in for the full-length bar only when the run began on - // the arc — the Cloud-first path, where the company already exists and steps - // 1-2 never happen. A run that started at step 1 keeps one continuous count. - // Step 2 is two different screens wearing one number: the grow path's "tell us - // about your team" questionnaire, and the create path's mission step. - // Onboarding stopped asking for the mission, but the questionnaire is still - // how a grow run describes the team it is levelling up — its answers seed the - // lead agent — so only the create path skips ahead. - const skipsMissionStep = onboardingPath !== "grow"; + // the arc — the Cloud-first path, where the company already exists and step 1 + // never happens. A run that started at step 1 keeps one continuous count. // Back lands on whatever came before this step *for this run*, which is not - // always `step - 1`. A create run went 1 → 3, so stepping blindly would walk - // it into the mission screen it never saw. Two runs still belong on step 2 - // going back: a grow run, whose step 2 is the questionnaire rather than the - // mission, and a run that *entered* on the mission step because something - // opened it there — it has seen that screen, so Back owes it the way back. + // always `step - 1`. The run goes 1 → 3 (there is no mission step 2 between + // naming the organization and naming the agent), so the agent step walks + // back to step 1 rather than to a screen the customer never saw. function backStepFrom(current: Step): Step { - if (current === 3 && skipsMissionStep && entryStep !== 2) return 1; + if (current === 3) return 1; return (current - 1) as Step; } @@ -2516,24 +2241,10 @@ function OnboardingWizardInner({ className="fixed inset-0 z-50 flex" onKeyDown={handleKeyDown} > - {/* Step 0: Front Door — full-screen choice */} - {step === 0 && ( -
- { - setOnboardingPath(path); - setStep(1); - }} /> -
- )} - - {/* Left half — form (steps 1+) */} - {step !== 0 && ( -
+ {/* Form column — the wizard opens directly on step 1, so there is no + front-door choice ahead of it, and it fills the width on every + step (the mission step's half-width split is gone). */} +
-
-
- -
-
-

Tell us about your team

-

- We'll use this to set up your lead agent and plan which agents to add. -

-
-
-
- - setQ1(e.target.value)} - /> -
-
- -