mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
fix(onboarding): restore proposal modes and invoke Claude skills natively
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
b9f978a58a
commit
78452129ed
8 files changed
+105
-6
No files matched your search
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { claudeNativeSkillPrompt } from "./native-skill-prompt.js";
|
||||
|
||||
const envelope = (description: unknown, prompt = "Write the welcome note now.") => JSON.stringify({
|
||||
schema: "paperclip.native-model-envelope.v2",
|
||||
task: { description, prompt },
|
||||
interactionResponses: [{ response: { status: "accepted" } }],
|
||||
});
|
||||
|
||||
describe("Claude native task skill invocation", () => {
|
||||
it.each(["Use the `first-task` skill (/first-task).", "$first-task"])("invokes %s before the unchanged current request", (description) => {
|
||||
const text = envelope(description);
|
||||
expect(claudeNativeSkillPrompt(text, ["first-task", "paperclip"])).toBe(`/first-task ${text}`);
|
||||
});
|
||||
it.each([
|
||||
envelope("Ordinary task", "Please mention /first-task in the output."),
|
||||
envelope("/not-assigned"), envelope(null), envelope("/first-task-extra"),
|
||||
JSON.stringify({ schema: "another-envelope", task: { description: "/first-task" } }),
|
||||
"Use /first-task", "null", "[]",
|
||||
])("does not invoke from unrelated or malformed input: %s", (text) => {
|
||||
expect(claudeNativeSkillPrompt(text, ["first-task"])).toBe(text);
|
||||
});
|
||||
it("does not invent an assignment or pick between multiple requested skills", () => {
|
||||
const text = envelope("/first-task and /research");
|
||||
expect(claudeNativeSkillPrompt(text, [])).toBe(text);
|
||||
expect(claudeNativeSkillPrompt(text, ["first-task", "research"])).toBe(text);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NATIVE_MODEL_ENVELOPE_SCHEMA } from "../../contracts/native-execution.js";
|
||||
|
||||
/** Invoke one unambiguously selected assigned skill through Claude's native
|
||||
* command parser. Keep the complete envelope as its argument, including the
|
||||
* current request and approval context. Comments never select a command.
|
||||
* Multiple selections remain available to the Skill tool rather than picking one.
|
||||
*/
|
||||
export function claudeNativeSkillPrompt(text: string, assignedNames: readonly string[]): string {
|
||||
let envelope: unknown;
|
||||
try { envelope = JSON.parse(text); } catch { return text; }
|
||||
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return text;
|
||||
const value = envelope as Record<string, unknown>;
|
||||
if (value.schema !== NATIVE_MODEL_ENVELOPE_SCHEMA || !value.task || typeof value.task !== "object") return text;
|
||||
const description = (value.task as Record<string, unknown>).description;
|
||||
if (typeof description !== "string") return text;
|
||||
const references = new Set(Array.from(
|
||||
description.matchAll(/(?:^|[\s(`])[$/]([a-zA-Z0-9_-]+)(?=$|[\s)`,.;:!?])/g),
|
||||
(match) => match[1],
|
||||
));
|
||||
const selected = [...new Set(assignedNames)].filter((name) => references.has(name));
|
||||
return selected.length === 1 ? `/${selected[0]} ${text}` : text;
|
||||
}
|
||||
@@ -276,6 +276,7 @@ describe("ACPX runtime host", () => {
|
||||
aggregateDigest: canonicalNativeRuntimeContextDigest(snapshot),
|
||||
};
|
||||
let skillsHome = "";
|
||||
const providerStartTurn = vi.fn(() => runtimeTurn());
|
||||
let assigned = true;
|
||||
let expectedReference = "ASSIGNED_SKILL_MARKER";
|
||||
const dependencies = fixture.dependencies({
|
||||
@@ -290,6 +291,7 @@ describe("ACPX runtime host", () => {
|
||||
expect((await stat(reference)).mode & 0o222).toBe(0);
|
||||
}
|
||||
return runtimePort({
|
||||
startTurn: providerStartTurn,
|
||||
getStatus: async () => ({ models: { currentModelId: "claude-sonnet-5" } }),
|
||||
});
|
||||
},
|
||||
@@ -307,12 +309,34 @@ describe("ACPX runtime host", () => {
|
||||
{ ...options, runtimeContext: context },
|
||||
dependencies,
|
||||
);
|
||||
const message = JSON.stringify({
|
||||
schema: "paperclip.native-model-envelope.v2",
|
||||
task: { description: "Use /assigned", prompt: "A new direct user request" },
|
||||
interactionResponses: index ? [{ response: { status: "accepted" } }] : [],
|
||||
});
|
||||
host.startTurn({ text: message, requestId: `skill-turn-${index}` });
|
||||
expect(providerStartTurn).toHaveBeenLastCalledWith({
|
||||
text: `/assigned ${message}`, requestId: `skill-turn-${index}`,
|
||||
});
|
||||
await host.close({ reason: "reopen test" });
|
||||
// A changed source must replace the prior materialized revision on resume.
|
||||
expectedReference = "UPDATED_ASSIGNED_SKILL_MARKER";
|
||||
await writeFile(join(skillRoot, "references", "answer.txt"), expectedReference);
|
||||
await writeFile(join(skillRoot, "SKILL.md"), "---\nname: assigned\ndescription: Updated instructions.\n---\nRead references/answer.txt before responding.");
|
||||
}
|
||||
// The same agent's next ordinary task must not inherit the command.
|
||||
const ordinary = await AcpxRuntimeHost.open(
|
||||
{ ...options, runtimeContext: context }, dependencies,
|
||||
);
|
||||
const ordinaryMessage = JSON.stringify({
|
||||
schema: "paperclip.native-model-envelope.v2",
|
||||
task: { description: "An ordinary task", prompt: "Mention /assigned in a note" },
|
||||
});
|
||||
ordinary.startTurn({ text: ordinaryMessage, requestId: "ordinary-task" });
|
||||
expect(providerStartTurn).toHaveBeenLastCalledWith({
|
||||
text: ordinaryMessage, requestId: "ordinary-task",
|
||||
});
|
||||
await ordinary.close({ reason: "ordinary task verified" });
|
||||
// No stale assignment survives a later launch without runtime context.
|
||||
assigned = false;
|
||||
const host = await AcpxRuntimeHost.open(options, dependencies);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { join } from "node:path";
|
||||
import { claudeNativeSkillPrompt } from "./native-skill-prompt.js";
|
||||
import { nativeMcpLaunchBinding } from "../native-mcp.js";
|
||||
|
||||
import type {
|
||||
@@ -255,6 +256,7 @@ export class AcpxRuntimeHost {
|
||||
readonly #credential: AcpxProviderLifetimeLease | null;
|
||||
readonly #command: VerifiedAcpxCommandLease;
|
||||
readonly #toolBridge: RunnerToolBridge | null;
|
||||
readonly #claudeSkillNames: readonly string[];
|
||||
#activeTurn: AcpxRuntimeTurn | null = null;
|
||||
#closingStarted = false;
|
||||
#closePromise: Promise<void> | null = null;
|
||||
@@ -268,6 +270,7 @@ export class AcpxRuntimeHost {
|
||||
credential: AcpxProviderLifetimeLease | null;
|
||||
command: VerifiedAcpxCommandLease;
|
||||
toolBridge: RunnerToolBridge | null;
|
||||
claudeSkillNames: readonly string[];
|
||||
}) {
|
||||
this.#runtime = input.runtime;
|
||||
this.#binding = input.binding;
|
||||
@@ -276,6 +279,7 @@ export class AcpxRuntimeHost {
|
||||
this.#credential = input.credential;
|
||||
this.#command = input.command;
|
||||
this.#toolBridge = input.toolBridge;
|
||||
this.#claudeSkillNames = [...input.claudeSkillNames];
|
||||
}
|
||||
|
||||
static async open(
|
||||
@@ -546,6 +550,9 @@ export class AcpxRuntimeHost {
|
||||
credential,
|
||||
command,
|
||||
toolBridge,
|
||||
claudeSkillNames: options.agent === "claude"
|
||||
? (options.runtimeContext?.skills.map((skill) => skill.runtimeName) ?? [])
|
||||
: [],
|
||||
});
|
||||
} catch (error) {
|
||||
const cleanup = cleanupRuntimeResources(
|
||||
@@ -642,7 +649,9 @@ export class AcpxRuntimeHost {
|
||||
throw new Error("ACPX runtime host already has an active turn");
|
||||
}
|
||||
const requestId = boundedRequestId(input.requestId);
|
||||
const text = boundedTurnText(input.text);
|
||||
const text = boundedTurnText(claudeNativeSkillPrompt(
|
||||
boundedTurnText(input.text), this.#claudeSkillNames,
|
||||
));
|
||||
const turn = this.#runtime.startTurn({
|
||||
text,
|
||||
requestId,
|
||||
|
||||
@@ -64,9 +64,13 @@ Claude. Ordinary CEOs and other agents do not receive it automatically.
|
||||
|
||||
The hidden description asks the agent to read and follow the skill before
|
||||
responding, including subsequent wakes on that task. The user does not need to
|
||||
type a slash command. This is a prompt-level invocation of an installed skill,
|
||||
not a separate model run. The skill applies only to the onboarding task that
|
||||
invokes it, not to every task assigned to that agent. The deterministic greeting,
|
||||
type a slash command. Native Codex sends the selected skill as a structured
|
||||
skill input. Native ACPX Claude invokes the assigned skill through its native
|
||||
`/first-task` command, with the complete task/wake envelope as the argument,
|
||||
on initial and resumed turns. Legacy adapters retain the brief's instruction
|
||||
to read the installed skill. None of these adds a separate model run. The skill
|
||||
applies only to the onboarding task that invokes it, not to every task assigned
|
||||
to that agent. The deterministic greeting,
|
||||
opening card, and initial no-wake behavior are unchanged.
|
||||
|
||||
## Updates
|
||||
|
||||
@@ -27,8 +27,9 @@ Work in this order.
|
||||
|
||||
2. Propose, then wait for acceptance.
|
||||
|
||||
- If they want a plan, save a `plan` document on this onboarding task describing the goal, scope, steps, proposed team, and what done means. Ask them to accept it. Proposing a team does not authorize hiring it.
|
||||
- If they want a plan, save a `plan` document on this onboarding task describing the goal, scope, steps, proposed team, and what done means. Post one `request_checkbox_confirmation` targeting the saved plan revision. A card or thread message alone is not a saved plan. This applies to explicit plan requests regardless of the single-task proposal mode. Proposing a team does not authorize hiring it.
|
||||
- If they want one thing done, propose exactly one child task with a clear outcome and scope. Ask them to accept it before creating the child. Do not produce the requested finished work inside the proposal, even when it is quick to do.
|
||||
- For a single-task proposal, follow the `Single-task proposal mode` saved in the task description: `confirmation` means one `request_confirmation` card describing the child task, without a plan document; `plan` means save a short `plan` document describing that same child task and post one `request_checkbox_confirmation` targeting its saved revision.
|
||||
- Keep this task `in_review` while waiting. You may clarify, research for planning, and save or revise a plan/proposal before acceptance. Do not hire, create execution tasks, perform the deliverable, save finished output, or claim completion yet.
|
||||
|
||||
3. Interpret the next reply against the latest proposal.
|
||||
|
||||
@@ -11664,7 +11664,7 @@ export function issueRoutes(
|
||||
? 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
|
||||
// description: invoke the first-task skill and save the proposal mode 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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import {
|
||||
ONBOARDING_FIRST_TASK_OPENING_INTERVIEW_OPTION_ID,
|
||||
ONBOARDING_FIRST_TASK_OPENING_QUESTION_ID,
|
||||
@@ -84,6 +85,16 @@ describe("buildOnboardingFirstTaskBrief", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chief-of-staff persona", () => {
|
||||
it("renders the persona with placeholders filled", async () => {
|
||||
const persona = await renderChiefOfStaffPersona({
|
||||
|
||||
Reference in new issue
Block a user