feat(onboarding): first task opens as a chat with a chief of staff (#13068)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Onboarding ends by handing a new user to their first agent on a seeded first task > - Today the wizard asks for a mission up front, the UI composes what the agent is told, and the agent starts running before the user says anything > - New users get a cold, ticket-shaped start, and nobody can edit the agent's brief or persona without a code change > - This pull request makes the first task a short chat: a four-step wizard, a chief-of-staff persona, a greeting plus a two-option opening card, server-owned markdown texts, and no run until the user answers > - It also gives question cards one consistent action row (Cancel / Skip / Next), makes agent hires idempotent within a run, and turns the Paperclip Runner flag on by default for self-hosted instances > - The benefit is a first run the user steers, with texts a board operator can edit as markdown ## Linked Issues or Issue Description No public GitHub issue exists for this change. The feature request fields follow. Related PRs and issues: - Refs #11043 — an earlier draft of the first-task onboarding experience. This PR supersedes it. - Refs #11280 — a report about the onboarding first-task route test. This PR extends that test file. ### Subsystem affected Onboarding wizard, the seeded first task and its texts, task-chat question cards, agent hiring, and the instance experimental settings. ### Problem or motivation The onboarding wizard collects a mission through two extra steps and a questionnaire. The UI then composes the first agent's instructions and the first task description from those answers. The first task wakes the agent at once, so the agent runs and posts before the user types a word. Board operators cannot change the greeting, the brief, or the persona without editing TypeScript. Question cards in chat behave differently per adapter, and a single-select pick submits on click. A misread hire response could create a duplicate agent that the creating agent cannot remove. ### Proposed solution Reduce the wizard to four steps and stop the UI from authoring agent texts. Move the greeting, the brief, the chief-of-staff persona, and the opening question into markdown and JSON files that the server loads at runtime. Seed the persona onto the first agent through an explicit hire marker. Do not wake the first task until the user answers the opening card or types. Give every question card the same Cancel / Skip / Next actions. Add an experimental toggle that switches the single-task proposal between one confirmation card and a plan document with a checkbox card. Make agent hires idempotent within a run. ### Alternatives considered - Keep the mission questionnaire and feed it into the brief. Rejected: the agent asks better questions in chat, and the wizard gets shorter. - Keep the first task open-ended with a plain composer. Rejected: a two-option card gives the user a clear first move. - Derive the plan-document behaviour from the user's intent only. Rejected in favour of an explicit experimental toggle so operators can choose. - Key the "pick does not submit" behaviour off the presence of a submit label. Rejected: several adapters set a submit label on single-select cards, and their cards would change behaviour. ### Roadmap alignment `ROADMAP.md` lists no planned core work on onboarding or the first task. This change refines the existing flow and does not duplicate planned work. ## What Changed - Wizard: four steps (Name your organization, Create your first agent, Connect a model, Review). The front door and both mission steps are removed with their state and saved-progress keys. The UI no longer composes the first agent's instructions or the first task description. - Server-owned texts: the greeting, the brief with two proposal variants, the chief-of-staff persona, the opening question, and a README live in `server/src/onboarding-assets/first-task/` and load at runtime. The create route stores the assembled brief and ignores any client description. - Persona seed: an `onboardingFirstAgent` marker on the hire lets the server seed the chief-of-staff persona over the first agent's entry file. Board-authored hires only. The persona tells the agent the hire response shape and to list agents before it acts on an unclear result. - No auto-run: the first task does not queue an assignment wake. The stranded-assignment reconciler leaves it idle until a user comment or an answered card exists. - Opening card: the server seeds an `ask_user_questions` card right after the greeting with two options: "Interview me and propose a plan and an agent team to execute it." and "I have a task in mind" with free text. Answering wakes the agent. - Experimental toggle `enableFirstTaskPlanProposal` (default off): the single-task proposal is one confirmation card, or a plan document plus a checkbox card when on. - Question cards: every `ask_user_questions` card renders Cancel, Skip, and Next (the submit label on the last question). Skip hides on required questions. Picking an option no longer advances or submits by itself. - Wizard guards: the dashboard's agentless offer ignores a cached empty agent list while a refetch is in flight. The hire step adopts an agent that already carries the typed name instead of hiring "Name 2". - Agent hires are idempotent within a run: a retry of the identical request under the same run id returns the existing agent with `200` and `idempotent: true`. The fingerprint covers the whole validated request, so a corrected payload is a new hire. Lookup, create, and activity record run under one lock per company and run, so overlapping retries cannot both create. - The Paperclip Runner experimental flag defaults to on for self-hosted instances. Cloud keeps its declared default: a managed instance whose tenant row and managed overlay omit the flag resolves it to off. - Question cards: a send that finds an earlier required answer missing returns to that question with a message instead of failing silently. - The two onboarding e2e specs follow the new wizard: the front door and growth intake shots are gone, and the planning-mode spec dismisses the opening card before it reads the composer. - Docs: `docs/board-operator/editing-first-task-texts.md` explains how to edit the texts and the toggle. ## Verification Commands, run from the repo root: ``` pnpm -r --filter './packages/*' --filter '!@paperclipai/paperclip-runner' build pnpm --filter ./packages/shared typecheck pnpm --filter ./ui typecheck pnpm --filter ./server exec tsc --noEmit pnpm check:token-gates pnpm --filter ./ui exec vitest run OnboardingWizard onboarding QuestionForm InteractionCard ProtocolCard TaskChatComposer Dashboard feature PAPERCLIP_IN_WORKTREE=false pnpm --filter ./server exec vitest run onboarding-first-task heartbeat-process-recovery agent-hire-idempotency instance-settings agent-skills-routes issue-onboarding onboarding-greeting --testTimeout=90000 ``` Results on this branch: - Typecheck is clean for shared, ui, and server. - Token gates: 4 of 4 clean. - UI: 344 tests pass across 23 files. - Server: all suites pass. The first test in `agent-skills-routes` has its own 10 s cap and needs about 15 s on my laptop for the app cold start. It passes with a longer cap. This PR does not change that cap. Manual steps on a dev instance: 1. Open `/onboarding`. Confirm four steps: Name your organization, Create your first agent, Connect a model, Review. 2. Finish the wizard. Confirm the first task shows the chief-of-staff greeting and the opening card with two options. Confirm no run starts. 3. Pick "Interview me…". Confirm no run starts. Press Continue. Confirm a run starts and an interview card of 3–4 questions arrives. 4. On a fresh organization, pick "I have a task in mind", type a task, and press Continue. Confirm a proposal arrives as one confirmation card. 5. Turn on Settings → Experimental → "First task: propose with a plan document" and repeat step 4. Confirm a plan document and a checkbox card arrive. 6. Visit the dashboard after the hire. Confirm the wizard does not reopen and one agent exists. 7. Open any question card. Confirm Cancel returns the plain composer with the card still pending, Skip advances an optional question, and Next moves to the next question. Design reference with flow diagrams, chat mock-ups, and live captures: https://pages.paperclip.ing/first-task-flow/proposed/ ## Risks - `pnpm dev` now builds the runner daemon because the Paperclip Runner flag is on by default. Developers without a Rust toolchain must set `PAPERCLIP_RUNNER_BINARY` or turn the flag off. Self-hosted instances that never set the flag now let qualified agents use the runner. - The wizard drops the mission steps and their saved-progress keys. A user who is mid-wizard on an older build restarts at step 1 after an upgrade. Existing organizations are not touched. - The first task no longer runs on its own. A user who neither answers the card nor types sees no agent activity. This is intended. - The persona seed applies only to hires that carry the marker from the wizard. API hires are unchanged. - Hire idempotency is scoped to one run id and to the exact request. Retries across runs, or with a changed payload, still create a second agent. The lock is per server process, which matches how an instance serves its API. - Single-select question cards no longer submit on pick. Users of adapters that relied on that behaviour now press Next. - No database migrations. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude (Anthropic) through Claude Code. `claude-fable-5-1` with extended thinking, tool use, and code execution wrote most commits. `claude-opus-4-8` wrote the toggle, texts, wizard, and idempotency commits, as the `Co-Authored-By` trailers show. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e200104727
commit
5acf56658b
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -56,7 +56,10 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
|||
"Allow explicitly configured local Codex, OpenCode, and qualified ACPX agents to use the experimental Rust Paperclip Runner, including authenticated sandbox ingress when required. Onboarding remains on legacy adapters.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
// On by default for self-hosted instances. Requires a Rust toolchain (or
|
||||
// PAPERCLIP_RUNNER_BINARY) for `pnpm dev`, which builds runnerd whenever
|
||||
// this is on.
|
||||
selfHostedDefault: true,
|
||||
},
|
||||
enableManagedSandboxOnly: {
|
||||
title: "Managed Environment Only",
|
||||
|
|
@ -285,6 +288,14 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
|||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableFirstTaskPlanProposal: {
|
||||
title: "First task: propose with a plan document",
|
||||
description:
|
||||
"When the user's first request is a single task, the chief of staff writes a short plan document and a checkbox card instead of a one-card confirmation. Applies to organizations created after the toggle is flipped.",
|
||||
tier: "preference",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const INSTANCE_FEATURE_KEYS = Object.keys(INSTANCE_FEATURE_CATALOG).sort() as InstanceFeatureKey[];
|
||||
|
|
|
|||
|
|
@ -86,6 +86,13 @@ export interface InstanceExperimentalSettings {
|
|||
* behavior change outside interaction wording.
|
||||
*/
|
||||
enableSimplifiedEnglishInteractions: boolean;
|
||||
/**
|
||||
* When the user's first onboarding request is a single task, the chief of
|
||||
* staff proposes with a short plan document and a checkbox card instead of a
|
||||
* one-card confirmation. Read once, when the onboarding first task is created;
|
||||
* flipping it later does not change an existing first task.
|
||||
*/
|
||||
enableFirstTaskPlanProposal: boolean;
|
||||
autoRestartDevServerWhenIdle: boolean;
|
||||
enableWorkspaceBranchReconcileForward: boolean;
|
||||
enableWorkspaceDirtyQuarantineRepair: boolean;
|
||||
|
|
|
|||
|
|
@ -98,6 +98,12 @@ export const createAgentSchema = z.object({
|
|||
// round trip. The server permits the no-claim bind only for a user actor and
|
||||
// only when that owner already has a stored value. It carries no token.
|
||||
applyStoredClaudeLogin: z.boolean().optional(),
|
||||
// Narrow intent flag set by the onboarding wizard when it hires the very first
|
||||
// agent (the chief of staff). It is not an agent column: the server consumes
|
||||
// it to seed the server-owned chief-of-staff persona over the agent's entry
|
||||
// instruction file instead of the generic default, and honors it only for
|
||||
// board-authored requests. Mirrors onboardingFirstTask on issue create.
|
||||
onboardingFirstAgent: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type CreateAgent = z.infer<typeof createAgentSchema>;
|
||||
|
|
@ -128,7 +134,7 @@ export const createAgentHireSchema = createAgentSchema.extend({
|
|||
export type CreateAgentHire = z.infer<typeof createAgentHireSchema>;
|
||||
|
||||
export const updateAgentSchema = objectWithoutDefaults(
|
||||
createAgentSchema.omit({ permissions: true }),
|
||||
createAgentSchema.omit({ permissions: true, onboardingFirstAgent: true }),
|
||||
)
|
||||
.partial()
|
||||
.extend({
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<typeof createDb>;
|
||||
|
||||
/**
|
||||
* 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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, string> | undefined;
|
||||
expect(entrySeed?.["AGENTS.md"]).toContain("# Hiring and delegation");
|
||||
});
|
||||
|
||||
it("includes canonical desired skills in hire approvals", async () => {
|
||||
const db = createDb(true);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>, config: ManagedInstanceConfig | null) {
|
||||
return stripCloudCatalogDefaultEchoes(
|
||||
rawStored,
|
||||
patch,
|
||||
applyExperimentalSettingsPatch(rawStored, patch),
|
||||
config,
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** What a later read of that persisted row shows. */
|
||||
function readBack(stored: Record<string, unknown>, 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 }));
|
||||
});
|
||||
});
|
||||
|
|
@ -48,6 +48,7 @@ describe("instance settings service", () => {
|
|||
enableServerInfoDebugView: true,
|
||||
enablePaperclipDeveloperMode: true,
|
||||
enableSimplifiedEnglishInteractions: false,
|
||||
enableFirstTaskPlanProposal: false,
|
||||
autoRestartDevServerWhenIdle: true,
|
||||
enableWorkspaceBranchReconcileForward: true,
|
||||
enableWorkspaceDirtyQuarantineRepair: false,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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<string, Promise<void>>();
|
||||
|
||||
async function withHireRunLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
const previous = hireRunLocks.get(key) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const current = new Promise<void>((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<string, string>; 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<string, unknown>) {
|
||||
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<string, unknown> }> => {
|
||||
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<string, unknown> | null)?.hireFingerprint === requestFingerprint,
|
||||
);
|
||||
if (match) {
|
||||
const existingAgent = await svc.getById(match.entityId);
|
||||
if (existingAgent && existingAgent.status !== "terminated") {
|
||||
const priorApprovalId = (match.details as Record<string, unknown> | 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<ReturnType<typeof approvalsSvc.getById>> | null = null;
|
||||
const actor = getActorInfo(req);
|
||||
let approval: Awaited<ReturnType<typeof approvalsSvc.getById>> | null = null;
|
||||
const actor = getActorInfo(req);
|
||||
|
||||
if (requiresApproval) {
|
||||
const requestedAdapterType = normalizedHireInput.adapterType ?? agent.adapterType;
|
||||
const requestedAdapterConfig =
|
||||
redactEventPayload(
|
||||
(agent.adapterConfig ?? normalizedHireInput.adapterConfig) as Record<string, unknown>,
|
||||
) ?? {};
|
||||
const requestedRuntimeConfig =
|
||||
redactEventPayload(
|
||||
(normalizedHireInput.runtimeConfig ?? agent.runtimeConfig) as Record<string, unknown>,
|
||||
) ?? {};
|
||||
const requestedMetadata =
|
||||
redactEventPayload(
|
||||
((normalizedHireInput.metadata ?? agent.metadata ?? {}) as Record<string, unknown>),
|
||||
) ?? {};
|
||||
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<string, unknown>,
|
||||
) ?? {};
|
||||
const requestedRuntimeConfig =
|
||||
redactEventPayload(
|
||||
(normalizedHireInput.runtimeConfig ?? agent.runtimeConfig) as Record<string, unknown>,
|
||||
) ?? {};
|
||||
const requestedMetadata =
|
||||
redactEventPayload(
|
||||
((normalizedHireInput.metadata ?? agent.metadata ?? {}) as Record<string, unknown>),
|
||||
) ?? {};
|
||||
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, {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>)[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<string, unknown>,
|
||||
next: InstanceExperimentalSettings,
|
||||
managedConfig: ManagedInstanceConfig | null,
|
||||
): Partial<InstanceExperimentalSettings> {
|
||||
if (!managedConfig) return next;
|
||||
const stored =
|
||||
rawStored && typeof rawStored === "object" && !Array.isArray(rawStored)
|
||||
? (rawStored as Record<string, unknown>)
|
||||
: {};
|
||||
const patchRecord = patch as Record<string, unknown>;
|
||||
const result: Record<string, unknown> = { ...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<InstanceExperimentalSettings>;
|
||||
}
|
||||
|
||||
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<InstanceSettings> => {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string> {
|
||||
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<string> {
|
||||
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<AskUserQuestionsPayload> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string, string>; entryFile: string }> {
|
||||
const persona = await renderChiefOfStaffPersona(placeholders);
|
||||
return { files: { "AGENTS.md": persona }, entryFile: "AGENTS.md" };
|
||||
}
|
||||
|
|
@ -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.");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
return renderOnboardingFirstTaskGreeting({
|
||||
agentName: input.agentName,
|
||||
organizationName: input.organizationName,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-col items-center justify-center min-h-(--sz-60vh) px-8">
|
||||
<div className="text-center mb-10">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
Welcome to Paperclip
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
How would you like to get started?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-lg w-full">
|
||||
<button
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-3 rounded-lg border-2 border-border p-6",
|
||||
"hover:border-foreground hover:bg-accent/30 transition-all",
|
||||
"text-center group cursor-pointer",
|
||||
)}
|
||||
onClick={() => onChoose("create")}
|
||||
>
|
||||
<div className="rounded-full bg-muted/50 p-3 group-hover:bg-accent transition-colors">
|
||||
<Rocket className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Build a new organization</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Begin with a mission, bring on a lead agent, and grow a team of agents to do the work.
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-3 rounded-lg border-2 border-border p-6",
|
||||
"hover:border-foreground hover:bg-accent/30 transition-all",
|
||||
"text-center group cursor-pointer",
|
||||
)}
|
||||
onClick={() => onChoose("grow")}
|
||||
>
|
||||
<div className="rounded-full bg-muted/50 p-3 group-hover:bg-accent transition-colors">
|
||||
<Zap className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Add agents to your org</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Bring AI agents into your existing team or workflows.
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<OnboardingWizard />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,69 +0,0 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// The onboarding wizard's decorative right-hand panel (which renders the
|
||||
// ASCII paperclip illustration) must follow the active shadcn theme instead of
|
||||
// hardcoding a dark surface. Otherwise a light/cream deployer theme (set via
|
||||
// the PAPERCLIP_DEFAULT_THEME bootstrap) renders a jarring cream form next to a
|
||||
// solid dark panel. The illustration glyphs already use `text-muted-foreground`,
|
||||
// so the panel must sit on the paired `bg-muted` surface to read as an
|
||||
// intentional ink-on-surface texture in every theme.
|
||||
//
|
||||
// Asserting against the source keeps this guard cheap: the panel className is a
|
||||
// static string literal, and the full wizard dialog is too heavy to mount here.
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe("OnboardingWizard decorative panel theming", () => {
|
||||
const source = readFileSync(
|
||||
path.join(here, "OnboardingWizard.tsx"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
/**
|
||||
* The className expression on the wrapper around <AsciiArtAnimation />.
|
||||
* Anchoring here keeps both guards on the decorative panel itself - the same
|
||||
* tokens appear elsewhere in the wizard. `[^<>]` stops the match spanning
|
||||
* into other JSX elements.
|
||||
*/
|
||||
function panelClassNames(): string | null {
|
||||
return (
|
||||
source.match(/className=\{cn\(([^<>]*)\)\}\s*>\s*<AsciiArtAnimation\s*\/>/)?.[1] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
it("gives the decorative panel no background but the muted token", () => {
|
||||
// Scoped to the panel, not the file. A file-wide scan would fail this
|
||||
// case for a hardcoded colour anywhere else in the wizard, reporting a
|
||||
// panel regression that had not happened.
|
||||
//
|
||||
// Asserted as the complete set of `bg-` classes rather than a list of
|
||||
// spellings to forbid. The previous version scanned for `bg-[#rrggbb]`
|
||||
// alone, and passed unchanged once master migrated this class to
|
||||
// `bg-(--hex-1d1d1d)` - so it guarded nothing at all for a while. A named
|
||||
// colour like `bg-black` or `bg-zinc-900` would have slipped through the
|
||||
// same way. Naming what is allowed cannot rot like that.
|
||||
const panel = panelClassNames();
|
||||
expect(panel).not.toBeNull();
|
||||
const backgrounds = panel!.match(/\bbg-[^\s"'`,]+/g) ?? [];
|
||||
expect(backgrounds).toEqual(["bg-muted"]);
|
||||
});
|
||||
|
||||
it("themes the decorative panel with shadcn surface tokens", () => {
|
||||
// Anchor to the wrapper around <AsciiArtAnimation /> so the guard checks
|
||||
// the decorative panel itself (the same tokens appear elsewhere in the
|
||||
// wizard), then assert each token independently so a class reorder or a
|
||||
// utility inserted between them cannot false-fail the test. `[^<>]`
|
||||
// keeps the match from spanning across other JSX elements.
|
||||
const panel = source.match(
|
||||
/className=\{cn\(([^<>]*)\)\}\s*>\s*<AsciiArtAnimation\s*\/>/
|
||||
);
|
||||
expect(panel).not.toBeNull();
|
||||
expect(panel?.[1]).toMatch(/\bbg-muted\b(?!-)/);
|
||||
expect(panel?.[1]).toMatch(/\btext-muted-foreground\b/);
|
||||
});
|
||||
});
|
||||
|
|
@ -39,6 +39,11 @@ export interface QuestionFormProps {
|
|||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
mentions?: MentionOption[];
|
||||
onSubmit: (response: PaperclipQuestionResponse) => void | Promise<void>;
|
||||
/**
|
||||
* Resolves the request itself (a timeline card cancelling the interaction).
|
||||
* Inside the composer takeover the form falls back to dismissing the
|
||||
* takeover, which returns the plain composer without touching the request.
|
||||
*/
|
||||
onCancel?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -321,13 +326,11 @@ export function QuestionForm({
|
|||
selectedOptionIds: optionIds,
|
||||
...(!multiple ? { customText: undefined } : {}),
|
||||
};
|
||||
const nextAnswers = { ...answers, [question.id]: nextAnswer };
|
||||
setAnswers(nextAnswers);
|
||||
if (!multiple) {
|
||||
// Picking only selects. Next / Submit answers moves on or sends, so a
|
||||
// click can never start work by itself.
|
||||
setAnswers({ ...answers, [question.id]: nextAnswer });
|
||||
if (!multiple)
|
||||
setCustomActive((current) => ({ ...current, [question.id]: false }));
|
||||
if (page < questionSet.questions.length - 1) setPage(page + 1);
|
||||
else void submit(nextAnswers);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCustom() {
|
||||
|
|
@ -343,11 +346,21 @@ export function QuestionForm({
|
|||
}
|
||||
|
||||
async function submit(responseAnswers: Record<string, Answer> = answers) {
|
||||
const responseIsValid = questionSet.questions.every(
|
||||
if (disabled || working || inputUploading) return;
|
||||
const invalidIndex = questionSet.questions.findIndex(
|
||||
(candidate) =>
|
||||
answerError(candidate, responseAnswers[candidate.id]) == null,
|
||||
answerError(candidate, responseAnswers[candidate.id]) != null,
|
||||
);
|
||||
if (!responseIsValid || disabled || working || inputUploading) return;
|
||||
if (invalidIndex >= 0) {
|
||||
// A required answer is missing: the pagination arrows browse without
|
||||
// validating, and a restored draft can land past it. Go back to that
|
||||
// question and say so rather than dropping the send.
|
||||
setPage(invalidIndex);
|
||||
setError(
|
||||
`Question ${invalidIndex + 1} needs an answer before you can send.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setWorking("submit");
|
||||
setError(null);
|
||||
try {
|
||||
|
|
@ -387,12 +400,22 @@ export function QuestionForm({
|
|||
|
||||
const currentError = validationErrors[question.id];
|
||||
const isLastPage = page === questionSet.questions.length - 1;
|
||||
const showQuestionActionButton =
|
||||
multiple ||
|
||||
(isLastPage && (question.answerMode !== "single_select" || isCustomActive));
|
||||
const showActionRow = Boolean(
|
||||
takeoverActions?.skipButton || onCancel || showQuestionActionButton,
|
||||
);
|
||||
const busy = disabled || working != null || inputUploading;
|
||||
// Cancel resolves the request when the host owns that; otherwise it just
|
||||
// closes the composer takeover so the user can type freely.
|
||||
const cancelAction = onCancel
|
||||
? () => void cancel()
|
||||
: takeoverActions?.dismiss;
|
||||
|
||||
/** Leaves the current question unanswered and moves on (or sends). */
|
||||
function skipQuestion() {
|
||||
if (busy) return;
|
||||
const { [question.id]: _skipped, ...rest } = answers;
|
||||
setAnswers(rest);
|
||||
setCustomActive((current) => ({ ...current, [question.id]: false }));
|
||||
if (isLastPage) void submit(rest);
|
||||
else setPage(page + 1);
|
||||
}
|
||||
const pagination =
|
||||
questionSet.questions.length > 1 ? (
|
||||
<nav
|
||||
|
|
@ -417,6 +440,8 @@ export function QuestionForm({
|
|||
size="icon-xs"
|
||||
variant="ghost"
|
||||
aria-label="Next question"
|
||||
// The arrows browse; they do not validate. A send that finds an
|
||||
// earlier answer missing returns to that question (see submit).
|
||||
disabled={disabled || working != null || isLastPage}
|
||||
onClick={() => setPage((current) => current + 1)}
|
||||
>
|
||||
|
|
@ -599,43 +624,44 @@ export function QuestionForm({
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{showActionRow ? (
|
||||
<div className="mt-3 flex flex-wrap items-center justify-end gap-2">
|
||||
{takeoverActions?.skipButton}
|
||||
{onCancel ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={disabled || working != null || inputUploading}
|
||||
onClick={() => void cancel()}
|
||||
>
|
||||
{working === "cancel" ? (
|
||||
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
|
||||
) : null}{" "}
|
||||
Cancel
|
||||
</Button>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-end gap-2">
|
||||
{cancelAction ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={cancelAction}
|
||||
>
|
||||
{working === "cancel" ? (
|
||||
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
|
||||
) : null}{" "}
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
{!question.required ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={skipQuestion}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={busy || (isLastPage ? !allValid : currentError != null)}
|
||||
onClick={progressOrSubmit}
|
||||
>
|
||||
{working === "submit" ? (
|
||||
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
{showQuestionActionButton ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={
|
||||
disabled ||
|
||||
working != null ||
|
||||
inputUploading ||
|
||||
(isLastPage ? !allValid : currentError != null)
|
||||
}
|
||||
onClick={progressOrSubmit}
|
||||
>
|
||||
{working === "submit" ? (
|
||||
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
{questionSet.submitLabel ?? "Submit answers"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{isLastPage ? (questionSet.submitLabel ?? "Submit answers") : "Next"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1584,6 +1584,331 @@ describe("TaskChatComposer", () => {
|
|||
).toBeNull();
|
||||
});
|
||||
|
||||
it("waits for the submit button on a single-select question", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<TaskChatComposer
|
||||
onAdd={vi.fn()}
|
||||
workMode="standard"
|
||||
takeover={{
|
||||
id: "opening-question",
|
||||
label: "Questions",
|
||||
pendingCount: 1,
|
||||
inlineSkip: true,
|
||||
content: (
|
||||
<QuestionForm
|
||||
id="first-task-opening"
|
||||
questionSet={{
|
||||
schema: "paperclip.question_set.v1",
|
||||
submitLabel: "Continue",
|
||||
questions: [
|
||||
{
|
||||
id: "first-task-opening",
|
||||
prompt: "What would you like to do?",
|
||||
required: true,
|
||||
answerMode: "single_select",
|
||||
options: [
|
||||
{ id: "interview", label: "Interview me" },
|
||||
{ id: "task", label: "I have a task in mind" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
),
|
||||
onDismiss: vi.fn(),
|
||||
onSkip: vi.fn(),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const buttons = () =>
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>("button"));
|
||||
const interview = buttons().find((button) =>
|
||||
button.textContent?.includes("Interview me"),
|
||||
);
|
||||
expect(interview).not.toBeUndefined();
|
||||
|
||||
// Picking the option only selects it: nothing is sent yet.
|
||||
flushSync(() => interview?.click());
|
||||
await flushAsync();
|
||||
expect(interview?.getAttribute("data-selected")).toBe("true");
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
// A required question cannot be skipped.
|
||||
expect(
|
||||
buttons().find((button) => button.textContent?.trim() === "Skip"),
|
||||
).toBeUndefined();
|
||||
|
||||
// The submit button carries the card's label and does the sending.
|
||||
const submit = buttons().find(
|
||||
(button) => button.textContent?.trim() === "Continue",
|
||||
);
|
||||
expect(submit).not.toBeUndefined();
|
||||
expect(submit?.disabled).toBe(false);
|
||||
flushSync(() => submit?.click());
|
||||
await flushAsync();
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({
|
||||
answers: { "first-task-opening": { selectedOptionIds: ["interview"] } },
|
||||
});
|
||||
});
|
||||
|
||||
it("moves through questions with Next, Skip leaves one unanswered, Submit answers sends", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<TaskChatComposer
|
||||
onAdd={vi.fn()}
|
||||
workMode="standard"
|
||||
takeover={{
|
||||
id: "interview",
|
||||
label: "Questions",
|
||||
pendingCount: 1,
|
||||
inlineSkip: true,
|
||||
content: (
|
||||
<QuestionForm
|
||||
id="interview"
|
||||
questionSet={{
|
||||
schema: "paperclip.question_set.v1",
|
||||
questions: [
|
||||
{
|
||||
id: "env",
|
||||
prompt: "Where?",
|
||||
required: true,
|
||||
answerMode: "single_select",
|
||||
options: [{ id: "staging", label: "Staging" }],
|
||||
},
|
||||
{
|
||||
id: "when",
|
||||
prompt: "When?",
|
||||
required: false,
|
||||
answerMode: "single_select",
|
||||
options: [{ id: "today", label: "Today" }],
|
||||
},
|
||||
{
|
||||
id: "who",
|
||||
prompt: "Who?",
|
||||
required: false,
|
||||
answerMode: "single_select",
|
||||
options: [{ id: "me", label: "Me" }],
|
||||
},
|
||||
],
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
),
|
||||
onDismiss: vi.fn(),
|
||||
onSkip: vi.fn(),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const buttons = () =>
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>("button"));
|
||||
const byLabel = (label: string) =>
|
||||
buttons().find((button) => button.textContent?.trim() === label);
|
||||
|
||||
// Page 1: required, so no Skip; Next waits for an answer.
|
||||
expect(byLabel("Skip")).toBeUndefined();
|
||||
expect(byLabel("Next")?.disabled).toBe(true);
|
||||
flushSync(() => byLabel("Staging")?.click());
|
||||
await flushAsync();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(byLabel("Next")?.disabled).toBe(false);
|
||||
flushSync(() => byLabel("Next")?.click());
|
||||
await flushAsync();
|
||||
expect(container.textContent).toContain("When?");
|
||||
|
||||
// Page 2: optional. Pick, then Skip anyway — the pick is dropped.
|
||||
flushSync(() => byLabel("Today")?.click());
|
||||
await flushAsync();
|
||||
flushSync(() => byLabel("Skip")?.click());
|
||||
await flushAsync();
|
||||
expect(container.textContent).toContain("Who?");
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
// Last page: primary reads Submit answers and sends everything.
|
||||
expect(byLabel("Next")).toBeUndefined();
|
||||
flushSync(() => byLabel("Me")?.click());
|
||||
await flushAsync();
|
||||
flushSync(() => byLabel("Submit answers")?.click());
|
||||
await flushAsync();
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
const response = onSubmit.mock.calls[0]?.[0];
|
||||
expect(response.answers.env).toEqual({ selectedOptionIds: ["staging"] });
|
||||
expect(response.answers.when).toBeUndefined();
|
||||
expect(response.answers.who).toEqual({ selectedOptionIds: ["me"] });
|
||||
});
|
||||
|
||||
it("Skip on the last question submits the other answers", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<TaskChatComposer
|
||||
onAdd={vi.fn()}
|
||||
workMode="standard"
|
||||
takeover={{
|
||||
id: "optional-tail",
|
||||
label: "Questions",
|
||||
pendingCount: 1,
|
||||
inlineSkip: true,
|
||||
content: (
|
||||
<QuestionForm
|
||||
id="optional-tail"
|
||||
questionSet={{
|
||||
schema: "paperclip.question_set.v1",
|
||||
questions: [
|
||||
{
|
||||
id: "env",
|
||||
prompt: "Where?",
|
||||
required: false,
|
||||
answerMode: "single_select",
|
||||
options: [{ id: "staging", label: "Staging" }],
|
||||
},
|
||||
{
|
||||
id: "notes",
|
||||
prompt: "Anything else?",
|
||||
required: false,
|
||||
answerMode: "text",
|
||||
},
|
||||
],
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
),
|
||||
onDismiss: vi.fn(),
|
||||
onSkip: vi.fn(),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const byLabel = (label: string) =>
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === label,
|
||||
);
|
||||
flushSync(() => byLabel("Staging")?.click());
|
||||
flushSync(() => byLabel("Next")?.click());
|
||||
await flushAsync();
|
||||
expect(container.textContent).toContain("Anything else?");
|
||||
flushSync(() => byLabel("Skip")?.click());
|
||||
await flushAsync();
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({
|
||||
answers: { env: { selectedOptionIds: ["staging"] } },
|
||||
});
|
||||
});
|
||||
|
||||
it("Skip on the last question returns to a required question the arrows walked past", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<TaskChatComposer
|
||||
onAdd={vi.fn()}
|
||||
workMode="standard"
|
||||
takeover={{
|
||||
id: "walked-past",
|
||||
label: "Questions",
|
||||
pendingCount: 1,
|
||||
inlineSkip: true,
|
||||
content: (
|
||||
<QuestionForm
|
||||
id="walked-past"
|
||||
questionSet={{
|
||||
schema: "paperclip.question_set.v1",
|
||||
questions: [
|
||||
{
|
||||
id: "env",
|
||||
prompt: "Where?",
|
||||
required: true,
|
||||
answerMode: "single_select",
|
||||
options: [{ id: "staging", label: "Staging" }],
|
||||
},
|
||||
{
|
||||
id: "notes",
|
||||
prompt: "Anything else?",
|
||||
required: false,
|
||||
answerMode: "text",
|
||||
},
|
||||
],
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
),
|
||||
onDismiss: vi.fn(),
|
||||
onSkip: vi.fn(),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const byLabel = (label: string) =>
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === label,
|
||||
);
|
||||
// The pagination arrow browses past the unanswered required question.
|
||||
const arrow = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Next question"]',
|
||||
);
|
||||
flushSync(() => arrow?.click());
|
||||
await flushAsync();
|
||||
expect(container.textContent).toContain("Anything else?");
|
||||
|
||||
// Skip here would send; instead the form goes back and says why.
|
||||
flushSync(() => byLabel("Skip")?.click());
|
||||
await flushAsync();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("Where?");
|
||||
expect(container.textContent).toContain(
|
||||
"Question 1 needs an answer before you can send.",
|
||||
);
|
||||
});
|
||||
|
||||
it("Cancel closes the takeover and leaves the request pending", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
const onSkip = vi.fn();
|
||||
render(
|
||||
<TaskChatComposer
|
||||
onAdd={vi.fn()}
|
||||
workMode="standard"
|
||||
takeover={{
|
||||
id: "cancelable",
|
||||
label: "Questions",
|
||||
pendingCount: 1,
|
||||
inlineSkip: true,
|
||||
content: (
|
||||
<QuestionForm
|
||||
id="cancelable"
|
||||
questionSet={{
|
||||
schema: "paperclip.question_set.v1",
|
||||
questions: [
|
||||
{
|
||||
id: "env",
|
||||
prompt: "Where?",
|
||||
required: true,
|
||||
answerMode: "single_select",
|
||||
options: [{ id: "staging", label: "Staging" }],
|
||||
},
|
||||
],
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
),
|
||||
onDismiss,
|
||||
onSkip,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cancel = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((button) => button.textContent?.trim() === "Cancel");
|
||||
expect(cancel).not.toBeUndefined();
|
||||
flushSync(() => cancel?.click());
|
||||
await flushAsync();
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
expect(onSkip).not.toHaveBeenCalled();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("places Skip beside Submit answers for structured questions", () => {
|
||||
render(
|
||||
<TaskChatComposer
|
||||
|
|
@ -1603,7 +1928,7 @@ describe("TaskChatComposer", () => {
|
|||
{
|
||||
id: "environment",
|
||||
prompt: "Which environment should receive this?",
|
||||
required: true,
|
||||
required: false,
|
||||
answerMode: "multi_select",
|
||||
options: [
|
||||
{ id: "staging", label: "Staging", recommended: true },
|
||||
|
|
|
|||
|
|
@ -902,6 +902,7 @@ export function TaskChatComposer({
|
|||
takeoverSkipButton
|
||||
? takeoverSkipButton
|
||||
: null,
|
||||
dismiss: takeover.onDismiss,
|
||||
headerSlot: takeoverHeaderSlot,
|
||||
controlsSlot: takeoverControlsSlot,
|
||||
setHeaderClaimed: setTakeoverHeaderClaimed,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { createPortal } from "react-dom";
|
|||
|
||||
interface TaskChatComposerTakeoverActions {
|
||||
skipButton: ReactNode;
|
||||
/** Hides the takeover without resolving it, returning the plain composer. */
|
||||
dismiss: () => void;
|
||||
headerSlot: HTMLElement | null;
|
||||
controlsSlot: HTMLElement | null;
|
||||
setHeaderClaimed: (claimed: boolean) => void;
|
||||
|
|
|
|||
|
|
@ -437,6 +437,12 @@ describe("TaskChatInteractionCard", () => {
|
|||
button.textContent?.includes("Only collapse hidden descendants"),
|
||||
);
|
||||
await act(async () => firstAnswer?.click());
|
||||
// Picking only selects; Next moves to the second question.
|
||||
expect(container.textContent).toContain("1 of 2");
|
||||
const next = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Next",
|
||||
);
|
||||
await act(async () => next?.click());
|
||||
|
||||
expect(container.textContent).toContain("2 of 2");
|
||||
expect(container.textContent).toContain(
|
||||
|
|
|
|||
|
|
@ -604,14 +604,23 @@ describe("TaskChatProtocolCard", () => {
|
|||
(button) => button.textContent?.includes("Production"),
|
||||
);
|
||||
await act(async () => production?.click());
|
||||
// Picking only selects; the primary button reads Next until the last
|
||||
// question, where it takes the set's submit label.
|
||||
const nextButton = () =>
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||||
(button) => button.textContent?.trim() === "Next",
|
||||
);
|
||||
expect(container.textContent).toContain("Where should we deploy?");
|
||||
await act(async () => nextButton()?.click());
|
||||
expect(container.textContent).toContain(
|
||||
"Which regions should receive the release?",
|
||||
);
|
||||
const progress = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((button) => button.textContent?.trim() === "Continue");
|
||||
expect(progress).not.toBeUndefined();
|
||||
await act(async () => progress?.click());
|
||||
expect(
|
||||
Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Continue",
|
||||
),
|
||||
).toBeUndefined();
|
||||
await act(async () => nextButton()?.click());
|
||||
expect(container.textContent).toContain("Anything else we should know?");
|
||||
const submit = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === "Continue",
|
||||
|
|
|
|||
|
|
@ -1,153 +0,0 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Goal } from "@paperclipai/shared";
|
||||
import { useCompanyMission } from "./useCompanyMission";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const mockGoalsApi = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
|
||||
vi.mock("../api/goals", () => ({ goalsApi: mockGoalsApi }));
|
||||
|
||||
function companyGoal(id: string): Goal {
|
||||
return {
|
||||
id,
|
||||
companyId: "company-1",
|
||||
title: "Ship the thing",
|
||||
description: null,
|
||||
level: "company",
|
||||
status: "active",
|
||||
parentId: null,
|
||||
ownerAgentId: null,
|
||||
createdAt: new Date("2026-03-02T00:00:00Z"),
|
||||
updatedAt: new Date("2026-03-02T00:00:00Z"),
|
||||
} as Goal;
|
||||
}
|
||||
|
||||
let captured: ReturnType<typeof useCompanyMission> | null = null;
|
||||
|
||||
function Harness({ companyId }: { companyId: string | null }) {
|
||||
captured = useCompanyMission(companyId);
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useCompanyMission", () => {
|
||||
let container: HTMLDivElement;
|
||||
let queryClient: QueryClient;
|
||||
let root: Root | null = null;
|
||||
|
||||
function render(companyId: string | null) {
|
||||
root = createRoot(container);
|
||||
flushSync(() => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness companyId={companyId} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// React Query resolves through microtasks and React schedules the re-render
|
||||
// after them, so a single tick is not reliably enough under load. Drain
|
||||
// until the hook reports an answer rather than guessing at a tick count.
|
||||
async function settle() {
|
||||
for (let i = 0; i < 50 && !captured?.settled; i++) {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
captured = null;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
root = null;
|
||||
queryClient.clear();
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("withholds an answer while the lookup is in flight", () => {
|
||||
mockGoalsApi.list.mockReturnValue(new Promise(() => {}));
|
||||
render("company-1");
|
||||
|
||||
expect(captured).toEqual({
|
||||
hasMission: undefined,
|
||||
settled: false,
|
||||
mission: { goalId: null, goalInput: "" },
|
||||
fetching: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a mission when the company has a company-level goal", async () => {
|
||||
mockGoalsApi.list.mockResolvedValue([companyGoal("goal-1")]);
|
||||
render("company-1");
|
||||
await settle();
|
||||
|
||||
// The mission comes back in the shape the wizard's textarea holds, so the
|
||||
// agent step can seed the lead agent's instructions from it.
|
||||
expect(captured).toEqual({
|
||||
hasMission: true,
|
||||
settled: true,
|
||||
mission: { goalId: "goal-1", goalInput: "Ship the thing" },
|
||||
fetching: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports no mission when the company has no company-level goal", async () => {
|
||||
mockGoalsApi.list.mockResolvedValue([]);
|
||||
render("company-1");
|
||||
await settle();
|
||||
|
||||
expect(captured).toEqual({
|
||||
hasMission: false,
|
||||
settled: true,
|
||||
mission: { goalId: null, goalInput: "" },
|
||||
fetching: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("settles with an unknown mission when the lookup fails", async () => {
|
||||
// The fail-open rule. Waiting for the data itself would leave `settled`
|
||||
// false forever after a request exhausts its retries, and every caller
|
||||
// gates opening onboarding on it — an agentless company would then get no
|
||||
// onboarding at all, which is worse than being asked for its mission
|
||||
// twice.
|
||||
mockGoalsApi.list.mockRejectedValue(new Error("goals unavailable"));
|
||||
render("company-1");
|
||||
await settle();
|
||||
|
||||
expect(captured).toEqual({
|
||||
hasMission: undefined,
|
||||
settled: true,
|
||||
mission: { goalId: null, goalInput: "" },
|
||||
fetching: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("settles immediately when there is no company to ask about", () => {
|
||||
// A disabled query stays pending forever. Reading that as "still loading"
|
||||
// is the same failure as above, reached without a request.
|
||||
render(null);
|
||||
|
||||
expect(captured).toEqual({
|
||||
hasMission: undefined,
|
||||
settled: true,
|
||||
mission: { goalId: null, goalInput: "" },
|
||||
fetching: false,
|
||||
});
|
||||
expect(mockGoalsApi.list).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { goalsApi } from "../api/goals";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { selectDefaultCompanyGoalId } from "../lib/onboarding-launch";
|
||||
import {
|
||||
selectExistingCompanyMission,
|
||||
type ExistingCompanyMission,
|
||||
} from "../lib/onboarding-mission";
|
||||
|
||||
/**
|
||||
* Whether a company already has its mission, for deciding which onboarding
|
||||
* step it belongs on.
|
||||
*
|
||||
* A company created by Paperclip Cloud does have one: Cloud collects the
|
||||
* mission at signup and the tenant writes it as a company-level goal. Opening
|
||||
* such a company on the mission step asks the customer something they answered
|
||||
* minutes earlier on another origin.
|
||||
*
|
||||
* `settled` says whether the answer can be acted on. Callers wait for it
|
||||
* before opening the wizard, because the wizard applies a step once, when it
|
||||
* opens, and does not revise it afterwards — see the sync effect in
|
||||
* `OnboardingWizard`. A step decided before the lookup finishes would be the
|
||||
* step the customer is left on.
|
||||
*
|
||||
* Settled, not answered, on purpose. A gate that waits for the data itself
|
||||
* fails closed: a goals request that exhausts its retries leaves the value
|
||||
* undefined forever, and onboarding would then never open at all. `hasMission`
|
||||
* stays `undefined` after a failure, which {@link onboardingStepForCompany}
|
||||
* reads as "no mission" — the customer is asked for it again, and the flow
|
||||
* continues. Asking a question twice is recoverable; never opening onboarding
|
||||
* is not. This is the same fail-open rule the wake and provisioning readiness
|
||||
* gates follow: a check that guards a convenience must never be able to block
|
||||
* the thing it guards.
|
||||
*
|
||||
* `mission` carries the same goal back in the shape the wizard's mission
|
||||
* textarea holds it. A company entered on the agent step never runs steps 1
|
||||
* and 2, so that field is otherwise empty — and it is what seeds the lead
|
||||
* agent's instructions, so an empty one costs the customer the mission they
|
||||
* gave at signup.
|
||||
*
|
||||
* `fetching` is exposed separately from `settled` for the same reason the
|
||||
* draft ownership gate distinguishes them: retained goals from a previous read
|
||||
* are the right company's but not necessarily its current mission, so a
|
||||
* consumer that must not act on a stale mission waits on this rather than on
|
||||
* `settled`.
|
||||
*
|
||||
* The goal list is read under the query key the launch path already uses, so
|
||||
* this shares that cache entry rather than adding a request.
|
||||
*/
|
||||
export function useCompanyMission(companyId: string | null | undefined): {
|
||||
hasMission: boolean | undefined;
|
||||
settled: boolean;
|
||||
mission: ExistingCompanyMission;
|
||||
fetching: boolean;
|
||||
} {
|
||||
const { data: goals, isPending, isFetching } = useQuery({
|
||||
queryKey: queryKeys.goals.list(companyId ?? ""),
|
||||
queryFn: () => goalsApi.list(companyId!),
|
||||
enabled: Boolean(companyId),
|
||||
});
|
||||
|
||||
return {
|
||||
hasMission: goals ? selectDefaultCompanyGoalId(goals) !== null : undefined,
|
||||
// A disabled query stays pending forever, so no company means nothing to
|
||||
// wait for rather than an answer that never comes.
|
||||
settled: !companyId || !isPending,
|
||||
mission: goals
|
||||
? selectExistingCompanyMission(goals)
|
||||
: { goalId: null, goalInput: "" },
|
||||
fetching: Boolean(companyId) && isFetching,
|
||||
};
|
||||
}
|
||||
|
|
@ -2412,7 +2412,7 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
|||
--sz-calc-5: min(1280px,calc(100vw - 2rem)); /* Extracted from ui/src/components/FileViewerSheet.tsx (max-w-[min(1280px,calc(100vw-2rem))]). */
|
||||
--sz-94vw: 94vw; /* Extracted from ui/src/components/FileViewerSheet.tsx (w-[94vw]). */
|
||||
--sz-1280px: 1280px; /* Extracted from ui/src/components/FileViewerSheet.tsx (max-w-[1280px]). */
|
||||
--sz-60vh: 60vh; /* Extracted from ui/src/components/FrontDoor.tsx (min-h-[60vh]). */
|
||||
--sz-60vh: 60vh; /* Used by ui/src/pages/SkillStudio.tsx and others (min-h/max-h-[60vh]). */
|
||||
--sz-52vh: 52vh;
|
||||
--sz-40rem: 40rem;
|
||||
--sz-44rem: 44rem;
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
interface ComposeCeoInstructionsInput {
|
||||
companyName: string;
|
||||
companyGoal: string;
|
||||
growPath: boolean;
|
||||
growWorkflows: string;
|
||||
growPainPoints: string;
|
||||
growAutomate: string;
|
||||
q1: string;
|
||||
q2: string;
|
||||
q3: string;
|
||||
q4: string;
|
||||
}
|
||||
|
||||
export function composeCeoInstructions(input: ComposeCeoInstructionsInput): string {
|
||||
const {
|
||||
companyName,
|
||||
companyGoal,
|
||||
growPath,
|
||||
growWorkflows,
|
||||
growPainPoints,
|
||||
growAutomate,
|
||||
q1,
|
||||
q2,
|
||||
q3,
|
||||
q4,
|
||||
} = input;
|
||||
|
||||
const contextLines: string[] = [];
|
||||
contextLines.push(`**Company:** ${companyName}`);
|
||||
if (companyGoal.trim()) contextLines.push(`**Mission:** ${companyGoal.trim()}`);
|
||||
|
||||
if (growPath) {
|
||||
if (growWorkflows.trim()) contextLines.push(`**Existing workflows:** ${growWorkflows.trim()}`);
|
||||
if (growPainPoints.trim()) contextLines.push(`**Pain points:** ${growPainPoints.trim()}`);
|
||||
if (growAutomate.trim()) contextLines.push(`**First automation priority:** ${growAutomate.trim()}`);
|
||||
} else {
|
||||
if (q1.trim()) contextLines.push(`**What we do:** ${q1.trim()}`);
|
||||
if (q2.trim()) contextLines.push(`**Who we serve:** ${q2.trim()}`);
|
||||
if (q3.trim()) contextLines.push(`**Biggest bottleneck:** ${q3.trim()}`);
|
||||
if (q4.trim()) contextLines.push(`**What success looks like:** ${q4.trim()}`);
|
||||
}
|
||||
|
||||
return `# Role
|
||||
|
||||
You are the Paperclip agent for ${companyName}. You report to the person who set up this team — they may be a solo founder, a manager inside a larger org, or one of several people each running their own team of agents. Work as their lead agent: understand what they're trying to accomplish, propose a plan, and coordinate the work.
|
||||
|
||||
Work with the user conversationally. Propose, don't decide. When the user asks for something concrete (a brief, a hiring plan, a roadmap, a pitch), produce a real artifact — save it as a document on the relevant task so they can review and approve.
|
||||
|
||||
# Company context (from onboarding)
|
||||
|
||||
${contextLines.join("\n")}
|
||||
|
||||
Use this context directly when you write any work product. Do not re-ask the user for information they've already shared.
|
||||
|
||||
# Hiring plan output format
|
||||
|
||||
Any time you produce a hiring plan, describe each role using the exact template below. Every role gets all seven sections. Use \`##\` for the role heading (numbered) and \`###\` for each section heading:
|
||||
|
||||
\`\`\`
|
||||
## 1. {Role Name}
|
||||
|
||||
### Summary
|
||||
One-line description of this role.
|
||||
|
||||
### Expertise & Responsibilities
|
||||
What this agent does; detailed responsibilities.
|
||||
|
||||
### Priorities
|
||||
Ordered list of what matters most.
|
||||
|
||||
### Boundaries
|
||||
What this role should NOT do.
|
||||
|
||||
### Tools & Permissions
|
||||
What tools and access this role needs.
|
||||
|
||||
### Communication
|
||||
Tone, style, and interaction guidelines.
|
||||
|
||||
### Collaboration & Escalation
|
||||
Who this role works with; escalation paths.
|
||||
\`\`\`
|
||||
|
||||
Follow this structure for every role in the plan.
|
||||
|
||||
# Document conventions
|
||||
|
||||
When the user asks for a specific work product, save it as a document on the task using these keys:
|
||||
|
||||
- Hiring plan → document key \`plan\`
|
||||
- Company brief → document key \`brief\`
|
||||
- 30-day outline → document key \`roadmap-30d\`
|
||||
- Intro pitch → document key \`pitch\`
|
||||
|
||||
Use these keys consistently so the user's review flows (and any parsing logic) can locate the right artifact.
|
||||
`;
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatOnboardingGoalInput,
|
||||
parseOnboardingGoalInput,
|
||||
} from "./onboarding-goal";
|
||||
|
||||
describe("parseOnboardingGoalInput", () => {
|
||||
it("uses a single-line goal as the title only", () => {
|
||||
expect(parseOnboardingGoalInput("Ship the MVP")).toEqual({
|
||||
title: "Ship the MVP",
|
||||
description: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("splits a multiline goal into title and description", () => {
|
||||
expect(
|
||||
parseOnboardingGoalInput(
|
||||
"Ship the MVP\nLaunch to 10 design partners\nMeasure retention",
|
||||
),
|
||||
).toEqual({
|
||||
title: "Ship the MVP",
|
||||
description: "Launch to 10 design partners\nMeasure retention",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatOnboardingGoalInput", () => {
|
||||
it("renders a title-only goal as a single line", () => {
|
||||
expect(formatOnboardingGoalInput("Ship the MVP")).toBe("Ship the MVP");
|
||||
});
|
||||
|
||||
it("renders a title and description as one editable block", () => {
|
||||
expect(
|
||||
formatOnboardingGoalInput("Ship the MVP", "Launch to 10 design partners"),
|
||||
).toBe("Ship the MVP\n\nLaunch to 10 design partners");
|
||||
});
|
||||
|
||||
it("treats a null or blank description as absent", () => {
|
||||
expect(formatOnboardingGoalInput("Ship the MVP", null)).toBe("Ship the MVP");
|
||||
expect(formatOnboardingGoalInput("Ship the MVP", " ")).toBe("Ship the MVP");
|
||||
});
|
||||
|
||||
it("round-trips a parsed goal back to the same parse", () => {
|
||||
const raw = "Ship the MVP\nLaunch to 10 design partners\nMeasure retention";
|
||||
const parsed = parseOnboardingGoalInput(raw);
|
||||
|
||||
expect(
|
||||
parseOnboardingGoalInput(
|
||||
formatOnboardingGoalInput(parsed.title, parsed.description),
|
||||
),
|
||||
).toEqual(parsed);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
export function parseOnboardingGoalInput(raw: string): {
|
||||
title: string;
|
||||
description: string | null;
|
||||
} {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return { title: "", description: null };
|
||||
}
|
||||
|
||||
const [firstLine, ...restLines] = trimmed.split(/\r?\n/);
|
||||
const title = firstLine.trim();
|
||||
const description = restLines.join("\n").trim();
|
||||
|
||||
return {
|
||||
title,
|
||||
description: description.length > 0 ? description : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of {@link parseOnboardingGoalInput}: render a stored goal back into
|
||||
* the single textarea the mission step edits.
|
||||
*
|
||||
* Used when the wizard is entered on a company that already has a mission, so
|
||||
* the mission can be shown and re-saved instead of retyped from scratch.
|
||||
*/
|
||||
export function formatOnboardingGoalInput(
|
||||
title: string,
|
||||
description?: string | null,
|
||||
): string {
|
||||
const trimmedTitle = title.trim();
|
||||
const trimmedDescription = description?.trim() ?? "";
|
||||
|
||||
if (!trimmedTitle) return trimmedDescription;
|
||||
if (!trimmedDescription) return trimmedTitle;
|
||||
|
||||
return `${trimmedTitle}\n\n${trimmedDescription}`;
|
||||
}
|
||||
|
|
@ -109,14 +109,12 @@ describe("onboarding launch payloads", () => {
|
|||
expect(
|
||||
buildOnboardingIssuePayload({
|
||||
title: " Hire your first engineer ",
|
||||
description: " Kick off the hiring plan ",
|
||||
assigneeAgentId: "agent-1",
|
||||
projectId: "project-1",
|
||||
goalId: "goal-1",
|
||||
}),
|
||||
).toEqual({
|
||||
title: "Hire your first engineer",
|
||||
description: "Kick off the hiring plan",
|
||||
assigneeAgentId: "agent-1",
|
||||
projectId: "project-1",
|
||||
goalId: "goal-1",
|
||||
|
|
@ -125,6 +123,17 @@ describe("onboarding launch payloads", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("sends no client description — the server owns the first task's brief", () => {
|
||||
const payload = buildOnboardingIssuePayload({
|
||||
title: "Task",
|
||||
assigneeAgentId: "agent-1",
|
||||
projectId: "project-1",
|
||||
goalId: null,
|
||||
});
|
||||
expect(payload).not.toHaveProperty("description");
|
||||
expect(payload.onboardingFirstTask).toBe(true);
|
||||
});
|
||||
|
||||
it("omits goal links when no default company goal exists", () => {
|
||||
expect(buildOnboardingProjectPayload(null)).toEqual({
|
||||
name: "Onboarding",
|
||||
|
|
@ -134,7 +143,6 @@ describe("onboarding launch payloads", () => {
|
|||
expect(
|
||||
buildOnboardingIssuePayload({
|
||||
title: "Task",
|
||||
description: "",
|
||||
assigneeAgentId: "agent-1",
|
||||
projectId: "project-1",
|
||||
goalId: null,
|
||||
|
|
|
|||
|
|
@ -46,23 +46,23 @@ export function selectReusableOnboardingProject<T extends Pick<Project, "name" |
|
|||
|
||||
export function buildOnboardingIssuePayload(input: {
|
||||
title: string;
|
||||
description: string;
|
||||
assigneeAgentId: string;
|
||||
projectId: string;
|
||||
goalId: string | null;
|
||||
}) {
|
||||
const title = input.title.trim();
|
||||
const description = input.description.trim();
|
||||
|
||||
return {
|
||||
title,
|
||||
...(description ? { description } : {}),
|
||||
// No client description: the server assembles the first task's brief from
|
||||
// its own markdown and ignores any description sent here.
|
||||
assigneeAgentId: input.assigneeAgentId,
|
||||
projectId: input.projectId,
|
||||
...(input.goalId ? { goalId: input.goalId } : {}),
|
||||
status: "todo" as const,
|
||||
// Marks the single onboarding first task so the server seeds an agent
|
||||
// greeting and the task-detail view suppresses the seeded-description bubble.
|
||||
// Marks the single onboarding first task so the server assembles + stores
|
||||
// the brief, seeds the agent greeting, and the task-detail view suppresses
|
||||
// the seeded-description bubble.
|
||||
onboardingFirstTask: true,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,132 +0,0 @@
|
|||
import type { Goal } from "@paperclipai/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseOnboardingGoalInput } from "./onboarding-goal";
|
||||
import {
|
||||
isExistingCompanyMissionUnresolved,
|
||||
selectExistingCompanyMission,
|
||||
} from "./onboarding-mission";
|
||||
|
||||
function goal(overrides: Partial<Goal> & Pick<Goal, "id" | "title">): Goal {
|
||||
return {
|
||||
companyId: "company-1",
|
||||
description: null,
|
||||
level: "company",
|
||||
status: "active",
|
||||
parentId: null,
|
||||
ownerAgentId: null,
|
||||
createdAt: new Date("2026-03-02T00:00:00Z"),
|
||||
updatedAt: new Date("2026-03-02T00:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("selectExistingCompanyMission", () => {
|
||||
it("reads the company goal back into the mission textarea", () => {
|
||||
expect(
|
||||
selectExistingCompanyMission([
|
||||
goal({ id: "goal-1", title: "Ship the cloud onboarding walk" }),
|
||||
]),
|
||||
).toEqual({
|
||||
goalId: "goal-1",
|
||||
goalInput: "Ship the cloud onboarding walk",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes the goal description below the title", () => {
|
||||
expect(
|
||||
selectExistingCompanyMission([
|
||||
goal({
|
||||
id: "goal-1",
|
||||
title: "Ship the cloud onboarding walk",
|
||||
description: "End to end, across all four apps.",
|
||||
}),
|
||||
]),
|
||||
).toEqual({
|
||||
goalId: "goal-1",
|
||||
goalInput:
|
||||
"Ship the cloud onboarding walk\n\nEnd to end, across all four apps.",
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips through the mission textarea's own parser", () => {
|
||||
const mission = selectExistingCompanyMission([
|
||||
goal({
|
||||
id: "goal-1",
|
||||
title: "Ship the cloud onboarding walk",
|
||||
description: "End to end, across all four apps.",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(parseOnboardingGoalInput(mission.goalInput)).toEqual({
|
||||
title: "Ship the cloud onboarding walk",
|
||||
description: "End to end, across all four apps.",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports no mission when the company has no company-level goal", () => {
|
||||
expect(
|
||||
selectExistingCompanyMission([
|
||||
goal({ id: "team-goal", title: "Nested", level: "team" }),
|
||||
]),
|
||||
).toEqual({ goalId: null, goalInput: "" });
|
||||
});
|
||||
|
||||
it("reports no mission for a company with no goals at all", () => {
|
||||
expect(selectExistingCompanyMission([])).toEqual({
|
||||
goalId: null,
|
||||
goalInput: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isExistingCompanyMissionUnresolved", () => {
|
||||
it("holds the hire until the existing company's goals have been read", () => {
|
||||
expect(
|
||||
isExistingCompanyMissionUnresolved({
|
||||
existingCompanyId: "company-1",
|
||||
goalsLoaded: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("releases the hire once they land", () => {
|
||||
expect(
|
||||
isExistingCompanyMissionUnresolved({
|
||||
existingCompanyId: "company-1",
|
||||
goalsLoaded: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("holds the hire while a cached read is being superseded", () => {
|
||||
expect(
|
||||
isExistingCompanyMissionUnresolved({
|
||||
existingCompanyId: "company-1",
|
||||
goalsLoaded: true,
|
||||
goalsFetching: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("still ignores an in-flight read for a company created in this run", () => {
|
||||
expect(
|
||||
isExistingCompanyMissionUnresolved({
|
||||
existingCompanyId: null,
|
||||
goalsLoaded: false,
|
||||
goalsFetching: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("never holds a company created in this run — step 2 typed its mission", () => {
|
||||
expect(
|
||||
isExistingCompanyMissionUnresolved({
|
||||
existingCompanyId: undefined,
|
||||
goalsLoaded: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isExistingCompanyMissionUnresolved({ existingCompanyId: null, goalsLoaded: false }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
import type { Goal } from "@paperclipai/shared";
|
||||
|
||||
import { formatOnboardingGoalInput, parseOnboardingGoalInput } from "./onboarding-goal";
|
||||
import { selectDefaultCompanyGoalId } from "./onboarding-launch";
|
||||
|
||||
export type ExistingCompanyMission = {
|
||||
goalId: string | null;
|
||||
goalInput: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read an existing company's mission back out of its goals, in the shape the
|
||||
* wizard's mission textarea holds.
|
||||
*
|
||||
* The wizard can be entered on a company that already exists — the
|
||||
* `/{prefix}/onboarding` route, the dashboard's auto-open, or an in-app "add
|
||||
* agent" entry. On those paths steps 1 and 2 never run, so the mission has to
|
||||
* come from the company rather than from the form. Two things downstream read
|
||||
* it: the Review step's checklist, and the lead agent's instructions bundle.
|
||||
*/
|
||||
export function selectExistingCompanyMission(goals: Goal[]): ExistingCompanyMission {
|
||||
const goalId = selectDefaultCompanyGoalId(goals);
|
||||
if (!goalId) return { goalId: null, goalInput: "" };
|
||||
|
||||
const goal = goals.find((entry) => entry.id === goalId) ?? null;
|
||||
|
||||
return {
|
||||
goalId,
|
||||
goalInput: goal ? formatOnboardingGoalInput(goal.title, goal.description) : "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an existing company's mission has yet to be read back from the
|
||||
* server.
|
||||
*
|
||||
* On an existing-company entry the mission is never typed — it is hydrated
|
||||
* from the company's goals. Until that read lands, the wizard's mission field
|
||||
* still holds whatever the last run saved, which may be empty or may belong to
|
||||
* an entirely different company. Hiring the lead agent inside that window
|
||||
* seeds its instructions from that field and reports nothing wrong, so the
|
||||
* hire waits for the read.
|
||||
*
|
||||
* `fetching` counts as unresolved even when data is already present. Loaded
|
||||
* goals can be a cached read from before the wizard opened, with the current
|
||||
* request still in flight; they are at least the right company's, but not
|
||||
* necessarily its current mission. This is the same distinction the draft
|
||||
* ownership gate draws — `isFetching`, not `isLoading` — and for the same
|
||||
* reason: retained data is not an answer to the question being asked now.
|
||||
*/
|
||||
export function isExistingCompanyMissionUnresolved(params: {
|
||||
existingCompanyId?: string | null;
|
||||
goalsLoaded: boolean;
|
||||
goalsFetching?: boolean;
|
||||
}): boolean {
|
||||
if (!params.existingCompanyId) return false;
|
||||
if (params.goalsFetching) return true;
|
||||
|
||||
return !params.goalsLoaded;
|
||||
}
|
||||
export type MissionGoalPayload = {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
level?: "company";
|
||||
status?: "active";
|
||||
};
|
||||
|
||||
export type MissionPersistencePlan =
|
||||
| { kind: "skip" }
|
||||
| { kind: "create"; payload: MissionGoalPayload }
|
||||
| { kind: "update"; goalId: string; payload: MissionGoalPayload };
|
||||
|
||||
/**
|
||||
* Decide what confirming the mission has to write.
|
||||
*
|
||||
* The wizard used to early-return whenever a company id was already present,
|
||||
* so a mission typed on an existing company was silently discarded. An existing
|
||||
* company still needs no `companies.create` — but its mission must land on the
|
||||
* company-level goal, updating the goal the company already has rather than
|
||||
* creating a second one.
|
||||
*/
|
||||
export function planMissionPersistence(params: {
|
||||
goalInput: string;
|
||||
existingGoalId: string | null;
|
||||
}): MissionPersistencePlan {
|
||||
const parsed = parseOnboardingGoalInput(params.goalInput);
|
||||
if (!parsed.title) return { kind: "skip" };
|
||||
|
||||
if (params.existingGoalId) {
|
||||
return {
|
||||
kind: "update",
|
||||
goalId: params.existingGoalId,
|
||||
payload: { title: parsed.title, description: parsed.description },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "create",
|
||||
payload: {
|
||||
title: parsed.title,
|
||||
...(parsed.description ? { description: parsed.description } : {}),
|
||||
level: "company",
|
||||
status: "active",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -226,6 +226,29 @@ describe("shouldRouteAgentlessCompanyToOnboarding", () => {
|
|||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not trust a cached empty list while it is being refreshed", () => {
|
||||
// The wizard hires the first agent and lands on the first task. A
|
||||
// dashboard reached from there can still hold the empty list it cached
|
||||
// before the hire, with the refetch in flight — offering on it reopens
|
||||
// "Create your first agent" for a company that just got one.
|
||||
expect(
|
||||
shouldRouteAgentlessCompanyToOnboarding({
|
||||
pathname: "/PC1/dashboard",
|
||||
agentsLoaded: true,
|
||||
agentsRefreshing: true,
|
||||
agentCount: 0,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldRouteAgentlessCompanyToOnboarding({
|
||||
pathname: "/PC1/dashboard",
|
||||
agentsLoaded: true,
|
||||
agentsRefreshing: false,
|
||||
agentCount: 0,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not redirect onto onboarding from onboarding", () => {
|
||||
// The loop: finish the wizard without creating an agent, and a redirect
|
||||
// that ignored the current path would send you straight back in.
|
||||
|
|
|
|||
|
|
@ -137,13 +137,22 @@ export function resolveRouteOnboardingOptions(params: {
|
|||
* agents as `Agent[] | undefined` while the query is in flight, and an absent
|
||||
* list reads exactly like an empty one — redirecting on that would bounce
|
||||
* every user through onboarding on each cold load.
|
||||
*
|
||||
* `agentsRefreshing` covers the other way a count of zero lies: a cached list
|
||||
* that is being refetched. The wizard hires the first agent and then lands on
|
||||
* the first task; a dashboard reached from there can hold the empty list it
|
||||
* cached before the hire, with the refetch still in flight. Offering on that
|
||||
* list reopens "Create your first agent" for a company that just got one,
|
||||
* and the customer walks the agent and model steps a second time.
|
||||
*/
|
||||
export function shouldRouteAgentlessCompanyToOnboarding(params: {
|
||||
pathname: string;
|
||||
agentsLoaded: boolean;
|
||||
agentsRefreshing?: boolean;
|
||||
agentCount: number;
|
||||
}): boolean {
|
||||
if (!params.agentsLoaded) return false;
|
||||
if (params.agentsRefreshing) return false;
|
||||
if (params.agentCount > 0) return false;
|
||||
// Already there. Redirecting onto the path we are on is the loop that
|
||||
// "finished the wizard but created no agent" would otherwise spin in.
|
||||
|
|
|
|||
|
|
@ -80,7 +80,10 @@ export function Dashboard() {
|
|||
const hydratedActivityRef = useRef(false);
|
||||
const activityAnimationTimersRef = useRef<number[]>([]);
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
// `isFetching` is read alongside the data: a cached list is served while its
|
||||
// refetch runs, and an empty one from before the first hire must not pass
|
||||
// for the company's current state — see `shouldRouteAgentlessCompanyToOnboarding`.
|
||||
const { data: agents, isFetching: agentsRefreshing } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
|
|
@ -132,6 +135,7 @@ export function Dashboard() {
|
|||
const shouldOpenOnboarding = shouldRouteAgentlessCompanyToOnboarding({
|
||||
pathname: location.pathname,
|
||||
agentsLoaded: agents !== undefined,
|
||||
agentsRefreshing,
|
||||
agentCount: agents?.length ?? 0,
|
||||
});
|
||||
// Auto-open once per company. Every input to the effect sits behind a query,
|
||||
|
|
@ -308,7 +312,10 @@ export function Dashboard() {
|
|||
return <PageSkeleton variant="dashboard" />;
|
||||
}
|
||||
|
||||
const hasNoAgents = agents !== undefined && agents.length === 0;
|
||||
// Same rule as the auto-offer above: a list still being refreshed may be the
|
||||
// empty one cached before the first hire, and the banner's "Create one here"
|
||||
// opens the same agent step the offer does.
|
||||
const hasNoAgents = agents !== undefined && !agentsRefreshing && agents.length === 0;
|
||||
const pausedBanner = derivePausedAgentBanner(agents);
|
||||
const pausedImportedCount =
|
||||
pausedBanner?.kind === "imported" ? pausedBanner.pausedImportedAgentIds.length : 0;
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
|
|||
enableServerInfoDebugView: false,
|
||||
enablePaperclipDeveloperMode: false,
|
||||
enableSimplifiedEnglishInteractions: false,
|
||||
enableFirstTaskPlanProposal: false,
|
||||
enableSmokeLab: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableWorkspaceBranchReconcileForward: true,
|
||||
|
|
|
|||
|
|
@ -229,6 +229,8 @@ export function InstanceExperimentalSettings() {
|
|||
experimentalQuery.data?.enablePaperclipDeveloperMode === true;
|
||||
const enableSimplifiedEnglishInteractions =
|
||||
experimentalQuery.data?.enableSimplifiedEnglishInteractions === true;
|
||||
const enableFirstTaskPlanProposal =
|
||||
experimentalQuery.data?.enableFirstTaskPlanProposal === true;
|
||||
const enableSmokeLab = experimentalQuery.data?.enableSmokeLab === true;
|
||||
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
|
||||
return (
|
||||
|
|
@ -403,6 +405,19 @@ export function InstanceExperimentalSettings() {
|
|||
ariaLabel="Toggle simplified english interactions experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="First task: propose with a plan document"
|
||||
description="When the user's first request is a single task, the chief of staff writes a short plan document and a checkbox card instead of a one-card confirmation. Applies to organizations created after the toggle is flipped."
|
||||
checked={enableFirstTaskPlanProposal}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleMutation.mutate({ enableFirstTaskPlanProposal: checked })
|
||||
}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableFirstTaskPlanProposal"
|
||||
managed={managedKeys.enableFirstTaskPlanProposal}
|
||||
ariaLabel="Toggle first task plan proposal experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Status Cards"
|
||||
description="Enable the experimental shared status-card board and its gated API. Existing card data is kept when this is disabled."
|
||||
|
|
|
|||
Loading…
Reference in New Issue