Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2606,6 +2606,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
event.type === "turn.terminal",
);
return {
attachmentsDir,
runtime,
providerThread,
threadId,
Expand Down Expand Up @@ -2633,6 +2634,84 @@ describe("ClaudeAdapterV2 background wake turns", () => {
});
const makeWakeHarness = makeWakeHarnessWithOptions();

it.effect(
"carries earlier user-only skill instructions on sends and steering with attachments",
() =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const homePath = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skill-dispatch-" });
for (const [name, userOnly] of [
["interrogate", true],
["implement", true],
["review", false],
] as const) {
const directory = path.join(homePath, "skills", name);
yield* fs.makeDirectory(directory, { recursive: true });
yield* fs.writeFileString(
path.join(directory, "SKILL.md"),
`---\nname: ${name}\ndisable-model-invocation: ${userOnly}\n---\nFollow the ${name} instructions.\n`,
);
}
const harness = yield* makeWakeHarnessWithOptions({
settings: { ...DEFAULT_CLAUDE_SETTINGS, homePath },
});
const input = makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make("skill-dispatch"),
text: "$interrogate then $review then $implement the fixes",
attachments: [],
runtimePolicy: { ...CLAUDE_TEST_RUNTIME_POLICY, cwd: homePath },
});
yield* harness.runtime.startTurn(input);

const leadingText = `Ultrathink:\n/interrogate [The user invoked this skill. Follow the instructions at: ${path.join(homePath, "skills", "interrogate", "SKILL.md")}] then /review then`;
assert.deepEqual(harness.offeredMessages[0]?.message.content, [
{ type: "text", text: leadingText },
{ type: "text", text: "/implement the fixes" },
]);

const attachment = ChatFileAttachment.make({
type: "file",
id: ChatAttachmentId.make("skill-dispatch-12345678-1234-1234-1234-123456789abc"),
name: "requirements.pdf",
mimeType: "application/pdf",
sizeBytes: 4,
});
const idAllocator = yield* IdAllocator.IdAllocatorV2;
yield* harness.runtime.steerTurn({
threadId: harness.threadId,
runId: input.runId,
providerThread: harness.providerThread,
providerTurnId: idAllocator.derive.providerTurn({
driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
nativeTurnId: `turn:${input.attemptId}`,
}),
message: {
createdBy: "user",
creationSource: "web",
messageId: MessageId.make("skill-dispatch-steer"),
text: input.message.text,
attachments: [attachment],
},
});
assert.equal(harness.offeredMessages[1]?.priority, "now");
assert.deepEqual(harness.offeredMessages[1]?.message.content, [
{ type: "text", text: leadingText },
{
type: "text",
text: `/implement the fixes\n\n[Attached file "requirements.pdf" is saved at: ${path.join(harness.attachmentsDir, attachmentRelativePath(attachment)!)}]`,
},
]);
}).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
);

