Files
PaperClipAI/server/src/services/onboarding-first-task-assets.test.ts
T
DottaandPaperclip ce1ee9e89f fix: stop prompts from encouraging subtasks (#20362)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents read runtime prompts, tool help, skills, and role
instructions.
> - Several of these texts tell agents to create child tasks as a normal
next step.
> - One accepted-plan prompt even forbids work on the current task.
> - Task parentage is useful, but it does not need a prompt that
encourages task creation.
> - This pull request removes those prompts and keeps the task APIs
available.

## Linked Issues or Issue Description

Refs #10642. This change adjusts prompt wording; it does not add
delegation-cycle guards.

**Current behavior**
Prompts tell agents to create children after plan approval, use children
for long work, and create a task just to pass context. Onboarding
requires a child even for a single request.

**Proposed behavior**
Continue work on the current task. Keep explicit assignments, optional
task creation, parentage, dependencies, and approval gates. Use the
separate-task recipes when the user requests that structure or when
continuing existing work.

**Reason and benefit**
Reduce needless task trees without removing subtask support. Keep the
patch limited to wording, existing test expectations, and generated
catalog data.

## What Changed

- Align accepted-plan prompts and remove direct child-creation advice
from runtime and error text.
- Make task tool descriptions neutral. Keep their fields, defaults, and
handlers.
- Update the main skill, onboarding, CEO instructions, company
templates, specialist workflows, and matching guides.
- Keep historical release snapshots intact. Refresh the explicitly
seeded schema fixture's catalog digest; the server still verifies
historical release snapshot hashes.
- Refresh the affected generated catalogs, capability inventory,
semantic contracts, and fixture manifest.

## Verification

- Final tested head: `c6e304d0c105cdbfbe0b2ab0a233307fcd91a04b`.
- All 66 check runs are complete: 60 pass, one is neutral, and five are
skipped by CI conditions. Snyk also passes. No checks fail or remain
pending.
- [PR
CI](https://github.com/paperclipai/paperclip/actions/runs/42846226554)
passes typecheck, build, server/general tests, all eight E2E shards,
Runner support, Runner Rust, and the aggregate verify gate.
[Windows](https://github.com/paperclipai/paperclip/actions/runs/42846226546)
and [Docker
Runner](https://github.com/paperclipai/paperclip/actions/runs/42846226108)
also pass.
-
[Greptile](https://github.com/paperclipai/paperclip/pull/20362#issuecomment-6751419835)
and [Paperclip
review](https://github.com/paperclipai/paperclip/pull/20362#issuecomment-6751454010)
give this exact head 5/5. All six inline threads are resolved. Neither
current summary has findings.
- Merged master `bf0220d34a156c81853755c3d1365ddc527037de`. Resolved
three generated-file conflicts with the canonical generators.
- Local verification passes: 473 focused prompt, continuation, UI,
catalog, runtime and native-execution tests; Runner TypeScript build;
generated metadata; UI token gates; and diff checks.
- The prior head `c22018cb0f` failed three SDK evidence tests because
two source hashes were stale. Reproduced all three failures locally. The
existing generator refreshed only the hashes for the denial-copy file
and its test. All 53 affected evidence/source-pin tests then passed,
including private-value rejection. No scanner logic, assertions, or
runtime dependency pins changed. Current CI confirms the support suite
passes.
- A prior commit failed the Rust process reaping test. Its cause remains
unproven. No Rust code or assertions changed. Later Rust runs pass; this
does not establish the cause of the earlier failure.
- Local preflight built shared/plugin SDK packages, then stopped because
`cargo` is absent. Current-head CI supplies Rust and full build
coverage. `pnpm verify --explain` selects broad required CI; full local
suites were not repeated.

## Risks

- Wording changes guide the model; they do not enforce a ban on
subtasks.
- Existing installed skill versions and saved custom instructions need
their normal refresh process. This PR does not migrate company
configuration.
- Existing approved task assignments and approval gates remain in
effect.

## Model Used

- OpenAI GPT-6, through Codex, with reasoning, tool use, and code
execution. The exact routed model ID and context-window size were not
exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-10 09:58:54 -06:00

185 lines
9.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { readFile } from "node:fs/promises";
import { Ajv2020 } from "ajv/dist/2020.js";
import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "@paperclipai/paperclip-runner";
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.each([
{ usePlanProposal: false, mode: "confirmation" },
{ usePlanProposal: true, mode: "plan" },
])("invokes the skill with $mode mode without inlining the policy", async ({ usePlanProposal, mode }) => {
const brief = await buildOnboardingFirstTaskBrief({ usePlanProposal });
expect(brief).toContain("Use the `first-task` skill (/first-task)");
expect(brief).toContain("Read its SKILL.md");
expect(brief).toContain("subsequent wakes of this task");
expect(brief).toContain(`Single-task proposal mode: \`${mode}\`.`);
expect(brief).not.toContain("{{");
expect(brief).not.toContain("Take the path the user picked.");
expect(brief).not.toContain("request_confirmation");
expect(brief).not.toContain("request_checkbox_confirmation");
});
});
describe("first-task proposal mode policy", () => {
it("maps both persisted brief modes to their proposal forms", async () => {
const skill = await readFile(new URL("../onboarding-assets/first-task/skills/first-task/SKILL.md", import.meta.url), "utf8");
expect(skill).toContain("`confirmation` means one `request_confirmation`");
expect(skill).toContain("`plan` means save a short `plan` document");
expect(skill).toContain("`request_checkbox_confirmation` targeting its saved revision");
expect(skill).toContain("explicit plan requests regardless of the single-task proposal mode");
});
it("hands accepted file-producing child work to its own execution before reporting results", async () => {
const skill = await readFile(new URL("../onboarding-assets/first-task/skills/first-task/SKILL.md", import.meta.url), "utf8");
expect(skill).toContain("If the accepted child work requires a file deliverable");
expect(skill).toContain("self-contained child description");
expect(skill).toContain("requested filenames");
expect(skill).toContain("child's own Paperclip execution");
expect(skill).toContain("even when the child is assigned to yourself");
expect(skill).toContain("Do not create or register that file from the onboarding run");
expect(skill).toContain("`register_deliverable` is bound to its current task");
expect(skill).toContain("preserve existing blocker IDs");
expect(skill).toContain("`set_dependencies`");
expect(skill).toContain("`paperclip_block`");
expect(skill).toContain("`blockedByIssueIds`");
expect(skill).toContain("End the turn so the child can execute");
expect(skill).toContain("Do not poll for its result");
expect(skill).toContain("verify the saved child document and retrievable file");
expect(skill).toContain("actual child task and download links");
});
it("ships native child handoff arguments that let the child run before its waiting parent", async () => {
const skill = await readFile(new URL("../onboarding-assets/first-task/skills/first-task/SKILL.md", import.meta.url), "utf8");
const recipe = [...skill.matchAll(/```json\s+([\s\S]*?)```/g)]
.map((match) => JSON.parse(match[1]!) as Record<string, Record<string, unknown>>)
.find((value) => value.createTask && value.setDependencies);
if (!recipe) throw new Error("Shipped first-task skill is missing the native child handoff recipe");
expect(Object.keys(recipe).sort()).toEqual(["createTask", "setDependencies"]);
expect(recipe.createTask).toEqual({
idempotencyKey: "first-task-child:<proposal-id>",
title: "<child-title>",
description: "<approved-child-description>",
assigneeActorId: "<self-agent-id>",
status: "todo",
blockedByTaskIds: [],
});
expect(recipe.setDependencies).toEqual({
idempotencyKey: "first-task-wait:<proposal-id>",
blockedByTaskIds: ["<existing-blocker-id>", "<child-task-id>"],
});
const ajv = new Ajv2020({ allErrors: true, allowUnionTypes: true, strict: true });
for (const [operationId, input] of [["create_task", recipe.createTask], ["set_dependencies", recipe.setDependencies]] as const) {
const descriptor = CAPABILITY_SEMANTIC_TOOL_CATALOG.find((tool) => tool.operationId === operationId);
if (!descriptor) throw new Error(`Missing native tool descriptor: ${operationId}`);
const validate = ajv.compile(descriptor.inputSchema);
expect(validate(input), JSON.stringify(validate.errors)).toBe(true);
expect(validate({ ...input, notAToolArgument: true })).toBe(false);
}
expect(skill).toContain("Copy required input payloads verbatim");
expect(skill).toContain("encoded bytes, nonces, constraints, and requested filenames");
expect(skill).toContain('Do not leave references such as "provided above"');
expect(skill).toContain("Parent-child linkage is not a dependency");
});
it("keeps plan-only authorization and document-only child execution distinct from file handoff", async () => {
const skill = await readFile(new URL("../onboarding-assets/first-task/skills/first-task/SKILL.md", import.meta.url), "utf8");
expect(skill).toContain("For a plan-only request, retain the accepted plan on this task");
expect(skill).toContain("Do not start its implementation or hire the proposed team without authorization");
expect(skill).toContain("For an accepted single task, complete the work here.");
expect(skill).toContain("For document-only child work, you may execute it directly");
expect(skill).toContain("Save the finished output as a document on the child task and mark that child done");
expect(skill).toContain("reuse any task already created for it");
});
});
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).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).toEqual({
"AGENTS.md": await renderChiefOfStaffPersona({ agentName: "Ada", organizationName: "Acme" }),
});
});
});