fix(onboarding): restore proposal modes and invoke Claude skills natively

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-16 08:33:38 -05:00
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.
+1 -1
View File
@@ -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({