it.effect.each([
{ isError: false, title: "Check weather" },
{ isError: true, title: "Check weather" },
Expand Down
29 changes: 15 additions & 14 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,10 @@ import * as Stream from "effect/Stream";

import { resolveAttachmentPath } from "../../attachmentStore.ts";
import { resolveClaudeSdkExecutablePath } from "../../provider/Drivers/ClaudeExecutable.ts";
import { planClaudeSkillDispatch } from "../../provider/Drivers/ClaudeSkillDispatch.ts";
import {
planClaudeSkillDispatch,
type ClaudeSkillTarget,
} from "../../provider/Drivers/ClaudeSkillDispatch.ts";
import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts";
import {
compileClaudeModelSelection,
Expand Down Expand Up @@ -1592,17 +1595,15 @@ function isSupportedClaudeImageMimeType(
export function makeClaudeUserMessage(input: {
readonly text: string;
readonly priority?: SDKUserMessage["priority"];
readonly skillNames?: ReadonlySet<string>;
readonly skills?: ReadonlyMap<string, ClaudeSkillTarget>;
readonly uuid?: SDKUserMessage["uuid"];
}): SDKUserMessage {
// Claude Code expands a skill only from the LAST text block, and only when
// `/name` is its first character. A `$skill` chip anywhere in the prompt is
// therefore split into [leading text, "/name trailing text"] so the CLI runs
// it natively and the prose around it survives.
const dispatch =
input.skillNames === undefined
? undefined
: planClaudeSkillDispatch(input.text, input.skillNames);
input.skills === undefined ? undefined : planClaudeSkillDispatch(input.text, input.skills);
return {
type: "user",
message: {
Expand All @@ -1629,12 +1630,12 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input:
readonly uuid?: SDKUserMessage["uuid"];
readonly attachmentsDir: string;
readonly fileSystem: FileSystem.FileSystem;
readonly skillNames?: ReadonlySet<string>;
readonly skills?: ReadonlyMap<string, ClaudeSkillTarget>;
}) {
if (input.attachments.length === 0) {
return makeClaudeUserMessage({
text: input.text,
...(input.skillNames === undefined ? {} : { skillNames: input.skillNames }),
...(input.skills === undefined ? {} : { skills: input.skills }),
...(input.priority === undefined ? {} : { priority: input.priority }),
...(input.uuid === undefined ? {} : { uuid: input.uuid }),
});
Expand All @@ -1648,9 +1649,9 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input:
});

const dispatch =
input.skillNames === undefined
input.skills === undefined
? undefined
: planClaudeSkillDispatch(textWithAttachmentPaths, input.skillNames);
: planClaudeSkillDispatch(textWithAttachmentPaths, input.skills);
const content: Array<ClaudeUserContentBlock> = [];
if (dispatch?.leadingText !== undefined) {
content.push({ type: "text", text: dispatch.leadingText });
Expand Down Expand Up @@ -3448,18 +3449,18 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
offer: () => Effect.void,
};

const userInvocableSkillNames = (cwd: string | null) =>
const userInvocableSkills = (cwd: string | null) =>
discoverClaudeSkills(
adapterOptions.settings,
cwd ?? undefined,
adapterOptions.environment,
).pipe(
Effect.map(
(skills) =>
new Set(
new Map(
skills
.filter((skill) => skill.enabled && skill.userInvocable !== false)
.map((skill) => skill.name),
.map((skill) => [skill.name, skill]),
),
),
Effect.provideService(FileSystem.FileSystem, fileSystem),
Expand Down Expand Up @@ -8741,7 +8742,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
attachments: turnInput.message.attachments,
attachmentsDir,
fileSystem,
skillNames: yield* userInvocableSkillNames(turnInput.runtimePolicy.cwd),
skills: yield* userInvocableSkills(turnInput.runtimePolicy.cwd),
uuid: promptUuid,
});
const querySession = yield* openQuery(turnInput, nativeThreadId);
Expand Down Expand Up @@ -9046,7 +9047,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
priority: "now",
attachmentsDir,
fileSystem,
skillNames: yield* userInvocableSkillNames(currentTurn.input.runtimePolicy.cwd),
skills: yield* userInvocableSkills(currentTurn.input.runtimePolicy.cwd),
});
yield* backgroundForegroundSubagents(existing);
// The turn can end while the moves are answered. A steer offered
Expand Down
28 changes: 25 additions & 3 deletions apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { describe, expect, it } from "vite-plus/test";

import { planClaudeSkillDispatch } from "./ClaudeSkillDispatch.ts";
import { planClaudeSkillDispatch, type ClaudeSkillTarget } from "./ClaudeSkillDispatch.ts";

const SKILLS = new Set(["2spec", "implement", "review", "re-release-version"]);
const skillPath = (name: string) => `/home/me/.claude/skills/${name}/SKILL.md`;

const skillMap = (names: Iterable<string>) =>
new Map<string, ClaudeSkillTarget>([...names].map((name) => [name, { path: skillPath(name) }]));

const SKILLS = skillMap(["2spec", "implement", "review", "re-release-version"]);

describe("planClaudeSkillDispatch", () => {
it("leaves a prompt without a known skill untouched", () => {
Expand Down Expand Up @@ -68,7 +73,7 @@ describe("planClaudeSkillDispatch", () => {
});

it("ignores currency amounts and compact monetary expressions", () => {
const skillsWithCurrency = new Set([...SKILLS, "20", "20k", "100M", "1e6"]);
const skillsWithCurrency = skillMap([...SKILLS.keys(), "20", "20k", "100M", "1e6"]);
for (const symbol of ["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"]) {
expect(
planClaudeSkillDispatch(
Expand All @@ -78,4 +83,21 @@ describe("planClaudeSkillDispatch", () => {
).toBeUndefined();
}
});

it("bounds notes for repeated user-only skills and leaves the native dispatch unannotated", () => {
const skills = new Map([
["interrogate", { path: skillPath("interrogate"), userInvocationOnly: true }],
["implement", { path: skillPath("implement"), userInvocationOnly: true }],
]);
expect(
planClaudeSkillDispatch(
"$interrogate then $interrogate then $implement and $implement the fixes",
skills,
),
).toEqual({
leadingText: `/interrogate [The user invoked this skill. Follow the instructions at: ${skillPath("interrogate")}] then /interrogate then /implement and`,
commandText: "/implement the fixes",
skillName: "implement",
});
});
});
41 changes: 25 additions & 16 deletions apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { ServerProviderSkill } from "@t3tools/contracts";

/**
* Claude Code's only user-side invocation is a text block whose first character
* is `/`: the harness expands `/name args` into the SKILL.md body, and every
* character after the name (newlines included) arrives as `ARGUMENTS`. Only one
* skill expands per message; a second `/x` becomes argument text
* (anthropics/claude-code#87113).
* ([#87113](https://github.com/anthropics/claude-code/issues/87113)).
*/

/**
Expand All @@ -14,6 +16,8 @@
const SKILL_MENTION_PATTERN =
/(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu;

export type ClaudeSkillTarget = Pick<ServerProviderSkill, "path" | "userInvocationOnly">;

export interface ClaudeSkillDispatch {
readonly leadingText: string | undefined;
/** `/name` plus the trailing text, ready to be the message's last text block. */
Expand All @@ -23,33 +27,38 @@ export interface ClaudeSkillDispatch {

export function planClaudeSkillDispatch(
prompt: string,
skillNames: ReadonlySet<string>,
skills: ReadonlyMap<string, ClaudeSkillTarget>,
): ClaudeSkillDispatch | undefined {
const mentions = [...prompt.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) => {
const name = match[2] ?? "";
if (!skillNames.has(name)) return [];
const skill = skills.get(name);
if (!skill) return [];
const start = (match.index ?? 0) + (match[1]?.length ?? 0);
return [{ name, start, end: (match.index ?? 0) + match[0].length }];
return [{ name, skill, start, end: (match.index ?? 0) + match[0].length }];
});
const last = mentions.at(-1);
if (!last) {
return undefined;
}

const leading = prompt.slice(0, last.start);
const trailing = prompt.slice(last.end);
const leadingWithInlineSlashes = mentions
.slice(0, -1)
.reduceRight(
(text, mention) =>
`${text.slice(0, mention.start)}/${mention.name}${text.slice(mention.end)}`,
leading,
)
.trimEnd();
// Only the final skill gets native expansion. Earlier user-only skills need
// a fallback to their raw file because Claude's Skill tool refuses them.
const annotated = new Set([last.name]);
let leadingText = "";
let cursor = 0;
for (const mention of mentions.slice(0, -1)) {
leadingText += `${prompt.slice(cursor, mention.start)}/${mention.name}`;
if (mention.skill.userInvocationOnly === true && !annotated.has(mention.name)) {
leadingText += ` [The user invoked this skill. Follow the instructions at: ${mention.skill.path}]`;
annotated.add(mention.name);
}
cursor = mention.end;
}
leadingText = `${leadingText}${prompt.slice(cursor, last.start)}`.trimEnd();

return {
leadingText: leadingWithInlineSlashes.length > 0 ? leadingWithInlineSlashes : undefined,
commandText: `/${last.name}${trailing}`.trimEnd(),
leadingText: leadingText.length > 0 ? leadingText : undefined,
commandText: `/${last.name}${prompt.slice(last.end)}`.trimEnd(),
skillName: last.name,
};
}
5 changes: 4 additions & 1 deletion apps/server/src/provider/Drivers/ClaudeSkills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
const settings = { homePath: configDir, launchArgs: `--add-dir "${extraDir}"` };
const skills = yield* discoverClaudeSkills(settings, workspace);
assert.deepEqual(
planClaudeSkillDispatch("$demo", new Set(skills.map((skill) => skill.name))),
planClaudeSkillDispatch(
"$demo",
new Map(skills.map((skill) => [skill.name, { path: skill.path }])),
),
{
leadingText: undefined,
commandText: "/demo",
Expand Down
Loading