mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
## 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>
185 lines
9.3 KiB
TypeScript
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" }),
|
|
});
|
|
});
|
|
});
|