Files
PaperClipAI/server/src/services/onboarding-first-task-assets.test.ts
T
DottaandPaperclip c32e1ba6da fix(onboarding): keep approved child task handoffs runnable (#18160)
## Thinking Path

> - Paperclip manages work through tasks, agent runs, dependencies, and
human decisions.
> - The first-task workflow asks for approval before it creates an
execution task.
> - File-producing work must run on a child task before onboarding can
finish.
> - A child blocked by its onboarding source cannot start while the
source waits for that child.
> - This change rejects that dependency during task creation and later
edits, and gives the agent a correction.
> - The shipped recipe shows the dependency direction and requires the
actual approved inputs in the child instructions.

## Linked Issues or Issue Description

**What happened?**

After human approval, an onboarding run could create a child with the
onboarding source in `blockedByTaskIds`. Creation saved a blocked child
with no wake. The parent could then wait for that child. A handoff could
also refer to input from the parent conversation without including the
input in the child description.

**Expected behavior**

The approved child can execute before its waiting parent. Invalid
first-task dependency input fails before task creation and leaves the
same command key available for a corrected call. The child instructions
include the required approved input and constraints.

**Steps to reproduce**

1. Accept a proposal on a server-created first task.
2. Call native `create_task` with `status: "todo"` and the onboarding
source in `blockedByTaskIds`.
3. Observe a blocked child with no scheduled wake. Block the parent on
that child to expose the stalled handoff.

**Paperclip version or commit**

The regression reproduces on `f8484a9f8d1f68e9534fee4f29e00e716dc680f4`.
The repair is based on current master.

**Deployment mode**

Core native and REST task creation. The same server boundary serves
self-hosted and hosted instances.

Related: #18080 fixed remote delivery of the selected first-task skill.
This change handles the later child handoff boundary.

## What Changed

- Reject a task blocked by its authenticated first-task source during
creation and later dependency edits. Later agent edits also check the
locked target's server-owned onboarding parent, protecting a child
running under its own execution. Compare UUID identity in PostgreSQL so
alternate supported UUID spellings cannot bypass the check. The error
explains the correction.
- Add a native child/dependency recipe with self-contained approved
input guidance. Clarify the general `create_task` input descriptions and
update their generated contracts.
- Cover native and REST rejection, corrected retry, preserved valid
dependencies and replay, and an executable recipe with exact input bytes
read from the child's own context. Thread the authenticated run through
native dependency edits. Keep blocker corrections independent of a new
proposal decision.
- Update execution semantics and the existing onboarding map entries.

## Verification

- Red on `f8484a9f`: the new native regression received a created
blocked child instead of the expected rejection.
- Review regressions were red on the original PR head: uppercase,
unhyphenated and braced source UUIDs created blocked children; a later
child PATCH returned 200 and saved the reversed blocker.
- Review follow-up reproduced both native and REST child-own-run
bypasses on `75fa75a6`: the native dependency call applied the reverse
edge; the child's own PATCH returned 200. The regression uses the actual
authenticated child execution and an already checked-out task.
- All 95 tests across first-task admission, assets, and the full native
tool-authority suite pass. The shipped recipe is validated against the
native schemas and exercised through real task/dependency services with
disposable PostgreSQL. Rejected PATCH tests preserve existing
authorization and attempt-audit behavior and require no requested
task-field, relation or wake change. Compatibility controls retain
ordinary manual parent blockers, explicit board edits, other blockers
and empty-list corrections.
- Runner TypeScript build, generated contracts, capability inventory,
protocol manifest, and map checks passed.
- Server typecheck and all 95 focused tests pass with the child-run
follow-up. All latest checks pass on `f24509ae`: 55 successful checks,
one neutral review check, four skipped checks, and a successful legacy
security status. Both automated reviews are 5/5 on this exact commit.
- Prior-head CI failures are preserved: the Runner close fixture had
unconfirmed provider drain while suspension succeeded; its exact
fake-provider subprocess row passed locally with unchanged sources. The
browser outage test lost its title textarea before any outage write. A
bounded local control with a fully loaded composer passed every original
outage assertion. The prior failure is an editor-readiness race; its
exact asynchronous ordering remains unproved. No UI, browser-test, or
timeout change is included. Neither failure is counted as a pass or
silently discarded.
- The first `f24509ae` CI attempt had two interrupted jobs: Typecheck
and General server 9/12. Their logs record runner shutdown signals. The
server job also records lost runner communication. The same-head
failed-job rerun passed both jobs and the required aggregate. It reused
the other successful results. No source, test, or timeout change was
made for the rerun. The failed attempt remains preserved. Full
repository checks pass in CI; the local full repository typecheck, test,
and build commands were not repeated.
- No external model or provider calls are used by these checks.
Real-provider acceptance at this revision remains separate.

## Risks

- The new rejection uses the authenticated run and the server-owned
onboarding origin. Ordinary tasks, other blockers, empty-list
corrections, and already committed creation replay retain their existing
behavior. Blocker edits do not reapply the creation approval gate.
- Nonempty blocker edits perform two indexed identity reads; the task
lookup covers at most the run source and locked structural parent. Other
patches and empty-list corrections add no source lookup. The update
guard adds no parent row lock behind a child. Existing REST
authorization checkout and attempt auditing remain in place before the
domain mutation guard.
- The explicit input recipe guides the model. Schema and persistence
tests do not prove that a future model follows every instruction.
- The tool guidance changes the generated semantic catalog identity. The
maintained generated files are synchronized in this change.

## Model Used

OpenAI `gpt-6.1-sol` through Codex, with `ultra` reasoning effort,
repository tools, code generation, and test execution. The context
window is not exposed by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used and stated the available runtime
detail
- [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 described the issue in-PR following the bug issue template
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
ticket ID
- [x] I have run focused 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 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-10-29 10:57:31 -05: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("Create exactly one child linked to this onboarding task");
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("On later wakes, continue that same child instead of creating another");
});
});
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" }),
});
});
});